这是一篇导览课:先用前三个完整程序认识树形、状压和数位 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]))
#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:把一排开关变成一个数字
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,判断它们不打架的条件就是:
if((s1 & s2) || ((s1 << 1) & s2) || ((s1 >> 1) & s2)) continue;
完美翻译为:正上方、左上方、右上方都不能有国王!
#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:给密码锁填数字
1. 破除心理阴影
题目让你求:1 到 10000 之间,有多少个数字不包含“4”?
一万以内当然能一个一个查。可把上限换成
数位 DP 思维:把它想象成转密码锁。比如最大数字是 324,有三个拨圈。我们从最高位(百位)开始填数字。
2. 核心大白话:什么是 limit (天花板限制)?
这是学生最难懂的地方。
假设我们要填一个不超过 324 的数字。
- 如果你百位填了
1或2:十位是不是可以随便填0~9?因为不管怎么填,最终数字都不会超过 324。这个时候,天花板限制解除了 (limit=false)。 - 如果你百位填了
3:十位能随便填吗?不能!十位最多只能填到2。这个时候,你紧紧贴着天花板,天花板限制依然存在 (limit=true)。
只要突破了“天花板限制”,后面的方案数是可以直接存起来下次复用的(也就是记忆化)。
3. 傻瓜式四参数模板
无论题目怎么变,牢记这四个参数:
pos:当前在转第几个拨圈。last:上一个拨圈填了什么(方便判断有没有挨着的 4,或者像 Windy 数那样判断差值)。limit:是否贴着天花板。lead:是不是前导零(比如004,前面两个 0 是不算数的)。

下面的完整程序以 Windy 数 为例,要求相邻数位差的绝对值至少为 2。若要做开头的“不含 4”,应在枚举数字的循环开头加 if(i==4) continue;(首个有效数字也要排除),再把 else if(abs(i-last)>=2) 改为 else。
#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 的状态本质上是“我们需要记住什么信息,才能接着往下推”。 以经典问题“不能选择相邻物品,求最大收益”为例,看看环境变化如何影响状态数组的维度:
-
环境一:排成一排的线性结构
- 题目:一排有
个物品,相邻不能选。 - 分析:从左往右扫。要知道第
个能不能选,我只需且必须记住“第 个选没选”。 - 状态分配:
dp[i][0/1]。第一维i是位置,第二维0/1是状态(选或不选)。
- 题目:一排有
-
环境二:变成公司层级(树形 DP)
- 题目:物品变成了“没有上司的舞会”中的员工树,父子不能同时选。
- 分析:这其实就是把数组变成了树。决定当前节点
u能不能选的,是它的孩子或者父亲。 - 状态分配:
dp[u][0/1]。下标从线性的i变成了树节点编号u,内在逻辑完全没变。
-
环境三:变成棋盘方格(状压 DP)
- 题目:物品放在
网格里,上下左右及斜对角均不能相邻(如“互不侵犯”)。 - 分析:我们一行一行往下推。要决定第
行怎么放,不仅要知道上一行放了啥,还得知道上一行具体哪个格子放了啥,不然没法判断上下或斜向是否相邻。 - 状态分配:如果上一行有 10 个格子,难道开 10 维数组
dp[i][0/1][0/1]...吗?这就是状态压缩登场的时候!把这一行的 10 个格子压缩成一个二进制整数state。所以状态变成了dp[i][state]。
- 题目:物品放在
对照结论:DP 数组的第一维通常是推导的阶段(第几位、哪个节点、第几行),后面的维度则是为了不破坏规则必须记住的约束条件。
五、进阶融合:同时维护最优值与方案数(选学)
场景: 题目加码了!不仅问你“这排物品不相邻选取的最大收益是多少”,还问“能拿到这个最大收益的挑选方案有几种?”
(明确要求:数据范围
核心推导: 这就像打擂台一样,我们要同时准备两个数组:
dp数组:用来记录当前的最高分(擂主的分数)。cnt数组:用来记录能打出这个分数的人数(支持这个最高分的方案数)。
每当有新的方案试图转移过来时:
- 换擂主(分更高):如果新方案的收益 > 当前记录的最高分。旧的方案全部作废!
dp更新为新分数,cnt直接继承新方案的路径数。 - 并列第一(分一样):如果新方案的收益 == 当前记录的最高分。说明我们找到了另一条殊途同归的最优路。最高分不变,但我们要把新方案的路径数加到原来的
cnt上(cnt = (cnt + new_cnt) % MOD)。
实战例题: 在一排数字中选出不相邻的若干个,求最大和以及方案数(对
#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(答案错误)。这时候怎么排错?
核心方法:拒绝干瞪眼,开启“状态对拍”。 不要去猜测是哪一个维度算错了,直接用最直接的办法验证中间过程。
- 构造极小数据:找一张草稿纸,写下一个小到一眼能看出答案的数据(比如
,或者数位 DP 上界就是 )。 - 写一个无脑暴力 (DFS):哪怕复杂度是
或者 也无所谓,因为数据极小。用回溯法穷举出所有合法方案,并在程序里单独写一个函数,把每个“子状态”的绝对正确答案(哪怕是手算的)都打印出来。 - 输出 DP 数组探针:在你的 DP 代码循环内部,插入一句调试输出。当它推导完关键状态(如
dp[3][1])后,立刻把它打印出来。 - 两面对照:将 DP 推出来的
dp[3][1]与你暴力穷举或者手推的结果比对。如果暴力告诉你这个子局面的答案是 10,而 DP 算出来是 8,说明从上一层转移过来时漏掉了情况!你就能立刻把错误锁定在发生分歧的那两行代码里。
小建议:数位 DP 极易错边界。写完后,不妨写一个只带 for 循环的 check 暴力函数,把 1 到 100 逐个分解位去硬判(if(符合条件) cnt++)。把 DP 跑出来的结果和暴力搜出的结果一减,如果有差值,直接打出这个具体的差异数字,看 DP 到底是漏了它,还是多算了它。这是赛场上找 bug 最锐利的尖刀。