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

  • 发现:每个点的转移矩阵只与它的轻儿子的 \(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}\)

于是:

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

2.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}) \]

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

\[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) \]

2.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\) 表示函数复合,先算右边的。

2.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) \]

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

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

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

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

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

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

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

2.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\)

2.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. 向上跳链:如果链顶的父亲 \(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)\)

终于写完了这道题。

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