回溯算法学习笔记(《代码随想录》)

回溯算法学习笔记(《代码随想录》)

本文整理了《代码随想录》中回溯算法的核心题型,包含组合、分割、子集、排列四大类问题,每个题型均标注要点与完整代码,方便对比学习。

一、组合类问题

组合问题的核心是 “选元素、不重复、按顺序”,需重点关注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”。
  • 双重剪枝:
    1. 若当前路径和已大于n,直接终止递归(提前返回)。
    2. 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)

要点
  • 核心挑战:数组含重复元素,需去重(避免出现重复组合),且每个元素仅用一次。
  • 去重思路:
    1. 先对数组排序(使重复元素相邻)。
    2. 两种去重方式:
      • 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]出现多次)。
  • 去重步骤:
    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,且元素严格递增(非递减)。
  • 去重与递增控制:
    1. HashSet记录当前层已使用的元素(避免同一层重复选择,如[4,6,7,7]中第二个 7 跳过)。
    2. 递增判断:若路径非空,当前元素需≥路径最后一个元素(否则跳过,不满足递增)。
代码
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]的重复排列)。
  • 去重步骤:
    1. 先对数组排序(使重复元素相邻)。
    2. 去重条件:i>0 && nums[i]==nums[i-1] && !used[i-1](树层去重,避免同一层重复选择)。
    3. 同时保留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);
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值