看到一道题能用递归(recursion),不代表接下来就知道该写回溯(backtracking)还是 DP。可以先想输出:题目是要你列出每一种选法,还是只要一个最优值?如果两条不同的选择路线,最后都在问同一个问题,这个问题的答案能不能只算一次?下面分别用子集和“不选相邻元素”来说明。

1. 回溯:选择、往下走、撤销

先列出 [1, 2] 的所有子集。空集也算,所以结果是 []、[1]、[1, 2]、[2]。我们用 path 放目前选了哪些数,用 start 指明接下来从哪个位置开始选。

[]
├─ 选 1 → [1]
│          └─ 再选 2 → [1, 2]
└─ 选 2 → [2]

每到一个节点,就已经得到一个子集,不必等到最底下才记答案。每次只往后选:选了 1 之后还能接着选 2;从 2 开始的那一支却不会回头选 1,所以 [2, 1] 不会和 [1, 2] 重复。

def subsets(values):
    result = []
    path = []

    def extend(start):
        result.append(path.copy())
        for index in range(start, len(values)):
            path.append(values[index])
            extend(index + 1)
            path.pop()

    extend(0)
    return result

这几行最容易混的是 copy 和 pop。它们不是一回事:

  • copy 是给答案留一份当前选择的副本。否则所有答案都指向同一个列表,后面改 path,前面存的答案也跟着变。
  • pop 是撤销刚才的选择。走完 [1, 2],先退回 [1];这一支走完再退回 [],才能从空集开始选 2。不撤销,下一支就会带着上一支的东西。

这里假设输入元素互不相同。有重复值时,要另加去重规则。n 个元素有 2ⁿ 个子集,所有子集总共要保存 n·2ⁿ⁻¹ 个元素(n≥1),所以输出空间与时间是 O(n·2ⁿ)。不算输出,当前路径和递归栈(recursion stack)只需要 O(n)。

2. 3 种题,3 种“下一步”

问题 下一层怎么走 如何避免重复
子集 / 不可重复使用的组合 从 index+1 继续 按下标单向前进
元素可重复使用的组合 可继续用当前 index 仍保持选择顺序;剪枝(pruning)需满足数值前提
排列 每层尝试所有尚未使用的下标 used 记录当前路径使用情况,回退时撤销

剪枝就是提前判断“这条路走下去也不会有答案”,然后不再往下试。比如用正数凑目标和,已经超了,就没必要再加;但允许负数时,后面可能把和减回来,这样剪就会漏解。类似地,不能一看见重复节点就加全局 visited:两条不同路径,可能正是题目要的两个答案。

3. DP:先把状态说成一句人话

动态规划(dynamic programming, DP)怎么从递归里来?用 House Robber 看一遍。先不想题目里的故事:给一排非负数 [2, 7, 9, 3, 1],选一些数,不能相邻,希望总和最大。例如选 2、9、1 得到 12。

先定义 best(index):只看从 index 开始的这一段,最多还能选出多少收益。 前面的选择已经处理完,不计入这个返回值。

面对当前位置,只有两种情况:

  • 不选它:剩下的问题是 best(index + 1)。
  • 选它:拿到 values[index],下一项不能选,剩下是 best(index + 2)。

两边取较大值,就得到递推式(recurrence relation)。这里不是先猜一个公式,再想它是什么意思,而是把题目允许的选择逐项写出来。

best(0)
├─ 不选位置 0 → best(1)
│                ├─ 不选位置 1 → best(2)
│                └─ 选位置 1   → 7 + best(3)
└─ 选位置 0   → 2 + best(2)

图里 best(2) 出现了两次。虽然前面选法不同,但接下来都是在问:[9, 3, 1] 这一段最多能拿多少?它的答案不会因为之前选了什么而改变,所以可以只算一次,存下来复用。这就是记忆化搜索(memoization)。

动画 同一个子问题:算一次,存下来

这张图需要启用 JavaScript。

from functools import cache

def rob_memo(values):
    @cache
    def best(index):
        if index >= len(values):
            return 0
        return max(best(index + 1), values[index] + best(index + 2))

    return best(0)

走到数组外,已经没有东西可选,返回 0;这就是递归终止条件(base case)。加上 cache 后,每个位置只计算一次,每次比较两个选择,时间 O(n),缓存和调用栈(call stack)各占 O(n)。输入不能在计算中改变;缓存放在外层函数内部,每次处理新输入时也会重新创建。

为什么这里只记一个下标就够?因为“不能选相邻项”已经体现在跳过下一项这个动作里。如果规则改成“最多选两项”,同一段数组还会受剩余名额影响,就得把名额也放进状态。能不能复用,要看余下的问题是否真的相同,不是看函数参数长得像不像。

4. 同一个递推,换成迭代

写迭代(iteration)版时,先不急着压缩成两个变量,把刚才的结果填进一张表。best[index] 仍表示“从这里开始的最大收益”,含义没有换。它依赖右边两个位置,所以从右往左填:

位置 当前值 不选 选当前 最多收益
5、6 已越界 — — 0
4 1 0 1 + 0 1
3 3 1 3 + 0 3
2 9 3 9 + 1 10
1 7 10 7 + 3 10
0 2 10 2 + 10 12
def rob_table(values):
    best = [0] * (len(values) + 2)
    for index in range(len(values) - 1, -1, -1):
        best[index] = max(best[index + 1], values[index] + best[index + 2])
    return best[0]

多留的两个 0 对应递归里的越界返回。先把这张表算对,出错时也容易定位:如果位置 2 没算出 10,就看它是否正确读到了位置 3 和 4。

最后再省空间

算每一格时,其实只读右边两格,其他结果之后都用不到。用 one_ahead 和 two_ahead 保存它们,仍然从右往左走:

def rob_iterative(values):
    one_ahead = two_ahead = 0
    for value in reversed(values):
        current = max(one_ahead, value + two_ahead)
        one_ahead, two_ahead = current, one_ahead
    return one_ahead

比如处理 9 之前,两个变量分别是 3 和 1;这一轮算出 max(3, 9 + 1) = 10,再把它们换成 10 和 3,供下一轮使用。Python 的这次同时赋值先计算右侧,再写到左侧,不会提前覆盖旧的 one_ahead。

表格版时间和辅助空间(auxiliary space)都是 O(n),滚动变量版时间仍是 O(n),辅助空间降到 O(1);reversed 也不会复制整个列表。两版只是保存结果的方式不同。如果还要知道具体选了哪些位置,保留表格往往更省事,不能只拿一个最优值就还原完整选择。

5. 递归与迭代的取舍

写法 优势 代价
递归 + 记忆化 接近问题定义,只访问需要的状态 函数调用、缓存、递归深度限制
从已知结果逐步填表 顺序明确,方便检查所有状态 可能计算用不到的状态,要排好依赖顺序
滚动变量 内存小、实现简短 只适合有限历史依赖,还原路径更麻烦

分析 DP 时用“状态数 × 每个状态的转移成本”,再加 key 构造和输出成本。不是所有 DP 都 O(n)。例如二维表可能 O(nm),每个状态还遍历 k 种选择,就可能 O(nmk)。

用英语讲一遍这个解法

英语里,recursion 和 iteration 是名词;描述写法时,用 a recursive solution 或 an iterative solution。不必翻译整段笔记,先把状态、选择和停止条件讲清楚:

I define best(index) as the maximum sum I can get from that index onward without taking adjacent elements. I either skip the current element, or take it and skip the next one. The base case is zero once the index reaches the end.

接着说为什么要缓存,以及它的成本:

Different choices can lead to the same subproblem, so I cache each result. There are O(n) states, and each takes O(1) work. That gives O(n) time and O(n) auxiliary space, including the recursion stack.

如果被问到能不能省空间,再解释从右往左填表,以及只保留后两个状态。不用一开口就报最优复杂度,却说不清它从哪里来。

6. 少量例题与自测

  • 子集:练当前选择的复制和撤销。
  • 全排列:区分“顺序不同算不算不同答案”。
  • 打家劫舍:递归 → 记忆化 → 表格 → 滚动变量。
  • 零钱兑换:先说明“凑到这个金额,最少要几枚硬币”,再写状态和转移。
为什么不能给所有回溯直接加 cache,就变成多项式时间?

缓存要有完整、可复用的状态;当前路径可能影响合法选择和最终输出。即使重复计算能省掉,枚举全部子集仍有指数数量的输出,缓存不能消掉输出成本。

贪心“每次取当前最大的收益”能解 House Robber 吗?

不能。[4, 5, 4] 取中间 5 不如取两端得到 8。DP 保留互斥选择的最优结果,而不是只看当前最大。

7. 换一种讲法再看

不同资料可能从前往后定义状态,也可能像本页这样从后往前。两种都可以;改写代码时先确认每个变量的含义,不要只照着换循环方向。

本页术语 11
递归 recursion
函数直接或间接调用自身,把问题交给更小的子问题;要能走到终止条件
回溯 backtracking
尝试一个选择,继续搜索,返回后撤销选择,再试其他分支
递归栈 recursion stack
递归过程中尚未返回的调用所占的栈空间
剪枝 pruning
确认某个分支不可能产生所需答案后,提前停止搜索
动态规划 dynamic programming, DP
用状态描述子问题,保存并复用结果,避免重复求解
递推式 recurrence relation
说明一个子问题的答案怎样由其他子问题的答案得到
记忆化搜索 memoization
缓存递归子问题的结果,再遇到相同状态时直接复用
递归终止条件 base case
不再递归、可以直接给出结果的情况;也要保证递归能到达这里
调用栈 call stack
保存尚未返回的函数调用,以及返回后继续执行所需的信息
迭代 iteration
通过循环反复更新状态;需要自己保存下一轮会用到的信息
辅助空间 auxiliary space
除输入和输出外,算法执行时额外使用的空间,包括调用栈