🪜

动态规划入门与一维 DP

别重复思考——把答案写进表格,后面的题直接"抄作业"

💡 打个比方

你每天做同一类数学题,有两种做法:暴力法——每道都从头算,哪怕昨天刚算过一模一样的;聪明法——准备一个作业本(一张表),把算过的答案抄在本子上,遇到重复的子题直接抄作业。第 5 题要用到第 4 题和第 3 题的结果?本子上现成的,拿过来用。动态规划(DP,Dynamic Programming)就是"作业本算法":把大问题拆成小问题,小问题的答案记在数组里,大问题站在小问题的肩膀上。

DP 不神秘,它只回答三个问题:① 表格每格是什么意思(状态)?② 每格怎么从前面的格子算出来(转移)?③ 最开始的格子填什么(边界)?——"一问含义、二问转移、三问边界",本课全部围绕这"三问"展开。

动态规划(DP):把问题拆成重叠子问题,用数组(表)记录子问题答案:先定状态(dp[i] 的含义),再写转移方程(dp[i] 怎么由前面的格算出),最后定边界(最小的几个格手算)。自底向上填表,避免重复计算。

🖼️ 大图看明白

左边是暴力递归的"重复思考"(fib(3) 被算了两次,红的就是重复的);右边是 DP 的"填表"(每格只算一次,后面的格直接抄前面的格)。

暴力递归:同一子题反复算 fib(5) fib(4) fib(3) fib(3) fib(2) fib(2) fib(1) fib(3) 被算了两次 —— 同一子题重复劳动 fib(5)→fib(4)+fib(3)→继续拆……总调用约 2ⁿ 量级 n=40 时暴力递归基本"等死"(2⁴⁰ ≈ 万亿) DP 填表:每格只算一次 i=11 i=22 i=33 i=45 i=58 f(5) = f(4) + f(3) = 5 + 3 = 8 从左到右逐格填:前两格就绪 → 本格可算 每格只算一次,不重复思考,总共 n 步 DP 核心 = 把"重复思考"换成"填表":状态 + 转移 + 边界

🧩 子任务 1:斐波那契的三种写法(暴力 → 抄作业 → 填表)

🎯 小目标:看懂同一道题的三种解法,说清每种快慢的原因

f(1)=1,f(2)=1,f(n)=f(n-1)+f(n-2)。写法一:裸递归——照定义写,简洁但慢(重复算):

int fib(int n) {
    if (n <= 2) return 1;
    return fib(n - 1) + fib(n - 2);   // 同一子题反复算
}

写法二:记忆化——算过的存进数组,下次直接"抄作业"(自顶向下):

int memo[64] = {0};
int fib(int n) {
    if (n <= 2) return 1;
    if (memo[n]) return memo[n];      // 算过 → 抄作业
    return memo[n] = fib(n - 1) + fib(n - 2);
}

写法三:自底向上填表——从小到大逐格填,一个数组搞定(本课主线):

int fib(int n) {
    int a = 1, b = 1;              // f(1), f(2):边界
    for (int i = 3; i <= n; i++) {
        int c = a + b;             // 转移:f(i) = f(i-1) + f(i-2)
        a = b; b = c;
    }
    return n <= 2 ? 1 : b;
}
考点:裸递归 O(2ⁿ) 慢在重复子问题;记忆化和填表都是 O(n)。填表(自底向上)是 GESP 考场上最常用的一维 DP 姿势。
✅ 小检查:裸递归算 fib(5),fib(2) 会被算几遍?填表法算 fib(5) 要做几次加法?
看看答案
fib(2) 被算 3 遍(fib(5)→fib(4)→fib(2) 一遍、fib(4)→fib(3)→fib(2) 一遍、fib(3)→fib(2) 一遍)。填表法只做 3 次加法(f(3)、f(4)、f(5) 各一次)——同样的答案,重复劳动少得多。

🧩 子任务 2:DP 三要素——"三问"口诀

🎯 小目标:拿到任何 DP 题,能按"一问含义、二问转移、三问边界"拆题

做 DP 题就像填一张表,动手前先问三句:

  1. 一问含义(状态):dp[i] 到底是什么意思?必须一句话说明白,比如"爬到第 i 级台阶的方案数"。含义含糊 = 全盘皆输。
  2. 二问转移(方程):dp[i] 怎么从前面已算好的格子推出?通常分情况讨论(最后一步怎么走 / 拿不拿这件东西),每种情况算一个候选,取和或取 max。
  3. 三问边界(初始值):最小的几个格子(dp[0]、dp[1]……)直接手算填好,让后面的格子有"地基"。

三问答完,代码几乎是自动的:for 循环从小到大填表。如果转移里用到了还没算的格子——说明状态定义错了,回去重新问第一句。

考点:一维 DP 三要素 = 状态定义 + 转移方程 + 边界。自底向上(填表)顺序必须保证"算 dp[i] 时,dp[i-1]、dp[i-2] 已经算好"。
✅ 小检查:题目"n 级台阶,每次 1 或 2 级,多少种走法",请按三问各写一句答案(不写代码)。
看看答案
一问:dp[i] = 到达第 i 级台阶的方案数。二问:最后一步要么跨 1 级(从 i-1 来)要么跨 2 级(从 i-2 来),dp[i] = dp[i-1] + dp[i-2]。三问:dp[1] = 1,dp[2] = 2(一级只有一种;两级有 1+1 和 2 两种)。

🧩 子任务 3:爬楼梯——把"三问"走一遍完整流程

🎯 小目标:独立完成爬楼梯 DP 的状态、转移、边界和填表

题目:n 级台阶,每次跨 1 级或 2 级,问爬到顶的走法数。三问:状态 dp[i] = 爬到第 i 级的方案数;转移 dp[i] = dp[i-1] + dp[i-2](最后一步跨 1 级或跨 2 级,两类走法互不重叠、合起来是全部);边界 dp[1] = 1,dp[2] = 2。填表(n=10):

i :      1   2   3   4   5   6   7   8   9   10
dp[i]:   1   2   3   5   8  13  21  34  55  89

验证小 n:n=3 时,dp[3] = dp[2] + dp[1] = 3,枚举核对:1+1+1、1+2、2+1,正好 3 种 ✓。n=4:dp[4] = 5,枚举:1111、112、121、211、22,正好 5 种 ✓。小例子数得过来,就敢信大例子。

考点:爬楼梯是 DP 的"Hello World"。转移的论证要点是分类完整且互斥:最后一步只有"跨 1 级"和"跨 2 级"两种可能,方案数相加不会重不漏。
✅ 小检查:① dp[6] = ?② n=2 时枚举所有走法,和 dp[2] 对得上吗?
看看答案
① dp[6] = dp[5] + dp[4] = 8 + 5 = 13。② n=2 的走法只有 1+1 和 2,共 2 种,dp[2] = 2 ✓(边界就是直接枚举的结果)。

🧩 子任务 4:打家劫舍——max 型转移

🎯 小目标:会做"不能全选"的 max 型一维 DP(拿/不拿两种情况取大)

题目:一排 6 家,钱分别是 3、4、2、7、9、1 元,相邻两家不能同时偷,最多偷多少?三问:状态 dp[i] = 只看前 i 家时能偷到的最大金额;转移:第 i 家不偷 → 金额 = dp[i-1];偷 → 第 i-1 家必须空着,金额 = dp[i-2] + w[i];两情况取大:dp[i] = max(dp[i-1], dp[i-2] + w[i]);边界:dp[0] = 0,dp[1] = w[1] = 3。填表:

家 i :    0   1   2   3   4   5   6
钱 w  :       3   4   2   7   9   1
dp[i]:    0   3   4   5  11  14  14

读表:dp[4] = max(5, 4+7) = 11(偷 1、4 两家:3+7);dp[5] = max(11, 5+9) = 14(偷 1、3、5 家:3+2+9);dp[6] = max(14, 11+1) = 14(第 6 家钱太少,不如不偷)。答案 14 元。注意这是 max 型转移(优化问题),和斐波那契/爬楼梯的求和型转移(计数问题)是 DP 的两大门派:计数相加,优化取大。

易错点:① "不偷"分支是 dp[i-1],不要再加 w[i](那等于偷了还说不偷)。② "偷"分支必须跳一家用 dp[i-2],用 dp[i-1] 就可能和 i-1 家撞车。③ dp[0] = 0 别忘——它是"偷第 1 家"情况的地基。
✅ 小检查:① dp[3] 为什么是 5 而不是 3+2=5 和 max(4, 0+2)=4 里更小的那个?② 如果第 6 家的钱是 100,dp[6] 变多少?
看看答案
① dp[3] = max(dp[2], dp[1]+w[3]) = max(4, 3+2) = 5:不偷第 3 家最多 4,偷第 3 家(跳过第 2 家)3+2=5,取大 = 5。② dp[5] 仍是 14;dp[6] = max(14, dp[4]+100) = max(14, 111) = 111(偷 1、4、6 家:3+7+100)。

💻 完整代码

一维 DP 两道经典题:爬楼梯(求和型)+ 打家劫舍(max 型)。

// 一维 DP:爬楼梯 + 打家劫舍
#include <bits/stdc++.h>
using namespace std;

int main() {
    // 1) 爬楼梯:n 级台阶,每次 1 或 2 级,方案数
    int n = 10;
    vector<int> f(n + 1);
    f[1] = 1; f[2] = 2;                     // 边界
    for (int i = 3; i <= n; i++)
        f[i] = f[i - 1] + f[i - 2];         // 转移(求和型)
    printf("爬 n=%d 级: %d 种\n", n, f[n]);  // 89

    // 2) 打家劫舍:相邻不能同时偷,最大金额
    int w[] = {0, 3, 4, 2, 7, 9, 1};        // 1 开始编号
    int m = 6;
    vector<int> dp(m + 1, 0);
    dp[0] = 0;
    dp[1] = w[1];                            // 边界
    for (int i = 2; i <= m; i++)
        dp[i] = max(dp[i - 1], dp[i - 2] + w[i]);  // 转移(max 型)
    printf("最多偷: %d 元\n", dp[m]);        // 14(偷 1、3、5 家)
    return 0;
}
易错点:① 数组开 n+1、从 1 编号,边界格先填再进 for。② 求和型 DP 数值增长快,n 大时可能爆 int(GESP5 学过高精度,这里先记住风险)。③ 抄转移方程前先写清"状态含义"——含义错,方程必错。

✋ 你来试试

第 1 题:爬楼梯 n=6,方案数是多少?(先按三问列式,再算)
状态 dp[i]=到第 i 级的方案数;转移 dp[i]=dp[i-1]+dp[i-2];边界 dp[1]=1、dp[2]=2。填表:dp[3]=3、dp[4]=5、dp[5]=8、dp[6]=13。答案 13。
第 2 题:打家劫舍,钱改成 w = [5, 1, 1, 5](1 开始编号),最多偷多少?偷哪几家?
dp[0]=0、dp[1]=5、dp[2]=max(5,1)=5、dp[3]=max(5, 5+1)=6、dp[4]=max(6, 5+5)=10。答案 10 元,偷第 1 家和第 4 家(5+5,中间空两家正好合法)。
第 3 题:为什么爬楼梯是"求和型"转移、打家劫舍是"max 型"转移?各对应哪类问题?
爬楼梯数"有多少种走法":最后一步的两类走法互不重叠,方案数相加才是总数(计数问题)。打家劫舍求"最多偷多少":两种选择里只要更好的那个,取 max(优化问题)。一句话:数方案用加法,求最值用 max。
🎯 一句话记住:DP = 填表抄作业,不重复思考;三问:一问状态含义、二问转移方程、三问边界;计数相加、优化取大。