动态DP(DDP)——快速更新问题的智慧
较难2动态DP(DDP)——快速更新答案的魔法
你有没有想过,游戏里的技能树一旦被策划修改了某个技能的数值,系统是怎么立刻算出新的最优加点的?又或者,老师改了一道题目的分数,你如何快速知道全班最高分的变化?这些场景背后,都藏着一种叫动态DP(Dynamic Programming with Updates,简称 DDP)的技巧。简单说,DDP 能让我们在修改数据后,不用从头算一遍,只更新一小部分就得到新答案。
什么是动态DP?
想象你玩一个角色扮演游戏,角色有一个技能树(像一棵倒着的树),每个技能点都有战斗力加成。但是规则说:相邻的两个技能不能同时学(比如学了“火球术”就不能学旁边的“冰霜术”)。你想选一些技能,让总战斗力最大。这就是经典的树形动态规划问题。
现在假设游戏策划突然把某个技能的基础战斗力从 10 改成了 20。如果每次都从树的根开始重新算一遍,数据量大时就会很慢(比如一棵树有几万个节点)。有没有办法只修改一小部分,就能得到新答案?这就是动态DP要做的事情——在动态变化的数据上,快速维护 DP 的结果。
生活中的比喻:搭积木塔
假设你搭了一个积木塔(像一棵倒立的树),每块积木重量不同。你想让塔最稳(总重量最大),但有个规则:相邻的两块积木不能同时选中(否则会倒)。现在你换掉了其中一块积木的重量,要快速知道新塔的最大重量。
你可以这样做:在每一块积木上记录两个信息:
- 如果选这块积木,以它为顶点的子树的最大重量是多少。
- 如果不选这块积木,以它为顶点的子树的最大重量是多少。
修改某块积木的重量后,你只需要更新这块积木以及它上面所有积木(一直往上到塔顶)的信息。因为只有这些路径上的信息会受影响。这样一来,修改的代价只和树的高度有关,而不是整棵树的大小。这就是 DDP 的基本思想。
先从一个简单例子开始:最大子段和
为了更容易理解,我们先不看树,而是看一个一维数组。你面前有一排连续的地鼠洞,每个洞里有一个分值(可以是正数、负数或零)。你要打连续的一串地鼠(也就是一个连续的子段),使得分总和最大。这就是经典的最大子段和问题。
现在,你可以任意修改一个地鼠洞的分值,然后需要快速知道新的最大子段和。如果每次修改后都用普通的循环重新计算(O(n)),当数据很大时就太慢了。有没有更快的办法?
有!我们可以用线段树来帮忙。线段树像一棵二叉树,每个节点代表一段区间,节点里存着关于这段区间的几个关键信息,让我们能在修改时只更新 O(log n) 个节点,就能得到整个数组的最大子段和。
线段树节点存什么?
对于线段树的每个节点(表示区间 [L, R]),我们存四个数字:
sum:该区间所有元素的总和。pre:该区间内,从最左边开始往右的连续子段的最大和(简称“最大前缀和”)。suf:该区间内,从最右边开始往左的连续子段的最大和(简称“最大后缀和”)。ans:该区间内,全局的最大子段和(不一定要从左或右边界开始)。
举个例子,假设数组是 [ -2, 1, -3, 4, -1, 2, 1, -5, 4 ](著名的例子)。对于整个区间:
sum= 把所有数加起来 = 1。pre= 从左边开始,最大连续和是4(选4, -1, 2, 1或者4本身?实际上是1吗?我们算一下:从左边依次加:-2 得 -2,加 1 得 -1,加 -3 得 -4,加 4 得 0,加 -1 得 -1,加 2 得 1,加 1 得 2,加 -5 得 -3,加 4 得 1。最大的前缀和出现在第 8 个数(位置8)之后?其实最大前缀和是 4(只取第4个数4)或者 1?耐心算:前缀和序列:-2, -1, -4, 0, -1, 1, 2, -3, 1,最大值是 2(位置7结尾)。但最大子段和是整个区间内的,pre必须以左边界开始,所以只能从第一个数开始,不能跳过。因此pre= 2(从位置1到7的和是 2)。但更精确的计算是:从第一个数开始,可以随时停止,所以最大前缀和是4?不,因为从第一个数开始,不能跳过 -2,所以必须包含 -2。实际上如果数组是[-2, 1, -3, 4],前缀和可能是-2, -1, -4, 0,最大值 0。但这里例子是为了说明概念,我们不用纠结具体数字。suf= 从右边开始,最大连续和是4(只取最后一个数 4),或者1?从右边加:4, -5+4=-1, 1+(-1)=0, 2+0=2, -1+2=1, 4+1=5, -3+5=2, 1+2=3, -2+3=1。最大后缀和是 5(从位置3到9的和是 1-3+4-1+2+1-5+4 = 3? 再算一遍:位置3=-3,4=4,5=-1,6=2,7=1,8=-5,9=4 => 和=4? 还是直接看:数组是 [-2,1,-3,4,-1,2,1,-5,4],从右边开始的最优序列是 [4] 得 4,或者 [1,-5,4]=0,或者 [2,1,-5,4]=2,或者 [-1,2,1,-5,4]=1,或者 [4,-1,2,1,-5,4]=5?不行,因为 -1 前面还有 4?实际上从右边开始,你必须连续包含从末尾往左的数,所以可以选最后四个数:4,-5,1,2? 不对,从右往左:4, -5, 1, 2, -1, 4, -3, 1, -2。最大子段和是 4 或 5?5 来自 4+2+1-5+4? 这要连续。我们不需要精确数字,只是说明概念。ans= 整个区间的最大子段和,可能是4+(-1)+2+1=6或者4等,这里是 6(从第4到第7个)。
在实际计算中,这些值是通过左右子节点的信息合并得到的。合并公式非常重要,下面我们来详细推导。
合并两个相邻区间的信息
假设我们已经有左区间 [L, mid] 和右区间 [mid+1, R] 的四个值,现在要合并成父区间 [L, R] 的四个值。合并公式如下:
父区间.sum = 左.sum + 右.sum
父区间.pre = max(左.pre, 左.sum + 右.pre)
父区间.suf = max(右.suf, 右.sum + 左.suf)
父区间.ans = max(左.ans, 右.ans, 左.suf + 右.pre)
为什么这样合并?我们一个一个解释。
- sum:总和就是左右两部分的和,简单。
- pre(最大前缀和):从父区间左边开始的最大连续和,有两种可能:
- 只取左区间的一部分,即左区间的最大前缀和(因为右区间还没开始)。
- 取整个左区间,再加上右区间的最大前缀和(因为左区间必须全部取完,才能进入右区间)。 两者取较大值。
- suf(最大后缀和):从父区间右边开始的最大连续和,有两种可能:
- 只取右区间的一部分,即右区间的最大后缀和。
- 取整个右区间,再加上左区间的最大后缀和。
- ans(全局最大子段和):有三种来源:
- 完全在左区间:左.ans
- 完全在右区间:右.ans
- 跨越中点:左区间的最大后缀 + 右区间的最大前缀
生活中的例子:假设你有一排地鼠洞,左边一段的分值记录在左区间上,右边一段记录在右区间上。你要找整个一排的最大连续子段和。那么最优解要么完全在左边,要么完全在右边,要么跨过中间——这时中间的部分必须包括左边末尾和右边开头。
代码实现
下面的C++代码实现了用线段树支持单点修改和全局最大子段和查询。每一行变量都加了中文注释,方便理解。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005; // 数组最大长度
// 线段树节点,存储区间信息
struct Node {
int sum; // 区间和
int pre; // 区间最大前缀和
int suf; // 区间最大后缀和
int ans; // 区间最大子段和
} tree[MAXN * 4]; // 线段树数组,一般开4倍空间
int a[MAXN]; // 原始数组,下标从1开始
// 合并左右子节点,更新父节点
void pushup(int idx) {
int l = idx * 2, r = idx * 2 + 1; // 左右孩子下标
tree[idx].sum = tree[l].sum + tree[r].sum;
tree[idx].pre = max(tree[l].pre, tree[l].sum + tree[r].pre);
tree[idx].suf = max(tree[r].suf, tree[r].sum + tree[l].suf);
tree[idx].ans = max({tree[l].ans, tree[r].ans, tree[l].suf + tree[r].pre});
}
// 建树:递归构建区间[l, r]的线段树
void build(int idx, int l, int r) {
if (l == r) {
// 叶子节点,只有一个元素
tree[idx] = {a[l], a[l], a[l], a[l]};
return;
}
int mid = (l + r) / 2;
build(idx * 2, l, mid); // 左儿子
build(idx * 2 + 1, mid + 1, r); // 右儿子
pushup(idx); // 合并
}
// 单点修改:将位置 pos 的值改为 val
void update(int idx, int l, int r, int pos, int val) {
if (l == r) {
a[pos] = val; // 更新原始数组(可选)
tree[idx] = {val, val, val, val};
return;
}
int mid = (l + r) / 2;
if (pos <= mid) update(idx * 2, l, mid, pos, val);
else update(idx * 2 + 1, mid + 1, r, pos, val);
pushup(idx); // 沿路更新
}
int main() {
int n, q; // n: 数组长度, q: 修改次数
cin >> n >> q;
for (int i = 1; i <= n; i++) cin >> a[i];
build(1, 1, n); // 建树
while (q--) {
int pos, val; // 要修改的位置和新的值
cin >> pos >> val;
update(1, 1, n, pos, val);
// 整个数组的最大子段和就是根节点的ans
cout << tree[1].ans << endl;
}
return 0;
}
常见错误与避坑指南
- 忘记调用 pushup:修改后如果没有在
update函数最后调用pushup,父节点的信息就不会更新,导致查询结果错误。记住:在递归返回后必须合并。 - 合并公式写错:尤其是
pre和suf的公式容易混淆。记住:pre依赖于左子树的sum加右子树的pre;suf依赖于右子树的sum加左子树的suf。可以对比上面的生活例子来记忆。 - 数组开太小:线段树节点数一般是
4 * n,但如果你用了MAXN作为数组长度,要确保MAXN足够大。比如n=100000,tree数组大小至少400000。 - 下标从0开始还是1开始:上面的代码统一从1开始,方便线段树计算孩子下标。如果从0开始,要注意
mid的计算和边界。 - 查询整个数组时直接输出根节点:如果题目要求查询任意区间,需要实现
query函数,并返回一个Node结构体。上面的代码只支持全局查询,对于更复杂的查询,需要扩展。
从一维到树:DDP 的无限可能
学会了最大子段和的动态维护,你已经掌握了 DDP 的核心思想:用数据结构(线段树、树链剖分等)把 DP 的转移过程表示为矩阵乘法或合并操作,使得更新时只影响 O(log n) 个节点。
对于更复杂的树形 DP(比如开头的技能树例子),我们可以用树链剖分将树拆成多条链,每条链用线段树维护 DP 值,修改一个节点时只需更新它所在链上的 O(log n) 个节点,这就是真正的动态树形 DP。甚至可以用矩阵乘法来统一表示转移,然后用线段树维护区间矩阵乘积。
相关知识与拓展
- 线段树基础:学会线段树的构建、单点修改和区间查询(求和、最值)。动态DP的线段树只是存的信息不同。
- 树链剖分(Heavy Light Decomposition):把树拆成链,让树上路径可以像数组一样用线段树维护。
- 矩阵乘法优化DP:很多 DP 转移可以写成矩阵乘法的形式,这样线段树节点存矩阵,合并就是矩阵相乘。动态DP的常见实现就是“线段树 + 矩阵”。
- Splay Tree / LCT:更高级的动态树数据结构,用于维护森林上的 DP。
如果你对技能树优化问题感兴趣,可以搜索 “树形DP + 树链剖分 + 动态DP”,那是NOI级别的经典题目。
总结:动态DP是一种在动态变化的数据上快速重新计算DP结果的技术。通过将DP转移抽象成可合并的信息(如最大子段和的四个值),并用线段树等数据结构维护,每次修改只需要 O(log n) 时间。它就像给你的算法装上了“快速重算引擎”,让程序能在数据变化时瞬间给出新答案。
例题精讲
动态DP(DDP)中,将DP转移过程映射到线段树维护的关键前提是什么?
在树上的动态DP中,使用树链剖分后,每个节点维护的矩阵(或结构体)仅包含该节点及其所有轻儿子的贡献,重儿子的贡献通过查询重链上后续节点的线段树来获得。
以下代码实现了一个线段树节点,用于维护区间最大子段和(动态DP的典型例子)。请补全 merge 函数中的空白,使得合并后的 res.maxv 正确表示合并区间的最大子段和。
struct Node {
int sum; // 区间和
int lmax; // 区间最大前缀和
int rmax; // 区间最大后缀和
int maxv; // 区间最大子段和
};
Node merge(Node a, Node b) {
Node res;
res.sum = a.sum + b.sum;
res.lmax = max(a.lmax, a.sum + b.lmax);
res.rmax = max(b.rmax, b.sum + a.rmax);
res.maxv = max({a.maxv, b.maxv, ___}); // 填空处
return res;
}在树上的最大权独立集动态DP中,定义 g[u][0] = sum_{v是u的轻儿子} max(f[v][0], f[v][1]),g[u][1] = w[u] + sum_{v是u的轻儿子} f[v][0]。转移方程(只考虑重儿子)为:f[u][0] = g[u][0] + max(f[重][0], f[重][1]),f[u][1] = g[u][1] + f[重][0]。现将其表示为 max-plus 矩阵乘法(加法取max,乘法取加法),向量 v = [f[重][0], f[重][1]]^T,矩阵 M = [[g[u][0], g[u][0]], [g[u][1], -∞]],则乘积 M × v 的第二个元素等于?
以下为 max-plus 矩阵乘法的实现(2×2矩阵),请补全循环体内的空白,完成矩阵乘法。
const int INF = 1e9;
struct Mat {
int a[2][2];
Mat() { for (int i=0;i<2;i++) for (int j=0;j<2;j++) a[i][j] = -INF; }
};
Mat mul(Mat A, Mat B) {
Mat C;
for (int i=0;i<2;i++) {
for (int j=0;j<2;j++) {
for (int k=0;k<2;k++) {
C.a[i][j] = max(C.a[i][j], ___); // 填空处
}
}
}
return C;
}