【动态规划算法专题】动态规划五部曲实战:泰波那契数、三步问题、最小花费爬楼梯

先搞懂:什么是动态规划

理解:

动态规划(Dynamic Programming,简称 DP)就是:把一个大问题拆成一串小问题,把每个小问题的答案存起来,用的时候直接拿,不再重新算一遍。

听起来很抽象,其实生活里到处都是。比如爬楼梯时想问走到第 10 阶一共有多少种走法,你不需要真的把所有路线都走一遍------只要知道到第 8 阶有几种、到第 9 阶有几种,加起来就行了。而到第 8 阶有几种又可以由第 5、6、7 阶推出来......一层层往回推,最后站在地面,1 种走法,这种显然已知的答案上。这就是最朴素的动态规划。

两个关键词:状态 + 转移

所有 DP 问题都绕不开这两件事:

关键词 含义 类比
状态 用一个东西(通常是 dp 数组的一项)表示「某个子问题的答案」 「到第 i 阶有多少种走法」就是状态
转移 用更小的状态推出现在这个状态 「到第 i 阶 = 从第 i-1 阶上来 + 从第 i-2 阶上来」
  • 状态决定了存什么;

  • 转移决定了怎么算。

这两步定下来,剩下的初始化、填表顺序、返回值其实都是收尾工作。

为什么要存起来:重叠子问题

如果直接写递归,很多子问题会被重复计算。以斐波那契 f(n) = f(n-1) + f(n-2) 为例,算 f(5) 时的调用关系:

cpp 复制代码
                f(5)
              /      \
          f(4)        f(3)
         /    \       /   \
      f(3)   f(2)  f(2)  f(1)
      /  \
   f(2)  f(1)

可以看到 f(3) 被算了两次,f(2) 被算了三次。n 越大,重复计算的规模会指数级爆炸。而如果第一次算完就把结果记下来,下次直接查表,复杂度立刻从指数级降到线性级------这就是动态规划最核心的价值:用空间换时间,每个状态只算一次。

两种写法,本质是一件事

写法 方向 特点
记忆化搜索(递归 + 缓存) 自顶向下 从大问题往下拆,拆到已知答案再往回返
递推(dp 数组 / 循环) 自底向上 从小状态开始,一路填到目标状态

两者思路完全等价。这篇博客用的是递推写法,因为它的代码更短、更好写,也方便后面做空间优化。


什么时候能用 DP

满足下面两点,基本就可以往 DP 上想:

  1. 重叠子问题:不同的大问题会反复用到同一个小问题的答案;

  2. 最优子结构 / 可递推:大问题的答案能由小问题的答案组合出来(求和、求最值、求方案数都算)。

至于怎么落地成代码,就是我们下面要反复用的五部曲:

状态表示 --> 状态转移方程 --> 初始化 --> 填表顺序 --> 返回值

记住这五步,DP 就不再是「玄学」,而是一套可以照着走完的流程。

一、题目一:1137. 第 N 个泰波那契数

题目解析

题目链接 1137. 第 N 个泰波那契数 - 力扣(LeetCode)

泰波那契序列 T(n) 定义如下:

  • T(0) = 0

  • T(1) = 1

  • T(2) = 1

  • 当 n >= 0 时,T(n + 3) = T(n) + T(n + 1) + T(n + 2)

给定整数 n,返回第 n 个泰波那契数 T(n) 的值。

示例 1

bash 复制代码
输入:n = 4
输出:4
解释:
T(3) = 0 + 1 + 1 = 2
T(4) = 1 + 1 + 2 = 4

示例 2

bash 复制代码
输入:n = 25
输出:1389537

提示

  • 0 <= n <= 37

  • 答案保证是一个 32 位整数,即 answer <= 2^31 - 1

序列前几项:

i 0 1 2 3 4 5 6 7
T(i) 0 1 1 2 4 7 13 24

可以看出,它本质上是斐波那契的三项版:每一项都由前面连续三项相加得到。


算法原理:动态规划五部曲

这道题是最经典的线性 DP 入门题,用状态表示 → 状态转移方程 → 初始化 → 填表顺序 → 返回值这套固定流程来分析,思路会非常清晰。


状态表示

什么是状态表示? 就是用一个 dp 数组(也可以叫 dp 表),dp[i] 到底代表什么含义。

怎么确定 dp[i] 的含义? 有两个来源:

  1. 题目要求 :题目要我们求第 n 个泰波那契数;

  2. 经验 + 题目要求 :结合题目的字面含义,直接用「第 i 个泰波那契数」作为状态。

所以:

dp[i] 表示:第 i 个泰波那契数的值。

这里不用绕弯子------题目问什么,状态就表示什么,这也是最简单、最不容易出错的一种状态定义方式。


状态转移方程

状态转移方程回答的是:dp[i] 等于什么?

根据题目给的递推关系 T(n + 3) = T(n) + T(n + 1) + T(n + 2),把下标整体减 3,就得到:

cpp 复制代码
dp[i] = dp[i - 1] + dp[i - 2] + dp[i - 3]

这一步的关键是从分析问题的过程中发现重复子问题 :想求第 i 项,只需要知道第 i-1、i-2、i-3 项,而这三项又可以继续往下拆,直到拆成已知的 dp[0]、dp[1]、dp[2]。

常见错误:直接套斐波那契写成 dp[i] = dp[i-1] + dp[i-2],漏掉 dp[i-3]。一定要回到题目给的递推式去验证。


初始化

初始化的作用只有一个:保证填表的时候不越界。

dp[i] 依赖前三项,那么 i = 0, 1, 2 这三个位置就没法用递推式算出来,必须提前给定:

cpp 复制代码
dp[0] = 0
dp[1] = 1
dp[2] = 1

对应 dp 表:

cpp 复制代码
下标 i :   0   1   2   3   4   5   6 ...
dp[i]  :   0   1   1   ?   ?   ?   ? ...
                   ↑   ↑
              已知    从 i = 3 开始用递推式填
填表顺序

填表顺序的原则是:为了保证填写当前状态的时候,所需要的状态已经计算过了。

dp[i] 需要用到 dp[i-1]、dp[i-2]、dp[i-3],也就是左边的状态。所以顺序是:

从左往右 (i 从 3 递增到 n)。


返回值

题目要求返回第 n 个泰波那契数,而 dp[n] 正好就是这个含义,所以返回:

cpp 复制代码
return dp[n];

至此,五部曲全部完成,代码几乎是翻译过来的。


基础版代码实现
cpp 复制代码
class Solution {
public:
    int tribonacci(int n) {
        if (n == 0) return 0;
        if (n == 1 || n == 2) return 1;

        vector<int> dp(n + 1);
        dp[0] = 0;
        dp[1] = 1;
        dp[2] = 1;

        for (int i = 3; i <= n; i++) {
            dp[i] = dp[i - 1] + dp[i - 2] + dp[i - 3];
        }
        return dp[n];
    }
};
  • 时间复杂度:O(n)

  • 空间复杂度:O(n)


空间优化:从 O(n) 到 O(1)

观察状态转移方程:

cpp 复制代码
dp[i] = dp[i - 1] + dp[i - 2] + dp[i - 3]

填 dp[i] 时,只会用到紧挨着它的前三个状态,而 dp[i-4] 及更早的状态以后再也不会被用到。既然如此,整个 dp 数组其实是浪费的------我们只需要保存最近的三项就够了。

于是可以用三个变量 a、b、c 分别代表前三个状态,再用 d 表示当前要算的新状态,整体向右滚动:

也就是说,每一轮循环里做的三件事是:

  1. 用前三项算出当前项 d = a + b + c;

  2. 三个指针整体右移:a = b; b = c; c = d;

  3. 循环结束时,d 恰好停留在 T(n) 上。

优化后的代码

cpp 复制代码
class Solution {
public:
    int tribonacci(int n) {
        if (n == 0) return 0;
        if (n == 1 || n == 2) return 1;

        int a = 0, b = 1, c = 1, d = 0;
        for (int i = 3; i <= n; i++) {
            d = a + b + c;
            a = b;
            b = c;
            c = d;
        }
        return d;
    }
};
  • 时间复杂度:O(n)

  • 空间复杂度:O(1)

这也就是常说的滚动数组思想:当状态转移只依赖固定个数的前驱状态时,就可以把数组压缩成几个变量。


二、题目二:面试题 08.01. 三步问题

同样的五部曲,再走一道计数型的线性 DP,体会一下状态表示和初始化是怎么跟着题意变化的。

题目解析

题目链接 面试题 08.01. 三步问题 - 力扣(LeetCode)

三步问题。有个小孩正在上楼梯,楼梯有 n 阶台阶,小孩一次可以上 1 阶、2 阶或 3 阶。实现一种方法,计算小孩有多少种上楼梯的方式。结果可能很大,你需要对结果取模 1000000007。

示例 1

cpp 复制代码
输入:n = 3
输出:4
说明:有四种走法

示例 2

cpp 复制代码
输入:n = 5
输出:13
说明:n = 5 一共有 13 种方法

提示

  • 1 <= n <= 1000000

先把前面几项手动数出来(也可以先写个暴力递归验证):

n 1 2 3 4 5 6 7
方法数 1 2 4 7 13 24 44

规律很明显:从第 4 项开始,每一项都等于前三项之和。


算法原理:动态规划五部曲

状态表示

经验 + 题目要求 :以 i 位置为结尾,想想到达 i 位置时的状态。

dp[i] 表示:到达第 i 阶台阶时,一共有多少种方法。

和泰波那契那题不同,这里的状态含义不是第 i 个数是多少,而是到达第 i 个位置的方案数」------这是计数型 DP 的典型写法。

状态转移方程

以 i 位置的状态,根据「最近的一步」来划分问题 。也就是说:到达第 i 阶的最后一步,只可能是以下三种情况:

  • 最后一步从 i - 1 迈 1 阶上来:有 dp[i - 1] 种方法;

  • 最后一步从 i - 2 迈 2 阶上来:有 dp[i - 2] 种方法;

  • 最后一步从 i - 3 迈 3 阶上来:有 dp[i - 3] 种方法。

这三种情况互不重叠,且覆盖了所有走法,所以直接相加:

cpp 复制代码
dp[i] = dp[i - 1] + dp[i - 2] + dp[i - 3]

公式和泰波那契完全一样,但来路完全不同:泰波那契是「题目直接给的递推式」,这里是「按最后一步划分」推出来的。这个套路在计数类 DP 里非常常用。


初始化

dp[i] 依赖前三项,所以 dp[1]、dp[2]、dp[3] 必须先填好,否则会越界。注意这里的初值不是题目白给的,而是根据语义数出来的:

  • dp[1] = 1:到第 1 阶只有 1 一种走法(走一步 1 阶);

  • dp[2] = 2:1+1、2,共 2 种;

  • dp[3] = 4:1+1+1、1+2、2+1、3,共 4 种。


填表顺序

dp[i] 依赖 i - 1、i - 2、i - 3,都是左边的状态,所以:

从左往右 (i 从 4 递增到 n)。


返回值

题目求的是到达第 n 阶的方法数,正好就是 dp[n]:

cpp 复制代码
return dp[n];

代码实现

cpp 复制代码
class Solution {
public:
    int waysToStep(int n) {
        const int MOD = 1e9 + 7;
        if (n == 1 || n == 2) return n;
        if (n == 3) return 4;

        vector<int> dp(n + 1);
        dp[1] = 1;
        dp[2] = 2;
        dp[3] = 4;

        for (int i = 4; i <= n; i++) {
            dp[i] = ((dp[i - 1] + dp[i - 2]) % MOD + dp[i - 3]) % MOD;
        }
        return dp[n];
    }
};
  • 时间复杂度:O(n)

  • 空间复杂度:O(n)

关于那个两次取模的写法 :三个小于 1e9 + 7 的数直接相加,最大接近 3 * 1e9,会超出 int 的范围(约 2.1e9)。所以先让前两项相加取模,再加第三项取模,保证中间结果始终在 int 内。也可以改成 long long 一次取模。


空间优化

和泰波那契一样,dp[i] 只依赖前三项,同样可以把数组压缩成三个变量:

cpp 复制代码
class Solution {
public:
    int waysToStep(int n) {
        const int MOD = 1e9 + 7;
        if (n == 1 || n == 2) return n;
        if (n == 3) return 4;

        int a = 1, b = 2, c = 4, d = 0;
        for (int i = 4; i <= n; i++) {
            d = ((a + b) % MOD + c) % MOD;
            a = b;
            b = c;
            c = d;
        }
        return d;
    }
};

空间复杂度降到 O(1)。不过本题 n <= 10^6,O(n) 的数组本来就够用,不优化也完全能过。


三、题目三:746. 使用最小花费爬楼梯

前两道都是求数值 / 求方案数,这道开始进入真正的最优化:求最小花费。而且这道题可以用两种完全相反的状态定义来做,正好用来理解状态定义决定了一切。

题目解析

题目链接:746. 使用最小花费爬楼梯 - 力扣(LeetCode)

给你一个整数数组 cost,其中 cost[i] 是从楼梯第 i 个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。

你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。

请你计算并返回达到楼梯顶部的最低花费。

示例 1

cpp 复制代码
输入:cost = [10, 15, 20]
输出:15
解释:从下标 1 的台阶开始,支付 15,向上爬两个台阶,直接到达楼梯顶部。总花费为 15。

示例 2

cpp 复制代码
输入: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。

提示

  • 2 <= cost.length <= 1000

  • 0 <= cost[i] <= 999

关键概念:楼梯顶部在数组之外 。数组下标是 0 ~ n-1,顶部是第 n 级。这一点决定了 dp 数组要开 n + 1 还是 n。


解法一:以 i 位置为结尾(从左往右)

状态表示

经验 + 题目要求,以 i 位置为结尾:

dp[i] 表示:到达 第 i 个位置时的最小花费。

注意到达两个字------它只包含走到这里的钱,不包含从这个台阶继续往上走要付的钱。

状态转移方程

用之前的状态推导 dp[i],根据最近的一步来划分 。要到达第 i 级,最后一步只有两种可能:

  • 从 i - 1 走一步上来:先花 dp[i-1] 到达 i-1,再支付 cost[i-1] 走一步

    cpp 复制代码
    dp[i - 1] + cost[i - 1]
  • 从 i - 2 走两步上来:先花 dp[i-2] 到达 i-2,再支付 cost[i-2] 走两步

    cpp 复制代码
    dp[i - 2] + cost[i - 2]

两种走法都能到达 i,要求最小花费,所以取较小值:

cpp 复制代码
dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2])

注意这里用的是 cost[i-1]、cost[i-2],而不是 cost[i]------因为花的是离开上一个台阶的钱。

初始化

题目说可以从下标 0 或 1 开始,也就是说「站在第 0 / 1 级上」还没花任何钱,所以:

cpp 复制代码
dp[0] = 0
dp[1] = 0

这两个初值同样是为了保证填表时不越界(i = 2 时用不到 dp[-1])。

填表顺序

dp[i] 依赖 dp[i-1]、dp[i-2],所以:

从左往右 (i 从 2 到 n)。

返回值

题目要求到达楼梯顶部,也就是第 n 级:

cpp 复制代码
return dp[n];
代码实现
cpp 复制代码
class Solution {
public:
    int minCostClimbingStairs(vector<int>& cost) {
        int n = cost.size();
        vector<int> dp(n + 1);
        dp[0] = dp[1] = 0;

        for (int i = 2; i < n + 1; i++) {
            dp[i] = min(cost[i - 1] + dp[i - 1], cost[i - 2] + dp[i - 2]);
        }
        return dp[n];
    }
};
  • 时间复杂度:O(n)

  • 空间复杂度:O(n)

因为只依赖前两项,同样可以用两个变量滚动,空间降到 O(1)(解法二同理)。


解法二:以 i 位置为起点(从右往左)

换一个视角:不问「怎么到 i」,而问「从 i 出发还要花多少」。

状态表示

经验 + 题目要求,以 i 位置为起点:

dp[i] 表示:从第 i 个台阶出发,到达楼梯顶部的最小花费。

这个状态里包含了在 i 处支付的 cost[i],因为只要从这里出发就一定要付这笔钱。

状态转移方程

用之后的状态推导 dp[i]。站在 i 上,先支付 cost[i],然后面临两个选择:

  • 走一步到 i + 1:cost[i] + dp[i + 1]

  • 走两步到 i + 2:cost[i] + dp[i + 2]

取较小值,并把公共的 cost[i] 提出来:

cpp 复制代码
dp[i] = min(dp[i + 1], dp[i + 2]) + cost[i]
初始化

最靠右的两级没有「后面两级」可用,得直接给:

cpp 复制代码
dp[n - 1] = cost[n - 1]   // 再走一步就上顶部
dp[n - 2] = cost[n - 2]   // 直接走两步上顶部
填表顺序

dp[i] 依赖右边的状态,所以:

从右往左 (i 从 n - 3 递减到 0)。

返回值

起点可以是 0 或 1,两者取最小:

cpp 复制代码
return min(dp[0], dp[1]);
代码实现
cpp 复制代码
class Solution {
public:
    int minCostClimbingStairs(vector<int>& cost) {
        int n = cost.size();
        vector<int> dp(n);
        dp[n - 1] = cost[n - 1], dp[n - 2] = cost[n - 2];

        for (int i = n - 3; i >= 0; i--) {
            dp[i] = min(dp[i + 1], dp[i + 2]) + cost[i];
        }
        return min(dp[0], dp[1]);
    }
};
  • 时间复杂度:O(n)

  • 空间复杂度:O(n)

两种解法怎么选

解法一(以 i 结尾) 解法二(以 i 为起点)
状态含义 到达 i 的最小花费 从 i 出发的最小花费
转移方向 由 i-1、i-2 推 i 由 i+1、i+2 推 i
填表顺序 从左往右 从右往左
dp 大小 n + 1(含顶部) n(只有台阶)
返回值 dp[n] min(dp[0], dp[1])

两者都对,本质是同一张图的两种遍历方向。以 i 为结尾更贴合到达某处的问法,以 i 为起点更贴合从某处出发的问法。做题时哪种顺就写哪种,但状态定义一旦定了,转移方向、填表顺序、返回值就得全部跟着它走,不能混。


四、三题对比与总结

三道题用同一套模板,但细节上的差异恰好是动态规划里最容易出错的地方:

对比项 1137. 泰波那契数 面试题 08.01. 三步问题 746. 最小花费爬楼梯
类型 递推 / 数列计算 计数 最优化(最小值)
状态表示 dp[i]:第 i 个泰波那契数 dp[i]:到达第 i 阶的方法数 dp[i]:到达第 i 级的最小花费
转移来源 题目直接给出递推式 按「最后一步」划分 按「最近一步」划分后取 min
方程 dp[i-1] + dp[i-2] + dp[i-3] dp[i-1] + dp[i-2] + dp[i-3] min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2])
初始化 0, 1, 1(题目给定) 1, 2, 4(按语义数出来) dp[0] = dp[1] = 0(站在起点不花钱)
填表顺序 从左往右 从左往右 从左往右(倒序写法则从右往左)
取模 不需要 1e9 + 7,注意防溢出 不需要
空间优化 三个变量滚动 三个变量滚动 两个变量滚动

把这三道题放在一起,其实就是在回答动态规划到底怎么想:

  1. 状态表示 :题目问什么,dp[i] 就表示什么;计数题表示到达 i 的方案数,最优化题表示到达 i 的最小 / 最大代价。

  2. 状态转移方程 :数列题直接翻译递推式;计数题按最近一步划分后相加;最优化题按最近一步划分后取 min / max。

  3. 初始化:为了保证填表不越界,初值要么题目给,要么按语义数;同时初值本身的含义要说得清(比如站在起点不花钱)。

  4. 填表顺序:依赖左边 → 从左往右;依赖右边 → 从右往左。

  5. 返回值 :对应题目所求的那个状态;有多个合法起点时记得取 min。

  6. 空间优化 :只依赖固定个数的前驱 → 滚动数组,O(n) → O(1)。

相关推荐
jimy11 小时前
C++ 报错的 qualifiers 的意思
开发语言·c++
倍利福猎头公司官方账号1 小时前
大企业HR如何向猎头公司清晰传达AI岗位的技术需求?
人工智能·算法·机器人·求职招聘·业界资讯
揽星河..2 小时前
【C++】类和对象
c++·算法
D e re k ZH2 小时前
C++ 并发、未定义行为与工程化难点(No 3)
数据结构·c++·算法·面试
释厄6233 小时前
同性线套娃律——圆面积必然=1+2+...+n 的求和·圆心不与 π 相关
java·人工智能·算法·1024程序员节
暴力求解3 小时前
C++ ---继承(二)
开发语言·c++·算法
码林拾遗WJ4 小时前
告警分级的自动化实测:模型判断 vs 人工拍板
人工智能·算法·自动化
Tisfy4 小时前
LeetCode 0453.最小操作次数使数组元素相等:两次遍历(反向思维)
算法·leetcode·题解·遍历·反向思维
君万4 小时前
【LeetCode每日一题】回溯:46. 全排列 78. 子集 39. 组合总和
算法·leetcode·go