回溯专题

368 阅读5分钟

1. 介绍

回溯是 DFS 的一种技巧,通过试错的思想,尝试从可行解中选择一个路径,照着路径得出结果后再验证结果是否可行,如果结果不是自己想要的,再回退从其他可行解中进行选择
本质是一种穷举,最多加上一些剪枝操作,尽可能早排除一些肯定不正确的路径,不过依然无法改变它是穷举的事实
回溯法可以抽象成一棵高度有限的 N 叉树,通常可行解个数构成树的子树,递归深度构成树的高度
回溯法解决的问题是,在集合中查找子集,这个子集可能是符合条件的某条路径,也可能是某种叶节点

2. 模板

本质是使用 DFS 进行穷举,在进入下一次递归前选择一种状态,并在递归后需要撤销该状态
其模板如下:

func dfs(参数) {
    if 终止条件 {
        存放结果
        return
    }
    
    for 遍历每个当前可行解 {
        设置状态
        dfs(参数)
        撤销状态
    }
}

for 中遍历可行解是不同集合间的(可以考虑二叉树的后序遍历,因为只遍历了俩所以没有用 for),递归遍历的是同一个解中的,牢记这个原则进行遍历,实在遇事不决就画出遍历过程图帮助梳理

  1. 尝试画出遍历过程
  2. 考虑横向如何 for,需要舍弃哪些,需要保留哪些
  3. 考虑纵向递归,需要如何向最终结果步进
  4. 考虑何时收集结果

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. 组合

组合问题就是在一个集合中,按一定规律找出它的子集,收集的树的叶节点,所以是在终止条件中进行
思考方向大概是:

  1. 考虑可行解 for 要遍历的区间
  2. 是否重复选择
  3. 是否需要去重

其模板为:

// 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. 电话号码的字母组合

image.png
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. 组合总和

注意题干提出两个要求:

  1. 数字可以重复选择,所以进入递归时,idx 的值不需要变化
  2. 解集不包含重复组合,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

注意题干要求:

  1. 每个数只能使用一次,所以递归时往前走一步
  2. 解集不包含重复组合,进行集合间去重(三步曲)
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

注意题干要求:

  1. 集合中不存在重复元素
  2. 解集不包含重复组合

由于 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. 分割回文串

image.png
横向 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. 子集

image.png
为了不重复,需要递归的时候往前走

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

满足前提数组中包含重复元素,解集间不能重复,可以使用去重三部曲:

  • 排序
  • 去重
  • 向前走

image.png

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. 全排列

image.png
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

image.png
数组中有重复元素,要求结果集间去重,在上面全排序的基础上进行去重三部曲:

  • 排序
  • 递归向前,这里通过被选入 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]
    }
}