在Day17中我们已经初步认识了回溯的过程,回溯的过程就是搜索遍历树的过程,回溯函数会在我们的代码逻辑驱动下,遍历到树的每一个节点。只不过在走遍树的过程中,需要时时刻刻记录我们走过的路径/或者走过的节点,并在到达叶子节点的时候存储这一次到达叶子的记录
回到Day17的257.二叉树的所有路径题目,那道题其实就是在遍历树的每一个节点。在遍历到一个节点的时候,我们需要记录该节点,并且如果到达叶子节点,我们可能需要存储记录。在到达叶子节点之后我们需要往回走去寻找其他能到别的叶子节点的路径,或者在该节点发现途径该节点能到达的叶子节点都已经去过了,我们也需要往回走去寻找不需要途径该节点能够到达的其他叶子。只要往回走,我们就需要维护我们的记录,这个维护过程就是回溯
回溯算法有两种模板,分别对应于记录节点和记录路径的回溯模板
怎么写回溯模板?我们可以想象回溯函数遍历节点就像人(指针)在整棵树上游走,我们想象我们就是那个人,我们的代码逻辑就是实现这个人到某一节点后,应该怎么继续行动
记录节点的回溯模板
void backtracking(参数)
因为是记录节点,我们开始想象自己是那个人,我们要记录到某个节点之后,从起始节点到该节点的物理路径所途径的节点,一种比较好的方式是,到了一个节点之后,我们用小本本记下来这个节点
void backtracking(参数)
{
添加记录;
}
然后,我们记录之后,我们需要开始环视四周,看看是不是到达叶子节点了,如果到达叶子节点的话,我们就知道:这个时候应该将小本本记录的到该叶子节点所途径的节点存放下来
void backtracking(参数)
{
添加记录;
if (终止条件) {
存放结果;
}
}
然后,我们需要往回走,从而方便继续寻找别的叶子节点。在往回走之前,我们需要在小本本中擦除当前节点。如果不擦除会怎么样?看看上图,我们到达了紫色节点,此时是叶子节点,我们记录的是绿、青、紫。然后我们需要往回走,走到青色节点之后再往下走能够走到橙色节点。如果我们不擦除紫色节点,则到达橙色节点的时候,我们的记录会是绿、青、紫、橙,而非绿、青、橙,显然不符合我们的期望。
void backtracking(参数)
{
添加记录;
if (终止条件) {
存放结果;
撤销记录 // 回溯
return; // 往回走
}
}
如果没到叶子节点,我们就会跨过 if 语句。这个时候我们站在该节点,需要遍历下一步能去的所有节点,并前往。注意这里的“我们”指的是回溯函数,我们走向下一个节点意思就是需要递归调用回溯函数处理下一个节点
void backtracking(参数)
{
添加记录;
if (终止条件) {
存放结果;
撤销记录 // 回溯
return; // 往回走
}
for (节点:所有可去的节点) {
backtracking(节点, 其他参数); // 前往下一个节点
}
}
当我们执行完 for 语句,说明所途径该节点能够到达的叶子节点我们都去过了,此时需要往回走去寻找不需要途径该节点能够到达的其他叶子。注意往回走之前,记得在小本本上擦除当前节点这条记录,原因和到达叶子节点往回走需要擦除的原因一样。
由此我们就给出了记录节点的回溯模板
void backtracking(参数)
{
添加记录;
if (终止条件) {
存放结果;
撤销记录 // 回溯
return; // 往回走
}
for (节点:所有可去的节点) {
backtracking(节点, 其他参数); // 前往下一个节点
}
撤销记录; // 回溯
return; // 往回走
}
还有一种记录路径的回溯模板
这里的记录“路径”可能容易产生歧义,换种说法:用图论的说法来讲,这是一种记录边的回溯模板(上一个是记录节点)
因为是记录边(路径),我们开始想想自己是那个人,我们要记录到某个节点之后,从起始节点到该节点的物理路径所途径的边(路径),一种比较好的方式是,在即将从该节点出发时,即将到下一个节点前,记录到下一个节点会途径的边(路径)
其余的和上一个记录节点的模板差不多,不过还是有几点区别:
- 添加记录和撤销记录的时机变了
- 这里添加的记录记录的是路径,上一个记录的是节点
- 上一个回溯函数的功能是处理传入的节点,这里回溯函数的功能是走往传入的路径(从而进入到下一个节点)
void backtracking(参数) {
if (终止条件) {
存放结果;
return; // 往回走
}
for (路径:所有可选择的路径) {
添加记录;
backtracking(路径,其他参数); // 前往下一个节点
撤销记录; // 回溯
}
return; // 往回走
}
这两种模板的共性都是:有添加记录,必然会有一个撤销记录的逻辑与之匹配。记录节点的回溯模板中,我们是进入节点后添加记录,离开节点前撤销记录;而记录路径的回溯模板中,我们是进入节点前添加记录,离开节点后撤销记录
有了这两个模板,我们在后续解题的过程中需要将问题抽象为遍历树的问题,然后套用这两个模板中的一个。虽然最终的代码可能有细节上的出入,但是大体逻辑脱离不开这两个代码框架
77. 组合
我们需要将问题抽象为遍历树的问题。一个关键的步骤就是抽象出树形结构
每次从集合中选取元素,不同的选择会走向不同的节点,在不同的节点上,下一步可选择的范围也不同,通过不同的可选择元素的范围标识不同的节点。需要调整可选择的范围
注意组合和排序的区别:[1,2]和[2,1]算是两种排序,但是只算一种组合,因此取2之后就不能再取1了,否则会和取1后再取2得到的组合重复
本题最后的结果集合记录的是路径(边),使用记录路径的回溯模板
对照着模板一步一步看:
终止条件是什么?题目需要返回 k 个数的组合,如果我们记录的组合里面已经有 k 个数了,则说明到达了终止条件,需要存放结果。因为我们希望在递归过程中都能随时进行记录的添加或者撤销操作,因此我们需要全局变量用来记录组合内的元素(该变量是记录走过的边的小本本),以及全局变量用于存放结果
vector<int> path; // 用于在遍历过程中记录边
vector<vector<int> > result; // 用于存放最后的结果
if (path.size() == k) // 终止条件
{
result.push_back(path); // 存放结果
return; // 往回走
}
如果没有到达叶子节点,即跨过终止条件的 if 语句,此时我们就相当于站在了一个节点上,接下来应该去看看下一步有哪些可以选择的路径。问题来了:怎么标识我们现在站在这个节点上可供选择路径范围?结合绘制出来的树结构,我们的解决方案是:通过不同的 startIndex 标识不同的可选择元素范围,从而标识不同节点
因此,我们可以通过 startIndex 明确在该节点可以选择的路径,从而进行选择。在前往下一个节点的时候,也是通过传入不同的 startIndex 来表明“此时回溯函数遍历到了下一个节点”
代码如下
class Solution {
public:
vector<vector<int> > result; // 用于存放最后的结果
vector<int> path; // 用于在遍历过程中记录边
vector<vector<int>> combine(int n, int k) {
backtracking(n, k, 1);
return result;
}
void backtracking(int n, int k, int startIndex) {
if (path.size() == k) // 终止条件
{
result.push_back(path); // 存放结果
return; // 往回走
}
for (int i = startIndex; i <= n; ++i) { // 到达了一个节点后,可选择的路径由startIndex控制
path.push_back(i); // 记录选择的路径
backtracking(n, k, i + 1); // 前往下一个节点,这个节点的可选择元素范围为i + 1到n
path.pop_back(); // 撤销记录
}
return;
}
};
这个代码还有没有办法进行优化? 有!剪枝操作
在普通的回溯中,我们到了一个节点后,如果还有途径这个节点能够到达的叶子节点,则一定会继续走下去,直到走遍所有的途径这个节点能够到的所有叶子节点,才往回走。
我们的剪枝操作其实就是,不需要走遍所有的途径这个节点所能够走到的叶子节点,就能够直接往回走。如何实现呢?如果我们在这个节点,我们能够通过一些方法知道选择某些路继续走下去是一定行不通的(指走不到叶子节点或者走到叶子节点不可能收集到结果),那我们就不去走那些冤枉路了
比如假如我们现在处于第一层的节点,此时如果我们选择路径:取2,那么沿着这条路径走到下一个节点的时候,剩下的可选择元素就只有3和4了,是无论如何也没办法构成组合大小为4的结果的,那么我们就能够断定:选择取2这条路径走下去是一定行不通的。同理,路径取3、取4都行不通。 于是我们就在选择路径的时候,直接排除掉这些路径就行了
后面几次的剪枝操作同理,都能够断定走某些路径是一定不可行的
重新查看上面的代码
class Solution {
public:
vector<vector<int> > result; // 用于存放最后的结果
vector<int> path; // 用于在遍历过程中记录边
vector<vector<int>> combine(int n, int k) {
backtracking(n, k, 1);
return result;
}
void backtracking(int n, int k, int startIndex) {
if (path.size() == k) // 终止条件
{
result.push_back(path); // 存放结果
return; // 往回走
}
for (int i = startIndex; i <= n; ++i) { // 到达了一个节点后,可选择的路径由startIndex控制
path.push_back(i); // 记录选择的路径
backtracking(n, k, i + 1); // 前往下一个节点
path.pop_back(); // 撤销记录
}
return;
}
};
选择路径的逻辑是在 for 循环中,我们需要进行修改。
进入 for 循环的代码逻辑,假如我们选择了路径 i,那么所记录组合的大小变成了 path.size() + 1,此时假如我们前往了下一个节点,则在下一个节点的剩余能够选择的元素就只有 i + 1、i + 2、......、n,共计 n - ( i + 1) + 1个元素,我们在下一个节点时剩余能够选择的元素个数 n - i 加上到达下一个节点的时候所记录的组合大小 path.size() + 1应该大于等于所需要的组合大小 k
解方程得到:我们选择的路径 i 应该满足的要求:
i <= n - k + path.size() + 1
改变 for 循环中的判断条件即可
class Solution {
public:
vector<vector<int> > result; // 用于存放最后的结果
vector<int> path; // 用于在遍历过程中记录边
vector<vector<int>> combine(int n, int k) {
backtracking(n, k, 1);
return result;
}
void backtracking(int n, int k, int startIndex) {
if (path.size() == k) // 终止条件
{
result.push_back(path); // 存放结果
return; // 往回走
}
for (int i = startIndex; i <= n - k + path.size() + 1; ++i) { // 到达了一个节点后,可选择的路径由startIndex控制
// 注意剪枝操作
path.push_back(i); // 记录选择的路径
backtracking(n, k, i + 1); // 前往下一个节点
path.pop_back(); // 撤销记录
}
return;
}
};
回顾总结
总结了记录节点和记录路径的两种回溯算法模板
回溯题需要抽象出树结构
剪枝操作我们是如何推导出来的?假设一下选择了某条路径进入了下一个节点的情况,从而推导出在当前节点的可行路径所应该满足的条件