动态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}\)

于是:

\[f_i = \text{MEX}(S_i \cup \{f_{son}\}) \]

3. 把轻儿子打包成一个函数 \(g_i\)

我们把重儿子的权值 \(f_{son}\) 看作一个变量 \(x\),那么:

\[f_i = \text{MEX}(S_i \cup \{x\}) \]

定义函数:

\[g_i(x) = \text{MEX}(S_i \cup \{x\}) \]

这个函数只依赖于轻儿子集合 \(S_i\),与重儿子无关。于是:

\[f_i = g_i(f_{son}) \]

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\) 里了。因此:

\[g_i(x) = m_0 \]

情况 2: \(x = m_0\)

此时 \(m_0\) 出现了,所以最小未出现的数变成了下一个缺失的数,也就是 \(m_1\)。因此:

\[g_i(m_0) = m_1 \]

所以 \(g_i\) 只在输入等于 \(m_0\) 时输出 \(m_1\),其他情况都输出 \(m_0\)。这种函数可以用一个三元组表示:

\[(p, q, r) \]

含义:

  • 输入 \(x = p\) 时,输出 \(r\)
  • 输入 \(x \neq p\) 时,输出 \(q\)

因此:

\[g_i = (m_0, m_0, m_1) \]

5. 重链上的函数复合

一条重链从上到下是:

\[u_1 \to u_2 \to \dots \to u_k \]

对于每个节点 \(u_j\),都有:

\[f_{u_j} = g_{u_j}(f_{u_{j+1}}) \]

其中 \(f_{u_{k+1}}\) 是链底节点的重儿子,但是链底没有重儿子,所以实际上链底的函数是常函数(后面再说)。

于是:

\[f_{u_1} = g_{u_1}(g_{u_2}(\dots g_{u_k}(*) \dots)) \]

整条链可以看成一个复合函数:

\[F_{chain} = g_{u_1} \circ g_{u_2} \circ \dots \circ g_{u_k} \]

其中 \(\circ\) 表示函数复合,先算右边的。

6. 两个函数的复合规则

假设有两个函数:

\[F_1 = (p_1, q_1, r_1), \quad F_2 = (p_2, q_2, r_2) \]

我们想求 \(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\) 这一个点特殊,所以它也可以表示为三元组:

\[H = (p_2, F_1(q_2), F_1(r_2)) \]

而 \(F_1(v)\) 的计算很简单:

\[F_1(v) = \begin{cases} r_1, & v = p_1 \\ q_1, & v \neq p_1 \end{cases} \]

关键:复合两个函数只需要 \(O(1)\) 时间。

复合例子:

设:

\[F_1 = (2, 5, 9), \quad F_2 = (4, 3, 2) \]

求 \(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\)

所以:

\[H = (4, 5, 9) \]

7. 用线段树维护一条重链的复合函数

对于一条重链,我们可以在线段树上维护每个区间对应的复合函数。

线段树叶子节点:对应一个节点 \(u\),存 \(g_u = (m_0, m_0, m_1)\)。

非叶子节点:设左儿子区间代表链的上半段(靠近链顶),右儿子代表下半段(靠近链底)。那么该区间的函数就是:

\[F_{node} = F_{left} \circ F_{right} \]

也就是先应用右儿子的函数,再应用左儿子的函数(因为左儿子在链的更上方)。

合并时直接用上面的复合公式即可。

8. 链底节点的处理

链底节点没有重儿子,它的权值 \(f_{u_k}\) 只由轻儿子决定:

\[f_{u_k} = \text{MEX}(S_{u_k}) \]

即 \(f_{u_k} = m_0\),与输入无关。我们可以把它的函数设为常函数:

\[g_{u_k} = (0, m_0, m_0) \]

这里 \(p\) 可以任意(因为输入无意义,永远不会用到),\(q\) 和 \(r\) 都设为 \(m_0\),表示无论输入什么,输出都是 \(m_0\)。

9. 动态加叶子的更新流程

每次添加一个叶子 \(v\) 作为节点 \(u\) 的儿子。

  1. 更新轻儿子集合:将 \(v\) 的权值加入 \(S_u\),同时可能需要删除旧的链顶权值(当跳链时)。
  2. 重新计算 \(g_u\):根据新的 \(S_u\) 计算 \(m_0, m_1\),得到新的 \(g_u = (m_0, m_0, m_1)\)。
  3. 更新线段树:将节点 \(u\) 对应的叶子节点更新为新的 \(g_u\),然后向上更新整条链的复合函数。
  4. 查询链顶权值:取出整条链的复合函数 \(F_{chain}\),随便代入一个值(例如 0)得到链顶 \(top\) 的权值 \(f_{top} = F_{chain}(0)\)。
  5. 向上跳链:如果链顶的父亲 \(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)\) 且不重不漏

对于每个点分别统计:

  1. 经过该点 \(x\) 的链权值和( 记为 \(f_x\) )

  2. 路径转点为 \(x\) 的链的权值和 ( 记为 \(g_x\) ) 。即被统计的链的两端点的 \(lca\) 是 \(x\) 。

这样当要统计 \(w(u,v)\) 时,先找到 \(lca(u,v)\) ,则:

\[w(u,v)=f_{lca(u,v)}+\sum_{i\in u-v,i\neq lca(u,v)} g_i \]

第二步:普通 \(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_x=max_{son\in S}(dp_{son},fir+sec+f_x) \]

\[link_x=max_{son\in S}(link_{son}+g_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_x=max(max(dp_{h\_son},maxans_x),max(link_{h\_son}+fir_x,fir_x+sec_x)+f_x) \]

\[link_x=max(link_{h\_son},fir_x)+g_x \]

据此设计矩阵:

\[\begin{bmatrix} dp_x \\ link_x \\ 0 \end{bmatrix} = \begin{bmatrix} 0 & fir_x+f_x & \max(maxans_x,\ fir_x+sec_x+f_x) \\ -\infty & g_x & fir_x+g_x \\ -\infty & -\infty & 0 \end{bmatrix} \begin{bmatrix} dp_{h\_son} \\ link_{h\_son} \\ 0 \end{bmatrix} \]

因此题就基本做完了。

修改:让人头疼的环节

添加和删除本质上是一致的,这里以添加为例。

首先,动态 \(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\) 的最大值,而不是作为矩阵中的状态。

也就是说:

令矩阵

\[M_x = \begin{bmatrix} 0 & fir_x+f_x & fir_x+sec_x+f_x \\ -\infty & g_x & fir_x+g_x \\ -\infty & -\infty & 0 \end{bmatrix} \]

定义全局 \(multiset\)(即所有重链链顶的 \(dp'\) 值构成的集合):

\[global\_g = \left\{ \begin{bmatrix} 1 & 0 & 0 \end{bmatrix} \left( \prod_{x \in \text{重链}(top \to bottom)} M_x \right) \begin{bmatrix} 0 \\ 0 \\ 1 \end{bmatrix} \;\middle|\; top \in \text{所有重链链顶} \right\} \]

最终结果就是:

\[Ans = \max(global\_g) \]

其中 \(\max(global\_g)\) 表示取出 \(global_g\) 中的最大值,这就是每次操作后需要输出的结果。

1.修改 \(g_l\):单点加

当添加一条链 \(u \to v\) 时,设 \(l = lca(u, v)\),那么只有 \(g_l\) 增加 \(w\):

\[g_l \leftarrow g_l + w \]

\(g_l\) 出现在矩阵的 \(M_x[1][1]\) 和 \(M_x[1][2]\) 中。它的改变会导致从 \(l\) 开始,它所在重链的矩阵乘积改变,从而影响链顶的 \(dp'\) 和 \(link\)。

由于 \(link\) 改变后,又会作为轻儿子贡献传给父亲,所以需要自底向上更新。

2.修改 \(f_x\):路径加

路径 \(u \to v\) 上的所有点 \(x\),都有:

\[f_x \leftarrow f_x + w \]

因为矩阵 \(M_x\) 中,\(f_x\) 只出现在 \(M_x[0][1]\) 和 \(M_x[0][2]\),所以区间加 \(f\) 只需要给这两个位置加 \(w\)。

posted @ 2026-09-06 18:04  wlhao  阅读(27)  评论(0)    收藏  举报