树形DP--从入门到入土

树形依赖背包

一般形式:给定一颗\(n\)个节点的点权树,要求选出\(m\)个节点使得这些选出的节点的点权和最大,一个节点能选当且仅当其父亲节点被选中,根节点能直接选。

一般解法:

\(f_{u,i}\)表示在\(u\)的子树中选择\(i\)个节点(包括本身)的最大价值,转移方程为:$$f_{u,i} = max(f_{u,j} + f_{v, i - j} + d_v) [j = 1 ... i - 1]$$

其中\(d_v\)表示\(v\)的点权,\(i-j\)表示在子树\(v\)中选择\(i - j\)个节点。

遍历复杂度\(O(n)\),总复杂度\(O(nm^2)\)

优化:

一般有两种方式可以优化到\(O(nm)\)

  1. 树的孩子兄弟表示法:一种将多叉树变为二叉树的常用方法,就是将每个点与它的第一个儿子连边,然后将它的儿子依次连接起来,如图:

    \(f_{i,j}\)为以\(i\) 为根的子树中用大小为\(j\)的包能取到的最大价值,那么转移方程为:$$f_{i, j} = max(f_{left[i],j-w[i]} + v[i], f_{right[i],j})$$

    其中,\(left_i\)\(i\)在原树中的第一个儿子(即二叉树中的左儿子),\(right_i\)\(i\)在原树中的下一个兄弟(即二叉树中的右儿子)

  2. DFS序法:对整棵树求出\(DFS\)序与子树大小\(siz\),那么若根节点为\(u\),第一个儿子即为\(dfn_u + 1\),第二个儿子为\(dfn_u + siz_{firstson} + 1\)

    \(f_{i,j}\)为当前DP到\(DFS\)序为\(i\)的点,目前已选\(j\)个点,则转移方程为:

    • 选当前点:\(f_{i + 1,j + 1} = f_{i,j} + d_i\)

      因为\(i+1\)号节点为\(i\)的儿子或者兄弟,在选\(i\)之后都是可选的

    • 不选当前节点:\(f_{nx[i],j} = f_{i, j}\)

      其中\(nx[i]\)表示下一颗子树,因为没有选\(i\),所以不能选\(i\)的子节点

以上优化都是将转移降到了\(O(1)\),但它们只适用于点权问题。

分组树形背包

例如:Luogu1272 重建道路

此时,父亲与儿子之间并不存在依赖关系,我们设\(f_{k,i,j}\)为以\(i\)为根的子树,在前\(t\)个儿子中,分离出一个大小为\(j\)的子树的最小代价,则对于每一个儿子\(v\)

\[f_{t + 1,i,j} = min(f_{t,i,j - k} + f_{fullson[v],v,k} - 2) \]

其中,\(fullson[v]\)表示\(v\)的儿子个数。

有了这个转移方程,我们就可以在\(DFS\)时DP了。

不过,这样的空间开销太大了,我们可以参照01背包的降维优化,通过逆序枚举\(j\)来把\(t\)那一维消掉

\[f_{i,j} = min(f_{i,j - k} + f_{v,k} - 2) \]

初始化时,将\(f_{i,1} = ind[i]\),其中,\(ind_i\)为与\(i\)有连边的节点数。因为将两个点合并到一个点集中时每一个点都会少一条出边,所以要\(-2\)

参考代码:

#include <cstdio>
#include <algorithm>
#include <cstring>

using namespace std;

const int maxn = 1e4 + 10;
int f[200][200],n,head[maxn],num,ind[maxn],K;
struct Edge{
    int then,to;
}e[maxn];

void add(int u, int v){e[++num] = (Edge){head[u], v}; head[u] = num;}

void DFS(int u, int fa){
    f[u][1] = ind[u];
    for(int i = head[u]; i; i = e[i].then){
        int v = e[i].to;
        if(v == fa) continue;
        DFS(v, u);
        for(int j = K; j; -- j)
            for(int k = 0; k <= j; ++ k)
                f[u][j] = min(f[u][j], f[u][j - k] + f[v][k] - 2);
    }
}

int Ans = 0x3f3f3f3f;
int main(){
    scanf("%d%d", &n, &K); 
    for(int i = 1; i < n; ++ i){
        int u,v; scanf("%d%d", &u, &v);
        add(u, v); add(v, u);
        ind[v]++; ind[u]++;
    }
    memset(f,0x3f,sizeof(f));
    DFS(1, 0);
    for(int i = 1; i <= n; ++ i) Ans = min(Ans, f[i][K]);
    printf("%d\n", Ans);
    return 0;
}

不过,这一方法只适用于处理边权,用于点权就效率太低下了。

换根DP(二次扫描法)

一般来说,我们会默认\(1\)为树的根,但是有些题目要求计算以每一个节点为根时的内容

朴素想法是枚举每一个点作为根时的情况,复杂度\(O(n^2)\),显然太高了;

正解:换根DP,复杂度\(O(n)\)

大致思路:

  1. \(1\)为根\(DFS\)一遍,统计出所需要的数据和以\(1\)为根的答案;
  2. \(1\)开始再次\(DFS\),每次从节点\(u\)\(v\)时,计算出树根从\(u\)转移到\(v\)时的贡献变化。

例题:Luogu3748 [POI2008]STA-Station

题意:给定一个 \(n\)个点的树,请求出一个结点,使得以这个结点为根时,所有结点的深度之和最大。

一个结点的深度之定义为该节点到根的简单路径上边的数量。

思路:按照刚才的想法,我们先\(DFS\)一遍,求出每个节点的子树大小\(siz\),并求出以\(1\)为根的答案\(f_1\)

第二遍\(DFS\),当根从\(u\)转移到\(v\)时,\(v\)子树内(含\(v\))节点的深度\(-1\),其他节点的深度\(+1\),所以可以得到下面的转移方程:

\[f_v = f_u + n - 2 \times siz_v \]

最后统计最大值就可以了。

参考代码:

#include <cstdio>
#define LL long long

using namespace std;

const int maxn = 1e6 + 10;
int n,head[maxn << 1],num;
LL f[maxn];
struct Edge{
    int then,to;
}e[maxn << 1];

void add(int u, int v){e[++num] = (Edge){head[u], v}; head[u] = num;}

int siz[maxn];
void DFS1(int u, int fa, int deep){
    siz[u] = 1;
    for(int i = head[u]; i; i = e[i].then){
        int v = e[i].to;
        if(v == fa) continue;
        DFS1(v, u, deep + 1); 
        siz[u] += siz[v]; f[u] += deep + 1;
    }
}

void DFS2(int u, int fa){
    for(int i = head[u]; i; i = e[i].then){
        int v = e[i].to;
        if(v == fa) continue;
        f[v] = f[u] + n - 2 * siz[v];
        DFS2(v, u);
    }
}

int main(){
    scanf("%d", &n);
    for(int i = 1; i < n; ++ i){
        int u,v; scanf("%d%d", &u, &v);
        add(u, v); add(v, u);
    }
    DFS1(1, 0, 0); DFS2(1, 0);
    int Max = 0;
    for(int i = 1; i <= n; ++ i)
        if(f[i] > f[Max]) Max = i;
    printf("%d\n", Max);
    return 0;
}

基环树DP

一般思路:断环为链,再分类讨论

例题:Luogu1453 城市环路

题意:给定\(N\)个点,\(N\)条边,保证任意两点间至少存在一条路径。其中每个点均有其权值\(val_i\),问如何选择点,使得在保证任意直接相连的两点不会同时被选中的情况下,被选中的点的权值和最大?

思路:首先,假如本题不是基环树,那么普通树形DP就可以搞定。而基环树DP的核心就是把基环树上问题转化为普通树上问题。考虑删去基环上的边\(E_i\),边的端点为\(u\)\(v\)。我们将\(u\)作为新根,可以求得\(f_{u,0}\)\(f_{u,1}\)(其中\(f_{i,0/1}\)表示以\(i\)为根节点的子树选/不选\(i\)时的最大权值和)。在实际的图中,\(u\)\(v\)是不能同时取到的,为了保证答案合法,我们把\(f_{u, 0}\)记为临时答案

显然,这个答案并不能保证是最优的,因为答案可能要包含\(u\)

如何解决这个问题?我们可以再以\(v\)作为新根,最做一遍树规,取上次的临时答案与\(f_{v,0}\)\(max\)即可。

如何保证答案最优?

当我们以\(u\)为根进行树规时,已经把除选择点\(u\)以外的最优情况求出,而当以\(v\)为根时,又将选择\(u\)的情况求出了。由于不能同时选择\(u\)\(v\),所以答案必然最优

参考代码:

#include <cstdio>
#include <algorithm>
#include <cstring>

using namespace std;

const int maxn = 1e5 + 10;
int n,f[maxn][2],head[maxn << 1],num,val[maxn];
int root,x,y;
double k;
struct Edge{
    int then,to;
}e[maxn << 1];

void add(int u, int v){e[++num] = (Edge){head[u], v}; head[u] = num;}

int vis[maxn],flag;
void DFS1(int u, int fa){
    vis[u] = 1;
    for(int i = head[u]; i; i = e[i].then){
        int v = e[i].to;
        if(v == fa) continue;
        if(flag) return;
        if(vis[v]){
            x = u, y = v; flag = 1;
            return;
        } 
        DFS1(v, u);
    }
}

void DFS2(int u, int fa){
    f[u][1] = val[u];
    for(int i = head[u]; i; i = e[i].then){
        int v = e[i].to;
        if(v == fa) continue;
        if((u == x && v == y) || (u == y && v == x)) continue;
        DFS2(v, u);
        f[u][0] += max(f[v][0], f[v][1]);
        f[u][1] += f[v][0];
    }
}

int main(){
    scanf("%d", &n);
    for(int i = 1; i <= n; ++ i) scanf("%d", val + i);
    for(int i = 1; i <= n; ++ i){
        int u,v; scanf("%d%d", &u, &v);
        u += 1, v += 1;
        add(u, v); add(v, u);
    } DFS1(1, 1);
    scanf("%lf", &k);
    memset(f,0,sizeof(f));
    root = x, DFS2(root, root);
    int Ans = f[root][0];
    memset(f,0,sizeof(f));
    root = y, DFS2(root, root);
    Ans = max(Ans, f[root][0]);
    printf("%.1lf\n", Ans * k);
    return 0;
}

虚树

有时候,题目会给出一颗\(n\)\(1e5\)级别的树,每次指定\(m\)个节点,给它们一些性质,然后求答案,保证\(\sum m\)\(n\)为同一级别。如果我们用朴素的树形DP,复杂度是基于\(n\)的,会\(T\)到飞起。

因此,我们可以用单调栈建出一颗“虚树”,它的节点数是\(m\)级别的,同时又能保证答案的正确性。

建树:OI-Wiki上讲得很详细(因为略麻烦,这里不展开阐述当然不是我懒

模板题:Luogu2495 [SDOI2011]消耗战

思路:每次建出虚树,DP即可(注意在每一次初始化时保证效率

参考代码:

#include <cstdio>
#include <algorithm>
#include <vector>
#include <cstring>
#define LL long long

using namespace std;

const int maxn = 6e5 + 10;
int n,m,k,now[maxn];
int head[maxn << 1],num,cur[maxn],cnt;
struct Edge{
    int then,to;
    LL val;
}t[maxn << 1];
vector<int> e[maxn];

void Add(int u, int v, LL val){t[++cnt] = (Edge){cur[u], v, val}; cur[u] = cnt;}

LL fa[maxn][30],dep[maxn],val[maxn],id[maxn],_num;
void DFS(int u, int f){
    dep[u] = dep[f] + 1;
    fa[u][0] = f; id[u] = ++_num;
    for(int i = 1; i <= 18; ++ i) fa[u][i] = fa[fa[u][i - 1]][i - 1];
    for(int i = cur[u]; i; i = t[i].then){
        int v = t[i].to;
        if(v == f) continue;
        val[v] = min(val[u], t[i].val);
        DFS(v, u);
    }
}

int LCA(int x, int y){
    if(dep[x] < dep[y]) swap(x, y);
    for(int i = 18; i >= 0; -- i)
        if(dep[fa[x][i]] >= dep[y]) x = fa[x][i];
    if(x == y) return x;
    for(int i = 18; i >= 0; -- i)
        if(fa[x][i] != fa[y][i]) x = fa[x][i], y = fa[y][i];
    return fa[x][0];
}

bool cmp(int x, int y){return id[x] < id[y];}

int sta[maxn],top;
void build(){
    sort(now + 1, now + 1 + k, cmp);
    sta[1] = 1, top = 1; e[1].clear();
    for(int i = 1; i <= k; ++ i){
        if(top == 1){sta[++top] = now[i]; continue;}
        int pos = now[i];
        int L = LCA(pos, sta[top]);
        if(L == sta[top]) continue;
        while(id[L] <= id[sta[top - 1]] && top > 1) e[sta[top - 1]].push_back(sta[top]), top--;
        if(L != sta[top]) e[L].push_back(sta[top]), sta[top] = L;
        sta[++top] = pos;
    }
    while(top > 0) e[sta[top - 1]].push_back(sta[top]), top--;
    return;
}

LL dfs(int u, int fa){
    if(e[u].size() == 0) return val[u];
    LL tmp = 0;
    for(int i = 0; i < e[u].size(); ++ i) tmp += dfs(e[u][i], u);
    e[u].clear();
    return min(tmp, (LL)val[u]);
}

int main(){
    scanf("%d", &n);
    memset(val,0x7f,sizeof(val));
    for(int i = 1; i < n; ++ i){
        int u,v; LL val; scanf("%d%d%lld", &u, &v, &val);
        Add(u, v, val); Add(v, u, val);
    } 
    DFS(1, 1); scanf("%d", &m);
    while(m--){
        scanf("%d", &k);
        for(int i = 1; i <= k; ++ i) scanf("%d", &now[i]);
        build();
        printf("%lld\n", dfs(1, 1));
    }
    return 0;
}

练习题

[HAOI2009]毛毛虫 题解

[POI2013]LUK-Triumphal arch 题解

CF219D 题解

Luogu1131 时态同步

Noip2019 括号树

以上习题都较为基础,请结合自身情况使用

参考博客

https://blog.csdn.net/weixin_30278311/article/details/95302702?utm_medium=distribute.pc_relevant.none-task-blog-title-10&spm=1001.2101.3001.4242

http://blog.csdn.net/no1_terminator/article/details/77824790

https://www.cnblogs.com/wlzhouzhuan/p/12643056.html

https://blog.csdn.net/DorMOUSENone/article/details/54971697

https://blog.csdn.net/wu_tongtong/article/details/79219822

特别鸣谢

UltiMadowLuogu题单为本文提供思路

posted @ 2020-11-11 09:14  When_C  阅读(274)  评论(1编辑  收藏  举报