子树中标签相同的节点数 图解题解
这道题到底在问什么
- 输入
- n=7, 节点 0 与节点 2 都是标签 a
- 输出
- ans[0]=2(整棵树里和根同标签 a 的有 2 个)
- 输入
- 任意一个叶子节点
- 输出
- 1(它的子树只有它自己)
- 输入
- 四个节点连成链、标签全是 b
- 输出
- [4,3,2,1](越往上同标签越多)
最优解:为什么这么做
一句话答案:LeetCode 1519 子树中标签相同的节点数:把无向树建成邻接表后跑一次后序 DFS,用共享的 26 字母计数器进节点前减、出子树后加,差值就是子树内同标签数,时间 O(n)、空间 O(n)。
每个节点要数出子树里和自己同标签的有几个
给一棵 n 个节点的树,编号 0 到 n-1,根是 0,每个节点带一个小写字母标签,记在 labels 里。一个节点的子树,就是它自己加上底下所有后代。要对每个 i 算出它子树里和它同标签的节点有几个,i 自己也算。题面 n=7,edges=[[0,1],[0,2],[1,4],[1,5],[2,3],[2,6]],labels=abaedcd,答案 [2,1,1,1,1,1,1]——只有根 0 是 2,因为标签 a 的有节点 0 和 2。
对每个节点各数一遍子树,长链上会退化
对每个节点都把整棵子树从头数一遍,就得重走一遍它的后代。碰上从根拉到底的长链越靠根子树越大:根数 n 个、下一个数 n-1 个,加起来是平方级 O(n²),n 一大就走不动。省在孩子的子树早数过,父节点接过来直接用,不必重数。
先把无向边建成邻接表,再用后序把孩子收上来
题目给的边不分方向,得先建邻接表,也就是每个节点记一份自己连到谁的名单,每条边两个端点互相记成邻居。从根 0 往下走,走到谁谁就是孩子。因为两头都记了,递归要带上父节点、遇到就跳过,否则会在父子间来回无限走、撑爆栈。遍历用后序,也就是先把一个节点的孩子都处理完再处理自己——父节点合并时,孩子的统计已经摆好。
共享一个计数器,进节点前减、出子树后加
顺着这个思路,不给每个节点单独存统计,而是全程共用一个 26 格的计数器 cnt,记各字母眼下出现了几次。诀窍在进入节点 i、还没记数之前:先用 ans[i] 减去此刻 cnt 里它标签的值,把外面已有的同标签底数扣掉;再给自己标签加 1,递归所有孩子——孩子会把各自子树里的同标签也累进 cnt。等子树走完,让 ans[i] 加上此刻该标签的值。一减一加,净增的正好是 i 子树贡献的同标签数,i 自己也在其中。
拿题面这棵 7 节点的树,把差值法走通一遍
跟着差值法走一遍。进根 0(a),cnt[a] 从 0 到 1,ans[0] 进门减 0。进节点 1(b),孩子 4、5 是叶子,各自把 cnt[d]、cnt[c] 从 0 加到 1,ans[4]=ans[5]=1;回节点 1,ans[1]=1-0=1。
进节点 2(a),此刻 cnt[a] 已是 1、根记过,ans[2] 先减 1。孩子 3(e)给 ans[3]=1;孩子 6(d)进去前 cnt[d] 已是 1、节点 4 记的,出来变 2,ans[6]=2-1=1,差值只算 6 自己。出节点 2 时 cnt[a]=2,ans[2]=1。回根 0,ans[0]=2;注意 cnt 里 d 也是 2,但 ans[0] 只读 a,答案是 [2,1,1,1,1,1,1]。
为什么是 O(n),三种极端小树各长什么样
建邻接表把 n-1 条边各记两遍是 O(n),后序 DFS 每个节点只进出一次、计数器做常数次加减,也是 O(n),合起来时间 O(n)。空间上邻接表 O(n)、递归栈最深是树高即长链时 O(n),计数器不计,空间 O(n)。三种极端小树验一下:单节点答案是 1;一条链标签全同,每个答案等于它子树的大小;标签全不同则答案全是 1。
▶ 动画逐步走查(共 23 步)——想跟着动画一帧帧对照就展开
- 3记牢一句话: 每个节点向上交一份「子树里各字母出现几次」的统计, 父节点把孩子的统计合并再加上自己; 而 ans[i] 就是这份统计里节点 i 自己那个标签的次数。下面每一帧都在套这条规则。
- 4根 0, 每个节点带一个字母标签先看清这棵树。紫色的节点 0 是根。它的两个孩子是 1 和 2; 节点 1 下面挂着 4 和 5, 节点 2 下面挂着 3 和 6。右边「标签表」记着每个节点的字母: 0 号 a, 1 号 b, 2 号 a, 3 号 e, 4 号 d, 5 号 c, 6 号 d。我们要给七个节点各算一个 ans, ans[i] 是它子树里和它同标签的节点数。
- 5边是无向的, 从 0 当根定父子方向动手前先建邻接表。题目给的是无向边, 每条边的两个端点都要互相记成对方的邻居, 这样从任意点都能往四周走。然后把节点 0 当根, 从它出发往下走, 沿途遇到的节点就成了孩子。递归时带上父节点, 遇到父节点就跳过, 免得从孩子又走回父亲绕圈。方向定好, 这棵树就有了上下层级。
- 6节点向上交一份 26 字母统计把规则在画面上钉死。每个节点要向上交一份统计: 它的子树里, 26 个小写字母分别出现了几次。父节点把所有孩子的统计逐字母相加, 再给自己的标签加 1, 就得到自己的统计。而某个节点的答案, 就是它这份统计里、自己那个标签出现的次数。带着这条规则, 开始后序搜索。
- 7进入节点 0(标签 a), 结果待定后序深度优先搜索从根 0 开始。蓝色表示这个节点已经进入、正挂在搜索路径上, 但它的统计还没算出来, 因为得先把孩子那边都收完。节点 0 的标签是 a。先往它的第一个孩子 1 走下去。
- 8进入节点 1(标签 b), 继续往下走到节点 1, 它的标签是 b。现在还不能下结论, 因为它下面还挂着 4 和 5 两个孩子, 得先把孩子的统计收上来才能合并。继续往它的孩子里下探, 先去 4。
- 9节点 4(标签 d)是叶子走到节点 4, 标签是 d。它没有孩子, 是个叶子, 可以马上结算。叶子的子树就它自己一个节点, 统计也最简单。
- 10叶子 4 统计 {d×1}, ans[4]=1结算节点 4。它变绿, 代表这棵子树算完了。子树里只有它自己, 标签是 d, 所以统计是 d 出现 1 次。节点 4 自己的标签就是 d, 在统计里 d 出现 1 次, 所以 ans[4] 等于 1。节点 4 把这份 {d×1} 的统计交回给父亲 1。接着看 1 的另一个孩子 5。
- 11节点 5(标签 c)是叶子走到节点 5, 标签是 c, 它也是叶子, 同样可以直接结算。和刚才的 4 一样, 子树里只有它自己一个节点。
- 12叶子 5 统计 {c×1}, ans[5]=1结算节点 5。它变绿, 子树里只有自己, 标签是 c, 统计就是 c 出现 1 次。节点 5 自己的标签 c 在统计里出现 1 次, 所以 ans[5] 等于 1。现在节点 1 的两个孩子都算完了: 4 交回 {d×1}, 5 交回 {c×1}。回到节点 1, 该把它们合并了。
- 13合并孩子 {d×1} 与 {c×1}回到节点 1。把两个孩子的统计逐字母相加: 孩子 4 带回 d 出现 1 次, 孩子 5 带回 c 出现 1 次, 两个字母不重叠, 合并后是 c 一次、d 一次。还差最后一步, 把节点 1 自己的标签也加进去。
- 14加自己 b, ans[1]=b 的次数=1结算节点 1。它自己的标签是 b, 给统计里 b 加 1, 子树 1 的完整统计就是 b 一次、c 一次、d 一次。节点 1 变绿。它自己的标签 b 在这份统计里出现 1 次, 所以 ans[1] 等于 1。注意子树 1 里虽然有三个节点, 但标签和 b 相同的只有节点 1 自己。节点 1 把这份统计交还给根 0。回根 0, 去看另一个孩子 2。
- 15进入节点 2(标签 a), 先看孩子从根 0 走到另一个孩子 2, 它的标签是 a。它下面还挂着 3 和 6, 按后序还是要先把孩子看完, 才能合并出自己的统计。先去 3。
- 16节点 3(标签 e)是叶子走到节点 3, 标签是 e, 叶子, 直接结算。子树里只有它自己一个节点。
- 17叶子 3 统计 {e×1}, ans[3]=1结算节点 3。它变绿, 子树只有自己, 标签 e, 统计是 e 出现 1 次。自己的标签 e 出现 1 次, 所以 ans[3] 等于 1。节点 3 把 {e×1} 交回父亲 2。再看 2 的另一个孩子 6。
- 18节点 6(标签 d)是叶子走到节点 6, 标签是 d, 也是叶子。和前面几个叶子一样, 子树里只有它自己。
- 19叶子 6 统计 {d×1}, ans[6]=1结算节点 6。它变绿, 标签 d, 统计是 d 出现 1 次, ans[6] 等于 1。现在节点 2 的两个孩子都算完了: 3 交回 {e×1}, 6 交回 {d×1}。回到节点 2, 把它们合并。
- 20合并孩子 {e×1} 与 {d×1}回到节点 2。把孩子的统计逐字母相加: 孩子 3 带回 e 一次, 孩子 6 带回 d 一次, 合并后是 d 一次、e 一次。下一步把节点 2 自己的标签 a 加进去。
- 21加自己 a, ans[2]=a 的次数=1结算节点 2。它自己标签是 a, 给统计里 a 加 1, 子树 2 的完整统计是 a 一次、d 一次、e 一次。节点 2 变绿。它自己的标签 a 在统计里出现 1 次, 所以 ans[2] 等于 1。节点 2 把这份统计交还给根 0。现在根 0 的两个孩子都收齐了。
- 22合并子树1 与子树2 的统计回到根 0。把两支孩子的统计逐字母相加。子树 1 带回 b 一次、c 一次、d 一次; 子树 2 带回 a 一次、d 一次、e 一次。两份相加: a 一次, b 一次, c 一次, d 在两边各一次合成 2 次, e 一次。最后还要把根自己的标签 a 加进去。
- 23加自己 a, ans[0]=a 的次数=2结算根 0。它自己标签是 a, 给统计里 a 再加 1, a 变成 2 次。整棵树的统计是 a 两次、b 一次、c 一次、d 两次、e 一次。根 0 自己的标签是 a, 在统计里 a 出现 2 次, 所以 ans[0] 等于 2。这两个 a 来自节点 0 自己和节点 2。整棵树搜完, 答案出来了。
- 24ans = [2,1,1,1,1,1,1]回放一遍。整个过程只做了一次后序遍历: 从根下探到每个叶子, 再自底向上把每层的统计合并回来, 七个节点的 ans 一次就全算出来了。结果是 ans 等于 2, 1, 1, 1, 1, 1, 1。除了根节点是 2, 其它六个都是 1, 因为它们各自的子树里没有第二个同标签的节点。
- 25根统计 a×2 也有 d×2, ans 只看 a最后看一处最容易绕晕的地方。根 0 的统计里, 字母 a 出现 2 次, 字母 d 也出现 2 次(来自节点 4 和节点 6)。可 ans[0] 只等于 2, 是因为它只看根自己那个标签 a 的次数。d 出现几次和根没关系。这就是核心: 合并时统计全部 26 个字母, 但每个节点的答案只读出它自己那个标签的次数。
⚠️ 容易写错的地方
✗ 错:对每个节点单独把它的整棵子树从头数一遍
✓ 对:一次后序 DFS 自底向上, 父节点直接复用孩子算好的统计
逐个节点重新数子树最坏是 O(n²)(长链时越靠根子树越大), 复用孩子结果才降到 O(n)
✗ 错:无向边只建一个方向, 或递归时不挡住父节点
✓ 对:每条边两个方向都加进邻接表, 递归时带上父节点 fa 并跳过它
不挡父节点会在父子之间来回无限递归, 直接把栈撑爆
✗ 错:算 ans[i] 时只数后代, 忘了把节点 i 自己算上
✓ 对:节点 i 自己也在它的子树里, 统计要先给自己的标签加 1
i 和自己标签当然相同, 漏掉自己每个答案都会少 1, ans[i] 至少是 1
完整代码(Python / C++ / Java)
Python
from __future__ import annotations
from typing import *
from collections import *
from functools import *
from itertools import *
from math import *
from heapq import *
from bisect import *
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def countSubTrees(self, n: int, edges: List[List[int]], labels: str) -> List[int]:
def dfs(i, fa):
ans[i] -= cnt[labels[i]]
cnt[labels[i]] += 1
for j in g[i]:
if j != fa:
dfs(j, i)
ans[i] += cnt[labels[i]]
g = defaultdict(list)
for a, b in edges:
g[a].append(b)
g[b].append(a)
cnt = Counter()
ans = [0] * n
dfs(0, -1)
return ansC++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <optional>
#include <queue>
#include <set>
#include <sstream>
#include <stack>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode() : val(0), left(nullptr), right(nullptr) {}
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
};
struct ListNode {
int val;
ListNode *next;
ListNode() : val(0), next(nullptr) {}
ListNode(int x) : val(x), next(nullptr) {}
ListNode(int x, ListNode *next) : val(x), next(next) {}
};
class Solution {
public:
vector<int> countSubTrees(int n, vector<vector<int>>& edges, string labels) {
vector<vector<int>> g(n);
for (auto& e : edges) {
int a = e[0], b = e[1];
g[a].push_back(b);
g[b].push_back(a);
}
vector<int> ans(n);
int cnt[26]{};
function<void(int, int)> dfs = [&](int i, int fa) {
int k = labels[i] - 'a';
ans[i] -= cnt[k];
cnt[k]++;
for (int& j : g[i]) {
if (j != fa) {
dfs(j, i);
}
}
ans[i] += cnt[k];
};
dfs(0, -1);
return ans;
}
};Java
import java.util.*;
class TreeNode { int val; TreeNode left; TreeNode right; TreeNode(){} TreeNode(int val){this.val=val;} TreeNode(int val, TreeNode left, TreeNode right){this.val=val;this.left=left;this.right=right;} }
class ListNode { int val; ListNode next; ListNode(){} ListNode(int val){this.val=val;} ListNode(int val, ListNode next){this.val=val;this.next=next;} }
class Solution {
private List<Integer>[] g;
private String labels;
private int[] ans;
private int[] cnt;
public int[] countSubTrees(int n, int[][] edges, String labels) {
g = new List[n];
Arrays.setAll(g, k -> new ArrayList<>());
for (int[] e : edges) {
int a = e[0], b = e[1];
g[a].add(b);
g[b].add(a);
}
this.labels = labels;
ans = new int[n];
cnt = new int[26];
dfs(0, -1);
return ans;
}
private void dfs(int i, int fa) {
int k = labels.charAt(i) - 'a';
ans[i] -= cnt[k];
cnt[k]++;
for (int j : g[i]) {
if (j != fa) {
dfs(j, i);
}
}
ans[i] += cnt[k];
}
}复杂度
时间
O(n)
建邻接表把每条边记两遍是 O(n)(树有 n-1 条边); 后序 DFS 每个节点只访问一次, 参考代码在每个节点上对计数器做常数次加减, 是 O(1)。两步都是线性, 总体 O(n)
空间
O(n)
邻接表存 2(n-1) 个邻居是 O(n); 递归栈深度是树高, 最坏退化成长链时是 O(n); 共享计数器只有 26 个格子是 O(1)。按峰值取最大, 空间 O(n)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 子树中标签相同的节点数 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么用一个共享计数器的前后差值,就能只数这棵子树、不把上面的同标签也算进去?+
关键在进节点时先扣掉底数那一步。进入节点 i 之前,cnt 里它那个标签的值,记的是这条搜索路径上、i 上方已经出现过的同标签个数,这部分和 i 的子树无关,所以先从 ans[i] 里减掉。之后加自己、递归孩子,cnt 里该标签只会因为 i 的子树内部再多出来,等子树走完再加回 cnt 的现值,一减一加抵掉了外面的底数,剩下的净增量恰好是子树内部的同标签数。演算里节点 2 就是例子:进它之前 cnt[a] 已是 1,是根记的,最后 ans[2]=2-1=1,前面那个 a 被扣干净了。
n 能到十万,这样递归会不会爆栈?+
有风险。如果这棵树退化成一条长链,递归深度就等于节点数,可能栈溢出。工程上可以把递归改成用显式栈的迭代后序,或者先求出每个节点的父亲,再按自底向上的顺序把孩子的统计累加给父亲。这些写法时间仍是 O(n),只是不再依赖系统调用栈的深度。
输入本来就是一棵树,为什么还要先转成无向图、建邻接表?+
因为 edges 给的是不分方向的边,只告诉你哪两个节点相连、没说谁是父谁是子。要从根节点 0 往下定出父子关系,就得两个方向都记进邻接表,然后从 0 出发遍历,先到的当父、后到的当子,靠递归时跳过父节点来避免走回头路。如果只按 edges 里的顺序单向建边,很可能有的节点根本没有指向孩子的边,从根出发下探不到它,子树就漏了。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 子树中标签相同的节点数 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。