22、第七章 回溯算法part02

本节内容

- 216.组合总和III
- 17.电话号码的字母组合

总和※

建议:如果把 组合问题理解了,本题就容易一些了。

题目链接: https://leetcode.cn/problems/combination-sum-iii/
文章讲解: https://programmercarl.com/0216.%E7%BB%84%E5%90%88%E6%80%BB%E5%92%8CIII.html
视频讲解: https://www.bilibili.com/video/BV1wg411873x

题目分析

示例 1:

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

示例 2:

1
2
3
4
5
6
7
输入: 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:

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

方案一

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Solution {
public List<List<Integer>> combinationSum3(int k, int n) {
List<List<Integer>> result = new ArrayList<>();
backtracking(1, k, n, 0, new ArrayList<>(), result);

return result;
}

/**
*
* @param start 加入数组的数值,同时也是开始遍历的数值
* @param k 当前正在加入第几个参数,k是从头向下减的
* @param n 相加之和之后的判断数值
* @param sum 当前数值相加之后的值,其等于list中的数值相加
* @param list 遍历的数值加入到list中
* @param result 存放结果
*/
private void backtracking(int start, int k, int n, int sum, List<Integer> list, List<List<Integer>> result) {
if (sum > n) return; // 剪枝
if (k == 0) {
if (sum == n) {
result.add(new ArrayList<>(list));
}
return;
}

for (int i = start; i <= 9 - k + 1; i++) { // 剪枝
list.add(i);
backtracking(i + 1, k - 1, n, sum + i, list, result);
list.remove(list.size() - 1);
}

}
}

结果

解答成功:
执行耗时:0 ms,击败了100.00% 的Java用户
内存消耗:39.1 MB,击败了52.72% 的Java用户

分析

时间复杂度:
O( 9 ^ k )

代码随想录

https://programmercarl.com/0216.%E7%BB%84%E5%90%88%E6%80%BB%E5%92%8CIII.html

思路

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

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

想到这一点了,做过77. 组合之后,本题是简单一些了。

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

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

选取过程如图:

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

回溯三部曲

  • 确定递归函数参数

77. 组合 一样,依然需要一维数组path来存放符合条件的结果,二维数组result来存放结果集。

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

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

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

接下来还需要如下参数:

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

所以代码如下:

1
2
3
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收集当前的结果。

所以 终止代码如下:

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

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

如图:

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

代码如下:

1
2
3
4
5
6
7
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(); // 回溯
}

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

剪枝

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

如图:

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
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(); // 回溯
}

for循环的范围也可以剪枝,i <= 9 - (k - path.size()) + 1就可以了。

  • 时间复杂度: O(n * 2^n)
  • 空间复杂度: O(n)

代码实现

模板方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution {
List<List<Integer>> result = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();

public List<List<Integer>> combinationSum3(int k, int n) {
backTracking(n, k, 1, 0);
return result;
}

private void backTracking(int targetSum, int k, int startIndex, int sum) {
// 减枝
if (sum > targetSum) {
return;
}

if (path.size() == k) {
if (sum == targetSum) result.add(new ArrayList<>(path));
return;
}

// 减枝 9 - (k - path.size()) + 1
for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) {
path.add(i);
sum += i;
backTracking(targetSum, k, i + 1, sum);
//回溯
path.removeLast();
//回溯
sum -= i;
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 上面剪枝 i <= 9 - (k - path.size()) + 1; 如果还是不清楚
// 也可以改为 if (path.size() > k) return; 执行效率上是一样的
class Solution {
LinkedList<Integer> path = new LinkedList<>();
List<List<Integer>> ans = new ArrayList<>();
public List<List<Integer>> combinationSum3(int k, int n) {
build(k, n, 1, 0);
return ans;
}

private void build(int k, int n, int startIndex, int sum) {

if (sum > n) return;

if (path.size() > k) return;

if (sum == n && path.size() == k) {
ans.add(new ArrayList<>(path));
return;
}

for(int i = startIndex; i <= 9; i++) {
path.add(i);
sum += i;
build(k, n, i + 1, sum);
sum -= i;
path.removeLast();
}
}
}

其他方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> list = new ArrayList<>();

public List<List<Integer>> combinationSum3(int k, int n) {
res.clear();
list.clear();
backtracking(k, n, 9);
return res;
}

private void backtracking(int k, int n, int maxNum) {
if (k == 0 && n == 0) {
res.add(new ArrayList<>(list));
return;
}

// 因为不能重复,并且单个数字最大值是maxNum,所以sum最大值为
// (maxNum + (maxNum - 1) + ... + (maxNum - k + 1)) == k * maxNum - k*(k - 1) / 2
if (maxNum == 0
|| n > k * maxNum - k * (k - 1) / 2
|| n < (1 + k) * k / 2) {
return;
}
list.add(maxNum);
backtracking(k - 1, n - maxNum, maxNum - 1);
list.remove(list.size() - 1);
backtracking(k, n, maxNum - 1);
}
}

17.电话号码的字母组合※

建议:本题大家刚开始做会有点难度,先自己思考20min,没思路就直接看题解。

题目链接:
文章讲解: https://programmercarl.com/0017.%E7%94%B5%E8%AF%9D%E5%8F%B7%E7%A0%81%E7%9A%84%E5%AD%97%E6%AF%8D%E7%BB%84%E5%90%88.html
视频讲解: https://www.bilibili.com/video/BV1yV4y1V7Ug

题目分析


方案一

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
class Solution {
public List<String> letterCombinations(String digits) {
List<String> result = new ArrayList<>();
if (digits.length() == 0) return result;
backtracking(digits, 0, new StringBuilder(), result);
return result;
}

private void backtracking (String digits, int start, StringBuilder path,List<String> result) {
if (start == digits.length()) {
result.add(path.toString());
return;
}

for (Character character : chooseLetter(digits.charAt(start))) {
path.append(character);
backtracking(digits, start + 1, path, result);
path.deleteCharAt(path.length() - 1);
}
}

private List<Character> chooseLetter(char ch) {
List<Character> result = new ArrayList<>();
switch (ch) {
case '2':
result.add('a');
result.add('b');
result.add('c');
break;
case '3':
result.add('d');
result.add('e');
result.add('f');
break;
case '4':
result.add('g');
result.add('h');
result.add('i');
break;
case '5':
result.add('j');
result.add('k');
result.add('l');
break;
case '6':
result.add('m');
result.add('n');
result.add('o');
break;
case '7':
result.add('p');
result.add('q');
result.add('r');
result.add('s');
break;
case '8':
result.add('t');
result.add('u');
result.add('v');
break;
case '9':
result.add('w');
result.add('x');
result.add('y');
result.add('z');
break;
}
return result;
}
}

结果

解答成功:
执行耗时:1 ms,击败了47.26% 的Java用户
内存消耗:40.1 MB,击败了55.72% 的Java用户

分析

时间复杂度:
O( 4 * n )

空间复杂度:
O( 4 * n )

代码随想录

https://programmercarl.com/0017.%E7%94%B5%E8%AF%9D%E5%8F%B7%E7%A0%81%E7%9A%84%E5%AD%97%E6%AF%8D%E7%BB%84%E5%90%88.html

思路

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

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

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

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

数字和字母如何映射

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

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

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

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

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

回溯三部曲:

  • 确定回溯函数参数

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

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

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

代码如下:

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

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

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

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

代码如下:

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

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

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

1
2
3
4
5
6
7
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(); // 回溯
}

本题每一个数字代表的是不同集合,也就是求不同集合之间的组合

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

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

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

  • 时间复杂度: O(3^m * 4^n),其中 m 是对应四个字母的数字个数,n 是对应三个字母的数字个数
  • 空间复杂度: O(3^m * 4^n)

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class Solution {

//设置全局列表存储最后的结果
List<String> list = new ArrayList<>();

public List<String> letterCombinations(String digits) {
if (digits == null || digits.length() == 0) {
return list;
}
//初始对应所有的数字,为了直接对应2-9,新增了两个无效的字符串""
String[] numString = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};
//迭代处理
backTracking(digits, numString, 0);
return list;

}

//每次迭代获取一个字符串,所以会设计大量的字符串拼接,所以这里选择更为高效的 StringBuild
StringBuilder temp = new StringBuilder();

//比如digits如果为"23",num 为0,则str表示2对应的 abc
public void backTracking(String digits, String[] numString, int num) {
//遍历全部一次记录一次得到的字符串
if (num == digits.length()) {
list.add(temp.toString());
return;
}
//str 表示当前num对应的字符串
String str = numString[digits.charAt(num) - '0'];
for (int i = 0; i < str.length(); i++) {
temp.append(str.charAt(i));
//c
backTracking(digits, numString, num + 1);
//剔除末尾的继续尝试
temp.deleteCharAt(temp.length() - 1);
}
}
}

22、第七章 回溯算法part02
http://yuanql.top/2023/08/05/02_1_代码随想录算法训练营18期/22、第七章 回溯算法part02/
作者
Qingli Yuan
发布于
2023年8月5日
许可协议