🌳 回溯算法详解

全排列 · 子集 · 组合 · N 皇后:把所有"枚举所有可能"的题一网打尽

一句话总结

回溯 = 穷举 + 撤销。它在一棵"决策树"上做深度优先遍历:每到一个节点就做一次选择(做选择 → 递归 → 撤销选择),走到叶子就得到一个解。所谓"回溯",就是递归返回后把刚才的选择抹掉,回到分叉口去试下一条路。

🌲

决策树

每一层是一次选择,每个叶子是一个完整答案。回溯就是遍历这棵树。

✅

路径 path

记录"已经做出的选择",到叶子时它就是答案的一部分。

📋

选择列表

当前这一步还能选什么,通常用 start 下标或 used 数组控制。

✂️

剪枝

提前排除必不可能的分支,是回溯从超时到通过的关键。

一、通用框架:三步走

1.1 模板代码

def backtrack(路径, 选择列表): if 满足结束条件: # ① 终止:走到叶子 result.append(路径[:]) # 注意要拷贝! return for 选择 in 选择列表: # ② 遍历所有可选分支 if 该选择不合法: # 剪枝(可选) continue 做选择(路径.add(选择)) # ③ 前序:做选择 backtrack(路径, 新选择列表) # 递归进入下一层 撤销选择(路径.pop()) # ④ 后序:撤销,回到分叉口
💡 为什么必须"撤销"?因为 path 是一个被所有分支共享的对象。不撤销的话,试完第一条路回到分叉口时,path 里还残留着上一个分支的数据,下一条分支就会带着脏数据走下去。

1.2 决策树可视化:全排列 [1,2,3]

permute([1,2,3]):每个节点 = 一次选择,叶子 = 一个完整排列 [] [1] [2] [3] [1,2] [1,3] [2,1] [2,3] [3,1] [3,2] 1,2,3 ✓ 1,3,2 ✓ 2,1,3 ✓ 2,3,1 ✓ 3,1,2 ✓ 3,2,1 ✓ ↩ 递归返回后撤销最后一位(如 [1,2,3] → 撤销 3 → 回到 [1,2] → 无其他选择 → 再撤销 2 → [1] → 试 3) 三条主干 × 每条 2 个分支 = 3! = 6 个叶子(答案) 节点总数 = 1 + 3 + 6 + 6 ≈ O(n × n!),这就是全排列回溯的时间复杂度 空间复杂度 O(n):递归栈深度 = 树高 = 元素个数(不含结果集) 关键点:撤销操作让"一条 path"能被所有分支复用 → 空间只需 O(n) 而非 O(答案数)

二、四大经典题型

2.1 全排列(LeetCode 46):元素可复用性用 used 数组控制

def permute(nums): res, path = [], [] used = [False] * len(nums) def dfs(): if len(path) == len(nums): # 选满了 → 一个完整排列 res.append(path[:]) # 必须拷贝! return for i in range(len(nums)): if used[i]: # 已用过 → 跳过(排列的关键约束) continue used[i] = True path.append(nums[i]) # 做选择 dfs() # 递归 path.pop() # 撤销 used[i] = False # 撤销 dfs() return res

2.2 子集(LeetCode 78):每个节点都是答案

def subsets(nums): res, path = [], [] def dfs(start): res.append(path[:]) # ★ 每个节点都要收集(不是只在叶子) for i in range(start, len(nums)): path.append(nums[i]) dfs(i + 1) # 从 i+1 开始 → 避免重复、天然去重 path.pop() dfs(0) return res # 结果:[[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]] —— 共 2^n 个

2.3 组合(LeetCode 77):多了个数限制

def combine(n, k): res, path = [], [] def dfs(start): if len(path) == k: # 选够 k 个 → 收集 res.append(path[:]) return # 剪枝:剩余可选数量 n - start + 1 要够填满 k - len(path) 个位置 for i in range(start, n - (k - len(path)) + 2): path.append(i) dfs(i + 1) path.pop() dfs(1) return res

2.4 N 皇后(LeetCode 51):二维约束 + 剪枝

4 皇后:逐行放子,检查列冲突与两条对角线 ♛ ♛ ♛ ♛ row=0 放 (0,1) row=1 只能放 (1,3) row=2 只能放 (2,0) row=3 只能放 (3,2) ✓ 得到一个合法解 若某行无处可放 → 回溯 撤销上一行的皇后重试
def solveNQueens(n): res = [] board = [['.'] * n for _ in range(n)] cols, diag1, diag2 = set(), set(), set() # 列、主对角、副对角 def dfs(row): if row == n: # 所有行都放好了 res.append([''.join(r) for r in board]) return for col in range(n): # 技巧:同一主对角线上 row-col 恒定;同一副对角线上 row+col 恒定 if col in cols or (row - col) in diag1 or (row + col) in diag2: continue # 冲突 → 剪枝,不试这条分支 board[row][col] = 'Q' cols.add(col); diag1.add(row - col); diag2.add(row + col) dfs(row + 1) # 进入下一行 board[row][col] = '.' cols.remove(col); diag1.remove(row - col); diag2.remove(row + col) dfs(0) return res

三、剪枝:让回溯从超时到通过

3.1 两类剪枝

类型做法例子
可行性剪枝 当前分支已违反约束,直接 return N 皇后的列/对角线冲突、组合总和超过 target
重复性剪枝 同一层遇到相同元素只取第一个 40 组合总和 II:if i > start and nums[i] == nums[i-1]: continue
上下界剪枝 剩余元素不够填满/已超过,直接退出循环 77 组合:for i in range(start, n - need + 2)
记忆化(最优性剪枝) 记录已算过的状态 括号生成中记录已生成的串

3.2 去重剪枝图解(含重复元素的组合)

核心口诀:排序 + 同层跳过。先对数组排序,然后在 for 循环里加一句——如果当前元素和"同一层的上一个元素"相同,就跳过。注意必须是同一层(i > start),不能跨层比较,否则会漏掉合法解。
# 40. 组合总和 II:candidates 有重复,每个数字只能用一次 def combinationSum2(candidates, target): candidates.sort() # ① 必须先排序,让相同元素相邻 res, path = [], [] def dfs(start, remain): if remain == 0: res.append(path[:]); return for i in range(start, len(candidates)): if i > start and candidates[i] == candidates[i-1]: continue # ② 同层重复 → 跳过,避免重复解 if candidates[i] > remain: break # ③ 已排序 → 后面更大,直接剪掉 path.append(candidates[i]) dfs(i + 1, remain - candidates[i]) path.pop() dfs(0, target) return res

四、复杂度与题型对照

题型答案数量时间复杂度空间复杂度关键约束
子集 782ⁿO(n × 2ⁿ)O(n)start 递增,每个节点都收集
组合 77C(n,k)O(k × C(n,k))O(k)start 递增 + 个数上限
全排列 46n!O(n × n!)O(n)used 数组,可任意顺序选
N 皇后 51远小于 n!O(n!)O(n)行 + 列 + 双对角线集合剪枝
括号生成 22卡特兰数O(4ⁿ/√n)O(n)左括号数 ≥ 右括号数
⚠️ 面试高频错误:
① res.append(path) 忘记加 [:] → 所有答案都指向同一个 path,最后全是空列表;
② 撤销操作漏写(只 pop 不还原 used/集合)→ 状态污染;
③ 去重剪枝时用了 i > 0 而非 i > start → 跨层误杀,漏解;
④ 在循环里 break 而非 continue 前没先排序 → 提前截断合法分支。
一句话心法:回溯题先画决策树——问自己三件事:① 每层"选什么"(全排列选元素、子集选要不要、N 皇后选列);② 什么条件下算一个答案(到叶子?还是每个节点都是?);③ 怎么避免重复和无效分支(start 递增 / used 数组 / 排序跳过 / 上下界剪枝)。