回溯算法
组合
基本回溯
要把问题转换成树的形式,参考代码随想录 (programmercarl.com)
-
相当于对树进行深度搜索的过程
-
用path记录到叶节点的路径,所谓叶节点也就是满足k个数的情况,亦即递归函数出口。
path相当于一个栈
-
当到达叶节点时,path中存放的就是一个组合,需要把这个组合保存到结果集合中
然后进行回溯,将栈顶节点退出,继续查看下一个节点
-
当前遍历需要遍历从开始节点s到n的所有数
使用开始节点s是为了防止重复
实现代码如下
//直接定义全局变量,方便递归操作
int **ans; //组合集合
int *path; //当前组合
int top; //path栈指针
int cnt; //组合个数
void fun(int n, int k, int s){
int i;
if(top == k){ //递归出口,组合中的数已经达到k个
int *temp = (int *) malloc(sizeof(int) * k);
for(i = 0; i < top; ++i){
temp[i] = path[i];
}
ans[cnt++] = temp; //将这个组合保存在结果集中
}
else{
for(i = s; i <= n; ++i){
path[top++] = i;
fun(n, k, i + 1);
top--; //回溯
}
}
}
int** combine(int n, int k, int* returnSize, int** returnColumnSizes){
int i;
ans = (int **) malloc(sizeof(int *) * 10000);
path = (int *)malloc(sizeof(int) * 22);
cnt = 0;
top = 0;
fun(n, k, 1);
*returnSize = cnt;
*returnColumnSizes = (int *) malloc(sizeof(int) * cnt);
for(i = 0; i < cnt; ++i){
(*returnColumnSizes)[i] = k;
}
return ans;
}
优化(剪枝)
避免进入根本不可能的情况的递归
-
已经选择的元素个数:path.size();
-
所需需要的元素个数为: k - path.size();
-
列表中剩余元素(n-i+1) >= 所需需要的元素个数(k - path.size())
不满足这个条件,则不有足够的需要的组合数
-
在集合n中至多要从该起始位置 : i <= n - (k - path.size()) + 1,开始遍历
优化代码
for(i = s; i <= n-(k-top)+1; ++i){
path[top++] = i;
fun(n, k, i + 1);
top--; //回溯
}
同一种类型的题目,只不过在选择的时候多了一个限制条件,差别在
- 回溯时,有几个限制条件就要处理几个回溯,不能漏(⭐重要)
- 剪枝可以考虑多个条件(减枝之前先处理回溯)
实现代码
int **ans;
int *path;
int top;
int cnt;
int sum; //新增限制条件
void fun(int k, int n, int start){
int i;
if(top == k){
if(sum == n){ //新增限制条件
int *temp = (int *) malloc(sizeof(int) * top);
for(i = 0; i < top; ++i){
temp[i] = path[i];
}
ans[cnt++] = temp;
}
}
else{
for(i = start; i <= 9; ++i){
path[top++] = i;
sum += i;
fun(k, n, i + 1);
sum -= i; //回溯,别忘了回溯sum要减去i
top--; //回溯
}
}
}
int** combinationSum3(int k, int n, int* returnSize, int** returnColumnSizes){
int i;
ans = (int **) malloc(sizeof(int *) * 10000);
path = (int *) malloc(sizeof(int) * 10);
*returnColumnSizes = (int *) malloc(sizeof(int) * 10000);
cnt = 0;
top = 0;
sum = 0;
fun(k, n, 1);
*returnSize = cnt;
for(i = 0; i < cnt; ++i){
(*returnColumnSizes)[i] = k;
}
return ans;
}
优化(减枝)代码
for(i = start; i <= 9-(k-top)+1; ++i){ //剪枝
path[top++] = i;
sum += i;
if(sum > n){ //剪枝,sum已经>k,之后的数都不用考虑了
sum -= i; //剪枝之前先处理回溯
top--; //剪枝之前先处理回溯
break;
}
fun(k, n, i + 1);
sum -= i; //回溯,别忘了回溯sum要减去i
top--; //回溯
}
电话号码的字母组合
组合问题先画出树形结构再分析
给定数字串digits,可以看到如果遍历到树的节点高度为digits的长度,就是需要的组合,将其存放如结果集合
前两道题是同一个集合中元素的组合,而本题是不同集合元素的组合,因此控制方式不能再是定义开始位置start,而因该是每次从头开始。
注意:要提前处理digits为空的情况
实现代码如下
char **ans; //结果集合
char *path; //当前组合(栈)
//定义电话号码键盘
char *s[10] = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};
int cnt; //组合个数
int top; //path栈指针
void fun(char *digits, int index){
int i;
if(index == strlen(digits)){
char *temp = (char*)malloc(sizeof(char) * (index + 1));
for(i = 0; i < top; ++i){
temp[i] = path[i];
}
temp[i] = 0;
ans[cnt++] = temp;
return ;
}
int d = digits[index] - '0';
char *t = s[d];
for(i = 0; i < strlen(t); ++i){
path[top++] = t[i];
fun(digits, index + 1);
top--; //回溯
}
}
char ** letterCombinations(char * digits, int* returnSize){
ans = (char **)malloc(sizeof(char *) * 10000);
//要提前处理digits为空的情况,否则调用fun,结果集合中会有一个空组合
if(strlen(digits) == 0){
*returnSize = 0;
return ans;
}
path = (char *)malloc(sizeof(char) * (strlen(digits) + 1));
cnt = 0;
top = 0;
fun(digits, 0);
*returnSize = cnt;
return ans;
}
组合总和
基本组合问题是要求,指定组合中元素的个数要求,且不能重复
而组合总和没有元素个数的要求,要求元素总和为target,且同一个元素可以取多次,组合不能重复
要在基本组合问题的基础上解决两个问题
-
如何使得组合不重复(即[2,2,3]和[2,3,2]为同一个组合)
这个问题已经在基本组合中解决了,就是使用start变量控制不能取之前的元素
-
如何使得同一个元素可以重复取
基本组合中,取完一个元素i,下一层递归参数是i+1,实现了组合中元素不重复
为了使得组合中元素可以重复取,取完一个元素i,下一层递归参数变为i即可
该问题的树形结构如下所示
实现代码如下
int **ans; //结果集合
int *path; //当前组合(相当于一个栈,在此栈上进行回溯操作)
int *colSize; //当前组合中元素个数
int cnt; //符合要求的组合数
int top; //栈顶指针
int sum; //当前元素和
void fun(int *nums, int n, int target, int start){
int i;
if(sum == target){ //符合要求,保存在结果集合中
int *temp = (int *)malloc(sizeof(int) * 1000);
for(i = 0; i < top; ++i){
temp[i] = path[i];
}
ans[cnt] = temp;
colSize[cnt++] = top;
}
else{
for(i = start; i < n; ++i){ //从start开始遍历,避免组合重复
path[top++] = nums[i];
sum += nums[i];
if(sum > target){ //剪枝
sum -= nums[i];
top--;
continue;
}
fun(nums, n, target, i); //注意参数为i,可以重复取同一个元素
sum -= nums[i]; //回溯
top--; //回溯
}
}
}
int** combinationSum(int* candidates, int candidatesSize, int target, int* returnSize, int** returnColumnSizes){
ans = (int **)malloc(sizeof(int *) * 200);
path = (int *)malloc(sizeof(int) * 10000);
(*returnColumnSizes) = (int *)malloc(sizeof(int) * 200);
cnt = 0;
top = 0;
sum = 0;
colSize = (int *) malloc(sizeof(int) * 200);
fun(candidates, candidatesSize, target, 0);
*returnSize = cnt;
int i;
for(i = 0; i < cnt; ++i){
(*returnColumnSizes)[i] = colSize[i];
}
return ans;
}
(难)组合总和Ⅱ
解题参考:
回溯算法中的去重,树层去重树枝去重,你弄清楚了没?| LeetCode:40.组合总和II_哔哩哔哩_bilibili
与上题的不同之处
-
给定的数组元素可能有重复
-
要求一个元素只能使用一次
但组合中可能出现重复元素,因为给定的数组中该元素可能是重复的
同层值相同的元素不能重复使用,因为可能出现组合重复的情况,纵深值相同的元素可以重复出现,因为虽然值相同,但不是同一个元素(给定数组中可能存在值相同的多个元素)
实现代码如下
int **ans; //结果组合集合
int *path; //当前组合
int *colSize; //每个组合中元素的个数
int top; //栈顶指针
int cnt; //符合条件的组合数
int sum; //当前栈中元素和
int used[110]; //标记数组
void fun(int *candidates, int candidatesSize, int target, int startIndex){
int i;
if(sum == target){ //符合条件 保存到结果集合
int *temp = (int *)malloc(sizeof(int) * 200);
for(i = 0; i < top; ++i){
temp[i] = path[i];
}
ans[cnt] = temp;
colSize[cnt++] = top;
}
else{
for(i = startIndex; i < candidatesSize; ++i){
//同层元素值相同,可能出现组合重复
if(i > 0 && candidates[i] == candidates[i-1] && used[i-1] == 0){
continue;
}
path[top++] = candidates[i];
sum += candidates[i];
used[i] = 1;
//递归出口,由于提前排序,之后的必定大于target,直接break
if(sum > target){
sum -= candidates[i];
top--;
used[i] = 0;
break;
}
//i+1不允许同一个元素重复使用
fun(candidates, candidatesSize, target, i+1);
sum -= candidates[i]; //回溯
top--; //回溯
used[i] = 0; //回溯
}
}
}
//归并排序
void merge(int a[], int low, int mid, int high){
int k = 0;
int *b = (int *)malloc(sizeof(int) * (high-low+1));
int i = low, j = mid + 1;
while(i <= mid && j <= high){
if(a[i] < a[j]){
b[k++] = a[i++];
}
else {
b[k++] = a[j++];
}
}
while(i <= mid){
b[k++] = a[i++];
}
while(j <= high){
b[k++] = a[j++];
}
for(i = 0; i < k; ++i){
a[low+i] = b[i];
}
}
void mergeSort(int a[], int low, int high){
if(low < high){
int mid = (low + high) / 2;
mergeSort(a, low, mid);
mergeSort(a, mid+1, high);
merge(a, low, mid, high);
}
}
int** combinationSum2(int* candidates, int candidatesSize, int target, int* returnSize, int** returnColumnSizes){
ans = (int **) malloc(sizeof(int *) * 10000);
path = (int *)malloc(sizeof(int) * 200);
colSize = (int *)malloc(sizeof(int) * 200);
(*returnColumnSizes) = (int *)malloc(sizeof(int) * 200);
int i;
top = 0;
sum = 0;
cnt = 0;
for(i = 1; i < 110; ++i){ //used初始化
used[i] = 0;
}
mergeSort(candidates, 0, candidatesSize-1); //先对数组排序小->大
fun(candidates, candidatesSize, target, 0);
*returnSize = cnt;
for(i = 0; i < cnt; ++i){
(*returnColumnSizes)[i] = colSize[i];
}
return ans;
}
全排列
排列与组合不同之处:组合无序,排列有序
使用used数组标记元素是否已经被取过
具体代码如下
int **ans; //结果集合
int *path; //当前排列
int top; //栈指针
int cnt; //当前排列个数
int used[10]; //标记数组
void fun(int *nums, int n){
int i;
if(top == n){ //递归出口,也就是树中叶子节点
int *temp = (int *)malloc(sizeof(int) * 10);
for(i = 0; i < top; ++i){
temp[i] = path[i];
}
ans[cnt++] = temp; //将当前排列存放到结果集合
}
else{
for(i = 0; i < n; ++i){ //排列有序,可以取前面的元素
if(used[i] == 1){ //已经取过不再取
continue;
}
path[top++] = nums[i]; //处理nums[i]
used[i] = 1; //处理nums[i]
fun(nums, n);
used[i] = 0; //回溯
top--; //回溯
}
}
}
int** permute(int* nums, int numsSize, int* returnSize, int** returnColumnSizes){
ans = (int **)malloc(sizeof(int *) * 4000);
path = (int *)malloc(sizeof(int) * 10);
(*returnColumnSizes) = (int *)malloc(sizeof(int) * 4000);
//初始化
top = 0;
cnt = 0;
int i;
for(i = 0; i < 10; ++i){
used[i] = 0;
}
fun(nums, numsSize);
for(i = 0; i < cnt; ++i){
(*returnColumnSizes)[i] = numsSize;
}
*returnSize = cnt;
return ans;
}
全排列Ⅱ
在全排列的基础上使用组合总和Ⅱ的去重方法即可:先排序,同层不能重复出现相同的key,纵深可以出现
//同层不能取key相同,纵深可以取key相同
void fun(int *nums, int n){
int i;
if(top == n){
int *temp = (int *)malloc(sizeof(int) * top);
for(i = 0; i < top; ++i){
temp[i] = path[i];
}
ans[cnt++] = temp;
}
else{
for(int i = 0; i < n; ++i){
if(used1[i] == 1){
continue;
}
if(i > 0 && nums[i-1] == nums[i] && used2[i-1] == 0){
continue;
}
path[top++] = nums[i];
used1[i] = 1;
used2[i] = 1;
fun(nums, n);
used1[i] = 0;
used2[i] = 0;
top--;
}
}
}
子集
与组合问题的不同之处在于,要收集树中有所节点,不能重复,只能向后看,要添加startIndex
实现代码如下
int **ans;
int *cur;
int top;
int cnt;
void fun(int *nums, int n, int startIndex, int **returnColumnSizes){
int i;
int *temp = (int *)malloc(sizeof(int) * top);
for(i = 0; i < top; ++i){
temp[i] = cur[i];
}
ans[cnt] = temp;
(*returnColumnSizes)[cnt++] = top;
for(i = startIndex; i < n; ++i){
cur[top++] = nums[i];
fun(nums, n, i+1, returnColumnSizes);
top--; //回溯
}
}
int** subsets(int* nums, int numsSize, int* returnSize, int** returnColumnSizes){
ans = (int **)malloc(sizeof(int *) * 10000);
cur = (int *)malloc(sizeof(int) * 12);
(*returnColumnSizes) = (int *)malloc(sizeof(int) * 10000);
top = cnt = 0;
fun(nums, numsSize, 0, returnColumnSizes);
*returnSize = cnt;
return ans;
}
子集Ⅱ
在子集的基础上添加去重操作,同层不能重复取key相同的元素,纵深可以,添加标记数组实现
实现代码如下
void fun(int *nums, int n, int startIndex, int **returnColumnSizes){
int i;
int *temp = (int *)malloc(sizeof(int) * top);
for(i = 0; i < top; ++i){
temp[i] = cur[i];
}
ans[cnt] = temp;
(*returnColumnSizes)[cnt++] = top;
for(i = startIndex; i < n; ++i){
if(i > 0 && nums[i-1] == nums[i] && used[i-1] == 0){ //⭐关键代码
continue;
}
cur[top++] = nums[i];
used[i] = 1;
fun(nums, n, i+1, returnColumnSizes);
top--; //回溯
used[i] = 0; //回溯
}
}
(难)递增子序列
和子集Ⅱ类似,但是要注意
- 求的是子序列,不能改变原始序列的顺序,不能进行排序
- 主要要求递增子序列,和元素个数大于2,要有剪枝操作
- 要有去重操作,本题的去重不能进行排序,要额外增加一个访问数组,存放本层已经访问的过的key
实现代码如下
int **ans;
int *cur;
int top;
int cnt;
int find(int *a, int n, int key){ //检查当前key是否已经出现过
int i;
for(i = 0; i < n; ++i){
if(a[i] == key)
return 1;
}
return 0;
}
void fun(int *nums, int n, int startIndex, int **returnColumnSizes){
int i;
int *used = (int *)malloc(sizeof(int) * 20); //记录本层已经出现过的key
int useSize = 0;
if(top >= 2){ //元素个数大于2的保存在结果集合中
int *temp = (int*)malloc(sizeof(int) * top);
for(i = 0; i < top; ++i){
temp[i] = cur[i];
}
ans[cnt] = temp;
(*returnColumnSizes)[cnt++] = top;
}
for(i = startIndex; i < n; ++i){
if(top > 0 && nums[i] < cur[top-1]){ //出现了递减数列 跳过
continue;
}
if(find(used, useSize, nums[i]) == 1){ //本层已经出现过相同的key 跳过
continue;
}
cur[top++] = nums[i];
used[useSize++] = nums[i]; //保存已经出现过的key
fun(nums, n, i+1, returnColumnSizes);
top--; //回溯
}
}
int** findSubsequences(int* nums, int numsSize, int* returnSize, int** returnColumnSizes){
ans = (int **)malloc(sizeof(int *) * 40000);
cur = (int *)malloc(sizeof(int) * 20);
(*returnColumnSizes) = (int *)malloc(sizeof(int) * 40000);
top = cnt = 0;
int i;
fun(nums, numsSize, 0, returnColumnSizes);
*returnSize = cnt;
return ans;
}
总结
-
回溯算法可以用于求解:组合、排列、子集等问题,回溯问题均可以通过分析转换成树的形式表达。
-
注意组合与排列的区别,组合集合内的元素是无序的,即{1,2}与{2,1}是同一个组合,而对于排列,集合内的元素是有序的,{1,2}与{2,1}是两个不同的排列。
-
组合与排列在代码实现上的不同是,组合取完一个元素的取值之后,要考虑此元素之后的元素,而排列取完一个元素之后要从头考虑所有的元素,原因就是组合无序,而排列有序。
-
子集也可看做是组合问题,不同的是,组合考虑的树中的叶子节点,而子集考虑的是树中的所有节点。
-
在解题的过程中要注意题目给定的数组是否有重复的元素,若有重复的元素还涉及到去重的操作,整体思路是每一层递归不能重复使用关键字相同的元素,原因是会产生重复,而递归纵深方向可以使用关键字相同的元素,原因是虽然纵深方向关键字相同,但实际上是不同的元素。