DP 不是公式库
DP 最容易被学成公式库。背包一套公式,LIS 一套公式,LCS 一套公式。
这样短期有用,但遇到变体很容易崩。
更好的理解是:把所有 DP 问题看作 DAG 上的最短路、最长路或最优路径问题。
状态是节点,决策是边,递推是在沿着边传播最优值。
所以 DP 设计的核心问题是:
- 状态里必须记住哪些信息
- 哪些状态依赖哪些更小状态
- 依赖关系有没有环
- 每个状态从哪些候选转移取 min / max
DP 四步
看到一个 DP 问题,按顺序落实四步。
第一步:定义子问题
这是最关键的一步。常见定义方式:
- $T[k]$:以第 $k$ 个元素结尾的最优值
- $T[k,l]$:只看前 $k$ 个物品、容量为 $l$ 的最优值
- $T[i,j]$:子串 $S[i..j]$ 上的最优值
- $T[u]$:DAG 上从 $u$ 到终点的最优值
状态定义必须能唯一确定一个子问题。信息少了,递推不成立;信息多了,状态空间爆炸。
第二步:明确最后一步决策
DP 的递推通常来自最后一步:
- 最后一个元素取还是不取
- 最后一条边从哪里来
- 最后一个活动选不选
- 区间最后一次合并在哪里切
- 最后一个字符来自字符串 A 还是 B
如果说不清最后一步,就很难写出可靠递推。
第三步:写递推关系
把每一种合法决策对应的候选值列出来,取 max 或 min。
常见形式是:
$$ dp[i] = \min_j \{dp[j] + cost(j,i)\} $$或者:
$$ dp[i] = \max_j \{dp[j] + value(j,i)\} $$第四步:确定 base case 和计算顺序
Base case 是最小子问题。填表顺序必须保证每个状态被计算时,它依赖的状态已经算完。
这就是拓扑序。
自顶向下 memoization 和自底向上 table filling,本质上都是在同一个依赖 DAG 上计算。
Knapsack:容量是状态的一部分
0/1 Knapsack 的输入是物品重量 $w_i$、价值 $v_i$、容量 $C$。每个物品最多选一次,目标是最大化总价值。
状态定义:
$$ T[k,l] = \text{只考虑前 } k \text{ 个物品,容量为 } l \text{ 时的最大价值} $$最后一步决策:第 $k$ 个物品选不选。
递推:
$$ T[k,l] = \max( T[k-1,l], T[k-1,l-w_k]+v_k ) $$第一项是不选第 $k$ 个物品,第二项是选第 $k$ 个物品。
这里状态里必须有容量 $l$。只知道“考虑前 $k$ 个物品”不够,因为剩余容量决定后面还能不能选。
复杂度:
$$ O(nC) $$这个复杂度是 pseudo-polynomial。因为 $C$ 是数值大小,不是输入长度。若 $C$ 用二进制表示,输入长度只包含 $\log C$。
DP 变体通常是加维度
很多 DP 变体并不需要新思想,只是状态要多记一点。
两个背包:
$$ T[k,C,D] $$表示考虑前 $k$ 个物品,两个背包剩余容量分别为 $C$ 和 $D$。
物品分两类:
可以增加一个维度记录两类数量差、已经选了多少类,或者约束是否满足。
最多出现 $s$ 次下降:
$$ T[i,s] $$表示以第 $i$ 个元素结尾,并且已经用了 $s$ 次下降的最优值。
这种模式很常见:原问题有一个额外限制,就把限制变成状态的一部分。
但加维度会增加状态数量。DP 不是免费午餐,状态越多,复杂度越高。
LIS:以谁结尾
Longest Increasing Subsequence 的关键状态是“以某个元素结尾”。
定义:
$$ T[k] = \text{以 } a[k] \text{ 结尾的最长递增子序列长度} $$最后一步:在 $a[k]$ 前面接某个 $a[j]$,要求 $j 递推:
最终答案是:
$$ \max_k T[k] $$这里状态不定义成“前 $k$ 个元素的 LIS”也可以,但“以 $k$ 结尾”更容易转移。状态设计的原则是:它要方便描述最后一步。
常见变体:
- 限制间隔:只允许 $j$ 在 $k-1,k-2,k-3$ 中
- 限制值差:要求 $|a[j]-a[k]| \leq B$
- 奇偶交替:要求 $a[j]+a[k]$ 为奇数
- 求最大和:把长度换成总和
它们都沿用同一个状态,只是合法前驱集合变了。
LCS:二维前缀
Longest Common Subsequence 的状态通常是:
$$ T[i,j] = \text{字符串 } x[1..i] \text{ 和 } y[1..j] \text{ 的 LCS 长度} $$最后一步看最后两个字符:
如果 $x[i]=y[j]$:
$$ T[i,j]=T[i-1,j-1]+1 $$如果 $x[i]\neq y[j]$:
$$ T[i,j]=\max(T[i-1,j],T[i,j-1]) $$这个状态的含义很自然:两个前缀之间的最优匹配。
二维 DP 很多时候都来自“两个序列的前缀”。编辑距离、字符串交错、sequence alignment 都是类似结构。
区间 DP:子串或子数组上的最优值
区间 DP 的状态一般是:
$$ T[i,j] = \text{区间 } [i,j] \text{ 上的最优值} $$适用于问题天然定义在一个连续片段上,比如回文、括号、矩阵链乘法、合并成本。
一个典型递推:
$$ T[i,j] = \min_k \{T[i,k] + T[k+1,j] + cost(i,k,j)\} $$这里最后一步是把区间 $[i,j]$ 切成两段。
区间 DP 的填表顺序通常按长度递增:
length = 1, 2, 3, ...
因为 $T[i,j]$ 依赖更短区间。
不能连续选超过 k 个
这类题的约束是相邻结构。
比如不能连续选取 3 个,定义 $T[i]$ 为前 $i$ 个位置的最大价值。
最后几步可能是:
- 不选第 $i$ 个
- 选第 $i$ 个,不选 $i-1$
- 选第 $i$ 和 $i-1$,不选 $i-2$
递推:
$$ T[i]=\max( T[i-1], a_i+T[i-2], a_i+a_{i-1}+T[i-3] ) $$如果约束是不能连续选 $m$ 个,就把分支扩展到最多连续选 $m-1$ 个。
这类题的重点是把“连续选了几个”这件事在最后一步里展开,或者直接把连续数量作为状态维度。
Interleave / Shuffle
字符串交错问题问:字符串 $C$ 是否可以由 $A$ 和 $B$ 交错形成,并保持 $A$ 和 $B$ 各自内部顺序。
状态:
$$ T[i,j] = \text{A 的前 } i \text{ 个字符和 B 的前 } j \text{ 个字符能否组成 C 的前 } i+j \text{ 个字符} $$最后一个字符来自 A 或 B:
$$ T[i,j] = (A[i]=C[i+j] \land T[i-1,j]) \lor (B[j]=C[i+j] \land T[i,j-1]) $$这里不需要第三维 $k$,因为 $k=i+j$ 已经确定。状态里不要放冗余信息,这会让表变大。
Weighted interval scheduling
无权活动选择可以 greedy,按结束时间最早选。
有权活动选择就不行。一个长活动价值很高,可能比多个短活动更好。
这时用 DP。
把活动按结束时间排序。定义:
$$ T[k] = \text{只考虑前 } k \text{ 个活动时的最大价值} $$最后一步:第 $k$ 个活动选不选。
若选第 $k$ 个活动,就只能接在某个与它不冲突的最后活动后面。设 $p(k)$ 是结束时间不超过第 $k$ 个活动开始时间的最大索引。
递推:
$$ T[k] = \max(T[k-1], v_k + T[p(k)]) $$这个例子很适合说明算法选择:无权版本有 greedy-choice property,有权版本需要记录更多历史,所以变成 DP。
DP 正确性怎么写
DP 正确性证明通常用 induction。
结构大概是:
- Claim:$T[\cdot]$ 的定义确实表示对应子问题的最优值
- Base case:最小状态正确
- Inductive hypothesis:假设所有更小状态都已经正确
- Inductive step:证明当前状态的递推覆盖所有合法最后一步,并且每个候选都合法
例如 knapsack:
- 任意最优解要么不选第 $k$ 个物品,要么选第 $k$ 个物品
- 不选时,价值是前 $k-1$ 个物品容量 $l$ 的最优解
- 选时,剩余容量是 $l-w_k$,价值是前 $k-1$ 个物品容量 $l-w_k$ 的最优解再加 $v_k$
- 两种情况取 max 就覆盖了所有可能
这比直接写公式更重要。公式只是结果,证明解释为什么状态足够。
DP 的复杂度
DP 复杂度通常这样算:
状态数量 × 每个状态的转移成本
例子:
| 问题 | 状态数量 | 每个状态成本 | 总复杂度 |
|---|---|---|---|
| Knapsack | $nC$ | $O(1)$ | $O(nC)$ |
| LIS 基础版 | $n$ | $O(n)$ | $O(n^2)$ |
| LCS | $nm$ | $O(1)$ | $O(nm)$ |
| 区间切分 DP | $n^2$ | $O(n)$ | $O(n^3)$ |
如果递推里有一个 $\max_j$ 或 $\min_j$,通常每个状态要扫一圈。优化 DP 很多时候就是减少这个转移成本。
什么时候不要用 DP
DP 适合有重叠子问题和最优子结构的问题。
如果子问题之间没有重叠,分治可能更自然。
如果局部选择已经足够,并且能用 exchange argument 证明,greedy 更简单。
如果状态必须记录一个指数级集合,比如 TSP 的 subset DP,DP 也许能做,但复杂度会很高。
DP 的价值在于把指数级搜索压缩成多项式数量的状态。压缩是否成功,取决于状态能不能保留足够信息,又不保留太多信息。
这也是 Part II 的核心:DP 的关键在状态设计,递推只是状态依赖的结果。