首页 > 其他分享 >回溯

回溯

时间:2024-02-26 20:24:51浏览次数:21  
标签:剪枝 int 元素 选择 子集 回溯

13.1 回溯算法

「回溯算法 backtracking algorithm」是一种通过穷举来解决问题的方法,它的核心思想是从一个初始状态
出发,暴力搜索所有可能的解决方案,当遇到正确的解则将其记录,直到找到解或者尝试了所有可能的选择
都无法找到解为止。

回溯算法通常采用“深度优先搜索”来遍历解空间。在二叉树章节中,我们提到前序、中序和后序遍历都属
于深度优先搜索。接下来,我们利用前序遍历构造一个回溯问题,逐步了解回溯算法的工作原理。

b
例题一

给定一个二叉树,搜索并记录所有值为 7 的节点,请返回节点列表。

对于此题,我们前序遍历这颗树,并判断当前节点的值是否为 7 ,若是则将该节点的值加入到结果列表 res
之中。相关过程实现如图 13‑1 和以下代码所示。

// === File: preorder_traversal_i_compact.c ===
/* 前序遍历:例题一 */
void preOrder(TreeNode *root) {
if (root == NULL) {
return;
}
if (root->val == 7) {
// 记录解
res[resSize++] = root;
}
preOrder(root->left);
preOrder(root->right);
}

13.1.1 尝试与回退

之所以称之为回溯算法,是因为该算法在搜索解空间时会采用“尝试”与“回退”的策略。当算法在搜索过
程中遇到某个状态无法继续前进或无法得到满足条件的解时,它会撤销上一步的选择,退回到之前的状态,
并尝试其他可能的选择。
对于例题一,访问每个节点都代表一次“尝试”,而越过叶节点或返回父节点的 return 则表示“回退”。
值得说明的是,回退并不仅仅包括函数返回。为解释这一点,我们对例题一稍作拓展。

b

例题二

在二叉树中搜索所有值为 7 的节点,请返回根节点到这些节点的路径。

在例题一代码的基础上,我们需要借助一个列表 path 记录访问过的节点路径。当访问到值为 7 的节点时,则
复制 path 并添加进结果列表 res 。遍历完成后,res 中保存的就是所有的解。

// === File: preorder_traversal_ii_compact.c ===
/* 前序遍历:例题二 */
void preOrder(TreeNode *root) {
if (root == NULL) {
return;
}
// 尝试
path[pathSize++] = root;
if (root->val == 7) {
// 记录解
for (int i = 0; i < pathSize; ++i) {
res[resSize][i] = path[i];
}
resSize++;
}
preOrder(root->left);
preOrder(root->right);
// 回退
pathSize--;
}

在每次“尝试”中,我们通过将当前节点添加进 path 来记录路径;而在“回退”前,我们需要将该节点从
path 中弹出,以恢复本次尝试之前的状态。

观察图 13‑2 所示的过程,我们可以将尝试和回退理解为“前进”与“撤销”,两个操作是互为逆向的。

13.1.2 剪枝

复杂的回溯问题通常包含一个或多个约束条件,约束条件通常可用于“剪枝”。

b
例题三

在二叉树中搜索所有值为 7 的节点,请返回根节点到这些节点的路径,并要求路径中不包含
值为 3 的节点。

为了满足以上约束条件,我们需要添加剪枝操作:在搜索过程中,若遇到值为 3 的节点,则提前返回,停止
继续搜索。

// === File: preorder_traversal_iii_compact.c ===
/* 前序遍历:例题三 */
void preOrder(TreeNode *root) {
// 剪枝
if (root == NULL || root->val == 3) {
return;
}
// 尝试
path[pathSize++] = root;
if (root->val == 7) {
// 记录解
for (int i = 0; i < pathSize; i++) {
res[resSize][i] = path[i];
}
resSize++;
}
preOrder(root->left);
preOrder(root->right);
// 回退
pathSize--;
}

剪枝是一个非常形象的名词。如图 13‑3 所示,在搜索过程中,我们“剪掉”了不满足约束条件的搜索分支,
避免许多无意义的尝试,从而提高了搜索效率。

13.1.3 框架代码

接下来,我们尝试将回溯的“尝试、回退、剪枝”的主体框架提炼出来,提升代码的通用性。

在以下框架代码中,state 表示问题的当前状态,choices 表示当前状态下可以做出的选择。

/* 回溯算法框架 */
void backtrack(State *state, Choice *choices, int numChoices, State *res, int numRes) {
// 判断是否为解
if (isSolution(state)) {
// 记录解
recordSolution(state, res, numRes);
// 停止继续搜索
return;
}
// 遍历所有选择
for (int i = 0; i < numChoices; i++) {
// 剪枝:判断选择是否合法
if (isValid(state, &choices[i])) {
// 尝试:做出选择,更新状态
makeChoice(state, &choices[i]);
backtrack(state, choices, numChoices, res, numRes);
// 回退:撤销选择,恢复到之前的状态
undoChoice(state, &choices[i]);
}
}
}

接下来,我们基于框架代码来解决例题三。状态 state 为节点遍历路径,选择 choices 为当前节点的左子节
点和右子节点,结果 res 是路径列表。

// === File: preorder_traversal_iii_template.c ===
/* 判断当前状态是否为解 */
bool isSolution(void) {
return pathSize > 0 && path[pathSize - 1]->val == 7;
}
/* 记录解 */
void recordSolution(void) {
for (int i = 0; i < pathSize; i++) {
res[resSize][i] = path[i];
}
resSize++;
}
/* 判断在当前状态下,该选择是否合法 */
bool isValid(TreeNode *choice) {
return choice != NULL && choice->val != 3;
}
/* 更新状态 */
void makeChoice(TreeNode *choice) {
path[pathSize++] = choice;
}
/* 恢复状态 */
void undoChoice(void) {
pathSize--;
}
/* 回溯算法:例题三 */
void backtrack(TreeNode *choices[2]) {
// 检查是否为解
if (isSolution()) {
// 记录解
recordSolution();
}
// 遍历所有选择
for (int i = 0; i < 2; i++) {
TreeNode *choice = choices[i];
// 剪枝:检查选择是否合法
if (isValid(choice)) {
// 尝试:做出选择,更新状态
makeChoice(choice);
// 进行下一轮选择
TreeNode *nextChoices[2] = {choice->left, choice->right};
backtrack(nextChoices);
// 回退:撤销选择,恢复到之前的状态
undoChoice();
}
}
}

根据题意,我们在找到值为 7 的节点后应该继续搜索,因此需要将记录解之后的 return 语句删除。图 13‑4
对比了保留或删除 return 语句的搜索过程。

相比基于前序遍历的代码实现,基于回溯算法框架的代码实现虽然显得啰嗦,但通用性更好。实际上,许多
回溯问题都可以在该框架下解决。我们只需根据具体问题来定义 state 和 choices ,并实现框架中的各个方法即可。

13.1.4 常用术语

为了更清晰地分析算法问题,我们总结一下回溯算法中常用术语的含义,并对照例题三给出对应示例

表 13‑1 常见的回溯算法术语
名词 定义 例题三

解 Solution 解是满足问题特定条件的答案,可能有一个或
多个
根节点到节点 7 的满足约束条件的所有路

约束条件
Constraint
约束条件是问题中限制解的可行性的条件,通
常用于剪枝
路径中不包含节点 3
状态 State 状态表示问题在某一时刻的情况,包括已经做
出的选择
当前已访问的节点路径,即 path 节点列表

尝试
Attempt
尝试是根据可用选择来探索解空间的过程,包
括做出选择,更新状态,检查是否为解
递归访问左(右)子节点,将节点添加进
path ,判断节点的值是否为 7

回退
Backtracking
回退指遇到不满足约束条件的状态时,撤销前
面做出的选择,回到上一个状态
当越过叶节点、结束节点访问、遇到值为 3
的节点时终止搜索,函数返回

名词 定义 例题三

剪枝
Pruning
剪枝是根据问题特性和约束条件避免无意义的
搜索路径的方法,可提高搜索效率
当遇到值为 3 的节点时,则终止继续搜索

b
问题、解、状态等概念是通用的,在分治、回溯、动态规划、贪心等算法中都有涉及。

13.1.5 优势与局限性

回溯算法本质上是一种深度优先搜索算法,它尝试所有可能的解决方案直到找到满足条件的解。这种方法的
优势在于它能够找到所有可能的解决方案,而且在合理的剪枝操作下,具有很高的效率。

然而,在处理大规模或者复杂问题时,回溯算法的运行效率可能难以接受。

‧ 时间:回溯算法通常需要遍历状态空间的所有可能,时间复杂度可以达到指数阶或阶乘阶。

‧ 空间:在递归调用中需要保存当前的状态(例如路径、用于剪枝的辅助变量等),当深度很大时,空间
需求可能会变得很大。

即便如此,回溯算法仍然是某些搜索问题和约束满足问题的最佳解决方案。对于这些问题,由于无法预测哪
些选择可生成有效的解,因此我们必须对所有可能的选择进行遍历。在这种情况下,关键是如何进行效率优
化,常见的效率优化方法有两种。

‧ 剪枝:避免搜索那些肯定不会产生解的路径,从而节省时间和空间。

‧ 启发式搜索:在搜索过程中引入一些策略或者估计值,从而优先搜索最有可能产生有效解的路径。

13.1.6 回溯典型例题

回溯算法可用于解决许多搜索问题、约束满足问题和组合优化问题。

搜索问题:这类问题的目标是找到满足特定条件的解决方案。

‧ 全排列问题:给定一个集合,求出其所有可能的排列组合。

‧ 子集和问题:给定一个集合和一个目标和,找到集合中所有和为目标和的子集。

‧ 汉诺塔问题:给定三个柱子和一系列大小不同的圆盘,要求将所有圆盘从一个柱子移动到另一个柱子,
每次只能移动一个圆盘,且不能将大圆盘放在小圆盘上。

约束满足问题:这类问题的目标是找到满足所有约束条件的解。

标签:剪枝,int,元素,选择,子集,回溯
From: https://www.cnblogs.com/lulixiu1999/p/18035084

相关文章

  • 回溯
    回溯算法理论基础题目分类理论基础什么是回溯法回溯法也可以叫做回溯搜索法,它是一种搜索的方式。在二叉树系列中,我们已经不止一次,提到了回溯,例如二叉树:以为使用了递归,其实还隐藏着回溯。回溯是递归的副产品,只要有递归就会有回溯。所以以下讲解中,回溯函数也就是递归函数,指......
  • 递归与回溯算法
    递归函数中自己调用自己经典例题:汉诺塔需要将所有盘子按顺序放到塔C上(问题规模:n)就需要最大的盘子在C底部就需要将其余所有盘子移动到塔B上 第二塔上也需要按顺序摆放(问题规模:n-1)就需要第二大的盘子在B底部就需要将其余所有盘子移动到另一个塔上··········......
  • 回溯算法模板 & 78子集代码
     voidbacktracking(参数){   if(终止条件){       存放结果;       return;   }   for(选择:本层集合中元素(树中节点孩子的数量就是集合的大小)){       处理节点;       backtracking(路径,选择列表);//递归       ......
  • day30 回溯算法总结
     我的感悟:之前一直没看进去,理论篇。今天看了,收获很大。 我的笔记: 资料:卡尔回溯总结卡尔理论视频......
  • day29 回溯算法part5 代码随想录算法训练营 47. 全排列 II
    题目:47.全排列II我的感悟:用了一层判断,感觉也挺好用的理解难点:老师的写法,主要是理解used【i】和used[i-1]的概念我说怎么参考答案看不懂呢,它把两个判断放在一起写了。我的代码:用了一层判断classSolution:defpermuteUnique(self,nums:List[int])->List[Lis......
  • 复习回顾-回溯算法-46. 全排列
    注意点&感悟:used是全局的,通过改变标记来确定本层是否搜索完毕,用used跳过[1,1,1]这种不用start_index了题目链接:46.全排列自己独立写的代码:查看代码classSolution:defpermute(self,nums:List[int])->List[List[int]]:#不用start_index了|用used......
  • day29 回溯算法part5 代码随想录算法训练营 46. 全排列
    题目:46.全排列我的感悟:看不下去视频,可以先看文字讲解。看答案。带着疑问去看视频,效果会更好。加油!理解难点:排列,不用start_index了借助used=1来过滤掉[1,1,1]这种情况。如果不加ifused[i]==1,continue就会出现重复的。如下图: 代码示例:classSolution:d......
  • day29 回溯算法part5 代码随想录算法训练营 491. 非递减子序列
    题目:491.非递减子序列我的感悟:难不怕,不行就抄一遍,再默写一遍,多记忆几遍。加油!!!理解难点:uset是本层的, res收获的是节点(满足要求的节点),不用return(用了return是仅仅收集叶子节点的)判断的逻辑,是nums[i]当前的节点和目标的path的区别代码示例:classSolution:......
  • 复习回顾-回溯算法-90. 子集 II 【犹豫】
    注意点&感悟:对过滤条件放在for里面,还是外面,有些犹豫了。【疑问,先搁置】我感觉for里面,应该是进去树枝的过程,for外面写,应该是终止条件。================又看了一眼视频,for里面是取数的过程,所以,应该取数的过程,进行了剪枝。题目链接:90.子集II自己独立写的代码:classSo......
  • 回顾复习-回溯算法-78. 子集
    注意点&感悟:复习是个好东西题目链接:78.子集自己独立写的代码:classSolution:defsubsets(self,nums:List[int])->List[List[int]]:res=[]self.backtracking(nums,0,[],res)returnresdefbacktracking(self,nums,start_index,......