1. 题目背景解析
"1004 Counting Leaves"这个题目名称看似简单,却蕴含着几个关键信息点。首先,"1004"这个数字编号暗示它可能来自某个编程题库或算法竞赛系列,比如浙江大学PAT(Programming Ability Test)甲级题目编号就是1001开始。其次,"Counting Leaves"直译为"数叶子",在计算机科学中这通常是指树形数据结构的相关问题。
在实际编程竞赛和算法面试中,这类题目考察的是对树结构的理解和遍历算法的应用。题目一般会给出一个家族树、组织架构树或其他形式的树结构,要求统计其中所有叶子节点(没有子节点的末端节点)的数量。这类问题看似简单,但能很好地检验程序员对递归、广度优先搜索(BFS)和深度优先搜索(DFS)等基础算法的掌握程度。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 问题建模与数据结构选择
2.1 输入格式分析
典型的"Counting Leaves"题目输入格式通常包含:
- 第一行给出节点总数N(N<100)和非叶子节点数M
- 随后M行,每行格式为:ID K ID[1] ID[2] ... ID[K]
- ID是两位数字表示的节点编号(01~N)
- K是该节点的子节点数量
- ID[1]~ID[K]是该节点的子节点编号
例如:
code复制5 3
01 2 02 03
02 2 04 05
03 0
表示共有5个节点,其中3个是非叶子节点。节点01有两个子节点02和03,节点02有两个子节点04和05,节点03没有子节点(即叶子节点)。
2.2 数据结构选择
处理树结构问题,常见的数据结构选择有:
-
邻接表:使用vector<vector
>或map<int, vector >存储每个节点的子节点列表 - 优点:直观反映树形结构,便于遍历
- 缺点:无法直接获取父节点信息
-
结构体/类表示节点:
cpp复制struct Node {
int id;
vector<Node*> children;
};
- 优点:面向对象,扩展性强
- 缺点:内存管理稍复杂
- 双亲表示法:用数组存储每个节点的父节点
- 优点:查找父节点快
- 缺点:查找子节点需要遍历整个数组
对于本题,邻接表是最合适的选择,因为:
- 题目明确给出了父子关系
- 只需要向下遍历查找叶子节点
- 实现简单,代码量少
3. 算法设计与实现
3.1 广度优先搜索(BFS)解法
BFS是解决层次遍历问题的经典算法,特别适合需要按层统计叶子节点的场景。实现步骤:
- 建立邻接表表示树结构
- 初始化队列,将根节点(通常为01)入队
- 初始化当前层叶子数计数器count=0
- 当队列不为空时:
a. 获取当前队列大小size(即当前层节点数)
b. 对size个节点进行出队操作:
i. 若该节点没有子节点,count++
ii. 否则将其所有子节点入队
c. 输出当前层count值
d. 重置count=0
关键代码片段(C++):
cpp复制vector<vector<int>> tree(N+1); // 邻接表
queue<int> q;
q.push(root);
while(!q.empty()) {
int size = q.size();
int count = 0;
for(int i=0; i<size; i++) {
int node = q.front(); q.pop();
if(tree[node].empty()) count++;
else for(int child : tree[node]) q.push(child);
}
cout << (q.empty() ? "" : " ") << count;
}
3.2 深度优先搜索(DFS)解法
DFS通过递归或栈实现,可以记录每个节点的深度,然后统计各深度的叶子节点数。实现步骤:
- 建立邻接表
- 初始化深度数组depth[N+1]和每层叶子数统计数组result
- 从根节点开始DFS:
a. 遍历当前节点的所有子节点
b. 对每个子节点,递归调用DFS
c. 如果没有子节点,则result[depth[current]]++ - 输出result数组
关键代码片段:
cpp复制vector<int> result;
void dfs(int node, int d) {
if(tree[node].empty()) {
if(d >= result.size()) result.resize(d+1);
result[d]++;
return;
}
for(int child : tree[node]) {
dfs(child, d+1);
}
}
3.3 两种算法的对比
| 特性 | BFS | DFS |
|---|---|---|
| 数据结构 | 队列 | 栈/递归 |
| 空间复杂度 | O(w), w为树的最大宽度 | O(h), h为树的高度 |
| 适用场景 | 需要按层处理时 | 需要深度信息时 |
| 代码复杂度 | 中等 | 简单 |
| 扩展性 | 容易修改为最短路径等问题 | 适合回溯类问题 |
对于本题,BFS通常是更直观的选择,因为它天然按层次遍历,方便统计每层的叶子节点数。而DFS需要额外维护深度信息,但代码可能更简洁。
4. 边界条件与测试用例
4.1 常见边界情况
-
单节点树:只有根节点,没有子节点
- 输入:1 0
- 输出:1
-
完全二叉树:所有非叶子节点都有两个子节点
- 输入:
code复制7 3 01 2 02 03 02 2 04 05 03 2 06 07 - 输出:0 0 4
- 输入:
-
链式树:每个节点只有一个子节点,形成一条链
- 输入:
code复制4 3 01 1 02 02 1 03 03 1 04 - 输出:0 0 0 1
- 输入:
-
非连续编号:节点编号不连续或不是从01开始
- 输入:
code复制5 2 03 2 05 07 05 1 09 - 输出:0 1 1
- 输入:
4.2 测试用例设计策略
-
常规测试:包含多层的非完全树
- 输入:
code复制9 4 01 3 02 03 04 02 2 05 06 03 0 04 1 07 - 预期输出:0 1 3 1
- 输入:
-
极端测试:
- 最大节点数测试(如N=99)
- 深度最大的树(如单分支,深度99)
- 广度最大的树(如根节点有98个子节点)
-
非法输入测试:
- 空输入
- 节点编号超出范围
- 子节点数量与列出的子节点数不匹配
5. 优化与扩展
5.1 性能优化技巧
-
输入优化:对于大规模数据,使用更快的输入方法
cpp复制ios::sync_with_stdio(false); cin.tie(0); -
内存预分配:提前分配足够的空间避免动态扩容
cpp复制vector<vector<int>> tree; tree.reserve(N+1); -
层级统计优化:在BFS中,可以在入队时记录节点层级
cpp复制queue<pair<int,int>> q; // <node, level> q.push({1,0});
5.2 问题变种与扩展
-
输出每层的非叶子节点数:修改统计逻辑即可
-
找出最密集的层级:即节点最多的那一层
-
家族树版本:给定家族关系,统计每代没有后代的人数
-
组织架构版本:统计公司各部门基层员工数量
-
带权叶子统计:每个叶子节点有权重,求每层权重和
6. 实际应用场景
"Counting Leaves"这类树遍历问题在实际开发中有广泛应用:
-
文件系统分析:统计各层级目录下的文件数量
- 文件夹=非叶子节点
- 文件=叶子节点
-
网站结构分析:分析网站各层级页面的末端页面
- 导航节点=非叶子节点
- 内容页=叶子节点
-
组织架构管理:统计公司各层级的基层员工
- 管理层=非叶子节点
- 普通员工=叶子节点
-
产品分类系统:电商平台商品分类末端统计
- 分类节点=非叶子节点
- 具体商品=叶子节点
-
DOM树分析:统计HTML文档各层级的文本节点
- 元素节点=非叶子节点
- 文本节点=叶子节点
7. 常见错误与调试技巧
7.1 常见实现错误
-
根节点假设错误:假设根节点总是01,实际应从输入确定
- 解决:检查是否有节点未被任何节点引用,即为根
-
层级计数错误:在BFS中混淆当前层与下一层节点
- 解决:使用内层循环处理当前层所有节点
-
输入处理错误:错误解析输入行,特别是ID为带前导零的数字
- 解决:统一用字符串读取或指定输入格式
-
初始化错误:未正确初始化统计数组或邻接表
- 解决:确认数组大小足够,通常N+1
-
输出格式错误:末尾多空格或少空格
- 解决:使用条件判断或调整输出逻辑
7.2 调试技巧
-
小数据测试:先用简单的手工计算验证的测试用例
-
打印中间结果:在遍历时打印当前节点和层级
-
可视化工具:用Graphviz等工具绘制输入的树结构
-
边界检查:特别测试N=1, N=100等边界情况
-
内存检查:确保没有数组越界或未初始化访问
8. 不同语言实现要点
8.1 Python实现特点
-
使用字典表示邻接表更灵活
python复制tree = defaultdict(list) -
队列可用collections.deque
python复制from collections import deque q = deque([root]) -
注意输入处理,特别是带前导零的数字
python复制node_id = f"{int(id_str):02d}"
8.2 Java实现特点
-
使用ArrayList数组表示邻接表
java复制List<Integer>[] tree = new ArrayList[N+1]; -
队列使用LinkedList实现
java复制Queue<Integer> queue = new LinkedList<>(); -
注意节点编号到数组索引的转换
8.3 Go实现特点
-
使用slice表示邻接表
go复制tree := make([][]int, N+1) -
队列可用slice模拟
go复制queue := []int{root} -
注意输入扫描和错误处理
9. 复杂度分析与优化证明
9.1 时间复杂度
-
BFS实现:
- 每个节点入队出队一次:O(N)
- 每条边被访问一次:O(N-1)
- 总时间复杂度:O(N)
-
DFS实现:
- 每个节点被访问一次:O(N)
- 每条边被递归一次:O(N-1)
- 总时间复杂度:O(N)
9.2 空间复杂度
- 邻接表存储:O(N)
- BFS队列:最坏O(N)(当树退化为链表)
- DFS递归栈:最坏O(N)(当树退化为链表)
- 辅助数组:O(N)
9.3 算法正确性证明
BFS算法的正确性基于:
- 队列保证节点按层级顺序处理
- size变量确保同一层节点被一起处理
- 空子节点列表是叶子节点的充要条件
数学归纳法:
- 基础:根节点层级0正确
- 归纳:假设第k层正确,则第k+1层通过第k层节点的子节点获得,必然正确
10. 学习路径与进阶方向
10.1 树结构学习路径
-
基础:
- 二叉树及其遍历(前序、中序、后序)
- 二叉搜索树
- 堆/优先队列
-
中级:
- AVL树/红黑树
- Trie树
- 并查集
-
高级:
- 线段树
- 树状数组
- B树/B+树
10.2 相关竞赛题目推荐
-
简单:
- 二叉树的最大深度
- 对称二叉树
- 路径总和
-
中等:
- 二叉树的锯齿形层次遍历
- 二叉树的最近公共祖先
- 序列化和反序列化二叉树
-
困难:
- 二叉树中的最大路径和
- 监控二叉树
- 恢复二叉搜索树
10.3 实际工程应用方向
- 数据库索引:B+树实现
- 文件系统:目录树管理
- 游戏开发:场景图管理
- 前端开发:虚拟DOM树
- 网络路由:前缀树应用
掌握树遍历算法是每个程序员的基本功,从简单的"Counting Leaves"出发,可以延伸到各种复杂的树结构应用场景。在实际编程中,理解问题本质并选择合适的数据结构和算法,比单纯记忆模板更重要。建议从本题出发,逐步探索更复杂的树结构问题,同时思考它们在实际系统中的应用方式。
