【夯实算法基础】树形DP入门详解+多道例题剖析

《算法竞赛进阶指南》:给定一棵有N个节点的树(通常是无根树,也就是有N -1 条无向边),我们可以任选一个节点为根节点,从而定义除每个节点的深度和每个子树的根。在树上设计动态规划算法时,一般就以节点有深到浅(子树从小到大)的顺序作为DP的“阶段”DP的状态表示中,第一维通常是节点编号(代表以这个节点为根的子树)。大多数时候,我们都采用递归的方式实现树形动态规划。对于节点x,先递归在它的每个子节点上进行DP,在回溯时,从子节点想节点x进行状态转移

一般基础的题转移方程有两种模式:

选择节点类:一般一条边上的两个点不需要都存在,要求取最大最小值

f[i][0] += f[j][1]
f[i][1] += max/min(f[j][0],f[j][1])

树形背包类:树和背包的结构,子节点的选择是受限制的。父节点选了子节点才能选。

f[u][k] = f[v][k] + w[i]
f[u][k] = max(f[u][k], f[v][k-1] + w[i])

AC1072 树的最长路径(树的直径)

⭐️ 题目

AcWing 1072. 树的最长路径

image-20220726114039051

💦 思路

任取一个点作为起点,找到距离该点最远的一个点u,在从点u除法找到一个距离u最远的点v。那么uv之间的路径就是最长的一条直径

证明

image-20220726115410940

闫氏DP法

状态表示:我们可以将树看成是一个集合,每一条路径都在树上有一个深度最浅的点(也就是树上最高的点),我们可以按照最高的点分类,那么每一类的最大值就是整个集合的最大值。我们用f[x]来表示当前这个点到其子节点的最长路。那么直径就等于x的最长的两条路径的和。

状态计算:每一个点的最长路径就等于它的每一个子节点的最长路径加上子节点到当前节点的路径之和,在所有的路径中取最大值。

转移方程f[x] = max(f[v1] + w1, f[v2] + w2....)

最后我们只需要求每个点的最长路径和次长路径,它俩的和就是以当前这个点为中间节点的最长路径,最后在所有的最长路径中取最大值。

🐎 代码

#include <bits/stdc++.h>
using namespace std;
const int N = 1e4 + 10, M = N * 2;
int n;
int h[N], e[M], ne[M], w[M], idx;
int ans = 0;
void add(int a, int b, int c)
{
    e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx++;
}
int dfs(int u, int fa)
{
    int d1 = 0, d2 = 0;
    for (int i = h[u]; i != -1; i = ne[i])
    {
        int j = e[i];
        if (j == fa)
            continue;
        int d = dfs(j, u) + w[i];
        if (d >= d1)
        {
            d2 = d1;
            d1 = d;
        }
        else if (d > d2)
            d2 = d;
    }
    ans = max(ans, d1 + d2);
    return d1;
}
signed main()
{
    cin >> n;
    memset(h, -1, sizeof h);
    for (int i = 0; i < n - 1; i++)
    {
        int a, b, c;
        cin >> a >> b >> c;
        add(a, b, c);
        add(b, a, c);
    }
    dfs(1, -1);
    cout << ans;
    return 0;
}

AC1073 树的中心

😋 题目:1073. 树的中心

image-20220726204422648

💛 思路

那如何求一个点到其他点的最长路径呢?

一个点到其他点的最长路径无非是从它的几个子节点和它的父节点中寻找最长路径在加上自己的值。也就是向上走或者向下走的问题。

向下走是很容易的,我们只需要使用dfs寻找每一个点向下走的最长路径,然后在用子节点更新父节点。这里利用了回溯的特点。寻找向下走的最长距离还需要记录次长距离和分别是从哪个点下去的。具体为什么要求次长距离我们等一下在求向上走的时候需要用到。

image-20220726181425526

假设我们当前正在求2号点向下走的情况,可以遍历它的所有子节点56,在记录它的所有子节点的路径,保留最长路径和次长路径即可,通过一次dfs遍历我们就可以球的所有点向下走的最长路径。

我们再来看看向上走的情况,假设我们当前正在求5号点向上走的最长路径,那么它需要在2号点的其他子节点的最长路径以及2号点向上走的最长路径中找。如果我们一个一个找的话就回超时。这时我们在向下走时得到的最长距离和次长距离就排上了用场。

  • 5号点位于最长路径上,那么它向上走的距离就取2号点的子节点次长距离和2号点向上最长距离的最大值即可。这样我们的复杂度是O(1)的。

image-20220726183319977

  • 假设5号节点不在2号点的最长路径上,那么一定2好点的最长距离一定在它的其他子节点上。那5号点向上走的最长路径就等于2号点向上走的最长路径和2号点向下走的最长路径取最大值即可。

image-20220726183720499

🗡 代码

#include <bits/stdc++.h>
using namespace std;
const int N = 1e4 + 10, M = N * 2;
int n;
int h[N], e[M], ne[M], w[M], idx;
int d1[N]; //当前这个点向下走的最长路径
int d2[N]; //当前这个点向下走的次长路径
int up[N]; //当前这个点向上早的最长路径
int p1[N]; //当前这个点的最长路径是从哪一个子节点下去的
int p2[N]; //当前这个点的次长路径是从哪一个子节点下去的
int ans = 1e9;
void add(int a, int b, int c)
{
    e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx++;
}
// 往下走
int dfs_d(int u, int fa)
{
    for (int i = h[u]; i != -1; i = ne[i])
    {
        int j = e[i];
        if (j == fa)
            continue;
        int d = dfs_d(j, u) + w[i];
        if (d >= d1[u])
        {
            p2[u] = p1[u];
            p1[u] = j;
            d2[u] = d1[u];
            d1[u] = d;
        }
        else if (d > d2[u])
        {
            d2[u] = d;
            p2[u] = j;
        }
    }
    return d1[u];
}
// 往上走
void dfs_u(int u, int fa)
{
    for (int i = h[u]; i != -1; i = ne[i])
    {
        int j = e[i];
        if (j == fa)
            continue;
        if (p1[u] == j)
            up[j] = max(up[u], d2[u]) + w[i];
        else
            up[j] = max(up[u], d1[u]) + w[i];
        dfs_u(j, u); // 这一句要写在后面,不需要回溯回来,因为子节点需要父节点的信息
    }
}
signed main()
{
    cin >> n;
    memset(h, -1, sizeof h);
    for (int i = 0; i < n - 1; i++)
    {
        int a, b, c;
        cin >> a >> b >> c;
        add(a, b, c);
        add(b, a, c);
    }
    dfs_d(1, -1);
    dfs_u(1, -1);
    for (int i = 1; i <= n; i++)
        ans = min(ans, max(d1[i], up[i]));
    cout << ans;
    return 0;
}

AC323 战略游戏

🍨 题目 : 题目链接

image-20220727103028084

🦄 思路

每条边的两个端点至少需要一个士兵站岗,如果一个端点已经有了一个士兵,那么另一个端点可以有也可以没有士兵。如果一个端点没有士兵,那么另一个端点一定要有士兵。

这道题和没有上司的舞会那么题非常类似,那道题是每条边最多选一个点求最大,这道题是每条边至少选一个点求最小。

状态表示

  • f[u][0]表示以u为根的子树中选择,并且不选u这个点的方案
  • f[u][1]表示以u为根的子树中选择,并且选u这个点的方案
  • 属性Min

状态计算

  • 当前u不选,子节点一定要选 :f[u][0] = ∑(f[si,1])
  • 当前u选了,那么子节点可选可不选:f[u][1] = ∑min(f[si,0],f[si,1])

🐇 代码

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1510, M = N * 2;
int f[N][2];
int n;
int h[N], e[M], ne[M], idx;
void add(int a, int b) // 添加一条边a->b
{
    e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
void dfs(int u, int fa)
{
    f[u][1] = 1, f[u][0] = 0;
    for (int i = h[u]; i != -1; i = ne[i])
    {
        int j = e[i];
        if (j == fa)
            continue;
        dfs(j, u);
        f[u][1] += min(f[j][0], f[j][1]);
        f[u][0] += f[j][1];
    }
}
int main()
{
    int n;
    while (cin >> n)
    {
        memset(h, -1, sizeof h);
        idx = 0;
        for (int i = 1; i <= n; i++)
        {
            int a, b, m;
            scanf("%d:(%d)", &a, &m);
            while (m--)
            {
                cin >> b;
                add(a, b);
                add(b, a);
            }
        }
        dfs(1, -1);
        cout << min(f[1][0], f[1][1]) << endl;
    }
}

AC1077 皇宫看守

🌮 题目题目链接

这题和上道题的战略游戏有所不同,本道题是要求每个点都要被看到,而不是每条边都要被看到。所以一个点可以被它的父节点看到,也可以被子节点看到,也可以被自己看到。

状态表示

  • f[u][0]表示u这个点被父节点的守卫看到的方案

  • f[u][1]表示u这个点放置了一个首位,被自己看到的方案

  • f[u][2]表示u这个点被它的子节点的守卫看到的方案

  • 属性Min

状态计算

  • f[u][0]:因为这是一颗树,一个点只有一个父节点,所以它的子节点要被自己放置守卫或被它的子节点看到f[u][0] =∑(min(f[si, 1], f[si, 2])

  • f[u][1]u放置了一个守卫,那么它的子节点就可以被父节点看到或自己放守卫或者被它的子节点看到。f[u][1] =∑(min(f[si, 0], f[si, 1], f[si, 2])

image-20220727162736552

  • f[u][2]u被它的所有的子节点中的某个子节点看到,那么它的其他子节点可以是自己放置守卫或者被它自己的子节点看到。f[u][0] =min(∑(min(f[si, 1], f[si, 2]) - min(f[si, 1], f[si, 2]) + f[si, 1])

image-20220727162857633

🦅 代码

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1510, M = N * 2;
int f[N][3];
int n;
int h[N], e[M], ne[M], idx;
int w[N];
void add(int a, int b) // 添加一条边a->b
{
    e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
void dfs(int u, int fa)
{
    f[u][1] = w[u];
    f[u][2] = 0x3f3f3f3f;
    for (int i = h[u]; i != -1; i = ne[i])
    {
        int j = e[i];
        if (j == fa)
            continue;
        dfs(j, u);
        f[u][0] += min(f[j][1], f[j][2]);
        f[u][1] += min({f[j][0], f[j][1], f[j][2]});
    }
    for (int i = h[u]; i != -1; i = ne[i])
    {
        int j = e[i];
        if (j == fa)
            continue;
        f[u][2] = min(f[u][2], f[u][0] - min(f[j][1], f[j][2]) + f[j][1]);
    }
}
int main()
{
    int n;
    cin >> n;
    memset(h, -1, sizeof h);
    for (int i = 1; i <= n; i++)
    {
        int a, b, m;
        cin >> a;
        cin >> w[a];
        cin >> m;
        while (m--)
        {
            cin >> b;
            add(a, b);
            add(b, a);
        }
    }
    dfs(1, -1);
    cout << min(f[1][1], f[1][2]);
    return 0;
}

AcWing 286. 选课

🤗 题目题目链接

image-20220727170652658

👻 思路:有树形依赖的背包问题

因为每门课的先修课最多只有一门,所以这N门课程构成了一个森林结构(因为可能有不止一门课没有先修课),为了方便计算,我们需要引入一个“虚拟源点”–0号节点,作为那些没有先修课的课程的“先修课”,当然这个先修课没有学分。所以这样就把一个森林转换为了一棵树,其中0号节点为根节点。

状态表示:我们可以讲整个树看成一个集合,以每个点进行分类,每个点及其子节点选了多少门课程的最大值取更新父节点选了多少们课程的最大值,最后的到的就是整个集合的最大值。

f[i][j]:表示选了i号节点并且子节点选了j-1门课程(因为只有选了先修课之后才能选副课,所以先修课必须要选)的方案数。

其实这个一个分组背包问题,我们可以将一个点的所有子节点看成一个分组,总的最大值就等于一个个分组加起来可以取到的最大值,体积是选课数,价值是学分。。每一次处理完当前节点的背包状态转移之后需要在for循环外面将自己作为选修课加上,当然这里我们需要特判一下0号点,因为它不需要被选修。

状态计算

f[u][v] = max(f[u][v], f[u][v - k] + f[j][k])

f[u][v] = f[u][v - 1] + w[u]

🐴 代码

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 310, M = N;
int f[N][N];
int n, m;
int h[N], e[M], ne[M], idx;
int w[N];
void add(int a, int b) // 添加一条边a->b
{
    e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
void dfs(int u)
{
    for (int i = h[u]; i != -1; i = ne[i])
    {
        int j = e[i];
        //分组背包
        dfs(j);
        for (int v = m; v >= 0; v--) // 枚举体积
        {
            for (int k = 0; k <= v; k++) // 枚举方案
            {
                f[u][v] = max(f[u][v], f[u][v - k] + f[j][k]);
            }
        }
    }
    if (u != 0) // 如果不是0号点,那么就需要在课程数少1的基础上把自己加上
        for (int v = m; v >= 1; v--)
        {
            f[u][v] = f[u][v - 1] + w[u];
        }
}
int main()
{
    cin >> n >> m;
    memset(h, -1, sizeof h);
    for (int i = 1; i <= n; i++)
    {
        int b;
        cin >> b >> w[i];
        add(b, i);
    }
    dfs(0);
    cout << f[0][m];
    return 0;
}

AcWing1074. 二叉苹果树

📓 题目题目链接

image-20220727172914766

🍊 思路:有树形依赖的背包问题

这道题和选课那道题很相识,算是简配版。这道题就是一棵树,不需要建立虚拟点。并且本题是要保留边的最大值,而不是点,一个树枝如果不被剪掉的话那么树枝的两端都需要保留。这样也就转变为了分组背包问题,每个子节点都是一组,枚举体积和决策即可。

状态表示

  • 集合f[i][j]:以i为根节点的子树中,包含i的连通块的边数不超过j的方案
  • 属性 :最大值

状态计算f[u][v] = max(f[u][v], f[u][v - k - 1] + f[j][k] + w[i]);

🎠 代码

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 110, M = N * 2;
int n, m;
int f[N][N];
int h[N], e[M], w[M], ne[M], idx;
void add(int a, int b, int c)  // 添加一条边a->b,边权为c
{
    e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx ++ ;
}
void dfs(int u,int fa)
{
    for(int i = h[u]; i != -1; i = ne[i])
    {
        int j = e[i];
        if(j == fa)
            continue;
        dfs(j, u);
        for(int v = m; v >= 0; v--) // 体积
        {
            for(int k = 0; k <= v - 1; k++) // 方案
            {
                f[u][v] = max(f[u][v], f[u][v - k - 1] + f[j][k] + w[i]);
            }
        }
    }
}
int main()
{
    memset(h, -1, sizeof h);
    cin >> n >> m;
    for(int i = 1; i < n; i++)
    {
        int a, b, c;
        cin >> a >> b >> c;
        add(a, b, c);
        add(b, a, c);
    }
    dfs(1, -1);
    cout << f[1][m];
    return 0;
}

P2585 [ZJOI2006]三色二叉树

🥔 题目: 题目链接

一棵二叉树可以按照如下规则表示成一个由 0 0 0 1 1 1 2 2 2 组成的字符序列,我们称之为“二叉树序列 S S S”:

S = { 0 表示该树没有子节点 1 S 1 表示该树有一个节点, S 1 为其子树的二叉树序列 2 S 1 S 2 表示该树由两个子节点, S 1 和 S 2 分别表示其两个子树的二叉树序列 S= \begin{cases} 0& \text表示该树没有子节点\\ 1S_1& 表示该树有一个节点,S_1 为其子树的二叉树序列\\ 2S_1S_2& 表示该树由两个子节点,S_1 和 S_2 分别表示其两个子树的二叉树序列 \end{cases} S= 01S12S1S2示该树没有子节点表示该树有一个节点,S1为其子树的二叉树序列表示该树由两个子节点,S1S2分别表示其两个子树的二叉树序列

例如,下图所表示的二叉树可以用二叉树序列 S = 21200110 S=\texttt{21200110} S=21200110 来表示。

haha.png

你的任务是要对一棵二叉树的节点进行染色。每个节点可以被染成红色、绿色或蓝色。并且,一个节点与其子节点的颜色必须不同,如果该节点有两个子节点,那么这两个子节点的颜色也必须不同。给定一颗二叉树的二叉树序列,请求出这棵树中最多和最少有多少个点能够被染成绿色。

🐺 思路:选择节点型

由于每个点可以染成三种颜色,并且一个点和它的子节点颜色互不相同,我们要求的是整个树最多和最少能染多少个绿色,那么我们就可以将树看成一个集合。以每个点分类,从根节点向上回溯,最后得到的就是我们想要的最大值和最小值。

状态表示

  • f[i][0] 表示i这个点染染成红色的方案
  • f[i][1] 表示i这个点染成绿色的方案
  • f[i][2] 表示i这个点染成蓝色的方案

属性:绿色个数的最大值

状态计算

  • f[i][0]

    • 没有子节点: f[u][0] = 0
    • 只有一个子节点:f[u][0] = max(f[v][1], f[v][2]);
    • 有两个子节点:f[u][0] = max(f[v1][1] + f[v2][2], f[v1][2] + f[v2][1]);

    image-20220727191348593

  • f[i][1]

    • 没有子节点: f[u][1] = 1
    • 只有一个子节点:f[u][1] = max(f[v][0], f[v][2]) + 1;
    • 有两个子节点:f[u][1] = max(f[v1][0] + f[v2][2], f[v1][2] + f[v2][0]) + 1;
  • f[i][2]

    • 没有子节点: f[u][2] = 0
    • 只有一个子节点:f[u][2] = max(f[v][0], f[v][1]) + ;
    • 有两个子节点:f[u][2] = max(f[v1][0] + f[v2][1], f[v1][1] + f[v2][0]) + 1;

状态转移方程的问题我们已经会了,现在是如何建图的问题,通过题目描述我们发现这是在用前序遍历建图,所以我们只需要也通过一次前序遍历即可,我们的转移过程就可以放在前序遍历过程中。

🎠 代码

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 5e5 + 10, M = N;
int f[N][3]; //最多
int g[N][3]; //最少
int n, m;
char s[N];
int cnt; // 当前已经访问了几个节点
void dfs(int u) 
{
    // 当前点为叶子节点,不需要在往下遍历
    if (s[u] == '0')
    {
        f[u][1] = g[u][1] = 1;
        return;
    }
    //遍历当前点的左儿子
    dfs(++cnt);
    if (s[u] == '1')
    {
        f[u][0] = max(f[u + 1][1], f[u + 1][2]);
        f[u][1] = max(f[u + 1][0], f[u + 1][2]) + 1;
        f[u][2] = max(f[u + 1][0], f[u + 1][1]);
        g[u][0] = min(g[u + 1][1], g[u + 1][2]);
        g[u][1] = min(g[u + 1][0], g[u + 1][2]) + 1;
        g[u][2] = min(g[u + 1][0], g[u + 1][1]);
    }
    else
    {
        int k = cnt + 1;
        // 遍历当前点的右儿子
        dfs(++cnt);
        f[u][0] = max(f[u + 1][1] + f[k][2], f[u + 1][2] + f[k][1]);
        f[u][1] = max(f[u + 1][0] + f[k][2], f[u + 1][2] + f[k][0]) + 1;
        f[u][2] = max(f[u + 1][0] + f[k][1], f[u + 1][1] + f[k][0]);
        g[u][0] = min(g[u + 1][1] + g[k][2], g[u + 1][2] + g[k][1]);
        g[u][1] = min(g[u + 1][0] + g[k][2], g[u + 1][2] + g[k][0]) + 1;
        g[u][2] = min(g[u + 1][0] + g[k][1], g[u + 1][1] + g[k][0]);
    }
}
int main()
{
    scanf("%s", s + 1);
    dfs(++cnt);
    cout << max({f[1][0], f[1][1], f[1][2]}) << " " << min({g[1][0], g[1][1], g[1][2]});
    return 0;
}

P1273 有线电视网

🐷 题目题目链接

image-20220727203632991

🐒 思路:有树形依赖的背包问题

这道题和前面的选课等及其类似,但又有所不同。我们用f[i][j]表示以当前这个点为根节点有j个的用户可以观看所花费的钱数,如果f[i][j]>=0表示当前可以让j个用户观看。求最多的可以观看的用户,只需要满足f[i][j]>=0j最大即可。

状态表示f[i][j] f[i][j]表示以当前这个点为根节点有j个的用户可以观看所花费的钱数

属性:最大值

状态计算f[u][v] = max(f[u][v], f[u][v - k] + f[j][k] - w[i]);

注意

  1. 本题中最多了n-1个用户,所以我们每次做分组背包的时候不能m开始转移,这样会超时,我们只需要知道以当前点位根节点的子树中总共有多少个节点就可以了,多余的计算也无效,这样可以降低复杂度度为O(n^2).
  2. 初始化问题:我们要求的是最大值,所以全部初始化为-inf,当用户等于0是,值也是0,其他初始时都不存在。

🏇 代码

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 3e3 + 10, M = N * 2;
int f[N][N];
int n, m;
int h[N], e[M], ne[M], w[M], idx;
int money[N];
void add(int a, int b, int c) // 添加一条边a->b
{
    e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx++;
}
int dfs(int u)
{
    f[u][0] = 0;
    if (h[u] == -1)
    {
        f[u][1] = money[u];
        return 1;
    }
    // 统计当前子树中总共有多少个用户
    int sum = 0;
    for (int i = h[u]; i != -1; i = ne[i])
    {
        int j = e[i];
        sum += dfs(j);
        for (int v = sum; v >= 0; v--)
        {
            for (int k = 0; k <= v; k++)
            {
                f[u][v] = max(f[u][v], f[u][v - k] + f[j][k] - w[i]);
            }
        }
    }
    return sum;
}
int main()
{
    cin >> n >> m;
    memset(f, -0x3f, sizeof f);
    memset(h, -1, sizeof h);
    for (int i = 1; i <= n - m; i++)
    {
        int k;
        cin >> k;
        while (k--)
        {
            int b, c;
            cin >> b >> c;
            add(i, b, c);
        }
    }
    for (int i = n - m + 1; i <= n; i++)
        cin >> money[i];
    dfs(1);
    int ans = 0;
    for (int i = 0; i <= n; i++)
    {
        if (f[1][i] >= 0)
            ans = max(ans, i);
    }
    cout << ans;
    return 0;
}
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值