🔄 回溯 — 穷尽所有可能的艺术

回溯是递归+剪枝,排列组合子集问题的一网打尽之法

📖 回溯的核心概念

回溯(Backtracking)是一种通过递归探索所有可能解的算法,在发现当前路径不可能得到有效解时"回溯"到上一个选择点,尝试其他选项。回溯本质上是带剪枝的 DFS,是解决排列、组合、子集、棋盘问题的万能方法。

回溯 = DFS + 选择 + 撤销 回溯框架: def backtrack(路径, 选择列表): if 满足结束条件: result.append(路径.copy()) return for 选择 in 选择列表: 做选择 (加入路径) backtrack(路径, 新选择列表) 撤销选择 (移出路径) ← 回溯! 三种经典问题: ┌────────┬──────────────┬──────────────┐ │ 问题 │ 元素是否可重用 │ 顺序是否重要 │ ├────────┼──────────────┼──────────────┤ │ 子集 │ 不可重用 │ 顺序不重要 │ │ 组合 │ 不可重用 │ 顺序不重要 │ │ 排列 │ 不可重用 │ 顺序重要 │ └────────┴──────────────┴──────────────┘ 剪枝: 提前判断某条路径一定无解,跳过 - 排序 + 跳过重复 - 限制条件提前检查 - 剩余空间不够时提前终止

1. 回溯的通用模板

def backtrack(路径, 选择列表):
    # 1. 结束条件
    if 满足条件:
        result.append(路径.copy())  # 注意copy!
        return
    
    # 2. 遍历选择
    for i, 选择 in enumerate(选择列表):
        # 2a. 剪枝(可选)
        if 不满足条件:
            continue
        
        # 2b. 做选择
        路径.append(选择)
        
        # 2c. 递归
        backtrack(路径, 新选择列表)
        
        # 2d. 撤销选择(回溯)
        路径.pop()

2. 回溯问题的分类

问题类型选择列表去重方式示例
子集i 之后的所有元素排序 + i > start and nums[i]==nums[i-1]LC 78
组合i 之后的所有元素同上 + 剩余不够时剪枝LC 39, 77
排列所有未使用的元素排序 + used数组 或 交换LC 46, 47
回溯的核心区分:子集/组合问题用 start 控制选择范围(避免重复,顺序不重要),排列问题用 used 数组标记已使用(每个位置都可以选任何未用元素)。

🎯 题目一:全排列 (LC 46)

题目描述:给定一个不含重复数字的数组 nums,返回其所有可能的全排列。

示例: nums = [1, 2, 3] 回溯树: [] / | \ [1] [2] [3] / \ / \ / \ [1,2] [1,3] [2,1] [2,3] [3,1] [3,2] | | | | | | [1,2,3][1,3,2][2,1,3][2,3,1][3,1,2][3,2,1] 结果: 6种排列 用used数组标记已选元素: backtrack([1], used={1}) → 选2: backtrack([1,2], used={1,2}) → 选3: backtrack([1,2,3], used={1,2,3}) → 加入结果 → 选3: backtrack([1,3], used={1,3}) → 选2: backtrack([1,3,2], used={1,2,3}) → 加入结果

思路分析

排列回溯:
1. 用 used 数组标记已选元素
2. 每个位置尝试所有未使用的元素
3. 路径长度等于 nums 长度时,收集结果
4. 回溯时撤销选择,恢复 used
时间复杂度:O(n × n!),空间复杂度:O(n)

代码实现

class Solution:
    def permute(self, nums: List[int]) -> List[List[int]]:
        result = []
        
        def backtrack(path, used):
            if len(path) == len(nums):
                result.append(path[:])  # 注意拷贝!
                return
            for i in range(len(nums)):
                if used[i]:
                    continue
                path.append(nums[i])
                used[i] = True
                backtrack(path, used)
                path.pop()
                used[i] = False
        
        backtrack([], [False] * len(nums))
        return result

复杂度分析

时间复杂度:O(n × n!) — n! 种排列,每种拷贝 O(n)

空间复杂度:O(n) — 递归深度 + used 数组

变体练习

🎯 题目二:子集 (LC 78)

题目描述:给你一个整数数组 nums,数组中的元素互不相同。返回该数组所有可能的子集(幂集)。

示例: nums = [1, 2, 3] 回溯树 (start控制): [] / | \ [1] [2] [3] / \ \ [1,2] [1,3] [2,3] | [1,2,3] 所有子集: [], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3] 共 2³ = 8 个 关键: start参数确保只往后选, 不重复

思路分析

子集回溯:
1. 与排列不同,用 start 参数控制选择范围,只选 i 之后的元素
2. 每个节点都是合法子集,不需要等路径到头才收集
3. 回溯时 pop 撤销选择
时间复杂度:O(n × 2ⁿ),空间复杂度:O(n)

代码实现

class Solution:
    def subsets(self, nums: List[int]) -> List[List[int]]:
        result = []
        
        def backtrack(start, path):
            result.append(path[:])  # 每个节点都是子集
            for i in range(start, len(nums)):
                path.append(nums[i])
                backtrack(i + 1, path)  # 从i+1开始,不回头
                path.pop()
        
        backtrack(0, [])
        return result

复杂度分析

时间复杂度:O(n × 2ⁿ) — 2ⁿ 个子集,每个拷贝 O(n)

空间复杂度:O(n)

排列 vs 子集 的关键区别:排列用 used 数组(每个位置可以选任何未用元素),子集用 start 参数(只往后选,避免重复)。搞混了就全错!

变体练习

🎯 题目三:组合总和 (LC 39)

题目描述:给你一个无重复元素的整数数组 candidates 和一个目标整数 target,找出 candidates 中可以使数字和为目标数 target 的所有不同组合。candidates 中的数字可以无限制重复被选取。

示例: candidates = [2, 3, 6, 7], target = 7 回溯树: [] / | \ \ [2] [3] [6] [7] /|\ | [2,2] [2,3] ... /| [2,2,2] [2,2,3] | [2,2,2,2]=8>7 → 剪枝! 结果: [[2,2,3], [7]] 剪枝: 1. 排序candidates → 当前和+candidate > target时,后面更大,全剪 2. start不回退 → 避免重复组合(如[2,3]和[3,2]) 3. 可以重复选 → backtrack(i, ...) 而非 backtrack(i+1, ...)

思路分析

组合回溯 + 剪枝:
1. 排序 candidates,方便剪枝
2. 回溯参数:start(避免重复组合),current_sum(当前和)
3. 可以重复选:递归时传 i 而非 i+1
4. 剪枝:如果 current_sum + candidates[i] > target,直接 break(排序后后面的更大)
时间复杂度:O(2ⁿ)(最坏),实际因剪枝远小于此

代码实现

class Solution:
    def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
        result = []
        candidates.sort()  # 排序便于剪枝
        
        def backtrack(start, path, remaining):
            if remaining == 0:
                result.append(path[:])
                return
            if remaining < 0:
                return
            for i in range(start, len(candidates)):
                if candidates[i] > remaining:
                    break  # 剪枝:排序后后面更大
                path.append(candidates[i])
                backtrack(i, path, remaining - candidates[i])  # i而非i+1,可重复选
                path.pop()
        
        backtrack(0, [], target)
        return result

复杂度分析

时间复杂度:O(2ⁿ) — 最坏情况所有组合

空间复杂度:O(target/min_val) — 递归深度

remaining 参数比 current_sum 更好:remaining == 0 时直接收集,remaining < 0 时直接返回。避免了额外的减法比较。

变体练习

成就解锁:回溯大师 — 掌握排列、子集、组合三大回溯问题的核心模板与剪枝技巧
LeetCode AC验证:LC 46 Permutations ✅ | LC 78 Subsets ✅ | LC 39 Combination Sum ✅

📝 课后练习

  1. LC 47 全排列 II(有重复元素)
  2. LC 90 子集 II(有重复元素)
  3. LC 40 组合总和 II(每个数字只用一次)
  4. LC 17 电话号码的字母组合
  5. LC 22 括号生成(回溯+合法性剪枝)

🔑 本课要点回顾

📚 扩展阅读

思考题:如果组合问题中的 candidates 包含重复元素(LC 40),如何去重?提示:排序后,在同一层循环中,如果 nums[i] == nums[i-1] 且 i > start,跳过。