树上DP优化学习笔记

这里列一点东西,以后继续补充。

  • 更换枚举顺序,让一些不在dp式子左边的变量出现在最内层,方便接下来优化。
  • (感觉非常常用)预处理后缀/前缀和,省去一些连续的枚举
  • 删掉无用状态
  • 树链剖分

CF1249F Maximum Weight Subset

题意

给你一棵树,有 $n$ 个节点,每个节点有点权 $a_i$。你需要选出若干个节点,满足任意两个之间距离(即简单路径上的边数)$\gt k$,求点权和的最大值。

思路

首先不难想到,我们可以设计一个状态 $f_{u,i}$ 表示 $u$ 子树中,$u$ 到最近一个选中的节点距离为 $i$,且子树中选中的节点距离 $\gt k$ 时,最大的权值和。

那么就直接按照这个思路来写一个转移。

int f[220][220],tmp[220];
void dfs(int u,int fa){
    f[u][0]=a[u];
    for(int v:g[u]){
        if(v==fa)continue;
        dfs(v,u);
        // 从v到u,所有状态中的距离都要增加1
        memcpy(tmp+1,f[v],219*4);
        tmp[0]=0;
        memcpy(f[v],tmp,220*4);
        // 单走一边
        for(int i=0;i<=n;++i)
            tmp[i]=max(f[u][i],f[v][i]);
        // 将v,u合并
        for(int i=0;i<=n;++i){
            for(int j=0;j<=n;++j)
                if(i+j>k)tmp[min(i,j)]=max(tmp[min(i,j)],f[u][i]+f[v][j]);
        }
        memcpy(f[u],tmp,sizeof tmp);
    }
}

可以发现这是 $\Theta(n^3)$ 的。

那么我们就可以开始优化啦。

先看第一条,我们的转移式子左边有一个 min(i,j),这要求我们要枚举 $i,j$,不太舒服。所以可以直接枚举 $min(i,j)$ 的取值,然后选择让 $i$ 或者 $j$ 来 $=min(i,j)$。

int f[220][220],tmp[220];
void dfs(int u,int fa){
    f[u][0]=a[u];
    for(int v:g[u]){
        if(v==fa)continue;
        dfs(v,u);
        // 从v到u,所有状态中的距离都要增加1
        memcpy(tmp+1,f[v],219*4);
        tmp[0]=0;
        memcpy(f[v],tmp,220*4);
        // 单走一边
        for(int i=0;i<=n;++i)
            tmp[i]=max(f[u][i],f[v][i]);
        // 将v,u合并
        for(int i=0;i<=n;++i){
-           for(int j=0;j<=n;++j)
-               if(i+j>k)tmp[min(i,j)]=max(tmp[min(i,j)],f[u][i]+f[v][j]);
+           for(int j=i;j<=n;++j){
+               if(i+j>k)tmp[i]=max(tmp[i],f[u][i]+f[v][j]);
+               if(i+j>k)tmp[i]=max(tmp[i],f[u][j]+f[v][i]);
+           }
        }
        memcpy(f[u],tmp,sizeof tmp);
    }
}

注意到还有一个 if(i+j>k) 限制了 $j$ 的范围,我们考虑把它直接融入 for 中。

int f[220][220],tmp[220];
void dfs(int u,int fa){
    f[u][0]=a[u];
    for(int v:g[u]){
        if(v==fa)continue;
        dfs(v,u);
        // 从v到u,所有状态中的距离都要增加1
        memcpy(tmp+1,f[v],219*4);
        tmp[0]=0;
        memcpy(f[v],tmp,220*4);
        // 单走一边
        for(int i=0;i<=n;++i)
            tmp[i]=max(f[u][i],f[v][i]);
        // 将v,u合并
        for(int i=0;i<=n;++i){
-           for(int j=i;j<=n;++j){
-               if(i+j>k)tmp[i]=max(tmp[i],f[u][i]+f[v][j]);
-               if(i+j>k)tmp[i]=max(tmp[i],f[u][j]+f[v][i]);
+           for(int j=max(i,k-i+1);j<=n;++j){
+               tmp[i]=max(tmp[i],f[u][i]+f[v][j]);
+               tmp[i]=max(tmp[i],f[u][j]+f[v][i]);
            }
        }
        memcpy(f[u],tmp,sizeof tmp);
    }
}

好了,到此就达成了更换枚举顺序,让一些不在dp式子左边的变量出现在最内层,方便接下来优化。

那么接下来就可以考虑前缀/后缀和了。

我们直接把j这一层循环用后缀max处理掉。

int f[220][220],tmp[220],tmp1[220],tmp2[220];
void dfs(int u,int fa){
    f[u][0]=a[u];
    for(int v:g[u]){
        if(v==fa)continue;
        dfs(v,u);
        // 从v到u,所有状态中的距离都要增加1
        memcpy(tmp+1,f[v],219*4);
        tmp[0]=0;
        memcpy(f[v],tmp,220*4);
        // 单走一边
        for(int i=0;i<=n;++i)
            tmp[i]=max(f[u][i],f[v][i]);
+       // 预处理后缀max
+       for(int i=n;i>=0;--i)
+           tmp1[i]=max(tmp1[i+1],f[u][i]),
+           tmp2[i]=max(tmp2[i+1],f[v][i]);
        // 将v,u合并
        for(int i=0;i<=n;++i){
-           for(int j=max(i,k-i+1);j<=n;++j){
-               tmp[i]=max(tmp[i],f[u][i]+f[v][j]);
-               tmp[i]=max(tmp[i],f[u][j]+f[v][i]);
-           }
+           tmp[i]=max(tmp[i],f[u][i]+tmp2[max(i,k-i+1)]);
+           tmp[i]=max(tmp[i],tmp1[max(i,k-i+1)]+f[v][i]);
        }
        memcpy(f[u],tmp,sizeof tmp);
    }
}

这样就是 $\Theta(n^2)$ 的了。

进一步优化,我们需要找到是什么导致了 $\Theta(n^2)$。首先我们可以发现,之前的 memcpy 以及之后的转移我们都是以 $n$ 为上界的,但我们发现并不一定达到 $n$,只能达到 $maxdep$。所以可以先缩减一下范围。缩减完我们就发现这个其实是 $\Theta(n \log n)$ 的。

int f[220][220],tmp[220],tmp1[220],tmp2[220],dep[220];
void dfs(int u,int fa){
    f[u][0]=a[u];
    dep[u]=1;
    for(int id=hd[u];id;id=ed[id].nxt){
        int v=ed[id].to;
        if(v==fa)continue;
        dfs(v,u);
        dep[v]++;
        for(int i=dep[v];i>=1;--i)f[v][i]=f[v][i-1];
        f[v][0]=0;
        for(int i=0;i<=max(dep[u],dep[v]);++i)tmp[i]=max(f[u][i],f[v][i]);
        tmp1[dep[u]+1]=tmp2[dep[v]+1]=0;
        for(int i=dep[u];i>=0;--i)tmp1[i]=max(tmp1[i+1],f[u][i]);
        for(int i=dep[v];i>=0;--i)tmp2[i]=max(tmp2[i+1],f[v][i]);
        for(int i=0;i<=min(dep[u],dep[v]);++i){
            if(max(i,k+1-i)<=dep[v])tmp[i]=max(tmp[i],f[u][i]+tmp2[max(i,k+1-i)]);
            if(max(i,k+1-i)<=dep[u])tmp[i]=max(tmp[i],tmp1[max(i,k+1-i)]+f[v][i]);
        }
        memcpy(f[u],tmp,(max(dep[u],dep[v])+1)*4);
    }
}

注意到我们的代码中出现了一个 min(dep[u],dep[v]),也就是说其实真正受影响的是 $[0,min(dep[u],dep[v])]$ 部分,而 $[min(dep[u],dep[v])+1,n]$ 则是直接继承过来的。那么联想到长链剖分的思想,我们可以将短儿子合并到长儿子,然后在长儿子头部插入一个 $0$。

插入操作,可以通过 deque.push_front 操作,或者将整个 $f[u]$ 倒置存放于 vector 直接 emplace_back。这里据说前者并不是非常 $\Theta(1)$,所以选择后一种实现方式。

int tmp[220],tmp1[220],tmp2[220],dep[220];
vector<int> F[220];int allo=0;
struct nbf{
    int vid;
    inline int& operator[] (int i){return F[vid][F[vid].size()-1-i];}
    inline int operator[] (int i) const {return F[vid][F[vid].size()-1-i];}
    inline void push_front(int x){F[vid].emplace_back(x);}
    inline int front(){return F[vid].back();}
    inline int size(){return F[vid].size();}
}f[220];

void dfs(int u,int fa){
    dep[u]=1;
    int mxid=-1;
    for(int id=hd[u];id;id=ed[id].nxt){
        int v=ed[id].to;
        if(v==fa)continue;
        dfs(v,u);
        dep[v]++;
        if(dep[v]>dep[u])dep[u]=dep[v],mxid=v;
    }
    if(mxid==-1){
        f[u].vid=++allo;
    }else{
        f[u]=f[mxid];
    }
    f[u].push_front(max(a[u]+(f[u].size()>=k+1?f[u][k]:0),(f[u].size()?f[u].front():0)));
    for(int id=hd[u];id;id=ed[id].nxt){
        int v=ed[id].to;
        if(v==fa||v==mxid)continue;
        f[v].push_front(0);
        nbf tmp=nbf{n+1};
        F[n+1].assign(F[f[v].vid].begin(),F[f[v].vid].end());
        for(int i=0;i<dep[v];++i)tmp[i]=max(tmp[i],f[u][i]);
        for(int i=0;i<dep[v];++i){
            int oi=max(i,k+1-i);
            if(oi<dep[v])tmp[i]=max(tmp[i],f[u][i]+f[v][oi]);
            if(oi<dep[u])tmp[i]=max(tmp[i],f[u][oi]+f[v][i]);
        }
        f[u][dep[v]-1]=tmp[dep[v]-1];
        for(int i=dep[v]-2;i>=0;--i)f[u][i]=max(f[u][i+1],tmp[i]);
    }
}

那么理论上就 $\Theta(n)$ 了吧。

收获

  • 如果推出来一个复杂度很差的DP式子不要直接扔掉,可以先写一写,说不定能优化得非常棒。
  • 树上的dp可以用合并/提升的角度来转移,从这个角度也能方便想到启发式合并的优化。
  • 一般降低dp复杂度的方法都是将一部分内循环预处理。
  • 简单题也不能简单写完就跑路了,还是得深入思考。