这是一篇关于回溯算法的「详细的入门级攻略」(真的就只是「入门级」)。
「回溯」本质上是「搜索的一种方式」,一般情况下,该搜索指「深度优先搜索(dfs)」。且实现上使用「递归」的方式。
全排列是回溯最经典的应用之一,我们以全排列做基本示例,先来理解最简单的回溯是如何执行的。
(参考力扣的46题:https://leetcode-cn.com/problems/permutations/)
给定一个整数 n,将数字 1∼n 排成一排,将会有很多种排列方法。
现在,请你按照字典序将所有的排列方法输出。
3
1 2 3 1 3 2 2 1 3 2 3 1 3 1 2 3 2 1
解释:输入样例为输入的整数n。输出样例为1~n的三个数字(1,2,3)的所有排列方式。
先把这道题当做脑筋急转弯,我们很容易就可以想到简单的思路:「分别把不同的数字放到每个位置上」。
例如:
请仔细研究一下上面的放置思路,其中有一个隐藏的关键点:「从第一层向下搜索到第三层,找到一个结果之后,需要重新回到第一层;再从第一层延伸到第二层的其他分支。」也就是说,需要「沿着如下图的红色箭头指向顺序搜索」。
想要用代码实现这一搜索过程,这一关键点是需要想清楚的:「如何在搜索出一个结果之后,让代码可以往回搜索呢?」
「往回搜索」其实就是回溯的过程,先来看下全排列中的代码实现:
class Solution {
public:
vector> res; // 存储所有排列方法
vector st; // 存储数字是否被用过
vector path; // 存储当前排列方法
// 使用递归的实现搜索,其中u表示当前已经排列的个数
void dfs(int u, vector& nums) {
// 如果已经排列的数字个数和总数字个数相等,说明已经完成一次排列
// 把当前的排列方法放入最终结果,并return。
if (u == nums.size()) { // ①
res.push_back(path); // ②
return; // ③
}
// 枚举数字
for (int i = 0; i < nums.size(); i ++ ) { // ④
// 没有使用过的数字参与排列
if (!st[i]) { // ⑤
path.push_back(nums[i]); // ⑥
st[i] = true; // ⑦
dfs(u + 1, nums); // ⑧
st[i] = false; // ⑨
path.pop_back(); // ⑩
}
}
}
vector> permute(vector& nums) {
for (int i = 0; i < nums.size(); i ++ ) st.push_back(false);
dfs(0, nums);
return res;
}
};
「接下来是本文重中之重,我们来看一下上面的代码的完整的执行流程,以此来了解为何这样写就能完成回溯。」
根据上面“全排列”的解法,我们可以总结出一个「回溯问题的通用思路」,下面用伪代码来描述:
res; // 存放结果
path; // 存放当前的搜索路径
st; // 判断元素是否已经被使用
// u 表示递归处于哪一层
void dfs(u) {
if 结束条件 {
res.push_back(path);
return;
}
for 循环 {
// 剪枝
// do something~~
dfs(u + 1); // 递归,进入下一层
// 回溯,撤销 do something~~
}
}
下面我们就用这种方法,来批量的解决一堆回溯相关问题。
充分理解回溯的思路,那么就可以扩展到相同类型的题目上。
(参考力扣的47题:https://leetcode-cn.com/problems/permutations-ii/)
给定一个可包含重复数字的序列 nums ,按任意顺序 返回所有不重复的全排列。
全排列的经典扩展,给出的序列nums可能包含重复的,那么就需要考虑一个问题:「如何避免重复数字换序后,计算为新的排列方式。」
其实解决的办法很简单:「跳过重复的数字。」
举个例子:当前nums为[1,1,2],为了便于观察我们给重复的1做上标记来进行区分,得到 ,那么就会出现 , 是同一种排列。
为了避免这种情况,以最左边的 为准,如果出现重复的就跳过去,那么当排列出 ,就不会再排列出 。
实现上还有一个小细节需要注意下,给出的nums可能是乱序的,所以要先排序一下,以方便跳过相同的数字。
因为是搜索的全排列,所以排序不会对结果产生影响。
class Solution {
public:
vector> res;
vector path;
vector st;
vector> permuteUnique(vector& nums) {
sort(nums.begin(), nums.end());
st = vector(nums.size());
// path = vector(nums.size());
dfs(nums, 0);
return res;
}
void dfs(vector &nums, int u) {
if (u == nums.size()) {
res.push_back(path);
return;
}
for (int i = 0; i < nums.size(); i ++ ) {
if (!st[i]) {
// 剪枝。如果出现重复数字,并且重复数字已经被用了,就跳过。
if (i && nums[i - 1] == nums[i] && !st[i - 1]) continue;
st[i] = true;
path.push_back(nums[i]);
dfs(nums, u + 1);
path.pop_back();
st[i] = false;
}
}
}
};
(参考力扣的39题:https://leetcode-cn.com/problems/combination-sum)
给你一个 无重复元素 的整数数组 nums 和一个目标整数 target ,找出 nums 中可以使数字和为目标数 target 的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。
nums 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。
对于给定的输入,保证和为 target 的不同组合数少于 150 个。
初步看题目,发现与全排列高度相似,但又有些许不同:
那么,实现上我们要解决的一个重要难点就是:「如何让元素可以重复使用呢?」
class Solution {
public:
vector> res;
vector path;
vector> combinationSum(vector& nums, int target) {
sort(nums.begin(), nums.end()); // 至关重要的排序
dfs(nums, 0, target, 0);
return res;
}
// dfs的参数多加一个start
void dfs(vector& nums, int u, int target, int start) {
// 当前路径和正好等于target时,说明找到了一个合法路径。
if (target == 0) {
res.push_back(path);
return;
}
for (int i = start; i < nums.size(); i ++ ) {
// 剪枝。如果超过target,直接开始回溯。
if (target < nums[i]) return;
// do something~~
path.push_back(nums[i]);
target -= nums[i]; // target减少
// 递归。注意start处传的参数,是当前的i,所以下一层递归也会从这个i开始,
// 这样就达到了重复使用数字的目的。
dfs(nums, u + 1, target, i);
// 撤销 do something
target += nums[i];
path.pop_back();
}
}
};
(参考力扣的40题:https://leetcode-cn.com/problems/combination-sum-ii/) 给定一个候选人编号的集合 nums 和一个目标数 target ,找出 nums 中所有可以使数字和为 target 的组合。
nums 中的每个数字在每个组合中只能使用 一次 。
注意:解集不能包含重复的组合。
对比上一道题(组合总和),本题有两个关键点:
为了节省码字时间和文章空间,本题就不放完整代码了,正好读者可以自己试试能不能写出来。
下面写出两个关键点的实现,其余的代码和上一题“组合总和”「完全相同」。
for 循环 {
// 剪枝。全排列2的思路:对于重复数字直接跳过就可以啦。
if (target < nums[i]) return;
if (i > start && nums[i - 1] == nums[i]) continue;
// do something~~
// 递归。数字不可以重复使用。
dfs(nums, u + 1, target, i + 1);
// 撤销 do something~~
}
来源:
https://mdnice.com/writing/61372f6f011243899ea639222258173a