代码随想录day23-回溯(1)

代码随想录day23-回溯(1)

今天我们开始了回溯的章节。我们在二叉树章节的部分题目中也接触过回溯的思想,主要的思路就是一条路径遍历完毕,我们假设需要遍历其他的路径,怎么办呢?此时就是需要用到回溯的思想。

回溯法的效率

回溯法不是一种特别效率很高的算法,其本质就是一种暴力搜索算法,思想就是穷举,那么为什么我们需要使用回溯算法,而不直接使用循环来暴力搜索呢?
面对许多问题,例如组合问题,往往使用循环的方法暴力搜索是无法进行的,因为不知道循环的次数,面试这一类问题,我们直接将希望寄托在回溯算法上。

回溯法解决的问题

一般而言,回溯算法可以用来解决如下几种问题:

  • 组合问题:N个数里面按一定规则找出k个数的集合
  • 切割问题:一个字符串按一定规则有几种切割方式
  • 子集问题:一个N个数的集合里有多少符合条件的子集
  • 排列问题:N个数按一定规则全排列,有几种排列方式
  • 棋盘问题:N皇后,解数独等等

回溯法的理解

回溯法都可以理解成一棵深度有限的N叉树
因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度递归的深度,都构成的树的深度

回溯法的模板

关于回溯法,卡尔总结了一个回溯法的模板,可以在模板的基础上进行修改等操作,从而实现回溯法相关的题目。
这里给出Carl总结的回溯算法模板,即回溯三部曲

  • 回溯函数模板返回值以及参数
    回溯函数一般是没有返回值的,即void,名称为backtracking。
    其参数不容易一次性确定下来,所以一般是先写逻辑,然后需要什么参数,就填什么参数。
void backtracking(参数)
  • 回溯函数终止条件
    与二叉树的遍历一样,回溯也要有终止的条件。什么时候达到了终止条件,树中就可以看出,一般来说搜到叶子节点了,也就找到了满足条件的一条答案,把这个答案存放起来,并结束本层递归。
if (终止条件) {
    存放结果;
    return;
}
  • 回溯搜索的遍历过程

在上面我们提到了,回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度递归的深度构成的树的深度
一般而言,树的宽度使用for循环进行遍历,树的深度就是递归的深度,如图所示:
在这里插入图片描述
图片来源
可以看到,图中for循环遍历的是树的宽度,而递归则表示树的深度。
回溯函数遍历过程伪代码如下:

for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
    处理节点;
    backtracking(路径,选择列表); // 递归
    回溯,撤销处理结果
}

for循环就是遍历集合区间,可以理解一个节点有多少个孩子,这个for循环就执行多少次。
backtracking这里自己调用自己,实现递归。
我们从图中看出for循环可以理解是横向遍历,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了,一般来说,搜索叶子节点就是找的其中一个结果了。
分析完过程,回溯算法模板框架如下:

void backtracking(参数) {
    if (终止条件) {
        存放结果;
        return;
    }

    for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
        处理节点;
        backtracking(路径,选择列表); // 递归
        回溯,撤销处理结果
    }
}

接下来我们就使用这个解题的模板来做几个题目。

1、LeetCode 77 组合问题

题目分析:

本题乍一看可以使用循环来解决,比如n = 4, k = 2这种,我们只要使用两层循环就可以解决了。

int n = 4;
for (int i = 1; i <= n; i++) {
    for (int j = i + 1; j <= n; j++) {
        cout << i << " " << j << endl;
    }
}

但是如果k = 3的话呢,就需要3层循环才能解决。
那如果n = 100, k = 50的话,岂不是需要使用50层循环才能解决问题,而且测试用例是未知的,所以使用循环来解决的思路是不可行的。
所以这里就需要回溯来帮忙。我们之前提到过,所有的回溯都可以转换成成树形结构,所以我么这里也先将这个问题转换成树型结构来解决,如图所示:
在这里插入图片描述
图片来源
图中有两个信息,第一个就是一层元素不能重复,比如我们已经取了1,研究过跟1的组合了,我们就必须往后看,去研究2与后面的元素的组合;另一个就是树枝上的元素不重复,我们已经研究过1和2了,那么接下来我们研究的就是1和3。这个逻辑是我们写单层回溯逻辑的基础。

下面我们根据递归三部曲来分析这个题目:

1.递归函数的返回值以及参数
回溯函数的参数一般是比较多的,所以我们可以适当的使用一些全局变量,而不要全部都传参数。
这里我们需要两个全局变量:

vector<vector<int>> result; // 存放符合条件结果的集合
vector<int> path; // 用来存放符合条件结果

回溯函数的参数中,我们需要一个超级重要的参数startIndex,这个参数就是为了防止元素的重复的,所以递归函数的返回值即参数如下所示:

vector<vector<int>> result; // 存放符合条件结果的集合
vector<int> path; // 用来存放符合条件单一结果
void backtracking(int n, int k, int startIndex)
  • 回溯函数终止条件
    什么时候到达所谓的叶子节点了呢?
    path这个数组的大小如果达到k,说明我们找到了一个子集大小为k的组合了,在图中path存的就是根节点到叶子节点的路径,所以终止条件的代码如下:
if (path.size() == k) {
    result.push_back(path);
    return;
}
  • 单层搜索的过程
    回溯法的搜索过程就是一个树型结构的遍历过程,在如上面的图所示,可以看出for循环用来横向遍历,递归的过程是纵向遍历。
    for循环每次从startIndex开始遍历,然后用path保存取到的节点i。
    代码如下:
for (int i = startIndex; i <= n; i++) { // 控制树的横向遍历
    path.push_back(i); // 处理节点
    backtracking(n, k, i + 1); // 递归:控制树的纵向遍历,注意下一层搜索要从i+1开始
    path.pop_back(); // 回溯,撤销处理的节点,关键的地方,一个结点处理完毕,需要弹出来之前加的,然后开始处理横向的下一个结点,这就是回溯的核心所在
}
题目解答:
class Solution {
public:
    vector<int> path;  // 存放符合条件的结果集合
    vector<vector<int>> result;  // 存放符合条件的结果

    void backtracking (int n, int k, int startIndex) {
        if (path.size() == k) {  // 收获结果的时候了
            result.push_back(path);
            return;
        }

        for (int i = startIndex; i <= n; i++) {  // 优化的地方
            path.push_back(i);  // 处理当前结点
            backtracking(n, k, i + 1);  // 处理接下类结点,递归,下一层搜索从i+1开始
            path.pop_back();  // 撤销结点
        }`在这里插入代码片`
    }

    vector<vector<int>> combine(int n, int k) {
        // 典型的使用回溯的题目
        backtracking(n, k, 1);
        return result;
    }
};

此外,回溯算法虽然是一种暴力的算法,但是还是可以优化,这就是回溯算法的剪枝。接下来介绍的就是剪枝操作
由于我们需要组合,那么如果剩余的元素个数比我们要求的长度都小的话,就不需要继续回溯下去了,这种情况就需要剪枝。
我们总共的长度是npath的长度就是我我们目前存的元素的个数,k-path.size()就是我们剩余需要的元素个数。如果说n剩余的元素个数比这个小,那么就不需要遍历下去了。
i <= n - (k - path.size()) + 1这个就是我们剪枝的代码,这个i就是每一层我目前的位置,k - path.size()是剩余需要的个数
n中剩余的个数n - i >= k - path.size(),即i <= n - (k - path.size()) + 1,这里为什么有一个加1呢,因为我们是左闭的区间,需要包括开始那个本身。
i至多只能到n - (k - path.size()) + 1
举个例子,如果说我们刚开始进入,在第一层的时候,path.size() == 0,此时k = 3, n = 4,此时:
4 - (3 - 0) + 1 = 2,就是说i的位置至多到2,从[2,3,4]开始搜索都是合理的。

优化后的代码:

class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking(int n, int k, int startIndex) {
        if (path.size() == k) {
            result.push_back(path);
            return;
        }
        for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) { // 优化的地方
            path.push_back(i); // 处理节点
            backtracking(n, k, i + 1);
            path.pop_back(); // 回溯,撤销处理的节点
        }
    }
public:
    vector<vector<int>> combine(int n, int k) {
    	result.clear();  // 保证类可以被重复使用,因为这个是类成员变量,不clear的话下一次会累加
    	path.clear();
        backtracking(n, k, 1);
        return result;
    }
};

2、LeetCode 216 组合问题Ⅲ

题目分析

这个题在上一个题目的基础上,就只是增加了一个和的限制条件,本身也就是寻找长度为k的有几种组合。
同样的,这个题目也可以转化成一个N叉树的问题,其中N叉树的宽度为9(这里数的限制为1-9)。N叉树的深度就是k,如果当path的长度为k,就代表走到了叶子结点,此时就要返回了
此外本题也可以做相应的剪枝:

  • 剩余长度不够k的时候可以剪枝;
  • 未到k个元素或者到了k个元素,它们的和以及大于n的情况。

下面给出本题的树型分析:
在这里插入图片描述
图片来源

题目解答:
class Solution {
public:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking (int target, int k, int startIndex, int sum) {
        if (sum > target) return;  // 剪枝

        if (path.size() == k) {  // 收到了k个
            if (sum == target) result.push_back(path);  // 长度和总和都符合条件
            return;  // 无论是不是都返回,不需要要继续递归下去了
        }

        for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) {
            path.push_back(i);  // 处理结点
            sum += i;
            backtracking(target, k, i + 1, sum);  // 递归该结点的下一层
            sum -= i;  // 回溯
            path.pop_back();  
        }
    }

    vector<vector<int>> combinationSum3(int k, int n) {
        result.clear();
        path.clear();
        backtracking(n, k, 1, 0);
        return result;
    }
};

3、LeetCode 17 电话号码的字母组合

题目分析:

本题跟之前的组合和组合总和最大的不同是本题涉及到多个集合,之前的题目就是在一个集合里面选元素。
那么本题需要解决如下的问题:

  • 数字和字母如何映射;
  • 递归的深度就是数字的个数,那么如何进入到下一个集合呢;

之前我们为了防止元素重复出现,我们使用了startIndex来让该结点的下一层从下一个元素开始遍历,而不是从头遍历,但是这里是多个集合,不能再使用startIndex了。
对于这种多个集合的情况,我们这里使用了一个index,来代表集合的下标,这也是本题很关键的一个点。
这个index有两个作用,第一个是遍历digits,第二个是代表数的深度。

那么数字和字母如何映射呢,最简单的就是使用哈希表来做映射,我们定义一个unordered_map<int, string>,键代表数字,值就是每个数字对应的字母组合,如下所示:

    unordered_map<int, string> umap = {
        {2, "abc"}, {3, "def"}, {4, "ghi"}, {5, "jkl"}, {6, "mno"}, {7, "pqrs"}, {8, "tuv"}, {9, "wxyz"}
    };

下面根据递归三部曲逐步确定各个步骤:

  • 回溯函数的参数和返回值
    跟之前的一样,回溯函数的返回值一般是void,其实这个也比较好理解,我们相当于要遍历整个N叉树,从而找到合适的情况,所以不需要什么返回值。前面还分析到,需要用index来遍历digits,并且作为函数的返回条件,所以回溯函数的参数和返回值如下:
void backtracking (const string& digits, int index) 
  • 确定递归的终止条件:
        if (inedx == digits.size()) {  // index等于digits的长度,就代表遍历完所有的字母了
            result.push_back(path);
            return;
        }
  • 单层的处理逻辑
    其实这里单层的处理主要就是两步:第一步就是得到digits中的index对应的数字,第二个就是得到这个数字所映射的字符串。
    然后遍历这个字符串,然后对于字符串中每个字符,再递归剩余的数字对应的字符串,注意这里的index就是需要加1,代表遍历下一个数字所对应的字符串了。
    代码如下:
        int digit = digits[index] - '0';  // 得到dights中index索引的数字,使用这种方式来转换
        string letters = umap[digit];  // 得到数字对应的字符串
        for (int i = 0; i < letters.size(); i++) {
            path += letters[i];  // 路径增加
            backtracking(digits, index + 1);  // 递归,注意这里是index+1,意味着要开始遍历下一个数字了
            path.pop_back();  // 回溯
        }
题目解答
class Solution {
public:
    // 使用哈希表做映射
    unordered_map<int, string> umap = {
        {2, "abc"}, {3, "def"}, {4, "ghi"}, {5, "jkl"}, {6, "mno"}, {7, "pqrs"}, {8, "tuv"}, {9, "wxyz"}
    };
    vector<string> result;
    string path;
    void backtracking (const string& digits, int index) {
        if (index == digits.size()) {  // index等于digits的长度,就代表遍历完所有的字母了
            result.push_back(path);
            return;
        }

        int digit = digits[index] - '0';  // 得到dights中index索引的数字,使用这种方式来转换
        string letters = umap[digit];  // 得到数字对应的字符串
        for (int i = 0; i < letters.size(); i++) {
            path.push_back(letters[i]);  // 路径增加
            backtracking(digits, index + 1);  // 递归,注意这里是index+1,意味着要开始遍历下一个数字了
            path.pop_back();  // 回溯
        }
    }

    vector<string> letterCombinations(string digits) {
        result.clear();
        path.clear();
        if (digits.size() == 0) return {};
        backtracking(digits, 0);
        return result;
    }
};

4、LeetCode 39 组合总和

题目分析:

本题和组合总和的要求很类似,整数数组首先是没有重复元素,但是本题说到同一个数字可以无限制重复被选取就跟之前的不一样了。
我么之前使用startIndex来限制选取过的元素不可以回头再次选取,就是为了避免重复的组合产生,这也是组合问题都需要遵守的一个规则。后面提到的排列问题就不需要startIndex来给出限制了,因为往前面取跟之前的是两种不同的排列。
这个题目的要求提到,同一个数字可以无限制重复选取,唯一的不同就是递归该结点的下一层的时候,startIndex不需要变成i + 1了,也就是不需要说从下一个元素开始,此时的startIndex = i即可。

题目解答:
class Solution {
public:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking (vector<int>& candidates, int target, int startindex, int sum) {
        if (sum > target) return;  // 递归的终止条件
        if (sum == target) {
            result.push_back(path);
            return;
        }

        // for循环控制宽度
        for (int i = startindex; i < candidates.size(); i++) {
            path.push_back(candidates[i]);  // 处理
            sum += candidates[i];
            backtracking(candidates, target, i, sum);  // 递归
            sum -= candidates[i];  // 回溯
            path.pop_back();
        }
    }

    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        backtracking(candidates, target, 0,0);
        return result;
    }
};

注意这里的递归的终止条件是需要有if (sum > target) return;的,因为我们下一次递归是从i开始,如果不给限制条件的话,代码将一直递归第一个元素,无法终止。

本题也也可以做相应的剪枝操作,对数层进行剪枝,如果 sum + candidates[i] > target的话,就不用继续下去了,所以我们之前需要排序,才能使用这样的判断方法,否则后面还是有可能出现更小的情况的。

剪枝的操作的代码如下:

class Solution {
public:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking (vector<int>& candidates, int target, int startindex, int sum) {
        if (sum == target) {
            result.push_back(path);
            return;
        }

        // for循环控制宽度
        for (int i = startindex; i < candidates.size() && sum + candidates[i] <= target; i++) {  // 剪枝优化
            path.push_back(candidates[i]);  // 处理
            sum += candidates[i];
            backtracking(candidates, target, i, sum);  // 递归
            sum -= candidates[i];  // 回溯
            path.pop_back();
        }
    }

    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        sort(candidates.begin(), candidates.end());  // 首先进行排序
        backtracking(candidates, target, 0,0);
        return result;
    }
};

5、LeetCode 40 组合总和II

题目分析:

这道题和组合总和存在如下区别:

  1. 本题candidates 中的每个数字在每个组合中只能使用一次。
  2. 本题数组candidates的元素是有重复的,而组合总和是无重复元素的数组candidates。

在之前的题目中,我们学习到了,对于第一种情况,candidates中的每个数字只能在组合中使用一次,我们这里设置一个startIndex即可,下一次递归就从i + 1开始。
这个题难就难在这里的“重复”指的是什么维度的重复,是指树枝上的重复,还是树层上的重复呢?
根据下图我们可以看到:
在这里插入图片描述
图片来源
很明显,树枝山的重复是可以的,但是数层上一旦出现了重复,肯定会和之前出现一样的组合,这就是我们需要对树枝去重。在对树枝去重之前,我们需要先对数组排序,把相同的放在一起。

对树枝去重,我们这里定义一个candidates等长的布尔类型的used数组。这个used数组的作用是用来去重,具体如何去重呢?
如果我们出现了candidates[i] == candidates[i - 1]的情况,那么此时就说明出现了重复,此时我们需要判定到底这个重复出现在树枝还是数层。
如果used[i - 1] == true,说明同一树枝的candidates[i - 1]使用过;
如果used[i - 1] == true,说明同一树枝的candidates[i - 1]使用过。
这里怎么理解呢,我们举例来说明。
如果题目是candidates = [1, 1, 2],那么我们对第一个1,我们手下让used[0] = true,然后回溯1的下一层,注意startIndex需要从下一个位置开始,然后我们遍历第二层的1,此时的candidates[1] == candidates[0] && used[0] == true,这就很明显是树枝的重复。
如果我们做完了第一个1的所有事,我们需要回溯,即used[0] = false,此时我们来到第二个1,会出现:
candidates[1] == candidates[0] && used[0] == false,这种情况是数层的重复,是一定会出现重复的组合的,所以我们就是需要去除这种重复。下面的图可以更好理解我们上面说的:
在这里插入图片描述
图片来源

所以我们的回溯三部曲就如下:

  • 参数及返回值的确定
void backtracking(vector<int>& candidates, int target, int sum, int startIndex, vector<bool>& used)
  • 递归的终止条件
if (sum > target) return;
if (sum == target) {
	result.push_back(path);
	return;
}
  • 单层的处理逻辑
        for (int i = startIndex; i < candidates.size(); i++) {
            // 树层去重
            if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == false) {
                continue;  // 树层重复了
            }
            path.push_back(candidates[i]);  // 处理
            used[i] = true;
            sum += candidates[i];
            backtracking(candidates, target, i + 1, used, sum);  // 回溯,这里不能重复选,startIndex = i+1
            sum -= candidates[i];
            used[i] = false;  // 回溯
            path.pop_back();
        }
题目解答:
class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;

    void backtracking (vector<int>& candidates, int target, int startIndex, vector<bool>& used, int sum) {
        if (sum > target) return;
        if (sum == target) {
            result.push_back(path);
            return;
        }

        for (int i = startIndex; i < candidates.size(); i++) {
            // 树层去重
            if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == false) {
                continue;  // 树层重复了
            }
            path.push_back(candidates[i]);  // 处理
            used[i] = true;
            sum += candidates[i];
            backtracking(candidates, target, i + 1, used, sum);  // 回溯,这里不能重复选,startIndex = i+1
            sum -= candidates[i];
            used[i] = false;  // 回溯
            path.pop_back();
        }
    }

public:
    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
        path.clear();
        result.clear();
        sort(candidates.begin(), candidates.end());  // 先进行排序
        vector<bool> used(candidates.size(), false);
        backtracking(candidates, target, 0, used, 0);
        return result;
    }
};

今天我们初步接触了回溯的知识,主要做了一些关于组合的题目,主要有几种类型:

  1. 数组元素不重复,且不能重复选取;(一个数组)
  2. 数组元素不重复,可以重复选取;(一个数组)
  3. 数组元素重复,最终的组合情况不能重复,牵涉到树枝的重复和树层的重复;(一个数组)
  4. 多个数组的情况;(电话号码)

注意以上提到的几种方法具体的区别。
此外,我们还学习了回溯中常见的剪枝操作,可以剔除不必要的搜索,加快速度。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值