🏭

滚动数组:给 DP 表减肥

DP 表像传送带:算一格只用到「上一行」,后面的行可以丢进回收站。表不用整张存——滚着算、原地覆盖,空间从 O(n×m) 降到 O(m) 甚至 O(1),答案一分不少。

💡 打个比方

工厂的传送带上只放得下两个托盘:「上一排」和「当前排」。工人算当前排时只抬头看上一排——上一排用完后就滑进回收站(内存释放),新排写到同一块板上。DP 表就是这种传送带:只要算第 i 行最多用到第 i-1 行,整张表就可以换成「1~2 行」反复覆盖。斐波那契只依赖前两项 → 连 1 行都不用,2 个变量轮着用;0/1 背包、网格路径、LCS 都能滚。滚动的关键:覆盖的顺序不能把「还要用的旧值」碾掉——0/1 背包容量要倒着推(后面细说)。

术语:滚动数组(只保留必要的行/项、原地覆盖的数组);空间优化(答案不变、省内存);正推/倒推(循环方向,决定用的是新值还是旧值);原地覆盖(新值写进旧格子)。

🖼️ 大图看明白

滚动数组:传送带只留「上一排 + 当前排」——空间 O(n×m) → O(m) 第 n-2 行 ♻ 已回收 第 n-1 行 保留(参考用) 第 n 行 正在算(覆盖旧行) 算完一行 → 旧行滑进回收站,新行写回同一块板 1 1 1 1 1 2 3 4 1 3 6 10 1 4 10 20 网格路径表:算第 3 行只用第 2 行 + 当前行左侧 → 1 行就够 内存对比 整张表 O(n×m):16 格 滚动 O(m):1 行 4 格 答案不变:仍是最后一格 20(格子路径数)

🧩 子任务 1:什么时候能滚?——看依赖

🎯 小目标:拿到一个 DP 转移式,会判断「算当前行是否最多只用到上一行」→ 能滚;并知道要留几行。
// 逐个看转移式:算 dp[i][*] 会不会用到「更早的行」?
// 1) 斐波那契  f[i] = f[i-1] + f[i-2]   → 只依赖前 2 项 → 能滚(滚到 2 个变量)
// 2) 0/1 背包  dp[i][v] = max(dp[i-1][v], dp[i-1][v-w[i]] + val[i])
//              → 只用第 i-1 行(上一行)→ 能滚(1 行 → 一维数组)
// 3) 网格路径  dp[i][j] = dp[i-1][j] + dp[i][j-1]
//              → 上一行 + 当前行左侧(刚算好的)→ 能滚(1 行,正推)
// 4) LCS      dp[i][j] 用到 dp[i-1][j]、dp[i][j-1]、dp[i-1][j-1]
//              → 上一行、当前行左侧、对角(上一行前一列)→ 能滚(1 行 + 1 个临时变量)
// 结论:算当前行「最多只用到上一行」→ 整张表可以换成 1~2 行反复覆盖 = 滚动数组。
// 若用到「上上行的更早行」(行差为 2),就要留 2 行轮着用;
// 留几行 = 转移式引用的「最大行差」。
// 反例:区间 DP(合并石子)dp[i][j] 要用 dp[i][k] 和 dp[k+1][j]——
// 这两个是「同一行里的更短区间」,不是「上一行」,一维简单滚不适用(进阶,先不纠结)。
✅ 小检查:① 网格路径为什么留 1 行就够(不需要 2 行)?② 若某 DP 用到「上上行」(dp[i-2][j] + dp[i-1][j]),要留几行?③ 「滚动」和「只算答案、根本不存表」的区别是什么?
看看答案 ① 算第 i 行时只用第 i-1 行(旧值)和当前行左侧(新值)——旧行用完即弃,一行原地覆盖即可。 ② 2 行(A 行、B 行轮着写:当前算 B,参考 A,算完 A 变成新的参考)。留几行 = 最大行差。 ③ 滚动是「把表压缩成 1~2 行存下来继续算」;只算答案不存表是「手推/递归」。滚动后仍能输出最后一行全部信息(如背包每格的最大价值),手推一般只能拿一个数。
⚠️ 易错:滚动前先看依赖——转移式引用了「更早的行」,1 行就不够;要留的行数 = 引用的最大行差。别一上来就改成一维。

🧩 子任务 2:斐波那契滚动——两个变量轮着用

🎯 小目标:用 2 个变量算出第 N 个斐波那契数(空间 O(1)),理解「先算新值、再滑动旧值」的覆盖顺序。
// 斐波那契:f[1]=1, f[2]=1, f[i] = f[i-1] + f[i-2]
// 全表版:int f[10005]; for (i=3..n) f[i]=f[i-1]+f[i-2];  空间 O(n)
// 滚动版:只留 2 个变量——a = 前一项,b = 前前项
int a = 1, b = 1;          // a = f[2],b = f[1]
if (n == 1 || n == 2) { cout << 1; return 0; }
int c;
for (int i = 3; i <= n; i++) {
    c = a + b;             // 新值 = 前一项 + 前前项
    b = a;                 // 「前前项」往前滑一步(先挪旧的)
    a = c;                 // 「前一项」滑到新位置
}
cout << a;                 // 循环结束后 a 就是 f[n]

// 手推 n = 6:起点 (a,b) = (1,1)
// i=3: c=2 → (a,b) = (2,1)   → f[3] = 2
// i=4: c=3 → (a,b) = (3,2)   → f[4] = 3
// i=5: c=5 → (a,b) = (5,3)   → f[5] = 5
// i=6: c=8 → (a,b) = (8,5)   → f[6] = 8 ✓
✅ 小检查:① 循环每转完一圈,a 和 b 分别装着什么?② 为什么不能写成 a = c; b = a;(两行顺序互换)?③ 算 f[10000]:全表版用多少格,滚动版用几个变量?
看看答案 ① a = f[i](刚算的),b = f[i-1]——每转一圈两个「角色」整体往前滑一步,永远只差一项。 ② 先写 a = c 会把旧的 a(f[i-1])碾掉,再 b = a 就把新值塞给了 b;下一圈 c = a + b = f[i] + f[i],错。口诀:先算新值 c,再挪旧值(b = a; a = c)。 ③ 全表 10000 格(O(n))vs 滚动 3 个变量(O(1),含临时 c)。
⚠️ 易错:滚动的经典坑 = 覆盖顺序——把「后面还要用的旧值」先碾掉了。斐波那契里旧 a 在 b=a 之前必须先用完;记住「新值先落 c,旧值再滑动」。

🧩 子任务 3:0/1 背包一维版——为什么容量要倒着推

🎯 小目标:理解一维 0/1 背包 f[v] = max(f[v], f[v-w]+val) 中 v 必须从大到小;能手推演示「正推会重复拿同一件物品」。
// 二维回顾(六级):dp[i][v] = max(dp[i-1][v], dp[i-1][v-w[i]] + val[i])
// 一维滚动:用一个数组 f[v] 原地覆盖,公式形状不变:
for (int i = 1; i <= n; i++)
    for (int v = V; v >= w[i]; v--)      // 关键:v 从大到小(倒着推)
        f[v] = max(f[v], f[v - w[i]] + val[i]);

// 手推演示:只有 1 件物品 A(w=2, val=3),容量 V=5,f 初始全 0
// —— 正推 v = 2 → 5(错):
v=2: f[2] = max(0, f[0]+3) = 3   // f[0] 是旧值 ✓
v=3: f[3] = max(0, f[1]+3) = 3   // f[1] 是旧值 ✓
v=4: f[4] = max(0, f[2]+3) = 6   // f[2] 刚被更新成 3(新值)→ A 被拿了 2 次 ✗
v=5: f[5] = max(0, f[3]+3) = 6   // f[3] 也是新值 → A 又拿了 2 次 ✗
// —— 倒推 v = 5 → 2(对):
v=5: f[5] = max(0, f[3]+3) = 3   // f[3] 还没轮到,仍是旧值 0 ✓
v=4: f[4] = max(0, f[2]+3) = 3   // f[2] 仍是旧值 0 ✓
v=3: f[3] = max(0, f[1]+3) = 3
v=2: f[2] = max(0, f[0]+3) = 3   // A 只拿 1 次 ✓
// 同一件物品:正推 →「无限次拿」(完全背包);倒推 →「最多拿 1 次」(0/1 背包)。
✅ 小检查:① 算 f[v] 时用到的 f[v-w]「旧值」是什么意思?② 为什么「正推」恰好变成了完全背包的转移?③ 两件物品 A(w=2,val=3)、B(w=3,val=4),V=5,一维倒推版答案 = ?(手推)
看看答案 ① 「不拿第 i 件」时、只用前 i-1 件物品、容量 v-w 能达到的最大价值——也就是二维表里的第 i-1 行。一维里它是「还没被覆盖的旧格子」。 ② 正推时 f[v-w](v-w < v)已经在本轮被更新,里面已经含了第 i 件 → 再拿一次 → 第 i 件可以反复拿 = 完全背包。倒推时 f[v-w] 还没轮到 → 不含第 i 件 → 最多拿一次。 ③ 先处理 A(倒推):f[2]=f[3]=f[4]=f[5]=3。再处理 B(w=3,val=4),v=5→3:v=5: f[5]=max(3, f[2]+4=7)=7;v=4: f[4]=max(3, f[1]+4)=4;v=3: f[3]=max(3, f[0]+4)=4。答案 f[5]=7(A+B 都拿,重量 5 价值 7)。
⚠️ 易错:容量循环方向写反 = 背包类型换掉。0/1 背包 = 倒推(每件最多一次);完全背包 = 正推(每件任意次)。转移式一模一样,只差内层循环方向。另外 v 的下限是 w[i](容量装不下就不用试)。

🧩 子任务 4:二维 → 一维——网格路径(正推)与 LCS(对角变量)

🎯 小目标:会写两种一维滚动:网格路径 f[j] = f[j] + f[j-1](正推即可);LCS 一维 + 临时变量 diag 保存对角旧值。
// ① 网格路径(4×4 点阵,只能向右/向下):dp[i][j] = dp[i-1][j] + dp[i][j-1]
// 一维:f[j] = f[j] + f[j-1],j 正推 1→m。这里「被覆盖的旧值」正是需要的上一行,
// f[j-1] 新值正是当前行左侧——方向与 0/1 背包相反!
// 手推(f 初始 = 第 0 行 = [1,1,1,1]):
// 第 1 行:f[1]=1+1=2 → f[2]=1+2=3 → f[3]=1+3=4   → [1,2,3,4]
// 第 2 行:f[1]=2+1=3 → f[2]=3+3=6 → f[3]=4+6=10 → [1,3,6,10]
// 第 3 行:f[1]=3+1=4 → f[2]=6+4=10 → f[3]=10+10=20 → [1,4,10,20]
// 答案 f[3] = 20 ✓(与二维表最后一格一致)

// ② LCS 一维滚动:需要 上=旧f[j]、左=新f[j-1]、对角=旧f[j-1]
// 旧 f[j-1] 会被新值碾掉 → 用变量 diag 提前存住(= 上一列的旧值)
for (int i = 1; i <= n; i++) {
    int diag = 0;                     // 每行开头:第 0 列边界 = 0
    for (int j = 1; j <= m; j++) {
        int old = f[j];               // 存住旧 f[j](下一列的对角)
        if (A[i-1] == B[j-1]) f[j] = diag + 1;
        else f[j] = max(f[j], f[j-1]); // 旧 f[j]=上,f[j-1]=当前行左
        diag = old;                   // 下一列的对角 = 本列旧值
    }
}
// 手推 A="AB", B="BA":初始 f=[0,0,0]
// i=1('A'):j=1 不同 f[1]=max(0,0)=0;j=2 同 f[2]=0+1=1 → [0,0,1]
// i=2('B'):j=1 同 f[1]=0+1=1;j=2 不同 f[2]=max(旧1, 左1)=1 → [0,1,1]
// 答案 f[2] = 1 ✓(LCS("AB","BA") = 1)
✅ 小检查:① 网格路径为什么可以正推(和 0/1 背包方向相反)?② LCS 一维里变量 diag 存的是什么?③ 为什么每个 i(每行)开头要把 diag 重置成 0?
看看答案 ① 算 f[j] 需要「上一行的 f[j]」(= 覆盖前的旧值)和「当前行左侧 f[j-1]」(= 刚算的新值)——要碾掉的旧值正好是上一行,正推天然满足。0/1 背包要的是「旧 f[v-w]」,正推把它碾了,才必须倒推。 ② diag = 上一列的旧 f[j-1] = 转移式里的对角 dp[i-1][j-1]。 ③ 每行第 1 列(j=1)的对角是 dp[i-1][0],边界列全 0——所以行首 diag=0,等于「接住」这个边界值;不重置的话会带上上一行残留的旧对角。
⚠️ 易错:滚动的方向由「转移式需要哪个旧值」决定——需要「还没被覆盖位置」的旧值 → 朝远离方向推(背包倒推);被覆盖的旧值正是需要的 → 正推(网格)。动手前先问一句「我要哪个旧值」,再定方向。

💻 完整代码:0/1 背包一维滚动数组版

// 0/1 背包(一维版):n 件物品,背包容量 V,第 i 件重量 w[i]、价值 val[i],
// 每件最多拿 1 件,求能装下的最大总价值。空间 O(V)(六级二维版是 O(n×V))。
#include <iostream>
#include <algorithm>
using namespace std;

int w[1005], val[1005], f[10005];   // f[v]:容量 v 能达到的最大价值(一维,滚动)

int main() {
    int n, V;
    cin >> n >> V;
    for (int i = 1; i <= n; i++) cin >> w[i] >> val[i];

    for (int i = 1; i <= n; i++)
        for (int v = V; v >= w[i]; v--)        // 倒推:0/1(每件最多一次)
            f[v] = max(f[v], f[v - w[i]] + val[i]);

    cout << f[V] << endl;
    return 0;
}

// 样例:n=2, V=5;物品 (2,3)、(3,4) → 输出 7(两件都拿,重量 5 价值 7)
// 对照:内层若改正推(v = w[i]; v <= V; v++)→ 变成完全背包(物品可反复拿)。

✋ 你来试试

第 1 题:斐波那契 f[1]=f[2]=1,f[10] = ? 55——1, 1, 2, 3, 5, 8, 13, 21, 34, 55。滚动版:2 个变量滑 8 圈,a 最后落在 55。
第 2 题:0/1 背包,1 件物品 (w=3, val=5),V=7,答案 = ? 5——容量够装 2 件(6 ≤ 7),但 0/1 每件只能拿 1 件,所以只拿 1 件。倒推后 f[3..7] 全是 5。
第 3 题:完全背包(正推),同一件物品 (w=3, val=5),V=7,答案 = ? 10——正推时 f[6] = f[3] + 5,而 f[3] 已含这件物品 → 可以拿 2 件(重量 6 价值 10);第 3 件装不下(9 > 7)。
第 4 题(加餐):3×3 点阵(每边 3 个点),左上角到右下角、只向右或向下,路径数 = ? 6——每步 2 右 + 2 下,从 4 步里挑 2 步向右:C(4,2) = 6。一维滚动:初始 f=[1,1,1];第 1 行 [1,2,3];第 2 行 [1,3,6]。f[2]=6 ✓。
🎯 一句话记住:滚动数组 = 转移式「只依赖上一行/前一项」时原地覆盖省内存:0/1 背包一维 f[v]=max(f[v], f[v-w]+val),容量倒推(倒推留旧值=0/1,正推碾旧值=完全背包);网格路径正推(旧值正是上一行);LCS 一维 + 变量 diag 存对角。答案不变,空间大减。