☰
叶子的染色:树形DP中覆盖型状态设计的经典案例
2026/10/16 7:44:00 网站建设 项目流程

第一次跟“叶子的染色”正面相遇,是在一本通题库里,后来发现洛谷P3155也在考同一道题。当时看完题面第一反应是:这不就是给叶子染个色嘛,有什么好DP的?真动手之后才发现,“路径上第一个有色节点”这半句话,才是整道题最大的坑。

这道题是一个很经典的树形动态规划,适合刚学完DFS、准备啃树形DP的人用来练“状态压缩直觉”。题面不复杂,但如果你没理解“祖先染色会覆盖整棵子树”,很容易把状态设计成三维甚至四维,最后把自己绕晕。我这一篇不打算贴一堆状态转移唬人,就围绕一个关键点展开:为什么一个f[u]就够了,以及在实现里哪些细节最容易翻车。

1. 先把题意翻译成人话

1.1 题面里最容易读错的一句话

原题的约束是:

每个叶子节点有一个需要的颜色。对于每个叶子,从根到它的路径上,第一个被染色的节点的颜色必须等于这个叶子需要的颜色。

注意几个关键词:

  • “第一个被染色”,是按从根往叶子走的方向数,而不是从叶子往根数。
  • 它没有说叶子本身必须被染色,只是说路径上第一个有色节点颜色要吻合。

我见过很多人读完题,第一感觉是“叶子必须染”,于是答案至少是叶子数量 m。这样题目就退化成一个没有意义的数字游戏,显然不是出题人本意。正确理解是:只要某个祖先染成了叶子需要的颜色,这个叶子自己是可以不染的。比如一条链 根->x->y->叶子,叶子要0,你可以只染根,也可以只染叶子,答案都是1;如果你染中间的x,一样满足。这跟“叶子必须染”是两码事。

至于为什么“第一个”是根方向的第一个,其实按日常语言,“从根到叶子的路径上第一个有色节点”本来就是从路径起点开始数。如果理解成离叶子最近的那个,很多转移方程都会反过来,样例都不好过。

1.2 染色节点就是一把伞:一染遮百叶

把每个染色节点想象成一把撑开的伞。这把伞的伞面覆盖以它为根的整棵子树。一旦这把伞撑在某个位置,所有在这棵子树里的叶子,抬头看见的第一个颜色就是伞的颜色。所以这把伞的颜色必须和伞下每一个叶子的需求色一致,只要有一个叶子颜色不同,这个染色节点就是非法的。

这个比喻能解释很多东西:

  • 如果一片叶子头顶已经有伞,那么它自己不需要再染色。因为路径最前面的有色节点已经出现了。
  • 如果u染色,那么u的所有后代节点都没有必要染色,因为它们的任何染色都发生在“已经看见一把伞”之后,对叶子来说完全无效。
  • 如果一片叶子在多个染色节点的伞面交叠区域,只有最靠近根的那把伞起作用。

所以,一次染色的实际效果,是用1个节点“替换”一整片同色叶子的染色需求。树的层次越高,一片单色叶子区域就越大,用1个节点覆盖就越划算。这就是这道题优化空间的存在。

1.3 为什么最优解里不会出现祖先和后代同时染色

根据上面的伞模型,如果祖先A已经染色,后代B再染色,那么B的伞完全被A的伞盖住,B的存在不会被任何叶子感知到,因为叶子看到的第一个有色节点是A。去掉B之后,所有叶子的条件仍然满足,但染色数少了一个。因此,任何最优方案都可以调整成“染色节点两两之间没有祖先关系”的方案。

这个性质非常重要,它让问题可以切割成互不干扰的子问题:

  • 如果当前节点u不染色,那么它的每一个儿子子树都必须各自独立解决叶子的需求,因为两边叶子看到的第一个有色节点分别出现在各自的子树里。
  • 如果u染色,那么它直接覆盖整棵子树,子问题彻底结束,不再向下递归。

有了这两个互斥的大方向,就可以设计DP了。

2. 状态设计:一个 f[u] 就够了

2.1 给 f[u] 一个严格的定义

设f[u]表示:在“祖先不帮忙”的假设下,以 u 为根的子树独立解决问题所需的最少染色节点数。

“祖先不帮忙”指的是:从根到u的路径上没有已染色节点,因此u子树内的叶子不能指望祖先来覆盖,必须由u子树内部的染色节点来满足条件。祖先是否染色会影响子树最终要不要染色,这是树形DP里很容易踩的后效性。我们的做法是,在转移时把“如果u染色”的收益一次性算清,把它覆盖的所有子问题全部吞掉,这样每个子树的f值就只代表“自己独立存活”的成本,不依赖外部环境。

对于整棵树,答案就是f[root],因为根没有祖先,它只能靠自己。

2.2 两个选择,三种写法

对于非叶子节点u,只有两种大方向。

方向一:u不染色。那么u不会对任何叶子产生覆盖,每个儿子子树必须独立解决。代价:

sumF = Σ f[v] (v 是 u 的儿子)

方向二:u染色。u染成0或1。无论染成哪种颜色,都要求u子树中的所有叶子需求颜色一致,且就是这个颜色。如果满足,代价为1。

为什么代价是1而不是1 + Σ f[v]?因为只要u染色,u就是所有后代叶子路径上第一个有色节点,所有后代节点再染色都是多余的。前面1.3已经证明最优解不会保留多余的后代染色。所以你只需要花1个节点,整棵子树都交差。

于是转移式可以写成:

f[u] = min( sumF, 1(当子树全0), 1(当子树全1) )

注意“全0”和“全1”不可能同时成立,除非子树没有叶子,但非叶子节点子树必包含叶子。所以实际最多只有一个1被考虑。

拿一个最简单例子验证。根节点是3,两个孩子1、2都是叶子,1要0,2要1。此时f[1]=1, f[2]=1,根子树不是单色,不能染根,答案2。如果1、2都要0,则根子树全0,可以只染根,答案1。这个差异就是“伞”的价值。

2.3 叶子节点单独处理的原因

叶子节点l没有儿子,如果套用方向一“u不染色”,sumF=0,这会得到一个非法答案0:树里没有任何染色节点,叶子路径上根本没有有色节点。所以叶子只能选择方向二,也就是染自己,f[l]=1。

实现上,我们可以在进入叶子时直接返回:

f[u] = 1; has0[u] = (col[u] == 0); has1[u] = (col[u] == 1);

有的写法会先把res设成INF,再走统一的单色判断,同样能得到1。两种都可以,关键是不要让叶子得到0。

2.4 和洛谷题解里三状态DP的关系

很多题解会写三个状态:

dp[u][0] = 1 + Σ min(dp[v][0], dp[v][2]) dp[u][1] = 1 + Σ min(dp[v][1], dp[v][2]) dp[u][2] = Σ min(dp[v][0], dp[v][1], dp[v][2])

这个DP本身没问题,但初学者很容易把dp[u][2]误解成“u不染色时的答案”,然后发现dp[u][0]的转移里儿子不染色为什么只用dp[v][2]而不用dp[v][0],绕来绕去。

实际上,三状态DP是把“祖先是否已经染色”这一维藏进了不同状态里:

  • dp[u][0]和dp[u][1]相当于“u染色,假设它是当前子树内第一个挡箭牌”时的代价。
  • dp[u][2]则相当于“u不染,并且希望祖先不覆盖它,让儿子们各自处理”的代价。

把它展开成我们这一维状态,最后化简就是上面的min(sumF, 1)形式。所以胡写状态之前,先用伞模型把覆盖关系画清楚,会省很多力气。竞赛里你写三状态也能AC,但如果你能说出每个状态的实际含义,调试时会顺手很多。

3. C++ 代码实现与踩坑记录

3.1 建树时先搞清楚根和叶子的编号

题目习惯把节点1到m作为叶子,节点n作为根,n一般不是叶子。输入是无向边,我们无脑建双向邻接表,然后从n开始DFS。

注意输入顺序:洛谷P3155和一本通P1583都是先给n-1行边,再给m个叶子的颜色。代码里不要习惯性地把颜色读在边前面。如果你拿到的题面顺序不同,就把输入段对调。

叶子颜色的数组只需要开m大小,但下标是节点编号,所以我习惯直接开成MAXN大小,把col[i]存在节点i上,给DFS叶子判断时直接用,不用再做“第i个叶子对应节点i”的映射,因为本题叶子编号就是1..m。

3.2 完整代码

下面给出我实际提交能过的版本。注释尽量写给新手看。

// 洛谷 P3155 / 一本通 P1583 叶子的染色 #include <bits/stdc++.h> using namespace std; const int MAXN = 10005; int n, m; int col[MAXN]; // 叶子的需求颜色,0 或 1 int f[MAXN]; // 子树独立解决的最小染色数 bool has0[MAXN], has1[MAXN]; // 子树内是否存在需要0/1的叶子 vector<int> e[MAXN]; void dfs(int u, int fa) { // 题目保证 1..m 全是叶子 if (u <= m) { f[u] = 1; // 叶子只能靠自己,必须染一次 if (col[u] == 0) has0[u] = true; else has1[u] = true; return; } int sumF = 0; for (int v : e[u]) { if (v == fa) continue; dfs(v, u); has0[u] |= has0[v]; has1[u] |= has1[v]; sumF += f[v]; } // 方向一:u 不染色,每个儿子自己解决 int res = sumF; // 方向二:u 染色,前提是子树内所有叶子颜色一致 // 子树全为 0 if (has0[u] && !has1[u]) res = min(res, 1); // 子树全为 1 if (has1[u] && !has0[u]) res = min(res, 1); f[u] = res; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); cin >> n >> m; // 先读边,再读叶子颜色,注意顺序 for (int i = 1; i < n; ++i) { int a, b; cin >> a >> b; e[a].push_back(b); e[b].push_back(a); } for (int i = 1; i <= m; ++i) cin >> col[i]; // 题目指定的根是 n,不是 1 dfs(n, 0); cout << f[n] << '\n'; return 0; }

代码不长,但有几个点值得展开:

  • u <= m判断叶子:这是由题目保证的,不是通过度数判断。如果你写deg[u]==1,根在只有两个节点的极端情况可能被误判成叶子,因为此时根n的度数也是1。
  • has0[u]和has1[u]:分别表示子树内是否存在需求为0和需求为1的叶子。如果has0 && !has1说明子树全要0;如果has1 && !has0说明全要1;两个都是true说明非单色。
  • sumF是“u不染色”的代价。这里不需要把res初始成INF,因为我们已经把叶子特判掉了,非叶子节点一定可以有“不染色”方案。但如果你要统一处理叶子,就必须把res初始成INF,否则会翻车。

3.3 复杂度与运行规模

DFS每个节点只访问一次,每条边被枚举两次(邻接表),复杂度O(n+m)。这类题n一般不超过10^5,开int足够;如果出题人搞到10^6,递归DFS要小心栈溢出,可以手写栈后序遍历,但思路不变。空间上三个数组col/f/has0/has1都是O(n),邻接表也是O(n)。

3.4 最容易出错的三个实现细节

第一个是叶子特判的位置,必须放在进入DFS后、遍历儿子之前。如果放在后面,叶子会被当成内部节点处理,sumF=0会污染答案。

第二个是单色判断用“且有且仅有一种需求”这个条件,不是“只要有一个has为true就单色”。如果你写if (has0[u] || has1[u]) res = min(res, 1),那非单色的子树也会被错误地允许染u,答案会偏小。

第三个是根别写错。题目指定根是n,不是1。有些同学从上到下读完边后,顺手就从1开始DFS,样例可能碰巧对,但换一组数据就WA。我自己的习惯是在主函数里先写注释“根是n”,然后再写DFS调用。

4. 常见问题与调试方法

4.1 样例输出总比答案小1

如果你发现程序输出总是比手算少1,十有八九是叶子节点被算成了0。检查代码:叶子节点有没有特判?如果没有,在统一逻辑里res = sumF = 0,然后单色判断min(0,1)=0,叶子就被免费了。这会导致每条叶子分支少算1个染色节点,正好是“少1”。

解决方法很简单:在DFS开头加叶子特判,或者把res初始值设为INF后再统一走一遍单色逻辑。

4.2 输入顺序带来的“颜色全乱”

常见症状:程序跑出来的答案离奇,比如根明明能只染1个但输出3。在调试时,先用一个循环把col[1..m]输出出来,看是不是你输入的颜色。如果发现col[1]变成了某个边的端点,说明你把颜色读在边之前了,导致边把颜色数组覆盖掉。题目是先边后颜色,调整输入段即可。

4.3 用度数判断叶子踩坑

题目说1..m是叶子节点,这是最硬的条件。用deg[u]==1判断叶子,在一般树上没问题,但如果树比较小,根也会被误判成叶子。而且原题根是n,n通常不靠前,你把根当叶子后,col下标可能越界。

所以我在代码里坚持用u <= m而不是度数判断。这也提醒我们,题面给的“叶子编号1..m”是重要条件,不是装饰。

4.4 单色信息写错了怎么查

单色判断是本代码的另一个命门。可以写一个临时函数,在DFS后输出每个节点的has0和has1,再手推一下几个子树。比如某个节点有两片叶子分别0和1,那么它的has0和has1应该都是true;如果只有一个true,说明有一个叶子没被统计到,很可能是DFS时漏了边,或者父节点传参写错导致走了回头路。

另外,叶子颜色数组如果用错了下标,比如把第i个叶子的颜色存到了节点i+m,也会导致has统计错。

4.5 根选错会怎样

这道题根选错很隐蔽。如果选1为根,样例给的数据可能答案不变,因为覆盖和最优化可能恰好对称。但只要构造一个根不在叶子中心的树,比如让两个不同色的叶子分别位于根的两侧,无论根选1还是n,答案可能都是2;但选的根变成某个叶子时,DP中的“子树单色”区域会变得不对称,导致答案突然变小或变大。保险起见,严格按题面从n开始DFS。

如果你不确定题目根是n,就把题面多读两遍。信息学奥赛的题目在“根”这个字上经常做文章,很多WA都是栽在这里。

5. 扩展:如果题目改一改,状态还成立吗

5.1 颜色种类增加

如果叶子颜色不只是0和1,而是k种,我们这套f[u]的定义依然成立,但单色判断就得维护整个颜色集合。可以用一个bool数组或者bitset记录子树中出现过哪些颜色;染u的合法条件是子树内所有叶子颜色相同,也就是颜色集合大小恰好为1。复杂度会多一个k的系数,k很大时还可以记录“出现过的前两种不同颜色”,因为判断是否单色只需要知道颜色种数是否大于等于2。

5.2 染不同颜色有不同代价

如果染0的代价是w0,染1的代价是w1,那么“染u代价为1”这个前提不成立,f[u]也无法压缩成不带颜色的单值。这时最好回到三状态DP:dp[u][0]表示u染0子树独立的最小代价,dp[u][1]表示u染1的最小代价,dp[u][2]表示u不染且子树独立的最小代价,转移里把w0和w1用上。

5.3 覆盖型树形DP的通用思路

这道题的核心方法可以推广:任何“某个节点染色后会影响整棵子树”的树形DP,都先问两个问题——染色节点的影响范围是什么?影响范围能不能被整体计算?如果影响范围是整棵子树,且子树内条件必须一致,那么“染这个节点=用一个节点替换掉整棵子树的需求”这种思路就会非常高效。做其他覆盖型题目时,不妨也先画伞,再写方程。

我刷这道题的时候,第一版写的也是三状态DP,样例过了,交上去却WA。后来我把每个染色节点画成伞,发现我状态里的“不染色”含义没弄清楚,把叶子算没了。回到一维状态重写,一遍就AC。回头想想,所谓“叶子的染色”其实不是叶子染不染的问题,而是谁能用最少的伞盖住所有叶子的需求。

最后分享一个我的小习惯:遇到树形DP,先别急着看数据范围,先在草稿纸上画一条从根到叶子的链,把染色节点放上去,逐个验证“路径上第一个有色节点”会怎样变化。这一步看似简单,却能过滤掉绝大多数状态设计错误。如果你也在P3155上卡住,不妨试试这个办法。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询