登录
OAmaster
动态规划

动态规划 · 入门

从 LC 70 爬楼梯出发,看朴素递归怎么变成 O(n) DP,再压到 O(1) 空间。

Easy预计阅读 45 分钟更新于 2026-05-23

1. 核心原理

DP 要解决的问题模板长这样:「一个原问题能拆成结构相同的若干子问题,子问题之间还互相重复出现」。常见三类场景——最值(最大 / 最小代价)、计数(多少种方案)、是否可达(能不能拼出某状态)——都能套这套思路。

为什么暴力递归会爆?

直觉是写递归:把原问题展开成子问题、再展开成更小的子问题,直到边界。但子问题会被重复算很多次——比如算 f(5) 时要算 f(3)f(4),算 f(4) 时又要算 f(3)f(3) 就算了两遍。展开到 n = 45 这种规模,调用次数就是斐波那契本身,约 10⁹ 级别,直接 TLE。

f(5)
├── f(4)
│   ├── f(3)   ← 重复
│   └── f(2)
└── f(3)       ← 重复
    ├── f(2)
    └── f(1)

DP 的关键洞察

既然子问题重复,就算一次存起来,下次直接查表。这个思路演进出三种实现方式:

  1. 朴素递归:纯展开,无缓存。指数复杂度,仅用于推导。
  2. 记忆化(自顶向下):递归 + 缓存。从原问题往下展开,每个子问题只算一次。
  3. 递推(自底向上):循环 + 数组。从最小子问题往上填,不用递归栈。

三者答案一致,区别在「先算谁后算谁」+「要不要递归栈」。本章会把 LC 70 用这三种方式各写一遍,看演化过程。

DP 三要素预告

后面 §4 会展开讲,先记一下骨架——任何 DP 题都可以归结为:

要素含义LC 70 的例子
状态定义f(i) 是什么f(i) = 爬到第 i 级的方案数
转移方程f(i) 如何用更小的子问题表达f(i) = f(i - 1) + f(i - 2)
边界条件最小子问题的值f(1) = 1, f(2) = 2

写题时先把这三件事在草稿上写清楚,再开始敲代码——80% 的 DP bug 都来自状态定义模糊。


2. 通用模板

一维线性 DP 的骨架——f(i) 只依赖 f(i - 1)f(i - 2)。这是 LC 70 / 198 / 746 共用的形状。每道题就是把转移方程那一行换成自己的逻辑。

# 自底向上(递推):O(n) 空间
def solve_bottomup(n: int) -> int:
    if n <= 1:
        return base_case(n)
    dp = [0] * (n + 1)
    dp[0], dp[1] = base_case(0), base_case(1)       # 边界
    for i in range(2, n + 1):
        dp[i] = transition(dp, i)                   # 用 dp[i-1]、dp[i-2] 写出 dp[i]
    return dp[n]


# 滚动数组:O(1) 空间
def solve_rolling(n: int) -> int:
    if n <= 1:
        return base_case(n)
    prev2, prev1 = base_case(0), base_case(1)
    for i in range(2, n + 1):
        cur = transition_rolling(prev2, prev1, i)
        prev2, prev1 = prev1, cur                   # 滚动
    return prev1
// 自底向上(递推):O(n) 空间
public int solveBottomUp(int n) {
    if (n <= 1) return baseCase(n);
    int[] dp = new int[n + 1];
    dp[0] = baseCase(0);
    dp[1] = baseCase(1);
    for (int i = 2; i <= n; i++) {
        dp[i] = transition(dp, i);
    }
    return dp[n];
}

// 滚动数组:O(1) 空间
public int solveRolling(int n) {
    if (n <= 1) return baseCase(n);
    int prev2 = baseCase(0), prev1 = baseCase(1);
    for (int i = 2; i <= n; i++) {
        int cur = transitionRolling(prev2, prev1, i);
        prev2 = prev1;
        prev1 = cur;
    }
    return prev1;
}
// 自底向上(递推):O(n) 空间
int solveBottomUp(int n) {
    if (n <= 1) return baseCase(n);
    vector<int> dp(n + 1);
    dp[0] = baseCase(0);
    dp[1] = baseCase(1);
    for (int i = 2; i <= n; i++) {
        dp[i] = transition(dp, i);
    }
    return dp[n];
}

// 滚动数组:O(1) 空间
int solveRolling(int n) {
    if (n <= 1) return baseCase(n);
    int prev2 = baseCase(0), prev1 = baseCase(1);
    for (int i = 2; i <= n; i++) {
        int cur = transitionRolling(prev2, prev1, i);
        prev2 = prev1;
        prev1 = cur;
    }
    return prev1;
}

数组初始化要点

  • dp[0]dp[1] 直接赋边界值——不要把循环从 i = 0 起步,否则要在循环里写一堆 if i == 0 ... 分支。
  • 计数 DP 用 int 即可,但若 n ≥ 50 且涉及阶乘 / 累加,建议 long(Java)/ long long(C++)防溢出。
  • Python 不用考虑溢出,但要小心 dp = [0] * (n + 1)——n = 0dp 只有一个元素 dp[0],访问 dp[1] 会越界。

滚动数组优化思路

转移只回看「相邻 k 项」时,整个 dp 数组没必要保留——f(i) 算完之后 f(i - 3) 之前都用不上了。一维 DP 里 k = 2,两个变量 prev2 / prev1 就够;二维 DP 里如果 dp[i][j] 只依赖 dp[i - 1][.],可以压成两行。

自顶向下 vs 自底向上对照

维度自顶向下(记忆化)自底向上(递推)
写法递归 + 缓存for 循环 + 数组
调用模式f(n) 倒着展开f(1) 顺着填
优点跟数学定义对应直接没有递归栈,速度常数小
缺点Python 递归栈深、可能爆栈必须想清楚顺序
适用状态空间不规则、只算少量状态状态空间规则、全部要算

3. 母题精讲:LC 70 爬楼梯

链接:LeetCode 70

题意:你站在楼梯底下,要爬到第 n 级。每一步只能爬 1 级或 2 级。问有多少种不同的爬法。

数据范围:

  • n:1 到 45

示例:

输入:n = 3
输出:3
解释:三种方式
  1 + 1 + 1
  1 + 2
  2 + 1

暴力解:朴素递归

直觉很自然——爬到第 n 级的最后一步要么是从第 n - 1 级跨 1 级上来、要么是从第 n - 2 级跨 2 级上来。两类方案不重不漏:

f(n) = f(n - 1) + f(n - 2)
f(1) = 1
f(2) = 2

直接翻译成递归:

class Solution:
    def climbStairs_naive(self, n: int) -> int:
        if n <= 2:
            return n
        return self.climbStairs_naive(n - 1) + self.climbStairs_naive(n - 2)
class Solution {
    public int climbStairsNaive(int n) {
        if (n <= 2) return n;
        return climbStairsNaive(n - 1) + climbStairsNaive(n - 2);
    }
}
class Solution {
public:
    int climbStairsNaive(int n) {
        if (n <= 2) return n;
        return climbStairsNaive(n - 1) + climbStairsNaive(n - 2);
    }
};

代码正确,但只能跑到 n ≈ 30 就开始肉眼可见地变慢。原因是这棵递归树指数膨胀——f(5) 会算两次 f(3)、四次 f(2)、三次 f(1),等等。展开整棵树:

                  f(5)
                 /    \
              f(4)    f(3)
             /   \    /   \
          f(3) f(2) f(2) f(1)
          /  \
        f(2) f(1)

f(3) 在这棵小树里就算了 2 次;n = 45f(2) 会被算 f(45) ≈ 1.1 × 10⁹ 次(这正是斐波那契数列)。复杂度 O(2ⁿ)n = 45 时大约 3 × 10¹³ 次调用——直接卡死。

关键观察:子问题在重复计算

朴素递归的浪费就一个字:f(3) 不管被谁问到都返回 3,没必要每次重新算。

第一个改进是记忆化——把每个 f(k) 的结果存进缓存,下次问到同一个 k 时直接返回。

from functools import cache

class Solution:
    def climbStairs_memo(self, n: int) -> int:
        @cache
        def f(k: int) -> int:
            if k <= 2:
                return k
            return f(k - 1) + f(k - 2)
        return f(n)
class Solution {
    private int[] memo;

    public int climbStairsMemo(int n) {
        memo = new int[n + 1];
        return f(n);
    }

    private int f(int k) {
        if (k <= 2) return k;
        if (memo[k] != 0) return memo[k];           // 缓存命中
        return memo[k] = f(k - 1) + f(k - 2);
    }
}
class Solution {
public:
    int climbStairsMemo(int n) {
        memo.assign(n + 1, 0);
        return f(n);
    }

private:
    vector<int> memo;
    int f(int k) {
        if (k <= 2) return k;
        if (memo[k] != 0) return memo[k];           // 缓存命中
        return memo[k] = f(k - 1) + f(k - 2);
    }
};

每个 f(k) 只算一次,复杂度直接降到 O(n)。空间是 O(n) 的缓存 + O(n) 的递归栈。

关键观察:递归栈可以拆掉

继续看上面的记忆化版——它仍然是「自顶向下」的递归:f(n)f(n-1)f(n-1)f(n-2) ……一路压栈到 f(1)f(2),再回头填表。

但如果反过来呢?从 f(1)f(2) 开始,自底向上直接填一个数组:

class Solution:
    def climbStairs_tab(self, n: int) -> int:
        if n <= 2:
            return n
        dp = [0] * (n + 1)
        dp[1], dp[2] = 1, 2
        for i in range(3, n + 1):
            dp[i] = dp[i - 1] + dp[i - 2]
        return dp[n]
class Solution {
    public int climbStairsTab(int n) {
        if (n <= 2) return n;
        int[] dp = new int[n + 1];
        dp[1] = 1;
        dp[2] = 2;
        for (int i = 3; i <= n; i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[n];
    }
}
class Solution {
public:
    int climbStairsTab(int n) {
        if (n <= 2) return n;
        vector<int> dp(n + 1, 0);
        dp[1] = 1;
        dp[2] = 2;
        for (int i = 3; i <= n; i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[n];
    }
};

时间还是 O(n),但没有递归栈了。n = 10⁶ 也不会爆栈。这就是 tabulation(递推)

关键观察:状态只依赖最近两项

再看转移方程 dp[i] = dp[i - 1] + dp[i - 2]——dp[i] 只读 dp[i - 1]dp[i - 2] 两项。dp[i - 3] 之前的全是历史包袱,留着没用。

那为什么要开 O(n) 的数组?用两个变量轮换即可:

class Solution:
    def climbStairs(self, n: int) -> int:
        if n <= 2:
            return n
        prev2, prev1 = 1, 2                     # dp[1], dp[2]
        for _ in range(3, n + 1):
            prev2, prev1 = prev1, prev1 + prev2
        return prev1
class Solution {
    public int climbStairs(int n) {
        if (n <= 2) return n;
        int prev2 = 1, prev1 = 2;               // dp[1], dp[2]
        for (int i = 3; i <= n; i++) {
            int cur = prev1 + prev2;
            prev2 = prev1;
            prev1 = cur;
        }
        return prev1;
    }
}
class Solution {
public:
    int climbStairs(int n) {
        if (n <= 2) return n;
        int prev2 = 1, prev1 = 2;               // dp[1], dp[2]
        for (int i = 3; i <= n; i++) {
            int cur = prev1 + prev2;
            prev2 = prev1;
            prev1 = cur;
        }
        return prev1;
    }
};

时间 O(n)、空间 O(1)。同样的题,从 O(2ⁿ) 时间一路压到 O(n) 时间 O(1) 空间——四步走完。

这套「朴素递归 → 记忆化 → 递推 → 滚动数组」就是 DP 入门最经典的一条优化链。下面把这件事抽象一下。


4. 抽象:DP 三步走 + 自顶向下 vs 自底向上

三步走:状态 → 转移 → 顺序

不管题目长什么样,DP 都是三件事拼出来的:

第一步:定义状态。 给一个问题起一个名字,比如 f(i) = 「爬到第 i 级楼梯的方案数」。状态必须满足两个性质:

  • 无后效性f(i) 一旦算出来,后面的状态用 f(i) 时不需要回头看「f(i) 是怎么算出来的」。
  • 最优子结构:原问题的答案能由若干子问题的答案直接组合出来。

LC 70 里的 f(i) 满足这两条——爬到第 i 级的方案数只跟「爬到 i - 1i - 2 级的方案数」有关,跟「i - 1 是怎么爬上去的」无关。

第二步:写转移方程。 用更小的状态把当前状态算出来。LC 70 是 f(i) = f(i - 1) + f(i - 2)。后面 LC 198 打家劫舍是 f(i) = max(f(i - 1), f(i - 2) + nums[i]),LC 746 使用最小花费爬楼梯是 f(i) = min(f(i - 1) + cost[i - 1], f(i - 2) + cost[i - 2])。每道题的差异就在这一行转移方程上。

第三步:决定遍历顺序。f(i) 时必须保证 f(i - 1)f(i - 2) 都已经算好。对一维线性 DP,最自然的就是「i 从小到大递推」。区间 DP 要按区间长度从小到大、背包 DP 要按物品分层——后面专题会展开。

心智模型:DP = DAG 上的拓扑序递推

三步走背后有一个统一视角:把每个子问题画成 DAG 节点,依赖关系画成有向边,DP 就是沿拓扑序求值

以 LC 70 为例,节点 f(1)..f(n) 各为一个状态,边 f(i-1) → f(i)f(i-2) → f(i) 表示「算 f(i) 需要先算 f(i-1) 和 f(i-2)」——整张图是个 DAG(无环),拓扑序就是 i 从小到大。

f(1)  f(2)  f(3)  f(4)  ...  f(n)
 \    / \    / \    / \   ...
  \  /   \  /   \  /
   ↓      ↓      ↓
  f(3)   f(4)   f(5)

这个视角立刻把两件事讲清楚:

  • 「记忆化 = DFS 后序遍历」:从终点节点开始递归,回溯时填好每个子节点。
  • 「递推 = 拓扑序 BFS」:从源点节点开始,按拓扑序逐个填。

两种实现等价,本质都是按 DAG 拓扑序求值。后面所有 DP 专题——背包、区间、状压、树形、数位——都能套这个统一框架,区别只在「DAG 长什么样」。

复杂度公式:状态数 × 单次转移成本

DP 的复杂度有个通用公式:时间 = 状态数 × 单次转移成本。LC 70 状态数 n、单次转移 O(1)O(n);后面要讲的 0/1 背包状态数 n × W、转移 O(1)O(nW);区间 DP 状态数 、转移 O(n)O(n³)

记住这个公式,看到题目先估「我需要多少个状态」、再估「算一个状态要多久」,就能判断 DP 能不能过。比如 n = 50W = 10⁵ 的背包:5 × 10⁶,过;n = 10⁵ 的区间 DP:10¹⁵,不过——这时得换贪心或别的思路。

什么时候用 DP?什么时候不用?

DP 不是默认最优解。判断标准就是三步走前提里的「有重叠子问题 + 最优子结构」是否成立:

题目特征适用算法原因
子问题相互无重叠分治 / 递归没有可缓存的重复计算,DP 退化为暴力
局部最优全局最优贪心不需要保留多种状态对比,省掉 DP 维度
单位权图最短路BFS拓扑层级即步数,DP 杀鸡用牛刀
有重叠子问题 + 最优子结构DP经典使用场景
状态空间指数大 + 无单调性暴力 + 剪枝 / 启发式搜索DP 状态数超过内存

例如 LC 53 最大子数组和——既能 DP(Kadane 算法),也能分治(O(n log n))。DP 占优是因为子问题(「以 i 结尾的最大子段和」)数量是 n、转移 O(1)、总 O(n);分治没用上状态的单调性,常数大。

自顶向下 vs 自底向上

同一个递推关系有两种写法:

维度自顶向下(记忆化)自底向上(递推)
写法递归 + 缓存for 循环 + 数组
调用模式f(n) 倒着展开f(1) 顺着填
优点跟数学定义对应直接没有递归栈,速度常数小
缺点Python 递归栈深、可能爆栈必须想清楚顺序
适用状态空间不规则、只算少量状态状态空间规则、全部要算

实战里两种都要会写。记忆化的好处是思维负担轻——把递推方程当成函数写下来、加个 @cache 就完事,正确性几乎不用证明。递推的好处是性能稳定——没有递归调用开销、可以做空间优化。

工程上的建议:

  • 面试 30 分钟限时、状态空间结构不复杂——直接写记忆化,定义状态 + 写转移就交卷。
  • LC 提交 / 实际工程代码 / 状态多 10⁶ 量级——写递推,顺手做空间优化。

空间优化:滚动数组

一维 DP 里 dp[i] 只依赖 dp[i - 1]dp[i - 2] 这种「近邻」状态时,没必要开整个数组——几个变量轮换即可。LC 70 / 198 / 746 都符合这个模式,本章后面会把每道题都从 O(n) 空间压到 O(1)

二维 DP 里如果 dp[i][j] 只依赖 dp[i - 1][.](上一行)和 dp[i][.](当前行的左边),可以压成「两行」O(n) 空间;如果只依赖 dp[i - 1][j]dp[i][j - 1],可以倒着扫压到一行——背包、LCS 这些都用到。这是下一章 背包问题 的核心技巧。

记一个原则:先把朴素 DP 跑对,再考虑压空间。先压空间会让转移方程读不出来,bug 难定位。


5. 例题(三道)

按转移方程复杂度递增排:纯加法 → 二选一带 max → 二选一带 cost。每道题给「朴素 DP + 滚动数组」两版本。

5.1 LC 70 爬楼梯

链接:LeetCode 70

回到本章开头那道题。f(i) = 「爬到第 i 级的方案数」,转移 f(i) = f(i - 1) + f(i - 2),边界 f(1) = 1f(2) = 2

下面用 ArrayViz 走一遍 n = 6 的填表过程,每一格里写的就是 dp[i]

climbStairs(6) 填表过程

dp[i] = dp[i-1] + dp[i-2],从左到右

Step 1 / 5初始 dp[1]=1, dp[2]=2,是边界值
index
012345
value
1
2
0
0
0
0
i
已填好当前填尚未填
01/05

最后 dp[6] = 13——意思是爬到第 6 级有 13 种走法。注意 dp 数组其实就是斐波那契数列 1, 2, 3, 5, 8, 13, ...

代码(滚动数组版):

class Solution:
    def climbStairs(self, n: int) -> int:
        if n <= 2:
            return n
        prev2, prev1 = 1, 2                          # dp[1], dp[2]
        for _ in range(3, n + 1):
            prev2, prev1 = prev1, prev1 + prev2      # 同时更新避免覆盖
        return prev1
class Solution {
    public int climbStairs(int n) {
        if (n <= 2) return n;
        int prev2 = 1, prev1 = 2;
        for (int i = 3; i <= n; i++) {
            int cur = prev1 + prev2;
            prev2 = prev1;
            prev1 = cur;
        }
        return prev1;
    }
}
class Solution {
public:
    int climbStairs(int n) {
        if (n <= 2) return n;
        int prev2 = 1, prev1 = 2;
        for (int i = 3; i <= n; i++) {
            int cur = prev1 + prev2;
            prev2 = prev1;
            prev1 = cur;
        }
        return prev1;
    }
};

时间 O(n)、空间 O(1)。注意 Python 的同时赋值 prev2, prev1 = prev1, prev1 + prev2 会先求右侧再赋左侧,不会有「还没更新就读」的问题;Java / C++ 必须用临时变量 cur

Follow-up:上面的 f(i) 只是数方案。如果换成「每一级有一个权值,要在不能踩相邻两级的前提下偷最多权值」——状态怎么变?


5.2 LC 198 打家劫舍

链接:LeetCode 198

题意:一排房子排成一条直线,第 i 间房里有 nums[i] 现金。你不能偷相邻两间(会触发警报)。求能偷到的最大金额。

数据范围:

  • nums.length:1 到 100
  • nums[i]:0 到 400

示例:

输入:nums = [2, 7, 9, 3, 1]
输出:12
解释:偷第 0、2、4 间,金额 2 + 9 + 1 = 12。

状态f(i) = 「考虑前 i + 1 间房(下标 0 到 i),能偷到的最大金额」。

转移:站在第 i 间,有两个选择,取最大:

  • 不偷 i:金额跟「考虑前 i 间」一样,即 f(i - 1)
  • i:那 i - 1 不能偷,金额是 f(i - 2) + nums[i]

合起来 f(i) = max(f(i - 1), f(i - 2) + nums[i])

边界f(0) = nums[0](只一间房,偷不偷无论怎样最优都是偷),f(1) = max(nums[0], nums[1])

代码(递推 + 滚动数组两版):

# 递推版:O(n) 空间
class Solution:
    def rob_tab(self, nums: list[int]) -> int:
        n = len(nums)
        if n == 1:
            return nums[0]
        dp = [0] * n
        dp[0] = nums[0]
        dp[1] = max(nums[0], nums[1])
        for i in range(2, n):
            dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])
        return dp[n - 1]


# 滚动数组版:O(1) 空间
class Solution:
    def rob(self, nums: list[int]) -> int:
        prev2, prev1 = 0, 0                              # f(-2), f(-1) 都是 0
        for x in nums:
            prev2, prev1 = prev1, max(prev1, prev2 + x)
        return prev1
// 递推版:O(n) 空间
class Solution {
    public int robTab(int[] nums) {
        int n = nums.length;
        if (n == 1) return nums[0];
        int[] dp = new int[n];
        dp[0] = nums[0];
        dp[1] = Math.max(nums[0], nums[1]);
        for (int i = 2; i < n; i++) {
            dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
        }
        return dp[n - 1];
    }
}

// 滚动数组版:O(1) 空间
class Solution {
    public int rob(int[] nums) {
        int prev2 = 0, prev1 = 0;                        // f(-2), f(-1) 都是 0
        for (int x : nums) {
            int cur = Math.max(prev1, prev2 + x);
            prev2 = prev1;
            prev1 = cur;
        }
        return prev1;
    }
}
// 递推版:O(n) 空间
class Solution {
public:
    int robTab(vector<int>& nums) {
        int n = nums.size();
        if (n == 1) return nums[0];
        vector<int> dp(n, 0);
        dp[0] = nums[0];
        dp[1] = max(nums[0], nums[1]);
        for (int i = 2; i < n; i++) {
            dp[i] = max(dp[i - 1], dp[i - 2] + nums[i]);
        }
        return dp[n - 1];
    }
};

// 滚动数组版:O(1) 空间
class Solution {
public:
    int rob(vector<int>& nums) {
        int prev2 = 0, prev1 = 0;                        // f(-2), f(-1) 都是 0
        for (int x : nums) {
            int cur = max(prev1, prev2 + x);
            prev2 = prev1;
            prev1 = cur;
        }
        return prev1;
    }
};

时间 O(n)、空间 O(1)

注意滚动数组版的边界处理比朴素版还干净——初始 prev2 = prev1 = 0 直接对应「还没看任何房子」的状态。第一轮 x = nums[0] 时计算 max(0, 0 + nums[0]) = nums[0],自动等于 f(0);第二轮 x = nums[1] 时计算 max(nums[0], 0 + nums[1]) = max(nums[0], nums[1]),自动等于 f(1)用「全 0 起点」代替显式边界是滚动数组 DP 的常用清洁技巧。

LC 70 跟 LC 198 转移方程结构几乎一样——都是 f(i) 依赖 f(i - 1)f(i - 2),区别只在「求和」vs「max + cost」。这种「二选一型 1D DP」在 LC 简单/中等档里反复出现,滚动数组写法值得记熟。

Follow-up:再变一下题面——不是「偷东西」,而是「爬楼梯但每级有代价」,从 0 或 1 级起步,到达顶部时不需要踩顶层那一级,怎么花最少?


5.3 LC 746 使用最小花费爬楼梯

链接:LeetCode 746

题意:一个数组 costcost[i] 表示登上第 i 级楼梯的代价。每步可以爬 1 级或 2 级。起点可以选第 0 级或第 1 级(无代价进入),求登顶(越过最后一级)的最小总代价。

数据范围:

  • cost.length:2 到 1000
  • cost[i]:0 到 999

示例:

输入:cost = [10, 15, 20]
输出:15
解释:从第 1 级起步(代价 15),跨 2 级到顶。
输入:cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1]
输出:6
解释:依次踩 0 → 2 → 4 → 6 → 7 → 9,代价 1 + 1 + 1 + 1 + 1 + 1 = 6。

状态f(i) = 「到达第 i 级楼梯(不含i)所需的最小代价」。换句话说,f(i) 是 站到 i 之前所有踩过台阶的代价总和。

转移:到达 i 之前最后一步要么从 i - 1 跨 1、要么从 i - 2 跨 2:

  • i - 1 跨 1:在 i - 1 上付代价 cost[i - 1],到达 i - 1 之前的累计是 f(i - 1),合起来 f(i - 1) + cost[i - 1]
  • i - 2 跨 2:类似 f(i - 2) + cost[i - 2]

合起来 f(i) = min(f(i - 1) + cost[i - 1], f(i - 2) + cost[i - 2])

边界f(0) = f(1) = 0(从 0 或 1 级起步免费)。

答案f(n)——「登顶」等于「到达第 n 级之前」,因为登顶不需要再踩那一级。

代码:

# 递推版
class Solution:
    def minCostClimbingStairs_tab(self, cost: list[int]) -> int:
        n = len(cost)
        dp = [0] * (n + 1)                              # f(0) = f(1) = 0
        for i in range(2, n + 1):
            dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2])
        return dp[n]


# 滚动数组版
class Solution:
    def minCostClimbingStairs(self, cost: list[int]) -> int:
        prev2, prev1 = 0, 0                             # f(0), f(1)
        for i in range(2, len(cost) + 1):
            prev2, prev1 = prev1, min(prev1 + cost[i - 1], prev2 + cost[i - 2])
        return prev1
class Solution {
    public int minCostClimbingStairs(int[] cost) {
        int n = cost.length;
        int prev2 = 0, prev1 = 0;
        for (int i = 2; i <= n; i++) {
            int cur = Math.min(prev1 + cost[i - 1], prev2 + cost[i - 2]);
            prev2 = prev1;
            prev1 = cur;
        }
        return prev1;
    }
}
class Solution {
public:
    int minCostClimbingStairs(vector<int>& cost) {
        int n = cost.size();
        int prev2 = 0, prev1 = 0;
        for (int i = 2; i <= n; i++) {
            int cur = min(prev1 + cost[i - 1], prev2 + cost[i - 2]);
            prev2 = prev1;
            prev1 = cur;
        }
        return prev1;
    }
};

时间 O(n)、空间 O(1)

这道题真正容易绕晕的地方是状态的定义——f(i) 是「到达 i 之前」还是「踩到 i 之后」?两种定义都能 AC,但转移方程会不一样。本章用的「到达之前」版边界最干净(f(0) = f(1) = 0),后面会沿用这种定义。

另一种定义是 g(i) = 「踩过 i 之后的累计代价」,转移 g(i) = min(g(i - 1), g(i - 2)) + cost[i],最后答案 min(g(n - 1), g(n - 2))——因为最后一步可以从 n - 1n - 2 跨上顶。两种定义都对,但 g 版的答案处理略麻烦。

对比 LC 70 / 198 / 746 的转移方程

转移方程边界
LC 70 爬楼梯f(i) = f(i - 1) + f(i - 2)f(1) = 1, f(2) = 2
LC 198 打家劫舍f(i) = max(f(i - 1), f(i - 2) + nums[i])f(0) = nums[0], f(1) = max(nums[0], nums[1])
LC 746 最小花费爬楼梯f(i) = min(f(i - 1) + cost[i - 1], f(i - 2) + cost[i - 2])f(0) = f(1) = 0

三道题的状态空间、转移结构完全同构——都是「f(i) 依赖 f(i - 1)f(i - 2)」——只是聚合函数从「求和」变成「取 max」再变成「取 min + cost」。呼应 §4 的三步走——骨架相同,只是转移方程在变。


6. 边界、易错与复杂度

边界条件:n = 1 / 数组长度 1

LC 70 要求 n ≥ 1,但代码常被写成「dp[2] 直接赋初值」——n = 1 时就会越界。处理方法两种:

  • 显式判空:if n <= 2: return n(推荐,可读性最好)
  • 把 dp 数组从下标 0 起步:dp = [0, 1, 2],循环从 i = 3 开始(避免特判但要小心 off-by-one)

LC 198 / 746 同理——数组长度 1 时直接返回即可。

滚动数组的同时赋值陷阱

Python 写 prev2, prev1 = prev1, prev1 + prev2同时赋值——右侧整体求值后再统一写入左侧,所以 prev1 在右侧表达式里仍然是更新前的值。Java / Go / C++ 没有这个语法,必须显式 int cur = prev1 + prev2; prev2 = prev1; prev1 = cur;,否则会读到已更新的 prev1

写跨语言代码时容易在这里踩坑。

记忆化的递归栈深度

Python 默认递归栈 1000 层。n = 10⁴@cache + 递归必爆栈。两个解:

  • 改成递推(推荐)—— 几乎所有「线性状态空间」的 DP 都能递推
  • sys.setrecursionlimit(10**6) 顶一下,但内存随之上去

Java / Go / C++ 默认栈深得多,记忆化一般够用。Python 优先递推。

数组下标 vs 状态编号

dp[i]i 含义因题而异——LC 70 里 dp[i] 表示「爬到第 i 级」,LC 198 里 dp[i] 表示「考虑前 i + 1 间房子」,LC 746 里 dp[i] 表示「到达第 i 级之前」。

写题前在草稿纸上写出 dp[i] = ... 的完整中文定义会少踩坑——大部分 DP bug 来自状态定义模糊而非转移方程算错(比如 LC 746 是「到达之前」还是「踩到之后」),导致边界对不上、答案差 1。

自顶向下 vs 自底向上的工程取舍

场景推荐方式
状态空间规则、全要算递推 + 滚动数组
状态空间稀疏、只问几个记忆化
转移依赖多个非线性子问题记忆化(避免想清遍历顺序)
Python 大 nn > 10⁵递推(避免爆栈)
调试期、想快速验证转移方程记忆化(先正确再优化)

复杂度速查

时间空间(朴素)空间(滚动)
LC 70 爬楼梯O(n)O(n)O(1)
LC 198 打家劫舍O(n)O(n)O(1)
LC 746 最小花费爬楼梯O(n)O(n)O(1)

三道题的空间都能压到 O(1)——因为状态依赖只回看两步。

面试官常追问的几个方向

DP 题写完后面试官常追问下面这几类问题,每一类对应一个能立刻给出方向的技巧:

n 大到 10¹⁸ 怎么办(LC 70 进阶):线性 DP O(n) 还是太慢。LC 70 这种「线性常系数递推」可以用矩阵快速幂压到 O(log n)

[f(i)  ]   [1 1] ^ (i-1)   [f(1)]
[f(i-1)] = [1 0]         · [f(0)]

矩阵 2×2 自乘 log(i) 次即可。同样思路适用于斐波那契、跳台阶、字符串递推等任何「f(i) 是前 k 项线性组合」的题型。

状态空间爆炸怎么办(背包/区间 DP 量级超内存)

  • 维度小、值域大 → 状压 DP:用 bitmask 把「已选集合」压到一个 int 里
  • 子问题对称 → Meet-in-the-middle:把状态空间一分为二,分别枚举再合并
  • 状态稀疏 → map / hashmap 代替数组

有没有闭式解 / 数学公式:能写出递推不一定要写 DP。LC 70 实际上有斐波那契通项 f(n) = (φⁿ - ψⁿ) / √5;某些组合数 DP 直接用阶乘 + 逆元 O(1) 查。面试官追问时先想「是否存在生成函数 / 通项」,能给 senior signal。

能否压到更小空间:滚动数组 → 双变量 → 单变量(Kadane 风格)。再追问可以做 O(1) 额外空间——把答案累积进输入数组本身(in-place DP,比如 LC 53)。

在线/流式输入怎么办:每来一个新元素就增量更新 dp,不要重跑全部。LC 53 的 Kadane 算法就是天然在线版——cur_max = max(num, cur_max + num),每个元素 O(1) 处理,可支持流式。


Pro 会员

解锁完整北美 OA 题库

165 家公司 / 1000+ 道真题 · Python · Java · C++ 三语题解 · 月度更新


8. 小结

动态规划入门的三件事:

  • 定义状态:用一句话写清 f(i) 是什么,避免歧义
  • 写转移方程:用更小的子问题表达 f(i),每道题的差异都在这一行
  • 决定遍历顺序:保证算 f(i) 时所需的子问题已经备好

本章五道一线题——LC 70 / 198 / 746 + 加餐 LC 213 / 740——状态空间结构完全同构,都是 f(i) 依赖 f(i - 1)f(i - 2) 的一维 DP,区别只在「求和 / max / min + cost / 环形拆链 / 值域聚合」这一层语义。O(n) → 滚动数组 O(1) 的压缩在后续几乎所有线性 DP 章节都会复用。

LC 53 Kadane / LC 152 双 DP / LC 91 字符串 DP 是同一族的进阶变体——状态定义和转移依赖结构略有不同,但「先朴素再压空间」的工作流完全一致。

下一节 背包问题 把状态空间从一维推到二维——f(i, c) 表示「前 i 个物品、容量 c 时的最优值」,转移变成「选 vs 不选」的二维表。背包是 DP 题里另一族出现频率极高的母题,0-1 / 完全 / 多重 / 分组背包都能用同一份骨架处理。


对应灵神题单:DP 入门 / 1D DP / Fibonacci 型 + 单序列选择型

进一步阅读:灵茶山艾府《动态规划题单》入门部分;LeetCode DP 标签 入门难度

On this page