回溯算法 —— 入门
回溯算法
定义
回溯算法(backtracking algorithm)是一种通过穷举来解决问题的方法,它的核心思想是从一个初始状态出发,暴力搜索所有可能的解决方案,当遇到正确的解则将其记录,直到找到解或尝试了所有可能的选择都无法找到解为止。
回溯算法通常采用深度优先搜索来遍历解空间。在二叉树中,前序、中序和后序遍历都属于深度优先搜索。
下面我们利用前序遍历构造一个回溯问题,逐步了解回溯算法的工作原理。
例题1
给定一颗二叉树,搜索并记录所有值为7的节点,请返回节点列表。
对于此题,我们前序遍历这棵树,并判断当前节点的值是否为7,若是,则将该节点的值加入结果列表res中

代码实现:
/* 前序遍历 例题1 */
void preOrder(TreeNode *root) {
if(root == nullptr)
return;
if(root->val == 7){
// 记录解
res.push_back(root);
}
preOrder(root->left);
preOrder(root->right);
}
尝试与回退
之所以称之为回溯算法,是因为该算法在搜索解空间时,会采用尝试与回退的策略。当算法在搜索过程中遇到某个状态无法继续前进或无法得到满足条件的解时,它会撤销上一步的选择,退回到之前的状态,并尝试其他可能的选择。
对于例题1,访问每一个节点都代表一次尝试,而越过叶子节点或返回父节点的return则表示回退。
值得说明的是,回退并不仅仅包括函数返回。为解释这一点,对例题1稍作拓展。
例题2
在二叉树中搜索所有值为7的节点,返回根节点到这些节点的路径
与例题1不同的是,此题需要返回路径,我们需要借助一个列表path记录访问过的节点路径。每访问一个节点,则将其加入path中表示路径,当访问到值为7的节点时,则将复制path并添加进结果列表res中。
这里的操作,当访问到叶子节点时,该路径已经到头,此时就需要回退操作,来保证路径正确。
前序遍历 同时将节点加入path列表中作为路径

找到解 将path加入结果列表

继续前序遍历

到达叶子节点 该条路径已经到头 需要回退操作 将path最后一个节点弹出

继续遍历

叶子节点5先弹出,中序遍历的节点7的左右子节点也都已返回,说明节点7的路径以及遍历完成,弹出

根据上述步骤,继续遍历右子树




得到最终结果

代码实现:
/* 前序遍历: 例题2 */
void preOrder(TreeNode *root) {
if(root == nullptr)
return;
// 尝试
path.push_back(root);
if(root->val == 7){
// 记录解
res.push_back(path);
}
preOrder(root->left);
preOrder(root->right);
// 回退
path.pop_back();
}
剪枝
复杂的回溯问题通常包含一个或多个约束条件,约束条件通常可用于“剪枝”。
例题3
在二叉树中搜索所有值为7的节点,请返回根节点到这些节点的路径,并要求路径中不包含值为3的节点
为满足以上约束条件,我们需要添加剪枝操作:在搜索过程中,若遇到值为3的节点,则提前返回,不再继续搜索
在搜索过程中,我们剪掉了不满足约束条件的搜索分支,避免了许多无意义的尝试,从而提高了搜索效率

代码实现:
/* 前序遍历: 例题3 */
void preOrder(TreeNode *root) {
// 剪枝
if(root == nullptr || root->val == 3){
return;
}
// 尝试
path.push_back(root);
// 记录解
if(root->val == 7){
res.push_back(path);
}
preOrder(root->left);
preOrder(root->right);
// 回退
path.pop_back();
}
框架代码
我们尝试将回溯的“尝试、回退、剪枝”的主题框架提炼出来,提升代码的通用性
在以下框架代码中,state表示问题当前的状态,choices表示当前状态下可以做出的选择:
/* 回溯算法框架 */
void backtrack(State *state, vector<Choice *> &choices, vector<State *> &res){
// 判断是否为解
if(isSolution(state)){
// 记录解
recordSolution(state, res);
// 根据情况决定是否继续搜索
return; // 不再继续搜索
}
// 遍历所有选择
for(Choice choice : choices){
// 剪枝:判断选择是否合法
if(isValid(state, choice)){
// 尝试:做出选择,更新状态
makeChoice(state, choice);
backtrack(state, choices, res);
// 回退:撤销选择,恢复到之前的状态
undoChoice(state, choice);
}
}
}
下面我们基于框架代码来解决例题3,状态state为节点遍历路径,选择choices为当前节点的左子节点和右子节点,结果res是路径列表
/* 判断当前状态是否为解 */
bool Backtrack::isSolution(vector<TreeNode*>& state){
return !state.empty() && state.back()->val == 7;
}
/* 记录解 */
void Backtrack::recordSolution(vector<TreeNode*>& state, vector<vector<TreeNode*>>& res) {
res.push_back(state);
}
/* 判断在当前状态下,该选择是否合法 */
bool Backtrack::isValid(vector<TreeNode*>& state, TreeNode* choice) {
return choice != nullptr && choice->val != 3;
}
/* 更新状态 */
void Backtrack::makeChoice(vector<TreeNode*>& state, TreeNode* choice) {
state.push_back(choice);
}
/* 恢复状态 */
void Backtrack::undoChoice(vector<TreeNode*>& state, TreeNode* choice) {
state.pop_back();
}
void Backtrack::backtrack(vector<TreeNode*>& state, vector<TreeNode*>& choices, vector<vector<TreeNode*>>& res) {
// 判断是否为解
if (isSolution(state)) {
// 记录解
recordSolution(state, res);
}
// 遍历所有选择
for (TreeNode* choice : choices) {
// 剪枝:判断选择是否合法
if (isValid(state, choice)) {
// 尝试: 做出选择, 更新状态
makeChoice(state, choice);
// 递归遍历
vector<TreeNode*> choices_new = { choice->left, choice->right };
backtrack(state, choices_new, res);
// 回退
undoChoice(state, choice);
}
}
}
根据题意,我们在找到值为7的节点后应该继续搜索,因此需要将记录解之后的return语句删除。
下图对比了保留或删除return语句的搜索过程

相比于前序遍历的代码实现,基于回溯算法框架的代码实现虽然显得啰嗦,但通用性更好。实际上,许多回溯问题可以在该框架下解决。我们只需要根据具体问题来定义state和choices,并实现框架中的各个方法即可。
常见术语
| 名词 | 定义 | 例题3 |
|---|---|---|
| 解(solution) | 解时满足问题特定条件的答案,可能有一个或多个 | 根节点到节点7的满足约束条件的所有路径 |
| 约束条件(constraint) | 约束条件是问题中限制解的可行性的条件,通常用于剪枝 | 路径中不包含节点3 |
| 状态(state) | 状态表示问题在某一时刻的情况,包括已经做出的选择 | 当前已访问的节点路径,即path节点列表 |
| 尝试(attempt) | 尝试是根据可用选择来探索解空间的过程,包括做出选择,更新状态,检查是否为解 | 递归访问左(右)子节点,将节点添加进path,判断节点的值是否为7 |
| 回退(backtracking) | 回退指遇到不满足约束条件状态时,撤销前面做出的选择,回到上一状态 | 当越过叶节点、结束节点访问、遇到值为3的节点时终止搜索,函数返回 |
| 剪枝(pruning) | 剪枝是根据问题特性和约束条件避免无意义的搜索路径的方法,可以提高搜索效率 | 当遇到值为3的节点时,则不再继续搜索 |
优点与局限性
回溯算法本质上是一种深度优先搜索算法,它尝试所有可能的解决方案直到找到满足条件的解。这种方法的优点在于能够找到所有可能的解决方案,而且在合理的剪枝操作下,具有很高的效率。
然而,在处理大规模或者复杂问题时,回溯算法运行效率可能难以接受:
- 时间:回溯算法通常需要遍历状态空间的所有可能,时间复杂度可以达到指数阶或阶乘阶。
- 空间:在递归调用中需要保存当前的状态(例如路径、用于剪枝的辅助变量等),当深度很大时,空间需求可能会变得很大。
即便如此,回溯算法仍然是某些搜索问题和约束满足问题的最佳解决方案。对于这些问题,由于无法预测哪些选择可生成有效的解,因此我们必须对所有的可能进行遍历。
在这种情况下,关键是如何优化效率,常见的优化效率方法有两种:
- 剪枝:比密码搜索哪些肯定不会产生解的路径,从而节省时间和空间
- 启发式搜索:在搜索过程中引入一些策略或者估计值,从而优先搜索最有可能产生有效解的路径。
回溯典型例题
回溯算法可用于解决许多搜索问题、约束满足问题和组合优化问题。
搜索问题:这类问题的目标是找到满足特定条件的解决方案。
- 全排列问题:给定一个集合,求出其所有可能的排列组合。
- 子集和问题:给定一个集合和一个目标和,找到集合中所有和为目标和的子集。
- 汉诺塔问题:给定三根柱子和一系列大小不同的圆盘,要求将所有圆盘从一根柱子移动到另一根柱子,每次只能移动一个圆盘,且不能将大圆盘放在小圆盘上
约束满足问题:这类问题的目标是找到满足所有约束条件的解。
- n皇后:在n x n的棋盘上放置n个皇后,使得它们互不攻击。
- 数独:在9 x 9的网格中填入数字1-9,使得每行、每列和每3x3子网格中的数字不重复
- 图着色问题:给定一个无向图,用最少的颜色给图的每个顶点着色,使得相邻顶点颜色不同
组合优化问题:这类问题时在一个组合空间中找到某些满足某些条件的最优解。
- 0-1背包问题:给定一组物品和一个背包,每个物品有一定的价值和重量,要求在背包容量限制内,选择物品使得总价值最大。
- 旅行商问题:在一个图中,从一个点出发,访问所有其他点恰好一次后返回起点,求最短路径。
- 最大团问题:给定一个无向图,找到最大的完全子图,即图中的任意两个顶点之间都有边相连。
注意,对于许多组合优化问题,回溯不是最优解决方案
- 0-1背包问题通常使用动态规划解决,以达到更高的时间效率。
- 旅行商是一个著名的NP-Hard问题,擦灰姑娘用解法有遗传算法和蚁群算法等
- 最大团问题时图论中的一个经典问题,可用贪心算法等启发式算法解决

浙公网安备 33010602011771号