参考: 代码随想录: 回溯算法理论基础
回溯三部曲
- 递归函数参数
- 递归终止条件
- 单层搜索逻辑
通用模版
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
组合问题
- leetcode 39 组合总和
- 注意每次递推的起点, 需要使用 startIndex (i + 1)去避免重复的问题
- leetcode 40 组合总和 II
- 涉及到去重的问题, candidates[i] === candidates[i - 1] 的情况则需要continue
- 但是去重只需要树层去重, 而不需要树枝去重, 所以使用used来判断, 当used[i - 1] = false的时候证明是同一层级重复,则continue
- 而used[i - 1] = true, 则证明是树枝层级下重复, 则不影响
if(i > 0 && candidates[i] === candidates[i - 1] && used[i - 1] === false) continue;
- leetcode 216 组合总和 III
- 需要额外的 sum 变量,每次添加的元素的时候加上当前元素
- 当 sum 大于 n 的时候直接返回, 因为后面都是无效的
- 当 sum 等于 n 并且集合长度等于 k 时, 收集结果
if(sum > n) return
if(temp.length === k && sum === n){
result.push([...temp])
return
}
切割问题
需要注意三个问题:
- 如何模拟切割线
- 如何终止
- 如何截取子串
- leetcode 131 分割回文串
- 终止条件: 当 index 等于给出字符串 s 的长度时,证明已经全部分割好, 可以收集结果
- 单层的时候进行切割, 每次切割的结果进行验证, 符合则推入 temp, 不符合则 continue
for(let i = index; i < s.length; i++){
if(!check(s, index, i)) continue
temp.push(s.slice(index, i + 1))
backtracking(i + 1)
temp.pop()
}
- leetcode 93 复原 IP 地址
- 终止条件: IP地址为四部分, 所以当 temp 长度等于4并且 index = s.length 也就是遍历完, 进行结果收集
- 单层的时候进行切割, 每次切割的结果进行验证, 符合则推入 temp, 不符合则 continue
- 验证: 长度大于1并且前导值为0, 长度大于3或者数值大于255时则break, 因为已经不符合IP
for(let i = start; i < s.length; i++){
let str = s.slice(start, i + 1)
if(str.length > 3 || +str > 255) break;
if(str.length > 1 && str[0] === "0") break;
temp.push(str)
backtracking(i + 1)
temp.pop()
}
子集问题
- leetcode 78 子集
- 按照模版, 需注意 start 每次加1
- leetcode 90 子集 II
- 去重 (同组合总和 II)
if(i > 0 && nums[i] === nums[i - 1] && used[i - 1] == false) continue
排列问题
- leetcode 46 全排列
- 可以创建 used 来记录使用过的元素, 避免重复元素进栈
if(used[i] === 1) continue
temp.push(nums[i])
used[i] = 1
backtracking()
used[i] = 0
temp.pop()
- leetcode 47 全排列 II
- 需要同一层级去重
- 可以在同层级下创建一个 set 并且每次记录当前的元素, 如果元素已存在则 continue
let set = new Set()
for(let i = 0; i < nums.length; i++){
if(used[i] === 1 || set.has(nums[i])) continue
temp.push(nums[i])
set.add(nums[i])
used[i] = 1
backtracking()
used[i] = 0
temp.pop()
}
其他问题
- leetcode 491 递增子序列
- 验证后一个需要比前一个元素大, 也就是 num[i] > temp[temp.length - 1]
- 使用used去重
const used = []
for(let i = start; i < nums.length; i++){
if(temp.length > 0 && nums[i] < temp[temp.length - 1] || used[nums[i] + 100] === true) continue
temp.push(nums[i])
used[nums[i] + 100] = true
backtracking(i + 1)
temp.pop()
}
- leetcode 17 电话号码的组合
- 终止条件: temp 和 digits 的长度一致则收取结果
- 需要使用 startIndex, 并且每次递归查询 map 是否存在当前数字
if(index > digits.length || !map.has(digits[index])) return
let arr = map.get(digits[index])
for(let i = 0; i < arr.length; i++){
temp.push(arr[i])
backtracking(index + 1)
temp.pop()
}
性能分析
子集问题分析:
- 时间复杂度: O(2^n), 因为每一个元素的状态无外乎取与不取,所以时间复杂度为O(2^n)
- 空间复杂度:O(n),递归深度为n,所以系统栈所用空间为O(n),每一层递归所用的空间都是常数级别,注意代码里的result和path都是全局变量,就算是放在参数里,传的也是引用,并不会新申请内存空间,最终空间复杂度为O(n)
- 讲解
- 数的每一层延伸的个数是 2 的次方, 所以选择的元素 N 就说明有 2 ^ N
排列问题分析:
- 时间复杂度:O(n!),这个可以从排列的树形图中很明显发现,每一层节点为n,第二层每一个分支都延伸了n-1个分支,再往下又是n-2个分支,所以一直到叶子节点一共就是 n * n-1 * n-2 * … 1 = n!。
- 空间复杂度:O(n),和子集问题同理。
- 讲解
- 第一层递归: 在第一层,我们有 N 个选择(nums 数组中的每个元素)。
- 第二层递归: 在每个选择后,我们进入第二层,此时我们有 N−1 个选择。
- 继续这个过程: 这个过程一直持续到我们达到深度 N 的递归,此时没有剩余元素可供选择。
组合问题分析:
- 时间复杂度:O(2^n),组合问题其实就是一种子集的问题,所以组合问题最坏的情况,也不会超过子集问题的时间复杂度。
- 空间复杂度:O(n),和子集问题同理。