回溯算法学习笔记(《代码随想录》)
本文整理了《代码随想录》中回溯算法的核心题型,包含组合、分割、子集、排列四大类问题,每个题型均标注要点与完整代码,方便对比学习。
一、组合类问题
组合问题的核心是 “选元素、不重复、按顺序”,需重点关注startIndex的使用与剪枝优化,避免无效遍历。
1.1 基础组合(LeetCode 77)
要点
- 掌握回溯算法的基础模板:定义结果集
res与路径集path,通过递归遍历所有可能。 - 每次递归找到符合长度
k的路径后,需创建新列表存入结果(避免引用问题),并回溯撤销最后一个元素。
代码
class Solution {
// 定义两个全局变量,存储最终结果与当前路径
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
public List<List<Integer>> combine(int n, int k) {
backtracking(n, k, 1);
return res;
}
// startIndex:控制遍历起始位置,避免重复组合
public void backtracking(int n, int k, int startIndex) {
// 终止条件:当前路径长度等于目标长度k
if (path.size() == k) {
res.add(new ArrayList(path)); // 存入新列表,防止后续修改影响结果
return;
}
// 遍历所有可能的元素,从startIndex开始
for (int i = startIndex; i <= n; i++) {
path.add(i); // 加入当前元素到路径
backtracking(n, k, i + 1); // 递归,下一轮从i+1开始(不重复选)
path.remove(path.size() - 1); // 回溯:撤销最后一个元素,尝试其他可能
}
}
}
1.2 组合优化(剪枝)
要点
- 核心优化点:缩小
for循环的遍历范围,减少无效递归。 - 剪枝逻辑:若剩余可选择的元素数量(
n - i + 1)小于 “还需选择的元素数量(k - path.size())”,则无需继续遍历,直接终止。
关键代码(仅修改 for 循环条件)
// 剪枝后的for循环:i的上限从n改为 n - (k - path.size()) + 1
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) {
path.add(i);
backtracking(n, k, i + 1);
path.remove(path.size() - 1);
}
1.3 组合总和 III(LeetCode 216)
要点
- 在基础组合的基础上,增加 “总和判断”:终止条件需同时满足 “路径长度为
k” 和 “路径元素和为n”。 - 双重剪枝:
- 若当前路径和已大于
n,直接终止递归(提前返回)。 for循环中通过9 - (k - path.size()) + 1限制遍历范围(元素仅 1-9)。
- 若当前路径和已大于
代码
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
public List<List<Integer>> combinationSum3(int k, int n) {
backtracking(k, n, 1, 0);
return res;
}
// sum:当前路径的元素和
public void backtracking(int k, int n, int startIndex, int sum) {
// 剪枝1:总和已超过目标n,无需继续
if (sum > n) return;
// 终止条件:路径长度为k且总和等于n
if (path.size() == k) {
if (sum == n) res.add(new ArrayList<>(path));
return;
}
// 剪枝2:限制i的上限(元素仅1-9)
for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) {
sum += i; // 累加当前元素
path.add(i);
backtracking(k, n, i + 1, sum);
sum -= i; // 回溯:撤销总和
path.remove(path.size() - 1); // 回溯:撤销元素
}
}
}
1.4 电话号码的字母组合(LeetCode 17)
要点
- 需先建立 “数字 - 字母” 映射表(如
2→abc),将输入的数字字符串转为对应字母组合。 - 递归参数用
index记录当前处理的数字位置,终止条件为index等于数字字符串长度。 - 使用
StringBuilder存储路径(比List<Character>更高效,便于增删)。
代码
class Solution {
// 存储最终结果与当前路径(用StringBuilder优化操作)
List<String> res = new ArrayList<>();
StringBuilder str = new StringBuilder();
public List<String> letterCombinations(String digits) {
// 边界条件:输入为空字符串,直接返回空结果
if (digits == null || digits.length() == 0) return res;
// 数字-字母映射表(索引0-1对应空字符串,2-9对应实际字母)
String[] numString = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};
backtracking(digits, numString, 0);
return res;
}
// index:当前处理的数字在digits中的索引
public void backtracking(String digits, String[] numString, int index) {
// 终止条件:处理完所有数字,将当前路径存入结果
if (index == digits.length()) {
res.add(str.toString());
return;
}
// 获取当前数字对应的字母字符串(如digits[index]为'2',则temp为"abc")
String temp = numString[digits.charAt(index) - '0']; // 字符转数字:'2'-'0'=2
// 遍历当前数字对应的所有字母
for (int i = 0; i < temp.length(); i++) {
str.append(temp.charAt(i)); // 加入当前字母
backtracking(digits, numString, index + 1); // 处理下一个数字
str.deleteCharAt(str.length() - 1); // 回溯:删除最后一个字母
}
}
}
1.5 组合总和(LeetCode 39)
要点
- 与基础组合的区别:元素可重复使用,且无固定选择数量。
- 关键调整:递归时
startIndex不变(允许重复选当前元素),而非i+1。 - 终止条件:当前路径和等于
target时存入结果;若和大于target,直接返回(剪枝)。
代码
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
public List<List<Integer>> combinationSum(int[] candidates, int target) {
if (candidates == null) return res;
backtracking(candidates, target, 0, 0);
return res;
}
// sum:当前路径和;startIndex:遍历起始位置(控制重复选)
public void backtracking(int[] candidates, int target, int sum, int startIndex) {
// 终止条件:总和等于target
if (sum == target) {
res.add(new ArrayList(path));
return;
}
// 剪枝:总和已超过target,无需继续
if (sum > target) return;
// 遍历候选数组,从startIndex开始(允许重复选当前元素)
for (int i = startIndex; i < candidates.length; i++) {
path.add(candidates[i]);
sum += candidates[i];
backtracking(candidates, target, sum, i); // 此处startIndex为i,而非i+1
sum -= candidates[i]; // 回溯:撤销总和
path.remove(path.size() - 1); // 回溯:撤销元素
}
}
}
1.6 组合总和 II(LeetCode 40)
要点
- 核心挑战:数组含重复元素,需去重(避免出现重复组合),且每个元素仅用一次。
- 去重思路:
- 先对数组排序(使重复元素相邻)。
- 两种去重方式:
- 用
used数组:i>0 && candidates[i]==candidates[i-1] && !used[i-1](树层去重)。 - 不用
used数组:i>startIndex && candidates[i]==candidates[i-1](更简洁,本质是跳过同一层的重复元素)。
- 用
代码(用 used 数组去重)
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
boolean[] used; // 记录元素是否已使用,用于去重
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
Arrays.sort(candidates); // 排序:使重复元素相邻,便于去重
used = new boolean[candidates.length];
Arrays.fill(used, false); // 初始化used数组
backtracking(candidates, target, 0, 0);
return res;
}
public void backtracking(int[] candidates, int target, int sum, int startIndex) {
if (sum == target) {
res.add(new ArrayList(path));
return;
}
if (sum > target) return; // 剪枝
for (int i = startIndex; i < candidates.length; i++) {
// 去重:同一层中,当前元素与前一个元素相同且前一个未使用(树层去重)
if (i > 0 && candidates[i] == candidates[i - 1] && !used[i - 1]) {
continue;
}
used[i] = true; // 标记当前元素已使用
path.add(candidates[i]);
sum += candidates[i];
backtracking(candidates, target, sum, i + 1); // 元素仅用一次,startIndex为i+1
used[i] = false; // 回溯:取消标记
sum -= candidates[i]; // 回溯:撤销总和
path.remove(path.size() - 1); // 回溯:撤销元素
}
}
}
二、分割类问题
分割问题的核心是 “按规则分割字符串”,需通过startIndex控制分割起始位置,同时判断分割后的子串是否符合条件(如回文、IP 规范)。
2.1 分割回文串(LeetCode 131)
要点
- 需额外实现
check方法,判断子串是否为回文串。 - 递归逻辑:从
startIndex开始分割,若分割后的子串是回文串,则加入路径,继续分割剩余部分。 - 终止条件:
startIndex等于字符串长度(分割线到达字符串末尾)。
代码
class Solution {
List<List<String>> res = new ArrayList<>();
List<String> path = new ArrayList<>();
public List<List<String>> partition(String s) {
backtracking(s, 0, new StringBuilder());
return res;
}
// sb:临时存储当前分割的子串
public void backtracking(String s, int startIndex, StringBuilder sb) {
// 终止条件:分割线到达字符串末尾
if (startIndex == s.length()) {
res.add(new ArrayList(path));
return;
}
// 从startIndex开始,尝试所有可能的分割位置
for (int i = startIndex; i < s.length(); i++) {
sb.append(s.charAt(i)); // 拼接当前字符,形成子串
// 若当前子串是回文串,加入路径并继续分割
if (check(sb)) {
path.add(sb.toString());
backtracking(s, i + 1, new StringBuilder()); // 下一轮从i+1开始分割
path.remove(path.size() - 1); // 回溯:撤销路径
}
}
}
// 判断字符串是否为回文串
public boolean check(StringBuilder sb) {
for (int i = 0; i < sb.length() / 2; i++) {
// 对称位置字符不相等,不是回文串
if (sb.charAt(i) != sb.charAt(sb.length() - i - 1)) {
return false;
}
}
return true;
}
}
2.2 复原 IP 地址(LeetCode 93)
要点
- IP 地址规则:由 4 段数字组成,每段 0-255,且不能以 0 开头(除非段内只有 0,如 “0” 合法,“01” 不合法)。
- 递归参数:
pointSum记录已添加的小数点数量,终止条件为pointSum==3(需判断最后一段是否符合 IP 规则)。 - 剪枝:输入字符串长度超过 12(如 “1234567890123”)时,直接返回空结果(IP 最长为 12 位,如 “255.255.255.255”)。
代码
class Solution {
List<String> res = new ArrayList<>();
public List<String> restoreIpAddresses(String s) {
// 剪枝:IP地址最长12位(4段×3位),超过直接返回
if (s.length() > 12) {
return res;
}
backtracking(s, 0, 0);
return res;
}
// pointSum:已添加的小数点数量
public void backtracking(String s, int startIndex, int pointSum) {
// 终止条件:已添加3个小数点,判断最后一段是否合法
if (pointSum == 3) {
if (isVal(s, startIndex, s.length() - 1)) {
res.add(s);
}
return;
}
// 遍历分割位置,判断当前段是否合法
for (int i = startIndex; i < s.length(); i++) {
if (isVal(s, startIndex, i)) {
// 在i后添加小数点(字符串拼接)
s = s.substring(0, i + 1) + '.' + s.substring(i + 1);
pointSum += 1;
// 下一轮从i+2开始(跳过小数点)
backtracking(s, i + 2, pointSum);
// 回溯:删除小数点
s = s.substring(0, i + 1) + s.substring(i + 2);
pointSum -= 1;
} else {
// 当前段不合法,后续分割也不合法,直接终止循环
break;
}
}
}
// 判断s[startIndex..end]是否符合IP段规则
public boolean isVal(String s, int startIndex, int end) {
if (startIndex > end) return false;
// 规则1:不能以0开头(除非段内只有0)
if (s.charAt(startIndex) == '0' && startIndex != end) {
return false;
}
// 规则2:数字范围0-255,且无非法字符(非数字)
int num = 0;
for (int i = startIndex; i <= end; i++) {
// 非法字符(非0-9)
if (s.charAt(i) > '9' || s.charAt(i) < '0') {
return false;
}
num = num * 10 + (s.charAt(i) - '0');
// 数字超过255
if (num > 255) {
return false;
}
}
return true;
}
}
三、子集类问题
子集问题的核心是 “收集所有可能的子集”,与组合 / 分割的区别是:每个递归节点的路径都需存入结果(而非仅终止条件时存入)。
3.1 基础子集(LeetCode 78)
要点
- 无需额外终止条件(除
startIndex越界时返回),每次递归先将当前路径存入结果。 - 遍历逻辑与组合类似,通过
startIndex控制不重复选元素(子集是无序的,如[1,2]和[2,1]是同一个子集)。
代码
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
public List<List<Integer>> subsets(int[] nums) {
backtracking(nums, 0);
return res;
}
public void backtracking(int[] nums, int startIndex) {
// 关键:每个节点的路径都存入结果(子集)
res.add(new ArrayList(path));
// 终止条件:startIndex越界,无元素可选
if (startIndex >= nums.length) return;
// 遍历所有可能的元素,从startIndex开始
for (int i = startIndex; i < nums.length; i++) {
path.add(nums[i]);
backtracking(nums, i + 1); // 下一轮从i+1开始(不重复选)
path.remove(path.size() - 1); // 回溯:撤销元素
}
return;
}
}
3.2 子集 II(LeetCode 90)
要点
- 数组含重复元素,需去重(避免重复子集,如
[1,2]出现多次)。 - 去重步骤:
- 先对数组排序(使重复元素相邻)。
- 遍历中判断:
i>startIndex && nums[i]==nums[i-1]时,跳过当前元素(同一层的重复元素,避免重复子集)。
代码
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
public List<List<Integer>> subsetsWithDup(int[] nums) {
Arrays.sort(nums); // 排序:便于去重
backtracking(nums, 0);
return res;
}
public void backtracking(int[] nums, int startIndex) {
// 每个节点的路径都存入结果
res.add(new ArrayList(path));
if (startIndex >= nums.length) return;
for (int i = startIndex; i < nums.length; i++) {
// 去重:同一层中,当前元素与前一个元素相同,跳过
if (i > startIndex && nums[i] == nums[i - 1]) {
continue;
}
path.add(nums[i]);
backtracking(nums, i + 1);
path.remove(path.size() - 1); // 回溯:撤销元素
}
return;
}
}
3.3 递增子序列(LeetCode 491)
要点
- 需满足两个条件:子序列长度≥2,且元素严格递增(非递减)。
- 去重与递增控制:
- 用
HashSet记录当前层已使用的元素(避免同一层重复选择,如[4,6,7,7]中第二个 7 跳过)。 - 递增判断:若路径非空,当前元素需≥路径最后一个元素(否则跳过,不满足递增)。
- 用
代码
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
public List<List<Integer>> findSubsequences(int[] nums) {
backtracking(nums, 0);
return res;
}
public void backtracking(int[] nums, int startIndex) {
// 终止条件:路径长度≥2,存入结果(不终止递归,继续收集更长的子序列)
if (path.size() >= 2) {
res.add(new ArrayList<>(path));
}
// 用HashSet记录当前层已使用的元素,避免重复子序列
HashSet<Integer> hs = new HashSet<>();
for (int i = startIndex; i < nums.length; i++) {
// 跳过条件:1.不满足递增;2.当前元素已在当前层使用过
if (!path.isEmpty() && path.get(path.size() - 1) > nums[i] || hs.contains(nums[i])) {
continue;
}
hs.add(nums[i]); // 标记当前元素在当前层已使用
path.add(nums[i]);
backtracking(nums, i + 1); // 下一轮从i+1开始(不重复选)
path.remove(path.size() - 1); // 回溯:撤销元素
}
}
}
四、排列类问题
排列问题的核心是 “元素无顺序要求,每个元素仅用一次”,与组合的区别是:递归时从 0 开始遍历,用used数组标记已使用的元素(而非startIndex)。
4.1 基础全排列(LeetCode 46)
要点
- 无
startIndex,每次遍历从 0 开始(排列是有序的,如[1,2]和[2,1]是不同排列)。 - 用
used数组记录元素是否已使用,避免重复选择(如nums[0]已用,则跳过)。 - 终止条件:路径长度等于数组长度(收集到一个完整排列)。
代码
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
boolean[] used; // 标记元素是否已使用
public List<List<Integer>> permute(int[] nums) {
used = new boolean[nums.length];
backtracking(nums, used);
return res;
}
public void backtracking(int[] nums, boolean[] used) {
// 终止条件:路径长度等于数组长度(完整排列)
if (path.size() == nums.length) {
res.add(new ArrayList<>(path));
return;
}
// 从0开始遍历所有元素(排列无序)
for (int i = 0; i < nums.length; i++) {
if (used[i] == true) continue; // 元素已使用,跳过
used[i] = true; // 标记当前元素已使用
path.add(nums[i]);
backtracking(nums, used);
used[i] = false; // 回溯:取消标记
path.remove(path.size() - 1); // 回溯:撤销元素
}
}
}
4.2 全排列 II(LeetCode 47)
要点
- 数组含重复元素,需去重(避免重复排列,如
[1,1,2]的重复排列)。 - 去重步骤:
- 先对数组排序(使重复元素相邻)。
- 去重条件:
i>0 && nums[i]==nums[i-1] && !used[i-1](树层去重,避免同一层重复选择)。 - 同时保留
used[i]==true的判断(避免重复使用同一元素)。
代码
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
boolean[] used;
public List<List<Integer>> permuteUnique(int[] nums) {
Arrays.sort(nums); // 排序:便于去重
used = new boolean[nums.length];
backtracking(nums, used);
return res;
}
public void backtracking(int[] nums, boolean[] used) {
if (path.size() == nums.length) {
res.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < nums.length; i++) {
// 去重:同一层中,当前元素与前一个元素相同且前一个未使用(树层去重)
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) {
continue;
}
// 元素已使用,跳过
if (used[i] == true) {
continue;
}
used[i] = true;
path.add(nums[i]);
backtracking(nums, used);
used[i] = false;
path.remove(path.size() - 1);
}
}
}
回溯算法核心模板总结
所有回溯问题均可基于以下模板扩展,关键区别在于终止条件、遍历范围和去重 / 剪枝逻辑:
// 1. 定义全局/成员变量(结果集、路径集)
List<结果类型> res = new ArrayList<>();
路径类型 path = new 路径类型();
// 2. 主方法(初始化并调用回溯)
public 结果类型 solve(输入参数) {
backtracking(输入参数, 辅助参数); // 辅助参数如startIndex、used、sum等
return res;
}
// 3. 回溯方法
public void backtracking(输入参数, 辅助参数) {
// 终止条件:判断是否收集结果
if (终止条件) {
res.add(new 结果类型(path)); // 注意深拷贝
return;
}
// 遍历所有可能的选择
for (int i = 起始位置; i < 遍历范围; i++) {
// 剪枝/去重:跳过无效选择
if (剪枝/去重条件) {
continue;
}
// 选择:将当前元素加入路径
path.add(当前元素);
// 递归:处理下一层
backtracking(输入参数, 新辅助参数); // 新辅助参数如i+1、used更新等
// 回溯:撤销选择
path.remove(path.size() - 1);
}
}
1003

被折叠的 条评论
为什么被折叠?



