究竟该如何理解回溯算法?
- 3 个点赞 👍
递归+回溯这个理解起来还是挺有难度的哈哈。
我们先看下递归吧。我感觉递归的主要难点在于,它非常"不直接"。对于非递归代码,通常我们看完了整个代码,大概就知道它在干什么了。因为每行代码都很直白而且是非常明确的,比如有个for循环它是要遍历某个数组,或者有个if来判断某个条件是否成立,并根据真假执行后面的不同语句。而递归代码,有一个让人感到麻烦的地方在于,当代码执行到一定步骤之后,它又会去调用自己(只是参数取值有所不同),那代码看到这里之后,就比较容易困惑了。"我本来就是想着把代码完整读一遍来知道它在干嘛的,结果我还没搞清楚它在干嘛,又遇到它自己了"。这个有点"无穷无尽"的意思了哈哈。
我觉得想要理解递归的话可以分两个阶段。第一阶段是,找一个递归代码,就从第一次调用它开始,把代码走一遍,把每次调用的所有细节都写下来(比如参数取值),直到遇到递归的终止条件停止。这个阶段主要是帮助自己熟悉递归代码的运行方法,运行过程,和运行细节,练习多了之后也能消除一些对递归的"恐惧"。
第二阶段要尝试从相对抽象,概括的角度去理解递归了,这样才能比较熟练得写出自己的递归代码。我一般是这么想的,当我们尝试用递归函数fun(n)来解决问题的时候,我们先解决当前输入参数下能解决的一部分问题(这部分代码应该是直白明确的),至于剩下的问题就通过再次调用递归函数解决。我们要坚信递归函数可以帮我们完成剩余的问题,并且我们无需关注它到底是怎么实现的。比如说用递归函数计算n的阶乘n!吧。
fun(n)的作用是返回n!的结果,而且我们知道n!=n * (n-1)!,那当输入为n的时候,我可以把"n*"这一项直白明确的写下来,然后剩下的计算(n-1)!就交给fun(n-1)来解决,我们坚信它可以完成自己的任务,并且我们也不关注它到底怎么完成任务的。那么,就可以写出我们自己的代码了(显示的时候缩进好像有点问题)
int fun(n){
if(n == 1) return 1; // 终止条件
return n * fun(n - 1); // 坚信fun(n-1)可以正确计算(n-1)!
}
完全理解了递归以后,现在再说说递归+回溯吧。这个多了一个难点是"回溯"部分,而且通常递归函数里会多一个for循环。这个,我也是建议可以找一个代码像"第一阶段"那样,把所有细节全部自己走一遍。我这里就直接说"第二阶段"了哈。
一个相对抽象/概括的理解是,对于递归+回溯的函数fun(n),对于输入参数为n的时候,我们有多种"选择"。可以对比一下计算n的阶乘,那里其实只有一种选择,就是乘上n(但是通常,我们不会这么去刻意得想)。当有多种选择的时候,一般我们就要通过一个for循环来枚举每个可能的选择,而当选择确定后,我们可以解决这个选择下的一部分问题,并把剩下的问题再次交给递归了(我们坚信递归可以帮我们解决剩余的问题),最后,在这里递归返回之后,相当于我们已经知道了在当前选择下的最终结果,然后我们需要"恢复状态",这也是回溯思想的关键!"恢复状态"是因为当前的选择可能会改变一些"状态",比如全排列中数组的取值,你这个代码的话就是path数组会发生变化,八皇后问题里棋盘中皇后的位置。我们只有恢复状态之后,才可以进入下一次循环,来尝试另一个选择的情况。整体来说,递归+回溯的常见结构是
void fun(){
终止条件
for循环尝试多种选择 {
做出第i个选择,可能会改变一些状态
递归解决剩余问题
恢复做出第i个选择前的状态,为尝试第i+1个选择做准备
}
}
这个递归+回溯,想要理解的话主要还是要自己多练习哈,可以看看全排列问题和八皇后问题,正好我写过这两个问题,给自己宣传一波哈哈..
编辑于 2024-03-18 21:54・IP 属地上海查看全文>>
庞加莱的算法空间 - 1 个点赞 👍
回溯算法是一种通过探索所有可能的候选解来找出所有解的算法。当找到一个解时,它会将其添加到结果中,然后继续探索剩下的候选解。如果候选解被证明是不可能的,回溯算法会返回上一步,并尝试另一个候选解。这种算法通常用于解决组合问题,如全排列、子集组合等。
全排列是指将一组数字的所有可能排列都列出来。例如,对于数字集 {1, 2, 3},其全排列为 {1, 2, 3}、{1, 3, 2}、{2, 1, 3}、{2, 3, 1} 和 {3, 1, 2}。
下面我将解释回溯算法在解决全排列问题时的核心思想和执行步骤。
### 回溯算法的核心思想
1. **递归**:将问题分解成更小的子问题,直到子问题足够简单以至于可以直接解决。
2. **循环**:在探索解的空间时,重复地尝试不同的路径,直到找到所有解或确定当前路径不可能产生解。
3. **回溯**:当确定当前路径不可能产生解时,回溯到上一个状态,并尝试另一个路径。
### 解决全排列问题的步骤
假设我们有三个数字 {1, 2, 3},我们希望找出它们的所有排列。
#### 第一步:确定排列的深度
- 初始化一个结果列表,用于存储所有找到的排列。
- 从第一个数字开始,我们有三种选择(1, 2, 3)。
#### 第二步:递归探索
- 对于每个选择,我们将其添加到当前排列中,并递归地继续选择下一个数字。
- 例如,如果我们选择了数字1,那么当前排列是 {1},接下来我们需要为这个排列选择下一个数字。
#### 第三步:选择下一个数字
- 对于当前排列的每个位置,我们都有选择的数字集合。
- 我们从集合中选择一个数字,将其添加到排列中,并更新集合,移除已选择的数字。
- 例如,如果当前排列是 {1},我们可以选择2或3。选择2后,排列变为 {1, 2},选择3后,排列变为 {1, 3}。
#### 第四步:回溯
- 当我们到达排列的末尾时,我们检查排列是否已经形成了一个完整的序列(即,我们是否已经用完了所有数字)。
- 如果形成了完整的序列,我们将其添加到结果列表中。
- 如果没有形成完整的序列,我们回溯到上一个状态,并尝试另一个选择。
#### 第五步:重复
- 继续这个过程,直到所有的排列都已经被找到。
### 代码示例
以下是一个使用回溯算法解决全排列问题的Python代码示例:
```python
def permute(nums):
def backtrack(start, end):
if start == end:
result.append(nums[:])
for i in range(start, end):
nums[start], nums[i] = nums[i], nums[start]
backtrack(start + 1, end)
nums[start], nums[i] = nums[i], nums[start]
result = []
backtrack(0, len(nums))
return result
# 使用示例
permutations = permute([1, 2, 3])
for perm in permutations:
print(perm)
```
在这个代码中,`backtrack` 函数是一个递归函数,它接受两个参数:`start` 表示当前排列的起始位置,`end` 表示当前排列的结束位置。函数通过交换数字来构建排列,并在每一步都尝试所有可能的交换。
希望这个解释能帮助你更好地理解回溯算法在解决全排列问题中的应用。
发布于 2024-03-26 12:20・IP 属地江苏查看全文>>
知乎用户 - 0 个点赞 👍
查看全文>>
wyxsdzz - 0 个点赞 👍
查看全文>>
全赞工程师 - 0 个点赞 👍
回溯法 采用试错的思想,它尝试分步的去解决一个问题。
在分步解决问题的过程中,当它通过尝试发现现有的分步答案不能得到有效的正确的解答的时候,它将取消上一步甚至是上几步的计算,再通过其它的可能的分步解答再次尝试寻找问题的答案。
回溯法通常在解空间树上进行搜索,一般依赖的两种数据结构:子集树和排列树
子集树问题是从n个元素的集合S中找出满足某种性质的子集时,相应的解空间树,遍历子集树需O(2^n)计算时间
排列树问题是确定n个元素满足某种性质的排列时,相应的解空间树,遍历排列树需要O(n!)计算时间
设计思路
1.做题的时候,建议先画树形图,画图能帮助我们想清楚递归结构,想清楚如何剪枝。拿题目中的示例,想一想人是怎么做的,一般这样下来,这棵递归树都不难画出。
在画图的过程中思考清楚:
- 分支如何产生;
- 题目需要的解在哪里?是在叶子结点、还是在非叶子结点、还是在从跟结点到叶子结点的路径?
- 哪些搜索会产生不需要的解的?例如:产生重复是什么原因,如果在浅层就知道这个分支不能产生需要的结果,应该提前剪枝,剪枝的条件是什么,代码怎么写?【应用】[中等]单词搜索
题目描述:
给定一个 m x n 二维字符网格 board 和一个字符串单词 word 。如果 word 存在于网格中,返回 true ;否则,返回 false 。
单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。
// 回溯,时间复杂度O(MN*3的L次方),空间复杂度O(MN),M、N分别是board的行数、列数;L是word的长度 public boolean exist(char[][] board, String word) { int h = board.length, w = board[0].length; // 创建一个二维布尔数组用于记录当前位置是否被访问过 boolean[][] visited = new boolean[h][w]; // 遍历二维数组 for (int i = 0; i < h; i++) { for (int j = 0; j < w; j++) { // 调用check方法检查当前位置是否可以匹配word字符串的第一个字符,如果可以则继续 boolean flag = check(board, visited, i, j, word, 0); if (flag) { return true; } } } // 如果没有匹配成功,返回false return false; } public boolean check(char[][] board, boolean[][] visited, int i, int j, String s, int k) { // 如果当前字符不等于字符串s的第k个字符,返回false if (board[i][j] != s.charAt(k)) { return false; } else if (k == s.length() - 1) { // 如果字符串s的所有字符都匹配,返回true return true; } // 标记当前位置被访问 visited[i][j] = true; // 定义4个方向 int[][] directions = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}}; boolean result = false; // 遍历4个方向 for (int[] dir : directions) { int newi = i + dir[0], newj = j + dir[1]; if (newi >= 0 && newi < board.length && newj >= 0 && newj < board[0].length) { // 新的位置没有被访问 if (!visited[newi][newj]) { // 递归调用check函数 boolean flag = check(board, visited, newi, newj, s, k + 1); // 如果匹配,返回true if (flag) { result = true; break; } } } } // 标记当前位置没有被访问 visited[i][j] = false; return result; }
发布于 2024-03-22 13:00・IP 属地北京查看全文>>
arachis - 0 个点赞 👍
递归回溯法叫称为试探法,按选优条件向前搜索,当搜索到某一步,发现原先选择并不优或达不到目标时,就退回一步重新选择。这里有一个非常经典的例子叫“骑士巡逻问题”,这个例子应该更容易帮助你理解什么是递归回溯法。
所谓“骑士巡逻问题”是指按照国际象棋中骑士(Knight,走法类似于中国象棋中的“马”)的走法,一个骑士从棋盘的某一个格子出发,走遍整个棋盘的每一个方格,而且每个格子只能够经过一次,找出可能的线路和走法。如果骑士从某个格子出发,走完所有格子后最终能回到这个出发的格子,称这种巡逻方式为“封闭式巡逻”,否则称为“开放式巡逻”。国际象棋的棋盘是 \small{8 \times 8} 的方格,一共有26,534,728,821,064种封闭式巡逻,有19,591,828,170,979,904种开放式巡逻。对于下面的缩小为 \small{5 \times 5} 的棋盘,下面的动图给出了骑士的一种巡逻方案。
这里顺便吐槽一下,知乎回答问题时插入图片居然不支持gif,这里只能给一个gif图片的链接,图片来自于维基百科,可能需要科学一下才能看到。
下面用 Python 给出了“骑士巡逻问题”的代码,用其他语言书写本质上也没有什么差别。
SIZE = 5 def show_board(board, totals=[0]): """显示棋盘和第几种走法""" totals[0] += 1 print(f'第{totals[0]}种走法: ') for row in board: for col in row: print(str(col).center(4), end='') print() def patrol(board, row=0, col=0, step=1): """用递归回溯的方式搜索所有的巡逻方式 :param board: 棋盘(用嵌套列表表示) :param row: 骑士准备巡逻的位置对应的行 :param col: 骑士准备巡逻的位置对应的列 :param step: 序号(按巡逻的顺序依次标记) """ if row >= 0 and row < SIZE and col >= 0 and col < SIZE and board[row][col] == 0: # 如果row和col都在棋盘范围以内且对应的格子没有巡逻过就标记它 board[row][col] = step if step == SIZE * SIZE: # 所有的格子都被标记(一次巡逻完成)就输出棋盘 show_board(board) # 向下一个格子继续巡逻(国际象棋的骑士可以往八个方向进行) # 巡逻方向不同row和col的变化就不同,但step都是统一加1 patrol(board, row - 2, col - 1, step + 1) patrol(board, row - 1, col - 2, step + 1) patrol(board, row + 1, col - 2, step + 1) patrol(board, row + 2, col - 1, step + 1) patrol(board, row + 2, col + 1, step + 1) patrol(board, row + 1, col + 2, step + 1) patrol(board, row - 1, col + 2, step + 1) patrol(board, row - 2, col + 1, step + 1) # 八个方向都行不通或者所有格子已经巡逻完毕就回到上一步(回溯) # 回溯时要记得清除之前标记的step,标记为0相当于格子未被巡逻 board[row][col] = 0 def main(): # 初始化棋盘(每个格子都标为0表示没有巡逻过) board = [[0] * SIZE for _ in range(SIZE)] patrol(board) if __name__ == '__main__': main()
看看上面的代码是否能够帮助你更好的理解递归回溯算法。有一个小建议,给你的代码增加一些断点,然后用调试模式运行代码,通过单步执行并观察你的函数调用栈以及相关变量的值,就能更好的理解整个代码是如何运转起来的。
最后,还是给你一个C++版本的代码,我不确定你写的C还是C++,我也很久没有写过C++的代码了,下面的代码倒是可以运转起来的,但是不知道写得是否精准,你自己甄别吧。
#include <iomanip> #include <iostream> #include <vector> using namespace std; const int SIZE = 5; void show_board(const vector<vector<int> >& board) { static int total = 0; cout << "第" << ++total << "种走法: " << endl; for (const auto& row : board) { for (const auto& col : row) { cout << setw(4) << col; } cout << endl; } } void patrol(vector<vector<int> >& board, int row = 0, int col = 0, int step = 1) { if (row >= 0 && row < SIZE && col >= 0 && col < SIZE && board[row][col] == 0) { board[row][col] = step; if (step == SIZE * SIZE) { show_board(board); } patrol(board, row - 2, col - 1, step + 1); patrol(board, row - 1, col - 2, step + 1); patrol(board, row + 1, col - 2, step + 1); patrol(board, row + 2, col - 1, step + 1); patrol(board, row + 2, col + 1, step + 1); patrol(board, row + 1, col + 2, step + 1); patrol(board, row - 1, col + 2, step + 1); patrol(board, row - 2, col + 1, step + 1); board[row][col] = 0; } } int main() { vector<vector<int> > board(SIZE, vector<int>(SIZE, 0)); patrol(board); return 0; }
写到这里就顺便吐槽一下C++,用嵌套容器时两个大于号放一起会跟 >> 运算符冲突,原来的解决方案是在两个大于号中间添加一个空格,但这样会让整个代码看起来丑陋无比,就像我上面代码中写的那样,真心不喜欢!我记得从什么时候开始,C++是不是编译的时候加个参数 -std 就可以解决这个问题,具体是怎么操作的我也没啥印象了,如果有大佬知道这个问题的解决方案和背后的道理,也请大家不吝赐,感谢!
编辑于 2024-03-24 11:07・IP 属地四川查看全文>>
Python-Jack - 0 个点赞 👍
你的代码不完整,也没有原题,所以没法根据你的代码给你讲解。不过回溯本身还是很好理解的。举个例子,就好像你走迷宫,迷宫问题是回溯算法经常使用的场景。假设你从入口进入迷宫,如果面前只有一条路,那你是不是就要继续走下去?这就是使用递归的地方。如果到达一个路口,面前有几条岔路,你是不是应该每条岔路都走一下试试?这就是该使用循环的地方。而当你走到头发现刚才走过的是条死路,你是不是应该退回到上一个路口,根据这个路口的循环顺序去走下一条没走过的岔路?这就是回溯了。
发布于 2024-03-20 23:49・IP 属地北京查看全文>>
张鑫 - 0 个点赞 👍
回溯算法,就像是在一个迷宫里寻找出路,当你发现当前的路径走不通时,你就会退回到上一个路口,重新选择一条新的路继续走。这种“走不通就退回再走”的策略,就是回溯算法的核心思想。
回溯算法是一种试探性的算法,它会在问题的解空间中,按照某种策略(比如深度优先搜索)进行搜索。在搜索的过程中,它会不断地尝试各种可能性,并判断这些可能性是否满足问题的要求。一旦发现当前的选择不满足要求,它就会立即回溯到上一步,重新尝试其他的可能性。
这种算法非常适用于解决那些具有多个可能解的问题,比如排列组合问题、图的遍历问题等。通过回溯算法,我们可以找到问题的所有解,或者找到满足特定条件的最优解。
当然,回溯算法也有一些缺点,比如当问题的规模很大时,它可能需要搜索的解空间会非常大,这会导致算法的运行时间很长,甚至可能无法找到解。因此,在使用回溯算法时,我们需要根据具体问题的特点,选择合适的搜索策略和剪枝策略,以提高算法的效率。
总之,回溯算法是一种非常强大且灵活的算法,它可以帮助我们解决许多复杂的问题。通过理解并掌握回溯算法的思想和方法,我们可以更好地应对各种挑战,找到问题的最优解。
发布于 2024-03-23 10:58・IP 属地河南查看全文>>
辰乜佳 - 0 个点赞 👍
回溯算法是一种经典的解决问题的算法思想,通常用于求解组合类问题、排列类问题或者搜索类问题。
其基本思想是通过尝试不同的可能性,当达到某种情况时,发现这条路走不通了,就回到上一步重新尝试其他可能性,直至找到问题的解或者确定无解。
这是一个Python 例子用于求解一个数组中的子集问题,如果对你有帮助 请收藏和关注。
def backtrack(nums, path, res, start):
# 将当前子集加入结果集中
res.append(path[:])
# 从当前位置开始遍历数组
for i in range(start, len(nums)):
# 将当前元素加入到正在构建的子集中
path.append(nums[i])
# 递归处理下一个元素
backtrack(nums, path, res, i + 1)
# 回溯,将当前元素从子集中移除,尝试其他可能性
path.pop()
def subsets(nums):
res = []
backtrack(nums, [], res, 0)
return res
nums = [1, 2, 3]
print(subsets(nums))
发布于 2024-03-22 17:17・IP 属地重庆查看全文>>
算法软件开发工程 - 0 个点赞 👍
谢邀,如果不知道如何理解回溯算法的话,可以看看这个视频。
【和马士兵老师一起展望2024IT走向,并直播连麦解决程序员的疑难杂症【马士兵】-哔哩哔哩】 https://b23.tv/PsrULsM
希望能够帮助到您的学习!
发布于 2024-03-21 19:48・IP 属地湖南查看全文>>
程序员xysam - 0 个点赞 👍
打个形象的比喻,就好像走迷宫一样,做着点记号,发现某条路不对劲,就走走回头路,最终找到出口。
一般图遍历,树遍历算法会用到这些,所以,可以好好理解一下这两个数据结构算法。
实际应用场景的话,可以理解一下最短路径问题,背包问题等问题。
发布于 2024-03-23 09:38・IP 属地北京查看全文>>
知乎用户 - 0 个点赞 👍
查看全文>>
维克多 - 0 个点赞 👍
查看全文>>
路人甲 - 0 个点赞 👍
查看全文>>
遂古之初 - 0 个点赞 👍
第一阶段:用IDE单步debug,看执行过程,并理解这个执行过程。
第二阶段:改成用人脑模拟程序的执行过程。
第三阶段:熟练了之后,自然就能理解递归和回溯的过程了。
另外:在以上的基础上,有时候可以偷个懒,有个写递归函数的要点:明白一个函数的作用并相信它能完成这个任务,有时候不需要跳进这个函数里面企图探究更多细节, 否则可能会陷入无穷的细节无法自拔。
发布于 2024-03-18 21:53・IP 属地浙江查看全文>>
深山小妖 - 3 个点赞 👍
首先,理解递归。众所周知,人脑的栈是有限且远远达不到计算机的水平的,所以,要想在大脑中或者通过纸笔一层层的模拟理解递归,最终的结果只有一个,就是把自己绕在里面。理解递归的第一步,就是要纵观全局。
理解递归
理解递归最好的方案就是二叉树的遍历,要完成一棵二叉树的遍历,最简单的实现莫过于递归的方式了,以力扣144题:二叉树的前序遍历为例,一份可行的递归代码实现如下所示:
/** * Definition for a binary tree node. * struct TreeNode { * int val; * struct TreeNode *left; * struct TreeNode *right; * }; */ /** * Note: The returned array must be malloced, assume caller calls free(). */ #define MAX_NODE_NUN 100 int* preorderTraversal(struct TreeNode* root, int* returnSize) { int *ret = malloc(sizeof(int) * (MAX_NODE_NUN+1)); *returnSize = 0; preorder(root, returnSize, ret); return ret; } void preorder(struct TreeNode* root, int* retIndex, int *ret) { if (root == NULL) { return; } ret[(*retIndex)++] = root->val; preorder(root->left, retIndex, ret); preorder(root->right, retIndex, ret); }
在核心的
preorder()
函数中,只做了三件事情:- 边界/终止条件控制:当节点为空时,直接返回
- 做操作,此例中为将节点的值加入数组末尾
- 分别遍历左子树
root->left
和右子树root->right
,也就是递归的部分
而这三步,基本上是所有递归程序的通用步骤,你如果需要探求实际的执行过程,你只需要保证解决问题的方法无误,边界控制无误,那么最终结果就会是正确的。(当然,初学时可以使用比较小型的二叉树画着理解下执行过程,以及将递归的过程转使用循环的代码写出来
理解回溯
所谓回溯,其实就是多次执行递归,以你题目中的问题为例,结合你的图分析你的代码:
void backTracking(int* nums, int numsSize, int* used) { if(pathTop == numsSize) { copy(); return; } int i; for(i = 0; i < numsSize; i++) { if(used[i]) continue; used[i] = 1; path[pathTop++] = nums[i]; backTracking(nums, numsSize, used); //回溯 pathTop--; used[i] = 0; } }
-
if
判断的作用: 终止条件控制 -
for
循环的作用:依次选取数组中的数组中的一个数字 - 递归的作用:选取下一个数字,也就是进入你图中的第二层
那么剩下的,也只有循环中递归代码之前和之后的部分了,其功能简单来说:在递归之前,将选择的数字加入了数组,在递归之后,将选择的数字移出去了数组。需要理解的一点是,递归之前的代码,是为了本次做准备,递归之后的代码,则是为了下次做准备,这也是为何要在递归之后进行移除的原因。
简单模拟:
# [1,2,3,4]中取两个数 首次循环开始: nums[i]为1,取1后开始递归, 选取下一个数字,将会取 [2,3,4] (因为1取过之后使用used[i]进行了标记) 第二次循环开始: nums[i]为2,取2后开始递归 但是取2之前需要将递归之前取的1删掉,因为每次只能取一个数,进入递归之后才会取下一个数 ...
如上,望能理解~
发布于 2024-03-23 02:04・IP 属地江苏查看全文>>
文的盲