动态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 g_{i,1} = w_i + \sum_{\text{轻儿子}} f_{son,0} \] -
重链转移矩阵:
- 只考虑重儿子 $ son $,转移为:\[\begin{aligned} f_{i,0} &= g_{i,0} + \max(f_{son,0}, f_{son,1}) \\ f_{i,1} &= g_{i,1} + f_{son,0} \end{aligned} \]
- 构造矩阵(乘法定义为 $ C_{ij} = \max_k(A_{ik} + B_{kj}) $):\[\begin{bmatrix} g_{i,0} & g_{i,0} \\ g_{i,1} & -\infty \end{bmatrix} \times \begin{bmatrix} f_{son,0} \\ f_{son,1} \end{bmatrix} = \begin{bmatrix} f_{i,0} \\ f_{i,1} \end{bmatrix} \]
- 只考虑重儿子 $ son $,转移为:
-
发现:每个点的转移矩阵只与它的轻儿子的 \(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)
2.1 问题描述
有一棵树,初始只有一个节点。动态操作:每次添加一个叶子节点。每个节点的权值定义为:
它的所有儿子(不含其他子孙)的权值集合的 MEX(最小未出现的非负整数)。
要求每次添加后,输出所有节点的权值总和。
2.2 树链剖分
我们对树做重链剖分(离线建出整棵树再剖分)。每个节点有一个重儿子,其余是轻儿子。
对于节点 \(i\),它的权值 \(f_i\) 等于它所有儿子权值的 MEX。我们可以把它拆成两部分:
- 轻儿子的权值集合 \(S_i\)
- 重儿子的权值 \(f_{son}\)
于是:
2.3 把轻儿子打包成一个函数 \(g_i\)
我们把重儿子的权值 \(f_{son}\) 看作一个变量 \(x\),那么:
定义函数:
这个函数只依赖于轻儿子集合 \(S_i\),与重儿子无关。于是:
2.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\)
因此:
2.5 重链上的函数复合
一条重链从上到下是:
对于每个节点 \(u_j\),都有:
其中 \(f_{u_{k+1}}\) 是链底节点的重儿子,但是链底没有重儿子,所以实际上链底的函数是常函数(后面再说)。
于是:
整条链可以看成一个复合函数:
其中 \(\circ\) 表示函数复合,先算右边的。
2.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\)
所以:
2.7 用线段树维护一条重链的复合函数
对于一条重链,我们可以在线段树上维护每个区间对应的复合函数。
线段树叶子节点:对应一个节点 \(u\),存 \(g_u = (m_0, m_0, m_1)\)。
非叶子节点:设左儿子区间代表链的上半段(靠近链顶),右儿子代表下半段(靠近链底)。那么该区间的函数就是:
也就是先应用右儿子的函数,再应用左儿子的函数(因为左儿子在链的更上方)。
合并时直接用上面的复合公式即可。
2.8 链底节点的处理
链底节点没有重儿子,它的权值 \(f_{u_k}\) 只由轻儿子决定:
即 \(f_{u_k} = m_0\),与输入无关。我们可以把它的函数设为常函数:
这里 \(p\) 可以任意(因为输入无意义,永远不会用到),\(q\) 和 \(r\) 都设为 \(m_0\),表示无论输入什么,输出都是 \(m_0\)。
2.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)\)。
- 向上跳链:如果链顶的父亲 \(fa_{top}\) 存在,那么原来链顶的权值会作为 \(fa_{top}\) 的一个轻儿子的权值,我们需要:
- 从 \(S_{fa_{top}}\) 中删除旧的 \(f_{top}\)
- 加入新的 \(f_{top}\)
- 重新计算 \(g_{fa_{top}}\) 并更新其所在链
- 继续循环,直到根
因为函数复合是 \(O(1)\),线段树更新是 \(O(\log n)\),跳链次数 \(O(\log n)\),所以总复杂度 \(O(\log^2 n)\)。
终于写完了这道题。

浙公网安备 33010602011771号