【代码随想录训练营第42期 Day22打卡 回溯Part1 - LeetCode 77. 组合 216.组合总和III 17.电话号码的字母组合

目录

一、做题心得

二、回溯基础知识

1.定义

2.适用问题

3.一个思想

4.代码实现

三、题目与题解

题目一:77. 组合 

题目链接

题解:回溯

 题目二:216.组合总和III

题目链接

题解:回溯

 题目三:17.电话号码的字母组合 

题目链接

 题解:回溯

四、小结

一、做题心得

今天是代码随想录打卡的第22天,也是成功来到了回溯章节。回溯的话,其实之前二叉树递归思路讲解的时候也有提到,回溯基本就是基于递归之下的一个实现。今天的题应该算是很经典的回溯的应用了:组合问题。作为回溯的入门,今天也是通过做题理解到了很多相关的知识,还有就是,模板的使用。

话不多说,直接开始今天的内容。

二、回溯基础知识

1.定义

回溯算法(Backtracking Algorithm)是一种通过探索所有可能的候选解来找出所有解的算法。如果候选解被确认不是一个解(或者至少不是最后一个解),回溯算法会通过在上一步进行一些变化来丢弃该解,即“回溯”到上一步,并尝试另一个候选解。这个过程一直进行,直到找到所有解或确定无解为止。

2.适用问题

回溯算法常用来解决那些可以分解为多个步骤或子问题的复杂问题,特别是在这些子问题之间存在相互依赖关系,且每个子问题的解空间可以明确地枚举出来时。它特别适用于寻找问题的所有解,而不是单一最优解的情况。

比如:

  1. 排列组合问题:如全排列、组合、子集、幂集等问题,可以通过递归地尝试每个可能的元素来构建解。

  2. 分割问题:如将集合划分为满足特定条件的子集,这类问题可以通过回溯来尝试不同的划分方式。

  3. 棋盘问题:如八皇后问题、N皇后问题、骑士巡逻(骑士在棋盘上遍历每个格子恰好一次)等,这类问题涉及在二维空间上放置或移动对象以满足特定条件。

  4. 图的着色问题:给定一个图,要求用最少的颜色给图中的每个顶点着色,使得任意两个相邻的顶点颜色不同。

  5. 布尔满足性问题(SAT):确定是否存在一组布尔变量的赋值,使得给定的布尔表达式为真。

  6. 子集和问题:从给定的整数数组中找出所有可能的子集,使得子集中的元素之和等于一个特定的目标值。

  7. 路径寻找问题:如迷宫问题、旅行商问题(TSP)的近似解可以通过回溯法得到(虽然TSP的精确解通常使用动态规划或其他更高效的算法)。

  8. 字符串处理问题:如字符串的排列、生成所有可能的括号组合等。

  9. 决策树/游戏树的遍历:在决策制定过程中,如棋类游戏或任何需要评估多个可能选择并作出最佳决策的场景中,回溯算法可以用来遍历所有可能的决策路径。

回溯法的本质还是枚举,效率并不高,但是为了解决以上这些问题,回溯依旧是最优的选择。

3.一个思想

回溯的搜索遍历过程类似于对树的搜索遍历

回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度。

树的宽度我们可以通过横向遍历实现:for循环

树的深度我们可以通过纵向遍历实现:递归

4.代码实现

回溯算法的实现往往采取同一套适用的模板,每个人的做题习惯不同,可能会存在一些差异,但是终归做题的思路是无异的。这里给出一套代码随想录回溯部分的模板:

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

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

以上模板的具体含义以及如何通过这个模板实现解决各种问题,我将会在后边几道题中提到。

三、题目与题解

题目一:77. 组合 

题目链接

77. 组合 - 力扣(LeetCode)

给定两个整数 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
题解:回溯

作为回溯章节的第一道题,也是解决组合问题中最经典的一道题,我们可以通过这道题初步感受一下回溯是如何实现的。

  1. 定义全局变量
    • ans:一个二维向量,用于存储所有生成的组合结果。每个内部向量代表一个组合。
    • vec:一个一维向量,用于在回溯过程中临时存储当前正在构建的组合。
  2. 回溯函数:backtrack(int n, int k, int start)
    • 参数 n 表示可选数字的上限(即从1到n中选择)。
    • 参数 k 表示每个组合中应包含的元素数量。
    • 参数 start 表示当前搜索的起始位置,用于避免生成重复的组合并优化搜索空间。
  3. 终止条件
    • 当vec的大小等于k时,说明已经找到了一个符合条件的组合,将其加入到ans中,并返回上一层递归(即return)。
  4. 递归搜索与剪枝
    • 使用一个循环从 start 开始遍历到 n - (k -  vec.size()) + 1。这里的剪枝操作 n - (k -  vec.size()) + 1 是为了提前结束不必要的搜索,因为如果剩余可选的数字不足以构成长度为 k 的组合,那么就没有必要继续搜索。
    • 在循环内部,将当前数字 i 添加到 vec 中,然后递归调用 backtrace 函数,并将搜索的起始位置设为 i + 1(避免重复使用同一个数字)。
    • 递归返回后,通过vec.pop_back()撤销vec中的最后一个元素,实现回溯,以便尝试其他可能的组合。
  5. 主函数:combine(int n, int k)
    • 初始化ans和vec,并调用backtrace函数从数字1开始构建组合。
    • 返回所有生成的组合结果ans

我对代码也进行了详细注释,代码如下:

class Solution {
public:
    vector<vector<int>> ans;            //用于存放所有生成的组合结果
    vector<int> vec;                    //用于存放当前正在构建的组合
    void backtrack(int n, int k, int start) {           //回溯函数,用于递归地生成组合
        if (vec.size() == k) {              //终止条件:组合的大小等于k
            ans.push_back(vec);
            return;                  //找到了一个有效组合,返回上一级递归
        }
        for (int i = start; i <= n - (k - vec.size()) + 1; i++) {          //从start开始遍历,i表示某次搜索的起始位置(注意剪枝操作的优化)
            vec.push_back(i);               //处理节点
            backtrack(n, k, i + 1);        //递归:控制树的纵向遍历,注意下一层搜索要从i+1开始
            vec.pop_back();             //回溯,撤销处理的节点
        }
    }
    vector<vector<int>> combine(int n, int k) {
        backtrack(n, k, 1);         //调用回溯函数从数字1开始构建组合  
        return ans;
    }
};

当然,剪枝操作主要是为了降低复杂度,如果实在想不到剪枝,也可以直接 for 循环从 i = start 到 i = n。

 题目二:216.组合总和III

题目链接

216. 组合总和 III - 力扣(LeetCode)

找出所有相加之和为 n 的 k 个数的组合,且满足下列条件:

  • 只使用数字1到9
  • 每个数字 最多使用一次 

返回 所有可能的有效组合的列表 。该列表不能包含相同的组合两次,组合可以以任何顺序返回。

示例 1:

输入: k = 3, n = 7
输出: [[1,2,4]]
解释:
1 + 2 + 4 = 7
没有其他符合的组合了。

示例 2:

输入: k = 3, n = 9
输出: [[1,2,6], [1,3,5], [2,3,4]]
解释:
1 + 2 + 6 = 9
1 + 3 + 5 = 9
2 + 3 + 4 = 9
没有其他符合的组合了。

示例 3:

输入: k = 4, n = 1
输出: []
解释: 不存在有效的组合。
在[1,9]范围内使用4个不同的数字,我们可以得到的最小和是1+2+3+4 = 10,因为10 > 1,没有有效的组合。

提示:

  • 2 <= k <= 9
  • 1 <= n <= 60
题解:回溯

 这个题有了上一道题的基础其实就比较简单了,套我们提到的代码实现的模板。个人感觉唯一和上一道题不同的就是终止条件的地方了--即 if 判断语句那里。

这里先看看我自己写的代码(个人感觉更好理解,毕竟就是上一道题的变形,模板的套用):

 并没有想到具体的剪枝的操作,只是套用模板照猫画虎给整出来了,还有击败100%,有点意外。

不过如要进行剪枝的话,我们只需要提前加上判断语句:判断目前取到的值的和是否大于目标值,若大于,则返回递归。

代码如下:

class Solution {
public:
    vector<vector<int>> ans;
    vector<int> vec;
    void backtrace(int k, int n, int start) {
        int sum = 0;
        for (int i = 0; i < vec.size(); i++) {      //对当前集合所有元素求和
            sum += vec[i];
        }
        if (sum == n && vec.size() == k) {          //当前组合元素和为n 且 组合size为k时就满足要求,放入ans中
            ans.push_back(vec);
            return;
        }
        if (sum > n) {      //(关键)剪枝:已选取元素求和大于目标值时,就无需继续添加元素,直接返回递归
            return;
        }
        for (int i = start; i <= 9; i++) {
            vec.push_back(i);
            backtrace(k,n,i + 1);       //递归:纵向遍历,注意是从下一层i+1开始
            vec.pop_back();
        }
    }

    vector<vector<int>> combinationSum3(int k, int n) {
        backtrace(k,n,1);
        return ans;
    }
};

这里我们再看看代码随想录的代码,或许更适合你的思路:

class Solution {
public:
    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;             // 如果path.size() == k 但sum != targetSum 直接返回
        }
        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();             // 回溯
        }
    }
    vector<vector<int>> combinationSum3(int k, int n) {
        backtracking(n, k, 0, 1);
        return result;
    }
};

 题目三:17.电话号码的字母组合 

题目链接

17. 电话号码的字母组合 - 力扣(LeetCode)

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。

给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。

示例 1:

输入:digits = "23"
输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]

示例 2:

输入:digits = ""
输出:[]

示例 3:

输入:digits = "2"
输出:["a","b","c"]

提示:

  • 0 <= digits.length <= 4
  • digits[i] 是范围 ['2', '9'] 的一个数字。
 题解:回溯

这道题的话,看上去就要难不少了。

其实题意挺简单,就是将给定的字符串中的数字全部转为它们可以表示的字母替换:这里需要注意的是,0和1并没有对应的字母,而其他数字分别对应着3个字母即对应着三种情况。

这其实也是组合问题的一种变形,我们首先就是要想到使用回溯。回溯的话,我们套用上述的模板,想清楚终止条件,横向遍历,纵向遍历等等。当然这道题还有一个关键点就是哈希表(哈希映射)的使用,我们要通过哈希表将每个数字对应的字母(由于一个数字对应多个字母,就会是字符串型)存储下来,以便于后续的转换。

class Solution {
public:
    vector<string> ans;         //用于存储所有可能的字母组合(结果)
    string tmp;               //用于构建存储当前字母组合
    vector<string> hash = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};      //哈希(映射)表,将数字映射到对应的字母字符串,注意0和1无效,对应的字母为空
    void backtrace(int pos, string digits) {        //回溯--用于递归地生成所有可能的字母组合 
        if (pos == digits.size()) {             //终止条件:字符串中所有数字都已经处理完毕
            ans.push_back(tmp);
            return;
        }
        int num = digits[pos] - '0';    //取出当前位置(pos:从0开始)的数字(但以字符的形式),并转换为对应的索引num
        for (int i = 0; i < hash[num].size(); i++) {        //for循环:横向遍历字符串中每个字符
            tmp.push_back(hash[num][i]);        //处理当前位置
            backtrace(pos + 1, digits);     //递归:纵向遍历--注意从下个位置pos + 1开始
            tmp.pop_back();
        }
    }
    vector<string> letterCombinations(string digits) {
        if (digits.size() == 0)     return ans;         //给定字符串为空
        backtrace(0, digits);
        return ans;
    }
};

四、小结

今天的打卡到此也就结束了,后续回继续进行回溯的相关练习。最后,我是算法小白,但也希望终有所获。

  • 30
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值