[解题报告][算法总结] 2023/8/24 树形dp报告

题单

简介

树形 dp,一般常使用 记忆化搜索 解决。其关键是找到儿子和父亲之间的转移关系,设计状态。和普通 dp 不同的是,一般先递归处理儿子,再回溯处理父亲。

树形 dp 的几个特殊例子有树上背包,换根 dp(二次扫描)。

处理树的通用做法:直接建无向图,因为无向图的任意一个点都可以作为 root,为了放置重复搜索每次 dfs 记录一下该节点的 father,如果搜到的 \(v= father\) 则不走。

这样的处理使得我们不必要取找 root。

在递归中统计答案 一定要使用局部变量!,全局变量每次递归会更改,出现一些奇怪的错误。

Basic:树的遍历

给定一颗树,树中包含 \(n\) 个结点(编号\(1\)\(n\))和 \(n−1\) 条无向边。
请你找到树的重心,并输出将重心删除后,剩余各个连通块中点数的最大值。
重心定义:重心是指树中的一个结点,如果将这个点删除后,剩余各个连通块中点数的最大值最小,那么这个节点被称为树的重心。

Solution

对于每个点 \(i\) ,都将其作为重心,去找到它的连通块点数最大值。

它的每个儿子都可以作为一个连通块,很好处理。但是它的父亲呢?

如下图所示:

image

在上图中,若将 \(2\) 号节点作为“重心”,有三个连通块。若记它下面的每一个连通块共有 \(k\) 个点,整张图共有 \(n\) 个点,则它上面连通块的点数量为 \(n-k-1\)。这样我们就间接地求出上面“不好求”的部分。

因此,我们只需要对于每个点,递归的求出它下面每个连通块的点数量,再计算出 \(n-k-1\)。取 \(max\) 即为当前点作为“重心”时的答案。

具体实现递归即可。

具体实现
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <vector>
#define int long long
using namespace std;
const int N = 1000010;
const int INF = 0x3f3f3f3f;
vector <int> Edge[N];
int n;
int vis[N];
int ans = INF;
int minn_num = 0;
int dfs(int now)
{
    vis[now] = 1;
    int sum = 1,res = 0;
    for(int i=0;i<Edge[now].size();i++)
    {   
        int v = Edge[now][i];
        if(!vis[v])
        {
            int t = dfs(v);
            sum += t;
            res = max(res,t);
        }
    }
    res = max(res,n-sum);
    if(res <= ans)
    {
        ans = res;
        minn_num = now;
    }
  //  ans = min(ans,res);
    return sum;
}
signed main()
{
    // ios::sync_with_stdio(false);
    // cin.tie(0);
    // cout.tie(0);
    cin>>n;
    for(int i=1;i<n;i++)
    {
        int u,v;
        cin>>u>>v;
        Edge[u].push_back(v);
        Edge[v].push_back(u);
    }
    dfs(1);
    cout<<minn_num<<endl<<ans<<endl;
    return 0;
}

树形 dp 初步:计数类

一棵树共有 \(n\) 个节点,\(n-1\) 条边。你可以删去任意条边。使得最后保留在树上的部分点权值最大
原题链接:Luogu P1122 最大子树和

Solution

\(f_{i,j}\) 表示当父节点为 \(i\) 时,保留 \(j\) 条边时点权值最大值。

对于边 \(u-v\) 。先处理 \(v\) 。我们发现当且仅当 \(f{v,k}>0(\forall k < j)\) 时,我们才使 \(f{u,j}+f{v,k}\) 。最坏情况就是把所有枝条全部剪掉,这也就是所有点都是负边权的情况下。

以上即为本题状态转移方程。具体地,\(f{u,j}+f{v,k}(\forall k < j,f{v,k}>0)\)

实现
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <vector>
#define N 100010
#define INF 0x3f3f3f3f
using namespace std;
vector <int> Edge[N];
int n;
int maxn = -INF;
int a[N];
int f[N];
void dfs(int p,int fa)
{
	f[p] = a[p];
	for(int i=0;i<Edge[p].size();i++)
	{
		int t = Edge[p][i];
		if(t!=fa)
		{
			dfs(t,p);
			if(f[t] > 0) f[p] += f[t];
		}
	}
}
int main()
{
	scanf("%d",&n);
	for(int i=1;i<=n;i++) scanf("%d",&a[i]);
	for(int i=1;i<n;i++)
	{
		int x,y;
		scanf("%d%d",&x,&y);
		Edge[x].push_back(y);
		Edge[y].push_back(x);
	}
	dfs(1,-1);
	for(int i=1;i<=n;i++) maxn = max(maxn,f[i]);
	cout<<maxn<<endl;
	return 0;
}

树上背包:有依赖的背包问题

从某种意义上讲,树上背包 本质是枚举子树的选择。由于树的结构特性,我们显然无法像线性背包一样 “优美地”线性转移。

显然,枚举每个子树选择多少个儿子是不可避免的。树上背包就是递归计算出子树选择多少个儿子的最大贡献,然后进行枚举选择方案取最大值。非常“暴力”。理解树形 dp 的本质非常重要。

树上背包最常见的是树上 01 背包,和朴素 01 背包同理也可以使用滚动数组优化掉一层状态。具体操作详见下文。

典例1:基础的树上 01 背包

有一棵有 \(n\) 个点,\(n-1\) 条边的完全二叉树。你需要保留任意 \(q\) 条边,前提它们必须还是一棵树。每条边上都有权值,求权值最大。
原题链接Luogu P2015 二叉苹果树

Solution

树上的 01 背包问题。

首先,题目要求 一系列操作后剩下的 \(m\) 条边必须还组成一颗树。因此想要保留一个儿子上的边就必须保留它通往它父亲的边。

借鉴以往的经验,设 \(f_{i,j}\) 表示以 \(i\) 为父节点,保留 \(j\) 条边时最大边权和。

和普通树上 dp 同理,先处理儿子。对于每条边 \(u-v\) ,枚举 \(u\) 保留几条边,\(v\) 保留几条边。(显然 \(v\) 保留边数小于等于 \(u\) 保留边数)。设 \(u\) 保留 \(k\) 条边,\(v\) 保留 \(m\) 条边,边 \(u-v\) 的边权为 \(w\),则有:

\(f_{u,k}=max(f_{u,k},f_{v,m}+f_{u,k-m-1}+w)\)

我们在考虑转移的时候,只考虑两个节点即可,无需考虑很多。这样会更加简单。

最后答案即为 \(f_{1,q}\) 。也就是以祖先为根节点保留 \(q\) 条边时的最大权值。特别地,并不一定选 root。

实现
/*
树上背包

不难发现本题修建完后最后留在树上的苹果才算作答案。

所以,若一个子节点的枝条想要保留,则它的父节点的枝条一定保留!

所以就变成了一个树上做 01 背包问题

不妨设 $f_{i,j}$ 为 以 i 为父节点下面 j 个枝条最大苹果数量。
*/

#include <iostream>
#include <cstdio>
#include <algorithm>
#include <vector>
using namespace std;
const int N = 10010;
typedef pair<int,int> PAIR;
int n,q;
vector <PAIR> Edge[N];
int f[N][N];
int maxn = -1;
int sz[N];
void dp(int noww,int fa)
{
    for(int i=0;i<Edge[noww].size();i++)
    {
        int v = Edge[noww][i].first;
        if(v == fa) continue;
        dp(v,noww);
        int u = noww;
        for(int j=q;j>=0;j--)
        {
            for(int k=j-1;k>=0;k--) //最多保留 j-1条边,父节点不可能一条边也不保留!
            {
                f[noww][j] = max(f[noww][j],Edge[noww][i].second+f[noww][j-k-1]+f[v][k]);
                maxn = max(maxn,f[noww][j]);
            }
        }
    }
}
int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    cin>>n>>q;
    for(int i=1;i<n;i++)
    {
        int u,v,w;
        cin>>u>>v>>w;
        Edge[u].push_back(PAIR(v,w));
        Edge[v].push_back(PAIR(u,w));
    }
    dp(1,-1);
    cout<<f[1][q]<<endl;
    return 0;
}

典例2:有依赖的01背包

\(n\) 门课程,每个课程都有一个学分和一个先修课程(只有学完先修课程才能学该课程),你需要选择 \(M\) 门课程学习,求最大学分。

Solution

和上题同理,设 \(f_{i,j}\) 表示以 \(i\) 为父节点学 \(j\) 门课程时的最大学分。先处理儿子,然后枚举 \(i\) 学几门课程,\(k\) 学几门课程(有一条边 \(i-k\) ),状态转移:

\(f_{i,j}=max(f_{i,j},f_{i,j-l}+f_{k,l}\)

初始化使 \(f_{i,1}=a_i\)\(a_i\) 为每个课程 \(i\) 的学分)

本题还有一个小 Trick,其实用了很多次了。就是题目给出森林难以处理的时候,我们可以将 \(0\) 号节点参与建树,把森林变成一颗树。

最后输出 \(f_{0,m}\) 即可。\(0\) 即为我们的虚拟 root 。

实现
void dp(int now)
{
    for(int i=0;i<Edge[now].size();i++)
    {
        dp(Edge[now][i]);
        for(int j=m;j>=1;j--)
        {
            for(int k=0;k<j;k++) 
            {
                f[now][j] = max(f[now][j],f[Edge[now][i]][k]+f[now][j-k]);
            }
        }
    }
}

典例3:设计状态需要转化的树上背包

题目链接:Luogu P1273 有线电视网

Solution

欸这题看似有点难解决,如果我们给定满足用户数量求最多能赚多少钱是不是就好做了?

这也就是个朴素树上背包问题。

\(f_{i,j}\) 表示以 \(i\) 为父节点,满足 \(j\) 个用户时最多能赚多少钱,对于一条边 \(u-v\),先处理 \(v\)。枚举 \(u\)\(v\) 的满足用户数量。设 \(u\) 满足 \(k\) 个用户,\(v\) 满足 \(l\) 个用户:

\(f_{u,k} = max(f_{u,k},f_{v,l}+f_{u,k-l}+w)\)\(w\) 表示 \(u-v\) 边的权值)

一个小 Trick:我们首先预处理每个节点下面有多少个用户,这样就能减少很多不必要的枚举。

最后答案倒着枚举满足多少个用户,如果满足当前用户数量且赚钱大于0,则输出即可。

实现
//预处理部分
void pre(int now,int fa) //预处理,可以减少大量无效计算qwq
{
    for(int i=0;i<Edge[now].size();i++)
    {
        int v = Edge[now][i].first;
        if(v == fa) continue;
        pre(v,now);
        sz[now] += sz[v]; //递归预处理,回溯时更新
    }
}
//dp部分
void dp(int noww,int fa) 
{
    for(int i=0;i<Edge[noww].size();i++)
    {
       int v = Edge[noww][i].first;
       int w = Edge[noww][i].second;
        if(v == fa) continue;
        dp(v,noww);
        for(int j=sz[noww];j>=1;j--) //避免多跑,预处理一个节点下面的儿子数量
        {
            for(int k=1;k<=sz[v];k++)
            {
                f[noww][j] = max(f[noww][j],f[v][k]+f[noww][j-k]-w); // 显然保证 sz[v] \leq sz[u]
            }
        }
    }
}

决策dp

Trick:需要记录决策状态的dp,一个节点不同的决策对下一个节点有影响,并不一定代表有后效性,我们可以记录决策状态,分类讨论进行转移!

典例1:没有上司的舞会

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

Solution

我们发现一个人来或者不来参加影响她的儿子。这并不一定代表有后效性,我们发现题目明确告知了如何转移,直接按照题意模拟即可。

\(f_{i,g}(g\in\{0,1\})\) 表示以 \(i\) 为父节点,她来 or 不来时的最大快乐值。

首先考虑初始状态,显然初始使得 \(f_{i,1}=a_i,f_{i,0}=0\)。这是一定的,我们所有操作都是在这个基础上进行的。

考虑转移:

\(f_{u,1}+=f_{v,0},f_{u,0}+=max(f_{v,1},f_{v,0})\)

Explanation:如果父节点去,则子节点只能不去。如果父节点不去,则子节点可去可不去,取 max 即可。

这个题应该是树形dp很典的题了吧qwq

实现
void dfs(int pos)
{
	if(!Edge[pos].size())//叶子节点,直接赋值即可
	{
		f[0][pos] = 0;
		f[1][pos] = a[pos];
		return;
	}
	for(int i=0;i<Edge[pos].size();i++)
	{
		dfs(Edge[pos][i]);
		f[0][pos] += max(f[0][Edge[pos][i]],f[1][Edge[pos][i]]);
		f[1][pos] += f[0][Edge[pos][i]];
	}
	f[1][pos] += a[pos];
}

典例2:战略游戏

题目链接:Luogu P2016

Solution

容易发现一个节点放或者不放士兵影响后面的决策。所以需要设计这个状态。

定义 \(f_{i,k(k \in \{0,1\})}\) 表示以 \(i\) 为父节点,且 \(i\) 放或者不放时,其儿子放置士兵最小值。

因为 \(i\) 放 or 不放会影响到儿子的转移。所以,我们在写状态转移方程的时候需要分情况讨论:

  • \(i\) 放时,子节点可放可不放,因为所有子节点通向 \(i\) 的路已经被瞭望到。设边 \(u-v\),\(u\) 为 father,则 \(f_{u,1}+=min(f_{v,1},f_{v,0})\)

  • \(i\) 不放时,子节点必须放。因为如果子节点不放则子节点通向 \(i\) 的路无法瞭望。\(f_{u,0}+=f_{v,1}\)

每次转移前我们都需要初始化使 \(f_{u,1}=1,f{u,0}=0\)

最后答案输出 \(min(f_{1,1},f_{1,0})\) 即可。显然 root 可放可不放。

实现
void dp(int noww,int fa)
{
    f[noww][1] = 1;
    f[noww][0] = 0;
    for(int i=0;i<Edge[noww].size();i++)
    {
        int v = Edge[noww][i];
        if(v == fa) continue;
        dp(v,noww);
        int u = noww;
        f[u][0] += f[v][1];
        f[u][1] += min(f[v][0],f[v][1]);
    }
}

典例3:Gem气垫车

给出一棵树,要求你为树上的结点标上权值,权值可以是任意的正整数
唯一的限制条件是相临的两个结点不能标上相同的权值,要求一种方案,使得整棵树的总价值最小
原题链接:Luogu P4395

Solution

好有意思的题目!

我们发现一个点标什么权值影响后面的决策,显然需要设计这一层状态。

定义 \(f_{i,j}\) 表示以 \(i\) 为父节点,它染 \(j\) 编号时最小总权值。

初始化令 \(f_{i,j}=1\)。接下来考虑转移,枚举父亲的染色 \(i\) 和儿子的染色 \(j\),对于每条边 \(u-v\),有:

\(f_{u,i}+=min(f_{v,j}(i \ne j))\)

最后枚举 root 也就是 \(1\) 染什么色的最小值即可。

实现
void dp(int noww,int fa)
{
    for(int i=1;i<=20;i++) f[noww][i] = i;
    for(int i=0;i<Edge[noww].size();i++)
    {
        int v = Edge[noww][i];
        if(v == fa) continue;
        dp(v,noww);
        for(int j=1;j<=20;j++)
        {
            int minn = INF;
            for(int k=1;k<=20;k++)
            {
                if(j == k) continue;
                minn = min(minn,f[v][k]);
            }
            f[noww][j] += minn;
        }
    }
}

换根 dp

换根 dp 一般是没有给定 root,而且 root 之间的转移会影响一些值,并且影响的这些值随着 root 之间转移有规律。

换根 dp 非常套路,一般是两次 dfs,第一次 dfs 预处理一些值,以备转移。第二次 dfs 就是运行状态转移方程。

换根 dp 的很多处理方式都非常有用,很多题目都运用了换根 dp 的思想。

具体内容见 [算法学习笔记]换根 dp

posted @   SXqwq  阅读(24)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!
点击右上角即可分享
微信分享提示