算法学习记录~2023.5.12~回溯Day1~77. 组合 & 216.组合总和III & 17.电话号码的字母组合

基础知识

1. 什么是回溯法

回溯法也叫回溯搜索法,是一种搜索的方式。

回溯是递归的副产品,只要有递归就会有回溯,所以回溯函数其实也就是递归函数。

2. 回溯法的效率

回溯法的本质是穷举,如果想让回溯高效一些可以加一些剪枝操作,但仍然不是什么高效的算法。

但仍然需要使用,因为有一些问题只能通过暴力搜索才能解决。

3. 回溯法解决的问题

一般是解决如下问题:

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

排列:有序。{1, 2} 和 {2, 1} 就是2个集合
组合:无序。{1, 2} 和 {2, 1} 就是1个集合

4. 如何理解回溯法

回溯法解决的问题都可以抽象为树形结构

回溯法解决的就是在集合中递归查找子集,集合的大小构成了树的宽度递归的深度构成了树的深度

递归就要有终止条件,所以必然是一棵高度有限的树(N叉树)。

具体可以结合下面的图理解

5. 回溯法模板

回溯三步曲:

  • 回溯函数模板返回值以及参数

函数返回值一般为void。

由于回溯算法需要的参数不像二叉树递归一样容易一次性确定下来,因此一般先写逻辑,然后需要什么参数再补。

void backTracking (参数)
  • 回溯函数终止条件

一般来说搜到了叶子节点,就找到了满足条件的一条答案,此时就把答案存起来,并结束本层递归。

if (终止条件){
	存放结果;
	return;
}
  • 回溯搜索的遍历过程

在上面提到了,回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度
在这里插入图片描述
集合大小和孩子的数量是相等的。

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

77. 组合

题目链接

力扣题目链接

思路

该组合问题可以抽象为如下树形结构
在这里插入图片描述
每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围。

图中可以发现n相当于树的宽度,k相当于树的深度。

每次搜索到了叶子节点,我们就找到了一个结果。

回溯三步曲:

  1. 递归函数的返回值及参数

需要定义两个全局变量,一个用来存放当前路径(结果),一个存放所有路径的结果

vector<int> path;		//存放当前路径(结果)
vector<vector<int>> result;		//存放所有路径结果

集合n里取k个数,所以n和k一定为参数。
此外还需要一个index,用于记录本层递归中从哪里开始遍历,防止出现重复的组合。

void backtracking(int n, int k, int startIndex)
  1. 回溯函数终止条件

如果path数组的长度到达k,说明到达了叶子结点,找到了一个子集大小为看的组合,path寸的就是根结点到叶子节点的路径。

此时用result二维数组把path保存起来并终止本层递归

if (path.size() == k) {
    result.push_back(path);
    return;
}
  1. 单层搜索的过程

回溯法的搜索过程就是一个树型结构的遍历过程
在这里插入图片描述

for循环用来横向遍历,递归的过程是纵向遍历。

for循环每次从index开始遍历,然后用path保存渠道的节点

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<vector<int>> result;         //存放所有路径得到的结果
    vector<int> path;                   //存放单条路径结果
    void backtracking (int n, int k, int index){
        if (path.size() == k){          //路径长为k则找到了一组组合
            result.push_back(path);
            return ;
        }
        for (int i = index; i <= n; i++){	// 控制树的横向遍历
            path.push_back(i);          //处理节点
            backtracking(n, k, i + 1);  //递归
            path.pop_back();            //回溯,撤销被处理的节点
        }
    }
    vector<vector<int>> combine(int n, int k) {
        backtracking(n, k, 1);
        return result;
    }
};

思路2:从1的基础上剪枝优化

在这里插入图片描述
可以剪枝的地方就在每一层的for循环所选择的起始位置。如果for循环选择的起始位置之后的元素个数已经不足k个了,那就没必要搜索了。

已经选择的个数为 path.size(),所以剩余元素为 k - path.size(),因此起始位置最多只能到n - (k - path.size()) + 1.
之所以要 + 1,因为包括起止位置,是左闭右闭集合。

比如 n = 4,k = 3, 目前已经选取的元素为0(path.size为0),n - (k - 0) + 1 即 4 - ( 3 - 0) + 1 = 2,从2开始搜索都是合理的,可以是组合[2, 3, 4]

因此优化后的for循环为

for (int i = index; i <= n - (k - path.size()) + 1; i++) // i为本次搜索的起始位置

其他代码均和普通的一致

代码

class Solution {
public:
    vector<vector<int>> result;         //存放所有路径得到的结果
    vector<int> path;                   //存放单条路径结果
    void backtracking (int n, int k, int index){
        if (path.size() == k){          //路径长为k则找到了一组组合
            result.push_back(path);
            return ;
        }
        for (int i = index; i <= n - (k - path.size()) + 1; i++){   //优化的地方
            path.push_back(i);          //处理节点
            backtracking(n, k, i + 1);  //递归
            path.pop_back();            //回溯,撤销被处理的节点
        }
    }
    vector<vector<int>> combine(int n, int k) {
        backtracking(n, k, 1);
        return result;
    }
};

总结


216.组合总和III

题目链接

力扣题目链接

思路

思路和 77. 组合 大致相同,需要注意的点有两个

  1. 终止条件为k,也就是元素个数,树的深度,如果path.size() 和 k相等了,就终止。在这个前提下,再判断总和是不是等于目标和,如果是则加入到result
  2. 在这次的回溯中,除了需要回溯每次取的元素,还需要回溯总和

处理过程 和 回溯过程 是一一对应的,处理有加,回溯就要有减

代码1

class Solution {
public:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking(int k, int n,int sum, int index){
        if (path.size() == k){
            if (sum == n){
                result.push_back(path);
            }
            return ;        //注意return位置,如果path.size() == k 但sum != targetSum 直接返回
        }
        for (int i = index; i <= 9; i++){
            path.push_back(i);  //处理
            sum += i;           //处理
            backtracking(k, n, sum, i + 1);     //注意i+1调整index
            sum -= i;           //回溯
            path.pop_back();    //回溯
        }
    }

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

代码2:剪枝

  1. 首先就是关于个数的剪枝,也就是for循环条件的改变,和 77. 组合 一致
  2. 另外如果sum已经大于了目标和n,那么继续下去也没有意义
class Solution {
private:
    vector<vector<int>> result; // 存放结果集
    vector<int> path; // 符合条件的结果
    void backtracking(int targetSum, int k, int sum, int startIndex) {
        if (sum > targetSum) { // 剪枝操作
            return; 
        }
        if (path.size() == k) {
            if (sum == targetSum) {
            	result.push_back(path);
            }
            return;
        }
        for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) { // 剪枝
            sum += i; // 处理
            path.push_back(i); // 处理
            backtracking(targetSum, k, sum, i + 1); // 注意i+1调整startIndex
            sum -= i; // 回溯
            path.pop_back(); // 回溯
        }
    }

public:
    vector<vector<int>> combinationSum3(int k, int n) {
        result.clear(); // 可以不加
        path.clear();   // 可以不加
        backtracking(n, k, 0, 1);
        return result;
    }
};

总结


17.电话号码的字母组合

题目链接

力扣题目链接

思路

主要需要解决的有三个问题:

  1. 数字和字母如何映射
  2. 两个字母就两个for循环,三个字符我就三个for循环,以此类推,然后发现代码根本写不出来,因此需要回溯算法
  3. 输入1 * #按键等等异常情况(本题测试数据倒是不涉及)

对于数字和字母的映射:
可以使用map,也可以定义一个二维数组来做映射。比如用二位数组定义的话就如下所示

const string letterMap[10] = {
            "",     //0
            "",     //1
            "abc",  //2
            "def", // 3
            "ghi", // 4
            "jkl", // 5
            "mno", // 6
            "pqrs", // 7
            "tuv", // 8
            "wxyz", // 9
    }

对于回溯算法,按照之前的回溯三步曲即可,具体参照下面代码的注释。

需要注意的点是for循环要记住循环的是什么,在本题中为当前所指节点的所有可能字母,因此应该是lettersd的size而不是别的

此外也要注意下将index给int化,以及如何获取当前index所指数字的字母集

代码

class Solution {
public:
    const string letterMap[10] = {
            "",     //0
            "",     //1
            "abc",  //2
            "def", // 3
            "ghi", // 4
            "jkl", // 5
            "mno", // 6
            "pqrs", // 7
            "tuv", // 8
            "wxyz", // 9
    };
    string path;
    vector<string> result;

    void backtracking(const string& digits, int index){
        if (index == digits.size()){
            result.push_back(path);             //找到了一种组合
            return ;
        }

        int digit = digits[index] - '0';        //将index指向的数字转化为int
        string letters = letterMap[digit];      //存放index当前所指数字对应的字母集

        for (int i = 0; i < letters.size(); i++){   //本层的所有可能选项是当前所指数字对应的字母集,因此应注意是letters的size
            path.push_back(letters[i]);         //处理
            backtracking(digits, index + 1);    //index+1递归下一层,也就是下一个数字
            path.pop_back();                    //回溯
        }
    }

    vector<string> letterCombinations(string digits) {
        if (digits.size() == 0) {   //不写这个的话,输入为空是结果会是[""]而不是空[]
            return result;
        }
        backtracking (digits, 0);
        return result;
    }
};

总结

主要需要解决的有三个问题:

  1. 数字和字母如何映射
  2. 两个字母就两个for循环,三个字符我就三个for循环,以此类推,然后发现代码根本写不出来,因此需要回溯算法
  3. 输入1 * #按键等等异常情况(本题测试数据倒是不涉及)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

山药泥拌饭

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值