第 20 站 · 回溯
字符串拆分与构造
按切分点回溯 + 回文预处理 O(n · 2^n)
- 学完去
- 没有后续站依赖它。
- 面试频次
- 1 家公司考过 · 面经出现 1 次Salesforce 1
先做这道
LC 22括号生成卡住再看
维护左右括号计数,在构造阶段剪去非法前缀。
要点
- 回溯的每一层决定「下一段切到哪里」,start 走到 n 就得到一种完整的切法。
- 先用 O(n²) 的区间 DP 算出每个子串是不是回文,回溯里的判断就是 O(1)。
- 复原 IP 地址、单词拆分也是同一个框架,只是「这一段是否合法」的判断不同。
易错点
- 在回溯里每次都重新用双指针判断回文,重复计算会让常数大很多。
- 只要求方案数或最少切几刀时,不要枚举,改用 DP。
- 子串下标是左闭右开还是左闭右闭要统一;s[start:end+1] 和 substring(start, end+1) 很容易差一位。
看动画
一步一步播放这个模式在小例子上的执行过程。可以暂停、单步前进或后退,拖动进度条跳到任意一步。
回溯 · 子集的决策树
列出 [1, 2, 3] 的所有子集
换个输入
- 深度
- 0
- 当前路径
- {}
- 结果数
- 0
从空集出发,先决定是否选 1。
始终成立每层决定一个数选或不选;递归返回时撤销这一步,路径恢复原状,再试另一个分支。
模板
选一种语言展开。四种写法实现的是同一个模板,每一份都编译运行过,并用测试数据核对了输出。看完用它重写热身题 LC 22。
Python
def partition(s: str) -> list[list[str]]:
"""Every way to cut s into palindromes."""
n = len(s)
pal = [[False] * n for _ in range(n)] # pal[i][j]: s[i..j] is a palindrome
for i in range(n - 1, -1, -1):
for j in range(i, n):
pal[i][j] = s[i] == s[j] and (j - i < 2 or pal[i + 1][j - 1])
res, path = [], []
def dfs(start: int) -> None:
if start == n:
res.append(path[:])
return
for end in range(start, n): # the next piece is s[start..end]
if pal[start][end]:
path.append(s[start : end + 1])
dfs(end + 1)
path.pop()
dfs(0)
return resC++
// Every way to cut s into palindromes.
vector<vector<string>> partition(const string& s) {
int n = s.size();
vector<vector<bool>> pal(n, vector<bool>(n, false)); // pal[i][j]: s[i..j] is a palindrome
for (int i = n - 1; i >= 0; i--)
for (int j = i; j < n; j++) pal[i][j] = s[i] == s[j] && (j - i < 2 || pal[i + 1][j - 1]);
vector<vector<string>> res;
vector<string> path;
function<void(int)> dfs = [&](int start) {
if (start == n) {
res.push_back(path);
return;
}
for (int end = start; end < n; end++) // the next piece is s[start..end]
if (pal[start][end]) {
path.push_back(s.substr(start, end - start + 1));
dfs(end + 1);
path.pop_back();
}
};
dfs(0);
return res;
}Java
class Solution {
private final List<List<String>> res = new ArrayList<>();
private final List<String> path = new ArrayList<>();
private boolean[][] pal; // pal[i][j]: s[i..j] is a palindrome
private String s;
// Every way to cut s into palindromes.
List<List<String>> partition(String s) {
int n = s.length();
this.s = s;
pal = new boolean[n][n];
for (int i = n - 1; i >= 0; i--)
for (int j = i; j < n; j++)
pal[i][j] = s.charAt(i) == s.charAt(j) && (j - i < 2 || pal[i + 1][j - 1]);
res.clear();
dfs(0);
return res;
}
private void dfs(int start) {
if (start == s.length()) {
res.add(new ArrayList<>(path));
return;
}
for (int end = start; end < s.length(); end++) // the next piece is s[start..end]
if (pal[start][end]) {
path.add(s.substring(start, end + 1));
dfs(end + 1);
path.remove(path.size() - 1);
}
}
}Go
// partition returns every way to cut s into palindromes.
func partition(s string) [][]string {
n := len(s)
pal := make([][]bool, n) // pal[i][j]: s[i..j] is a palindrome
for i := range pal {
pal[i] = make([]bool, n)
}
for i := n - 1; i >= 0; i-- {
for j := i; j < n; j++ {
pal[i][j] = s[i] == s[j] && (j-i < 2 || pal[i+1][j-1])
}
}
var res [][]string
var path []string
var dfs func(start int)
dfs = func(start int) {
if start == n {
res = append(res, append([]string(nil), path...))
return
}
for end := start; end < n; end++ { // the next piece is s[start..end]
if pal[start][end] {
path = append(path, s[start:end+1])
dfs(end + 1)
path = path[:len(path)-1]
}
}
}
dfs(0)
return res
}常见变体
这一站的题还会用到下面 2 种写法。展开看什么时候换用它,代码同样四种语言都跑过测试。
按计数约束生成括号时间 O(4^n / √n)(卡特兰数个结果)· 递归深度 O(n)
每一步只有少数几种字符可选、靠计数就能剪枝(括号、IP 段数、字母大小写):左括号还有剩就能放,右括号只在比左括号少时才能放,这样走到长度 2n 的都是合法串,不用最后再检查。LC 22 括号生成,LC 301 删除无效的括号用同样的计数。
Python
def generate_parentheses(n: int) -> list[str]:
"""Every balanced string of n pairs of parentheses (LC 22)."""
res, path = [], []
def dfs(opened: int, closed: int) -> None:
if len(path) == 2 * n:
res.append("".join(path))
return
if opened < n: # an opening bracket fits while some are left
path.append("(")
dfs(opened + 1, closed)
path.pop()
if closed < opened: # a closing bracket needs an unmatched opening one
path.append(")")
dfs(opened, closed + 1)
path.pop()
dfs(0, 0)
return resC++
// Every balanced string of n pairs of parentheses (LC 22).
vector<string> generateParentheses(int n) {
vector<string> res;
string path;
function<void(int, int)> dfs = [&](int opened, int closed) {
if ((int)path.size() == 2 * n) {
res.push_back(path);
return;
}
if (opened < n) { // an opening bracket fits while some are left
path.push_back('(');
dfs(opened + 1, closed);
path.pop_back();
}
if (closed < opened) { // a closing bracket needs an unmatched opening one
path.push_back(')');
dfs(opened, closed + 1);
path.pop_back();
}
};
dfs(0, 0);
return res;
}Java
class Solution {
private final List<String> res = new ArrayList<>();
private final StringBuilder path = new StringBuilder();
private int n;
// Every balanced string of n pairs of parentheses (LC 22).
List<String> generateParentheses(int n) {
this.n = n;
res.clear();
dfs(0, 0);
return new ArrayList<>(res);
}
private void dfs(int opened, int closed) {
if (path.length() == 2 * n) {
res.add(path.toString());
return;
}
if (opened < n) { // an opening bracket fits while some are left
path.append('(');
dfs(opened + 1, closed);
path.deleteCharAt(path.length() - 1);
}
if (closed < opened) { // a closing bracket needs an unmatched opening one
path.append(')');
dfs(opened, closed + 1);
path.deleteCharAt(path.length() - 1);
}
}
}Go
// generateParentheses returns every balanced string of n pairs of parentheses (LC 22).
func generateParentheses(n int) []string {
var res []string
path := make([]byte, 0, 2*n)
var dfs func(opened, closed int)
dfs = func(opened, closed int) {
if len(path) == 2*n {
res = append(res, string(path))
return
}
if opened < n { // an opening bracket fits while some are left
path = append(path, '(')
dfs(opened+1, closed)
path = path[:len(path)-1]
}
if closed < opened { // a closing bracket needs an unmatched opening one
path = append(path, ')')
dfs(opened, closed+1)
path = path[:len(path)-1]
}
}
dfs(0, 0)
return res
}切分数字串并插入运算符时间 O(n · 4^n) · 递归深度 O(n)
在数字之间放 + - *、问哪些式子等于目标值:每层选下一个数的终点(不能有前导零),三种运算各递归一次。乘法优先级更高,所以要带着「最后一项」last,乘法时先减掉它再加上 last × x。LC 282 给表达式添加运算符。
Python
def add_operators(num: str, target: int) -> list[str]:
"""Every way to put + - * between the digits of num so it equals target (LC 282)."""
res = []
def dfs(i: int, expr: str, value: int, last: int) -> None:
# value: the expression so far; last: its final term, which a * must take back
if i == len(num):
if value == target:
res.append(expr)
return
for j in range(i, len(num)):
if j > i and num[i] == "0":
break # no numbers with a leading zero
s = num[i : j + 1]
x = int(s)
if i == 0:
dfs(j + 1, s, x, x)
else:
dfs(j + 1, expr + "+" + s, value + x, x)
dfs(j + 1, expr + "-" + s, value - x, -x)
dfs(j + 1, expr + "*" + s, value - last + last * x, last * x) # * binds tighter
dfs(0, "", 0, 0)
return resC++
// Every way to put + - * between the digits of num so it equals target (LC 282).
vector<string> addOperators(const string& num, long long target) {
vector<string> res;
// value: the expression so far; last: its final term, which a * must take back
function<void(size_t, const string&, long long, long long)> dfs = [&](size_t i, const string& expr, long long value, long long last) {
if (i == num.size()) {
if (value == target) res.push_back(expr);
return;
}
for (size_t j = i; j < num.size(); j++) {
if (j > i && num[i] == '0') break; // no numbers with a leading zero
string s = num.substr(i, j - i + 1);
long long x = stoll(s);
if (i == 0) {
dfs(j + 1, s, x, x);
} else {
dfs(j + 1, expr + "+" + s, value + x, x);
dfs(j + 1, expr + "-" + s, value - x, -x);
dfs(j + 1, expr + "*" + s, value - last + last * x, last * x); // * binds tighter
}
}
};
dfs(0, "", 0, 0);
return res;
}Java
class Solution {
private final List<String> res = new ArrayList<>();
private String num;
private long target;
// Every way to put + - * between the digits of num so it equals target (LC 282).
List<String> addOperators(String num, long target) {
this.num = num;
this.target = target;
res.clear();
dfs(0, "", 0, 0);
return new ArrayList<>(res);
}
// value: the expression so far; last: its final term, which a * must take back.
private void dfs(int i, String expr, long value, long last) {
if (i == num.length()) {
if (value == target) res.add(expr);
return;
}
for (int j = i; j < num.length(); j++) {
if (j > i && num.charAt(i) == '0') break; // no numbers with a leading zero
String s = num.substring(i, j + 1);
long x = Long.parseLong(s);
if (i == 0) {
dfs(j + 1, s, x, x);
} else {
dfs(j + 1, expr + "+" + s, value + x, x);
dfs(j + 1, expr + "-" + s, value - x, -x);
dfs(j + 1, expr + "*" + s, value - last + last * x, last * x); // * binds tighter
}
}
}
}Go
import "strconv"
// addOperators returns every way to put + - * between the digits of num so it equals target (LC 282).
func addOperators(num string, target int) []string {
res := []string{}
// value: the expression so far; last: its final term, which a * must take back
var dfs func(i int, expr string, value, last int)
dfs = func(i int, expr string, value, last int) {
if i == len(num) {
if value == target {
res = append(res, expr)
}
return
}
for j := i; j < len(num); j++ {
if j > i && num[i] == '0' {
break // no numbers with a leading zero
}
s := num[i : j+1]
x, _ := strconv.Atoi(s)
if i == 0 {
dfs(j+1, s, x, x)
} else {
dfs(j+1, expr+"+"+s, value+x, x)
dfs(j+1, expr+"-"+s, value-x, -x)
dfs(j+1, expr+"*"+s, value-last+last*x, last*x) // * binds tighter
}
}
}
dfs(0, "", 0, 0)
return res
}为什么对
- 每种切分方式唯一对应一串递增的切点,搜索每层决定「下一段在哪里结束」,所以每种切分恰好生成一次。
- 回文表 isPal[i][j] = (s[i] == s[j]) 且 isPal[i+1][j−1],只依赖更短的区间,所以 i 从后往前填,用到时一定已经算好。
- 剪枝只剪「当前这一段不合法」的分支:不合法的段不可能出现在任何合法切分里,剪掉不会漏解。
30 秒口述稿
面试官让你讲思路时,按做法、为什么对、复杂度的顺序说。中英文各一版,可以对着读几遍。
字符串切分我用回溯,每一层决定下一段在哪里结束,start 走到末尾就得到一种完整切分;每种切分对应唯一一串切点,所以不重不漏。判断某一段是否回文,我先用 O(n²) 的区间 DP 预处理,搜索里每次检查就是 O(1)。复原 IP、单词拆分也是同一个框架,只是这一段是否合法的判断不同。答案数最坏是指数级,复杂度 O(n · 2^n)。To partition a string I backtrack, and each level chooses where the next piece ends; when start reaches the end I have one complete split, and since each split is a unique sequence of cuts, none repeats or goes missing. I precompute an O(n²) palindrome table so each check inside the search is O(1). Restoring IP addresses and word break use the same frame with a different validity test. The number of answers can be exponential, so the worst case is O(n · 2^n).自评一下
对照上面的答案给自己打个分。没掌握的题会按 1、3、7、21 天的间隔回来,到期的题在「我的进度 · 到期复习」里。 只存在这台设备上;登录后会合并到你的账号。
小测
3 道概念题和复杂度题。答过的题会和 OA 选择题练习一起按间隔回来复习。 去 OA 选择题练习
作答记录只保存在这台设备的浏览器里;登录后打开OA 选择题练习页,会存进账号。
模板练习
用上面的模板和变体出题:把打乱的行排回原序,把挖掉的边界条件填回去,或者在空白编辑器里整段默写,跑本站的测试并逐行对比。每次「换一题」都会重新出题。
下面几行被打乱了。点一行选中,再点另一行和它交换,排回模板原来的顺序;缩进是提示。
def partition(s: str) -> list[list[str]]:
"""Every way to cut s into palindromes."""
n = len(s)
pal = [[False] * n for _ in range(n)]
for i in range(n - 1, -1, -1):
dfs(0)
return res分档练习
本站在 500 路里的 10 道题,按基础、核心、进阶分三档。每题附一句话思路,先自己想,想不出再看。
核心 5
| # | 题目 | 一句话思路 | 难度分 |
|---|---|---|---|
| 22 | 括号生成 | 维护左右括号计数,在构造阶段剪去非法前缀。 | |
| 131 | 分割回文串 | 枚举切割位置并验证回文,构建所有合法分段方案。 | |
| 93 | 复原 IP 地址 | 按段切分IP地址,处理前导零、长度与数值范围。 | |
| 320 | 列举单词的全部缩写 🔒 | 把连续省略长度作为状态,在保留字符时统一结算计数。 | |
| 254 | 因子的组合 🔒 | 以递增因子枚举分解方案,避免因子排列产生重复答案。 |
进阶 5
| # | 题目 | 一句话思路 | 难度分 |
|---|---|---|---|
| 267 | 回文排列 II 🔒 | 先验证回文排列的奇偶条件,再枚举一半字符生成唯一结果。 | |
| 291 | 单词规律 II 🔒 | 回溯建立字符到子串的双向映射,保持一一对应关系。 | |
| 1593 | 拆分字符串使唯一子字符串的数目最大 | 枚举字符串切分并维护已用子串集合,用剩余长度剪枝。 | |
| 301 | 删除无效的括号 | 在最少删除约束下生成有效括号字符串,并去除重复答案。 | |
| 282 | 给表达式添加运算符 | 插入运算符时维护累计值与上一项,正确处理乘法优先级和前导零。 |