回溯框架
一句话说明
回溯就是在决策树上做 DFS,只不过每走一步都要记得:递归回来后把这一步撤销掉。
先把回溯看成决策树
以 [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]你每往下一层,就是“做一个选择”;
你从递归返回,就是“撤销刚才那次选择”。
Go 模板
这是最通用的回溯骨架:
func backtrack(path *[]int, choices []int, used []bool, result *[][]int) {
if endCondition(*path) {
snapshot := append([]int(nil), (*path)...)
*result = append(*result, snapshot)
return
}
for i, choice := range choices {
if used[i] {
continue
}
// 做选择
*path = append(*path, choice)
used[i] = true
// 递归
backtrack(path, choices, used, result)
// 撤销选择
used[i] = false
*path = (*path)[:len(*path)-1]
}
}回溯三问
每道题都先回答这三个问题:
- 路径是什么?
- 当前可选项有哪些?
- 什么时候算一个完整解?
很多题只要这三件事想明白,代码自然就出来了。
排列问题
特点:
- 顺序重要
- 每个元素通常只能用一次
- 常配合
used数组
func permute(nums []int) [][]int {
result := make([][]int, 0)
path := make([]int, 0, len(nums))
used := make([]bool, len(nums))
var dfs func()
dfs = func() {
if len(path) == len(nums) {
snapshot := append([]int(nil), path...)
result = append(result, snapshot)
return
}
for i := 0; i < len(nums); i++ {
if used[i] {
continue
}
path = append(path, nums[i])
used[i] = true
dfs()
used[i] = false
path = path[:len(path)-1]
}
}
dfs()
return result
}组合问题
特点:
- 顺序不重要
- 为了避免重复,下一层从
start之后继续选
func combine(n, k int) [][]int {
result := make([][]int, 0)
path := make([]int, 0, k)
var dfs func(start int)
dfs = func(start int) {
if len(path) == k {
snapshot := append([]int(nil), path...)
result = append(result, snapshot)
return
}
for i := start; i <= n; i++ {
path = append(path, i)
dfs(i + 1)
path = path[:len(path)-1]
}
}
dfs(1)
return result
}子集问题
特点:
- 每个元素本质上都是“选 / 不选”
- 每个中间节点本身就是一个答案
func subsets(nums []int) [][]int {
result := make([][]int, 0)
path := make([]int, 0)
var dfs func(start int)
dfs = func(start int) {
snapshot := append([]int(nil), path...)
result = append(result, snapshot)
for i := start; i < len(nums); i++ {
path = append(path, nums[i])
dfs(i + 1)
path = path[:len(path)-1]
}
}
dfs(0)
return result
}去重怎么做
子集 / 组合去重
思路是:
- 先排序
- 同一层遇到相同数字时,只用第一个
func subsetsWithDup(nums []int) [][]int {
slices.Sort(nums)
result := make([][]int, 0)
path := make([]int, 0)
var dfs func(start int)
dfs = func(start int) {
snapshot := append([]int(nil), path...)
result = append(result, snapshot)
for i := start; i < len(nums); i++ {
if i > start && nums[i] == nums[i-1] {
continue
}
path = append(path, nums[i])
dfs(i + 1)
path = path[:len(path)-1]
}
}
dfs(0)
return result
}排列去重
排列去重更容易错,因为“同层去重”和“同枝去重”不是一回事。
func permuteUnique(nums []int) [][]int {
slices.Sort(nums)
result := make([][]int, 0)
path := make([]int, 0, len(nums))
used := make([]bool, len(nums))
var dfs func()
dfs = func() {
if len(path) == len(nums) {
snapshot := append([]int(nil), path...)
result = append(result, snapshot)
return
}
for i := 0; i < len(nums); i++ {
if used[i] {
continue
}
// 同一层中,相同元素只用第一个
if i > 0 && nums[i] == nums[i-1] && !used[i-1] {
continue
}
path = append(path, nums[i])
used[i] = true
dfs()
used[i] = false
path = path[:len(path)-1]
}
}
dfs()
return result
}剪枝怎么想
回溯真正的难点往往不是“会不会写模板”,而是“能不能早点停”。
超目标值直接停
func combinationSum(candidates []int, target int) [][]int {
slices.Sort(candidates)
result := make([][]int, 0)
path := make([]int, 0)
var dfs func(start, sum int)
dfs = func(start, sum int) {
if sum == target {
snapshot := append([]int(nil), path...)
result = append(result, snapshot)
return
}
if sum > target {
return
}
for i := start; i < len(candidates); i++ {
if sum+candidates[i] > target {
break
}
path = append(path, candidates[i])
dfs(i, sum+candidates[i])
path = path[:len(path)-1]
}
}
dfs(0, 0)
return result
}排序后,sum + candidates[i] > target 时直接 break,因为后面的数只会更大。
回溯和 DFS 的关系
回溯 = DFS + 做选择 + 撤销选择普通 DFS 更像“遍历”:
- 找连通块
- 走路径
- 判断可达性
回溯更像“枚举所有方案”:
- 排列
- 组合
- 子集
- N 皇后
易错点
result里保存路径时一定要复制,不能直接塞当前切片。- 撤销选择必须和做选择严格成对出现。
- 去重条件要分清“同层”还是“同枝”。
- 组合题常用
start,排列题常用used,别混。
相关主题
返回:搜索算法