跳转至

回溯

一句话:回溯就是 DFS 加上一句"撤销现场"——穷举所有可能解的通用框架是「做选择 → 递归进入下一层 → 撤销选择」,三步缺一不可。写不出撤销那一步,是这类题最常见的翻车原因。

固定框架:选择、递归、撤销

46-全排列、78-子集、39-组合总和、22-括号生成、51-N皇后 看起来是完全不同的问题,但代码骨架一模一样:

void backtrack(路径, 选择列表):
    if 满足结束条件:
        记录路径的拷贝; return
    for 选择 in 选择列表:
        做选择(路径.add(选择))
        backtrack(路径, 新的选择列表)
        撤销选择(路径.removeLast())

"记录路径的拷贝"和"撤销选择"是最容易漏掉的两步:路径通常用一个可变的 List 复用,如果直接把这个引用存进结果集,后续的撤销/添加会连带修改已经"记录"的答案——必须拷贝一份新的 List。撤销选择如果漏写,路径会一直增长,后续分支会带着不属于自己的选择继续递归。

怎么判断该不该剪枝

39-组合总和、40-组合总和II、90-子集II 都涉及去重/剪枝:同一层的选择列表里,如果排序后相邻两个值相等,且前一个值在本层还没被选(说明它在回溯时被撤销过),就跳过当前值——这是"同一层去重"和"同一条路径内允许重复使用"的标准写法,专门用来处理"结果不能重复但输入有重复元素"的题目。

判断能否剪枝的一般原则:如果当前的部分解已经不可能满足最终条件(比如组合总和已经超过目标值,且候选数组已排序),就没必要往下递归——排序后提前 break 而不是 continue,可以把剩下的候选一并跳过,这是回溯题里最常见的可提前终止场景。

递归树的"层"与"树枝"

78-子集(位运算)和回溯解法体现了同一个问题的两种视角:回溯是在显式遍历一棵"选或不选"的决策树,每一层对应一个元素,每个分支对应"选"或"不选";位运算解法是把这棵决策树"压平"成 2^n 个位掩码直接枚举,两者遍历的是同一个搜索空间,只是数据结构不同(显式递归 vs 隐式二进制编码),复杂度分析见位运算。

37-解数独、79-单词搜索 是回溯在二维网格上的应用,区别只是"选择列表"从"剩余候选数字/单词"变成了"当前格子能填的数字"或"下一步能走的方向",撤销现场对应"把格子清空"或"把访问标记复原",框架不变。

收账位置:答案住在决策树的哪里

同一个回溯框架、同一棵决策树,做题第一步先问:答案以什么资格收录?

答案的资格 哪类题 收账代码形态
必须到位(叶子) 77 组合、216/39/40 组合总和、131 切割 if (资格) { res.add(拷贝); return; }
前缀就合法(每个节点) 78/90 子集 res.add(拷贝) 无条件放方法第一行,没有 return——for 的循环条件 i < n 就是出口

把 78 的收账写成 77 惯性的"先 if 出口再 add",最深的前缀恰好住在 start == n 那一层——在 add 前就 return 了,整个最深子集漏收([1] → 只剩 [])。

撤销是"兄弟分支之间的隔离墙"(90 漏撤销实测)

撤销那行漏写的后果,不总是"立刻崩"——它取决于决策树有没有真分叉:

  • 单链树(如 [1,1]、[2,2,2]:去重把每层的兄弟全杀了,每层只剩一个分支)→ path 单调生长、没有兄弟会踩到脏状态,不撤销也碰巧全对;
  • 真分叉树(如 [1,2,2]:根层有 1 分支和 2 分支两个兄弟)→ 第一个分支走完没擦,path 带着 [1,2,2] 的脏底进第二个分支 → 根层"[2] 起头"实得 [1,2,2,2],幻影子集 [1,2,2,2,2](去重墙挡不住跨分支传染,它只防同层同值),同时真正的 [2]、[2,2] 消失。

实测(90,[1,2,2],漏撤销):4 个用例 PASS(全是单链树)、5 个 FAIL(有分叉的)——"单链碰巧对、分叉立刻脏"。撤销不是循环小家务,是下一次递归还"干净起点"的隔离墙。

for 里的三分法:return > break > continue

都是提前止损,分辨率从大到小:

关键字 传染半径 一句话语义 典型场景
return 整个方法(向下全停) 收网走人 / 出口 数量满、预算扣完
break 本层 for 剩下的候选全杀 有序保证后面全没戏 next < 0 超预算、剩余数字不够
continue 只跳过当前候选,后面照常 这一支没意义,别的支还有戏 同层去重、切割段不是回文

选择依据是"后面还有没有机会":数值单调递增 + 超预算 → 后面全部注定失败 → break;值重复/某段不是回文 → 只是这支白起,不该连坐后面的候选 → continue。

39 里 break(超预算)和 40/90 里 break+continue 同框共存、分工明确——山脉的传染半径不同,不是两个可以混用的东西。

撤销的粒度:你能 new 一张新表吗(90 漏撤销的镜像)

撤销只该撤你自己那一笔——做选择是一笔(used[i]=true + path.add),撤销就是同粒度的一笔(used[i]=false + path.remove)。used = new boolean[n](掀桌重开)不是撤销:它把"分支结束时的全 F 终态"搬到分支进行中的每一步,祖先的 true 是活账(跨层连用合法性的凭据),被一起撕掉后跨层连用锁死失效——47 里 [1,1,1]/[1,2,2] 幻影即此。与 90 的漏撤销互为镜像:撤太少(脏状态跨分支传染)/ 撤太多(活账被误杀)。

本章十题一表(47 收口总结)

题 家族 关键变形 收账形态
77 组合 组合 start 原型、数量剪枝 叶子:size==k
216 组合III 组合 remain 传参 + i>remain 剪枝 叶子 + remain==0(分开写防幽灵递归)
17 电话 组合 每层换集合,start 退场,idx 当层号 叶子:idx==len
39 组合总和 组合 传 i 可重复取,排序授权 break 叶子:remain==0(无数量维度)
40 组合II 组合 同层去重 i>start && 相邻同值 叶子
131 切割 组合 切段量纲 for(end 上限 len-1) 叶子:start==len
78 子集 子集 收账提升 每个节点:无条件第一行,for=出口
90 子集II 子集 78+40 合体 节点
46 全排列 排列 for 全量扫 0..n-1,used[] 上场 叶子:size==n
47 全排列II 排列 46+40 合体,去重多 !used[i-1] 叶子

去重宇宙对照(40/90/47 三胞胎)

层的定义 去重条件 跨层连用靠什么
40 组合 start 窗口 i > start && 相邻同值 递归 i+1 下移
90 子集 start 窗口 同 40 递归 i+1
47 排列 无 start 全量扫 i > 0 && 相邻同值 && !used[i-1] used[i-1]==T(前值使用中=祖先活账)

组合用位置(start)定层,排列用状态(used 是否使用中)定层——同一去重思想的两种载体。