LeetCode / 递归学习手册
PYTHON 3 · THINK → TRACE → OPTIMIZE
已阅读 0%
RECURSION · 从定义到优化

递归:让小问题
回答大问题。

用斐波那契数与爬楼梯,完整走过“写出递推 → 理解调用栈 → 消除重复计算 → 改写迭代 → 递归倍增”的学习路径。

Python 32 道指定题 · 无额外刷题本地双击 · 离线阅读建议用时 3–4 小时
02完整题目教程
04递进学习阶段
11完整 Python 解法
01递归执行实验室
学习顺序:先掌握递归,再理解为什么以及如何优化
阶段学习内容完成标准
01 基础知识函数契约、终止条件、递归树、调用栈能手推 F(4),区分调用次数与同时在栈的层数
02 核心模板返回型递归、字典记忆化、复杂度分析能解释缓存键和先查缓存的原因
03 典型应用先 509 斐波那契数,再 70 爬楼梯从题意推导状态与边界,再完成递归到滚动变量的优化
04 进阶算法复用两题学习快速倍增理解返回相邻两项与每层只递归一次

STAGE 01 / FOUNDATION第一阶段:理解递归的契约

递归是一种实现方式。先把函数含义说清楚,代码才有可靠的边界与返回值。

1. 递归函数到底在承诺什么?

递归函数会调用自身来求解更小的同类问题。以 fib(k) 为例,它的契约是“返回第 k 个斐波那契数”,并不要求调用者知道它内部怎样计算。写 fib(k - 1) + fib(k - 2) 时,我们使用的是子问题的答案。

01定义状态与返回值

k 表示第几项;fib(k) 返回一个整数,含义在全部递归层保持一致。

02列出终止条件

fib(0)=0,fib(1)=1;先处理边界,再访问子问题。

03缩小问题规模

k≥2 时,k−1 与 k−2 都比 k 小,最终到达 0 或 1。

04组合并返回答案

将两个子问题的返回值相加,用 return 交还上一层。

状态含义 → 终止条件 → 规模缩小 → 结果合并

2. 为什么这样写是正确的?

可以用数学归纳法理解:边界 0、1 的结果直接正确;假设所有小于 k 的状态都能正确返回,那么把第 k−1 项与第 k−2 项相加,就得到了按定义应有的第 k 项。正确的边界、正确的递推和一定会缩小的规模,三者缺一不可。

3. 递归树画“工作量”,调用栈看“同时存活”

下面是求 F(4) 的完整调用树。相同参数的节点仍是不同的调用,例如 F(2) 会被计算两遍。

F(4) 的完整递归调用树根 F4 分为 F3 与 F2;F3 分为 F2 与 F1;两个 F2 各自分为 F1 与 F0。共九次调用,最长路径有四个栈帧,重复的 F2 以金色显示。F(4)F(3)F(2)F(2)F(1)=1F(1)=1F(0)=0F(1)=1F(0)=0蓝:递归状态 金:重复的子问题 绿:终止状态
调用总数 9;最大同时存活的 fib 栈帧数 4。整棵树不会同时驻留在调用栈中。

Python 先求加号左边,再求右边。父调用等待子调用返回,随后从原来的位置继续执行。观察第一个 F(2) 的返回过程:

执行轨迹 · F(4) = 3
进入 F(4) → 进入 F(3) → 进入 F(2) → 进入 F(1)
F(1) 返回 1:F(2) 记住左结果 1
进入 F(0),返回 0:F(2) 返回 1 + 0 = 1
F(3) 再调用 F(1),得到 1:F(3) 返回 1 + 1 = 2
F(4) 再调用 F(2),重复求得 1:F(4) 返回 2 + 1 = 3

4. 逐步执行实验室

选择输入,对比普通递归与“已预存 0、1”的字典记忆化。此处用内联 JavaScript 模拟与 Python 相同的调用顺序,不执行 Python,也不访问网络。

完整过程调用次数9
最大函数栈深度4
最终返回值3

统计包含命中缓存的函数调用,不含外层 Solution 方法。返回事件不会额外计入调用次数;n 最多为 8,以便专注观察。

STAGE 02 / PATTERNS第二阶段:从核心模板到复杂度

这两题都属于“分解问题、合并返回值”的递归;状态依赖有重叠,适合进一步记忆化。

模板 A:返回型递归

用一条清晰的函数契约贯穿整个过程。以下是语言无关的伪代码,便于迁移;可提交的 Python 完整实现分别放在两题中。

核心模板 A · 伪代码
函数 solve(状态):
    如果状态是边界:
        返回边界答案
    左答案 ← solve(更小的状态一)
    右答案 ← solve(更小的状态二)
    返回合并(左答案, 右答案)

模板 B:带记忆化的递归

相同状态若总返回相同答案,就可以只计算一次。键必须包含所有会影响答案的参数。本专题只依赖剩余规模 k,因此用一个整数作键即可;如果问题还依赖位置、背包容量等,就要把这些量一起放入键。

核心模板 B · 伪代码
在本次求解开始时建立缓存,并存入边界答案
函数 solve(状态):
    如果状态已在缓存中:
        返回缓存[状态]
    答案 ← 合并(solve(子状态一), solve(子状态二))
    缓存[状态] ← 答案
    返回答案
返回 solve(初始状态)

复杂度:数状态、数转移、数栈深度

φ = (1 + √5) / 2 ≈ 1.618。O(2ⁿ) 是正确上界,但并非最紧界。
形式如何计算时间时间复杂度辅助空间
普通二分支递归T(n)=T(n−1)+T(n−2)+O(1);大量状态被重复求值O(φⁿ),常用较松上界 O(2ⁿ)O(n) 调用栈
记忆化递归O(n) 个不同状态 × 每个状态 O(1) 次转移O(n)O(n) 缓存 + O(n) 栈 = O(n)
数组动态规划按状态顺序把每项计算一次O(n)O(n) 数组,无递归栈
滚动变量迭代只保留下一步所需的相邻两项O(n)O(1) 辅助变量
快速倍增递归T(n)=T(⌊n/2⌋)+O(1)O(log n)O(log n) 调用栈

对于斐波那契普通递归,函数调用总数满足 C(0)=C(1)=1 和 C(n)=1+C(n−1)+C(n−2),可得 C(n)=2F(n+1)−1。例如 n=30 时需要 2,692,537 次调用,而记忆化只需要线性次调用。

STAGE 03 / PRACTICE第三阶段:两道典型应用

先从数学定义直接写递归,再从真实计数问题推导同一递推关系。每种解法都给出完整提交代码。

01LeetCode 509斐波那契数

前往 LeetCode 原题 ↗

简单 | 核心知识点:返回型递归、递推关系、记忆化、动态规划、滚动变量
接口:Solution.fib(self, n: int) -> int | 输入范围:0 ≤ n ≤ 30

题意与状态定义

输入非负整数 n,返回斐波那契序列的第 n 项。序列从 0, 1 开始,每个后续数等于前两个数之和。这里“第 n 项”按 0 起始编号,不能把第 0 项跳过。

F(0) = 0,F(1) = 1;F(n) = F(n−1) + F(n−2),n ≥ 2
输入输出核对
n=21F(1)+F(0)=1+0
n=32F(2)+F(1)=1+1
n=43F(3)+F(2)=2+1
n=0(边界)0直接返回定义值

解题思路:从定义走向高效实现

先把递推式翻译为函数调用,得到最直观的递归。随后观察到相同 k 会反复出现,用字典或缓存装饰器把答案保留下来;再按 k 从小到大填写数组,最后利用“只依赖前两项”的特点压缩为两个变量。它们计算同一状态,只是求值顺序和保存结果的方式不同。

解法一 · 朴素递归:先写对递归关系

dfs(k) 承诺返回 F(k)。k 为 0 或 1 时直接返回 k;否则先求前一项,再求前两项,并把它们相加。

Python 3 · 完整提交代码
class Solution:
    def fib(self, n: int) -> int:
        def dfs(k: int) -> int:
            if k <= 1:
                return k
            return dfs(k - 1) + dfs(k - 2)

        return dfs(n)

关键代码解释:k <= 1 同时覆盖 0、1;两个递归分支都严格缩小规模;外层必须 return dfs(n),否则调用结果会是 None。

复杂度:时间 O(φⁿ),也可写 O(2ⁿ) 上界;空间 O(n),来自最长递归链,不能按整棵调用树的节点数算栈空间。

解法二 · 字典记忆化:每个状态只展开一次

在每次 fib 调用内部建立 memo,预存两个边界。先查缓存,未命中才向下递归;计算结束后将结果写回缓存。所有内层调用共享这一个字典。

Python 3 · 完整提交代码
class Solution:
    def fib(self, n: int) -> int:
        memo = {0: 0, 1: 1}

        def dfs(k: int) -> int:
            if k in memo:
                return memo[k]
            memo[k] = dfs(k - 1) + dfs(k - 2)
            return memo[k]

        return dfs(n)

关键代码解释:k in memo 检查键而非答案真假;右边的递归求值完成后,才执行写入 memo[k];字典不在 dfs 内反复创建,否则无法复用子问题答案。

复杂度:时间 O(n);空间 O(n),其中字典 O(n),递归栈 O(n)。

解法三 · lru_cache:用标准库表达记忆化

lru_cache(maxsize=None) 按参数缓存返回值,不限制缓存条目数。这里的参数 k 是整数,可哈希;每次外层调用都会新建被装饰的内部函数。该写法与手动字典的核心思路一致。

Python 3 · 完整提交代码
from functools import lru_cache


class Solution:
    def fib(self, n: int) -> int:
        @lru_cache(maxsize=None)
        def dfs(k: int) -> int:
            if k <= 1:
                return k
            return dfs(k - 1) + dfs(k - 2)

        return dfs(n)

关键代码解释:装饰器必须放在递归实际调用的 dfs 上,且递归分支也调用这个函数;仅在外层缓存一次入口不能消除内部重复调用。无须自行维护缓存字典。

复杂度:时间 O(n);空间 O(n) 缓存与 O(n) 栈合计 O(n)。

解法四 · 数组动态规划:从小到大填写状态

令 dp[i]=F(i),按照 i=2 到 n 的顺序计算,依赖的前两项已经准备好。先处理 n=0、1,再初始化数组,避免长度不足时访问 dp[1]。

Python 3 · 完整提交代码
class Solution:
    def fib(self, n: int) -> int:
        if n <= 1:
            return n

        dp = [0] * (n + 1)
        dp[1] = 1
        for i in range(2, n + 1):
            dp[i] = dp[i - 1] + dp[i - 2]
        return dp[n]

关键代码解释:range(2, n + 1) 包含 n;dp[0] 使用数组初始值 0;dp[i] 对应第 i 项,不是前 i 项的总和。

复杂度:时间 O(n);空间 O(n) 数组,无递归调用栈。

解法五 · 滚动变量:面试推荐提交版

数组里更早的项在后续不再使用。初始化 a=F(0), b=F(1)。每轮把窗口向右移动一项;循环 t 轮后始终有 a=F(t), b=F(t+1),因此循环 n 轮后返回 a。

Python 3 · 完整提交代码
class Solution:
    def fib(self, n: int) -> int:
        a, b = 0, 1
        for _ in range(n):
            a, b = b, a + b
        return a

关键代码解释:a, b = b, a + b 右侧先按旧值一起求出,再赋给左侧。不能拆成先 a=b 再 b=a+b,否则第二行用到的是更新后的 a。n=0 时循环不执行,返回 0。

复杂度:时间 O(n);空间 O(1) 辅助变量。

示例推演:n=4 的三种视角

递归合并:F(4)=F(3)+F(2)=(F(2)+F(1))+(F(1)+F(0))=2+1=3。完整调用顺序见第一阶段的递归树。

字典记忆化:初始 {0:0,1:1} → 求得 F(2)=1 后写入 → 求得 F(3)=2 后写入 → F(4) 的右分支 F(2) 直接命中 → 写入 F(4)=3。每个非边界状态只展开一次。

滚动变量推演:n=4
已完成轮数 ta=F(t)b=F(t+1)说明
001初始化
111旧 b=1;旧 a+b=1
212继续右移
323继续右移
435返回 a=3
509 解法对比(单位代价模型)
解法时间空间优点与取舍
朴素递归O(φⁿ)O(n)最直观;重复计算严重,适合教学
字典记忆化O(n)O(n)缓存过程清晰,便于调试
lru_cacheO(n)O(n)简洁;需理解装饰器与缓存键
数组 DPO(n)O(n)中间项全部保留,容易画表
滚动变量O(n)O(1)简洁高效;需准确维护变量含义
快速倍增(第四阶段)O(log n)O(log n)递归规模减半;推导与实现更复杂

边界检查与易错点

  • 漏掉 n=0:F(0) 必须返回 0;用“n≤2 返回 1”会把这个边界写错。
  • 把递归返回值当输出:LeetCode 检查方法的返回值,不检查 print 的文本。
  • 缓存写在递归函数内部初始化:每层都新建字典会失去记忆化效果;当前写法在外层创建一次。
  • 循环次数与返回变量不匹配:上面滚动代码执行 n 轮、返回 a。若改为返回 b,就会错位一项。
  • 仅凭“两个分支”就断言 O(n²):时间取决于整棵调用树,未缓存时是指数增长;栈空间看最长活动链。
n正确返回检查目的
00空循环与第 0 项
11第二个基本状态
21第一次递推
30832040题目范围上界

题号、范围、难度与示例依据 力扣 509 原题;讲解、推导与代码为本手册编写。

02LeetCode 70爬楼梯

前往 LeetCode 原题 ↗

简单 | 核心知识点:分类计数、递归建模、记忆化、动态规划、滚动变量
接口:Solution.climbStairs(self, n: int) -> int | 输入范围:1 ≤ n ≤ 45

题意:不同的行走顺序分别计数

有 n 级台阶,每次可以向上走 1 级或 2 级,返回恰好到达楼顶的走法数。1→2 与 2→1 的顺序不同,因此是两种方案;越过楼顶不算合法完成。

输入输出所有方案
n=221+1;2
n=331+1+1;1+2;2+1
n=1(边界)11

解题思路:按最后一步分类,不重不漏

  1. 定义 W(k):恰好爬到第 k 级的不同走法数。
  2. 若最后一步走 1 级,之前必须在 k−1 级;在这些走法后追加 1,得到 W(k−1) 种。
  3. 若最后一步走 2 级,之前必须在 k−2 级;在这些走法后追加 2,得到 W(k−2) 种。
  4. 每种合法走法的最后一步只能属于其中一类,两类互不重叠,且包含所有可能,因此相加。
W(0) = 1,W(1) = 1;W(k) = W(k−1) + W(k−2),k ≥ 2
爬到第四级的两种最后一步分类从第三级的三种方案各追加一步,得到三种;从第二级的两种方案各追加两步,得到两种;合计五种。W(3) = 3W(2) = 2W(4) = 3 + 2 = 5最后一步前在第 3 级最后一步前在第 2 级追加 1 级追加 2 级按最后一步分类求和
计数用加法;两类的“最后一步”不同,所以不会重复统计。

解法一 · 朴素递归:直接实现分类计数

dfs(k) 表示还剩 k 级时的走法数,递推关系与“爬到第 k 级”的 W(k) 相同。k=0 或 1 时返回 1;只在 k≥2 时展开两个分支,因此不会访问负数状态。

Python 3 · 完整提交代码
class Solution:
    def climbStairs(self, n: int) -> int:
        def dfs(k: int) -> int:
            if k <= 1:
                return 1
            return dfs(k - 1) + dfs(k - 2)

        return dfs(n)

关键代码解释:k <= 1 这里返回 1,因为内部状态 0 的含义是“恰好完成”;与斐波那契返回 k 不同。本实现只覆盖题目输入和递推产生的非负状态,不能把它当作负数状态也返回 1 的通用模板。

复杂度:时间 O(φⁿ),或用 O(2ⁿ) 上界;空间 O(n) 调用栈。

解法二 · 记忆化递归:推荐先掌握

相同剩余级数的答案相同,将 dfs 用标准库缓存装饰器包装。递归计算仍是自顶向下,只是每个 k 的答案在首次计算后直接复用。

Python 3 · 完整提交代码
from functools import lru_cache


class Solution:
    def climbStairs(self, n: int) -> int:
        @lru_cache(maxsize=None)
        def dfs(k: int) -> int:
            if k <= 1:
                return 1
            return dfs(k - 1) + dfs(k - 2)

        return dfs(n)

关键代码解释:maxsize=None 保留本次求解的所有已算状态;只缓存整数走法数,不保存每条路径。若实际枚举所有路径,输出规模会很大,不能沿用当前线性时间结论。

复杂度:时间 O(n);空间 O(n),含缓存和递归栈。

解法三 · 数组动态规划:把递归依赖排成顺序

令 dp[i]=W(i)。初始化内部状态 dp[0]=1 与第一阶 dp[1]=1,然后从第 2 阶算到第 n 阶。

Python 3 · 完整提交代码
class Solution:
    def climbStairs(self, n: int) -> int:
        if n <= 1:
            return 1

        dp = [0] * (n + 1)
        dp[0], dp[1] = 1, 1
        for i in range(2, n + 1):
            dp[i] = dp[i - 1] + dp[i - 2]
        return dp[n]

关键代码解释:数组大小为 n+1,确保下标 n 存在;提前处理 n=1,也使内部扩展 n=0 时不越界;循环从 2 开始,以 1、1 两个边界为依赖。

复杂度:时间 O(n);空间 O(n) 数组。

解法四 · 滚动变量:面试推荐提交版

保留相邻两项 a=W(0)=1、b=W(1)=1。每次把窗口向右移动一格。循环 t 轮后 a=W(t),所以 n 轮后返回 a。

Python 3 · 完整提交代码
class Solution:
    def climbStairs(self, n: int) -> int:
        a, b = 1, 1
        for _ in range(n):
            a, b = b, a + b
        return a

关键代码解释:这里循环范围仍是 range(n),但初始值改为 1、1。n=1 时进行一轮,a=1;n=2 时进行两轮,a=2。最终 b 是 W(n+1),不要误返回 b。

复杂度:时间 O(n);空间 O(1) 辅助变量。

示例推演:n=4,答案为 5

分类推演:W(2)=1+1=2;W(3)=2+1=3;W(4)=3+2=5。按最后一步分类,前三种以 1 结束,后两种以 2 结束:

1+1+1+1最后走 1
1+2+1最后走 1
2+1+1最后走 1
1+1+2最后走 2
2+2最后走 2

记忆化推演:首次展开 dfs(4)→dfs(3)→dfs(2),计算边界后缓存 W(2)=2;回到 dfs(3) 得到并缓存 3;回到 dfs(4) 时右分支 dfs(2) 直接使用缓存 2,合并得到 5。

滚动变量推演:n=4
完成轮数 ta=W(t)b=W(t+1)动作
011初始化
112同一轮先算右侧,再一起更新
223保留当前与下一项
335继续右移
458返回 a=5
70 解法对比(单位代价模型)
解法时间空间优点与取舍
朴素递归O(φⁿ)O(n)建模直接;上限下不可实用
记忆化递归O(n)O(n)保持递归语义,消除重叠状态
数组 DPO(n)O(n)状态清楚,可随时查看每阶答案
滚动变量O(n)O(1)本题通常首选;不保存所有中间答案
快速倍增(第四阶段)O(log n)O(log n)利用 W(n)=F(n+1),适合进阶理解

边界检查与易错点

  • 把 W(0) 写为 0:会导致 W(2)=1,漏掉直接跨两阶这一种方案。
  • 混用两套边界:若使用 W(1)=1、W(2)=2,循环就应从 3 开始;本页代码统一使用 W(0)=W(1)=1。
  • 把顺序不同的方案合并:1+2 与 2+1 是两种;本题不是忽略顺序的组合计数。
  • 用乘法合并最后一步的类别:两类互斥,应相加;乘法用于连续独立选择的组合。
  • 无边界地向负数递归:若另写“从当前位置向上走”的版本,越界应返回 0、恰好到顶返回 1。本页缩小剩余级数的写法在 k≤1 提前返回,避免负数。
  • 直接枚举全部走法:题目只求数量,无须保存每个路径列表;缓存走法数才能达到 O(n)。
  • 认为加缓存就能处理任意大 n:缓存仍有线性调用栈;扩展规模时可改迭代或快速倍增。
n正确返回检查目的
11原题下界
22一步两级方案
33区分行走顺序
45按最后一步分类
451836311903原题上界

题号、范围、难度与示例依据 力扣 70 原题;讲解、推导与代码为本手册编写。

STAGE 04 / ADVANCED第四阶段:用快速倍增把规模减半

继续深化原有两题,不增加题目。掌握线性解法后,再学习“递归一次,同时返回两个相关答案”的设计。

1. 为什么返回相邻两项?

如果递归只返回 F(k),很难直接构造 F(2k+1)。让 pair(k) 同时返回 (F(k), F(k+1)),就能在一层内用恒等式完成倍增。设 a=F(k),b=F(k+1):

F(2k) = a × (2b − a);F(2k+1) = a² + b²

对 k≥1,斐波那契加法恒等式给出 F(2k)=F(k)·[F(k−1)+F(k+1)];再用 F(k−1)=b−a,就得到第一式。另一加法恒等式给出 F(2k+1)=F(k)²+F(k+1)²。k=0 时直接代入 a=0、b=1,两式也都成立。

  1. 先递归一次求 pair(m // 2),得到 a、b。
  2. 算出 c=F(2k)、d=F(2k+1)。若 m 为偶数,返回 (c,d)。
  3. 若 m 为奇数 m=2k+1,返回 (d,c+d),即 (F(m),F(m+1))。
  4. 递归边界是 pair(0)=(0,1);每次参数整除 2,深度为 O(log n)。
pair(5) 的减半与回溯过程pair(5)pair(2)pair(1)pair(0)÷2 向下÷2÷2向上返回:(0,1) → (1,1) → (1,2) → (5,8)每层一次递归;每个返回值是一对相邻斐波那契数
倍增的关键:缩小参数与扩大返回信息同时发生。

2. 509 · 快速倍增完整代码

入口取相邻两项中的第一项,得到 F(n)。代码不使用浮点数,所有中间运算均为精确整数。

Python 3 · 完整提交代码
class Solution:
    def fib(self, n: int) -> int:
        def pair(m: int) -> tuple:
            if m == 0:
                return 0, 1

            a, b = pair(m // 2)
            c = a * (2 * b - a)
            d = a * a + b * b
            if m % 2 == 0:
                return c, d
            return d, c + d

        return pair(n)[0]

关键代码解释:a,b 来自同一个递归调用,避免把两项分别递归求值。奇数分支返回 (d,c+d),第二项必须是下一项。n=0 直接返回 (0,1) 的第一项 0。

复杂度:单位代价模型下,时间 O(log n),空间 O(log n) 调用栈;n=0 时均为 O(1)。

3. 为什么爬楼梯 W(n)=F(n+1)?

比较边界:W(0)=1=F(1),W(1)=1=F(2)。两者都满足“前两项相加”的递推关系,所以由归纳法可得任意 n≥0 都有 W(n)=F(n+1)。这是一项索引偏移,而不是改变了递推式。

4. 70 · 快速倍增迁移完整代码

仍返回相邻斐波那契数,但入口求 pair(n + 1) 并取第一项。为便于独立复制提交,这里保留完整辅助函数。

Python 3 · 完整提交代码
class Solution:
    def climbStairs(self, n: int) -> int:
        def pair(m: int) -> tuple:
            if m == 0:
                return 0, 1

            a, b = pair(m // 2)
            c = a * (2 * b - a)
            d = a * a + b * b
            if m % 2 == 0:
                return c, d
            return d, c + d

        return pair(n + 1)[0]

关键代码解释:n+1 来自已证明的状态映射。n=4 时求 F(5)=5;n=1 时求 F(2)=1。不要直接返回 F(n),那会让答案少偏移一项。

复杂度:单位代价模型下,时间 O(log n),空间 O(log n) 调用栈(原题 n≥1)。

示例:509 输入 5 得 5;70 输入 4 也用 pair(5),得到 5
回溯状态已知的半规模结果c=F(2k)d=F(2k+1)返回
pair(0)直接命中边界——(0,1)
pair(1),奇数a=0,b=101(1,1)
pair(2),偶数a=1,b=112(1,2)
pair(5),奇数a=1,b=235(5,8)

矩阵快速幂也可以在 O(log n) 次矩阵运算内求解同一递推,但需要维护 2×2 矩阵乘法。快速倍增把该递推的代数结构直接浓缩为两项恒等式;本专题优先完整掌握这条进阶路径。

两题对照与核心模板速查

比较项509 斐波那契数70 爬楼梯
函数契约F(n):第 n 项的值W(n):到第 n 阶的方案数
边界F(0)=0,F(1)=1W(0)=1,W(1)=1(0 是内部状态)
递推依据数学定义按最后走 1 阶或 2 阶分类求和
基本递推F(n)=F(n−1)+F(n−2)W(n)=W(n−1)+W(n−2)
滚动初始化a,b=0,1a,b=1,1
滚动不变量t 轮后 a=F(t),b=F(t+1)t 轮后 a=W(t),b=W(t+1)
n 轮后返回aa
两题联系F(n)W(n)=F(n+1)

面试时如何解释你的优化?

  1. 先建模:说明参数和返回值,列出边界、递推与规模缩小的依据。
  2. 找重复:在 F(4) 的递归树标出重复 F(2),解释为何普通递归是指数时间。
  3. 加缓存:相同参数重复出现,记忆化使每个状态只展开一次,时间 O(n)、空间 O(n)。
  4. 排顺序:依赖只指向更小状态,改成从小到大的 DP,消除递归栈。
  5. 压空间:只读取前两项,用两个变量维护不变量,空间降为 O(1)。
  6. 回应追问:若要求更少算术步骤,解释快速倍增;同时说明大整数代价的适用口径。

面试追问与参考回答

为什么记忆化后是 O(n),而不是 O(2ⁿ)?

不同 k 只有 O(n) 个,每个状态只首次展开两个子调用。再次访问仅做一次缓存查询,因此总转移数也是 O(n),不再随完整递归树指数增长。

递归有两个分支,为什么栈空间只有 O(n)?

分支依次执行。求左分支时,右分支还没有入栈;返回后栈帧释放,再执行右分支。同时存活的帧沿一条根到叶路径,最多线性层数。

爬楼梯如何证明两类方案不会重复?

最后一步要么走 1 级,要么走 2 级,不可能同时属于两类。删除最后一步后分别对应到第 n−1 级与第 n−2 级的唯一前缀路径,因此两类既互斥又穷尽。

为什么不能把两个滚动更新拆成普通顺序赋值?

新 b 需要旧 a 与旧 b。先更新 a 会覆盖旧值,接着算 b 得到错误结果。可使用 Python 并行赋值,或额外临时变量保存旧 a。

若要输出每一条爬楼梯路径,复杂度还是线性吗?

不是。计数只返回一个整数;枚举必须至少花时间写出所有方案以及各方案的路径长度,总成本至少受输出规模约束,不能用“每个剩余级数只算一次”直接推导 O(n)。

四阶段学习计划与长期复习

阶段 / 建议用时本次练习验收标准
第一阶段 / 30–40 分钟画 F(4) 调用树;用实验室逐步点击普通与记忆化递归说出结果 3、普通调用 9 次、最长栈 4 层
第二阶段 / 25–35 分钟闭卷写出两种伪代码;比较状态数与调用次数解释缓存键、零值命中、O(n) 栈不消失
第三阶段 / 70–90 分钟先独立完成 509,再推导 70;各写记忆化、数组 DP、滚动变量通过 509 的 0、1、2、30 和 70 的 1、2、3、45
第四阶段 / 35–45 分钟从相邻两项推导倍增;手推 pair(5)解释奇偶分支、W(n)=F(n+1)、O(log n) 栈

完成后在第 1、3、7 天各花 10–15 分钟回顾:先遮住代码写出状态与边界,再手推小例子,最后闭卷实现滚动变量。若出错,回到首次失效的不变量或边界;不要只重新抄一遍代码。

掌握清单

清单独立保存;题目进度只由两张题目卡片上的“已掌握”决定。

当你能在不看答案的情况下完成递归建模、正确处理边界、解释复杂度并写出优化代码,再把对应题目标记为“已掌握”。