好叶子节点对的数量 图解题解
这道题到底在问什么
- 输入
- 叶子 10 和 40, 在节点 30 下面分叉
- 输出
- 路径 10-30-40 长度 2, 不超过 3, 是一组好对
- 输入
- 叶子 60 和 90, 在节点 70 下面分叉
- 输出
- 路径 60-70-80-90 长度 3, 不超过 3, 是一组好对
- 输入
- 叶子 10 和 60, 要绕到根 50 才会合
- 输出
- 路径长度 4, 超过 3, 不是好对
最优解:为什么这么做
一句话答案:LeetCode 1530 好叶子节点对的数量:一次后序遍历,每个节点上交一份到各叶子距离的清单,在节点处把左右两支按距离配对,两段距离够近就是一对好叶子,时间 O(n·d²)。
什么样的两片叶子算一组「好对」
叶子就是没有左右孩子的节点。给一棵二叉树和整数 distance,数有多少对叶子,两片间的最短路径长度不超过 distance——路径长度就是两叶子间的边数。用题面这棵树:根 50,左边挂 30、下面是叶子 10 和 40;右边挂 70,下面是叶子 60 和节点 80,80 再挂叶子 90。distance = 3。
两两枚举叶子对,规模一大就撑不住
两个叶子间的路径形状固定:先各自往上爬到最近公共祖先,也就是两片叶子最靠下的共同祖先,再分头下去,路径长度就等于两叶子到祖先的距离之和。照定义硬做,就得把叶子两两配对、各求一次祖先算长度;上千节点的树叶子成百上千,配对数平方级,撑不住。
每对好叶子只在它们的公共祖先处数一次
既然每对叶子都在公共祖先处才第一次分到左右两边,就反过来站在每个节点上只数「路径恰好在这里拐弯」的对——它必然一片来自左子树、一片来自右子树。为知道子树里每个叶子离自己多远,每个节点维护一个距离桶,也就是按到当前节点的距离分组的计数,cnt[d] 是到当前节点距离为 d 的叶子个数。用后序遍历,轮到某节点时左右孩子的桶都已算好。
每个节点先配对、再把距离加一上交
叶子到自己距离 0,桶就是 cnt[0]=1。非叶节点先递归拿到左桶 left、右桶 right。先配对:left[i] 是到左孩子距离 i 的叶子数,到本节点变 i+1,右支同理 j+1,经过本节点的路径长是 i+j+2;只要 i+j+2 ≤ distance,就把 left[i] 乘 right[j] 加进答案。
再把两支的桶各自距离加 1,cnt[d+1] = left[d] + right[d],交给父亲。这里两处顺序要盯紧:配对必须在合并前,否则合成一份就分不清叶子来自哪支、会错配;上交时每个距离别忘加 1,忘了父节点算出的路径偏短,把太远的对误判成好对。
题面这棵树,答案 2 是怎么数出来的
后序从根 50 下探到最左叶子。叶子 10 和 40 各返回「距离 0 处 1 个」。回到父亲 30,左右两支都是距离 0,经过 30 的路径长 0+0+2=2 ≤ 3,配成一对、答案记 1;合并后 30 的桶是「距离 1 处 2 个」。
右边叶子 60 返回「距离 0 处 1 个」;节点 80 只挂孩子 90、不配对,上交后桶是「距离 1 处 1 个」。到节点 70,左孩子那支距离 0、右孩子那支距离 1,路径长 0+1+2=3 ≤ 3,再配一对、答案累到 2。回到根 50,叶子到 50 是距离 2、2、3,任意一对最短也 4、超 3。答案 2:10 配 40,60 配 90。
为什么它几乎是线性的,还有哪些坑
桶最多 distance+1 项。每节点做一次 distance 乘 distance 的配对,全树 O(n·d²),d 就是 distance、不超过 10,近乎线性。空间 O(h·d),h 是树高、最坏成链时等于 n。边界看两种:单个节点没有第二片叶子、答案 0;两叶子在根下分叉、路径长 2,distance 给 2 算一对、给 1 不成,卡点是小于等于。
▶ 动画逐步走查(共 26 步)——想跟着动画一帧帧对照就展开
- 3记牢一句话: 每个节点向上交一份「子树里各叶子到我距离几、各有几个」的清单; 在每个节点, 把左支的距离和右支的距离相加, 只要和不超过 distance 就配成好对; 上交给父亲时清单里所有距离再加 1。下面每一帧都在套这条规则。
- 4根 50, 叶子是 10 40 60 90先看清这棵树。根是节点 50。它左边挂节点 30, 30 下面是两个叶子 10 和 40; 它右边挂节点 70, 70 下面一个是叶子 60, 另一个是节点 80, 80 再往下才是叶子 90。紫色点亮的四个就是叶子: 10、40、60、90。我们要数的是: 这四个叶子里, 有多少对彼此路径长度不超过 3。
- 5返回「子树里各叶子到我距离几、各几个」把规则在画面上钉死。每个节点要向上交一份清单, 记录它子树里每个叶子到这个节点的距离, 以及每种距离上各有几个叶子。叶子最简单, 它到自己距离是 0, 清单就是「距离 0 处 1 个叶子」。等这份清单交给父亲时, 因为又往上走了一条边, 清单里每个距离都要加 1。
- 6在每个节点配对左右两支的叶子再钉死第二条: 配对只在每个节点的左右两支之间发生。站在一个节点上, 把左孩子那一支某个距离的叶子, 和右孩子那一支某个距离的叶子相加, 如果两段距离之和不超过 distance, 它们就配成好对, 配对数等于两个距离上叶子个数相乘。为什么这样不重不漏? 因为任意两个叶子, 一定在它们最近的公共祖先那里第一次分到左右两支, 就在那个节点被数到一次。
- 7进入 50, 先把孩子算完走到节点 50, 蓝色表示它已经进入搜索路径, 但还没结算, 因为得先把孩子那边收完。它下面挂着 30 和 70, 按后序先一路往下探。
- 8进入 30, 先把孩子算完走到节点 30, 蓝色表示它已经进入搜索路径, 但还没结算, 因为得先把孩子那边收完。它下面挂着 10 和 40, 按后序先一路往下探。
- 9叶子 10, 准备结算往下走到叶子 10, 紫色表示正看着它。它没有左右孩子, 是个叶子, 可以马上结算。
- 10叶子 10 返回 距离0处 1 个结算叶子 10, 它变绿。它到自己的距离是 0, 所以这份清单是「距离 0 处 1 个叶子, 就是 10 自己」。它把这份清单交给父亲, 父亲收到后会把这个 0 加成 1。
- 11叶子 40, 准备结算往下走到叶子 40, 紫色表示正看着它。它没有左右孩子, 是个叶子, 可以马上结算。
- 12叶子 40 返回 距离0处 1 个结算叶子 40, 它变绿。它到自己的距离是 0, 所以这份清单是「距离 0 处 1 个叶子, 就是 40 自己」。它把这份清单交给父亲, 父亲收到后会把这个 0 加成 1。
- 13配成 1 对, ans 到 1回到节点 30, 它的左右两支清单都齐了。左支是 距离1处 1 个(叶子 10); 右支是 距离1处 1 个(叶子 40)。把左右距离两两相加看谁不超过 3: 距离1的 10 配 距离1的 40(1+1=2 ≤ 3)。绿色高亮的就是这一对好叶子。ans 加上 1, 现在是 1。
- 14节点 30 上交 距离1处 2 个(叶子 10、40)结算节点 30, 它变绿。把左右两支已经加过 1 的清单合并, 子树 30 的清单是 距离1处 2 个(叶子 10、40)。它再上交给父亲, 父亲会把每个距离继续加 1。
- 15进入 70, 先把孩子算完走到节点 70, 蓝色表示它已经进入搜索路径, 但还没结算, 因为得先把孩子那边收完。它下面挂着 60 和 80, 按后序先一路往下探。
- 16叶子 60, 准备结算往下走到叶子 60, 紫色表示正看着它。它没有左右孩子, 是个叶子, 可以马上结算。
- 17叶子 60 返回 距离0处 1 个结算叶子 60, 它变绿。它到自己的距离是 0, 所以这份清单是「距离 0 处 1 个叶子, 就是 60 自己」。它把这份清单交给父亲, 父亲收到后会把这个 0 加成 1。
- 18进入 80, 先把孩子算完走到节点 80, 蓝色表示它已经进入搜索路径, 但还没结算, 因为得先把孩子那边收完。它下面挂着 90, 按后序先一路往下探。
- 19叶子 90, 准备结算往下走到叶子 90, 紫色表示正看着它。它没有左右孩子, 是个叶子, 可以马上结算。
- 20叶子 90 返回 距离0处 1 个结算叶子 90, 它变绿。它到自己的距离是 0, 所以这份清单是「距离 0 处 1 个叶子, 就是 90 自己」。它把这份清单交给父亲, 父亲收到后会把这个 0 加成 1。
- 21单支, 不配对, 清单直接上交节点 80 只挂着一个孩子, 凑不出跨左右两支的叶子对, 所以这里不配对。它把这一支的叶子清单 距离1处 1 个(叶子 90) 原样往上交, 父亲收到再把距离加 1。
- 22节点 80 上交 距离1处 1 个(叶子 90)结算节点 80, 它变绿。把左右两支已经加过 1 的清单合并, 子树 80 的清单是 距离1处 1 个(叶子 90)。它再上交给父亲, 父亲会把每个距离继续加 1。
- 23配成 1 对, ans 到 2回到节点 70, 它的左右两支清单都齐了。左支是 距离1处 1 个(叶子 60); 右支是 距离2处 1 个(叶子 90)。把左右距离两两相加看谁不超过 3: 距离1的 60 配 距离2的 90(1+2=3 ≤ 3)。绿色高亮的就是这一对好叶子。ans 加上 1, 现在是 2。
- 24节点 70 上交 距离1处 1 个(叶子 60); 距离2处 1 个(叶子 90)结算节点 70, 它变绿。把左右两支已经加过 1 的清单合并, 子树 70 的清单是 距离1处 1 个(叶子 60); 距离2处 1 个(叶子 90)。它再上交给父亲, 父亲会把每个距离继续加 1。
- 25两支都太远, 配不成回到节点 50, 左支是 距离2处 2 个(叶子 10、40); 右支是 距离2处 1 个(叶子 60); 距离3处 1 个(叶子 90)。把左右距离两两相加: 2+2=4, 2+3=5。每一种组合的距离之和都超过 3, 所以在根这里一对都配不成, 标红的这几个叶子隔得太远。ans 保持 2。
- 26整棵树搜完, ans = 2结算根 50, 它变绿, 整棵树搜完了。把所有节点配出的好对加起来, ans 就是 2: 一组是节点 30 配出的 10 和 40, 一组是节点 70 配出的 60 和 90。这就是答案。
- 27ans = 2(10配40, 60配90)回放一遍。整个过程只做了一次后序遍历: 从根下探到每个叶子, 再自底向上, 在每个节点把左右两支的叶子按距离配对。节点 30 把距离都是 1 的叶子 10 和 40 配成一对, 路径长度 2; 节点 70 把距离 1 的叶子 60 和距离 2 的叶子 90 配成一对, 路径长度 3。根 50 想跨左右两支再配, 但都隔得太远。最终好叶子对是 2 组。
- 28左右都在距离 2 往上, 和必超 3看最容易绕晕的一处。叶子 10 和叶子 60 分属根的左右两支, 它们要会合必须爬到根 50。10 到根距离 2, 60 到根距离 2, 路径就是 2 加 2 等于 4, 已经超过 3。叶子 90 离根更远, 距离 3, 配谁都更超。所以一对叶子越是要绕到高处的祖先才会合, 路径就越长, 这也是为什么每对叶子只在它们最近的那个公共祖先处被数一次。
⚠️ 容易写错的地方
✗ 错:对每一对叶子都去找一次公共祖先再算路径
✓ 对:一次后序 DFS, 在每个节点就地配对左右两支的叶子
枚举所有叶子对再各自求路径是平方甚至更糟, 自底向上每对只在公共祖先处数一次才高效
✗ 错:把左右两支的距离清单合并以后再配对
✓ 对:必须先在合并前用左、右两份分别配对, 合并是配对之后的事
合并后就分不清谁来自左支谁来自右支, 会把同一支内部的两个叶子错配, 而它们的路径根本不经过当前节点
✗ 错:清单上交父亲时忘了把每个距离加 1
✓ 对:从孩子到父亲多走一条边, 清单里所有距离都要加 1
距离不加 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
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, val=0, left=None, right=None):
# self.val = val
# self.left = left
# self.right = right
class Solution:
def countPairs(self, root: TreeNode, distance: int) -> int:
self.ans = 0
# 返回 cnt: 长度 distance+1 的列表, cnt[d] = 子树里到当前节点距离为 d 的叶子个数
def dfs(node):
cnt = [0] * (distance + 1)
if node is None:
return cnt
if node.left is None and node.right is None:
cnt[0] = 1
return cnt
left = dfs(node.left)
right = dfs(node.right)
# 合并前先配对: 左支到左孩子距离 i → 到本节点 i+1; 右支同理 j+1
for i in range(distance + 1):
for j in range(distance + 1):
if i + j + 2 <= distance:
self.ans += left[i] * right[j]
# 合并: 左右两支距离各加 1, 上交给父亲
for d in range(distance):
cnt[d + 1] = left[d] + right[d]
return cnt
dfs(root)
return self.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) {}
};
/**
* Definition for a binary tree node.
* 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) {}
* };
*/
class Solution {
public:
int ans = 0;
int dist = 0;
int countPairs(TreeNode* root, int distance) {
dist = distance;
dfs(root);
return ans;
}
// 返回 cnt: 长度 dist+1, cnt[d] = 子树里到当前节点距离为 d 的叶子个数
vector<int> dfs(TreeNode* node) {
vector<int> cnt(dist + 1, 0);
if (!node) return cnt;
if (!node->left && !node->right) {
cnt[0] = 1;
return cnt;
}
vector<int> left = dfs(node->left);
vector<int> right = dfs(node->right);
// 合并前先配对: 左支到左孩子距离 i → 到本节点 i+1; 右支同理 j+1
for (int i = 0; i <= dist; ++i) {
for (int j = 0; j <= dist; ++j) {
if (i + j + 2 <= dist) {
ans += left[i] * right[j];
}
}
}
// 合并: 左右两支距离各加 1, 上交给父亲
for (int d = 0; d < dist; ++d) {
cnt[d + 1] = left[d] + right[d];
}
return cnt;
}
};Java
import java.util.*;
/**
* Definition for a binary tree node.
* public 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 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 {
int ans = 0;
int dist = 0;
public int countPairs(TreeNode root, int distance) {
dist = distance;
dfs(root);
return ans;
}
// 返回 cnt: 长度 dist+1, cnt[d] = 子树里到当前节点距离为 d 的叶子个数
int[] dfs(TreeNode node) {
int[] cnt = new int[dist + 1];
if (node == null) {
return cnt;
}
if (node.left == null && node.right == null) {
cnt[0] = 1;
return cnt;
}
int[] left = dfs(node.left);
int[] right = dfs(node.right);
// 合并前先配对: 左支到左孩子距离 i → 到本节点 i+1; 右支同理 j+1
for (int i = 0; i <= dist; ++i) {
for (int j = 0; j <= dist; ++j) {
if (i + j + 2 <= dist) {
ans += left[i] * right[j];
}
}
}
// 合并: 左右两支距离各加 1, 上交给父亲
for (int d = 0; d < dist; ++d) {
cnt[d + 1] = left[d] + right[d];
}
return cnt;
}
}复杂度
时间
O(n · d²)
d 是 distance, 这道题里不超过 10。每个叶子的距离都被限制在 1 到 d 之间, 每个节点维护的距离清单最多 d 项; 在每个节点做一次最多 d 乘 d 的配对双循环, 全树 n 个节点合起来是 O(n · d²)。因为 d 是不超过 10 的小常数, 实际接近线性
空间
O(h · d)
按峰值算: 后序递归的栈深是树高 h, 最坏退化成长链时 h 等于 n; 递归路径上每一层都挂着一份最多 d 项的距离清单, 所以同时存活的清单总量是 O(h · d)。距离清单本身每份是 O(d)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 好叶子节点对的数量 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么每个节点要返回完整的距离桶,只留最近那个叶子的距离行不行?+
不行。父节点配对时,子树里每一个叶子都可能和另一支的某个叶子凑成好对,所以要保留所有叶子各自到当前节点的距离分布,不能只留一个。好在距离被 distance 卡住,桶最多 distance 项,比它远的叶子再留着也配不成、可以丢掉,所以桶始终很短。
为什么配对一定要在左右桶合并之前做?+
因为配对数的是路径经过当前节点的对,这种对必然一个来自左子树、一个来自右子树。一旦把左右桶合成一份,就分不清每个叶子来自哪一支,可能把同属左支的两片叶子配在一起,可它们的路径根本不经过当前节点,会重复或数错。所以顺序固定:先用左右两份分别配对,再合并成一份上交。
节点数上千、distance 到十,这个做法会不会太慢?+
不会。每个节点的桶最多 distance 项,在每个节点做一次最多 distance 乘 distance 的配对,全树合起来是 O(n·d²)。distance 不超过 10,是个小常数,整体接近线性,一千多个节点很轻松。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 好叶子节点对的数量 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。