动态规划

动态规划进阶

树形、状压与数位 DP

6个章节
查看本篇目录一、树形 DP:公司上下级的“请客吃饭”二、状态压缩 DP:把一排开关变成一个数字三、数位 DP:给密码锁填数字四、读题定状态:从线性到树形、网格的降维联想五、进阶融合:同时维护最优值与方案数(选学)六、考场自救:小规模穷举验证转移(状态对拍)

这是一篇导览课:先用前三个完整程序认识树形、状压和数位 DP,再用第四节练习“读题定状态”。想继续深挖,可以沿各节末尾进入对应专题;第五节作拓展,第六节留作调试时的自救工具。

一、树形 DP:公司上下级的“请客吃饭”

1. 什么是树形 DP?

在之前的线性 DP 中,我们是排着队一个一个往后算(比如数组)。但在树形 DP 中,数据变成了一棵树(就像公司的组织架构图)。

核心规矩:小弟(子节点)算完了,才能算大哥(父节点)。所以我们通常用 DFS(深度优先搜索)一路走到最底层的基层员工,然后一边往上回溯,一边把结果汇报给上级,最终在最大的老板(根节点)那里得到答案。

2. 核心入门模型:没有上司的舞会 (洛谷 P1352)

生活场景:公司要办舞会,每个员工都有一个“幽默值”。但是有个规矩:如果老板去了,他的直接下属就绝对不敢去。怎么邀请人,才能让整个舞会的幽默值加起来最大?

大白话推导(状态定义):

每个人面临的情况只有两种:去,或者不去。

我们设 u 代表某个人。

  • dp[u][0]:代表 u 不去舞会时,他和他所有下属能凑出的最大幽默值。
  • dp[u][1]:代表 u 去舞会时,他和他所有下属能凑出的最大幽默值。

汇报逻辑(状态转移):

假设 u 是老板,v 是他的直接下属。

  • 如果老板去 (dp[u][1]):下属 v 吓得绝对不敢去。所以老板能拿到的幽默值,只能加上下属不去的幽默值。

    dp[u][1] = 自己的幽默值 + 下属不去的幽默值(dp[v][0])

  • 如果老板不去 (dp[u][0]):下属 v 自由了,他可以去,也可以不去!既然我们要幽默值最大,那就看下属去或者不去哪个得分高,就挑哪个。

    dp[u][0] = 0 + max(下属去(dp[v][1]), 下属不去(dp[v][0]))

C++
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=6005;

vector<int> node[N]; // 存下属
int r[N],in[N],dp[N][2];

void dfs(int u){
	dp[u][0]=0;
	dp[u][1]=r[u]; // 老板自己去的初始得分就是他自己的幽默值
	
	// 遍历 u 的所有直接下属 v
	for(int i=0;i<node[u].size();i++){
		int v=node[u][i];
		dfs(v); // 必须先让下属算完
		
		// 下属算完后,老板开始总结汇报
		dp[u][0]+=max(dp[v][0],dp[v][1]); // 老板不去:下属去不去都可以,挑个大的
		dp[u][1]+=dp[v][0];               // 老板去:下属绝对不能去
	}
}

void solve(){
	int n;
	cin>>n;
	for(int i=1;i<=n;i++) cin>>r[i];
	for(int i=1;i<n;i++){
		int l,k;
		cin>>l>>k;
		node[k].push_back(l); // k 是老板,l 是下属
		in[l]++; // l 有老板了,入度加 1
	}
	
	// 找最高级别的大老板(没有老板的人,入度为 0)
	int root=1;
	while(in[root]) root++;
	
	dfs(root);
	// 最后比较一下大老板去和不去,哪个得分更高
	cout<<max(dp[root][0],dp[root][1])<<'\n';
}

signed main(){
	ios::sync_with_stdio(0),cin.tie(0);
	solve();
	return 0;
}

树形 DP:后序遍历与节点状态汇总

详见专题《树形 DP:子树合并、连通选取与换根扫描》,继续练习子树裁剪与换根。

二、状态压缩 DP:把一排开关变成一个数字

1. 为什么需要状态压缩?

想象一下,棋盘有一行,共 10 个格子。每个格子可以放国王,也可以不放。

这就相当于 10 个开关,开是 1,关是 0。

如果用数组存,得写成 f[2][2][2][2]...,根本没法写。

魔法转换:开关的状态连起来是 101001...,这不就是一个二进制数吗?在电脑里,它就是一个普通的十进制整数!

把一堆开关的状态,压缩成一个十进制整数,这就是“状压”。

2. 必学的位运算魔法(大白话版)

  • 查看第 i 个灯是不是亮着:(x >> i) & 1。把第 i 个灯挪到最右边,和 1 对比一下,是 1 就是亮,是 0 就是灭。

  • 绝妙校验:怎么知道有没有两个国王挨在一起?

    比如状态 x 是二进制的 110(两个国王挨着了)。

    把 x 往左推一格 x << 1,变成 1100。

    让他们做“按位与” x & (x << 1)。如果上下对应都是 1,结果就不会是 0。

    只要 if(x & (x << 1)) 成立,就说明绝对有相邻的国王,直接淘汰这个状态!

3. 入门实战:互不侵犯(完整程序)

国王的攻击范围是一圈。所以:

第一,自己这一行不能有挨着的国王(用 x & (x << 1) 淘汰)。

第二,上下两行也不能有挨着、斜着碰到的国王。

假设上一行状态是 s1,当前行状态是 s2,判断它们不打架的条件就是:

C++
if((s1 & s2) || ((s1 << 1) & s2) || ((s1 >> 1) & s2)) continue;

完美翻译为:正上方、左上方、右上方都不能有国王!

C++
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=15,M=1<<10;

int dp[N][M][N*N]; 
int state[M],cnt[M];
int tot=0;

// 把单行所有不挨着的合法放置方法找出来
void init(int n){
	for(int i=0;i<(1<<n);i++){
		if(i&(i<<1)) continue; // 自己这一行不能有挨着的 1
		
		state[++tot]=i;
		int sum=0;
		for(int j=0;j<n;j++){
			if((i>>j)&1) sum++; // 数一数用了几个国王
		}
		cnt[tot]=sum;
	}
}

void solve(){
	int n,k;
	cin>>n>>k;
	init(n);
	
	for(int i=1;i<=tot;i++){
		if(cnt[i]<=k) dp[1][i][cnt[i]]=1;
	}
	
	for(int i=2;i<=n;i++){           // 第 i 行
		for(int j=1;j<=tot;j++){     // 当前行状态
			for(int x=1;x<=tot;x++){ // 上一行状态
				int s1=state[x],s2=state[j]; // s1:上一行,s2:当前行
				// 检查上下两行会不会打架
				if((s1&s2) || ((s1<<1)&s2) || ((s1>>1)&s2)) continue;
				
				for(int c=cnt[j];c<=k;c++){
					dp[i][j][c]+=dp[i-1][x][c-cnt[j]];
				}
			}
		}
	}
	
	int ans=0;
	for(int i=1;i<=tot;i++) ans+=dp[n][i][k];
	cout<<ans<<'\n';
}

signed main(){
	ios::sync_with_stdio(0),cin.tie(0);
	solve();
	return 0;
}

状态压缩 DP:位掩码与合法性校验

详见专题《状态压缩 DP:集合枚举、轮廓状态与折半搜索》,继续练习地形掩码与集合转移。

三、数位 DP:给密码锁填数字

1. 破除心理阴影

题目让你求:1 到 10000 之间,有多少个数字不包含“4”?

一万以内当然能一个一个查。可把上限换成 101810^{18} 呢?这时就不能再靠循环硬扫了。

数位 DP 思维:把它想象成转密码锁。比如最大数字是 324,有三个拨圈。我们从最高位(百位)开始填数字。

2. 核心大白话:什么是 limit (天花板限制)?

这是学生最难懂的地方。

假设我们要填一个不超过 324 的数字。

  • 如果你百位填了 1 或 2:十位是不是可以随便填 0~9?因为不管怎么填,最终数字都不会超过 324。这个时候,天花板限制解除了 (limit=false)。
  • 如果你百位填了 3:十位能随便填吗?不能!十位最多只能填到 2。这个时候,你紧紧贴着天花板,天花板限制依然存在 (limit=true)。

只要突破了“天花板限制”,后面的方案数是可以直接存起来下次复用的(也就是记忆化)。

3. 傻瓜式四参数模板

无论题目怎么变,牢记这四个参数:

  1. pos:当前在转第几个拨圈。
  2. last:上一个拨圈填了什么(方便判断有没有挨着的 4,或者像 Windy 数那样判断差值)。
  3. limit:是否贴着天花板。
  4. lead:是不是前导零(比如 004,前面两个 0 是不算数的)。

数位 DP:limit、lead 与记忆化流程

下面的完整程序以 Windy 数 为例,要求相邻数位差的绝对值至少为 2。若要做开头的“不含 4”,应在枚举数字的循环开头加 if(i==4) continue;(首个有效数字也要排除),再把 else if(abs(i-last)>=2) 改为 else。

C++
#include<bits/stdc++.h>
#define int long long
using namespace std;

int dp[20][20];
int digit[20];

// 就像填密码锁:从高位 pos 往低位填
int dfs(int pos,int last,bool limit,bool lead){
	// 拨圈全转完了;本题只计正整数,排除全零路线
	if(pos==0) return !lead;
	
	// 如果没有天花板限制,且不是前导零,并且这个状态以前算过,直接拿来用!(这就是 DP 加速的秘密)
	if(!limit && !lead && dp[pos][last]!=-1) return dp[pos][last];
	
	int res=0;
	// 看看当前这一位最多能拨到几
	int up=limit?digit[pos]:9; 
	
	// 开始尝试拨动这一位的数字 i
	for(int i=0;i<=up;i++){
		// 情况一:前面都是 0,现在这块填的还是 0,那就继续保持前导零状态往下传
		if(lead){
			res+=dfs(pos-1,i,limit&&(i==up),i==0);
		}
		// 情况二:正常填数字 (这里以 Windy 数为例,要求相邻数字差必须 >= 2)
		else if(abs(i-last)>=2){
			res+=dfs(pos-1,i,limit&&(i==up),false);
		}
	}
	
	// 算完之后,如果没有天花板限制和前导零的干扰,就把答案记到一个本子上
	if(!limit && !lead) dp[pos][last]=res;
	return res;
}

int calc(int x){
	if(x<=0) return 0;
	int len=0;
	// 把数字拆成数组,比如 324 拆成 digit[1]=4, digit[2]=2, digit[3]=3
	while(x){
		digit[++len]=x%10;
		x/=10;
	}
	// 从最高位往下搜,上一个数字随便给个不冲突的 -2,初始有天花板,初始也是前导零
	return dfs(len,-2,true,true);
}

void solve(){
	memset(dp,-1,sizeof dp);
	int l,r;
	cin>>l>>r;
	cout<<calc(r)-calc(l-1)<<'\n';
}

signed main(){
	ios::sync_with_stdio(0),cin.tie(0);
	solve();
	return 0;
}

详见专题《数位 DP:上界限制、前导零与记忆化搜索》。本篇的 pos/last/limit/lead,分别对应专题中的 p/pre/lim/zero,名字不同,作用相同。

四、读题定状态:从线性到树形、网格的降维联想

场景: 考场上遇到一道题,你知道要用 DP,但是第一步“开几维数组”就卡住了。

核心思维: DP 的状态本质上是“我们需要记住什么信息,才能接着往下推”。 以经典问题“不能选择相邻物品,求最大收益”为例,看看环境变化如何影响状态数组的维度:

  1. 环境一:排成一排的线性结构

    • 题目:一排有 NN 个物品,相邻不能选。
    • 分析:从左往右扫。要知道第 ii 个能不能选,我只需且必须记住“第 i−1i-1 个选没选”。
    • 状态分配:dp[i][0/1]。第一维 i 是位置,第二维 0/1 是状态(选或不选)。
  2. 环境二:变成公司层级(树形 DP)

    • 题目:物品变成了“没有上司的舞会”中的员工树,父子不能同时选。
    • 分析:这其实就是把数组变成了树。决定当前节点 u 能不能选的,是它的孩子或者父亲。
    • 状态分配:dp[u][0/1]。下标从线性的 i 变成了树节点编号 u,内在逻辑完全没变。
  3. 环境三:变成棋盘方格(状压 DP)

    • 题目:物品放在 N×NN \times N 网格里,上下左右及斜对角均不能相邻(如“互不侵犯”)。
    • 分析:我们一行一行往下推。要决定第 ii 行怎么放,不仅要知道上一行放了啥,还得知道上一行具体哪个格子放了啥,不然没法判断上下或斜向是否相邻。
    • 状态分配:如果上一行有 10 个格子,难道开 10 维数组 dp[i][0/1][0/1]... 吗?这就是状态压缩登场的时候!把这一行的 10 个格子压缩成一个二进制整数 state。所以状态变成了 dp[i][state]。

对照结论:DP 数组的第一维通常是推导的阶段(第几位、哪个节点、第几行),后面的维度则是为了不破坏规则必须记住的约束条件。

五、进阶融合:同时维护最优值与方案数(选学)

场景: 题目加码了!不仅问你“这排物品不相邻选取的最大收益是多少”,还问“能拿到这个最大收益的挑选方案有几种?” (明确要求:数据范围 1≤N≤1051 \le N \le 10^5,−109≤Ai≤109-10^9 \le A_i \le 10^9。允许一个都不选,即空集的收益为 0,算作 1 种合法方案)。

核心推导: 这就像打擂台一样,我们要同时准备两个数组:

  • dp 数组:用来记录当前的最高分(擂主的分数)。
  • cnt 数组:用来记录能打出这个分数的人数(支持这个最高分的方案数)。

每当有新的方案试图转移过来时:

  1. 换擂主(分更高):如果新方案的收益 > 当前记录的最高分。旧的方案全部作废!dp 更新为新分数,cnt 直接继承新方案的路径数。
  2. 并列第一(分一样):如果新方案的收益 == 当前记录的最高分。说明我们找到了另一条殊途同归的最优路。最高分不变,但我们要把新方案的路径数加到原来的 cnt 上(cnt = (cnt + new_cnt) % MOD)。

实战例题: 在一排数字中选出不相邻的若干个,求最大和以及方案数(对 109+710^9+7 取模)。

C++
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=100005;
const int MOD=1e9+7;

int a[N];
int dp[N][2];  // dp[i][0/1] 记录前 i 个物品,第 i 个不选/选时的 最大收益
int cnt[N][2]; // cnt[i][0/1] 记录对应的 方案数

void solve(){
	int n;
	cin>>n;
	for(int i=1;i<=n;i++) cin>>a[i];
	
	// 初始化第一个物品
	dp[1][0]=0;    cnt[1][0]=1; // 第一件不选:收益0,方案数为1种(空集也算合法方案)
	dp[1][1]=a[1]; cnt[1][1]=1; // 第一件选:收益a[1],方案数为1种
	
	for(int i=2;i<=n;i++){
		// 1. 第 i 个不选:前面那个选或不选都可以,我们需要挑一个收益最大的
		if(dp[i-1][0] > dp[i-1][1]){
			dp[i][0] = dp[i-1][0];
			cnt[i][0] = cnt[i-1][0]; // 继承更优者的方案数
		} else if(dp[i-1][0] < dp[i-1][1]){
			dp[i][0] = dp[i-1][1];
			cnt[i][0] = cnt[i-1][1];
		} else {
			// 两边收益一样大,方案数相加
			dp[i][0] = dp[i-1][0];
			cnt[i][0] = (cnt[i-1][0] + cnt[i-1][1]) % MOD;
		}
		
		// 2. 第 i 个选:前面那个绝对不能选
		dp[i][1] = dp[i-1][0] + a[i];
		cnt[i][1] = cnt[i-1][0]; // 方案数完全取决于 i-1 不选的方案数
	}
	
	// 最后在第 n 个选与不选里,再打一次擂台
	int ans_val, ans_cnt;
	if(dp[n][0] > dp[n][1]){
		ans_val = dp[n][0]; 
		ans_cnt = cnt[n][0];
	} else if(dp[n][0] < dp[n][1]){
		ans_val = dp[n][1]; 
		ans_cnt = cnt[n][1];
	} else {
		ans_val = dp[n][0]; 
		ans_cnt = (cnt[n][0] + cnt[n][1]) % MOD;
	}
	cout<<ans_val<<" "<<ans_cnt<<'\n';
}

signed main(){
	ios::sync_with_stdio(0),cin.tie(0);
	solve();
	return 0;
}
/*
输入样例:
4
2 2 2 2
输出样例:
4 3
样例解释:不相邻选择最大和为 4。有 3 种方案:选第1/3个、第1/4个、第2/4个。注意即使全负数,允许不选,最大和为 0,方案数为 1。
*/

六、考场自救:小规模穷举验证转移(状态对拍)

场景: 考场上,你花了一个小时推出状压 DP 的状态或者数位 DP 的边界转移。样例能过,但心里就是发虚。一交上去,果然大面积 WA(答案错误)。这时候怎么排错?

核心方法:拒绝干瞪眼,开启“状态对拍”。 不要去猜测是哪一个维度算错了,直接用最直接的办法验证中间过程。

  1. 构造极小数据:找一张草稿纸,写下一个小到一眼能看出答案的数据(比如 N=3N=3,或者数位 DP 上界就是 2020)。
  2. 写一个无脑暴力 (DFS):哪怕复杂度是 O(N!)O(N!) 或者 O(2N)O(2^N) 也无所谓,因为数据极小。用回溯法穷举出所有合法方案,并在程序里单独写一个函数,把每个“子状态”的绝对正确答案(哪怕是手算的)都打印出来。
  3. 输出 DP 数组探针:在你的 DP 代码循环内部,插入一句调试输出。当它推导完关键状态(如 dp[3][1])后,立刻把它打印出来。
  4. 两面对照:将 DP 推出来的 dp[3][1] 与你暴力穷举或者手推的结果比对。如果暴力告诉你这个子局面的答案是 10,而 DP 算出来是 8,说明从上一层转移过来时漏掉了情况!你就能立刻把错误锁定在发生分歧的那两行代码里。

小建议:数位 DP 极易错边界。写完后,不妨写一个只带 for 循环的 check 暴力函数,把 1 到 100 逐个分解位去硬判(if(符合条件) cnt++)。把 DP 跑出来的结果和暴力搜出的结果一减,如果有差值,直接打出这个具体的差异数字,看 DP 到底是漏了它,还是多算了它。这是赛场上找 bug 最锐利的尖刀。

搜索全部54篇讲义的标题、目录与正文
点击结果进入讲义Esc 关闭