【算法】回溯算法(三):三记重锤与 N 皇后------记忆化、状态设计与三层漏斗
摘要
回溯系列第三篇,收官。上一篇以 LC842 验收"毕业",我以为真毕业了------接下来三记重锤锤醒了梦:记忆化的三次错位 (417 万次调用 vs 176 次,缓存选择、缓存候选、只写不读一路踩过去)、排序被禁后的去重 (层属性 vs 路径属性,usedThisLevel 不还原是精髓)、中间态失控 (把 Go 的栈撑爆 1GB)。三锤全部指向同一件事:状态设计 。最后用 N 皇后收官(对角线编号 r±c + 三个标记数组 + 四动作对),并把整个系列沉淀成三层漏斗识别框架和写题前的终极检查清单。本篇也是通往动态规划的桥------记忆化的判据,就是 DP 的入场券。
说明,以下环境基于 go1.21+,题目均来自 LeetCode。前置阅读:回溯算法(一):从一道 IP 题到万能模板、回溯算法(二):五个坑与一次验收。配套代码仓库(按题号分目录):https://github.com/a18792721831/studyleetCode
系列篇目:
- 一、从一道 IP 题到万能模板
- 二、五个坑与一次验收------决策、切片与去重
- 三、三记重锤与 N 皇后------记忆化、状态设计与三层漏斗(本篇)
【算法】回溯算法(三):三记重锤与 N 皇后------记忆化、状态设计与三层漏斗
- [【算法】回溯算法(三):三记重锤与 N 皇后------记忆化、状态设计与三层漏斗](#【算法】回溯算法(三):三记重锤与 N 皇后——记忆化、状态设计与三层漏斗)
-
- 摘要
- [1. 翻车六:记忆化的三次错位(LC139 超时 → LC140 两连坑)](#1. 翻车六:记忆化的三次错位(LC139 超时 → LC140 两连坑))
-
- [1.1 第一次错位:不知道要记忆](#1.1 第一次错位:不知道要记忆)
- [1.2 第二次错位:memo 存了"一个选择"](#1.2 第二次错位:memo 存了"一个选择")
- [1.3 第三次错位:memo 存了"候选词列表"](#1.3 第三次错位:memo 存了"候选词列表")
- [1.4 判据:缓存结论,不缓存选择](#1.4 判据:缓存结论,不缓存选择)
- [2. 翻车七:排序被禁之后(LC491 递增子序列)](#2. 翻车七:排序被禁之后(LC491 递增子序列))
-
- [2.1 排序的真正角色](#2.1 排序的真正角色)
- [2.2 层属性 vs 路径属性](#2.2 层属性 vs 路径属性)
- [2.3 去重四件套集齐](#2.3 去重四件套集齐)
- [3. 翻车八:中间态失控(LC79 单词搜索)](#3. 翻车八:中间态失控(LC79 单词搜索))
-
- [3.1 病根:移动和匹配被拆成了两个递归](#3.1 病根:移动和匹配被拆成了两个递归)
- [3.2 修复:移动即匹配,消灭中间态](#3.2 修复:移动即匹配,消灭中间态)
- [3.3 粒度的两个方向](#3.3 粒度的两个方向)
- [3.4 决策设计四检验(三验证升级版)](#3.4 决策设计四检验(三验证升级版))
- [4. 状态设计收官:N 皇后(LC51)](#4. 状态设计收官:N 皇后(LC51))
-
- [4.1 骨架:每层一行,一次调用](#4.1 骨架:每层一行,一次调用)
- [4.2 对角线的数学:这题的灵魂](#4.2 对角线的数学:这题的灵魂)
- [4.3 三个标记数组 + 四动作对](#4.3 三个标记数组 + 四动作对)
- [4.4 "折叠判断"的第四次登场](#4.4 "折叠判断"的第四次登场)
- [5. 识别框架:三层漏斗](#5. 识别框架:三层漏斗)
-
- [5.1 第一层:范式闸门(是不是回溯)](#5.1 第一层:范式闸门(是不是回溯))
- [5.2 第二层:家族闸门(用哪个骨架)](#5.2 第二层:家族闸门(用哪个骨架))
- [5.3 第三层:延伸模块扫描(加什么装备)](#5.3 第三层:延伸模块扫描(加什么装备))
- [5.4 实操流程:迭代式](#5.4 实操流程:迭代式)
- [6. 终极检查清单](#6. 终极检查清单)
- 总结
- 参考资料
1. 翻车六:记忆化的三次错位(LC139 超时 → LC140 两连坑)
1.1 第一次错位:不知道要记忆
我自己写 139 的回溯版,测试用例:130 个 a + b,字典是 a 到 aaaaaaaaaa(10 种长度)。跑了半天跑不完,缩小到 22 个 a 统计调用次数:
22 个 a + b:
纯回溯: 4,169,729 次调用
记忆化版: 176 次
417 万 vs 176 。病根:字典有 10 种长度的词,切到位置 k 的路径数指数增长,而所有路径到达同一个位置后做的事情完全一样------"从位置 k 把剩下的拆完"。这个子问题的答案只取决于 k,与怎么到达毫无关系,但纯回溯每条路径到达后都从头完整枚举一遍。
更扎心的是这个用例无解 (b 匹配不上任何词),找到就短路返回的技巧救不了它------必须把整个搜索空间走完才能确认无解,这时候记忆化是唯一的活路:
go
// 三态:0=没算过 1=可拆 2=不可拆(用 bool 无法区分"没算过"和"算出来是 false")
memo := make([]int8, len(s)+1)
1.2 第二次错位:memo 存了"一个选择"
升级打 140 单词拆分 II(要所有方案)。第一版我把 memo 设计成 map[int]string------存"这个位置匹配到的某个词"。收集时还用 return 短路,结果只找到一个方案。
两个错误:
- return 短路错位:139 的短路是对的(只要可行性),140 要所有方案------收集完必须继续枚举。同一个"找到就返回",在存在性搜索里是优化,在枚举搜索里是 bug
- memo 语义错位 :我存的是"选了哪个词"(过程 ),而防爆炸需要存"从这出发的结论"。前一个相同值没被选(说明它在同层刚被试完弹回),我缓存它的"选择"会让后续所有路径都被锁死在同一个词上
1.3 第三次错位:memo 存了"候选词列表"
第二版我把 memo 升级成 map[int][]string------存"这个位置能匹配的所有词"。正确性达标了,但跑那个无解用例:
22 个 a + b:
我的版本: 4,169,729 次调用 ← 和完全没有 memo 一模一样!
DP 可行版: 2 次调用
一个数字都没差 。因为缓存候选词列表后,命中照样要 for 循环递归全部候选------子树还是整棵重搜。省掉的只是"重新扫描词表做字符串匹配"的零钱,指数级的路径枚举一分没省。
1.4 判据:缓存结论,不缓存选择
三次错位之后,判据终于立住了:
缓存的东西,能不能让后来者跳过子树的展开?
- 候选词列表不能(选择还要逐一展开)
can[end] == false能(整支不进)- 句子列表能(直接拼接)
- 一个词的"选择"根本是错的(锁死分支)
140 的正确姿势是两种之一:先跑 139 的 DP 算出 can[](从 idx 能否拆完),回溯只走活路 (can[end] 为 false 的分支一步不进);或者 memo 直接缓存子问题的完整产出(map[int][]string 存"从 idx 能拼出的所有句子",递归返回值直接拼接)。
还有一个更深的判据,判断这道题需不需要记忆化------看状态和路径的关系:
- 路径即状态 (排列/组合/子集):选择序列本身就是状态,
[1,2]和[2,1]是两个不同状态------每条路径活在不同的状态里,没有重复可折叠,永远不需要 memo - 状态与路径分离 (切分/网格/博弈):状态是聚合量(切到哪个位置、走到哪个格子、剩什么局面),多条路径坍缩到同一状态------重复计算出现了,记忆化的靶子
先问"我的状态是什么",再问"不同路径会坍缩到同一状态吗"------会,就必须想记忆化。
2. 翻车七:排序被禁之后(LC491 递增子序列)
递增子序列:找出所有长度 ≥2 的非严格递增子序列,元素必须保持原序 ,含重复元素,解不能重复。这题是块试金石------它禁止排序(排序会摧毁"子序列按原序提取"的语义),而我学的同层去重全部依赖排序。
我第一版叠了五个错误,输出 22 个结果(预期 8),[6,7] 重复 9 次:
- 模型混乱:选一个骨架里又塞了显式"不选"分支------for 的 i 递增已经天然表达"跳过",再加显式跳过等于每批子序列枚举两遍
- 递归传
start+1而不是i+1 - 递增约束三处错:
len(path)>1应为非空、break应为continue(未排序!当前元素小不代表后面没有大的) - 一个 map 想同时干"路径判重"和"同层判重"两种语义
nums[i]==nums[i-1]相邻判重------依赖排序让相同值相邻,这里失效
2.1 排序的真正角色
撞完墙才想通:排序在同层去重里的唯一角色 = 让相同值相邻,从而用"和前一个比较"这个 O(1) 手段发现"本层又遇到同一个值" 。它从来不是为了去重本身,只是去重的"发现手段"。禁止排序后,发现手段换成哈希集合 ------不依赖相邻,直接查"这个值本层试过没"。本质从没变过:同层同值,只走第一个。
2.2 层属性 vs 路径属性
这里出现了一个新的关键区分------两种标记的生命周期:
| used 数组(47/90) | usedThisLevel 集合(491) | |
|---|---|---|
| 记录什么 | 下标是否在 path 里 | 这个值本层是否试过 |
| 生命周期 | 路径属性:做选择置 true、还原置 false,随一条路径翻转 | 层属性 :每次进入函数新建,递归返回后不还原 |
| 为什么 | 它描述"当前路径的状态",路径变了它就变 | 它描述"这一层 for 循环已经试过哪些值",循环还在本层继续,标记必须保持;下一层递归自己建新的 |
usedThisLevel 不还原是精髓------递归返回后本层 for 还要继续试 i+1、i+2,"这个值试过了"的记录必须留住。它随层生灭,不需要你还原。
2.3 去重四件套集齐
go
func findSubsequences(nums []int) [][]int {
res := [][]int{}
path := []int{}
var backtrack func(start int)
backtrack = func(start int) {
// 每节点收集:长度 ≥ 2 的节点就是答案------收集后继续,不 return
if len(path) >= 2 {
res = append(res, slices.Clone(path))
}
usedThisLevel := map[int]bool{} // 层属性:本层已试过的"值"
for i := start; i < len(nums); i++ {
// 合法性:非严格递增。continue 不是 break------没排序!
if len(path) > 0 && nums[i] < path[len(path)-1] {
continue
}
// 同层去重:这个值本层已经试过
if usedThisLevel[nums[i]] {
continue
}
usedThisLevel[nums[i]] = true // 标记,不还原(层属性)
path = append(path, nums[i])
backtrack(i + 1)
path = path[:len(path)-1]
}
}
backtrack(0)
return res
}
| 47 排列 | 40 组合 | 90 子集 | 491 子序列 | |
|---|---|---|---|---|
| 骨架 | 全范围 for + used | start 推进 | 要/不要 | start 推进 + 每节点收集 |
| 能排序吗 | 能 | 能 | 能 | 禁止 |
| 发现同层同值 | 相邻比较 | 相邻比较 | 相邻比较 | 层内哈希集合 |
| 同层/同枝区分 | !used[i-1](前一个已弹出) |
i > start(前一个是本层候选) |
!used[i-1] |
集合层作用域天然区分 |
一个原则(同层同值只走第一个)、两个发现手段(排序相邻/层内集合)、三种层界定方式(used 翻转/start 边界/集合作用域)------四道题亲手翻过车修干净,这个考点才算真正焊死。
3. 翻车八:中间态失控(LC79 单词搜索)
这次翻车最壮观------不是超时,是把 Go 的栈撑爆了 1GB。
单词搜索:网格里按上下左右找单词,格子不能复用。我第一版设计了一个 step 参数来区分"移动了一步"和"匹配了一个字符",让递归同时承担"在网格上移动"和"匹配字符"两个职责。跑 ABCB 用例,直接:
fatal error: stack overflow(goroutine 栈超过 1GB 上限)
main.existUser.func1(0x2, 0x4, {空path}, 0x0, step=2233116) ← step 涨到 223 万
main.existUser.func1(0x2, 0x3, {空path}, 0x0, step=2233115)
main.existUser.func1(0x1, 0x3, {空path}, 0x0, step=2233114) ← 在 (1,3)↔(2,3) 之间
main.existUser.func1(0x2, 0x3, ...) ← 来回震荡,没有东西拦它
3.1 病根:移动和匹配被拆成了两个递归
我的 step 机制产生了"移动了但还没匹配 "的中间态------空 path 时这个状态没有任何终止条件(没有 visited 拦截、没有步数上限),漫游在网格环上无限转,直到栈爆。
3.2 修复:移动即匹配,消灭中间态
go
// 语义:word[idx] 必须在 (r,c) 这个格子上匹配
var dfs func(r, c, idx int) bool
dfs = func(r, c, idx int) bool {
if r < 0 || r >= m || c < 0 || c >= n { // 出界
return false
}
if used[r][c] || board[r][c] != word[idx] { // 已用 / 字母不匹配
return false
}
if idx == len(word)-1 { // 最后一个字符匹配上了
return true
}
used[r][c] = true // 做选择
found := dfs(r+1, c, idx+1) || dfs(r-1, c, idx+1) ||
dfs(r, c+1, idx+1) || dfs(r, c-1, idx+1) // 四方向 = 匹配下一字符
used[r][c] = false // 还原
return found
}
// 起点森林在外层:双重循环枚举所有格子作为起点
三参数 (r, c, idx) 的每个状态都是合法局面。向邻居移动一步就是去匹配下一个字符------移动和匹配是原子的,不可分。递归里根本不存在"移动了但没匹配"这种状态。
3.3 粒度的两个方向
有意思的是,这次错误和第一次翻车(括号题)方向正好相反:
LC22 括号:把多个原子动作打包成一个决策("放 idx 对")→ 粒度过粗 → 缺解
LC79 网格:把一个原子动作劈成两个递归(移动+匹配) → 粒度过碎 → 中间态失控
方向相反,病根相同:没识别出真正的原子动作。"最小决策"的最小 不是越小越好,而是恰好等于一个不可分割的完整语义单元。代码可分(改坐标和比字符是两行)≠ 语义可分("走一步"的意思就是"去那里匹配下一个字")。
3.4 决策设计四检验(三验证升级版)
1. 原子性:这个决策能拆成两个有独立语义的动作吗?
能 → 切的位置可能错了(LC79 的教训)
2. 中间态合法性:拆开后出现的中间状态,题目语义里存在吗?
不存在 → 必须合并(LC79:移动+匹配)
3. 覆盖完整性:所有合法解都能被某个决策序列构造出来吗?
不能 → 粒度过粗(LC22:(()()) 无路径)
4. 约束局部可判:每个决策点的可行性,能用当前状态 O(1) 判断吗?
4. 状态设计收官:N 皇后(LC51)
最后一题,N 皇后。第一版直接崩了:没有递归下沉、对角线检查只排了穿过棋盘中心的两条(i==row、row+i==n-1)、边放皇后边放镜像位(幻觉)、外层循环把起点概念搞混。从头重建。
4.1 骨架:每层一行,一次调用
N 皇后不需要起点森林 (单词搜索需要,这题不需要):单词搜索的路径可以从任何格子开始;N 皇后每行必须恰好放一个,处理顺序天然是第 0 行到第 n-1 行------一次调用 solve(0),第一行的皇后放哪列由第一层的 for 决定。
4.2 对角线的数学:这题的灵魂
皇后攻击线有三种(行不用查------每层只放一行,天然不冲突)。列很直观,两条对角线是灵魂。在对角线上走一步,坐标变化是固定的:
↗ 方向走一步:r+1, c-1 → r+c 不变 → 同一条 ↗ 对角线上,r+c 恒定
↘ 方向走一步:r+1, c+1 → r-c 不变 → 同一条 ↘ 对角线上,r-c 恒定
拿 4×4 棋盘把 r+c 算出来,相同数字就是同一条对角线:
r+c: r-c:
0 1 2 3 0 -1 -2 -3
1 2 3 4 1 0 -1 -2
2 3 4 5 2 1 0 -1
3 4 5 6 3 2 1 0
| 冲突线 | 标记数组 | 索引 | 大小 |
|---|---|---|---|
| 列 | cols[col] |
col ∈ 0, n-1 | n |
| ↗ 对角线 | diag1[r+c] |
r+c ∈ 0, 2n-2 | 2n-1 |
| ↘ 对角线 | diag2[r-c+n-1] |
r-c ∈ -(n-1), n-1,加 n-1 偏移消灭负数 | 2n-1 |
全棋盘共 4n-2 条对角线,r±c 给每一条唯一编号------这就是"核心是对斜线进行编号"。
4.3 三个标记数组 + 四动作对
go
func solveNQueens(n int) [][]string {
res := [][]string{}
pan := make([][]byte, n)
for i := range pan {
pan[i] = make([]byte, n)
for j := range pan[i] {
pan[i][j] = '.'
}
}
cols := make([]bool, n) // 列占用
diag1 := make([]bool, 2*n-1) // ↗ 对角线占用:编号 r+c
diag2 := make([]bool, 2*n-1) // ↘ 对角线占用:编号 r-c+n-1
var solve func(row int)
solve = func(row int) {
if row == n { // 判终:收集棋盘
sol := make([]string, n)
for i := range pan {
sol[i] = string(pan[i])
}
res = append(res, sol)
return
}
for col := 0; col < n; col++ { // 决策:这行放哪列
if cols[col] || diag1[row+col] || diag2[row-col+n-1] {
continue // 任何一条线被占,换列
}
cols[col] = true // 做选择:三个标记 + 棋盘
diag1[row+col] = true
diag2[row-col+n-1] = true
pan[row][col] = 'Q'
solve(row + 1) // 下沉:下一行
cols[col] = false // 还原:四个动作逐一撤销
diag1[row+col] = false
diag2[row-col+n-1] = false
pan[row][col] = '.'
}
}
solve(0)
return res
}
重写版用解数序列验证到 n=8:1 0 0 2 10 4 40 92,全对。注意"做选择"和"还原"是四个动作对(三标记 + 棋盘字符)------做多少,撤销多少,纪律和双动作时代一样。
4.4 "折叠判断"的第四次登场
回头看一条暗线:把 O(n) 的检查折叠成 O(1) 查表 ,这个招式出现了四次------131 的回文表(回文判断 O(n) → 查表)、140 的 can 数组(可行性折叠)、464 的位掩码(集合判断折叠)、51 的对角线标记数组(冲突检查 O(n) → 查表)。每次载体不同,原理同一:约束的判断与路径无关,就可以预计算/增量维护,变成查表。
5. 识别框架:三层漏斗
十一类题翻下来,最后把"拿到题怎么快速判断"沉淀成三层漏斗,每层 10 秒:
5.1 第一层:范式闸门(是不是回溯)
| 题面问法 | 范式 |
|---|---|
| "返回所有方案 / 列举全部" | 回溯 |
| "能否... / 存在一种..." | 回溯(短路版)或 DP |
| "最少/最大/最多" | DP 或贪心 |
| "多少种方法" | DP |
| n ≤ 20 | 枚举可接受,回溯候选 |
| n ≥ 10^4 | 与回溯无缘 |
5.2 第二层:家族闸门(用哪个骨架)
- "顺序有意义吗?" 有 → 排列(used + 全范围循环);无 → 组合(start 推进)
- "切字符串/序列吗?" → 切割(切多长)
- "每个元素可以不选吗?" → 要/不要(子集)
5.3 第三层:延伸模块扫描(加什么装备)
| 题面信号 | 挂载模块 |
|---|---|
| "候选可能含重复" / "解不能重复" | 同层去重(排序相邻 or 层内集合) |
| "判断能否" / "返回任意一个" | 存在性短路 |
| "最少切几刀" / "最多拆几段" | 排序 + break 剪枝;最优值优先想 DP |
| 段的性质被反复判断(回文/递增/求和) | 预处理表(O(n) 判断折叠成 O(1) 查表) |
| 约束只看计数不看排列 | 计数器状态(如括号的 open/close) |
| "划分成 k 组" | 分组骨架(元素 → 桶) |
| 状态与路径分离(切分/网格/博弈) | 记忆化(缓存结论) |
其中最后一行的判断法:先问"我的状态是什么",再问"不同路径会坍缩到同一状态吗"------会,就必须想记忆化;排列/组合/子集的路径即状态,永远不用想。
5.4 实操流程:迭代式
读题 → 三层漏斗过一遍(30 秒)
→ 写朴素骨架(家族模板 + 命中的延伸模块)
→ 拿极端用例试跑(无解、全重复、最大规模)
├─ 超时 → 补记忆化(问自己:缓存的是结论还是选择?)
├─ 结果重复 → 补同层去重(能用排序吗?)
└─ 缺解 → 决策粒度或模型错了,回炉四检验
→ 写码前扫终极检查清单
判断延伸不必一次全对------极端用例会替你暴露缺什么。
6. 终极检查清单
写任何回溯题之前,按这张表过一遍:
- 范式:要"所有方案"才用回溯;只要"能不能/多少种"去想 DP
- 决策:看着答案长出来,每层一个原子动作;位置能空 → 要/不要,不能空 → 选一个/切一段
- 四检验:原子性(移动+匹配不可拆)?中间态合法(拆开的状态题目里存在吗)?覆盖完整?约束局部可判?
- 判终 :"到达结尾"和"满足要求"是且不是或
- 收集:拷贝快照(slice 必须拷贝,string 不用);要所有方案就收集后继续,别短路
- 还原:做选择/递归/还原三连,一个不能少;做了多少动作就撤销多少(N 皇后是四动作对)
- 去重:题面有"含重复元素"→ 排序 + 同层去重;不能排序 → 层内哈希集合
- 剪枝 :合法性(这个选择非法)+ 可行性(子树必死)两层都要想;break 的资格是单调性(排序给的,或切割长度天然给的),不是排序本身
- 记忆化 :状态与路径分离吗?分离 → 缓存结论(能否/产出/长度),不缓存选择
总结
下半场的学习轨迹:
139 范式陷阱 + 417 万次调用的超时现场(记忆化判据立住)
↓
140 记忆化三次错位(缓存选择 → 缓存候选 → 缓存结论)
↓
491 排序被禁(去重第四载体:层内集合)
↓
79 中间态失控(1GB 栈溢出,粒度过碎的翻车)
↓
51 N 皇后(状态设计崩塌 → 对角线编号 → 三个标记数组)
↓
真正毕业
感触最深的三个:
模板是廉价的,决策设计才是回溯的灵魂。所有翻车里,没有一次是"循环三连写错了",全是栽在"每层决策是什么"这个上游问题上------粒度过粗(括号)、粒度过碎(网格)、模型错配(要不要滥用)、中间态失控(step 机制)。
状态设计是第二灵魂。下半场的三记重锤(记忆化、去重第四载体、中间态)全都指向同一件事:状态是什么、状态和路径什么关系、状态怎么表示。把这三问想清楚,一半的坑在写代码之前就能排掉。
同一个考点会在不同题里反复伏击你。去重四连漏、记忆化三连错------每次都是"看懂了题解"和"能对着别人讲明白"之间的差距。错题不能只是看懂,要能推演(8 条路径表)、要能量化(417 万次调用)、要能重写(每一节的修正版我都重新写了一遍)。
说实话,这个过程比想象中难受,每个新题型都有新错法。但也真实感受到了收敛:从括号题整题建模错误,到 N 皇后崩塌后一次重写就过解数序列验证。算法能力就是这么一点点磨出来的,没有银弹。以后还需要继续努力。加油!
最后留一个接口:本篇第一章"记忆化的三次错位"里,"缓存结论、状态与路径分离、多条路径坍缩到同一状态"------这三句话正是动态规划的入场券。回溯加记忆化再去掉递归,就是 DP。接下来的动态规划系列,就从这里出发。
参考资料
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。