代码随想录算法训练营第二十八天:回溯继续

发布于:2024-05-16 ⋅ 阅读:(139) ⋅ 点赞:(0)

代码随想录算法训练营第二十八天:回溯继续

216.组合总和III

力扣题目链接(opens new window)

找出所有相加之和为 n 的 k 个数的组合。组合中只允许含有 1 - 9 的正整数,并且每种组合中不存在重复的数字。

说明:

  • 所有数字都是正整数。
  • 解集不能包含重复的组合。

示例 1: 输入: k = 3, n = 7 输出: [[1,2,4]]

示例 2: 输入: k = 3, n = 9 输出: [[1,2,6], [1,3,5], [2,3,4]]

#算法公开课

《代码随想录》算法视频公开课 ****(opens new window)****​ 和组合问题有啥区别?回溯算法如何剪枝?| LeetCode:216.组合总和III ****(opens new window)****​ ,相信结合视频再看本篇题解,更有助于大家对本题的理解

#思路

本题就是在[1,2,3,4,5,6,7,8,9]这个集合中找到和为n的k个数的组合。

相对于77. 组合 ​**(opens new window)** ,无非就是多了一个限制,本题是要找到和为n的k个数的组合,而整个集合已经是固定的了[1,…,9]。

想到这一点了,做过77. 组合 ​**(opens new window)** 之后,本题是简单一些了。

本题k相当于树的深度,9(因为整个集合就是9个数)就是树的宽度。

例如 k = 2,n = 4的话,就是在集合[1,2,3,4,5,6,7,8,9]中求 k(个数) = 2, n(和) = 4的组合。

选取过程如图:

216.组合总和III

图中,可以看出,只有最后取到集合(1,3)和为4 符合条件。

#回溯三部曲

  • 确定递归函数参数

77. 组合 ​**(opens new window)** 一样,依然需要一维数组path来存放符合条件的结果,二维数组result来存放结果集。

这里我依然定义path 和 result为全局变量。

至于为什么取名为path?从上面树形结构中,可以看出,结果其实就是一条根节点到叶子节点的路径。

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

接下来还需要如下参数:

  • targetSum(int)目标和,也就是题目中的n。
  • k(int)就是题目中要求k个数的集合。
  • sum(int)为已经收集的元素的总和,也就是path里元素的总和。
  • startIndex(int)为下一层for循环搜索的起始位置。

所以代码如下:

vector<vector<int>> result;
vector<int> path;
void backtracking(int targetSum, int k, int sum, int startIndex)

其实这里sum这个参数也可以省略,每次targetSum减去选取的元素数值,然后判断如果targetSum为0了,说明收集到符合条件的结果了,我这里为了直观便于理解,还是加一个sum参数。

还要强调一下,回溯法中递归函数参数很难一次性确定下来,一般先写逻辑,需要啥参数了,填什么参数。

  • 确定终止条件

什么时候终止呢?

在上面已经说了,k其实就已经限制树的深度,因为就取k个元素,树再往下深了没有意义。

所以如果path.size() 和 k相等了,就终止。

如果此时path里收集到的元素和(sum) 和targetSum(就是题目描述的n)相同了,就用result收集当前的结果。

所以 终止代码如下:

if (path.size() == k) {
if (sum == targetSum) result.push_back(path);
return; // 如果path.size() == k 但sum != targetSum 直接返回
}
  • 单层搜索过程

本题和77. 组合 ​**(opens new window)** 区别之一就是集合固定的就是9个数[1,…,9],所以for循环固定i<=9

如图: 216.组合总和III

处理过程就是 path收集每次选取的元素,相当于树型结构里的边,sum来统计path里元素的总和。

代码如下:

for (int i = startIndex; i <= 9; i++) {
sum += i;
path.push_back(i);
backtracking(targetSum, k, sum, i + 1); // 注意i+1调整startIndex
sum -= i; // 回溯
path.pop_back(); // 回溯
}

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

参照关于回溯算法,你该了解这些! ​**(opens new window)** 中的模板,不难写出如下C++代码:

class Solution {
private:
vector<vector<int>> result; // 存放结果集
vector<int> path; // 符合条件的结果
// targetSum:目标和,也就是题目中的n。
// k:题目中要求k个数的集合。
// sum:已经收集的元素的总和,也就是path里元素的总和。
// startIndex:下一层for循环搜索的起始位置。
void backtracking(int targetSum, int k, int sum, int startIndex) {
if (path.size() == k) {
if (sum == targetSum) result.push_back(path);
return; // 如果path.size() == k 但sum != targetSum 直接返回
}
for (int i = startIndex; i <= 9; 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;
}
};

#剪枝

这道题目,剪枝操作其实是很容易想到了,想必大家看上面的树形图的时候已经想到了。

如图: 216.组合总和III1

已选元素总和如果已经大于n(图中数值为4)了,那么往后遍历就没有意义了,直接剪掉。

那么剪枝的地方可以放在递归函数开始的地方,剪枝代码如下:

if (sum > targetSum) { // 剪枝操作
return;
}

当然这个剪枝也可以放在 调用递归之前,即放在这里,只不过要记得 要回溯操作给做了。

for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) { // 剪枝
sum += i; // 处理
path.push_back(i); // 处理
if (sum > targetSum) { // 剪枝操作
sum -= i; // 剪枝之前先把回溯做了
path.pop_back(); // 剪枝之前先把回溯做了
return;
}
backtracking(targetSum, k, sum, i + 1); // 注意i+1调整startIndex
sum -= i; // 回溯
path.pop_back(); // 回溯
}

回溯算法:组合问题再剪剪枝 ​**(opens new window)** 一样,for循环的范围也可以剪枝,i <= 9 - (k - path.size()) + 1就可以了。

最后C++代码如下:

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; // 如果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(); // 回溯
}
}

public:
vector<vector<int>> combinationSum3(int k, int n) {
result.clear(); // 可以不加
path.clear();   // 可以不加
backtracking(n, k, 0, 1);
return result;
}
};
  • 时间复杂度: O(n * 2^n)
  • 空间复杂度: O(n)

#总结

开篇就介绍了本题与77.组合 ​**(opens new window)** 的区别,相对来说加了元素总和的限制,如果做完77.组合 ​**(opens new window)** 再做本题在合适不过。

分析完区别,依然把问题抽象为树形结构,按照回溯三部曲进行讲解,最后给出剪枝的优化。

相信做完本题,大家对组合问题应该有初步了解了。

17.电话号码的字母组合

力扣题目链接(opens new window)

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。

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

17.电话号码的字母组合

示例:

  • 输入:“23”
  • 输出:[“ad”, “ae”, “af”, “bd”, “be”, “bf”, “cd”, “ce”, “cf”].

说明:尽管上面的答案是按字典序排列的,但是你可以任意选择答案输出的顺序。

#算法公开课

《代码随想录》算法视频公开课 ****(opens new window)****​ ::还得用回溯算法!| LeetCode:17.电话号码的字母组合 ****(opens new window)****​ ,相信结合视频再看本篇题解,更有助于大家对本题的理解

#思路

从示例上来说,输入"23",最直接的想法就是两层for循环遍历了吧,正好把组合的情况都输出了。

如果输入"233"呢,那么就三层for循环,如果"2333"呢,就四层for循环…

大家应该感觉出和77.组合 ​**(opens new window)** 遇到的一样的问题,就是这for循环的层数如何写出来,此时又是回溯法登场的时候了。

理解本题后,要解决如下三个问题:

  1. 数字和字母如何映射
  2. 两个字母就两个for循环,三个字符我就三个for循环,以此类推,然后发现代码根本写不出来
  3. 输入1 * #按键等等异常情况

#数字和字母如何映射

可以使用map或者定义一个二维数组,例如:string letterMap[10],来做映射,我这里定义一个二维数组,代码如下:

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

#回溯法来解决n个for循环的问题

对于回溯法还不了解的同学看这篇:关于回溯算法,你该了解这些!(opens new window)

例如:输入:“23”,抽象为树形结构,如图所示:

17. 电话号码的字母组合

图中可以看出遍历的深度,就是输入"23"的长度,而叶子节点就是我们要收集的结果,输出[“ad”, “ae”, “af”, “bd”, “be”, “bf”, “cd”, “ce”, “cf”]。

回溯三部曲:

  • 确定回溯函数参数

首先需要一个字符串s来收集叶子节点的结果,然后用一个字符串数组result保存起来,这两个变量我依然定义为全局。

再来看参数,参数指定是有题目中给的string digits,然后还要有一个参数就是int型的index。

注意这个index可不是 77.组合 ​**(opens new window)** 和216.组合总和III ​**(opens new window)** 中的startIndex了。

这个index是记录遍历第几个数字了,就是用来遍历digits的(题目中给出数字字符串),同时index也表示树的深度。

代码如下:

vector<string> result;
string s;
void backtracking(const string& digits, int index)
  • 确定终止条件

例如输入用例"23",两个数字,那么根节点往下递归两层就可以了,叶子节点就是要收集的结果集。

那么终止条件就是如果index 等于 输入的数字个数(digits.size)了(本来index就是用来遍历digits的)。

然后收集结果,结束本层递归。

代码如下:

if (index == digits.size()) {
result.push_back(s);
return;
}
  • 确定单层遍历逻辑

首先要取index指向的数字,并找到对应的字符集(手机键盘的字符集)。

然后for循环来处理这个字符集,代码如下:

int digit = digits[index] - '0';        // 将index指向的数字转为int
string letters = letterMap[digit];      // 取数字对应的字符集
for (int i = 0; i < letters.size(); i++) {
s.push_back(letters[i]);            // 处理
backtracking(digits, index + 1);    // 递归,注意index+1,一下层要处理下一个数字了
s.pop_back();                       // 回溯
}

注意这里for循环,可不像是在​**回溯算法:求组合问题!** ****(opens new window)回溯算法:求组合总和! ****(opens new window)****​中从startIndex开始遍历的

因为本题每一个数字代表的是不同集合,也就是求不同集合之间的组合,而​**77. 组合** ****(opens new window)216.组合总和III ****(opens new window)****​都是求同一个集合中的组合!

注意:输入1 * #按键等等异常情况

代码中最好考虑这些异常情况,但题目的测试数据中应该没有异常情况的数据,所以我就没有加了。

但是要知道会有这些异常,如果是现场面试中,一定要考虑到!

关键地方都讲完了,按照关于回溯算法,你该了解这些! ​**(opens new window)** 中的回溯法模板,不难写出如下C++代码:

// 版本一
class Solution {
private:
const string letterMap[10] = {
"", // 0
"", // 1
"abc", // 2
"def", // 3
"ghi", // 4
"jkl", // 5
"mno", // 6
"pqrs", // 7
"tuv", // 8
"wxyz", // 9
};
public:
vector<string> result;
string s;
void backtracking(const string& digits, int index) {
if (index == digits.size()) {
result.push_back(s);
return;
}
int digit = digits[index] - '0';        // 将index指向的数字转为int
string letters = letterMap[digit];      // 取数字对应的字符集
for (int i = 0; i < letters.size(); i++) {
s.push_back(letters[i]);            // 处理
backtracking(digits, index + 1);    // 递归,注意index+1,一下层要处理下一个数字了
s.pop_back();                       // 回溯
}
}
vector<string> letterCombinations(string digits) {
s.clear();
result.clear();
if (digits.size() == 0) {
return result;
}
backtracking(digits, 0);
return result;
}
};
  • 时间复杂度: O(3^m * 4^n),其中 m 是对应四个字母的数字个数,n 是对应三个字母的数字个数
  • 空间复杂度: O(3^m * 4^n)

一些写法,是把回溯的过程放在递归函数里了,例如如下代码,我可以写成这样:(注意注释中不一样的地方)

// 版本二
class Solution {
private:
const string letterMap[10] = {
"", // 0
"", // 1
"abc", // 2
"def", // 3
"ghi", // 4
"jkl", // 5
"mno", // 6
"pqrs", // 7
"tuv", // 8
"wxyz", // 9
};
public:
vector<string> result;
void getCombinations(const string& digits, int index, const string& s) { // 注意参数的不同
if (index == digits.size()) {
result.push_back(s);
return;
}
int digit = digits[index] - '0';
string letters = letterMap[digit];
for (int i = 0; i < letters.size(); i++) {
getCombinations(digits, index + 1, s + letters[i]);  // 注意这里的不同
}
}
vector<string> letterCombinations(string digits) {
result.clear();
if (digits.size() == 0) {
return result;
}
getCombinations(digits, 0, "");
return result;

}
};

我不建议把回溯藏在递归的参数里这种写法,很不直观,我在二叉树:以为使用了递归,其实还隐藏着回溯 ​**(opens new window)** 这篇文章中也深度分析了,回溯隐藏在了哪里。

所以大家可以按照版本一来写就可以了。

#总结

本篇将题目的三个要点一一列出,并重点强调了和前面讲解过的77. 组合 ​**(opens new window)** 和216.组合总和III ​**(opens new window)** 的区别,本题是多个集合求组合,所以在回溯的搜索过程中,都有一些细节需要注意的。

其实本题不算难,但也处处是细节,大家还要自己亲自动手写一写。

本周小结!(回溯算法系列一)

#周一

本周我们正式开始了回溯算法系列,那么首先当然是概述。

关于回溯算法,你该了解这些! ​**(opens new window)** 中介绍了什么是回溯,回溯法的效率,回溯法解决的问题以及回溯法模板。

回溯是递归的副产品,只要有递归就会有回溯

回溯法就是暴力搜索,并不是什么高效的算法,最多在剪枝一下。

回溯算法能解决如下问题:

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

是不是感觉回溯算法有点厉害了。

回溯法确实不好理解,所以需要把回溯法抽象为一个图形来理解就容易多了,每一道回溯法的题目都可以抽象为树形结构。

针对很多同学都写不好回溯,我在关于回溯算法,你该了解这些! ​**(opens new window)** 用回溯三部曲,分析了回溯算法,并给出了回溯法的模板。

这个模板会伴随整个回溯法系列!

#周二

回溯算法:求组合问题! ​**(opens new window)** 中,我们开始用回溯法解决第一道题目,组合问题。

我在文中开始的时候给大家列举k层for循环例子,进而得出都是同样是暴力解法,为什么要用回溯法。

此时大家应该深有体会回溯法的魅力,用递归控制for循环嵌套的数量!

本题我把回溯问题抽象为树形结构,可以直观的看出其搜索的过程:for循环横向遍历,递归纵向遍历,回溯不断调整结果集

#周三

针对回溯算法:求组合问题! ​**(opens new window)** 还可以做剪枝的操作。

回溯算法:组合问题再剪剪枝 ​**(opens new window)** 中把回溯法代码做了剪枝优化,在文中我依然把问题抽象为一个树形结构,大家可以一目了然剪的究竟是哪里。

剪枝精髓是:for循环在寻找起点的时候要有一个范围,如果这个起点到集合终止之间的元素已经不够 题目要求的k个元素了,就没有必要搜索了

#周四

回溯算法:求组合总和! ​**(opens new window)** 中,相当于 回溯算法:求组合问题! ​**(opens new window)** 加了一个元素总和的限制。

整体思路还是一样的,本题的剪枝会好想一些,即:已选元素总和如果已经大于n(题中要求的和)了,那么往后遍历就没有意义了,直接剪掉

在本题中,依然还可以有一个剪枝,就是回溯算法:组合问题再剪剪枝 ​**(opens new window)** 中提到的,对for循环选择的起始范围的剪枝。

所以,剪枝的代码,可以把for循环,加上 i <= 9 - (k - path.size()) + 1​ 的限制!

组合总和问题还有一些花样,下周还会介绍到。

#周五

回溯算法:电话号码的字母组合 ​**(opens new window)** 中,开始用多个集合来求组合,还是熟悉的模板题目,但是有一些细节。

例如这里for循环,可不像是在 回溯算法:求组合问题! ​**(opens new window)** 和回溯算法:求组合总和! ​**(opens new window)** 中从startIndex开始遍历的。

因为本题每一个数字代表的是不同集合,也就是求不同集合之间的组合,而​**回溯算法:求组合问题!** ****(opens new window)回溯算法:求组合总和! ****(opens new window)****​都是是求同一个集合中的组合!

如果大家在现场面试的时候,一定要注意各种输入异常的情况,例如本题输入1 * #按键。

其实本题不算难,但也处处是细节,还是要反复琢磨。

#周六

因为之前链表系列没有写总结,虽然链表系列已经是两个月前的事情,但还是有必要补一下。

所以给出链表:总结篇! ​**(opens new window)** ,这里对之前链表理论基础和经典题目进行了总结。

同时对链表:环找到了,那入口呢? ​**(opens new window)** 中求环入口的问题又进行了补充证明,可以说把环形链表的方方面面都讲的很通透了,大家如果没有做过环形链表的题目一定要去做一做。

#总结

相信通过这一周对回溯法的学习,大家已经掌握其题本套路了,也不会对回溯法那么畏惧了。

回溯法抽象为树形结构后,其遍历过程就是:for循环横向遍历,递归纵向遍历,回溯不断调整结果集

这个是我做了很多回溯的题目,不断摸索其规律才总结出来的。

对于回溯法的整体框架,网上搜的文章这块一般都说不清楚,按照天上掉下来的代码对着讲解,不知道究竟是怎么来的,也不知道为什么要这么写。

所以,录友们刚开始学回溯法,起跑姿势就很标准了。

下周依然是回溯法,难度又要上升一个台阶了。

最后祝录友们周末愉快!

如果感觉「代码随想录」不错,就分享给身边的同学朋友吧,一起来学习算法!


网站公告

今日签到

点亮在社区的每一天
去签到