来源:算法讲解067【必备】从递归入手二维动态规划 · 2026-11-01
依据:无字幕,部分内容由我补充(已标注)
二维动态规划(2D DP)就是状态由两个维度(通常是位置/长度/两个序列的下标)确定的 DP,从递归暴力搜索出发,加记忆化(memoization)消除重复子问题,再改写成自底向上的填表;核心是状态定义 + 转移方程 + 边界 + 遍历顺序四件事。
共同特征:答案依赖两个独立变化的量,且大问题由小问题组合而来。
dp[i][j],含义必须能用一句话说清,例如"s1 前 i 个字符和 s2 前 j 个字符的最长公共子序列长度"。return 里那几行 min/max/求和。dp[i][j] 时它依赖的格子已经算好。i 从小到大、j 从小到大填。dp[i][*] 只依赖 dp[i-1][*],可把第一维压成 2 行甚至 1 行。为什么记忆化是对的:递归暴力会重复计算同一子问题(比如 LCS 里 (i,j) 会被多条路径到达)。子问题的答案只由 (i,j) 决定,与"怎么到达这里"无关——这叫无后效性。所以第一次算完存起来,之后直接查表,正确性不变,复杂度从指数降到"状态数 × 单状态转移代价"。
为什么自底向上是对的:把状态看成有向无环图(DAG),边是"大状态依赖小状态"。只要按拓扑序填表,填每个格子时依赖的格子都已就绪,结果与记忆化完全一致。二维 DP 里"拓扑序"通常就是按 i 递增、j 递增(或按区间长度递增)。
为什么二维:因为问题有两个自由维度。比如 LCS 中"s1 处理到哪"和"s2 处理到哪"互相独立,必须同时记录,否则信息丢失。
直觉模板:dp[i][j] = 若干 dp[i-1][j-1] / dp[i-1][j] / dp[i][j-1] 的组合,边界是 i=0 或 j=0 那一行/列。
f(i, j) 的含义(一句话),写出终止条件和所有分支的 return。f(i,j) 会被多次调用(画递归树或数状态数)。memo[i][j],初值设为"未计算"哨兵(如 -1 或 INF),进入函数先查表。i ∈ [0, n]、j ∈ [0, m],据此定数组大小(多开 1~2 格防越界)。f(i,j) 的 return 改写成 dp[i][j] = ...,按依赖方向定遍历顺序,先填边界。fill 或重新 assign,别用 memset 对 vector<vector<long long>> 乱搞。最长公共子序列(LCS):s1 = "abcde",s2 = "ace",答案 3("ace")。
状态:dp[i][j] = s1 前 i 个字符与 s2 前 j 个字符的 LCS 长度。
转移:
s1[i-1] == s2[j-1]:dp[i][j] = dp[i-1][j-1] + 1dp[i][j] = max(dp[i-1][j], dp[i][j-1])边界:dp[0][*] = dp[*][0] = 0。
填表(行 = i,列 = j,s2 = "ace"):
| i\j | 0 | 1(a) | 2(c) | 3(e) |
|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 |
| 1(a) | 0 | 1 | 1 | 1 |
| 2(b) | 0 | 1 | 1 | 1 |
| 3(c) | 0 | 1 | 2 | 2 |
| 4(d) | 0 | 1 | 2 | 2 |
| 5(e) | 0 | 1 | 2 | 3 |
每步变化:(1,1) 字符 a==a → dp[0][0]+1=1;(2,2) 字符 b≠c → max(dp[1][2], dp[2][1]) = max(1,1)=1;(3,3) 字符 c==c → dp[2][2]+1=2;(5,3) 字符 e==e → dp[4][2]+1=3。答案 dp[5][3]=3。
Python(记忆化递归,好读)
import sys
from functools import lru_cache
def lcs(s1: str, s2: str) -> int:
n, m = len(s1), len(s2)
@lru_cache(maxsize=None)
def f(i: int, j: int) -> int:
# s1 前 i 个字符 与 s2 前 j 个字符 的 LCS 长度
if i == 0 or j == 0:
return 0
if s1[i - 1] == s2[j - 1]:
return f(i - 1, j - 1) + 1
return max(f(i - 1, j), f(i, j - 1))
return f(n, m)
if __name__ == "__main__":
print(lcs("abcde", "ace")) # 3
Python(自底向上,用于对照)
def lcs_dp(s1: str, s2: str) -> int:
n, m = len(s1), len(s2)
dp = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(1, m + 1):
if s1[i - 1] == s2[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
return dp[n][m]
C++(竞赛风,可直接抄)
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string s1, s2;
cin >> s1 >> s2;
int n = (int)s1.size(), m = (int)s2.size();
// dp[i][j] = s1 前 i 个字符 与 s2 前 j 个字符 的 LCS 长度
// 用 vector,多测时重新 assign 即可,避免 memset 对 vector 的坑
vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= m; ++j) {
if (s1[i - 1] == s2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
cout << dp[n][m] << '\n';
return 0;
}
O(n·m),单状态转移 O(1)(LCS/编辑距离/网格路径)→ O(n·m)。最好/平均/最坏都是 O(n·m)(与数据内容无关)。区间 DP 是 O(n^3)(多一层枚举分割点)。O(n·m);滚动数组可压到 O(min(n,m))。1e3 × 1e3 = 1e6 状态轻松过;1e5 × 1e5 不可能,看到 n,m ≤ 5000 的 LCS 要警惕(2.5e7 状态,int 数组约 100MB,需滚动数组 + 卡常)。1e5~2e6 规模通常要求一维 DP 或特殊性质(如 LIS 的 O(n log n))。n×m,问从 (0,0) 到 (n-1,m-1) 的路径数/最小代价/最大收益,有障碍或代价 → 网格 DP。dp[l][r])。O(1) → 直接二维 DP;若某一维 ≤ 20 可考虑状压。模板 1:通用二维 DP 骨架(以 LCS 为例,含滚动数组版)
#include <bits/stdc++.h>
using namespace std;
// 二维 DP 通用骨架:先写递归 -> 记忆化 -> 填表
// 这里给 LCS 的滚动数组版,空间 O(min(n,m))
int lcs(const string& s1, const string& s2) {
int n = (int)s1.size(), m = (int)s2.size();
// 让 s2 是较短的那个,滚动数组更省
if (n < m) return lcs(s2, s1);
vector<int> prev(m + 1, 0), cur(m + 1, 0);
for (int i = 1; i <= n; ++i) {
cur[0] = 0;
for (int j = 1; j <= m; ++j) {
if (s1[i - 1] == s2[j - 1]) {
cur[j] = prev[j - 1] + 1;
} else {
cur[j] = max(prev[j], cur[j - 1]);
}
}
swap(prev, cur);
}
return prev[m];
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string a, b;
cin >> a >> b;
cout << lcs(a, b) << '\n';
return 0;
}
模板 2:网格最小路径和(含障碍,滚动数组)
#include <bits/stdc++.h>
using namespace std;
const int INF = 0x3f3f3f3f;
// grid[i][j] = 0 表示可走,1 表示障碍;求 (0,0)->(n-1,m-1) 最小代价
int minPathSum(vector<vector<int>>& grid) {
int n = (int)grid.size(), m = (int)grid[0].size();
vector<int> dp(m, INF);
for (int i = 0; i < n; ++i) {
for (int j = 0; j < m; ++j) {
if (grid[i][j] == 1) { dp[j] = INF; continue; } // 障碍
if (i == 0 && j == 0) { dp[j] = grid[i][j]; continue; }
int fromUp = (i > 0) ? dp[j] : INF; // 上一行同列(未更新前是 i-1 行)
int fromLeft = (j > 0) ? dp[j - 1] : INF; // 本行左列(已更新)
dp[j] = min(fromUp, fromLeft) + grid[i][j];
}
}
return dp[m - 1] >= INF ? -1 : dp[m - 1];
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> n >> m;
vector<vector<int>> g(n, vector<int>(m));
for (auto& row : g) for (auto& x : row) cin >> x;
cout << minPathSum(g) << '\n';
return 0;
}
模板 3:区间 DP 骨架(石子合并,dp[l][r])
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<int> a(n + 1), pre(n + 1, 0);
for (int i = 1; i <= n; ++i) { cin >> a[i]; pre[i] = pre[i - 1] + a[i]; }
// dp[l][r] = 合并区间 [l,r] 的最小代价
vector<vector<int>> dp(n + 2, vector<int>(n + 2, 0));
for (int len = 2; len <= n; ++len) { // 按区间长度递增(拓扑序)
for (int l = 1; l + len - 1 <= n; ++l) {
int r = l + len - 1;
dp[l][r] = INT_MAX;
for (int k = l; k < r; ++k) { // 枚举分割点
dp[l][r] = min(dp[l][r], dp[l][k] + dp[k + 1][r]);
}
dp[l][r] += pre[r] - pre[l - 1]; // 合并代价
}
}
cout << dp[1][n] << '\n';
return 0;
}
dp[i][j] 对应 s1[i-1] 还是 s1[i]?写之前定死,全篇统一。字符串题推荐 dp 下标从 1 开始、字符串访问 s[i-1]。dp[n][m] 但循环到 i<=n,越界。统一开 n+1 行 m+1 列。dp[0][*]/dp[*][0] 必须为 0;编辑距离的 dp[i][0]=i、dp[0][j]=j 漏了必错。dp[i-1][j-1] 却按 j 外层 i 内层填(一般也行,但滚动数组时不行)。滚动数组 + 依赖左上角时,必须保留 prev[j-1] 或临时变量,否则被覆盖。j 从大到小;完全背包从小到大。搞反就是"物品无限用"或"只能用一次"。vector 用 assign 重置;static 数组用 fill。memset 对 long long 数组只能清 0 或 -1,别清 0x3f(会得到 0x3f3f3f3f3f3f3f3f,不是 INF 的 long long 版)。int 溢出:方案数、路径数、区间代价和可能超 2^31,用 long long。取模题每步 % MOD。INF 相加溢出:dp[i-1][j] + cost 若 dp 是 INF 会溢出成负数,先判 INF 再转移,或 INF 取 0x3f3f3f3f(两倍不溢出 int)。len 递增,不能按 l 递增(dp[l][r] 依赖更短区间)。n=0 或 m=0 时答案应为 0/n/m,循环不进入要保证返回正确。max 改 min,三种操作 dp[i-1][j]+1(删)、dp[i][j-1]+1(插)、dp[i-1][j-1]+(s1[i-1]!=s2[j-1])(改)。dp[l][r],s[l]==s[r] 时 dp[l+1][r-1]+2,否则 max(dp[l+1][r], dp[l][r-1]);答案 dp[0][n-1]。dp[i][j] 表示 s 前 i 匹配 p 前 j,* 分支要枚举"匹配 0 个或多个",注意 dp[i][j-1](* 匹配空)和 dp[i-1][j](* 多吃一个)。dp[i][j] = s 前 i 中等于 t 前 j 的子序列数,s[i-1]==t[j-1] 时 dp[i-1][j-1]+dp[i-1][j],否则 dp[i-1][j]。C(n+m-2, n-1) 直接算(模素数时)。dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]]+v[i]),一维压缩 j 倒序。n 的区间最优。list/map 存 DP 表——常数爆炸,一律 vector。vector<vector<int>> 每行单独分配,缓存不友好;大表用一维 vector<int> dp((n+1)*(m+1)) 手动 i*(m+1)+j 寻址,常数更小。#define int long long 导致 main 返回类型错、ios 变慢、memset 语义变——禁用,需要时显式 long long。n=1e5 的线性递归)栈溢出,改迭代或手写栈。-1 当哨兵但答案可能为 -1(如带负权的最短路)——换 INF 或 bool vis。T 很大时每组 vector 重新分配有开销,可复用全局数组 + fill 有效区域。O(m);进一步可用 bitset 做可达性 DP(如子集和、LCS 的 O(nm/64) 位运算加速)。O(n^2)(石子合并的 GarsiaWachs 是另一条路)。题 1:s1 = "intention",s2 = "execution",求编辑距离(插入/删除/替换代价均为 1)。
答案 5。经典例子:intention → execution 需 5 步(如 intention → inention(删 t)→ enention(i→e)→ exention(n→x)→ exection(n→c)→ execution(插 u))。用 dp[i][j] 转移:s1[i-1]==s2[j-1] 取 dp[i-1][j-1],否则 1+min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]),边界 dp[i][0]=i、dp[0][j]=j。
</details>
题 2:3×3 网格,从 (0,0) 到 (2,2),只能向右/向下,中间 (1,1) 有障碍,求路径数。
答案 2。无障碍共 C(4,2)=6 条;经过 (1,1) 的路径 = 到 (1,1) 的 2 条 × 从 (1,1) 到 (2,2) 的 2 条 = 4 条,6-4=2。DP 验证:dp[0][*]=dp[*][0]=1,dp[1][1]=0(障碍),dp[1][2]=dp[0][2]+dp[1][1]=1,dp[2][1]=1,dp[2][2]=dp[1][2]+dp[2][1]=2。
</details>
题 3:石子合并,n=4,石子数 [4,1,1,4],每次合并相邻两堆代价为两堆之和,求合并成一堆的最小总代价。
答案 18。最优:[4,1,1,4] → [4,2,4](合并 1+1=2,代价 2)→ [6,4](合并 4+2=6,代价 6)→ [10](合并 6+4=10,代价 10),总代价 2+6+10=18。区间 DP:dp[l][r]=min_k(dp[l][k]+dp[k+1][r]) + sum(l..r),按 len 从 2 到 4 填表。
</details>
以下内容字幕未涉及,按竞赛需要补充:
vector<int> dp((n+1)*(m+1))