Algorithm · Backtracking

回溯

决策树上的系统化枚举:三件套模板 · 子集/排列/组合 · 同层去重与剪枝 —— 把 O(n!) 写出秩序

三件套

路径(已做选择)· 选择列表(还能选啥)· 结束条件(收集答案)

三大模板

子集(start 指针)· 排列(used 数组)· 组合(start + 长度剪枝)

进阶

同层去重(排序 + used[i−1])· 可行性/最优性剪枝 · N 皇后 O(1) 判冲突

定位:回溯是"暴力的艺术"——指数复杂度不可避免,但模板化 + 剪枝让它可控。三大模板覆盖 LC 回溯题 90%。

Why Backtracking

先看一道写不出来的题:层数由输入决定

任务答案有多少个你会怎么下手
列出 [1,2,3] 的全部子集2³ = 8手算,一分钟列完
列出 [1..20] 的全部子集2²⁰ ≈ 104 万想写 20 层 for 循环 —— 写不出来
列出 [1..30] 的全排列30! ≈ 10³²宇宙寿命内跑不完
先看清困难到底在哪:这类题难的不是"算得慢",而是代码根本写不出来——for 循环的层数由输入 n 决定,而代码必须在敲下的那一刻就把层数定死。就算你硬写出了 n 层,还得自己保证不漏、不重
回溯换掉的就是这件事:把"一次性生成整个答案"改成"一次只做一个决定,剩下的交给下一层"。层数不再写死在代码里,而由递归深度自然长出来;漏解与重复也不再靠人脑保证,而由"每层都把可选的一一走遍"这个结构保证。

一个能在脑子里跑的最小例子

手里是 [1,2,3],从空集 [] 出发。现在只问一个问题:1 要不要进这个子集?要 → [1];不要 → []。然后对 2 问一遍,再对 3 问一遍。三个"要不要"问完,8 个答案自然全部出现。回溯的全部动作就是这么一句。

为什么必须"撤销"

走「要 1」这条路得到 [1,2,3] 之后,还要回到「不要 1」那条路重新走。如果不把刚才加进去的 1 拿掉,第二条路就会带着 1 一起走,答案全错。这个"拿掉最后一步"的动作叫撤销(backtrack,字面意思就是"往回走"),它是这套方法名字的由来。

代价也说清楚

回溯不消除指数级复杂度——2ⁿ、n! 该多大还是多大。它换来的是三件事:代码能写出来、答案不漏不重、还能剪枝把明显没戏的整棵子树提前砍掉。所以它是"指数问题里最可控的写法",不是"把指数变成多项式的魔法"。

本 deck 的路线

先把"一步步做决定"画成一棵决策树(下一页的定义 + 图解页)→ 抽出十行模板 → 子集 / 组合 / 排列三个变体 → 最后两页讲去重与剪枝。读完你应该能自己推导出模板,而不是背它。

动机页:先用一个"n 层循环写不出来"的具体困境把读者卡住,再说明真正的困难是"层数由输入决定 + 不漏不重",最后点明回溯换来的三件事(能写、不漏不重、可剪枝)与它换不来的东西(指数复杂度)。最小例子用 [1,2,3] 三个"要不要",与后面的决策树图解页完全同一例子,形成前后呼应。

Prerequisites & Glossary

先把词认全:下面每一页都会用到它们

术语一句话理解(先记住这个,细节后面展开)
决策树 decision tree把"每一步有哪些选择"画成的树:根是还没做任何决定,每条边是一个决定,每个节点是一个"半成品答案"
路径 path从根走到当前节点,沿途做出的全部选择——也就是"到目前为止的部分答案"
选择列表 choices站在当前节点上,还能选哪些;这份清单怎么算,决定了是子集 / 组合 / 排列
结束条件 base case什么时候停止往下走:收集答案、或判定这条路作废
撤销 / 状态恢复返回上一层之前,把这一步加进路径的东西去掉,让兄弟分支拿到干净状态
剪枝 pruning明知整棵子树不可能有答案(或不可能更优)时,提前不再往下走
解空间 solution space所有可能被枚举到的候选答案的总个数;它的大小就是回溯的复杂度(2ⁿ / n! / C(n,k))
DFS 深度优先搜索"一条路走到底,走不动了再退回上一个岔口"的遍历顺序;回溯就用它
同层 vs 树枝同层=同一个父节点下的兄弟分支;树枝=从根往下的一条链。去重时两者语义完全不同

如果这些词还有一个是陌生的

先读这几篇再回来。本 deck 默认你已经能读懂 2ⁿ、递归和数组下标:
复杂度分析 → 2ⁿ 与 n! 到底有多大
二叉树 → 决策树就是一棵"每个节点含义不同"的树
栈与队列 → 递归背后那个隐式栈
动态规划 → 只要最值、不要具体方案时的替代方案

本 deck 的最小心智模型

把回溯记成一个不断重复的两步动作:① 在当前层,把"还能选的"逐个试一遍,每试一个就往下走一层;② 从下层回来,一定把刚才试的那一步撤掉。后面所有变体(子集 / 组合 / 排列 / 棋盘 / 去重 / 剪枝)都不改这个动作,只改"还能选的"这份清单怎么算

为什么"同层 / 树枝"值得单独记

去重那一页的所有困惑都来自把这两件事混为一谈:树枝上要禁止"同一个元素用两次"(模板自带),同层里要禁止"选相同的值"(需要额外判断)。两个"重复"含义不同,代码写法也不同。

阅读提示:术语不用背,忘了回来查这一页;真正要能默写的只有十行模板,以及速查页里那张三大模板对照表和五条检查清单。
前置页:九个术语先定义再使用。右侧"最小心智模型"把后续所有变体统一成同一个两步动作(试一个 / 撤一个),明确"变的只有选择列表怎么算",大幅降低记忆负担;并预告去重页的最大困惑点(同层 vs 树枝),为第 8 页埋钩子。

Decision Tree Walk

回溯是什么:在决策树上做系统化遍历

上面那三个"要不要"的问题,画出来就是一棵树:每个岔口是一个决定,从根走到任何一个节点,沿途的选择连起来就是一个候选答案。

一句话定义

把问题建模成"每一步有若干选择"的决策树,用 DFS 走遍所有分支;走不通或收集完答案就撤销最后一步、换下一个分支——"回"的是状态,"溯"的是路径。

三件套(模板三要素)

路径 path:已经做出的选择;② 选择列表:当前还能选什么;③ 结束条件:路径满足答案形态时收集(或提前剪枝返回)。

和 DFS 的关系

DFS 是遍历方式,回溯是遍历+状态恢复的算法框架:DFS 走图/树是"访问完就完",回溯必须"撤销现场"让兄弟分支拿到干净状态。

和 DP 的分工

所有方案 / 具体方案 → 回溯(方案数指数个,没法压缩);只要最值/计数且满足无后效性 → DP(DP deck)。

复杂度的直觉

决策树节点数就是复杂度:子集 2ⁿ、排列 n!、组合 C(n,k)——指数不可避免,能做的是剪枝减树

问题形态解空间典型题
子集(每个元素选/不选)O(2ⁿ)78/90/131 分割回文串
排列(顺序敏感)O(n!)46/47/60
组合(定长不排序)O(C(n,k))77/39/40/216
棋盘/网格放置指数+剪枝51 N 皇后/37 数独/79 单词搜索
图/隐式图搜索状态空间332 重新安排行程
面试金句:"回溯的代码只有十行,但十行之外全是设计:状态用什么表示、选择列表怎么维护、在哪剪枝——模板是骨架,剪枝是灵魂。"
核心是"决策树视角"+三件套术语体系。与 DP 的分工(要方案 vs 要最值)是高频辨析。右表给出五类问题形态与解空间大小——面试先报复杂度再写代码。

Subset Tree · [1,2,3]

图解:[1,2,3] 的子集决策树

子集问题的决策树与回溯过程 以 1 2 3 求子集为例:根为空集,第一层决定是否选 1,第二层决定是否选 2,第三层决定是否选 3;每个节点都是一个子集答案,全树共 8 个节点 8 个答案。 [] [1] [] (跳过1) [1,2] [1] [2] [] (跳过2) [1,2,3]✓ [1,2]✓ [1,3]✓ [1]✓ [2,3]✓ [2]✓ [3]✓ []✓ 选 1 不选 1 每个节点都是一个合法子集(叶子打 ✓)· 8 节点 = 2³ · 遍历方式 = DFS + 撤销
这棵树回答三个问题:为什么子集是 2ⁿ(每元素一次二选一)、答案为什么在"每个节点"而不只在叶子(子集题特殊:路径本身就是答案)、"撤销"发生在哪(兄弟分支切换前)。

The Ten-Line Frame

模板代码:十行骨架走天下

var (
    res  [][]int
    path []int
)
func backtrack(choices, start int) {
    // 1. 结束条件:收集答案
    if 满足条件 {
        res = append(res,
            append([]int{}, path...)) // 拷贝!
        // 不要 return:子集题要继续
    }
    // 2. 遍历选择列表
    for i := start; i < n; i++ {
        // 3. 剪枝 + 做选择
        path = append(path, a[i])
        backtrack(i+1)        // 下一层
        path = path[:len(path)-1] // 撤销
    }
}

第 1 步:结束条件的位置决定题型

每个节点都是答案(子集)→ 收集后不返回;只叶子是答案(组合定长)→ 长度够了 return;找到即停(数独)→ 找到后层层 return true。

第 2 步:选择列表怎么来

三种形态:start 指针往后(子集/组合,防重复选);used 数组(排列,位置无关);棋盘坐标(N 皇后)。选择列表的维护方式就是题型的签名

第 3 步:append / 撤销必须成对

Go 经典坑:res = append(res, path) 存的是底层数组引用,撤销后 res 里全变——必须拷贝一份。这是 Go 岗专属送命题。

参数传递注意

path 用切片共享底层数组(append 撤销配对);若用值传递每层拷贝,代码简单但开销大——面试说清取舍。

模板逐行讲:结束条件位置的三种形态、选择列表的三种维护、Go 的 res 拷贝坑(专属重点)。"十行之外全是设计"在这一页落地。

Subsets · Permutations · Combinations

三大模板:只差"选择列表"的维护方式

子集(LC 78):start 指针

每个元素"选/不选",答案在每个节点。start 保证只往后选——树的右侧天然排除已处理元素,不需要 used。

排列(LC 46):used 数组

顺序敏感,每层都能从头选(除了已在路径里的)→ used[i] 标记占用。结束条件:len(path)==n 只在叶子收。

组合(LC 77):start + 长度剪枝

= 子集 + 定长 k。剪枝:i <= n−(k−len(path))+1——剩下元素不够凑满 k 就别进循环了,把"到叶子才发现不行"提前到"进循环前"

记忆口诀

子集无限制、组合限长度、排列乱顺序——三句话对应 start / start+剪枝 / used 三种维护。写题先问自己属于哪个模板。

维度子集 78组合 77排列 46
选择列表start..nstart..n(剪枝)全部非 used
答案位置每个节点长度 == k叶子(长度 n)
去重手段start 天然去重start + 同层去重used + 同层去重
复杂度O(2ⁿ·n)O(C(n,k)·k)O(n!·n)
输入要求有序更好剪有重复才需排序
面试金句:"三大模板的关系是组合 = 子集 + 定长、排列 = 子集 − start——把'选择列表怎么维护'这个问题回答清楚,三个模板就是一个模板。"
三大模板的对照表是本 deck 核心:选择列表维护方式(start/used)决定题型。组合剪枝公式 n−(k−len)+1 要能推导:剩余元素必须够填满剩余位置。

Dedup · LC 90/47/40

去重:排序 + "同层跳过"

// 90 子集 II:输入含重复元素
sort.Ints(a) // 前提:先排序!
func backtrack(start int) {
    res = append(res, copyOf(path))
    for i := start; i < len(a); i++ {
        // 同层去重:与前一个值相同
        // 且前一个在本层被跳过
        if i > start && a[i] == a[i-1] {
            continue
        }
        path = append(path, a[i])
        backtrack(i + 1)
        path = path[:len(path)-1]
    }
}
// 47 排列去重(used 版):
if used[i] || (i > 0 &&
    a[i] == a[i-1] && !used[i-1]) {
    continue
}

去重的两层含义

树枝去重(同一元素不能用两次):子集/组合用 start、排列用 used——这是模板自带的;② 同层去重(同一层不选相同的值):本页的主角,解决"输入有重复"。

为什么 i > start 且 a[i]==a[i−1] 就跳过

排序后同值元素相邻:本层若第二次选同值,产生的子树与第一次完全相同 → 只保留"同值第一个"的分支。i>start 保证"第一个"不被跳过。

排列版为什么是 !used[i−1]

used[i−1]==true 说明 a[i−1] 在树枝上(当前路径)——可用;false 说明在同层被回撤——同值必重。两种写法(!used 与 used)都行,方向不同而已,但要与排序配合。

先排序是铁律

同层去重依赖"同值相邻"——忘记 sort 是该族题的头号 bug。复杂度:去重后树规模 = 不同答案数,最坏仍 2ⁿ。

"树枝去重 vs 同层去重"的两层区分是理解关键:模板自带前者,后者靠"排序 + i>start 跳过同值"。排列版 !used[i−1] 的含义(同层回撤)要能讲清楚。

Pruning · LC 51/37/79

剪枝与棋盘类:N 皇后与数独

剪枝的两大类

可行性剪枝:当前部分解已违反约束 → 整棵子树砍掉(N 皇后冲突、组合和超限);② 最优性剪枝:当前代价已 ≥ 已知最优 → 不用走完(TSP、记忆化中的界限)。剪枝越靠上层省得越多

N 皇后(LC 51):O(1) 判冲突

三组集合:cols(列)、diag1(row−col,↘ 方向同值)、diag2(row+col,↙ 方向同值)——按行递归,每行只试 n 列,冲突判定全 O(1)。n=9 时解 352 个,纯暴力 + O(1) 判定即可过。

数独(LC 37)

行/列/九宫三个 bool 矩阵记录占用;填充时找"候选最少"的空格先试(MRV 启发式)能大幅剪枝;解唯一 → 找到后层层返回 true。

单词搜索(LC 79)

网格 DFS + 原地标记(把走过格子改字符 / 用位掩码)替代 visited 数组,回溯时还原——"原地标记+恢复"是网格回溯的标准姿势(Trie 协同见 LC 212)。

状态表示剪枝要点
51 N 皇后三个集合(列/两对角)O(1) 判冲突;按行放置天然无行冲突
37 解数独行/列/宫 bool 矩阵MRV:先填候选最少的格
79 单词搜索网格原地标记首字母过滤 + 长度剪枝
39/40 组合总和路径 + 剩余 target排序后 break(39 可重复)/同层去重(40)
131 分割回文串子串起点回文 DP 预判表 O(1) 判回文
面试金句:"棋盘类回溯的性能差距全在冲突判定:从 O(n) 扫描到 O(1) 集合查询,n=8 的 N 皇后快出一个数量级——用空间给决策树瘦身。"
剪枝两分类 + 棋盘四题的状态表示。N 皇后三集合(cols/diag1/diag2)是必背设计;数独 MRV 启发式、79 原地标记是进阶亮点。

LeetCode Shortlist

必刷题单:回溯的四个阶段

阶段题目(编号 · 难度)要点
模板三连78 子集 M · 77 组合 M · 46 全排列 M三大模板各默写一遍
去重进阶90 子集 II M · 47 全排列 II M · 40 组合总和 II M排序 + 同层去重统一套路
棋盘与剪枝51 N 皇后 H · 37 解数独 H · 79 单词搜索 MO(1) 冲突判定 / 原地标记
综合应用39/216 组合总和 M · 131 分割回文串 M · 22 括号生成 M · 332 行程 H131 需回文 DP 预处理;332 是欧拉路径
刷法建议:78/77/46 连写三遍(手上有模板感)→ 90/47/40 补去重 → 51 是回溯"集大成"必刷 → 332 留作图论衔接(Hierholzer 算法,见 图 deck)。
四阶段刷法:模板 → 去重 → 棋盘 → 综合。51 N 皇后是回溯面试的"证书题"——能现场写并讲清剪枝,回溯环节基本稳过。

Cheat Sheet

一页带走:十行骨架 · 三大模板 · 五条检查

① 十行骨架:所有回溯题的公共部分

func backtrack(选择列表, path) {
    if 满足结束条件 {
        res = append(res, append([]int{}, path...)) // 拷贝!
        return                              // 子集题不 return
    }
    for _, c := range 选择列表 {
        path = append(path, c)      // ① 做选择
        backtrack(新选择列表, path)
        path = path[:len(path)-1]   // ② 撤销(必成对)
    }
}

② 去重与剪枝:三个判断点

树枝去重同一个元素不能在同一条路径上用两次 —— 子集/组合靠 start、排列靠 used模板自带
同层去重先排序,再 i > start && a[i] == a[i-1] 跳过;排列版是 !used[i-1]
组合剪枝剩下元素不够凑满 k 就别进循环:i <= n-(k-len(path))+1

③ 三大模板:差异全在这四格

维度子集 78组合 77排列 46
选择列表start..nstart..n+剪枝全部非 used
答案位置每个节点长度 == k叶子(长度 n)
去重手段start 天然去重start + 同层去重used + 同层去重
复杂度O(2ⁿ·n)O(C(n,k)·k)O(n!·n)

④ 默写检查清单(写完逐条扫一遍)

1 · 答案拷贝了吗?Go 里 append(res, path) 存的是切片引用,撤销后全被改掉——必须 append([]int{}, path...)Go 岗专属送命题。

2 · 结束条件的位置对吗?子集每节点收且不 return;组合够长才收;数独/单词搜索找到即层层 return true

3 · 选择列表怎么算?子集与组合用 start 只往后看(天然去掉顺序重复),排列用 used 跳过已占用。

4 · 同层去重前排序了吗?a[i]==a[i-1] 依赖"同值相邻",忘记 sort 是该族题的头号 bug。

5 · 树枝与同层分清了吗?树枝禁"同一元素用两次",同层禁"选相同的值"——两个"重复"不是一回事。

一句话背下来:回溯 = 在决策树上做 DFS,并且每走一步都要能原路退回来;所有变体改的只有"当前还能选什么"这份清单。
速查页:左半是"唯一骨架 + 去重剪枝三判断点",右半是三大模板四格对照 + 五条检查清单。检查清单覆盖了 Go 切片引用坑、结束条件位置、选择列表维护、排序前提、树枝/同层辨析五类出错点——本 deck 的面试失分几乎全在这五条上。

Interview QA

高频追问合集

先自答,再对照:每题先说出你的答案(哪怕只说关键词),再展开看下面那行——想不出来的时候别急着看,卡住的那 30 秒才是真正长记性的部分。

1 · 回溯和 DFS 是什么关系?

遍历 vs 框架

DFS 是遍历方式;回溯 = DFS + 状态恢复(做选择/撤销选择成对)。图 DFS 不需要撤销(visited 永真),回溯的 visited 是"当前路径"语义,要还原。

2 · 子集、组合、排列模板差在哪?

start / used

子集:start 指针、每节点收答案;组合:start + 定长剪枝、长度够才收;排列:used 数组、每层可从头选。口诀"组合=子集+定长、排列=子集−start"。

3 · 组合剪枝公式 n−(k−len)+1 怎么来的?

够不够凑

从 i 开始还剩 n−i+1 个可选元素,还差 k−len 个位置:n−i+1 ≥ k−len ⇒ i ≤ n−(k−len)+1。超过它连叶子都凑不齐,整段 for 直接不进。

4 · 同层去重为什么是 a[i]==a[i−1] 且 i>start?

排序+跳同值

排序使同值相邻;本层第二个同值元素的子树与第一个完全重复 → 跳过。i>start(或排列版 !used[i−1])保护"本层第一个同值"不被误跳。

5 · N 皇后怎么 O(1) 判冲突?

三个集合

cols[c]、diag1[r−c]、diag2[r+c] 三个 map/数组:同列、同 ↘ 对角(差相等)、同 ↙ 对角(和相等)各一查一插。按行递归天然消除行冲突。

6 · 回溯的时间复杂度怎么算?

决策树节点数

树节点数 × 每节点代价:子集 O(2ⁿ·n)(拷贝 path)、排列 O(n!·n)、组合 O(C(n,k)·k)。先说"决策树多大"再说"拷贝代价"。

7 · 收集答案为什么必须拷贝?

Go 切片引用

path 是共享底层数组的切片,append(res, path) 存引用——回溯撤销后 res 里的"答案"全被改掉。必须 append([]int{}, path...) 拷贝。这是 Go 岗专属高频坑。

8 · 什么时候回溯、什么时候 DP?

方案 vs 最值

要所有/具体方案、约束复杂难建模 → 回溯;只要最值/计数且无后效性 → DP。分界案例:全排列(回溯)vs 最长递增子序列(DP);括号生成两法皆可但回溯更自然。

八题覆盖:与 DFS 关系、三模板、剪枝公式推导、去重原理、N 皇后判定、复杂度计算、Go 拷贝坑、与 DP 分工。

Related & References

相关知识点与参考

算法系列(本分类)

动态规划 →(要最值时的替代/升级)
复杂度分析 →(2ⁿ/n! 解空间的记账)
图算法 →(332 欧拉路径;DFS 同源)
排序算法 →(去重前必排序)

数据结构系列

二叉树 →(决策树的前置形态)
栈与队列 →(递归栈与显式栈)
Trie →(LC 212 的协同结构)

参考来源(本 deck 结论可溯源至下列一手材料)

LeetCode 78/77/46/90/47/51/37/79/131 官方题解三大模板、同层去重、棋盘剪枝的标准表述
S. Skiena · The Algorithm Design Manual(回溯章节)剪枝分类与回溯系统化设计
oi-wiki.org/search/backtracking · 递归/剪枝优化剪枝四类(可行性/最优性/搜索顺序/记忆化)
Go 官方 FAQ · slices and array internalsappend/切片共享底层数组导致 res 污染的语义依据
收尾:DP/图/Trie 三向链接。Go 切片引用坑以官方 FAQ 为依据——是本 deck 的 Go 岗特色考点。总页数 10。