回溯算法入门 - LeetCode经典回溯算法题

本文聚焦于LeetCode上面的:78(子集)、90(子集II)、77(组合)、216(组合总和III)、39(组合总和)、40(组合总和II)几道非常经典的题目,带领大家彻底拿下回溯和剪枝。

什么是回溯?

想象我们正在玩一个迷宫游戏:

  • 我们面前有一个岔路口(选择)。
  • 我们随便选一条路往前走(做选择)。
  • 走到死胡同了,我们退回到上一个岔路口(撤销选择)。
  • 尝试另一条没走过的路,直到找到迷宫出口。

这其实就是 回溯:一种通过"不断试错"来寻找所有解的算法:当发现当前选择的不是正确路径(或者已经找完这条路径的所有可能)时,就退回去,重新选择。

在代码里,这个"退回去"的过程,就是通过递归函数的自动"回退"来实现的。

回溯的核心框架

所有的回溯题,几乎都可以套用下面的模板:

javascript 复制代码
function backtrack(路径, 选择列表) {
    if (满足结束条件) {
        result.push([...路径]); // 存放结果
        return;
    }

    for (选择 in 选择列表) {
        // 1. 做选择(前序遍历)
        路径.push(选择);
        
        // 2. 进入下一层决策树(递归)
        backtrack(新的路径, 新的选择列表);
        
        // 3. 撤销选择(后序遍历)
        路径.pop();
    }
}

关键点

  • 路径:记录已经做出的选择。
  • 选择列表:当前可以做的选择。
  • 结束条件:到达决策树的底层,无法再做选择。

从子集问题入门(LeetCode 78)

题目

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

示例:nums = [1,2,3] 输出:[[], [1], [2], [1,2], [3], [1,3], [2,3], [1,2,3]]

思路解析

构建一棵树,每个节点代表一个子集。我们从空集(根节点)开始,每次从当前位置之后选一个数加入,保证不重复。

图解过程

graph TD subgraph "第0层:初始状态 []" S((开始
选择组合)) end subgraph "第1层:选择第一个数" S -->|"选择 1"| L1_1["[1]
剩余可选: 2,3"] S -->|"选择 2"| L1_2["[2]
剩余可选: 3"] S -->|"选择 3"| L1_3["[3]
剩余可选: 无"] end subgraph "第2层:选择第二个数" L1_1 -->|"选择 2"| L2_1["[1,2]
剩余可选: 3"] L1_1 -->|"选择 3"| L2_2["[1,3]
剩余可选: 无"] L1_2 -->|"选择 3"| L2_3["[2,3]
剩余可选: 无"] L1_3 -->|"无可选"| L2_4(("终止")) end subgraph "第3层:选择第三个数" L2_1 -->|"选择 3"| L3_1["[1,2,3]
剩余可选: 无"] L2_2 -->|"无可选"| L3_2(("终止")) L2_3 -->|"无可选"| L3_3(("终止")) end subgraph "第4层:最终状态" L3_1 -->|"无可选"| L4_1(("完成")) end

代码实现

javascript 复制代码
var subsets = function(nums) {
    const result = [];
    
    const backtrack = (start, path) => {
        // 每个节点都是一个子集,直接加入结果
        result.push([...path]);
        
        // 从 start 开始遍历,避免重复
        for (let i = start; i < nums.length; i++) {
            // 做选择:把 nums[i] 加入路径
            path.push(nums[i]);
            // 递归:继续从下一个位置开始选择
            backtrack(i + 1, path);
            // 撤销选择:回溯的关键
            path.pop();
        }
    };
    
    backtrack(0, []);
    return result;
};

答疑:为什么 result.push([...path]) 要放在递归之前?

因为我们要收集每一个节点,包括空集;在进入递归之前,当前路径已经代表了一个完整的子集,所以先记录下来。

什么是剪枝?

如果问题里面加了限制条件,比如"子集的和必须为某个值",或者"不能包含重复元素",此时,我们就不需要遍历整棵树。当我们提前知道某条分支不可能产生有效解时,就直接跳过它,这就叫剪枝

剪枝能大大提高效率,避免无意义的递归。

组合总和问题(LeetCode 39)

题目

给你一个无重复元素的整数数组 candidates 和一个目标整数 target,找出 candidates 中可以使数字和为目标数 target 的所有不同组合,并以列表形式返回。candidates 中的同一个数字可以无限制重复被选取。

示例:candidates = [2,3,6,7], target = 7 输出:[[2,2,3],[7]]

思路解析

这题和上面的子集问题(LeetCode 78)很像,但又有两点不同:

  1. 有目标和限制:只有当路径和等于 target 才加入结果。
  2. 可以重复选同一个数:递归时,start 参数不一定要 i+1,可以是 i(表示可以重复选当前数)。

代码实现(含剪枝)

javascript 复制代码
var combinationSum = function(candidates, target) {
    const result = [];
    
    const backtrack = (start, path, sum) => {
        // 结束条件:找到了目标和
        if (sum === target) {
            result.push([...path]);
            return;
        }
        
        // 剪枝:如果 sum 已经超过 target,后面就不用看了
        if (sum > target) return;
        
        for (let i = start; i < candidates.length; i++) {
            // 做选择
            path.push(candidates[i]);
            // 递归:因为可以重复选,所以还是从 i 开始
            backtrack(i, path, sum + candidates[i]);
            // 撤销选择
            path.pop();
        }
    };
    
    backtrack(0, [], 0);
    return result;
};

剪枝点

if (sum > target) return; 这句就是剪枝。当发现当前和已经超过目标,立刻停止往下递归(继续往后递归累加,和会越来越大,没有意义了),节省时间。

本题解法使用的是加法累加求和;也可以用减法求余数,大家有兴趣可以尝试这种解法!

去重问题的核心:排序 + used 数组(LeetCode 90)

题目

给你一个整数数组 nums,其中可能包含重复元素,请你返回该数组所有可能的子集(幂集)。解集不能包含重复的子集。

示例:nums = [1,2,2] 输出:[[], [1], [1,2], [1,2,2], [2], [2,2]] 注意:不能有两个 [1,2](虽然第一个2和第二个2不同,但值相同,视为重复)。

思路解析

当数组有重复元素时,如果还按之前的方法,会出现重复子集。比如 [1,2](第一个2)和 [1,2](第二个2)会被当成两个解,此时我们需要去重处理。

去重套路

  • 排序:让相同的元素挨在一起。
  • 剪枝:如果当前元素和前一个元素相同,并且前一个元素没有被使用过,就跳过。

为什么是"前一个元素没被使用过"就跳过?

  • 在同一层(同一级递归)中,如果前一个相同的数没有在路径里,说明当前数和前一个数是同一层的重复选择,必须跳过。
  • 如果在不同层(比如 [1,2,2] 中的第二个2是在下一层),那么前一个2是在路径中的(被使用过),这种情况不应该跳过。

代码实现

javascript 复制代码
var subsetsWithDup = function(nums) {
    const result = [];
    nums.sort((a, b) => a - b); // 排序是去重的前提
    
    const backtrack = (start, path) => {
        result.push([...path]);
        
        for (let i = start; i < nums.length; i++) {
            // 剪枝去重:同一层中,如果当前值和前一个值相同,跳过
            if (i > start && nums[i] === nums[i - 1]){
                continue;
            } 
            
            path.push(nums[i]);
            backtrack(i + 1, path);
            path.pop();
        }
    };
    
    backtrack(0, []);
    return result;
};

关键点

这段代码的关键点在于:if (i > start && nums[i] === nums[i - 1]){ continue; }

  • i > start 保证了是在同一层(i 从 start 开始,如果 i 比 start 大,说明是这一层的第二个及之后的元素)。
  • nums[i] === nums[i-1] 判断是否重复:重复了,则直接跳过。

组合总和 II(LeetCode 40):去重 + 剪枝

题目

给定一个数组 candidates(可能有重复)和一个目标数 target,找出所有和为 target 的组合。candidates 中的每个数字在每个组合中只能使用一次。

示例:candidates = [10,1,2,7,6,1,5], target = 8 输出:[[1,1,6], [1,2,5], [1,7], [2,6]]

思路解析

这道题的本质其实是 39 题和 90 题的结合体:

  • 每个数只能用一次:递归时传 i+1。
  • 数组有重复:需要去重(同一层不能选相同的数)。
  • 有目标和:需要剪枝(和超过 target 就停止)。

最终代码

javascript 复制代码
var combinationSum2 = function(candidates, target) {
    const result = [];
    candidates.sort((a, b) => a - b); // 排序
    
    const backtrack = (start, path, sum) => {
        if (sum === target) {
            result.push([...path]);
            return;
        }
        
        // 剪枝
        if (sum > target) {
            return; 
        }
        
        for (let i = start; i < candidates.length; i++) {
            // 同一层去重
            if (i > start && candidates[i] === candidates[i - 1]) {
                continue;
            }
            
            path.push(candidates[i]);
            backtrack(i + 1, path, sum + candidates[i]); // i+1 保证不重复用
            path.pop();
        }
    };
    
    backtrack(0, [], 0);
    return result;
};

回溯题的核心套路

刷完这几道题,我们会发现它们就是套娃。记住这个总结,下次遇到新题直接套:

题目 特点 去重? 递归参数 剪枝条件
78.子集 无重复,找所有子集 start = i+1
90.子集II 有重复,找所有子集 是(同一层) start = i+1 i>start && nums[i]===nums[i-1]
77.组合 n个数选k个 start = i+1 路径长度==k
216.组合总和III 1-9选k个,和为n start = i+1 和>n 或 长度>k
39.组合总和 无重复,可重复选 start = i 和>target
40.组合总和II 有重复,不可重复选 是(同一层) start = i+1 和>target 且 去重

解题要点

  1. 画树形图:把决策过程画出来,你就知道递归怎么走了。
  2. 确定结束条件:什么时候把路径加入结果?
  3. 确定选择列表:下一层递归从哪开始?
  4. 考虑剪枝:哪里可以提前终止?
  5. 考虑去重:有重复元素时,记得排序 + 同一层跳过。

结语

希望这篇文章能帮大家彻底搞懂回溯与剪枝。以后只要遇到"所有组合"、"所有子集"、"所有排列"这类问题,别忘了拿出这个万能模板试一试!当然,上述题目也不止一种解法,你还知道其他解法吗?欢迎在评论区留言分享!

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

相关推荐
宵时待雨2 小时前
C++笔记归纳12:二叉搜索树
开发语言·数据结构·c++·笔记·算法
炎爆的土豆翔2 小时前
SIMD常见操作,结合样例一文理解
开发语言·c++·算法
xcs194052 小时前
前端 vue this.$nextTick(() => {
前端·javascript·vue.js
广州华水科技2 小时前
如何在基础设施安全中有效实现GNSS位移监测的应用?
前端
仰泳的熊猫2 小时前
题目2305:蓝桥杯2019年第十届省赛真题-等差数列
数据结构·c++·算法·蓝桥杯
大漠_w3cpluscom2 小时前
前端怎么提升自己的CSS编写能力?
前端
我是若尘2 小时前
大数据量渲染优化:分批渲染技术详解
前端
ruanCat2 小时前
pnpm 踩坑实录:用 public-hoist-pattern 拯救被严格隔离坑掉的依赖
前端·npm·node.js
yuki_uix2 小时前
渲染优化三件套:React.memo、useMemo、useCallback 的使用边界
前端·react.js