[DP总结]树形DP

Posted hkttg

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[DP总结]树形DP相关的知识,希望对你有一定的参考价值。

树形DP

树形DP,顾名思义,是在树上进行DP操作,因此往往需要使用DP实现,在转移时,通常先递归求出子树中的解,再在根节点进行计算转移。

树中只有两种关系,一种是父子关系,一种是平行关系。

下面是几道简单的树形DP。从中我们可以窥出树形DP的本质。

[luogu] P1352 没有上司的舞会

题目描述

某大学有N个职员,编号为1~N。他们之间有从属关系,也就是说他们的关系就像一棵以校长为根的树,父结点就是子结点的直接上司。现在有个周年庆宴会,宴会每邀请来一个职员都会增加一定的快乐指数Ri,但是呢,如果某个职员的上司来参加舞会了,那么这个职员就无论如何也不肯来参加舞会了。所以,请你编程计算,邀请哪些职员可以使快乐指数最大,求最大的快乐指数。

输入输出格式

输入格式:

第一行一个整数N。(1<=N<=6000)

接下来N行,第i+1行表示i号职员的快乐指数Ri。(-128<=Ri<=127)

接下来N-1行,每行输入一对整数L,K。表示K是L的直接上司。

最后一行输入0 0

输出格式:

输出最大的快乐指数。

输入输出样例

输入样例#1:

7
1
1
1
1
1
1
1
1 3
2 3
6 4
7 4
4 5
3 5
0 0

输出样例#1:

5

最经典的一道题之一。考虑这道题中每个人之间的关系,可以发现每个人的上司可以看做是他的父节点,并且呈树状分布,显然是树形DP的思路。由于如果某个职员的上司来参加舞会了,那么这个职员就无论如何也不肯来参加舞会,那么我们定义(dp[u][0/1])表示当前选(1)或不选(0)的最大可行值。那么DP转移方程显而易见:(dp[u][0]+=max(dp[v][0] + dp[v][1]))(dp[u][1]+=dp[v][0])其中(v in u.to)

#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstring>
#include <cstdlib>
#include <cstdio>
#include <ctime>
#include <iostream>
#include <map>
#include <queue>
#include <vector>
using namespace std;

const int MAXN = 6010;
int n, w[MAXN];
int fa[MAXN], dp[MAXN][2];

struct Edge {
    int nxt, to;
}e[MAXN];

template <typename _Tp>
inline void read(_Tp &x) {
    char ch = getchar( ); bool f = 0; x = 0;
    while (!isdigit(ch)) { if (ch == '-') f = 1; ch = getchar( ); }
    while (isdigit(ch)) { x = x * 10 + ch - '0'; ch = getchar( ); }
    x *= f;
}

int head[MAXN], tot;
inline void AddEdge(int x, int y) {
    e[++ tot].to = y, e[tot].nxt = head[x], head[x] = tot;
}

int Dfs(int u) {
    dp[u][0] = 0, dp[u][1] = w[u];
    for (int i = head[u], v; i; i = e[i].nxt) {
        v = e[i].to;
        Dfs(v);
        dp[u][0] += max(dp[v][0], dp[v][1]);
        dp[u][1] += dp[v][0];
    }
}

int main( ) {
    read(n);
    for (int i = 1; i <= n; ++ i) read(w[i]);
    int L, K; 
    for (int i = 1; i < n; ++ i) {
        read(L), read(K);
        AddEdge(K, L);
        fa[L] = K;
    }
    //寻找根节点
    int rt = 1;
    while (fa[rt]) rt = fa[rt];
    Dfs(rt);
    printf("%d
", max(dp[rt][0], dp[rt][1]));
    return 0;
}

上面的代码运行了29ms,不快不慢,标准树形DP。

还有一种做法,可以达到0ms,时间复杂度直逼(O(n))输入下界。代码是这样的:

#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstring>
#include <cstdlib>
#include <cstdio>
#include <ctime>
#include <iostream>
#include <map>
#include <queue>
#include <vector>
using namespace std;

const int MAXN = 6010;
int n, w[MAXN];
int dp[MAXN][2];

template <typename _Tp>
inline void read(_Tp &x) {
    char ch = getchar( ); bool f = 0; x = 0;
    while (!isdigit(ch)) { if (ch == '-') f = 1; ch = getchar( ); }
    while (isdigit(ch)) { x = x * 10 + ch - '0'; ch = getchar( ); }
    x *= f;
}

int main( ) {
    int ans = 0;
    read(n);
    for (int i = 1; i <= n; ++ i) read(dp[i][1]);
    int u, v;
    for (int i = 1; i < n; ++ i) {
        scanf("%d%d", &v, &u);
        dp[u][1] += dp[v][0];
        dp[u][0] += max(dp[v][0], dp[v][1]);
        ans = max(ans, max(dp[u][0], dp[u][1]));
    }
    printf("%d
", ans);
    return 0;
}

上述代码直接抛弃了显性的树形结构,对于每个输入的父子节点,在输入时直接用子节点更新父节点的最优值,利用了线性DP的思想。由此可见,树形DP是很灵活的,太过拘泥于树有时反而想不到最优解法。

但是相比较而言,树形递归要比递推的思路更加显然,更加清晰,因此在做一些比较复杂,不好递推的题目时,递归Dfs显然是必须的。

[luogu] P2014 选课

题目描述

在大学里每个学生,为了达到一定的学分,必须从很多课程里选择一些课程来学习,在课程里有些课程必须在某些课程之前学习,如高等数学总是在其它课程之前学习。现在有N门功课,每门课有个学分,每门课有一门或没有直接先修课(若课程a是课程b的先修课即只有学完了课程a,才能学习课程b)。一个学生要从这些课程里选择M门课程学习,问他能获得的最大学分是多少?

输入输出格式

输入格式:

第一行有两个整数N,M用空格隔开。(1<=N<=300,1<=M<=300)

接下来的N行,第I+1行包含两个整数ki和si, ki表示第I门课的直接先修课,si表示第I门课的学分。若ki=0表示没有直接先修课(1<=ki<=N, 1<=si<=20)。

输出格式:

只有一行,选M门课程的最大得分。

输入输出样例

输入样例#1:

7  4
2  2
0  1
0  4
2  1
7  1
7  6
2  2

输出样例#1:

13

还是显然的树形DP。首先要处理的问题是,关于本题的关系结构。细心的同学可能会发现,题目中并没有保证这是一棵树,准确来说,本题的关系是一个森林。对于这种结构,我们通常增加一个虚拟节点0,并从0向各个不连通的树的根连一条边,使之成为一颗真正的多叉树。题目中(k_i=0)表示没有直接先修课这个性质实际上简化了这一问题,这样只需正常读入即可保证是一棵根节点为0的树。

考虑(dp[i][j])表示以(i)为根,选了(j)个节点的最大值。那么转移方程就是(dp[i][j]=max(dp[v][k] + dp[i][j-k-1]+s[v]))。表示以(i)为根,选了(j-k-1)个节点之后,由于要从他的儿子中选节点他自己本身必须选,所以先选上(v)节点本身,再从他的一个儿子中选取(k)个节点。答案是(dp[0][m])

#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstring>
#include <cstdlib>
#include <cstdio>
#include <ctime>
#include <iostream>
#include <map>
#include <queue>
#include <vector>
using namespace std;

const int MAXN = 1010;
int n, m, s[MAXN];
int dp[MAXN][MAXN];

struct Edge {
    int nxt, to;
}e[MAXN];

template <typename _Tp>
inline void read(_Tp &x) {
    char ch = getchar( ); _Tp f = 0; x = 0;
    while (!isdigit(ch)) { if (ch == '-') f = 1; ch = getchar( ); }
    while (isdigit(ch)) { x = x * 10 + ch - '0'; ch = getchar( ); }
    x *= f;
}

int head[MAXN], tot;
inline void AddEdge(int x, int y) {
    e[++ tot].to = y, e[tot].nxt = head[x], head[x] = tot;
}

void Dfs(int u) { 
    for (int i = head[u], v; i; i = e[i].nxt) { 
        v = e[i].to; 
        Dfs(v); 
        for (int j = m; j; -- j) 
            for (int k = j - 1; k >= 0; -- k) 
                dp[u][j] = max(dp[u][j], dp[v][k] + dp[u][j - k - 1] + s[v]);
    }
}

int main( ) {
    read(n), read(m);
    int fa;
    for (int i = 1; i <= n; ++ i) {
        read(fa);
        read(s[i]);
        AddEdge(fa, i);
    }
    Dfs(0);
    printf("%d
", dp[0][m]);
    return 0;
}

观察这个转移方程,我们发现,它与分组背包十分类似。事实上,这道题就是一个经典的树上分组背包模型。

[luogu] P2015 二叉苹果树

题目描述

有一棵苹果树,如果树枝有分叉,一定是分2叉(就是说没有只有1个儿子的结点)

这棵树共有N个结点(叶子点或者树枝分叉点),编号为1-N,树根编号一定是1。

我们用一根树枝两端连接的结点的编号来描述一根树枝的位置。下面是一颗有4个树枝的树

2   5
  / 
  3   4
    /
    1

现在这颗树枝条太多了,需要剪枝。但是一些树枝上长有苹果。

给定需要保留的树枝数量,求出最多能留住多少苹果。

输入输出格式

输入格式:

第1行2个数,N和Q(1<=Q<= N,1<N<=100)。

N表示树的结点数,Q表示要保留的树枝数量。接下来N-1行描述树枝的信息。

每行3个整数,前两个是它连接的结点的编号。第3个数是这根树枝上苹果的数量。

每根树枝上的苹果不超过30000个。

输出格式:

一个数,最多能留住的苹果的数量。

输入输出样例

输入样例#1:

5 2
1 3 1
1 4 10
2 3 20
3 5 20

输出样例#1:

21

观察到本题与上一题十分类似,稍微不同的是,上一题是多叉树,本题是二叉树,这对DP本身没有什么实质性的影响。注意题目中说道“一些树枝上长有苹果”,也就是说苹果数量并不是点权而是边权,因此本题的状态转移与上一题有细微的差别(将点权变为边权)。还有一点不同是,题目中的读入数据并没有给定明确的父子关系,因此如何建树是一个问题。这种情况下最常见的方法是将边建成双向边。每次Dfs时,判断它是否往回指向了父节点,如果是直接跳过,不是则表明当前节点就是子节点。

#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstring>
#include <cstdlib>
#include <cstdio>
#include <ctime>
#include <iostream>
#include <map>
#include <queue>
#include <vector>
using namespace std;

const int MAXN = 1010;
int N, Q; 
int dp[MAXN][MAXN];

struct Edge {
    int nxt, to, val;
}e[MAXN << 1];

template <typename _Tp>
inline void read(_Tp &x) {
    char ch = getchar( ); _Tp f = 1; x = 0;
    while (!isdigit(ch)) { if (ch == '-') f = -1; ch = getchar( ); }
    while (isdigit(ch)) x = x * 10 + ch - '0', ch = getchar( );  
    x *= f;
}

int head[MAXN], tot;
void AddEdge(int x, int y, int z) {
    e[++ tot].to = y, e[tot].val = z, e[tot].nxt = head[x], head[x] = tot;
}

void Dfs(int u, int fa) {
    for (int i = head[u], v; ~i; i = e[i].nxt) {
        v = e[i].to;
        if (v == fa) continue;
        Dfs(v, u);
        for (int j = Q; j; -- j)
            for (int k = j - 1; k >= 0; -- k)
                dp[u][j] = max(dp[u][j], dp[v][k] + dp[u][j - k - 1] + e[i].val);
    }
}

int main( ) {
    memset(head, -1, sizeof(head));
    read(N), read(Q);
    int x, y, z;
    for (int i = 1; i < N; ++ i) {
        read(x), read(y), read(z);
        AddEdge(x, y, z);
        AddEdge(y, x, z);
    }
    Dfs(1, 0);
    printf("%d
", dp[1][Q]);
    return 0;
}

实际上可以加一个小优化,增加一个数组(cntE[i])表示以(i)为根的树的边数,那么转移为

void Dfs(int u, int fa) {
    for (int i = head[u], v; ~i; i = e[i].nxt) {
        v = e[i].to;
        if (v == fa) continue;
        Dfs(v, u);
        cntE[u] += cntE[v] + 1;
        for (int j = min(Q, cntE[u]); j; -- j)
            for (int k = min(j - 1, cntE[v]); k >= 0; -- k)
                dp[u][j] = max(dp[u][j], dp[v][k] + dp[u][j - k - 1] + e[i].val);
    }
}

然而并没有什么卵用

[luogu] P1273 有线电视网

题目描述

某收费有线电视网计划转播一场重要的足球比赛。他们的转播网和用户终端构成一棵树状结构,这棵树的根结点位于足球比赛的现场,树叶为各个用户终端,其他中转站为该树的内部节点。

从转播站到转播站以及从转播站到所有用户终端的信号传输费用都是已知的,一场转播的总费用等于传输信号的费用总和。

现在每个用户都准备了一笔费用想观看这场精彩的足球比赛,有线电视网有权决定给哪些用户提供信号而不给哪些用户提供信号。

写一个程序找出一个方案使得有线电视网在不亏本的情况下使观看转播的用户尽可能多。

输入输出格式

输入格式:

输入文件的第一行包含两个用空格隔开的整数N和M,其中2≤N≤3000,1≤M≤N-1,N为整个有线电视网的结点总数,M为用户终端的数量。

第一个转播站即树的根结点编号为1,其他的转播站编号为2到N-M,用户终端编号为N-M+1到N。

接下来的N-M行每行表示—个转播站的数据,第i+1行表示第i个转播站的数据,其格式如下:

K A1 C1 A2 C2 … Ak Ck

K表示该转播站下接K个结点(转播站或用户),每个结点对应一对整数A与C,A表示结点编号,C表示从当前转播站传输信号到结点A的费用。最后一行依次表示所有用户为观看比赛而准备支付的钱数。

输出格式:

输出文件仅一行,包含一个整数,表示上述问题所要求的最大用户数。

输入输出样例

输入样例#1:

5 3
2 2 2 5 3
2 3 2 4 3
3 4 2

输出样例#1:

2

说明

样例解释

技术分享图片

如图所示,共有五个结点。结点①为根结点,即现场直播站,②为一个中转站,③④⑤为用户端,共M个,编号从N-M+1到N,他们为观看比赛分别准备的钱数为3、4、2,从结点①可以传送信号到结点②,费用为2,也可以传送信号到结点⑤,费用为3(第二行数据所示),从结点②可以传输信号到结点③,费用为2。也可传输信号到结点④,费用为3(第三行数据所示),如果要让所有用户(③④⑤)都能看上比赛,则信号传输的总费用为:

2+3+2+3=10,大于用户愿意支付的总费用3+4+2=9,有线电视网就亏本了,而只让③④两个用户看比赛就不亏本了。

一眼题……依然是个树上依赖分组背包。(dp[i][j])表示以(i)为根节点,选了(j)个终端的最大收益。

本题的不同之处在于,选就一定要选到根节点(用户端),不然显然亏了。

鉴于本题的特殊性,Dfs写成int类型比较好。

(我保证下一题一定不是树上分组背包了)

#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstring>
#include <cstdlib>
#include <cstdio>
#include <ctime>
#include <iostream>
#include <map>
#include <queue>
#include <vector>
using namespace std;

const int MAXN = 3010;
int N, M; 
int f[MAXN][MAXN], W[MAXN];

struct Edge {
    int nxt, to, val;
}e[MAXN];

template <typename _Tp>
inline void read(_Tp &x) {
    char ch = getchar( ); _Tp f = 1; x = 0;
    while (!isdigit(ch)) { if (ch == '-') f = -1; ch = getchar( ); }
    while (isdigit(ch)) x = x * 10 + ch - '0', ch = getchar( );  
    x *= f;
}

int head[MAXN], tot;
void AddEdge(int x, int y, int z) {
    e[++ tot].to = y, e[tot].val = z, e[tot].nxt = head[x], head[x] = tot;
}

int Dfs(int u) {
    if (u > N - M) {
        f[u][1] = W[u];
        return 1;
    }
    int sum = 0; //sum表示当前节点子树中共选了多少终端 
    for (int i = head[u], v; ~i; i = e[i].nxt) {
        v = e[i].to;
        sum += Dfs(v);
        for (int j = sum; j; -- j)
            for (int k = sum; k; -- k) 
                f[u][j] = max(f[u][j], f[v][k] + f[u][j - k] - e[i].val);
    }
    return sum;
}

int main( ) {
    memset(head, -1, sizeof(head));
    read(N), read(M);
    int x, y, k;
    for (int i = 1; i <= N - M; ++ i) {
        read(k);
        for (int j = 1; j <= k; ++ j) {
            read(x), read(y);
            AddEdge(i, x, y);
        }
    }
    for (int i = 1; i <= M; ++ i) read(W[N - M + i]);
    memset(f, -10, sizeof(f));
    for (int i = 1; i <= N; ++ i) f[i][0] = 0; 
    Dfs(1);
    for (int i = M; i; -- i) if (f[1][i] >= 0) {
        printf("%d
", i);
        break;
    }
    return 0;
}

一定要注意DP数组的初值要足够小啊QWQ

[luogu] P1270 “访问”美术馆

题目描述

经过数月的精心准备,Peer Brelstet,一个出了名的盗画者,准备开始他的下一个行动。艺术馆的结构,每条走廊要么分叉为两条走廊,要么通向一个展览室。Peer知道每个展室里藏画的数量,并且他精确测量了通过每条走廊的时间。由于经验老到,他拿下一幅画需要5秒的时间。你的任务是编一个程序,计算在警察赶来之前,他最多能偷到多少幅画。

技术分享图片

输入输出格式

输入格式:

第1行是警察赶到的时间,以s为单位。第2行描述了艺术馆的结构,是一串非负整数,成对地出现:每一对的第一个数是走过一条走廊的时间,第2个数是它末端的藏画数量;如果第2个数是0,那么说明这条走廊分叉为两条另外的走廊。数据按照深度优先的次序给出,请看样例。

一个展室最多有20幅画。通过每个走廊的时间不超过20s。艺术馆最多有100个展室。警察赶到的时间在10min以内。

输出格式:

输出偷到的画的数量

输入输出样例

输入样例#1:

60
7 0 8 0 3 1 14 2 10 0 12 4 6 2

输出样例#1:

2

这题就比较有意思了。首先输入就比较有意思……仔细分析发现可以递归读入当前节点的左儿子右儿子,将整张图抽象成一棵树。显然每条走廊来一次,回一次,一共要走两次,所以时间要乘2。如果当前节点有画可偷,直接考虑消耗(i)的时间可以投多少画,也就是(dp[u][i]=min((i - t[u].t) / 5, t[u].p))。(别忘了最多只能偷(t[u].p)张画)。没画可偷,就意味着有一个分叉道路,也就是需要递归左右儿子节点,然后做一次背包即可。所以这是一道经典树形背包问题。

#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstring>
#include <cstdlib>
#include <cstdio>
#include <ctime>
#include <iostream>
#include <map>
#include <queue>
#include <vector>
#define ls (u << 1)
#define rs (u << 1 | 1)
using namespace std;

const int MAXN = 1010;
int S;
int dp[MAXN][MAXN];

struct Tree {
    int t, p;
}t[MAXN];

template <typename _Tp>
inline _Tp read(_Tp &x) {
    char ch = getchar( ); _Tp f = 0; x = 0;
    while (!isdigit(ch)) { if (ch == '-') f = -1; ch = getchar( ); }
    while (isdigit(ch)) { x = x * 10 + ch - '0'; ch = getchar( ); }
    return x * f;
}

void Init(int u) {
    scanf("%d%d", &t[u].t, &t[u].p);
    t[u].t <<= 1;
    if (!t[u].p) Init(ls), Init(rs);
}

void Dfs(int u) {
    if (t[u].p) {
        for (int i = t[u].t; i < S; ++ i) 
            dp[u][i] = min((i - t[u].t) / 5, t[u].p);
        return ;
    }
    Dfs(ls), Dfs(rs);
    for (int i = 1; i < S; ++ i) 
        for (int j = 0; j <= i - t[u].t; ++ j) 
            dp[u][i] = max(dp[u][i], dp[ls][j] + dp[rs][i - t[u].t - j]);
}

int main( ) {
    read(S);
    Init(1);
    Dfs(1);
    printf("%d
", dp[1][S - 1]);
    return 0;
}

以上是关于[DP总结]树形DP的主要内容,如果未能解决你的问题,请参考以下文章

树形dp总结

树形DP初探?总结

树形DP 学习总结

树形 DP 总结

树形 DP 总结

树形DP总结(持续更新...)