动态规划 DP

状态设计、经典模型、转移与优化

核心思想

将原问题拆成重叠子问题,用表格保存子问题最优解,避免重复计算。

适用需同时满足:

  • 最优子结构:全局最优可由子问题最优组合得到
  • 无后效性:当前状态已包含做决策所需的全部历史信息

若只能证明「局部贪心」正确,用贪心;若子问题会重复出现且可合并,优先考虑 DP。

四步模板

  1. 定义状态dp[i]dp[i][j]dp[mask] 等分别表示什么(尽量一维、语义清晰)
  2. 初始条件dp[0]、空集、叶子节点等边界如何填
  3. 转移方程:枚举「最后一步 / 决策」从已知状态推到未知
  4. 答案max(dp[n])dp[0][n-1]、所有终点状态的最值等

写代码前建议先画表格或 DAG,确认转移方向(从小到大 / 区间长度递增)。

线性 DP

最长上升子序列(LIS)

$O(n^2)$dp[i] = 以 a[i] 结尾的 LIS 长度。

for (int i = 0; i < n; ++i) {
    dp[i] = 1;
    for (int j = 0; j < i; ++j)
        if (a[j] < a[i]) dp[i] = max(dp[i], dp[j] + 1);
}

$O(n \log n)$:维护长度为 $k$ 的上升子序列的最小末尾数组 tail[],对每个 a[i]lower_bound 替换或追加。

最长公共子序列(LCS)

dp[i][j] = s[0..i)t[0..j) 的 LCS 长度:

  • s[i-1] == t[j-1]dp[i][j] = dp[i-1][j-1] + 1
  • 否则:dp[i][j] = max(dp[i-1][j], dp[i][j-1])

空间可压成两行滚动数组。

编辑距离

dp[i][j] = 将 s[0..i) 变成 t[0..j) 的最少操作数(插入、删除、替换),转移与 LCS 类似,字符不等时取三种操作 +1 的最小值。

最大子段和

dp[i] = 以 a[i] 结尾的最大子段和:dp[i] = max(a[i], dp[i-1] + a[i]),全程维护全局最大值。可 $O(1)$ 空间。

背包 DP

一维数组 dp[j] 表示容量为 $j$ 时的最优值,内层循环容量方向决定背包类型。

0/1 背包

每个物品最多选一次,容量从大到小

for (int i = 0; i < n; ++i)
    for (int j = W; j >= w[i]; --j)
        dp[j] = max(dp[j], dp[j - w[i]] + v[i]);

完全背包

每个物品无限个,容量从小到大

for (int i = 0; i < n; ++i)
    for (int j = w[i]; j <= W; ++j)
        dp[j] = max(dp[j], dp[j - w[i]] + v[i]);

多重背包

物品 $i$ 最多选 c[i] 次:可二进制拆分体积为 $1,2,4,\ldots$ 的 0/1 物品,或用单调队列优化到 $O(nW)$。

二维费用

两种限制(体积 + 重量)时:dp[j][k],转移与 0/1 类似,两层容量都逆序。

区间 DP

合并、括号匹配、石子合并等:先算小区间,再算大区间。

dp[l][r] = 合并区间 $[l,r]$ 的最优代价,枚举分割点 $k$:

for (int len = 2; len <= n; ++len)
    for (int l = 0; l + len - 1 < n; ++l) {
        int r = l + len - 1;
        dp[l][r] = INF;
        for (int k = l; k < r; ++k)
            dp[l][r] = min(dp[l][r], dp[l][k] + dp[k+1][r] + cost(l, r));
    }

注意:环上区间可先断环复制一倍,或枚举起点。

树形 DP

在树上 DFS,自底向上合并子树信息。常见状态:

  • dp[u][0/1]:选 / 不选节点 $u$(最大独立集、没有上司的舞会)
  • dp[u]:以 $u$ 为根的子树最优值(换根 DP 需二次 DFS)
void dfs(int u, int fa) {
    dp[u] = val[u];
    for (int v : adj[u]) if (v != fa) {
        dfs(v, u);
        dp[u] = max(dp[u], dp[u] + dp[v]);  // 依题意合并
    }
}

换根 DP:第一次求以 1 为根的答案;第二次 DFS 把根从 $u$ 换到子节点 $v$,用 $O(1)$ 或 $O(\deg)$ 更新父节点贡献。

状压 DP

集合用二进制 mask 表示,第 $i$ 位为 1 表示已选第 $i$ 个元素。

  • 旅行商 TSPdp[mask][i] 已访问集合 mask、当前在 $i$ 的最小代价,$n \le 20$ 左右
  • 子集枚举for (int s = mask; s; s = (s-1) & mask) 枚举 mask 的子集,复杂度 $O(3^n)$ 级别需注意

数位 DP

统计 $[L,R]$ 内满足数位约束的个数(不含前导零、模意义下等)。按从高到低 DFS,状态通常包括:当前位、是否紧贴上界、是否已开始填数、余数等,用 memo[pos][tight][started][...] 记忆化。

关键:区分「前导零」与「真正数字」的转移,边界 $L,R$ 可分别 DP 后相减。

滚动数组与空间优化

只依赖上一层时:

for (int i = 0; i < n; ++i)
    for (int j = 0; j <= W; ++j)
        dp[i % 2][j] = ...;  // 或两个一维数组 swap

区间 DP 若只依赖更短区间,不能简单滚动,需保留二维。

进阶优化(了解)

技巧 典型场景
单调队列优化 转移形如 $\max_{k
斜率优化 转移可写成直线求最值,维护凸包
矩阵快速幂 固定长度线性递推求第 $n$ 项,$O(k^3 \log n)$
Bitmask + SOS 子集和 / 超集和 DP

常见错误

  • 循环方向错(0/1 背包正序导致同一物品用多次)
  • 状态定义含糊(「前 $i$ 个」是否包含第 $i$ 个)
  • 初始化:求最小值时 INF 是否够大;空集 / 空串的 dp 是否为 0
  • 答案取错下标(LIS 要 max(dp[i]),不是 dp[n-1]

调试建议

$n \le 15$ 时对拍暴力;打印 DP 表检查边界;画图确认 DAG 无环与转移顺序。

例题

例 1:采药 / 0-1 背包(洛谷 P1048)
$n$ 种物品,体积 $w_i$、价值 $v_i$,背包容积 $W$,每种最多选一次。dp[j] = max(dp[j], dp[j-w_i]+v_i),$j$ 从 $W$ 到 $w_i$ 逆序。答案 dp[W]。复杂度 $O(nW)$。

例 2:石子合并(区间 DP)
一行 $n$ 堆石子,每次合并相邻两堆,代价为两堆之和,求合并成一堆的最小代价。dp[l][r] 为合并 $[l,r]$ 的最小代价,枚举最后合并位置 $k$:dp[l][r] = min(dp[l][k] + dp[k+1][r] + sum(l,r))。按区间长度递增计算。复杂度 $O(n^3)$。