递归老写崩?一个"退回"公式,把回溯题变成填空题

别再凭直觉写递归了------三个坑 + 一个模板,回溯从此不靠蒙


1. 开篇:这道"全排列",你已经重写了三遍

LeetCode 46 全排列。你心想:"回溯嘛,不就是一个 for 循环加递归。"

js 复制代码
// ❌ 第一版:没写撤销
function permute(nums) {
    const result = [], path = []
    function dfs() {
        if (path.length === nums.length) {
            result.push(path)              // ⚠️ 直接 push 引用
            return
        }
        for (let n of nums) {
            if (path.includes(n)) continue
            path.push(n)
            dfs()
            // ⚠️ 忘了 pop()!撤销没做!
        }
    }
    dfs()
    return result
}
// 跑完发现 result 里全是同一个空数组 []------因为 push 的是引用,而且没撤销

你删掉重写。第二遍加上 pop(),但没加 used[] 标记,重复元素去不掉。第三遍终于写对了,但面试官追问"组合题怎么改?"------你又卡住了。

根本原因:你没把回溯抽象成一个模板。每次都是凭直觉写,所以每次都要重来。这篇文章把回溯浓缩成一个公式------排列、组合、子集,全部只改三处。


2. 核心概念:递归和回溯,先分清再写

递归 :把大问题拆成同类小问题,直到小到能直接解决。公式:f(n) = g(f(n-1))
回溯 :把"尝试→回退→换条路再试"这个过程写成代码。本质是在递归的"归"之后多一步------撤销刚才的选择

先看纯递归,再看回溯------感受它们的区别:

js 复制代码
// 纯递归:阶乘 ------ 只"递"和"归",没有"回退换路"
function factorial(n) {
    if (n <= 1) return 1                   // 终止条件
    return n * factorial(n - 1)            // 子问题关系
}

// 回溯:全排列 ------ 多了"做选择"和"撤销选择"
function permute(nums) {
    // ... 完整代码见 Step 2
    path.push(nums[i])    // 做选择
    backtrack()           // 递归
    path.pop()            // 撤销选择  ← 回溯多出来的这一步
}

一句话区分:纯递归是一条路走到黑;回溯是走一条路记下来,退回岔路口再走另一条。


3. 核心代码

Step 1:回溯的万能模板

js 复制代码
function backtrack(路径, 选择列表) {
    if (满足结束条件) {
        result.push([...路径])              // ⚠️ 必须拷贝!原因见下方
        return
    }

    for (let 选择 of 选择列表) {
        if (选择不合法) continue            // 剪枝

        路径.push(选择)                     // 🔑 做选择
        backtrack(路径, 新的选择列表)
        路径.pop()                          // 🔑 撤销选择------和"做选择"完全对称
    }
}

⚠️ result.push([...路径]) 不能写成 result.push(路径) 。因为 路径 是引用,之后 pop() 会改掉它------最终 result 里全是同一个空数组。

这个坑的本质是 JavaScript 的对象引用机制。你 push 进去的是一个数组对象的地址 ,不是它的值。后面你 pop() 修改了这个数组,result 里存的那个地址指向的也是同一个数组------自然也被改了。[...路径] 创建一个新数组,切断这个联系。

Step 2:全排列(LeetCode 46)------模板第一改

给定 [1, 2, 3],返回所有排列。

js 复制代码
function permute(nums) {
    const result = []
    const path = []
    const used = new Array(nums.length).fill(false) // 🔑 标记哪些用过了

    function backtrack() {
        if (path.length === nums.length) {          // 🔑 结束条件:全选完了
            result.push([...path])                   // ⚠️ 拷贝!
            return
        }

        for (let i = 0; i < nums.length; i++) {
            if (used[i]) continue                   // 🔑 剪枝:用过的跳过

            path.push(nums[i])                       // 做选择
            used[i] = true

            backtrack()

            path.pop()                               // 撤销选择(和上面完全对称)
            used[i] = false
        }
    }

    backtrack()
    return result
}

排列题的关键选择 :需要 used[] 标记,因为 [1,2][2,1] 是不同的排列,同一个数不能重复用,但可以被不同位置再次选中。

scss 复制代码
决策树(permute([1,2,3])):
                     []
        /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]

回溯 = 深度优先遍历这棵树,到叶子就记录,然后退回上一层走另一个分支。

Step 3:组合(LeetCode 77)------模板第二改

[1..n] 中选 k 个数,返回所有组合。注意 [1,2][2,1] 算同一种。

js 复制代码
function combine(n, k) {
    const result = []
    const path = []

    function backtrack(start) {                     // 🔑 比排列多一个 start 参数
        if (path.length === k) {
            result.push([...path])
            return
        }

        for (let i = start; i <= n; i++) {           // 🔑 从 start 开始,不往前看
            path.push(i)
            backtrack(i + 1)                         // 🔑 下一层从 i+1 开始
            path.pop()
        }
    }

    backtrack(1)
    return result
}

组合 vs 排列:只改了两处

改动 排列 组合
循环起点 let i = 0 每次从头选 let i = start 只往后选
递归传参 backtrack() 无参 backtrack(i + 1) 下移起点
去重方式 used[] 数组标记 start 参数保证不回头

🔑 start 参数是组合题的核心 :它保证了每个数只用一次且不回头,天然避免了 [1,2][2,1] 重复。

Step 4:子集(LeetCode 78)------模板第三改

给定 [1, 2, 3],返回所有子集(包括空集)。

js 复制代码
function subsets(nums) {
    const result = []
    const path = []

    function backtrack(start) {
        result.push([...path])                       // 🔑 每个节点都记录!不是到叶子才记录

        for (let i = start; i < nums.length; i++) {
            path.push(nums[i])
            backtrack(i + 1)                         // 和组合一样,不回头
            path.pop()
        }
    }

    backtrack(0)
    return result
}

子集和组合的唯一区别:结束条件不同。

  • 组合:path.length === k 才记录(只要指定长度的)
  • 子集:每一步都记录(收集决策树上的每一个节点,不论深度)
ini 复制代码
决策树对比([1,2,3]):

组合 k=2:                    子集:
[]          ← 跳过              []          ← 记录 ✅
├1                              ├1          ← 记录 ✅
│├2  → [1,2] ✅                 │├2  → 记录 ✅
│└3  → [1,3] ✅                 │└3  → 记录 ✅
├2                              ├2          ← 记录 ✅
│└3  → [2,3] ✅                 │└3  → 记录 ✅
└3      ← 跳过                  └3          ← 记录 ✅

同一棵树,组合只摘指定深度的果子,子集是路过就摘。

模板总结:三道题改的是同一份代码

js 复制代码
// 🔑 回溯三要素------每道题只要改这三个地方
function backtrack(...) {
    if (/* 🔴 改这里:什么时候记录答案? */) {
        result.push([...path])
        return
    }

    for (let i = /* 🔴 改这里:从哪开始选? */; i < ...; i++) {
        if (/* 🔴 改这里:什么情况跳过? */) continue

        path.push(...)
        backtrack(/* 🔴 改这里:下一层从哪开始? */)
        path.pop()
    }
}
题型 记录条件 循环起点 剪枝 下一层传参
排列 path.length === n i = 0 used[i] 无 start
组合 path.length === k i = start 无(或剪枝优化) i + 1
子集 每次进入都记录 i = start i + 1

4. 总结:下次回溯题,抄模板改三处就行

回扣开篇:你开头全排列重写三遍,不是因为不聪明------是因为每次凭直觉写,没把"做选择→递归→撤销选择"内化成肌肉记忆。下次遇到回溯题,先别想细节,先把这个模板敲出来,再改那三个地方。

一句话总结(可截图转发)

回溯 = 递归 + 做选择 + 撤销选择(完全对称)。排列用 used\[\] 标记,组合用 start 参数不回头,子集是路过就记录。result.push([...path]) 必须拷贝,否则白写。

避坑清单

现象 修复
result.push(path) 不拷贝 result 里全是空数组 result.push([...path])
忘写终止条件 Maximum call stack size exceeded 每个递归函数第一行写终止条件
撤销没做干净 后面分支结果混入前面残留 做选择和撤销要一一对应,完全对称
重复元素不会去重 全排列结果有重复 排序后 if (i > start && nums[i] === nums[i-1]) continue
递归太深爆栈 超过约 1 万层报错 改迭代或尾递归

下一步行动

  1. 把这个模板手写三遍,直到不用看就能敲出来
  2. 用模板做三道变体:39. 组合总和40. 组合总和 II47. 全排列 II------感受"模板不变,只改三处"
  3. 试着不看代码,凭理解画出全排列的决策树------面试官最爱让候选人画这个

留个开放问题:回溯的时间复杂度怎么算?全排列是 O(n!),组合是 O(C(n,k)),子集是 O(2^n)------这些数怎么来的?你能用决策树的节点数解释吗?评论区聊聊 👇


相关推荐
闪电悠米2 小时前
力扣hot100-41.缺失的第一个正数-原地哈希详解
数据结构·算法·哈希算法
星空露珠3 小时前
28种颜色对应名称,
开发语言·数据库·算法·游戏·lua
hongyucai3 小时前
torch具身常用算子
人工智能·深度学习
浮江雾3 小时前
Flutter第十七节-----路由管理(3)
android·开发语言·前端·javascript·flutter·入门
林泽毅3 小时前
PyTRIO快速入门(一):概念、推理与训练
人工智能·python·深度学习·机器学习·语言模型
那些年丶ny4 小时前
颜色扩展库
前端·javascript·数据可视化
YWL4 小时前
OpenLayers + Vue 2 使用指南(01)
前端·javascript·vue.js
QXWZ_IA5 小时前
桥梁数字孪生怎么落地?
人工智能·科技·算法·智能硬件·政务
Kel5 小时前
输出层与反分词(Output Layer & Detokenization)
人工智能·算法·架构