深入理解回溯算法
回溯算法是一种经典的问题解决方法,通常用于解决搜索和优化问题。它的核心思想是尝试在解空间中搜索所有可能的解,并在找到合适的解或者确定不存在解时回溯到上一步继续搜索。在本文中,我们将探讨回溯算法的原理、应用场景以及实现方式。
原理解析
回溯算法基于深度优先搜索(DFS)的思想,它通过递归地尝试每一种可能的情况来解决问题。在搜索过程中,当遇到无法满足条件的情况时,就会回溯到上一步选择另一种可能性。这种反复搜索的过程直到找到解或者确定不存在解为止。
应用场景
回溯算法在许多问题中都有广泛的应用,例如:
- 组合问题:给定一组数,从中找出所有可能的组合。
- 排列问题:给定一组数,找出所有可能的排列方式。
- 子集问题:给定一组数,找出所有可能的子集。
- 图的遍历:如深度优先搜索(DFS)就是一种回溯算法。
- 棋盘问题:如八皇后问题、数独等。
回溯法的效率
回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案,所以回溯的效率并不高,如果想让回溯法高效一些,可以加一些剪枝的操作,但也改不了回溯法就是穷举的本质。
既然效率不高,为什么要选择回溯算法呢?其实在需要用到回溯算法的地方,也只能用回溯算法。
回溯算法的实现
回溯算法的原理非常简单,它基于深度优先搜索(DFS)的思想,就是问题可以想象成树形结构。
回溯算法是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度就构成了树的深度。
所以基本流程是这样:
- 结束条件:根据题目要求,及时退出递归并回收结果。
- 选择路径:根据问题定义,做出一些选择,通常是在搜索树中向下一个节点移动。
- 递归探索:递归地尝试所有可能的选择,每次选择后进入下一层递归。
- 检查条件:在每一步检查当前选择是否符合条件,如果符合继续探索,否则回溯到上一步。
- 撤销选择:在回溯时撤销当前选择,继续尝试其他可能性。
接下来看看回溯算法的基本模板:
首先是结束条件,这里的条件就取决于不同的问题:
if (终止条件) {
存放结果;
return;
}
然后是选择路径,将问题抽象化成一棵树,一般是横向遍历搜索,纵向递归搜索:
如图:
遍历的代码就是
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
所以整体的代码框架就是:
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
回溯算法难以理解,所以还是要从题目出发,这里从组合,子集,排列问题各选一题来理解回溯思想。
77. 组合
给定两个整数 n
和 k
,返回范围 [1, n]
中所有可能的 k
个数的组合。
你可以按 任何顺序 返回答案。
示例 1:
输入:n = 4, k = 2
输出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]
示例 2:
输入:n = 1, k = 1
输出:[[1]]
提示:
1 <= n <= 20
1 <= k <= n
对于这道题目,如果是两个数,暴力的解法就是嵌套两层for循环找出所有的代码情况,但是随着数字变多,这种方法就变得不切实际了,所以说用到回溯算法的题目,一般就只能用回溯算法了。
将所有组合都树形化,这样能直观的看到每种组合的情况,然后我们的代码就要在这棵树上收取结果,跟着回溯的模板,我们要思考递归的结束条件判断,这里比较明显,就是判断路径上的数长度是否超过了k。然后是搜索阶段,这题没有限制,所以只需要从剩下的数里面循环寻找就可以了,然后进入下一层循环。最后是撤销选择,这个时候我们只需要将路径上的数字删掉就可以了,然后新的数就会顶替原来的位置。
下面就是具体的代码实现:
int anstop; //定义答案数组,这里要求返回二维数组
int** ans;
int pathtop; //这里定义路径数组,其实就是记录树上每条路径的组合,用来处理叶子节点
int* path;
void backTracking(int n, int k, int startIndex) { //回溯递归函数,startIndex指的是当前遍历到了哪一个数
if(pathtop == k) { //当我的记录数组的长度达到了k,就说明我已经找到了长度为k的组合,也就是我们的答案之一,这里就需要返回这个数组了
int* temp = (int*)malloc(sizeof(int) * k);
int i;
for(i = 0; i < k; i++) { //将path数组里面记录的值存入临时数组
temp[i] = path[i];
}
ans[anstop++] = temp; //将临时数组存入答案数组,并将答案数组的指针加一
return;
}
int j;
for(j = startIndex; j <= n; j++) { //这部分是对节点的具体操作,从当前值继续向后遍历
path[pathtop++] = j; //先将当前的值记录
backTracking(n, k, j + 1); //然后调用递归函数,将下一个值带进去
pathtop--; //这部分则是具体的回溯操作,因为每当函数返回了值,我们还需要将上一个节点的值弹出,为新的值空出位置
}
}
int** combine(int n, int k, int* returnSize, int** returnColumnSizes){
ans = (int**)malloc(sizeof(int*) * 100000);
path = (int*)malloc(sizeof(int) * k);
anstop = pathtop = 0;
backTracking(n, k, 1);
*returnSize = anstop;
*returnColumnSizes = (int*)malloc(sizeof(int) * (*returnSize));
int i;
for(i = 0; i < anstop; i++) {
(*returnColumnSizes)[i] = k;
}
return ans;
}
78. 子集
给你一个整数数组 nums
,数组中的元素 互不相同 。返回该数组所有可能的
子集
(幂集)。
解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
示例 1:
输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
示例 2:
输入:nums = [0]
输出:[[],[0]]
提示:
-
1 <= nums.length <= 10
-
-10 <= nums[i] <= 10
-
nums
中的所有元素 互不相同这是是子集问题,但是和组合问题的思想是一样的。
按照之前的回溯思想,收集答案的时候都是从叶子节点上来收集的,但是对于这道题目,当我们把树状图画出来的时候,我们可以发现数上每一个节点都是答案的一种可能,所以在之前的回溯方法中,我们收集答案的时候都会添加一些判断,以此来判断是否是有效答案,但是这一题的关键就是每次进入一层递归时,都要直接将path数组中记录的答案加入答案数组中,不用添加别的判断,然后在超出数组范围时退出递归;
int** ans;
int anstop;
int* path;
int pathtop;
int* length;
void backtracking(int* nums, int numsSize, int startindex) {
int* temp = (int*)malloc(sizeof(int) * pathtop); //这里是将记录的叶子节点加入答案数组
int i;
for(i = 0; i < pathtop; i++) {
temp[i] = path[i];
}
length[anstop] = pathtop;
ans[anstop++] = temp;
if(startindex >= numsSize) {
return;
}
int j;
for(j = startindex; j < numsSize; j++) {
path[pathtop++] = nums[j]; //将当前的数字记录在path数组中
backtracking(nums, numsSize, j + 1); //进行下一次递归
pathtop--;
}
}
int** subsets(int* nums, int numsSize, int* returnSize, int** returnColumnSizes) {
path = (int*)malloc(sizeof(int) * numsSize);
ans = (int**)malloc(sizeof(int*) * 100000);
length = (int*)malloc(sizeof(int) * 1500);
anstop = pathtop = 0;
backtracking(nums, numsSize, 0);
*returnSize = anstop;
*returnColumnSizes = (int*)malloc(sizeof(int) * anstop);
int i;
for(i = 0; i < anstop; i++) {
(*returnColumnSizes)[i] = length[i];
}
return ans;
}
turnColumnSizes = (int*)malloc(sizeof(int) * anstop);
int i;
for(i = 0; i < anstop; i++) {
(*returnColumnSizes)[i] = length[i];
}
return ans;
}
46. 全排列
给定一个不含重复数字的数组 nums
,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。
示例 1:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
示例 2:
输入:nums = [0,1]
输出:[[0,1],[1,0]]
示例 3:
输入:nums = [1]
输出:[[1]]
提示:
1 <= nums.length <= 6
-10 <= nums[i] <= 10
nums
中的所有整数 互不相同
排列问题就有点不同了,主要在于排列问题讲究顺序,我们需要思考在将所有组合情况找出来的同时,还要区别不同的顺序。依然是先将问题树形化:
以[1,2,3]为例,抽象成树形结构如下:
在排列问题当中,我们需要用一个数组来记录哪些数字我们已经使用过了,如图中的橘黄色部分。
按照模板来思考我们需要做什么:
1.递归结束条件:可以看出叶子节点,就是收割结果的地方。那么什么时候,算是到达叶子节点呢?当收集元素的数组path的大小达到和nums数组一样大的时候,说明找到了一个全排列,也表示到达了叶子节点。
2.单层搜索:因为排列问题讲究顺序,所以不能像前面的问题一样从剩下的数字里面寻找合适的,我们要从头开始遍历数组,以防错过任何一种顺序,但是为了避免重复,我们还要利用used数组进行已使用过数字的判断。
3.递归搜索:这里要关键是将uesd数组传下去。
4.回溯操作:将使用过的数字状态改回来。
下面是具体代码实现:
int** ans; // 存储最终结果的二维数组
int anstop; // 记录结果的数量
int* path; // 存储当前路径的一维数组
int pathtop; // 记录当前路径的长度
// 回溯函数,用于生成排列
void backtracking(int* nums, int numsSize, int* used) {
// 当路径长度等于数组长度时,表示找到一个排列
if(pathtop == numsSize) {
// 创建一个新的一维数组,存储当前排列
int* temp = (int*)malloc(sizeof(int) * numsSize);
for(int i = 0; i < numsSize; i++) {
temp[i] = path[i];
}
// 将当前排列存入最终结果数组中
ans[anstop++] = temp;
return;
}
int j;
// 遍历选择列表
for(j = 0; j < numsSize; j++) {
// 如果数字已经被使用,则跳过
if(used[j] == 1) {
continue;
}
// 标记数字为已使用
used[j] = 1;
// 将当前数字加入路径中
path[pathtop++] = nums[j];
// 递归搜索下一层
backtracking(nums, numsSize, used);
// 撤销当前选择
used[j] = 0;
pathtop--;
}
}
// 主函数,生成排列
int** permute(int* nums, int numsSize, int* returnSize, int** returnColumnSizes) {
// 分配内存空间
ans = (int**)malloc(sizeof(int) * 10000);
path = (int*)malloc(sizeof(int) * numsSize);
anstop = pathtop = 0;
// 初始化用于标记数字是否被使用的数组
int* used = (int*)malloc(sizeof(int) * numsSize);
memset(used, numsSize, 0);
// 调用回溯函数生成排列
backtracking(nums, numsSize, used);
// 设置返回值
*returnSize = anstop;
*returnColumnSizes = (int*)malloc(sizeof(int) * anstop);
for(int i = 0; i < anstop; i++) {
(*returnColumnSizes)[i] = numsSize;
}
return ans;
}
int* used = (int*)malloc(sizeof(int) * numsSize);
memset(used, numsSize, 0);
// 调用回溯函数生成排列
backtracking(nums, numsSize, used);
// 设置返回值
*returnSize = anstop;
*returnColumnSizes = (int*)malloc(sizeof(int) * anstop);
for(int i = 0; i < anstop; i++) {
(*returnColumnSizes)[i] = numsSize;
}
return ans;
}