文章

【算法】回溯算法(二):五个坑与一次验收——决策、切片与去重

【算法】回溯算法(二):五个坑与一次验收——决策、切片与去重 摘要 回溯系列第二篇,翻车实录上半场。上一篇沉淀的模板十分钟就能背下来,但背下来和会用是两回事——本篇记录我在五道题上真实翻车的经历:决策粒度过粗(括号生成缺解)、Go 切片引用(全排列丢一半结果)、"要/不要"模型滥用(三道题同一个错)、同层去重(同一考点三次没接住,含 [1,2,2] 怎么生成的 8 条路径推演)、范式识别(该用 D

【算法】回溯算法(二):五个坑与一次验收——决策、切片与去重

文章信息

  • 原文链接:https://jiayq.blog.csdn.net/article/details/164337469
  • 发布时间:2026-09-16 16:37:42
  • 标签:#Golang, #算法, ##回溯决策

【算法】回溯算法(二):五个坑与一次验收——决策、切片与去重

摘要

回溯系列第二篇,翻车实录上半场。上一篇沉淀的模板十分钟就能背下来,但背下来和会用是两回事——本篇记录我在五道题上真实翻车的经历:决策粒度过粗 (括号生成缺解)、Go 切片引用 (全排列丢一半结果)、“要/不要”模型滥用 (三道题同一个错)、同层去重 (同一考点三次没接住,含 [1,2,2] 怎么生成的 8 条路径推演)、范式识别 (该用 DP 的题硬上回溯)。每个坑都有错误代码、实测证据和修正方案,最后用 LC842 斐波那契拆分做一次毕业验收。坑虽多,主题只有两个:决策怎么设计、重复怎么消灭

说明,以下环境基于 go1.21+,题目均来自 LeetCode。前置阅读:回溯算法(一):从一道 IP 题到万能模板。配套代码仓库(按题号分目录):https://github.com/a18792721831/studyleetCode

系列篇目:

1. 翻车一:决策粒度(LC22 括号生成)

1.1 错误代码

括号生成:输入 n,输出所有合法的括号组合。我第一版是这样想的——每次决策”往结果里放几对 配好对的括号”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func generateParenthesis(n int) []string {
	var gener func(s string)
	res := make([]string, 0)
	gener = func(s string) {
		if len(s) == n*2 {
			res = append(res, s)
			return
		}
		for idx := len(s)/2; idx <= n; idx++ {
			tmpS, right := "", ""
			if len(s) >= 2*idx { // 这行是硬凑的,我自己都说不清为什么
				continue
			}
			for i := 0; i < idx; i++ { // 一次造一整块 "(((...)))"
				tmpS += "("
				right += ")"
			}
			s += tmpS + right
			gener(s)
			s = s[:len(s)-idx*2]
		}
	}
	gener("")
	return res
}

跑出来:n=2 只输出 [(())],缺 ()()n=3 只输出 2 个,缺 3 个。

1.2 为什么缺解

这个模型每层塞进去的是一块已经配好对((())),它只能生成”完整块的顺序拼接”。但合法括号串的本质结构是嵌套 ,看 (()())

1
2
(()()) = "(" + "()" + ")" + "()"

它没法分解成若干个”各自配对完整的块”的拼接——在这个决策树里,它根本没有对应的路径 。所以缺解。

1.3 正确姿势:看着答案长出来

后来我学到一个方法:别盯着代码想决策,盯着一个最终答案,模拟它怎么一步步”长”出来——每长出一个原子单元,就是一层递归

(()()) 的长法:位置 0 放 ( → 位置 1 放 ( → 位置 2 放 ) → …… 每层放一个字符 。配上两个计数约束:

想放| 前提| 含义
—|—|—
(| open < n| 左括号没超预算
)| close < open| 任何前缀里右括号不能多于左括号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func generateParenthesis(n int) []string {
	res := []string{}
	var backtrack func(path []byte, open, close int)
	backtrack = func(path []byte, open, close int) {
		if len(path) == 2*n {
			res = append(res, string(path)) // string() 转换本身就是拷贝
			return
		}
		if open < n {
			path = append(path, '(')
			backtrack(path, open+1, close)
			path = path[:len(path)-1]
		}
		if close < open {
			path = append(path, ')')
			backtrack(path, open, close+1)
			path = path[:len(path)-1]
		}
	}
	backtrack([]byte{}, 0, 0)
	return res
}

close < open 这条约束保证了走到叶子的每条路径都合法——把合法性编码进选择条件,非法分支压根不进树。

记住这个直觉:决策里出现”循环构造一段东西”,几乎必然粒度过大 。这是血的教训。

2. 翻车二:Go 切片引用(LC46 全排列)

这个坑和算法无关,但 Go 玩回溯必踩。我写的全排列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func permute(nums []int) [][]int {
	res := make([][]int, 0)
	var backtrack func(path []int)
	backtrack = func(path []int) {
		if len(path) == len(nums) {
			res = append(res, path) // ← bug 在这一行
			return
		}
		for _, v := range nums {
			if isHave(path, v) {
				continue
			}
			path = append(path, v)
			backtrack(path)
			path = path[:len(path)-1]
		}
	}
	backtrack([]int{})
	return res
}

逻辑全对,但 res = append(res, path) 存进结果的是切片头(指针+len+cap),不是数据快照 。回溯继续跑,path 的底层数组被原地覆盖写,之前收集的结果跟着一起变。

2.1 实测证据

加上指针追踪跑一遍,铁证如山:

1
2
3
4
5
6
7
8
9
10
11
收集时刻: [5 4 6 2]  底层数组=0x...180
收集时刻: [5 4 2 6]  底层数组=0x...180   ← 和上一个指向同一块内存!
收集时刻: [5 6 4 2]  底层数组=0x...1c0
收集时刻: [5 6 2 4]  底层数组=0x...1c0
...
=== permute 返回后,最终 res 内容 ===
res[0] = [5 4 2 6]  ← 本应是 [5 4 6 2],被覆盖了!
res[1] = [5 4 2 6]
res[2] = [5 6 2 4]  ← 本应是 [5 6 4 2],被覆盖了!
...

24 个结果两两重复,只剩 12 个不同值,一半的排列永久丢失

2.2 原因:append 的扩容轨迹

path 长度从 0 涨到 4,底层数组经历 cap 0 → 1 → 2 → 4 三次扩容。关键在第三次:进入第三层递归时扩容出的 cap=4 数组,被”前两段固定”的所有选择复用 ——第三段从 6 换成 2 时是原地覆盖写(cap 足够,不重新分配)。两次收集指向同一个数组,数组残留最后一次写入的值。

2.3 修复:一行

1
2
3
4
5
6
7
if len(path) == len(nums) {
    tmp := make([]int, len(path))
    copy(tmp, path)      // 收集时拷贝快照,切断与回溯共享数组的联系
    res = append(res, tmp)
    return
}

go1.21+ 可以直接 res = append(res, slices.Clone(path))

一个经验:string 收集不需要拷贝 (Go 的 string 不可变,append(res, s) 天然安全),必须拷贝的是可变类型 (slice / []byte)。所以回溯的 path 用 []byte,收集时 string(path) 一步到位——转换本身就是拷贝。这也解释了为什么括号生成那版没踩这个坑:string(path) 顺手把拷贝做了。

3. 翻车三:”要/不要”的滥用(LC47 / LC17)

我在子集、全排列 II、电话号码三道题上犯了同一个 错:把”要/不要”当万能模型。

3.1 全排列不是”要不要”

我给 LC47 设计的决策是”每个元素要不要进结果集”。但全排列要求所有元素都用上 ——终止条件是”所有元素都在临时结果集里”。那”不要”的分支走到最后怎么满足终止条件?自相矛盾了

排列的正确决策是每个位置选一个没用过的元素 (或者反过来,每个元素选一个位置)。

3.2 电话号码不是”要不要”

LC17 我设计成”每个字母取不取”,比如 ad = a 取、b 不取、c 不取、d 取、e 不取、f 不取。但既然”都不取”对每个字母都是合法决策,那 2 的 a/b/c 都不取 这条路径就存在——可输出里没有”只有 d”的答案。模型自己打自己。

事实是:每个数字必须恰好贡献一个字母 ,位置不能空着。决策是”选一个”:每层面对一个数字,从它对应的字母里必选其一。

3.3 病根

读题后先做一个 10 秒判断:“这个位置能空着吗?” 不能空(电话的数字必须贡献字母、排列的每个位置必须有数)→ 选一个;能空(子集里元素可以不进)→ 要/不要。一句话能避免三次翻车。

4. 翻车四:去重三连漏(LC47 → LC40 → LC90)

这是我最顽固的坑,同一考点三次没接住。

4.1 结果级去重为什么不行

LC47 时我的方案是”收集时查重,结果集里已存在就忽略”;LC90 时我又说”不排序,结果集去重,简单实现”。两个问题:

  1. 树照样全走 ,重复子树整棵白搜,指数级的路白跑
  2. 实现上恰恰更复杂 :结果级去重要把每个子集序列化成 key 存哈希(排序拼字符串或数组做 map key),而树层去重只需要一行 if

4.2 同层去重:三题一表

三道题,同一个考点,三种载体:

模型去重条件前提
47 全排列 II选一个 + usednums[i]==nums[i-1] && !used[i-1] → skip排序
40 组合总和 II选一个 + starti > start && nums[i]==nums[i-1] → skip排序
90 子集 II要/不要i>0 && nums[i]==nums[i-1] && !used[i-1] → 不选排序

共同本质:排序让相同值相邻,然后保证同一层里相同值只允许走第一个

4.3 最难的一问:去重后,[1,2,2] 怎么生成?

这是我当时最大的困惑:既然去重拦相同值,那包含两个 2 的子集 [1,2,2] 还出得来吗?

答案是:去重拦的是”同层横向”,不拦”同枝纵向” 。把 [1,2,2](记两个 2 为 2a、2b)的 8 条路径全列出来:

路径结果判定
要1 → 要2a → 要2b[1,2,2]✓ 保留
要1 → 要2a → 不要2b[1,2]✓ 保留([1,2]唯一 生成路径)
要1 → 不要2a → 要2b[1,2]✗ 剪掉
要1 → 不要2a → 不要2b[1]✓ 保留
不要1 → 要2a → 要2b[2,2]✓ 保留
不要1 → 要2a → 不要2b[2]✓ 保留
不要1 → 不要2a → 要2b[2]✗ 剪掉
不要1 → 不要2a → 不要2b[]✓ 保留

剪掉 2 条,剩 6 个恰好是全部去重子集。画成树:

要 1

不要 1

要 2a

不要 2a

要 2b(同枝)

不要 2b

要 2b

不要 2b

要 2a

不要 2a

要 2b(同枝)

不要 2b

要 2b

不要 2b

开始

[1]

[]

[1,2]

[1]

[1,2,2] ✓

[1,2] ✓

✗ 同层重复,剪掉

[1] ✓

[2]

[]

[2,2] ✓

[2] ✓

✗ 同层重复,剪掉

[] ✓

去重条件里的 !used[i-1] 就是判别同枝还是同层的开关

  • 生成 [1,2,2] 时:面对 2b,2a 还在 path 里used[2a]==true)→ 条件不成立 → 放行 。这是同枝纵向,两个 2 是”都要”的嵌套关系
  • 被剪的路径:面对 2b 时,2a 被跳过了used[2a]==false)→ 条件成立 → 拦掉”要 2b”。这是同层横向,”跳过 2a 选 2b”和”选 2a 跳过 2b”产出完全一样

本质一句话:去重后每个子集只剩唯一生成路径,对连续相同值只许取前 k 个,不许跳着取[1,2,2] 是”取前 2 个”,[1,2] 是”取前 1 个”,[1] 是”取前 0 个”——全合法;被禁止的只有”取后面的不取前面的”。

4.4 识别信号

看到题面”可能含重复元素 “或”解不能重复 “,第一反应就是上一节那张三题表。

5. 翻车五:范式识别(LC139 陷阱)

单词拆分(LC139),我按回溯建模(“从字典里选词/切段”)。建模本身不算错,但这题根本不该用纯回溯 ——会超时(超时的完整现场在下一篇)。

关键洞察:s 长度到 300,决策树里同一个 start 会被海量重复到达 ——不同路径切到同一个位置,然后从那里重新枚举拆法。而”从 start 开始能否拆完”的答案是确定的,与怎么到达无关 ——这就是无后效性,是 DP 的入场券。

一个杀手级对照,一字之差范式切换:

  • 139 单词拆分 :“能否拆分” → 只要可行性 → DP
  • 140 单词拆分 II :“返回所有拆分方案” → 要枚举 → 回溯 (方案数指数级,DP 折叠的状态存不下每个方案的样子)

识别口诀:只要”能不能/多少种/最优值”用 DP,要”方案/所有”才回溯 。再补一个隐藏信号:n ≤ 20 左右暗示暴力搜索可接受 (答案空间 2^n / n! / C(n,k) 级),n ≥ 10^4 基本和回溯无缘。

问法范式
返回所有 方案回溯
只要”能不能”DP(或记忆化回溯)
DP / 贪心
求方案数量DP / 组合数学

6. 第一次毕业验收:LC842 将数组拆分成斐波那契序列

用一道综合题验收第一段学习。输入数字字符串,把它拆成斐波那契式序列(每项 = 前两项之和),返回拆出的序列。

设计四件套:

要素| 设计
—|—
决策| 下一段切几位(切割类,1~10 位防溢出)
状态| start(切到哪)+ path(已切出的数,只需看最后两个)
合法性剪枝①| 前导零:"0" 合法,"01" 非法——用 break0 开头且长度 >1,更长的段也全非法,整层不用再试
合法性剪枝②| 前两层自由切 (无和约束),第三段起切出的值必须 == path 最后两个之和
终止| 切完整串且至少 3 个数
短路| 存在性搜索:找到就返回,且不还原 ——path 保持完整直接当答案上浮

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
func splitIntoFibonacci(num string) []int {
	n := len(num)
	// 返回 nil 表示失败;成功返回完整方案
	var backtrack func(start int, path []int) []int
	backtrack = func(start int, path []int) []int {
		// 判终:切完整串,至少 3 个数
		if start == n {
			if len(path) >= 3 {
				return append([]int{}, path...) // 拷贝收集
			}
			return nil
		}
		// 决策:下一段切 1~10 位
		for length := 1; length <= 10 && start+length <= n; length++ {
			seg := num[start : start+length]
			// 合法性剪枝①:前导零(break,更长的也非法)
			if len(seg) > 1 && seg[0] == '0' {
				break
			}
			val, _ := strconv.ParseInt(seg, 10, 64)
			// 合法性剪枝②:第三段起,必须等于前两段之和
			if len(path) >= 2 && val != int64(path[len(path)-1]+path[len(path)-2]) {
				continue
			}
			// 做选择
			path = append(path, int(val))
			if ans := backtrack(start+length, path); ans != nil {
				return ans // 短路:不还原,带着答案直接上浮
			}
			// 还原
			path = path[:len(path)-1]
		}
		return nil
	}
	return backtrack(0, []int{})
}

两个值得看两遍的细节:前导零的 break (一段 0 开头就整层死刑)和短路时不还原return ans 跳过了弹出,path 完整地就是答案)。

到这里我以为毕业了。事实证明,这只是下半场的开始——接下来三记重锤,锤出了比前五次更值钱的东西。

总结

上半场的学习轨迹:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
IP 题(照着模板写对)
   ↓
括号题翻车(决策粒度过粗:一步塞一块)
   ↓
全排列踩出切片引用的坑(收集必须拷贝)
   ↓
"要不要"三次误用(47/17/40)
   ↓
去重三连漏(47 → 40 → 90 同一考点)
   ↓
139 范式陷阱(该用 DP 的题硬上回溯)
   ↓
842 毕业验收(切割 + 双剪枝 + 短路收集)

上半场的坑集中在两件事:决策怎么设计 (粒度过粗、模型错配——五坑里三个是它的变体)和重复怎么消灭 (去重三连漏)。两个主题分别指向回溯的两条命脉:模板里的”选择列表”和”同层只走第一个”。

自以为毕业的下一个小时,139 的超时现场(417 万次调用)就把我打回了原形——那三记重锤,在下一篇。

参考资料


版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

本文由作者按照 CC BY 4.0 进行授权