1. 介绍
回溯是 DFS 的一种技巧,通过试错的思想,尝试从可行解中选择一个路径,照着路径得出结果后再验证结果是否可行,如果结果不是自己想要的,再回退从其他可行解中进行选择
本质是一种穷举,最多加上一些剪枝操作,尽可能早排除一些肯定不正确的路径,不过依然无法改变它是穷举的事实
回溯法可以抽象成一棵高度有限的 N 叉树,通常可行解个数构成树的子树,递归深度构成树的高度
回溯法解决的问题是,在集合中查找子集,这个子集可能是符合条件的某条路径,也可能是某种叶节点
2. 模板
本质是使用 DFS 进行穷举,在进入下一次递归前选择一种状态,并在递归后需要撤销该状态
其模板如下:
func dfs(参数) {
if 终止条件 {
存放结果
return
}
for 遍历每个当前可行解 {
设置状态
dfs(参数)
撤销状态
}
}
for 中遍历可行解是不同集合间的(可以考虑二叉树的后序遍历,因为只遍历了俩所以没有用 for),递归遍历的是同一个解中的,牢记这个原则进行遍历,实在遇事不决就画出遍历过程图帮助梳理
- 尝试画出遍历过程
- 考虑横向如何 for,需要舍弃哪些,需要保留哪些
- 考虑纵向递归,需要如何向最终结果步进
- 考虑何时收集结果
3. 常见考点
1. 重复选择
重复选择指的是同一个可行解可以被选择多次,所以在传递递归参数时,可以不急着往前走,例如
func dfs(nums []int, idx int) {
for i := idx; i < xxx; i++ {
// 这已经选了当前值
path = append(path, nums[i])
// 传递的递归参数 idx 仍然是当前值,这样可以保障一个数被选择多次
dfs(nums, i)
path = path[:len(path)-1]
}
}
2. 去重
去重的前提是集合中有重复,如果集合中无重复,正常递归往下走,是不会出现重复的
去重通常分两种:
- 不同结果集之间去重,比如 [1,2,5,1] 中和为 8 的组合,[1,2,5] 和 [2,5,1] 两个解就重复了
- 定义范围:不同结果集的遍历是通过 for 进行的
- 定义问题:保障相同解在 for 中被剪枝
- 定义解决办法:在同一个 for 中,有多个相同值时,只应该选择其中一个,其余应该被剪枝,且不会出现在其他解的其他层 for 中被放入解,以下三个缺一不可
- 排序,排序能保证相同值被放在一起
- 每次递归向前走,排序 + 每次递归向前走,保证让重复值在同一层有统一入口,不会在 [1,2,5,1] 中出现 [1,2,5] 解第一层选 1,而 [2,5,1] 解在第三层选 1,只会出现 [1,2,5,1] 排序为 [1,1,2,5] 第一层中,都能以 1 为入口,这样就方便控制在 for 中去重了
- for 中去重,可以在单个递归中使用 map,对当前已遍历过的数记录,如果有重复就剪枝
func 业务函数(nums []int, target int) int {
// 1. 排序
sort.Ints(nums)
dfs(nums, 0)
...
}
func dfs(nums []int, idx int) {
// 2. 去重
m := make(map[int]bool)
for i := idx; i < len(nums); i++ {
if _, ok := m[nums[idx]]; ok {
continue
}
m[nums[i]] = true
...
// 3. 向前走
dfs(nums, i+1)
...
}
}
- 同一结果集中去重,比如
3. 剪枝
回溯的本质就是穷举,复杂度很高,例如求子集 O(2),求排列数 O(n!),所以需要在穷举过程中将一些肯定不可能达成的分支排除掉
4. 常见题型
1. 组合
组合问题就是在一个集合中,按一定规律找出它的子集,收集的树的叶节点,所以是在终止条件中进行
思考方向大概是:
- 考虑可行解 for 要遍历的区间
- 是否重复选择
- 是否需要去重
其模板为:
// res 为全局变量方便一点
var res [][]int
func xxx(nums []int, sum int) [][]int {
// 一定要初始化
res = [][]int{}
dfs(nums, sum, 0, make([]int, 0))
return res
}
// idx 代表遍历 nums 集合的进度,依靠它来缩小横向 for 解空间集
// path 负责收集结果
func dfs(nums []int, sum, idx int, path []int) {
// 注意:终止条件有时候不一定就是 "需要收集结果的条件",通常情况下后者是前者的子集,需要考虑清楚
if 终止条件 {
// path 一定要重新 append 到空切片中
res = append(res, append([]int{}, path...))
return
}
// 遍历解空间,横向 for
for i := idx; i < len(nums); i++ {
path = append(path, nums[i])
// 遍历解空间,纵向递归
dfs(...)
path = path[:len(path)-1]
}
}
77. 组合
for 中遍历当前层可行解,递归选择一个可行解进入下一层
var res [][]int
func combine(n int, k int) [][]int {
res = [][]int{}
dfs(n, k, 0, make([]int, 0, k))
return res
}
func dfs(n, k, idx int, path []int) {
if len(path) == k {
res = append(res, append([]int{}, path...))
return
}
for i := idx+1; i <= n; i++ {
path = append(path, i)
dfs(n, k, i, path)
path = path[:len(path)-1]
}
}
17. 电话号码的字母组合
for 中对某一个数的多个数字进行选择,递归对不同数字进行选择
var res []string
var m map[byte][]byte
func letterCombinations(digits string) []string {
res = []string{}
m = map[byte][]byte{
'2': {'a', 'b', 'c'},
'3': {'d', 'e', 'f'},
'4': {'g', 'h', 'i'},
'5': {'j', 'k', 'l'},
'6': {'m', 'n', 'o'},
'7': {'p', 'q', 'r', 's'},
'8': {'t', 'u', 'v'},
'9': {'w', 'x', 'y', 'z'},
}
if digits == "" {
return res
}
dfs(digits, 0, "")
return res
}
func dfs(digits string,idx int, path string) {
if idx == len(digits) {
res = append(res, path)
return
}
nums := m[digits[idx]]
for i := 0; i < len(nums); i++ {
path = path + string(nums[i])
dfs(digits, idx+1, path)
path = path[:len(path)-1]
}
}
39. 组合总和
注意题干提出两个要求:
- 数字可以重复选择,所以进入递归时,idx 的值不需要变化
- 解集不包含重复组合,nums 本身就无重复元素,所以这条不需要特殊处理,如果 nums 本身有重复元素,那需要进行集合间去重(参考3.2去重)
var res [][]int
func combinationSum(nums []int, sum int) [][]int {
res = [][]int{}
dfs(nums, 0, sum, make([]int, 0))
return res
}
func dfs(nums []int, idx, sum int, path []int) {
if sum <= 0 {
if sum == 0 {
res = append(res, append([]int{}, path...))
}
return
}
for i := idx; i < len(nums); i++ {
sum -= nums[i]
path = append(path, nums[i])
dfs(nums, i, sum, path)
path = path[:len(path)-1]
sum += nums[i]
}
}
40. 组合总和 II
注意题干要求:
- 每个数只能使用一次,所以递归时往前走一步
- 解集不包含重复组合,进行集合间去重(三步曲)
var res [][]int
func combinationSum2(nums []int, sum int) [][]int {
res = [][]int{}
sort.Ints(nums)
dfs(nums, 0, sum, make([]int, 0))
return res
}
func dfs(nums []int, idx, sum int, path []int) {
if sum <= 0 {
if sum == 0 {
res = append(res, append([]int{}, path...))
}
return
}
m := make(map[int]struct{})
for i := idx; i < len(nums); i++ {
if _, ok := m[nums[i]]; ok {
continue
}
m[nums[i]] = struct{}{}
sum -= nums[i]
path = append(path, nums[i])
dfs(nums, i+1, sum, path)
path = path[:len(path)-1]
sum += nums[i]
}
}
216. 组合总和 III
注意题干要求:
- 集合中不存在重复元素
- 解集不包含重复组合
由于 nums 本身无重复元素,所以不用去重,正常递归往下走就行
var res [][]int
func combinationSum3(k int, n int) [][]int {
res = [][]int{}
dfs(k, n, 1, make([]int, 0))
return res
}
func dfs(k, n, idx int, path []int) {
if n <= 0 {
if n == 0 && k == len(path) {
res = append(res, append([]int{}, path...))
}
return
}
for i := idx; i <= 9; i++ {
n -= i
path = append(path, i)
dfs(k, n, i+1, path)
path = path[:len(path)-1]
n += i
}
}
2. 切割
131. 分割回文串
横向 for 中的搜索是将当前字符串截长度1..n,纵向递归进去是截取后剩余字符串,截取后进入递归前判断截取的字符串是否是回文,不是就剪枝
var res [][]string
func partition(s string) [][]string {
res = [][]string{}
dfs(s, make([]string, 0))
return res
}
func dfs(s string, path []string) {
if 0 == len(s) {
res = append(res, append([]string{}, path...))
return
}
for i := 1; i <= len(s); i++ {
sub := s[:i]
if !check(sub) {
continue
}
path = append(path, sub)
dfs(s[i:], path)
path = path[:len(path)-1]
}
}
func check(sub string) bool {
for i := 0; i < len(sub)/2; i++ {
if sub[i] != sub[len(sub)-i-1] {
return false
}
}
return true
}
93. 复原 IP 地址
和前面分割回文串本质一样,不过复原 IP 地址相当于只能切割四次,第四次必须得切在最后,且切割出的 IP 地址有效
var res []string
func restoreIpAddresses(s string) []string {
res = []string{}
if len(s) > 12 {
return res
}
dfs(4, s, "")
return res
}
func dfs(times int, s, path string) {
if len(s) == 0 || times == 0 {
// 第四次必须切在最后
if times == 0 && len(s) == 0 {
// path 结尾会多一个 ".",去除
res = append(res, path[:len(path)-1])
}
return
}
for i := 1; i <= len(s); i++ {
if i > 3 {
return
}
sub := s[:i]
if !check(sub) {
continue
}
dfs(times-1, s[i:], path+sub+".")
}
}
func check(s string) bool {
// "0" 开头特判
if len(s) != 1 && s[0] == '0' {
return false
}
num, _ := strconv.Atoi(s)
if num >= 0 && num <= 255 {
return true
}
return false
}
3. 子集
相比于组合问题是求树的叶子节点,子集问题求的是树的所有节点
78. 子集
为了不重复,需要递归的时候往前走
var res [][]int
func subsets(nums []int) [][]int {
res = [][]int{}
dfs(0, nums, make([]int, 0))
return res
}
func dfs(idx int, nums, path []int) {
res = append(res, append([]int{}, path...))
for i := idx; i < len(nums); i++ {
path = append(path, nums[i])
dfs(i+1, nums, path)
path = path[:len(path)-1]
}
}
90. 子集 II
满足前提数组中包含重复元素,解集间不能重复,可以使用去重三部曲:
- 排序
- 去重
- 向前走
var res [][]int
func subsetsWithDup(nums []int) [][]int {
res = [][]int{}
sort.Ints(nums)
dfs(0, nums, make([]int, 0))
return res
}
func dfs(idx int, nums, path []int) {
res = append(res, append([]int{}, path...))
m := make(map[int]bool)
for i := idx; i < len(nums); i++ {
if _, ok := m[nums[i]]; ok {
continue
}
m[nums[i]] = true
path = append(path, nums[i])
dfs(i+1, nums, path)
path = path[:len(path)-1]
}
}
4. 全排列
全排列也是求树的叶子节点
46. 全排列
nums 的 for 遍历中,for 的集合是 nums 中还未被 path 选中的数,所以需要使用 used 数组来记录哪些被选过了
var res [][]int
func permute(nums []int) [][]int {
res = [][]int{}
dfs(nums, make([]int, 0), make([]bool, len(nums)))
return res
}
func dfs(nums, path []int, used []bool) {
if len(path) == len(nums) {
res = append(res, append([]int{}, path...))
return
}
for i := 0; i < len(nums); i++ {
if used[i] {
continue
}
path = append(path, nums[i])
used[i] = true
dfs(nums, path, used)
used[i] = false
path = path[:len(path)-1]
}
}
47. 全排列 II
数组中有重复元素,要求结果集间去重,在上面全排序的基础上进行去重三部曲:
- 排序
- 递归向前,这里通过被选入 path 来推动
- for 中去重,m 这个 map
var res [][]int
func permute(nums []int) [][]int {
res = [][]int{}
sort.Ints(nums)
dfs(nums, make([]int, 0), make([]bool, len(nums)))
return res
}
func dfs(nums, path []int, used []bool) {
if len(path) == len(nums) {
res = append(res, append([]int{}, path...))
return
}
m := make(map[int]bool)
for i := 0; i < len(nums); i++ {
if used[i] {
continue
}
if _, ok := m[nums[i]]; ok {
continue
}
m[nums[i]] = true
path = append(path, nums[i])
used[i] = true
dfs(nums, path, used)
used[i] = false
path = path[:len(path)-1]
}
}