动态 dp
带家好,今天我们来 Van 动态 dp 辣
刚一听到这个名词:动态动态规划?What are you f**king saying?(某位姓苏的同学附体)
事实上,动态 \(dp\) 可以视作带上修改的 \(dp\),就以动态 \(dp\) 的模板为例:给定一棵树,每个点都有一个权值 \(a_i\),要在树上选出一个独立集使其权值之和最大。
静态的版本显然是留给刚学树形 \(dp\) 的萌新做的,\(dp_{u,0/1}\) 表示已经考虑了 \(u\) 子树中的点,点 \(u\) 在/不在独立集中的最大权值和,那么显然有转移 \(dp_{u,0}=\sum\limits_{v\in son_u}\max(dp_{v,0},dp_{v,1}),dp_{u,1}=a_u+\sum\limits_{v\in son_u}dp_{v,0}\),最终答案即为 \(\max(dp_{1,0},dp_{1,1})\)。但是带上修改之后,事情就变得棘手起来。显然每次修改都 \(\mathcal O(n)\) 地跑一遍树形 \(dp\) 复杂度吃不消。那么有没有什么好办法呢?这时候就要用到动态 \(dp\) 了。
前置知识:用矩阵表示 \(dp\) 转移
蛤?这个……学动态 \(dp\) 不会矩阵乘法?建议出门右转 P1939(大雾
最常规的矩阵乘法是对于两个大小分别为 \(n\times k\) 和 \(k\times m\) 的矩阵 \(A,B\),\(A\times B\) 的结果是一个 \(n\times m\) 的矩阵 \(C\),满足 \(C_{i,j}=\sum\limits_{p=1}^kA_{i,p}\times B_{p,j}\),因此,形如 \(dp_i=\sum\limits_{j=1}^ka_jdp_{i-j}\)(分治 NTT(逃)) 的 \(dp\) 就可以写作 \(\begin{bmatrix}dp_{i}&dp_{i-1}&\cdots&dp_{i-k+1}\end{bmatrix}=\begin{bmatrix}dp_{i-1}&dp_{i-2}&\cdots&dp_{i-k}\end{bmatrix}\times\begin{bmatrix}a_1&1&0&\cdots&0\\a_2&0&1&\cdots&0\\\vdots&\vdots&\vdots&\ddots&\vdots\\a_{k-1}&0&0&\cdots&1\\a_k&0&0&\cdots&0\end{bmatrix}\)(这里转移矩阵我老搞混行和列,记住一点,就是转移矩阵中第 \(i\) 行第 \(j\) 列的数 \(A_{i,j}\) 表示原来的第 \(i\) 个数会对新的第 \(j\) 个数产生 \(A_{i,j}\) 的贡献)
但是并不是所有 \(dp\) 式子都长这样,事实上有的题目也会出现形如 \(dp_i=\max\limits_{j=1}^k\{a_j+dp_{i-j}\}\) 的 \(dp\) 转移方程式,这种情况下我们可以重新定义矩阵乘法为 \(C_{i,j}=\max\limits_{p=1}^k\{A_{i,p}+B_{p,j}\}\),感性理解可知我们新定义的矩阵乘法也是满足结合律的,有兴趣的读者不妨也可以拿出纸和笔来手动计算 \(A\times(B\times C)\) 和 \((A\times B)\times C\) 的值,这个任务留给读者自行完成。因此这种 \(dp\) 转移方程也可以写作 \(\begin{bmatrix}dp_{i}&dp_{i-1}&\cdots&dp_{i-k+1}\end{bmatrix}=\begin{bmatrix}dp_{i-1}&dp_{i-2}&\cdots&dp_{i-k}\end{bmatrix}\times\begin{bmatrix}a_1&0&-\infty&\cdots&-\infty\\a_2&-\infty&0&\cdots&-\infty\\\vdots&\vdots&\vdots&\ddots&\vdots\\a_{k-1}&-\infty&-\infty&\cdots&0\\a_k&-\infty&-\infty&\cdots&-\infty\end{bmatrix}\)
动态 dp
了解了这些前置知识后,我们就要来解决之前提出的这个问题了。
首先很容易注意到的一点是当我们修改一个点 \(x\) 的权值时,影响的 \(dp\) 值只可能在 \(x\) 到根节点的路径上,因此我们考虑请出擅长处理树上路径问题的数据结构——树链剖分。我们按照树链剖分的套路处理出每个点的重儿子 \(wson_x\) 并将树剖成一条条重链,那么我们可以很自然地将每个点 \(u\) 的儿子分成两类:轻儿子、重儿子。考虑按照上面的分类标准改写之前的 \(dp\) 转移方程式,之前我们的 \(dp\) 转移方程式是不分轻儿子重儿子直接求和累加答案的,即
- \(dp_{u,0}=\sum\limits_{v\in son_u}\max(dp_{v,0},dp_{v,1})\)
- \(dp_{u,1}=a_u+\sum\limits_{v\in son_u}dp_{v,0}\)
这样的 \(dp\) 转移方程式显然不好与树链剖分直接结合,因此我们考虑将轻重儿子分开来加和,即设一个 \(g_{u,0}\) 表示 \(u\) 所有轻儿子的 \(\max(dp_{v,0},dp_{v,1})\) 之和,\(g_{u,1}\) 表示所有轻儿子的 \(dp_{v,0}\) 之和。为了表述方便,在下文中我们统一设 \(v=wson_u\),那么上面的 \(dp\) 转移方程式就可以改写为
- \(dp_{u,0}=g_{u,0}+\max(dp_{v,0},dp_{v,1})\)
- \(dp_{u,1}=g_{u,1}+a_u+dp_{v,0}\)
发现 \(dp_{u,1}\) 中有两项与 \(u\) 有关,因此考虑将它们合并,即将 \(dp_{u,1}\) 的定义改为 \(u\) 所有轻儿子的 \(dp_{v,0}\) 之和加上 \(a_u\),那么上面 \(dp_{u,1}\) 的转移式就可以简单明了地写作 \(dp_{u,1}=g_{u,1}+dp_{v,0}\)。
考虑怎么优化这个转移,这个式子看起来有点眼熟,我们不妨将它稍微改写一下:
- \(dp_{u,0}=\max(g_{u,0}+dp_{v,0},g_{u,0}+dp_{v,1})\)
- \(dp_{u,1}=\max(g_{u,1}+dp_{v,0},-\infty+dp_{v,1})\)
对!正是我们在前文中埋下伏笔的广义矩阵乘法!如果用矩阵的形式表示出来就是 \(\begin{bmatrix}dp_{u,0}&dp_{u,1}\end{bmatrix}=\begin{bmatrix}dp_{v,0}&dp_{v,1}\end{bmatrix}\times\begin{bmatrix}g_{u,0}&g_{u,1}\\g_{u,0}&-\infty\end{bmatrix}\)
考虑 \(u\) 所在的重链 \(C_u\),假设 \(u\) 到 \(C_u\) 链底 \(x\) 之间经过的点分别为 \(u=v_1,v_2,\cdots,v_k=x\),那么有 \(\begin{bmatrix}dp_{u,0}&dp_{u,1}\end{bmatrix}=\begin{bmatrix}dp_{v_2,0}&dp_{v_2,1}\end{bmatrix}\times\begin{bmatrix}g_{u,0}&g_{u,1}\\g_{u,0}&-\infty\end{bmatrix}\),而 \(\begin{bmatrix}dp_{v_2,0}&dp_{v_2,1}\end{bmatrix}=\begin{bmatrix}dp_{v_3,0}&dp_{v_3,1}\end{bmatrix}\times\begin{bmatrix}g_{v_2,0}&g_{v_2,1}\\g_{v_2,0}&-\infty\end{bmatrix}\),如果我们不断递归展开可以得到 \(\begin{bmatrix}dp_{u,0}&dp_{u,1}\end{bmatrix}=\begin{bmatrix}dp_{x,0}&dp_{x,1}\end{bmatrix}\times\begin{bmatrix}g_{v_{k-1},0}&g_{v_{k-1},1}\\g_{v_{k-1},0}&-\infty\end{bmatrix}\times\cdots\times\begin{bmatrix}g_{v_2,0}&g_{v_2,1}\\g_{v_2,0}&-\infty\end{bmatrix}\times\begin{bmatrix}g_{v_1,0}&g_{v_1,1}\\g_{v_1,0}&-\infty\end{bmatrix}\),又由于 \(v_k\) 为叶子节点,\(\begin{bmatrix}dp_{x,0}&dp_{x,1}\end{bmatrix}=\begin{bmatrix}0&a_{x}\end{bmatrix}=\begin{bmatrix}0&0\end{bmatrix}\times\begin{bmatrix}g_{x,0}&g_{x,1}\\g_{x,0}&-\infty\end{bmatrix}\),如果我们记 \(A_u=\begin{bmatrix}g_{u,0}&g_{u,1}\\g_{u,0}&-\infty\end{bmatrix}\),那么 \(\begin{bmatrix}dp_{u,0}&dp_{u,1}\end{bmatrix}=\begin{bmatrix}0&0\end{bmatrix}\times A_{v_k}A_{v_{k-1}}\cdots A_{v_2}A_{v_1}\)。
因此我们有一个大胆的想法,不维护 \(dp_{u,0/1}\) 了,改维护 \(g_{u,0/1}\),因为由于 \(u\) 到其所在重链底的 DFS 序是一段连续的区间,因此它们的乘积是可以通过线段树在 \(\omega^3\log n\),其中 \(\omega\) 为矩阵大小。现在考虑修改一个权值会对哪些 \(g\) 产生影响,显然你修改的那个点的 \(g_{x,1}\) 会变(减去原来的 \(a_x\) 加上新的 \(a_x\)),而由于 \(x\) 所在的这一条重链上其他任何一个点的轻儿子都没有被改过,因此其它点的 \(g\) 都不会变——但是,对于 \(x\) 所在重链顶 \(u\) 的父亲 \(fa\) 而言,由于 \(u\) 的 \(dp\) 值变了,而 \(u\) 不是 \(fa\) 的重儿子,因此 \(fa\) 的 \(g\) 值会变,同理 \(fa\) 的 \(g\) 值变了,\(fa\) 所在链链顶的父亲的 \(g\) 也会变,如此跳到 \(1\) 号点即可,根据树链剖分那一套理论,跳的次数是 \(\log n\) 的,再加上线段树的复杂度,总复杂度 \(\omega^3\log^2n\)。
注意点:注意线段树 pushup
中矩阵乘法的顺序,应先乘右儿子,再乘左儿子。
修改一个点权值部分的伪代码如下:
void change(int x){
while(x){
if(top[x]^1){
线段树求出 top[x] 的 dp 值
撤销 dp[top[x]] 对 fa[top[x]] 的贡献
}
修改 top[x] 的权值
if(top[x]^1){
线段树求出 top[x] 的新的 dp 值
加入新的 dp[top[x]] 对 fa[top[x]] 的贡献
} x=fa[top[x]];
}
}
完整代码:
const int MAXN=1e5;
const ll INF=0x3f3f3f3f3f3f3f3fll;
int n,qu,a[MAXN+5],hd[MAXN+5],to[MAXN*2+5],nxt[MAXN*2+5],ec=0;
void adde(int u,int v){to[++ec]=v;nxt[ec]=hd[u];hd[u]=ec;}
int f[MAXN+5][2],g[MAXN+5][2];
int siz[MAXN+5],fa[MAXN+5],dep[MAXN+5],wson[MAXN+5];//things obtained by dfs1
int dfn[MAXN+5],top[MAXN+5],tim=0,rid[MAXN+5];//things obtained by dfs2
int bot[MAXN+5];
void dfs1(int x=1,int f=0){
fa[x]=f;siz[x]=1;
for(int e=hd[x];e;e=nxt[e]){
int y=to[e];if(y==f) continue;
dep[y]=dep[x]+1;dfs1(y,x);siz[x]+=siz[y];
if(siz[y]>siz[wson[x]]) wson[x]=y;
}
}
void dfs2(int x=1,int tp=1){
top[x]=tp;rid[dfn[x]=++tim]=x;if(wson[x]) dfs2(wson[x],tp);
for(int e=hd[x];e;e=nxt[e]){
int y=to[e];if(y==fa[x]||y==wson[x]) continue;
dfs2(y,y);
}
}
void dfs3(int x=1){
g[x][0]=f[x][0]=0;g[x][1]=f[x][1]=a[x];
if(wson[x]){
dfs3(wson[x]);
f[x][0]+=max(f[wson[x]][0],f[wson[x]][1]);
f[x][1]+=f[wson[x]][0];
}
for(int e=hd[x];e;e=nxt[e]){
int y=to[e];if(y==fa[x]||y==wson[x]) continue;dfs3(y);
g[x][0]+=max(f[y][0],f[y][1]);g[x][1]+=f[y][0];
f[x][0]+=max(f[y][0],f[y][1]);f[x][1]+=f[y][0];
}
}
struct matrix{
ll a[2][2];
matrix(){memset(a,192,sizeof(a));}
matrix operator *(const matrix &rhs){
matrix res;
for(int i=0;i<2;i++) for(int j=0;j<2;j++) for(int k=0;k<2;k++)
chkmax(res.a[i][j],a[i][k]+rhs.a[k][j]);
return res;
}
};
matrix get(int x){
matrix res;
res.a[0][0]=g[x][0];res.a[0][1]=g[x][1];
res.a[1][0]=g[x][0];return res;
}
struct node{int l,r;matrix v;} s[MAXN*4+5];
void pushup(int k){s[k].v=s[k<<1|1].v*s[k<<1].v;}//be careful about the order!
void build(int k,int l,int r){
s[k].l=l;s[k].r=r;if(l==r) return s[k].v=get(rid[l]),void();
int mid=l+r>>1;build(k<<1,l,mid);build(k<<1|1,mid+1,r);pushup(k);
}
void modify(int k,int p){
if(s[k].l==s[k].r) return s[k].v=get(rid[p]),void();int mid=s[k].l+s[k].r>>1;
(p<=mid)?modify(k<<1,p):modify(k<<1|1,p);pushup(k);
}
matrix query(int k,int l,int r){
if(l<=s[k].l&&s[k].r<=r) return s[k].v;int mid=s[k].l+s[k].r>>1;
if(r<=mid) return query(k<<1,l,r);else if(l>mid) return query(k<<1|1,l,r);
else return query(k<<1|1,mid+1,r)*query(k<<1,l,mid);
}
void change(int x){
while(x){
if(top[x]^1){
matrix t=query(1,dfn[top[x]],dfn[bot[top[x]]]);
g[fa[top[x]]][0]-=max(max(t.a[0][0],t.a[1][0]),max(t.a[0][1],t.a[1][1]));
g[fa[top[x]]][1]-=max(t.a[0][0],t.a[1][0]);
}
modify(1,dfn[x]);
if(top[x]^1){
matrix t=query(1,dfn[top[x]],dfn[bot[top[x]]]);
g[fa[top[x]]][0]+=max(max(t.a[0][0],t.a[1][0]),max(t.a[0][1],t.a[1][1]));
g[fa[top[x]]][1]+=max(t.a[0][0],t.a[1][0]);
} x=fa[top[x]];
}
}
int max_indset(){
matrix t=query(1,dfn[1],dfn[bot[1]]);
return max(max(t.a[0][0],t.a[1][0]),max(t.a[0][1],t.a[1][1]));
}
int main(){
scanf("%d%d",&n,&qu);for(int i=1;i<=n;i++) scanf("%d",&a[i]);
for(int i=1,u,v;i<n;i++) scanf("%d%d",&u,&v),adde(u,v),adde(v,u);
dfs1();dfs2();dfs3();build(1,1,n);
for(int i=1;i<=n;i++) if(top[i]==i){
int cur;for(cur=i;wson[cur];cur=wson[cur]);
bot[i]=cur;
}
while(qu--){
int x,v;scanf("%d%d",&x,&v);
g[x][1]-=a[x];a[x]=v;g[x][1]+=a[x];
change(x);printf("%d\n",max_indset());
}
return 0;
}
例题
1. CF750E New Year and Old Subsequence
连树都没上,怎么算动态 dp,顶多算线段树维护矩阵乘法罢(
2. 2021 NFLS 科技特长生考试 T4
没错就是这个题,yet another 没上树的动态 dp(大雾
题意:给你一个 01 串,两种操作:
- 将某段区间所有数改为 \(v(v\in\{0,1\})\)
- 求某段区间中本质不同子序列个数
首先考虑怎样解决静态的问题,考虑 \(dp_{i,0/1}\) 表示在序列的前 \(i\) 个元素中以 \(0/1\) 结尾的子序列有多少个,那么对于某个 \(s_i='1'\) 而言有 \(dp_{i,0}=dp_{i-1,0}\),而对于所有原来能够得到的子序列而言,在它们后面添上一个 \('1'\) 都能得到一个新的且两两不同的子序列,再加上 \(1\) 本身(由于空串没有被算进去,因此直接令 \(dp_{i,1}=dp_{i-1,0}+dp_{i-1,1}\) 会少算一个 \(1\)),总共是 \(dp_{i-1,0}+dp_{i-1,1}+1\) 个,因此有:
对于 \(s_i='0'\) 的情况也同理,线段树维护一波矩阵乘法即可,注意矩阵乘法的顺序。
时间复杂度 \(m\log n\)。
3. P6021 洪水
终于上树了(大雾
首先列出最 naive 的 \(dp\) 方程:\(dp_u\) 表示堵住 \(u\) 子树内所有叶子节点的最小代价,那么有 \(dp\) 转移方程 \(dp_u=\min(\sum\limits_{v\in\text{son}(u)}dp_v,val_u)\),那么我们套路地设 \(dpl_u=\sum\limits_{v\in\text{lightson}(u)}dp_v\),记 \(w=wson_u\),则显然有 \(dp_u=\min(dpl_u+dp_w,val_u)\),上述式子可以用矩阵表示为:
其中两个大小分别为 \(n\times m\) 和 \(m\times k\) 的矩阵 \(A,B\) 相乘将得到大小为 \(n\times k\) 的矩阵 \(C\),满足 \(C_{i,j}=\min\limits_{k=1}^m\{A_{i,k}+B_{k,j}\}\),线段树维护矩乘一波带走即可,时间复杂度 \(n\log^2n\)。
4. P3781 [SDOI2017]切树游戏
神仙题 | 阿巴细节题,题解
小结
动态 \(dp\) 的题,大多数只要看出来可以 \(dp\) + 单点修改,基本上就可以被认定为是动态 \(dp\)。对于树上动态 \(dp\) 的题,只要记住一点:将轻儿子与重儿子分开来处理,对每个点新开一个数组 \(dpl\) 维护轻儿子 \(dp\) 值的和/积,那么修改一个点的权值最多影响 \(\log n\) 个点的 \(dpl\),线段树维护矩阵乘法即可高效求解每个点的 \(dp\) 值。了解清楚这个套路之后,解决动态 \(dp\) 的题目就变得有迹可循了。