数据结构与算法学习笔记(Acwing 提高课)----动态规划·树形DP

发布于:2025-06-05 ⋅ 阅读:(20) ⋅ 点赞:(0)

数据结构与算法学习笔记----动态规划·树形DP

@@ author: 明月清了个风
@@ first publish time: 2025.6.4

ps⭐️树形动态规划(树形DP)是处理树结构问题的一种动态规划方法,特征也很明显,会有一个树形结构,其实是DFS的优化。

Acwing 1072. 树的最长路径

给定一棵树,树中包含 n n n个节点(编号 1 ∼ n 1 \sim n 1n)和 n − 1 n - 1 n1条无向边,每条边都有一个权值。

现在请你找到树中最长的一条路径。

换句话说,要找到两个点,使得他们之间的距离最远。

输入格式

第一行包含整数 n n n

接下来 n − 1 n - 1 n1行,每行包含三个整数 a i , b i , c i a_i, b_i,c_i ai,bi,ci,表示点 a i a_i ai b i b_i bi之间存在一条权值为 c i c_i ci的边。

输出格式

输出一个整数,表示树的最长路径的长度。

数据范围

1 ≤ n ≤ 10000 1 \le n \le 10000 1n10000,

1 ≤ a i , b i ≤ n 1 \le a_i,b_i \le n 1ai,bin,

1 ≤ c i ≤ 10 5 1 \le c_i \le 10^5 1ci105

思路

找树的最长路径(直径)有一个通用的思路:

  1. 任选一个点,找出离这个点最远的点 u u u
  2. 再找到离 u u u最远的点 v v v

u u u v v v之间的路径就是直径。

证明如下;

假设任选的这个点是 a a a,找到离他最远的点为 u u u,只要证明 u u u是某条直径的一个端点就行了。

假设一条直径为 b c bc bc,那他可能会与 a u au au之间没有交点,由于树是连通的,所以从 a a a开始走一定能走到 c c c点,假设这条路径是 a x y c axyc axyc,其中 x x x a u au au上, y y y b c bc bc上,由于 u u u是离 a a a最远的点,那么 x u xu xu的长度大于等于 x y + y c xy + yc xy+yc,那么就有 b y + y x + x u ≥ y c by + yx + xu \ge yc by+yx+xuyc,因此 b u bu bu之间的距离一定大于等于 b c bc bc的长度,因此 b u bu bu一定是一条直径的端点。

另一种情况是两条线相交,假设交点是 x x x,那么由于 u u u是离 a a a最远的点,就有 u x ≥ x c ux \ge xc uxxc,那么 b x + x u ≥ b c bx + xu \ge bc bx+xubc,因此 u u u也一定是一条直径的端点。

在这里插入图片描述

由于这道题目并没有指定根节点,因此可以任选一个点将整棵树构建起来,然后对于每个点记录经过该点的最长路径。

对于一个点的最长路径,对于每个点来说可以分为两种情况,一种是往下走,一种是往上走。就是枚举他的每个子节点存的最大距离再加上他到这个子节点的距离;另一种情况是穿过一个点的路径,那么就可以计算这个点往下的最长的路径以及第二长的路径相加。

代码

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

using namespace std;

const int N = 10010, M = N * 2;

int n;
int h[N], e[M], ne[M], w[M], idx;
int ans;

void add(int a, int b, int c)
{
    w[idx] = c, e[idx] = b, ne[idx] = h[a], h[a] = idx ++;
}

int dfs(int u, int father)
{
    int dist = 0;
    int d1 = 0, d2 = 0;
    
    for(int i = h[u]; i != -1; i = ne[i])
    {
        int j = e[i];
        if(j == father) continue;
        
        int d = dfs(j, u) + w[i];
        dist = max(dist, d);
        
        if(d >= d1)
        {
            d2 = d1;
            d1 = d;
        } else if(d > d2) d2 = d;
    }
    
    ans = max(ans, d1 + d2);
    return dist;
}

int 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 << endl;
    
    return 0;
}

Acwing 1073. 树的中心

给定一棵树,树中包含 n n n个节点(编号 1 ∼ n 1 \sim n 1n)和 n − 1 n - 1 n1条无向边,每条边都有一个权值。

请你在树中找到一个点,使得该点到树中其他结点的最远距离最近。

输入格式

第一行包含整数 n n n

接下来 n − 1 n - 1 n1行,每行包含三个整数 a i , b i , c i a_i, b_i,c_i ai,bi,ci,表示点 a i a_i ai b i b_i bi之间存在一条权值为 c i c_i ci的边。

输出格式

输出一个整数,表示所求点到树中其他结点的最远距离。

数据范围

1 ≤ n ≤ 10000 1 \le n \le 10000 1n10000,

1 ≤ a i , b i ≤ n 1 \le a_i,b_i \le n 1ai,bin,

1 ≤ c i ≤ 10 5 1 \le c_i \le 10^5 1ci105

思路

与上一题一样,对于树中的一点有向上走与向下走两种方案,每个点都记录一个向上走与向下走的最大值,假设点 2 2 2向其父节点 1 1 1走,那么这就是向上走,在 1 1 1点可以继续向上走,也可以向下不经过点 2 2 2的子节点走,若向下走,那就要考虑点 2 2 2是否是向下走的最大长度,如果是,那就使能选择次大值更新。

这一题复杂的地方在于,更新的过程同时用到了子节点更新父节点和父节点更新子节点。

代码

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

using namespace std;

const int N = 100010, M = N * 2, inf = 0x3f3f3f3f;

int n;
int h[N], e[M] ,w[M], ne[M], idx;
int d1[N], d2[N], up[N];
int p1[N], p2[N];

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 father)
{
    if(h[u] == -1) return 0;
    
    d1[u] = d2[u] = -inf;
    
    for(int i = h[u]; i != -1; i = ne[i])
    {
        int j = e[i];
        if(j == father) continue;
        int d = dfs_d(j, u) + w[i];
        if(d >= d1[u])
        {
            d2[u] = d1[u], d1[u] = d;
            p2[u] = p1[u], p1[u] = j; // 记录最大值和次大值是从向哪个子节点走的
        } else if(d > d2[u])
            d2[u] = d, p2[u] = j;
    }
    
    if(d1[u] == -inf) d1[u] = d2[u] = 0; // 如果是叶子结点无法往下,置为0
    
    return d1[u];
}


int dfs_u(int u, int father)
{
    if(h[u] == -1) return 0;
    
    for(int i = h[u]; i != -1; i = ne[i])
    {
        int j = e[i];
        if(j == father) 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);
    }
}

int 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);
    
    int res = inf;
    for(int i = 1; i <= n; i ++) res = min(res, max(up[i], d1[i]));
    
    cout << res << endl;
    
    return 0;
    
}

Acwing 1075. 数字转换

如果一个数 x x x的约数之和 y y y(不包括他本身)比他本身小,那么 x 可以变成 x可以变成 x可以变成 y y y y y y也可以变成 x x x

例如 4 4 4可以变成 3 3 3 1 1 1可以变成 7 7 7

限制所有数字变换在不超过 n n n的正整数范围内进行,求不断进行数字变换且不出现重复数字的最多变换步数。

输入格式

输入一个正整数 n n n

输出格式

输出断进行数字变换且不出现重复数字的最多变换步数

数据范围

1 ≤ n ≤ 50000 1 \le n \le 50000 1n50000

思路

对于每个数 x x x都会有一个约数之和 y y y与之对应,且 x x x y y y的关系是唯一的,但需要注意的是 y y y可能会对应于多个数,因此要将 y y y作为父节点。那么对于所有数就可以构建出一堆树(注意不是一棵树,因为不一定所有数都能连通),最多变换步数就是这堆树中最长的树的直径,就是上面的第一题。

问题就是建树,也就是找到 1 ∼ n 1 \sim n 1n中所有数的约数之和。当然可以枚举每个数进行计算(试除法),但是如果 n n n的范围进一步扩大就会超时。可以反向思考,不是求每个数的约数是哪些数,而是求每个数是哪些数的约数,也就是枚举每个数的倍数

for(int i = 1; i <= n; i ++)
    for(int j = 2; j * i <= n; j ++)  // 从2开始枚举

这样的时间复杂度是 n ln ⁡ n n \ln{n} nlnn,因为 n + n 2 + n 3 + ⋯ n + \frac{n}{2} + \frac{n}{3} + \cdots n+2n+3n+是一个调和级数,

代码

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

using namespace std;

const int N = 50010;

int n;
int sum[N];
int h[N], e[N], ne[N], idx;
bool st[N];
int ans;

void add(int a, int b)
{
    e[idx] = b, ne[idx] = h[a], h[a] = idx ++;
}

int dfs(int u)
{
    int d1 = 0, d2 = 0;
    for(int i = h[u]; i != -1; i = ne[i])
    {
        int j = e[i];
        int d = dfs(j) + 1;
        if(d >= d1) d2 = d1, d1 = d;
        else if(d > d2) d2 = d;
    }
    
    ans = max(ans, d1 + d2);
    return d1;
}

int main()
{
    cin >> n;
    
    memset(h, -1, sizeof h);
    
    for(int i = 1; i <=n; i ++)
        for(int j = 2; j <= n / i; j ++)
            sum[i * j] += i;
            
    for(int i = 2; i <= n; i ++)  // 建立边的时候要从2开始,因为对于1来说,他会和0建边,题目要求是正整数。
        if(sum[i] < i)
        {
            add(sum[i], i);
            st[i] = true;
        }
    
    for(int i = 1; i <= n; i ++)
        if(!st[i])
            dfs(i);
    
    cout << ans << endl;
    
    return 0;
}

Acwing 1074. 二叉苹果树

有一颗苹果树,如果树枝有分岔,一定是分两叉,即没有只有一个儿子的节点。

这棵树共 N N N个节点,编号为 1 1 1 N N N,树根编号一定是 1 1 1

我们用一根树枝两端连接的节点编号描述一根树枝的位置。

一棵苹果树的树枝太多了,需要剪枝,但是一些树枝上长有苹果,给定需要保留的树枝数量,求最多能留住多少苹果,这里的保留是指最终与 1 1 1号点连通。

输入格式

第一行包含两个整数 N N N Q Q Q,分别表示树的节点数以及要保留的树枝数量。

接下来 N − 1 N - 1 N1行描述树枝信息,每行三个整数,前两个是它连接的节点的编号,第三个数是这根树枝上苹果数量。

输出格式

输出仅一行,表示最多能留住的最多的苹果数量。

数据范围

1 < Q < N ≤ 100 1 < Q < N \le 100 1<Q<N100

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

思路

在[背包模型四](数据结构与算法学习笔记(Acwing提高课)----动态规划·背包模型(四)-CSDN博客)中讲过一道有依赖的背包问题,其实这道题和那道题是一样的,将苹果数量放到每个点上,要选择一个点就要选择它的父节点,将所有点的体积看为 1 1 1,总的体积就是 Q Q Q

但是这题可以进一步简化,使用 f [ i ] [ j ] f[i][j] f[i][j]表示以 i i i为根的子树中选择 j j j个树枝的最大价值。

对于状态划分来说,对于每个具有两个分岔的小子树,将其两个子节点看成是一个分组背包问题里的两个物品组,也就是对于以 i i i为根的子树有两个物品 s 1 s_1 s1 s 2 s_2 s2,划分为在 s 1 s_1 s1中选择 0 0 0条边,那就是 f [ s 1 ] [ 0 ] f[s_1][0] f[s1][0];选择 1 1 1条边,那就是 f [ s 1 ] [ 1 ] f[s_1][1] f[s1][1];以此类推,最多是 f [ s 1 ] [ j − 1 ] f[s_1][j - 1] f[s1][j1],因为要选择 s 1 s_1 s1就要选择 i i i,也就是必选 i → j i \rightarrow j ij这条边,当然计算的时候要加上这条边的权重。对于子树 s 2 s_2 s2也是一样的。

代码

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

using namespace std;

const int N = 110, M = N * 2;

int n, m;
int f[N][N];
int h[N], e[M], ne[M], w[M], idx;

void add(int a, int b, int c)
{
    w[idx] = c, e[idx] = b, ne[idx] = h[a], h[a] = idx ++;
}

void dfs(int u, int father)
{
    for(int i = h[u]; i != -1; i = ne[i])  // 枚举物品组
    {
        if(e[i] == father) continue;
        dfs(e[i], u);
        
        for(int j = m; j >= 0; j --) // 体积
            for(int k = 0; k < j; k ++) // 决策
                f[u][j] = max(f[u][j], f[u][j - k - 1] + f[e[i]][k] + w[i]);
    }
}

int main()
{
    cin >> n >> m;
    
    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 << f[1][m] << endl;
    
    return 0;
}

Acwing 323. 战略游戏

鲍勃喜欢玩电脑游戏,特别是战略游戏,但有时他找不到解决问题的方法,这让他很伤心。

现在他有以下问题。

他必须保护一座中世纪城市,这条城市的道路构成了一棵树。

每个节点上的士兵可以观察到所有和这个点相连的边。

他必须在节点上放置最少数量的士兵,以便他们可以观察到所有的边。

你能帮助他吗?

例如,下面的树:

在这里插入图片描述

只需要放置 1 1 1名士兵(在节点 1 1 1处),就可观察到所有的边。

输入格式

输入包含多组测试数据,每组测试数据用以描述一棵树。

对于每组测试数据,第一行包含整数 N N N,表示树的节点数目。

接下来 N N N 行,每行按如下方法描述一个节点。

节点编号:(子节点数目) 子节点 子节点 …

节点编号从 0 0 0 N − 1 N−1 N1,每个节点的子节点数量均不超过 10 10 10,每个边在输入数据中只出现一次。

输出格式

对于每组测试数据,输出一个占据一行的结果,表示最少需要的士兵数。

数据范围

0 < N ≤ 1500 0 < N \le 1500 0<N1500

思路

这道题的题意是对于一颗树,选择最少的节点数覆盖所有的边,这个其实和没有上司的舞会很像,在没有上司的舞会中,我们求的是每条边上最多仅选择一个点求最大值,属于最大独立集问题,而这道题中,每条边上至少选择一个点求最小值,这两个是对称的问题。

状态计算和状态转移也很简单,直接看代码叭。

代码

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

using namespace std;

const int N = 1600;

int n;
int f[N][2];
int h[N], e[N], ne[N], idx;
bool st[N];

void add(int a, int b)
{
    e[idx] = b, ne[idx] = h[a], h[a] = idx ++;
}

void dfs(int u)
{
    f[u][0] = 0;
    f[u][1] = 1;
    
    for(int i = h[u]; i != -1; i = ne[i])
    {
        int j = e[i];
        
        dfs(j);
        
        f[u][0] += f[j][1];
        f[u][1] += min(f[j][0], f[j][1]);
    }
}

int main()
{
    while(scanf("%d", &n) != -1)
    {
        idx = 0;
        memset(h, -1, sizeof h);
        memset(st, 0, sizeof st);
        
        for(int i = 0; i < n; i ++)
        {
            int cnt;
            int id;
            scanf("%d:(%d)", &id, &cnt);
            
            while(cnt --)
            {
                int ver;
                cin >> ver;
                add(id, ver);
                st[ver] = true;
            }
            
        }
        int root = 0;
        while(st[root]) root ++;
            
        dfs(root);
        cout << min(f[root][1], f[root][0]) << endl;
    }
    
    return 0;
}

Acwing 1077. 皇宫看守

太平王世子事件后,陆小凤成了皇上特聘的御前一品侍卫。

皇宫以午门为起点,直到后宫嫔妃们的寝宫,呈一棵树的形状,某些宫殿间可以互相望见。

大内保卫森严,三步一岗,五步一哨,每个宫殿都要有人全天候看守,在不同的宫殿安排看守所需的费用不同。

可是陆小凤手上的经费不足,无论如何也没法在每个宫殿都安置留守侍卫。

帮助陆小凤布置侍卫,在看守全部宫殿的前提下,使得花费的经费最少。

输入格式

输入中数据描述一棵树,描述如下:

第一行 n n n,表示树中结点的数目。

第二行至第 n + 1 n + 1 n+1行,每行描述每个宫殿结点信息,依次为:该宫殿结点标号为 i i i,在该宫殿内安置侍卫所需的经费 k k k,该边的儿子数 m m m,接下来 m m m个数,分别是这个结点的 m m m个儿子的编号 r 1 , r 2 , ⋯   , r m r_1, r_2, \cdots, r_m r1,r2,,rm

对于一个 n n n个节点的树,结点标号在 1 1 1 n n n之间,且标号不重复。

输出格式

输出最少的经费。

数据范围

0 < N ≤ 1500 0 < N \le 1500 0<N1500

思路

在这题中,同样是挑选足够的点完成整颗树的覆盖,但是对于每条边来说,这一题的状态表示与之前并不一样,使用 f [ i ] [ 0 ] f[i][0] f[i][0]表示点 i i i被父节点看到的最小花费, f [ i ] [ 1 ] f[i][1] f[i][1]表示点 i i i被子节点看到的最小花费, f [ i ] [ 2 ] f[i][2] f[i][2]表示点 i i i上摆放守卫的最小花费。

对于状态转移,当节点状态为 f [ i ] [ 0 ] f[i][0] f[i][0]时,那么他的最小花费就是他的所有子节点状态为 1 1 1 2 2 2时的和,但是他的子节点不可能是 0 0 0,因为 i i i 0 0 0,表示没有放侍卫,就不可能看到他的子节点,所以他的子节点只能是另外两种状态;当节点状态为 f [ i ] [ 2 ] f[i][2] f[i][2]时,它的子节点可以是三种状态,因为它上面放了侍卫;当节点状态为 f [ i ] [ 1 ] f[i][1] f[i][1]时,表示他是被子节点看到的,因此需要枚举是哪个子节点看到的它,然后取最小值就行,假设是 k k k这个结点看到了 i i i,那么一定要有 f [ k ] [ 2 ] f[k][2] f[k][2],其他的子节点可以是 1 1 1 2 2 2

代码

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

using namespace std;

const int N = 1510;

int n;
int f[N][3];
int h[N], e[N], w[N], ne[N], idx;
bool st[N];

void add(int a, int b)
{
    e[idx] = b, ne[idx] = h[a], h[a] = idx ++;
}

void dfs(int u)
{
    f[u][2] = w[u];
    
    for(int i = h[u]; i != -1; i = ne[i])
    {
        int j = e[i];
        
        dfs(j);
    
        f[u][0] += min(f[j][1], f[j][2]);
        f[u][2] += min(f[j][0], min(f[j][1], f[j][2]));
    }
    
    f[u][1] = 1e9;
    
    for(int i = h[u]; i != -1; i = ne[i])
    {
        int j = e[i]; // 枚举是哪个子节点看到当前节点u
        
        f[u][1] = min(f[u][1], f[j][2] + f[u][0] - min(f[j][1], f[j][2]));
    }
}

int main()
{
    cin >> n;
    
    memset(h, -1, sizeof h);
    for(int i = 1; i <= n; i ++)
    {
        int id, cnt, cost;
        cin >> id >> cost >> cnt;
        w[id] = cost;
        while(cnt --)
        {
            int ver;
            cin >> ver;
            add(id, ver);
            st[ver] = true;
        }
    }
    
    int root =  1;
    while(st[root]) root ++;
    
    dfs(root);
    
    cout << min(f[root][1], f[root][2]) << endl;
    
    return 0;
}

网站公告

今日签到

点亮在社区的每一天
去签到