1. 回溯算法基础与核心思想
回溯算法是一种通过探索所有可能的候选解来找出所有解的算法。如果候选解被确认不是一个解(或者至少不是最后一个解),回溯算法会通过在上一步进行一些变化来丢弃该解,即"回溯"并尝试其他可能的解。
回溯算法的核心思想可以概括为"尝试-回溯-再尝试"的过程。它通常用于解决组合、排列、子集等需要穷举所有可能情况的问题。回溯算法本质上是一种暴力搜索算法,但通过剪枝操作可以显著提高效率。
1.1 回溯算法的基本框架
回溯算法通常遵循以下基本框架:
- 选择:从候选解中选择一个可能的解
- 约束:检查这个选择是否满足问题的约束条件
- 目标:检查这个选择是否达到问题的目标
- 回溯:如果选择不满足约束或未达到目标,撤销这个选择,尝试其他可能
在代码实现上,回溯算法通常采用递归的方式,基本模板如下:
java复制void backtrack(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中的元素) {
处理节点;
backtrack(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
1.2 回溯算法的关键要素
- 路径:已经做出的选择
- 选择列表:当前可以做的选择
- 结束条件:到达决策树底层,无法再做选择的条件
1.3 回溯算法的效率优化
回溯算法的时间复杂度通常较高,因为需要遍历所有可能的解。但通过以下方法可以优化:
- 剪枝:提前排除不可能的解,减少递归次数
- 记忆化:存储已经计算过的结果,避免重复计算
- 排序预处理:对输入数据进行排序,便于剪枝操作
2. 子集问题:找出所有子集的异或总和再求和(LC1863)
2.1 问题分析与思路
题目要求计算数组中所有子集的异或总和之和。异或操作的特点是:相同为0,不同为1。对于子集问题,我们需要考虑所有可能的子集组合。
解题思路:
- 使用深度优先搜索遍历所有可能的子集
- 在遍历过程中维护当前路径的异或值
- 每当进入一个新的节点时,将当前异或值加入总和
- 利用异或操作的特性(a^b^b = a)来恢复现场
2.2 代码实现与解析
java复制class Solution {
int path = 0; // 当前路径的异或值
int sum = 0; // 所有子集异或和的总和
public int subsetXORSum(int[] nums) {
dfs(nums, 0);
return sum;
}
void dfs(int[] nums, int i) {
sum += path; // 将当前路径的异或值加入总和
for (int j = i; j < nums.length; j++) {
path ^= nums[j]; // 选择当前数字,更新异或值
dfs(nums, j + 1); // 递归处理下一个数字
path ^= nums[j]; // 回溯,恢复异或值
}
}
}
关键点说明:
path变量记录当前路径的异或值- 每次进入
dfs函数时,先将当前path值加入总和 - 通过
^=操作实现选择和撤销选择,利用异或的自反性(a^a=0)恢复现场 - 通过
j+1确保每个元素只被选择一次,避免重复
2.3 复杂度分析
- 时间复杂度:O(2^n),因为需要遍历所有子集
- 空间复杂度:O(n),递归栈的深度
3. 全排列II(LC47)
3.1 问题分析与思路
与普通全排列不同,本题包含重复数字,需要去除重复的排列。关键在于如何有效地剪枝,避免生成重复的排列。
解题思路:
- 首先对数组进行排序,使相同数字相邻
- 使用回溯算法生成所有排列
- 剪枝条件:
- 当前数字已经被使用过(通过check数组判断)
- 当前数字与前一个数字相同,且前一个数字未被使用(保证相同数字的相对顺序)
3.2 代码实现与解析
java复制class Solution {
boolean[] check; // 记录数字是否被使用过
List<List<Integer>> ret = new ArrayList<>(); // 结果集
List<Integer> path = new ArrayList<>(); // 当前路径
public List<List<Integer>> permuteUnique(int[] nums) {
Arrays.sort(nums); // 排序以便剪枝
check = new boolean[nums.length];
dfs(nums);
return ret;
}
void dfs(int[] nums) {
if (path.size() == nums.length) { // 找到一个完整排列
ret.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < nums.length; i++) {
// 剪枝条件
if (check[i] || (i != 0 && nums[i] == nums[i-1] && !check[i-1])) {
continue;
}
path.add(nums[i]);
check[i] = true;
dfs(nums);
// 回溯
path.remove(path.size() - 1);
check[i] = false;
}
}
}
关键点说明:
- 排序是为了让相同数字相邻,便于剪枝
check[i]标记数字是否已被使用- 剪枝条件
i != 0 && nums[i] == nums[i-1] && !check[i-1]确保对于重复数字,只有第一个未被使用的数字会被选择 - 注意
new ArrayList<>(path)创建新列表,避免引用问题
3.3 复杂度分析
- 时间复杂度:O(n*n!),最坏情况下需要生成所有排列
- 空间复杂度:O(n),递归栈和辅助数组的空间
4. 电话号码字母组合(LC17)
4.1 问题分析与思路
给定一个数字字符串,每个数字对应多个字母,要求返回所有可能的字母组合。这是一个典型的组合问题,可以使用回溯算法解决。
解题思路:
- 建立数字到字母的映射关系
- 使用回溯算法遍历所有可能的组合
- 对于每个数字,依次尝试其对应的所有字母
- 当组合长度等于输入数字长度时,加入结果集
4.2 代码实现与解析
java复制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.isEmpty()) return ret;
char[] d = digits.toCharArray();
dfs(d, 0);
return ret;
}
void dfs(char[] d, int i) {
if (i == d.length) { // 找到一个完整组合
ret.add(path.toString());
return;
}
String cur = hash[d[i] - '0']; // 当前数字对应的字母串
for (int j = 0; j < cur.length(); j++) {
path.append(cur.charAt(j)); // 选择当前字母
dfs(d, i + 1); // 递归处理下一个数字
path.deleteCharAt(path.length() - 1); // 回溯
}
}
}
关键点说明:
hash数组存储数字到字母的映射关系path使用StringBuilder高效构建字符串- 递归深度由输入数字长度决定
- 每次递归处理一个数字,遍历其所有可能的字母
4.3 复杂度分析
- 时间复杂度:O(4^n),最坏情况下每个数字对应4个字母
- 空间复杂度:O(n),递归栈的深度
5. 括号生成(LC22)
5.1 问题分析与思路
生成所有有效的括号组合,需要满足:
- 左右括号数量相等
- 任何时候右括号数量不超过左括号
解题思路:
- 使用回溯算法,在每一步选择添加左括号或右括号
- 通过两个计数器跟踪已使用的左右括号数量
- 添加左括号的条件:已使用数量小于n
- 添加右括号的条件:已使用数量小于左括号数量
5.2 代码实现与解析
java复制class Solution {
List<String> ret = new ArrayList<>();
StringBuilder path = new StringBuilder();
int left = 0; // 已使用左括号数
int right = 0; // 已使用右括号数
public List<String> generateParenthesis(int n) {
dfs(n);
return ret;
}
void dfs(int n) {
// 剪枝:左括号超过n或右括号超过左括号
if (left > n || right > left) return;
// 找到一个有效组合
if (right == n) {
ret.add(path.toString());
return;
}
// 尝试添加左括号
path.append("(");
left++;
dfs(n);
left--;
path.deleteCharAt(path.length() - 1);
// 尝试添加右括号
path.append(")");
right++;
dfs(n);
right--;
path.deleteCharAt(path.length() - 1);
}
}
关键点说明:
left和right计数器确保括号有效性- 剪枝条件
left > n || right > left提前终止无效路径 - 两种选择:添加左括号或右括号,分别递归处理
- 注意回溯时要恢复计数器状态和路径
5.3 复杂度分析
- 时间复杂度:O(4^n/√n),卡特兰数的时间复杂度
- 空间复杂度:O(n),递归栈的深度
6. 组合(LC77)
6.1 问题分析与思路
从1到n的数字中选出k个数的所有组合。组合不考虑顺序,因此[1,2]和[2,1]是相同的。
解题思路:
- 使用回溯算法,从起始位置开始选择数字
- 每次选择一个数字后,只能从后面的数字继续选择,避免重复
- 当组合大小达到k时,加入结果集
6.2 代码实现与解析
java复制class Solution {
List<List<Integer>> ret = new ArrayList<>();
List<Integer> path = new ArrayList<>();
public List<List<Integer>> combine(int n, int k) {
dfs(n, k, 1);
return ret;
}
void dfs(int n, int k, int start) {
if (path.size() == k) { // 找到一个有效组合
ret.add(new ArrayList<>(path));
return;
}
for (int i = start; i <= n; i++) {
path.add(i); // 选择当前数字
dfs(n, k, i + 1); // 从下一个数字继续选择
path.remove(path.size() - 1); // 回溯
}
}
}
关键点说明:
start参数确保只从后面的数字选择,避免重复组合- 递归终止条件是路径长度等于k
- 注意
new ArrayList<>(path)创建新列表 - 回溯时移除最后添加的数字
6.3 复杂度分析
- 时间复杂度:O(C(n,k)*k),共有C(n,k)个组合,每个组合需要O(k)时间
- 空间复杂度:O(k),递归栈和路径的空间
7. 目标和(LC494)
7.1 问题分析与思路
给定一个数字数组,通过在数字前添加+或-号,使表达式结果等于目标值。求所有可能的组合数。
解题思路:
- 每个数字有两种选择:加或减
- 使用回溯算法尝试所有可能
- 当处理完所有数字且和等于目标时,计数加一
- 可以优化为参数传递和,避免全局变量
7.2 代码实现与解析
java复制class Solution {
int ret = 0;
int[] nums;
int target;
public int findTargetSumWays(int[] _nums, int _target) {
nums = _nums;
target = _target;
dfs(0, 0);
return ret;
}
void dfs(int i, int sum) {
if (i == nums.length) { // 处理完所有数字
if (sum == target) ret++;
return;
}
// 尝试加当前数字
dfs(i + 1, sum + nums[i]);
// 尝试减当前数字
dfs(i + 1, sum - nums[i]);
}
}
关键点说明:
- 使用参数传递当前和,简化回溯操作
- 两种选择:加或减当前数字
- 递归终止条件是处理完所有数字
- 当和等于目标时增加计数
7.3 复杂度分析
- 时间复杂度:O(2^n),每个数字有2种选择
- 空间复杂度:O(n),递归栈的深度
8. 组合总和(LC39)
8.1 问题分析与思路
从候选数字中选出和等于目标的组合,数字可以重复使用,组合不考虑顺序。
解题思路:
- 排序候选数字便于剪枝
- 回溯算法,每次从当前位置开始选择(避免重复组合)
- 当和超过目标时剪枝
- 数字可以多次使用,因此递归时起始位置不变
8.2 代码实现与解析
java复制class Solution {
List<List<Integer>> ret = new ArrayList<>();
List<Integer> path = new ArrayList<>();
int[] nums;
int target;
public List<List<Integer>> combinationSum(int[] _nums, int _target) {
nums = _nums;
target = _target;
Arrays.sort(nums); // 排序便于剪枝
dfs(0, 0);
return ret;
}
void dfs(int start, int sum) {
if (sum == target) { // 找到一个有效组合
ret.add(new ArrayList<>(path));
return;
}
for (int i = start; i < nums.length; i++) {
if (sum + nums[i] > target) break; // 剪枝
path.add(nums[i]);
dfs(i, sum + nums[i]); // 注意起始位置不变,允许重复使用
path.remove(path.size() - 1);
}
}
}
关键点说明:
- 排序后可以提前终止不可能的选择
start参数避免生成重复组合- 递归时起始位置不变,允许数字重复使用
- 当和超过目标时提前终止循环
8.3 复杂度分析
- 时间复杂度:O(N^target),最坏情况下需要尝试所有组合
- 空间复杂度:O(target),递归栈的深度
9. 字母大小写全排列(LC784)
9.1 问题分析与思路
给定一个字符串,生成所有字母大小写可能的组合。数字保持不变。
解题思路:
- 遍历字符串的每个字符
- 如果是字母,有两种选择:保持原样或转换大小写
- 如果是数字,只有一种选择
- 使用回溯算法尝试所有可能
9.2 代码实现与解析
java复制class Solution {
List<String> ret = new ArrayList<>();
StringBuilder path = new StringBuilder();
char[] ss;
public List<String> letterCasePermutation(String s) {
ss = s.toCharArray();
dfs(0);
return ret;
}
void dfs(int i) {
if (i == ss.length) { // 找到一个完整组合
ret.add(path.toString());
return;
}
char c = ss[i];
// 不改变的情况(数字或字母原样)
path.append(c);
dfs(i + 1);
path.deleteCharAt(path.length() - 1);
// 如果是字母,尝试改变大小写
if (Character.isLetter(c)) {
char changed = (char)(c ^ 32); // 利用ASCII码特性转换大小写
path.append(changed);
dfs(i + 1);
path.deleteCharAt(path.length() - 1);
}
}
}
关键点说明:
- 使用
^32高效转换字母大小写 - 数字只有一种选择,直接添加
- 字母有两种选择:原样或转换
- 递归处理下一个字符
9.3 复杂度分析
- 时间复杂度:O(2^k*n),k是字母数量,n是字符串长度
- 空间复杂度:O(n),递归栈和路径的空间
10. 回溯算法总结与实战技巧
10.1 回溯算法常见问题类型
- 组合问题:从集合中找出满足条件的组合(LC77, LC39)
- 排列问题:求集合的全排列,可能有重复元素(LC46, LC47)
- 子集问题:求集合的所有子集(LC78, LC90)
- 分割问题:将字符串分割满足条件的子串(LC131)
- 棋盘问题:N皇后,解数独等(LC51, LC37)
10.2 回溯算法优化技巧
-
剪枝:提前终止不可能的解
- 基于约束条件的剪枝(如组合总和中的和超过目标)
- 基于重复元素的剪枝(如全排列II中的重复数字处理)
-
记忆化:存储中间结果避免重复计算
- 适用于有重叠子问题的情况
-
迭代实现:对于深度较大的问题,可以使用栈模拟递归
-
并行处理:对于大规模问题,可以考虑并行化处理
10.3 回溯算法常见错误与调试
- 忘记恢复现场:回溯时要撤销选择
- 重复结果:注意组合与排列的区别,合理设置起始位置
- 无限递归:确保有正确的终止条件
- 性能问题:合理剪枝,避免不必要的计算
10.4 回溯算法与其他算法的关系
- 与DFS的关系:回溯算法通常用DFS实现,但回溯强调"试错"和"撤销"
- 与动态规划的关系:有些问题既可以用回溯也可以用DP,DP通常更高效
- 与贪心算法的关系:贪心算法是特殊的回溯,每次选择局部最优
在实际应用中,回溯算法虽然时间复杂度较高,但对于需要穷举所有解的问题,它提供了一种系统化的解决方法。通过合理的剪枝和优化,可以显著提高效率。