动态DP(DDP)
动态DP(DDP)
核心用途:解决带有单点修改的树形 \(dp\) 问题。
1. 核心模板(P4719 最大权独立集)
-
问题描述:选了儿子就不能选父亲,求选的节点的权值最大值,注意需要支持单点修改权值。
-
状态定义:$ f_{i,0/1} $ 表示子树 $ i $ 不选/选的最大权。
-
轻儿子贡献固化:定义
\[g_{i,0} = \sum_{\text{轻儿子}} \max(f_{son,0}, f_{son,1}), \quad \]\[ \]-
只考虑重儿子 $ son $,转移为:
\[\begin{aligned} \]
-
f_{i,1} &= g_{i,1} + f_{son,0}
\end{aligned}
$$
-
构造矩阵(乘法定义为 $ C_{ij} = \max_k(A_{ik} + B_{kj}) $):
\[\begin{bmatrix} \]
g_{i,1} & -\infty
\end{bmatrix}
\times
\begin{bmatrix}
f_{son,0} \
f_{son,1}
\end
\begin{bmatrix}
f_{i,0} \
f_{i,1}
\end{bmatrix}
$$
-
发现:每个点的转移矩阵只与它的轻儿子的 \(f\) 有关,意味着对于一条重链而言,我们只需要去维护重链上的矩阵乘积,就能知道链顶的 \(f\) 值。
-
维护:每条重链建线段树维护矩阵乘积。修改一个点的点权只需更新它的 $ g $,然后沿着重链往上跳,每次修改重链顶端的父节点的轻儿子贡献(即更新其 $ g $ 矩阵)。复杂度 $ O(\log^2 n) $。
-
代码:
#include <bits/stdc++.h> using namespace std; const int N = 1e5 + 5; const int INF = 0x3f3f3f3f;// 2x2 矩阵,用于动态DP的转移(max-plus代数) struct Matrix{ int arr[3][3]; // 使用1-based索引,实际只用前2行2列 Matrix(){} // 默认构造函数 // 通过初始化列表构造矩阵,支持二维列表 Matrix(initializer_list<initializer_list<int> > lit){ int i=0; for (auto &now:lit){ int j=0; for (int v:now){ arr[i][j]=v; j++; } i++; } } }; // 矩阵乘法(max-plus):C[i][j] = max_k (A[i][k] + B[k][j]) Matrix operator * (const Matrix &a,const Matrix &b){ Matrix res={{-INF,-INF},{-INF,-INF}}; int len=2; for (int i=0;i<len;i++){ for (int j=0;j<len;j++){ for (int k=0;k<len;k++){ res.arr[i][j]=max(res.arr[i][j],a.arr[i][k]+b.arr[k][j]); } } } return res; } // 线段树节点,只存储一个矩阵 struct A{ Matrix sum_data; }tree[N*4]; // 线段树向上合并:父节点矩阵 = 左儿子矩阵 * 右儿子矩阵(注意顺序,因为矩阵乘法不满足交换律,对应链上从浅到深的顺序) void push_up(int k){ tree[k].sum_data=tree[k*2].sum_data*tree[k*2+1].sum_data; } // 建立线段树:初始化为单位矩阵(对角线0,其余-INF),表示空转移 void Build(int k,int l,int r){ if (l==r){ tree[k].sum_data={{0,-INF},{-INF,0}}; return; } int mid=(l+r)>>1; Build(k*2,l,mid); Build(k*2+1,mid+1,r); push_up(k); } // 单点更新:将位置p的转移矩阵设置为 [[g0,g0],[g1,-INF]] void update(int k,int l,int r,int p,int g0,int g1){ if (l==r){ tree[k].sum_data=Matrix({{g0,g0},{g1,-INF}}); return; } int mid=(l+r)>>1; if (p<=mid) update(k*2,l,mid,p,g0,g1); else update(k*2+1,mid+1,r,p,g0,g1); push_up(k); } // 区间查询:返回区间矩阵乘积 Matrix query(int k,int l,int r,int ql,int qr){ if (ql<=l&&r<=qr){ return tree[k].sum_data; } int mid=(l+r)>>1; Matrix lson={{0,-INF},{-INF,0}}, rson={{0,-INF},{-INF,0}}; // 单位矩阵 if (ql<=mid) lson=query(k*2,l,mid,ql,qr); if (qr>mid) rson=query(k*2+1,mid+1,r,ql,qr); return lson*rson; // 注意顺序:左区间乘右区间 } // 全局变量 int a[N]; // 节点权值 int n,m; vector<int> G[N]; // 邻接表 int g[N][2],f[N][2]; // g: 轻儿子贡献(不包括重儿子),f: 节点最终的DP值(选/不选的最大权) int dfn[N],total=0; // dfs序 int sz[N],h_son[N]; // 子树大小,重儿子 int link_tp[N],link_bt[N]; // 链顶、链底(链底用于快速获取整条重链的dfs序区间) int fa[N]; // 父节点 // 第一次DFS:计算子树大小、重儿子 void init(int x,int last){ sz[x]=1; int id=0; // 重儿子编号 for (int to:G[x]){ if (to==last) continue; init(to,x); sz[x]+=sz[to]; if (sz[id] < sz[to]) id=to; } h_son[x]=id; } // 第二次DFS:进行树链剖分,计算dfs序,并初始化DP值 void dfs(int x,int last,int ff){ fa[x]=last; link_tp[x]=ff; dfn[x]=++total; // 先处理重儿子,确保重链上dfs序连续 if (h_son[x]){ dfs(h_son[x],x,ff); // 更新f[x]时先加上重儿子的贡献(重儿子在链上会通过矩阵转移,但这里先存储一部分,实际上最终f值会通过链上矩阵乘法得到,这里初始化仍用于计算g) f[x][0]+=max(f[h_son[x]][0],f[h_son[x]][1]); f[x][1]+=f[h_son[x]][0]; link_bt[x]=link_bt[h_son[x]]; // 链底继承重儿子的链底 } else link_bt[x]=x; // 叶子节点,链底是自己 // 处理轻儿子,计算g值 for (int to:G[x]){ if (to==last||to==h_son[x]) continue; dfs(to,x,to); // 轻儿子作为新链的链顶 g[x][0]+=max(f[to][0],f[to][1]); g[x][1]+=f[to][0]; } // 加上自身权值 g[x][1]+=a[x]; // 当前f值:f[x][0] = 所有轻儿子贡献和 g[x][0] (重儿子贡献已在之前加入,但这里会重新计算,实际上最终f通过矩阵得到,暂存无妨) f[x][0]+=g[x][0]; f[x][1]+=g[x][1]; // 更新线段树中该节点对应的矩阵(基于当前的g值) update(1,1,n,dfn[x],g[x][0],g[x][1]); } int main(){ cin >> n >> m; for (int i=1;i<=n;i++){ cin >> a[i]; } for (int i=1,u,v;i<n;i++){ cin >> u >> v; G[u].push_back(v); G[v].push_back(u); } Build(1,1,n); // 线段树初始化为单位矩阵 init(1,0); // 第一次DFS求重儿子 dfs(1,0,1); // 第二次DFS剖分并初始化DP // 处理每次修改 for (int i=1,x,y;i<=m;i++){ cin >> x >> y; int new_g0=g[x][0], new_g1=g[x][1]+y-a[x]; // 更新x的g值,g1增加差值 a[x]=y; // 更新权值 // 从x向上逐条重链更新 while(x){ g[x][0]=new_g0, g[x][1]=new_g1; update(1,1,n,dfn[x],new_g0,new_g1); // 更新线段树中x节点的矩阵 int tp=link_tp[x]; int bt=link_bt[x]; // 查询这条重链从链顶到链底的矩阵乘积,得到链顶的新DP值 Matrix res=query(1,1,n,dfn[tp],dfn[bt]); // 从矩阵中提取链顶的新DP值: // 链顶的f值由链底的单位向量(初始为[0, -INF]表示不选,[-INF,0]表示选,但通常直接取矩阵最大值列) // 这里采用简便方法:新f[tp][0] = max(res[0][0], res[0][1]),新f[tp][1] = max(res[1][0], res[1][1]) int nf0=max(res.arr[0][0],res.arr[0][1]); int nf1=max(res.arr[1][0],res.arr[1][1]); // 记录旧的链顶DP值 int of0=f[tp][0], of1=f[tp][1]; // 更新父节点(链顶的父节点)的g值:去掉旧链顶贡献,加入新链顶贡献 new_g0=g[fa[tp]][0]-max(of0,of1)+max(nf0,nf1); new_g1=g[fa[tp]][1]-of0+nf0; // 更新链顶的f值(用于下次循环计算父节点g值使用) f[tp][0]=nf0, f[tp][1]=nf1; // 跳到父节点所在链继续更新 x=fa[tp]; } // 输出根节点的最大权独立集 cout << max(f[1][0],f[1][1]) << endl; } return 0; }
2. 谁说DDP一定是矩阵形式?(CF1740H MEX Tree Manipulation)
1. 问题描述
有一棵树,初始只有一个节点。动态操作:每次添加一个叶子节点。每个节点的权值定义为:
它的所有儿子(不含其他子孙)的权值集合的 MEX(最小未出现的非负整数)。
要求每次添加后,输出所有节点的权值总和。
2. 树链剖分
我们对树做重链剖分(离线建出整棵树再剖分)。每个节点有一个重儿子,其余是轻儿子。
对于节点 \(i\),它的权值 \(f_i\) 等于它所有儿子权值的 MEX。我们可以把它拆成两部分:
- 轻儿子的权值集合 \(S_i\)
- 重儿子的权值 \(f_{son}\)
于是:
3. 把轻儿子打包成一个函数 \(g_i\)
我们把重儿子的权值 \(f_{son}\) 看作一个变量 \(x\),那么:
定义函数:
这个函数只依赖于轻儿子集合 \(S_i\),与重儿子无关。于是:
4. 函数 \(g_i\) 的形式:
先计算两个值:
- \(m_0 = \text{MEX}(S_i)\)
- \(m_1 = \text{MEX}(S_i \cup \{m_0\})\)
现在我们看 \(g_i(x)\) 的取值:
情况 1: \(x \neq m_0\)
因为 \(m_0\) 本来就不在 \(S_i\) 中,加入的 \(x\) 也不是 \(m_0\),所以 \(m_0\) 依然没有出现,而所有小于 \(m_0\) 的数都已经在 \(S_i\) 里了。因此:
情况 2: \(x = m_0\)
此时 \(m_0\) 出现了,所以最小未出现的数变成了下一个缺失的数,也就是 \(m_1\)。因此:
所以 \(g_i\) 只在输入等于 \(m_0\) 时输出 \(m_1\),其他情况都输出 \(m_0\)。这种函数可以用一个三元组表示:
含义:
- 输入 \(x = p\) 时,输出 \(r\)
- 输入 \(x \neq p\) 时,输出 \(q\)
因此:
5. 重链上的函数复合
一条重链从上到下是:
对于每个节点 \(u_j\),都有:
其中 \(f_{u_{k+1}}\) 是链底节点的重儿子,但是链底没有重儿子,所以实际上链底的函数是常函数(后面再说)。
于是:
整条链可以看成一个复合函数:
其中 \(\circ\) 表示函数复合,先算右边的。
6. 两个函数的复合规则
假设有两个函数:
我们想求 \(H(x) = F_1(F_2(x))\)。
由于 \(F_2\) 只在 \(x = p_2\) 时输出 \(r_2\),其余情况输出 \(q_2\),所以 \(F_2(x)\) 只有两种可能的输出:\(q_2\) 或 \(r_2\)。
因此:
- 若 \(x \neq p_2\),则 \(F_2(x) = q_2\),所以 \(H(x) = F_1(q_2)\)
- 若 \(x = p_2\),则 \(F_2(x) = r_2\),所以 \(H(x) = F_1(r_2)\)
于是 \(H\) 也只在 \(x = p_2\) 这一个点特殊,所以它也可以表示为三元组:
而 \(F_1(v)\) 的计算很简单:
关键:复合两个函数只需要 \(O(1)\) 时间。
复合例子:
设:
求 \(H = F_1 \circ F_2\)。
- \(p_2 = 4\)
- 正常输出:\(F_1(q_2) = F_1(3)\)
- 特殊输出:\(F_1(r_2) = F_1(2)\)
计算:
- \(F_1(3)\):因为 \(3 \neq 2\),所以 \(F_1(3) = q_1 = 5\)
- \(F_1(2)\):因为 \(2 = p_1\),所以 \(F_1(2) = r_1 = 9\)
所以:
7. 用线段树维护一条重链的复合函数
对于一条重链,我们可以在线段树上维护每个区间对应的复合函数。
线段树叶子节点:对应一个节点 \(u\),存 \(g_u = (m_0, m_0, m_1)\)。
非叶子节点:设左儿子区间代表链的上半段(靠近链顶),右儿子代表下半段(靠近链底)。那么该区间的函数就是:
也就是先应用右儿子的函数,再应用左儿子的函数(因为左儿子在链的更上方)。
合并时直接用上面的复合公式即可。
8. 链底节点的处理
链底节点没有重儿子,它的权值 \(f_{u_k}\) 只由轻儿子决定:
即 \(f_{u_k} = m_0\),与输入无关。我们可以把它的函数设为常函数:
这里 \(p\) 可以任意(因为输入无意义,永远不会用到),\(q\) 和 \(r\) 都设为 \(m_0\),表示无论输入什么,输出都是 \(m_0\)。
9. 动态加叶子的更新流程
每次添加一个叶子 \(v\) 作为节点 \(u\) 的儿子。
- 更新轻儿子集合:将 \(v\) 的权值加入 \(S_u\),同时可能需要删除旧的链顶权值(当跳链时)。
- 重新计算 \(g_u\):根据新的 \(S_u\) 计算 \(m_0, m_1\),得到新的 \(g_u = (m_0, m_0, m_1)\)。
- 更新线段树:将节点 \(u\) 对应的叶子节点更新为新的 \(g_u\),然后向上更新整条链的复合函数。
- 查询链顶权值:取出整条链的复合函数 \(F_{chain}\),随便代入一个值(例如 0)得到链顶 \(top\) 的权值 \(f_{top} = F_{chain}(0)\)。
- 向上跳链:如果链顶的父亲 \(f_{a_{top}}\) 存在,那么原来链顶的权值会作为 \(f_{a_{top}}\) 的一个轻儿子的权值,我们需要:
- 从 \(S_{f_{a_{top}}}\) 中删除旧的 \(f_{top}\)
- 加入新的 \(f_{top}\)
- 重新计算 \(g_{f_{a_{top}}}\) 并更新其所在链
- 继续循环,直到根
因为函数复合是 \(O(1)\),线段树更新是 \(O(\log n)\),跳链次数 \(O(\log n)\),所以总复杂度 \(O(\log^2 n)\)。
终于写完了这道题。
3. P6666
问题描述:
有一棵树,每个操作可以增加/删除一条链,链有一个权值。
每个操作后求任选一条链,使得被这条链经过的已知链的权值最大。
记被一条链 \(u-v\) 经过的链的权值和为 \(w(u,v)\)
第一步转换:有没有简单方法统计 \(w(u,v)\) 且不重不漏
对于每个点分别统计:
-
经过该点 \(x\) 的链权值和( 记为 \(f_x\) )
-
路径转点为 \(x\) 的链的权值和 ( 记为 \(g_x\) ) 。即被统计的链的两端点的 \(lca\) 是 \(x\) 。
这样当要统计 \(w(u,v)\) 时,先找到 \(lca(u,v)\) ,则:
第二步:普通 \(DP\)
设 \(t(x,y)=\sum_{i\in u-v} g_i\)
设 \(dp_x\) 表示 \(x\) 子树内的任选一条链的最大 \(w\) 值。
设 \(link_x\) 表示 \(x\) 子树内任选一条端点之一为 \(x\) 的链的最大 \(t\) 值。
设 \(fir\) 表示最大的 \(link_{son}\) ,\(sec\) 表示次大的 \(link_{son}\)
设 \(S\) 表示 \(x\) 的儿子集合。
则有:
这样直接 \(DP\) 是静态的。
就可以想到进行动态 \(DP\) 。
第三步:升级为动态 \(DP\)
动态 \(DP\) 通常都会划分为重儿子贡献和轻儿子贡献,本题也一样。
设 \(l\_son\) 表示轻儿子,\(h\_son\) 表示重儿子。
设 \(fir_x\) 表示最大的 \(link_{l\_son}\) ,\(sec_x\) 表示次大的 \(link_{l\_son}\)
设 \(maxans_x\) 表示 \(max(dp_{l\_son})\) 。
那么:
据此设计矩阵:
因此题就基本做完了。
修改:让人头疼的环节
添加和删除本质上是一致的,这里以添加为例。
首先,动态 \(DP\) 的结构是树链剖分,而 \(g,f\) 的修改正好可以用树链剖分来做。
所以,添加一条路径时需要区间修改 \(f\) 的值,单点修改 \(g\) 的值。
区间修改需要打 \(tag\) 。
但是:你会发现根本无法打标记或单点修改:
因为矩阵中有一个:
\(max(maxans_x,\ fir_x+sec_x+f_x)\)
假设给一段区间中的 \(f_x\) 都加上 \(w\) ,这一项会变成:
\(max(maxans_x,fir_x+sec_x+f_x+w)\)
这不等于:
\(max(maxans_x,fir_x+sec_x+f_x)+w\)
关键变形:把 \(maxans\) 拆出去
\(maxans\) 是什么?是 \(x\) 所有轻儿子中最大的 \(dp\) 。
仔细观察转移矩阵:
我们发现 \(link\) 完全与 \(dp\) 无关,也就是说 \(dp\) 的改变不会引起 \(link\) 的改变。
所以也就是说 \(dp\) 的改变不会使 \(link\) 改变,也就不会反过来影响自己。
这意味着 \(fir_x+sec_x+f_x\) 与 \(maxans_x\) 不必在每一轮转移中去 \(max\) 。
甚至一个链顶的父亲的矩阵也只与链顶的 \(link\) 有关,这意味这我们完全可以全局维护 \(maxans\) 的最大值,而不是作为矩阵中的状态。
也就是说:
令矩阵
定义全局 \(multiset\)(即所有重链链顶的 \(dp'\) 值构成的集合):
最终结果就是:
其中 \(\max(global\_g)\) 表示取出 \(global_g\) 中的最大值,这就是每次操作后需要输出的结果。
1.修改 \(g_l\):单点加
当添加一条链 \(u \to v\) 时,设 \(l = lca(u, v)\),那么只有 \(g_l\) 增加 \(w\):
\(g_l\) 出现在矩阵的 \(M_x[1][1]\) 和 \(M_x[1][2]\) 中。它的改变会导致从 \(l\) 开始,它所在重链的矩阵乘积改变,从而影响链顶的 \(dp'\) 和 \(link\)。
由于 \(link\) 改变后,又会作为轻儿子贡献传给父亲,所以需要自底向上更新。
2.修改 \(f_x\):路径加
路径 \(u \to v\) 上的所有点 \(x\),都有:
因为矩阵 \(M_x\) 中,\(f_x\) 只出现在 \(M_x[0][1]\) 和 \(M_x[0][2]\),所以区间加 \(f\) 只需要给这两个位置加 \(w\)。

浙公网安备 33010602011771号