回溯可以说是非常常见的一种算法了,诸如子集、组合和排列等问题都可以用回溯算法来解决。回溯算法的本质是递归,而涉及到递归的解法,中间的运算过程总会使笔者难以理解,这个时候一套解题方法论就显得非常重要了。本文将剖析子集问题的解法,将笔者在网上找到的优秀学习资源(详见文末参考链接)与大家分享。
回溯算法解题思想
参考 labuladong 大佬的回溯算法详解,回溯算法的解题框架如下:
result = []
def backtrack(路径, 选择列表):
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
做选择
backtrack(路径, 选择列表)
撤销选择
这个解题框架下我们只需要关注三个概念:
- 路径:已经做出的选择
- 选择列表:当前可以做的选择
- 结束条件:当满足结束条件时,将路径存储到结果中
框架只是提供了一个大概的解法思想,在实际应用中我们往往需要具体问题具体分析,对框架进行微调,比如调整 backtrack
函数的参数定义,再比如考虑剪枝等等。
那么我们要如何确定这个 backtrack
函数的定义呢?
C++ 总结了回溯问题类型 带你搞懂回溯算法(大量例题) 一文中总结了以下几个步骤,笔者认为非常有效:
①画出递归树,找到状态变量(回溯函数的参数),这一步非常重要※
②根据题意,确立结束条件
③找准选择列表(与函数参数相关),与第一步紧密关联※
④判断是否需要剪枝
⑤作出选择,递归调用,进入下一层
⑥撤销选择
子集
题目描述(来自leetcode)
给你一个整数数组 nums ,数组中的元素互不相同。返回该数组所有可能的子集(幂集)。
解集不能包含重复的子集。你可以按 任意顺序 返回解集。
示例 1:
输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
示例 2:
输入:nums = [0]
输出:[[],[0]]
解题步骤
递归树
第一个步骤永远都是先把递归树画出来,方便我们更直观地进行分析。
树中的节点代表选择列表,边代表路径。可以看到,对于某个路径,选择列表永远只有数组中索引值位于它后面的数,比如 [1]
,选择列表有 [2,3]
,而对于 [2]
,选择列表只有 [3]
。因此我们应该采用一个变量定义当前选择路径值的索引值,则 backtrack
函数定义如下:
const backtrack = (path: number[], nums: number[], start: number) => {
// TODO
}
确定结束条件
由图中可以看到每条路径都应该加入到结果集中,因此这里没有结束条件。
const res = []
const backtrack = (path: number[], nums: number[], start: number) => {
res.push([...path])
}
确定选择列表
如「递归树」步骤中分析,对于某个路径,选择列表永远只有数组中索引值位于它后面的数。
const res = []
const backtrack = (path: number[], nums: number[], start: number) => {
res.push([...path])
for (let i = start; i < nums.length; i++) {
}
}
是否需要剪枝
所有路径都应该加入结果集,因此不需要剪枝。
做出选择
其实就是循环里面的逻辑:将选择列表中的数值加入路径,进入下一层。
const res = []
const backtrack = (path: number[], nums: number[], start: number) => {
res.push([...path])
for (let i = start; i < nums.length; i++) {
path.push(nums[i])
backtrack(path, nums, i + 1)
}
}
撤销选择
将路径中入栈的值出栈,状态重置,这个很好理解。
完整代码
function subsets(nums: number[]): number[][] {
const res = []
// 路径,选择列表
const backtrack = (path: number[], nums: number[], start: number) => {
// 满足条件的话将路径放进结果
res.push([...path])
for (let i = start; i < nums.length; i++) {
path.push(nums[i])
backtrack(path, nums, i + 1)
path.splice(path.length - 1, 1)
}
}
backtrack([], nums, 0)
return res
};
子集2
题目描述(来自leetcode)
给你一个整数数组 nums ,其中可能包含重复元素,请你返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。返回的解集中,子集可以按 任意顺序 排列。
示例 1:
输入:nums = [1,2,2]
输出:[[],[1],[1,2],[1,2,2],[2],[2,2]]
示例 2:
输入:nums = [0]
输出:[[],[0]]
解题步骤
递归树
第一步当然还是画个图。可以看到图中出现了一些重复的路径,这意味着我们需要进行剪枝。
结束条件和选择列表和「子集」类似,这里不再赘述。
是否需要剪枝
递归树中看到出现了很多重复的路径,因此可以肯定需要进行剪枝,那么要如何进行剪枝呢?
将需要剪枝的路径使用蓝色方框标识出来,可以看到 需要剪枝的是选择列表中当前数值与前一个数值相同的部分。而要判断相邻数值是否相同,我们首先要进行排序。
完整代码
function subsetsWithDup(nums: number[]): number[][] {
const res = []
const backtrack = (path: number[], nums: number[], start: number) => {
res.push([...path])
for (let i = start; i < nums.length; i++) {
if (i > start && nums[i] === nums[i - 1]) continue // 剪枝
// 做选择
path.push(nums[i])
backtrack(path, nums, i + 1)
path.splice(path.length - 1, 1)
}
}
// 对数组进行排序
nums.sort()
backtrack([], nums, 0)
return res
};
总结
回溯的本质是递归,递归的解法实际上就是将一个大问题划分为规模更小的子问题,笔者认为分析递归最重要的画出递归树,再结合一些解题方法论往往会事半功倍。
本文以「子集」问题为例尝试应用回溯算法的解题方法论,后续也会继续应用到组合和排列等场景中。算法没有捷径,多做多看多总结,共勉~