目录
一、前言
本篇文章是本人通过代码随想录学习之后写下的,所以有部分内容与代码随想录写的是相似的,这里也通过这篇文章感谢代码随想录🙏🙏🙏
• 什么是回溯算法
1)百度百科:回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。
2)回溯算法实际上就是递归,相比与嵌套for循环这种暴力的方法,回溯则是一种“刚中带柔”的方法,代码实现起来也相对简洁,但他的效率其实并不是很高,同样是一种暴力的解法。
• 用回溯能解决那些题型
⬇️⬇️⬇️对应力扣上的题⬇️⬇️⬇️
这里有leetcode的题目链接,大家可以去练练手,看看你是否有思路?
1)组合 | 组合 | 组合总和 |
2)分割 | 分割回文串 | 复原IP地址 |
3)子集 | 子集 | 子集Ⅱ |
4)排列 | 全排列 | 全排列Ⅱ |
5)棋盘 | N皇后 | 解数独 |
不难发现,这些题差不多都是要我们搜索符合条件的子情况,用回溯这样的暴力搜索就能将所有情况都搜索出来,然后再筛选符合条件的情况;
所以以后遇到类似的问题大家都可以考虑一下能否用回溯来解决。
二、总体思路
1. 通过树逐层解剖
2. 代码框架
void backTracking()
{
if (终止条件)
{
存放符合条件的子集;
return;
}
for (int i = 本层递归的开始位置; i < 集合大小; i++)
{
存放该结点;
bcakTracking(); //递归
回溯,将结点从子集中删除;
}}
3. 是否需要去重
有些题给出的集合中有重复的数字,那么就要考虑得到的子集有重复的情况了;
例如给出一个集合{1,1,2},找出总和为3的所有不重复的子集,加入先取第一个1,那么后面一个1在同一树层上就不能取了,但是在同一树枝上可以取;
具体去重操作我们在下面例题里具体说明⬇️
三、例题
1. 组合总和Ⅱ
给定一个候选人编号的集合
candidates
和一个目标数target
,找出candidates
中所有可以使数字和为target
的组合。
candidates
中的每个数字在每个组合中只能使用 一次 。示例 1:
输入: candidates = [10,1,2,7,6,1,5], target = 8 输出:[ [1,1,6] , [1,2,5] , [1,7] , [2,6] ]
这里我们以题中例题的序列为例:
1)排序
首先我们先将这个序列进行排序(以插入排序为例,之前文章有讲解),便于之后的去重操作(有些题改变了顺序不能求出答案,可以尝试其他方法去重)
2)去重
if (i > startIndex && candidates[i] == candidates[i - 1])
continue;
配合树来看,上述中提到的去重操作为什么写成这样呢?
• 设想如果我们第一层选了1,那么下一层是可以选下一个1的,这两个1没有在位置上重复
(i== startIndex && candidates[i] == candidates[i - 1])
• 但是在第一层选了1之后回溯到第二个1的时候,如果再选了它,那么与他组成的子集必定会与第一个1组成的子集重复,所以我们要将第二个1跳过
(i > startIndex && candidates[i] == candidates[i - 1])
3)树:
4)回溯框架
• 树的横向是该函数栈帧中的for循环,那么如何控制for循环的开始位置(及从序列的第几个数开始遍历)就非常重要了:
因为组合是不讲究顺序的,如果每层for循环从序列的第一个数开始,那么肯定有重复的组合,例如一次层选1,第二层选2和第一层选2,第一层选1,他们构成的组合都是[ 1, 2 ];
所以每层for循环要从该数开始(startIndex),往后遍历。
• 纵向是递归,递归函数的条件是什么呢?
上述说到每层for的起始位置是下一个数,那么进入下一层树我们就该将本层遍历到第 i 个数的下一个数传下去
void backTracking(int* candidates, int candidatesSize, int target, int sum, int startIndex)
{
if (sum > target)
return;
if (sum == target) //将本次符合条件的子集复制,再存放到结果集里
{
int* tmppath = (int*)malloc(sizeof(int) * pathTop);
for (int i = 0; i < pathTop; i++)
{
tmppath[i] = path[i];
}
lenth[retTop] = pathTop;
ret[retTop++] = tmppath;
}
for (int i = startIndex; i < candidatesSize; i++)
{
if (i > startIndex && candidates[i] == candidates[i - 1]) //去重
continue;
sum += candidates[i];
path[pathTop++] = candidates[i];
backTracking(candidates, candidatesSize, target, sum, i + 1); //i+1及下一层树的startIndex
sum -= candidates[i]; //回溯
pathTop--;
}
}
5)整体代码
//定义为全局变量,以便减少递归传参的数量
int* path; //符合条件的单个子集
int** ret; //结果集
int* lenth; //每个子集的长度
int retTop; //结果的个数
int pathTop; //记录子集的长度
void backTracking(int* candidates, int candidatesSize, int target, int sum, int startIndex)
{
if (sum > target)
return;
if (sum == target)
{
int* tmppath = (int*)malloc(sizeof(int) * pathTop);
for (int i = 0; i < pathTop; i++)
{
tmppath[i] = path[i];
}
lenth[retTop] = pathTop;
ret[retTop++] = tmppath;
}
for (int i = startIndex; i < candidatesSize; i++)
{
if (i > startIndex && candidates[i] == candidates[i - 1])
continue;
sum += candidates[i];
path[pathTop++] = candidates[i];
backTracking(candidates, candidatesSize, target, sum, i + 1);
sum -= candidates[i];
pathTop--;
}
}
void Sort(int* candidates, int candidatesSize) //插入排序
{
for (int i = 0; i < candidatesSize - 1; i++)
{
int end = i;
int tmp = candidates[i + 1];
while (end >= 0)
{
if (candidates[end] > tmp)
{
candidates[end + 1] = candidates[end];
end--;
}
else
break;
}
candidates[end + 1] = tmp;
}
}
int** combinationSum2(int* candidates, int candidatesSize, int target, int* returnSize, int** returnColumnSizes)
{
path = (int*)malloc(sizeof(int) * 100); //开辟空间
ret = (int**)malloc(sizeof(int*) * 100);
lenth = (int*)malloc(sizeof(int*) * 100);
retTop = pathTop = 0;
Sort(candidates, candidatesSize); //排序
backTracking(candidates, candidatesSize, target, 0, 0); //回溯
//返回结果
*returnSize = retTop;
*returnColumnSizes = (int*)malloc(sizeof(int) * retTop);
for (int i = 0; i < retTop; i++)
{
(*returnColumnSizes)[i] = lenth[i];
}
return ret;
}
2. 全排列Ⅱ
给定一个可包含重复数字的序列
nums
,按任意顺序 返回所有不重复的全排列。示例 1:
输入:nums = [1,1,2] 输出: [[1,1,2] , [1,2,1] , [2,1,1]]
1)排序
还是先对序列排序,方便之后去重
2)树
3)回溯框架
• 树的横向是该函数栈帧中的for循环:与组合不同[1,1,2] 与 [1,2,1]是同一组合不同排列,所以每次for循环的其实位置都是序列的第一个元素
• 纵向是递归:每次for循环的条件不变,所以递归传到下一层树的条件是一样的,不需要对传入的参数进行调整;
void backTracking(int* nums, int numsSize, int* used)
{
if (pathTop == numsSize)
{
int* tmppath = (int*)malloc(sizeof(int) * numsSize);
for (int i = 0; i < numsSize; i++)
{
tmppath[i] = path[i];
}
ret[retTop++] = tmppath;
}
for (int i = 0; i < numsSize; i++)
{
if (used[i] == 1 || (i > 0 && used[i - 1] == 0 && nums[i] == nums[i - 1]))
continue;
path[pathTop++] = nums[i];
used[i] = 1;
backTracking(nums, numsSize, used);
used[i] = 0;
pathTop--;
}
}
4)去重
if (used[i] == 1 || (i > 0 && used[i - 1] == 0 && nums[i] == nums[i - 1]))
continue;
这道题的难点就是去重这步操作,如何想出这一系列去重条件呢?
• 因为每层树及每次for循环都是从第一个数开始,而同一个位置的数不能重复使用,所以我们可以定义一个与集合大小一样的数组used,用来记录该位置的数是否被用过(用过置为1,没用过置为0)
used[ i ] == 1
• 之前说过将集合排序,相同的数就挨在一起了,举例当同一个for循环中第二个1与第一个1相等,那么这时在选取第二个1得到的子集与选取第一个1的子集肯定有重复;
如何判断是否在同一层for循环中的?我们知道一个树枝结束后要回溯,及将该位置的used数组重置为0,所以在同一层遇到第二个1时,只去要看前一个位置的used是否为0( ued[i-1] ),是1则表示不在同一层for循环而是在上一层递归,是0则表示在同一层for循环。
i > 0 && used[i - 1] == 0 && nums[i] == nums[i - 1]
5)整体代码
//定义为全局变量,以便减少递归传参的数量
int** ret; //结果集
int* path; //记录当前排列
int retTop; //记录排列的个数
int pathTop; //记录放入排列的数量
void Sort(int* nums, int numsSize) //插入排序
{
for (int i = 0; i < numsSize - 1; i++)
{
int end = i;
int tmp = nums[end + 1];
while (end >= 0)
{
if (nums[end] > tmp)
{
nums[end + 1] = nums[end];
end--;
}
else
break;
}
nums[end + 1] = tmp;
}
}
void backTracking(int* nums, int numsSize, int* used)
{
if (pathTop == numsSize)
{
int* tmppath = (int*)malloc(sizeof(int) * numsSize);
for (int i = 0; i < numsSize; i++)
{
tmppath[i] = path[i];
}
ret[retTop++] = tmppath;
}
for (int i = 0; i < numsSize; i++)
{
if (used[i] == 1 || (i > 0 && used[i - 1] == 0 && nums[i] == nums[i - 1]))
continue;
path[pathTop++] = nums[i];
used[i] = 1;
backTracking(nums, numsSize, used);
used[i] = 0;
pathTop--;
}
}
int** permuteUnique(int* nums, int numsSize, int* returnSize, int** returnColumnSizes)
{
path = (int*)malloc(sizeof(int) * numsSize); //开辟空间
ret = (int**)malloc(sizeof(int*) * 1000);
retTop = pathTop = 0;
int* used[numsSize];
memset(used, 0, sizeof(used)); //初始化used数组
Sort(nums, numsSize);
backTracking(nums, numsSize, used);
//返回结果
*returnSize = retTop;
*returnColumnSizes = (int*)malloc(sizeof(int) * retTop);
for (int i = 0; i < retTop; i++)
{
(*returnColumnSizes)[i] = numsSize;
}
return ret;
}
3. 解数独
编写一个程序,通过填充空格来解决数独问题。
数独的解法需 遵循如下规则:
- 数字
1-9
在每一行只能出现一次。- 数字
1-9
在每一列只能出现一次。- 数字
1-9
在每一个以粗实线分隔的3x3
宫内只能出现一次。数独部分空格内已填入了数字,空白格用
'.'
表示。示例 1:
1)两层for循环遍历棋盘
• 之前做的题都是一维的,只需要在一层for循环里套递归回溯,而解数独是个二维数组,需要在两层for循环套递归回溯,用两层for循环遍历整个棋盘;
2)for + 递归 + 回溯
• 题中 ‘ . ’ 表示没有填数字,所以我们紧接下来用for循环依次判断1到9的数填入本次空是否合法,如果遇到合法的数就将其填入并进入下次递归;
如果1到9都没有合法的则说明之前填入的数需要撤回,以便之后的数可以合法填入。这时候就需要在整个for循环结束后return false ,在递归下面紧接这回溯的操作
if(board[i][j] == '.')
{
for (int k = '1'; k <= '9'; k++)
{
if (isvalid(board, i, j, k)) //判断k是否填入合法
{
board[i][j] = k;
bool result = solveSudoku(board, boardSize, boardColSize); //递归棋盘
if (result == true) //改步骤看总代码分析
return true;
board[i][j] = '.'; //回溯
}
}
return false; //整个for循环走完也没有合法的数,则返回false
}
3)那什么时候才算完成数独呢?
当两个for循环走完(及整个棋盘遍历完)也没有遇到 ‘ . ’ (及所有棋盘都填满数字),则返回 true,这时在之前递归步骤之下的 return true会引发连锁反应,逐渐返回到第一层递归,最后返回函数。
4) 整体代码
bool isvaild(char** board, int x, int y, char k) //判断是否合法
{
int startRow = (x / 3) * 3;
int startCol = (y / 3) * 3;
for (int i = startRow; i < startRow + 3; i++)
{
for (int j = startCol; j < startCol + 3; j++)
{
if (board[i][j] == k)
{
return false;
}
}
}
for (int i = 0; i < 9; i++)
{
if (board[i][y] == k)
return false;
}
for (int j = 0; j < 9; j++)
{
if (board[x][j] == k)
return false;
}
return true;
}
bool solveSudoku(char** board, int boardSize, int* boardColSize)
{
for (int i = 0; i < boardSize; i++)
{
for (int j = 0; j < boardSize; j++)
{
if (board[i][j] == '.')
{
for (int k = '1'; k <= '9'; k++)
{
if (isvaild(board, i, j, k))
{
board[i][j] = k;
bool result = solveSudoku(board, boardSize, boardColSize);
if (result == true)
return true;
board[i][j] = '.';
}
}
return false;
}
}
}
return true; //整个棋盘填满之后返回true
}
到这里回溯算法的解法与例题就结束了,希望大家有所收获,文章有错误或者有疑问都可以评论联系我,我们一起学习🙏