Day 19: 回溯算法
回溯算法理论基础
文章讲解:代码随想录
题目建议:其实在讲解二叉树的时候,就给大家介绍过回溯,这次正式开启回溯算法,大家可以先看视频,对回溯算法有一个整体的了解。
回溯模板如下:
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
组合
文章讲解:代码随想录
视频讲解:带你学透回溯算法-组合问题(对应力扣题目:77.组合)| 回溯法精讲!
剪枝操作:带你学透回溯算法-组合问题的剪枝操作(对应力扣题目:77.组合)| 回溯法精讲
题目建议:对着 在 回溯算法理论基础
给出的 代码模板,来做本题组合问题,大家就会发现 写回溯算法套路。在回溯算法解决实际问题的过程中,大家会有各种疑问,先看视频介绍,基本可以解决大家的疑惑。本题关于剪枝操作是大家要理解的重点,因为后面很多回溯算法解决的题目,都是这个剪枝套路
。
class Solution {
public List<List<Integer>> combine(int n, int k) {
}
}
题目解析
核心思路
- 使用回溯算法递归搜索所有可能的组合
- 通过
startIndex
参数避免重复组合
- 到叶子节点时,就拿到了结果集合之一
- 可以进行
剪枝
优化减少不必要的搜索
方法一:基础实现(未剪枝)
回溯框架:
- 使用递归代替多层嵌套循环
- 通过 path 记录当前组合
- 达到长度 k 时保存结果
class Solution {
List<List<Integer>> ret = new ArrayList<>();
List<Integer> path = new ArrayList<>();
int n, k;
public List<List<Integer>> combine(int n1, int k1) {
n = n1;
k = k1;
dfs(1);
return ret;
}
private void dfs(int startIndex) {
if (path.size() == k) {
ret.add(new ArrayList<>(path));
// err: 不能直接 add(path), 而是新创建一个以当前 path 为副本的 List 再 add
return;
}
for (int i = startIndex; i <= n; i++) {
path.add(i);
dfs(i + 1); // err: 易错点, 不是 dfs(startIndex + 1)
path.remove(path.size() - 1); // 恢复现场
}
}
}
方法二:剪枝优化实现
剪枝优化:
- 计算剩余需要的元素数量:
k - path.size()
- 调整循环上限:
n - (k - path.size()) + 1
- 避免无效搜索,提高效率
class Solution {
List<List<Integer>> ret = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
int n, k;
public List<List<Integer>> combine(int n1, int k1) {
n = n1; k =k1;
dfs(1);
return ret;
}
private void dfs(int start) {
if (path.size() == k) {
ret.add(new ArrayList<>(path));
return;
}
// 剪枝优化:i <= n - (k - path.size()) + 1
// 比如是1-10,3
// 现在有1个了,还需要两个 k - path.size(), 那么i不能大于9
// 因为 i > 9, path 最多只有 (9, 10),达不到 k 个元素, 所以去掉这种情况
// 10 - 2 + 1 = 9 n - k + path.size + 1
for (int i = start; i <= n - (k - path.size()) + 1; i++) {
path.add(i);
dfs(i + 1);
path.removeLast(); // 回溯
}
}
}
- 时间复杂度:
- 优化前:
O(C(n,k) × k)
- 优化后:最坏情况相同,但实际运行更快
- 优化前:
- 空间复杂度:
O(k)
递归调用栈深度O(k)
临时 path 存储
这种解法是组合问题的经典回溯解决方案,通过剪枝优化可以显著提高性能。
组合总和 Ⅲ
题目链接:216. 组合总和 III - 力扣(LeetCode)
文章讲解:代码随想录
视频讲解:和组合问题有啥区别?回溯算法如何剪枝?| LeetCode:216.组合总和III
题目建议:如果把 组合问题理解了,本题就容易一些了。
class Solution {
public List<List<Integer>> combinationSum3(int k, int n) {
}
}
题目解析
- 问题描述:在集合
[1, 2, 3, 4, 5, 6, 7, 8, 9]
中找到和为n
的k
个数的组合。- 与 77. 组合 的关系:本题是 [77. 组合] 的变种,多了一个限制条件,即组合的和为
n
。- 集合固定:集合是
[1, 2, 3, 4, 5, 6, 7, 8, 9]
,共有 9 个数。- 树的深度和宽度:
k
是组合的大小(树的深度)。- 集合的大小是 9(树的宽度)。
- 示例:对于
k = 2
和n = 4
,需要在集合中找到和为 4 的 2 个数的组合。选取过程如图:
核心思路
- 使用回溯算法遍历所有可能的组合
- 通过剪枝优化减少不必要的搜索
当组合长度等于 k 且和等于 n 时记录结果
代码实现
class Solution {
List<Integer> path = new ArrayList<>();
List<List<Integer>> ret = new ArrayList<>();
int k, n;
public List<List<Integer>> combinationSum3(int k1, int n1) {
k = k1; n = n1;
dfs(1, 0);
return ret;
}
private void dfs(int start, int sum){
if(sum > n) return; // err: 提前停止递归
if(path.size() == k && sum == n){
ret.add(new ArrayList<>(path));
return;
}
for(int i = start; i <= 9 ; i++){ // err: 题目说的是只能使用 1-9, 而不是 1-n
path.add(i);
sum += i;
dfs(i + 1, sum);
path.remove(path.size()-1);
sum -= i ;
}
}
}
复杂度分析
- 时间复杂度:
O(C(9,k)) = O(9^k)
,实际通过剪枝会更快 - 空间复杂度:
O(k)
,递归栈深度和临时路径存储
关键点总结
- 回溯框架:递归尝试每个可能的数字,满足条件时记录结果
- 剪枝优化:
- 当当前和超过目标值时提前终止
- 限制循环范围确保剩余数字足够完成组合
- 回溯操作:在递归返回时需要撤销上一步的选择
这个解法高效地遍历了所有可能的组合,同时通过剪枝避免了不必要的搜索,是解决此类组合问题的经典方法。
电话号码的字母组合
题目链接:17. 电话号码的字母组合 - 力扣(LeetCode)
文章讲解:代码随想录
视频讲解:还得用回溯算法!| LeetCode:17.电话号码的字母组合
题目建议:本题大家刚开始做会有点难度,先自己思考20min,没思路就直接看题解。
class Solution {
public List<String> letterCombinations(String digits) {
}
}
题目解析
-
回溯法解决多层循环问题:
- 输入如“23”时,可以
抽象为树形结构
,树的深度等于输入字符串的长度
,叶子节点是需要收集的结果
。 - 回溯法通过递归实现动态的多层循环。
- 输入如“23”时,可以
-
回溯三部曲:
- 确定回溯函数参数:
- 使用字符串数组
ret
保存结果,使用字符串s
收集当前路径。 - 参数包括输入的数字字符串
digits
和当前处理的索引index
。
- 使用字符串数组
- 确定终止条件:
- 当
index
等于digits.length()
时,说明已经处理完所有数字,将当前路径s
加入结果列表result
。
- 当
- 确定单层遍历逻辑:
- 根据
digits[index]
找到对应的字母集合。 - 使用
for
循环遍历字母集合,递归处理下一层(index + 1
),并在递归返回后回溯。
- 根据
- 确定回溯函数参数:
-
数字和字母的映射:
-
使用数组
hash
存储数字到字母的映射关系。String[] hash = { "", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz" }; List<String> ret = new ArrayList<>(); StringBuilder path = new StringBuilder();
-
-
异常情况处理:
- 输入可能包含无效字符(如
1
、*
、#
等),需要在代码中考虑这些情况。
- 输入可能包含无效字符(如
-
代码实现:
class Solution { String[] hash = { "", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz" }; List<String> ret = new ArrayList<>(); StringBuilder path = new StringBuilder(); public List<String> letterCombinations(String digits) { if (digits.length() == 0) return ret; // err: 边界条件是 digits.length() == 0, 其他都不对 dfs(digits, 0); return ret; } private void dfs(String digits, int index) { if (path.length() == digits.length()) { ret.add(path.toString()); return; } String cur = hash[digits.charAt(index) - '0']; for (int i = 0; i < cur.length(); i++) { path.append(cur.charAt(i)); // 拼接好一个 hash 元素后, 又去拼接另一个 hash 元素 dfs(digits, index + 1); // 注意 StringBuilder 删除字符的 API path.deleteCharAt(path.length() - 1); } } }
-
时间复杂度和空间复杂度:
- 时间复杂度:
O(3^m * 4^n)
,其中m
是对应三个字母的数字个数,n
是对应四个字母的数字个数。 - 空间复杂度:
O(3^m * 4^n)
。
- 时间复杂度:
-
总结:
- 本题是多个集合求组合的问题,与组合问题(如
77.组合
和216.组合总和III
)的区别在于每个数字对应不同的集合。 - 回溯法是解决此类问题的有效方法,关键在于理解递归和回溯的逻辑。
- 本题是多个集合求组合的问题,与组合问题(如