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

0 阅读6分钟

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


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

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

// ❌ 第一版:没写撤销
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))

回溯:把"尝试→回退→换条路再试"这个过程写成代码。本质是在递归的"归"之后多一步——撤销刚才的选择

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

// 纯递归:阶乘 —— 只"递"和"归",没有"回退换路"
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:回溯的万能模板

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],返回所有排列。

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] 是不同的排列,同一个数不能重复用,但可以被不同位置再次选中。

决策树(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] 算同一种。

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],返回所有子集(包括空集)。

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 才记录(只要指定长度的)
  • 子集:每一步都记录(收集决策树上的每一个节点,不论深度)
决策树对比([1,2,3]):

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

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

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

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

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

        path.push(...)
        backtrack(/* 🔴 改这里:下一层从哪开始? */)
        path.pop()
    }
}
题型记录条件循环起点剪枝下一层传参
排列path.length === ni = 0used[i]无 start
组合path.length === ki = start无(或剪枝优化)i + 1
子集每次进入都记录i = starti + 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)——这些数怎么来的?你能用决策树的节点数解释吗?评论区聊聊 👇