面试题32(二):分行从上到下打印二叉树
在层序队列上分别维护当前层待打印数与下一层已发现数,于计数归零时换行并完成下一层状态转移。
学习目标
- 能用层序队列 + 双计数器(toBePrinted/nextLevel)分行打印二叉树
- 能解释"动态队长 ≠ 当前层数量"的原因
- 能完成层结束时的换行与下一层状态转移
从“队列里混着两层,哪里该↡”开始
先预测:处理根8后,↡中是6、10;处理6并加入5、7后,队列变成10、5、7。此时队长是3,但当前层只剩10一个节点。若把动态队长当作当前层数量,5、7会被误打印到第二行。
“分行从上到下打印二叉树”仍使用↡,但必须额外识别层边界。作者没有在每层开始读取queue.size,而是维护两个独立↡:toBePrinted,以及nextLevel。层结束后换行是分行打印的关键:toBePrinted 归零即触发换行,并把 nextLevel 转移为新一层计数。
根入队时toBePrinted为1、nextLevel为0。每打印一个节点,前者减1;每发现一个非空孩子并入队,后者加1。当前层最后一个节点处理完时,前者恰为0,而后者恰好统计完整下一层。
层序入队
根节点入队,toBePrinted=1 表示当前层待打印数。
两个↡的状态转移
作者每轮先读取并打印队头,再依次把左、右孩子入队并增加nextLevel,随后pop当前节点并减少toBePrinted。只有在toBePrinted归零时才输出换行。
| 事件 | 计数更新 | 语义 |
|---|---|---|
| 出队并打印节点 | toBePrinted减1 | 消费当前层一个位置 |
| 左孩子非空入队 | nextLevel加1 | 登记下一层一个节点 |
| 右孩子非空入队 | nextLevel加1 | 维持同层左到右 |
| toBePrinted不为0 | 不换行 | 当前层尚有节点 |
| toBePrinted等于0 | 换行;赋值nextLevel;清零nextLevel | 层边界完成 |
执行时,必须按语义完成三件事:打印换行;把toBePrinted赋为nextLevel;将nextLevel清零。下一轮开始后,两个计数器重新分别代表当前层与尚未完整发现的下一层。
如果先清零nextLevel再赋值,下一层计数会丢失;如果只赋值不清零,后续孩子会累计以前层的数量;如果在孩子入队前判断toBePrinted,当前层最后一个节点的孩子尚未记入下一层。
忠实还原作者双计数实现
作者使用std::queue保存BinaryTreeNode指针,输出式接口对空树直接返回。左孩子先于右孩子入队,因此每行保持从左到右。
#include <cstdio>
#include <queue>
struct BinaryTreeNode {
int m_nValue;
BinaryTreeNode* m_pLeft;
BinaryTreeNode* m_pRight;
};
void Print(BinaryTreeNode* pRoot) {
if (pRoot == nullptr) {
return;
}
std::queue<BinaryTreeNode*> nodes;
nodes.push(pRoot);
int nextLevel = 0;
int toBePrinted = 1;
while (!nodes.empty()) {
BinaryTreeNode* pNode = nodes.front();
std::printf("%d ", pNode->m_nValue);
if (pNode->m_pLeft != nullptr) {
nodes.push(pNode->m_pLeft);
++nextLevel;
}
if (pNode->m_pRight != nullptr) {
nodes.push(pNode->m_pRight);
++nextLevel;
}
nodes.pop();
--toBePrinted;
if (toBePrinted == 0) {
std::printf("\n");
toBePrinted = nextLevel;
nextLevel = 0;
}
}
}源码用int计数。实际树节点数若可能超过int上限,应使用std::size_t;每次孩子入队后计数同步增加,不会出现负数。调试构建可以断言toBePrinted加nextLevel等于队列大小:队列中正好由当前层残余和下一层已发现节点两部分组成。
这条等式能精确定位同步错误:孩子已入队却忘记增加nextLevel时,队列大小比两计数之和大;空孩子被错误计数时,两计数之和又比队列大;节点pop后忘记减少toBePrinted,两边也立即相差1。只在换行时检查最终输出会把错误延迟到下一层,逐轮断言能把失败固定在第一次错误更新。生产构建可移除昂贵诊断,但测试版应在每次循环尾验证计数均非负、两数之和等于队长,并在队列非空时要求toBePrinted为正。
把pop放在计数递减前后不改变值,只要每轮恰处理一个节点;但孩子计数必须发生在层转移之前。作者先打印再pop,函数若I/O失败无法回滚已写内容,工程接口应让输出回调返回状态并立即停止。
↡是等价变体,不是作者源码
现代返回二维数组时,常用:缓存levelSize等于queue.size,然后固定循环levelSize次。因为这一时刻队列只含当前层;循环中新增孩子留给下一次外层循环。
#include <cstddef>
#include <queue>
#include <vector>
struct TreeNode {
int value;
TreeNode* left = nullptr;
TreeNode* right = nullptr;
};
std::vector<std::vector<int>> levels(const TreeNode* root) {
if (root == nullptr) return {};
std::vector<std::vector<int>> result;
std::queue<const TreeNode*> pending;
pending.push(root);
while (!pending.empty()) {
const std::size_t levelSize = pending.size();
std::vector<int> line;
line.reserve(levelSize);
for (std::size_t i = 0; i < levelSize; ++i) {
const TreeNode* node = pending.front();
pending.pop();
line.push_back(node->value);
if (node->left != nullptr) pending.push(node->left);
if (node->right != nullptr) pending.push(node->right);
}
result.push_back(std::move(line));
}
return result;
}快照版的levelSize等价于作者每层开始时的toBePrinted;循环期间pending中新增长度等价于nextLevel。前者以固定次数隐式统计下一层,后者在每个孩子入队时显式记账。
| 方案 | 层边界来源 | 适合输出 | 特点 |
|---|---|---|---|
| 作者双计数 | 逐节点递减当前层、累加下一层 | 流式printf | 精确体现状态转移 |
| 层首快照 | 每层开始缓存queue.size() | 二维结果更自然 | 固定循环次数 |
| 哨兵nullptr | 层尾加入空标记 | 不推荐默认使用 | 多一次特殊分支 |
| 两个队列 | current与next交换 | 边界直观 | 容器状态更多 |
哨兵法在每层末尾放nullptr,取到哨兵时换行并追加新哨兵。若忘记在队列只剩哨兵时停止,可能无限追加;同时真实节点类型与控制标记混在一起。两个队列current/next也正确,但单队列加计数更紧凑。
TypeScript队列与二维结果
数组加head索引避免shift搬移元素。当前层大小应在内层循环前冻结为queue.length减head,而不是把后者直接写进不断重新求值的循环条件:
type Node = {
value: number;
left: Node | null;
right: Node | null;
};
function levels(root: Node | null): number[][] {
if (root === null) return [];
const queue: Node[] = [root];
const result: number[][] = [];
let head = 0;
while (head < queue.length) {
const levelSize = queue.length - head;
const line: number[] = [];
for (let i = 0; i < levelSize; ++i) {
const node = queue[head++];
line.push(node.value);
if (node.left !== null) queue.push(node.left);
if (node.right !== null) queue.push(node.right);
}
result.push(line);
}
return result;
}若写成i小于queue.length减head,右侧表达式会随着head递增和孩子push同时变化,边界难以推断。固定levelSize既是正确性条件,也让每一行预分配、进度统计和取消检查更清楚。
作者六组测试的树形覆盖
Test1是三层完整树:8;第二行6、10;第三行5、7、9、11。它验证每行多个节点、不同父节点孩子的左右顺序和4个下一层节点计数。
Test2为全左链5、4、3、2;Test3为全右链5、4、3、2,每层各打印一个值。Test4单节点5,Test5空树无输出。Test6是不规则链:100的左孩子50,50的右孩子150,三行分别是100、50、150;它验证孩子方向变化不会影响层计数。
作者测试通过printf写预期和实际结果供人工比对,没有自动断言。Test6还遗漏DestroyTree调用,示例进程退出时由系统回收,但长期测试应释放树。章节复刻应保留算法行为,不应复制测试资源泄漏。
#include <cassert>
#include <vector>
void testLevels() {
TreeNode n5{5}, n7{7}, n9{9}, n11{11};
TreeNode n6{6, &n5, &n7};
TreeNode n10{10, &n9, &n11};
TreeNode n8{8, &n6, &n10};
assert(levels(&n8) ==
(std::vector<std::vector<int>>{
{8}, {6, 10}, {5, 7, 9, 11}}));
TreeNode n150{150};
TreeNode n50{50, nullptr, &n150};
TreeNode n100{100, &n50, nullptr};
assert(levels(&n100) ==
(std::vector<std::vector<int>>{
{100}, {50}, {150}}));
TreeNode only{5};
assert(levels(&only) ==
(std::vector<std::vector<int>>{{5}}));
assert(levels(nullptr).empty());
}全左链和全右链也应保留,防止只处理某一侧孩子。若值重复,应核对每行节点ID或地址,因为仅凭值无法发现同一节点被重复入队。
时间、空间与流式语义
每个节点入队、出队、打印一次,时间O(n)。辅助队列由层前沿控制,空间O(W),W为最大层宽。二维结果额外保存n个值和h个行容器,输出空间O(n+h),通常记作O(n)。
双计数只增加O(1)控制空间,不改变队列峰值。偏斜树队列峰值1但行数h等于n;完全树行数约为log n但最后一层很宽。行数与宽度是两个独立维度,不能用树高替代队列空间。
流式打印的优势是无需保留全部结果,并且层结束即可刷新一行。若下游有背压,回调需要允许暂停或异步等待;队列中的节点必须在暂停期间保持有效。返回二维数组则在遍历成功后一次性交付,易测试但占更多内存。
输出空树时作者什么也不写,连空行也不由Print产生;测试包装函数额外printf换行。API应区分“树内容输出”和“测试格式”,否则空树可能多一行或少一行。
树契约与正确性证明
算法假设有限无环二叉树且每个非根节点只有一个父节点。共享孩子会被重复计入nextLevel并打印两次,环会使队列永不为空。一般图需要visited集合;普通树版本不应静默去重坏结构。
是:队列前toBePrinted个节点是当前层尚未打印部分,随后nextLevel个节点是下一层已发现部分;两数之和等于队列大小。当前层节点按左到右排列,下一层也按其父节点出队顺序及左后右排列。
处理一个当前层节点后,队头删除使toBePrinted减1;每个非空孩子加入队尾并使nextLevel同增,不变量保持。toBePrinted归零时,队列只含完整下一层,赋值转移后它们全部成为新当前层,换行位置恰在两层之间。
有限树每个节点由唯一父节点入队一次,最终队列为空。由不变量,每个节点恰输出一次,每行包含且仅包含同一深度节点,并保持从左到右。
本章练习
练习
问题 1: 为什么不能用 queue.size() 当当前层数量?
问题 2: 两个计数器各自维护什么?
问题 3: 层结束时如何状态转移?
概念说明
本章核心概念包括:当前层剩余节点数,下一层节点数。理解这些概念是掌握解题方法的关键,需要结合具体示例与边界条件反复练习。
本章回顾
- 分行层序遍历需要在普通队列之外识别当前层终点。
- toBePrinted记录当前层剩余节点数,nextLevel记录下一层节点数。
- 出队后当前层减一,非空孩子入队后下一层加一。
- 当前层归零时换行、转移下一层计数并清零旧账本。
- 层首快照levelSize是等价变体,但不是作者源码实现。
- 动态queue.size混合两层,不能直接作为不断变化的循环边界。
- 时间O(n)、辅助空间O(W),二维输出另占O(n)。
- 作者六组测试覆盖完整、偏斜、单点、空树与不规则三层树。
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 层计数器
- 记录当前层剩余与下一层节点的两个独立变量。
- 状态转移
- 当前层处理完时,下一层计数转移为当前层的操作。
- 层首快照
- 记录每层开始时队列长度的等价实现变体。
- 层序遍历
- 按层从左到右的 BFS 遍历方式。
- 队列
- 先进先出结构,层序遍历的载体。
- 换行
- 当前层计数归零时输出换行的操作。