层序遍历(广度优先遍历)是二叉树最经典的遍历方式之一,它要求按从上到下、从左到右的顺序依次访问每个节点。传统的实现方式是用裸指针构建二叉树,再配合一个队列完成遍历。但在现代C++中,用std::unique_ptr管理节点已经成为更安全的选择,它能让内存自动释放,从根本上杜绝泄漏。不过智能指针与层序遍历结合时会带来一些新问题:拷贝被禁止怎么办?遍历过程中如何避免所有权转移?怎么才能优雅地按层打印?这篇文章把整个思路拆开讲透,并配上执行过程的图解说明。

用unique_ptr建造二叉树:先搞清楚所有权
用智能指针建树的第一步是定义节点结构。每个节点持有值、左孩子和右孩子,其中左右孩子用std::unique_ptr<Node>管理。这样写的好处是:当根节点被销毁时,整个树会沿着所有权链自动递归析构,不需要手写destroy函数,也不存在忘记delete导致的泄漏。
#include <iostream>
#include <memory>
#include <queue>
#include <vector>
struct Node {
int value;
std::unique_ptr<Node> left;
std::unique_ptr<Node> right;
explicit Node(int v) : value(v) {}
};这里有一个关键点必须理解:unique_ptr是不能拷贝的,只能移动。所以往树里插入子节点时,要用std::make_unique<Node>(v)创建并直接转移所有权,或者用std::move把已有的指针挂上去。如果你尝试写node->left = child;(其中child是一个unique_ptr左值),编译器会直接报错,这是所有权系统在保护你。
构造一棵测试用的树时,推荐写一个辅助函数,接受原始值按需创建节点:
std::unique_ptr<Node> buildSampleTree() {
auto root = std::make_unique<Node>(1);
root->left = std::make_unique<Node>(2);
root->right = std::make_unique<Node>(3);
root->left->left = std::make_unique<Node>(4);
root->left->right = std::make_unique<Node>(5);
root->right->left = std::make_unique<Node>(6);
root->right->right = std::make_unique<Node>(7);
return root; // 返回时触发移动语义,所有权交还调用者
}这棵树一共三层:第一层是1,第二层是2和3,第三层是4、5、6、7。层序遍历的正确输出应该是1 / 2 3 / 4 5 6 7,后面我们用这个结果验证代码。
图解层序遍历:队列的完整执行过程
层序遍历的核心工具是队列(先进先出)。算法流程很简单:先把根节点入队,然后循环执行“出队一个节点、访问它、把它的非空孩子依次入队”,直到队列为空。为了方便理解,下面用文字把队列的演化过程逐步还原。
初始状态队列为空,将节点1入队,队列内容为[1]。第一轮循环:节点1出队并打印,它的孩子2和3依次入队,队列变为[2, 3]。第二轮:节点2出队打印,孩子4和5入队,队列变为[3, 4, 5]。第三轮:节点3出队打印,孩子6和7入队,队列变为[4, 5, 6, 7]。之后四轮循环依次取出4、5、6、7打印,它们都没有孩子,队列逐渐清空。最终输出顺序正好是层序的1、2、3、4、5、6、7。
关键细节在于:队列里存什么?如果直接存unique_ptr,出队时就等于把节点从树上“摘下来”了,树会被遍历过程摧毁。正确做法是存裸指针Node*。裸指针在这里是安全的,因为它只是“观察者”,不参与所有权管理,队列的生命周期始终短于树的生命周期,不会出现悬空指针。
void levelOrderPrint(const std::unique_ptr<Node>& root) {
if (!root) return;
std::queue<const Node*> q;
q.push(root.get()); // get()取出裸指针,不转移所有权
while (!q.empty()) {
const Node* cur = q.front();
q.pop();
std::cout << cur->value << ' ';
if (cur->left) q.push(cur->left.get());
if (cur->right) q.push(cur->right.get());
}
std::cout << '\n';
}注意root.get()这个调用:它返回内部的裸指针而不交出所有权。参数用const std::unique_ptr<Node>&引用传递,避免任何拷贝企图,同时保证树在遍历期间完整无损。
逐层打印:两种标记层边界的方法
上面的代码输出是一整行数字,看不出层与层的分界。要实现“逐层打印”,需要知道每一层在哪里结束。方法一是层计数法:在每轮处理前记录当前队列的长度,这个长度就是当前层的节点数,处理完这么多个节点后换行。
void printByLevels(const std::unique_ptr<Node>& root) {
if (!root) return;
std::queue<const Node*> q;
q.push(root.get());
while (!q.empty()) {
size_t levelSize = q.size(); // 当前层的节点数量
for (size_t i = 0; i < levelSize; ++i) {
const Node* cur = q.front();
q.pop();
std::cout << cur->value << ' ';
if (cur->left) q.push(cur->left.get());
if (cur->right) q.push(cur->right.get());
}
std::cout << '\n'; // 一层结束,换行
}
}这个方法的执行逻辑用刚才那棵树推演:第一轮levelSize为1,打印1后队列里有2和3;第二轮levelSize为2,打印2 3后队列里是4 5 6 7;第三轮levelSize为4,打印完换行。输出正是期望的三行。它只需要一个计数变量,空间开销几乎为零,是最常用的写法。
方法二是哨兵法:在层与层之间插入一个nullptr作为分隔标记,遇到nullptr就换行。这种方法在旧代码里常见,但要多一次空指针判断,代码也略绕,两者时间复杂度都是O(n),实践中推荐方法一。
常见坑点与进阶优化
第一个坑是深树析构栈溢出。unique_ptr的自动析构是递归进行的,如果树非常深(比如退化成链表的十万层),析构时递归深度过大可能爆栈。解决方案是析构前手动迭代销毁:用循环不断把最左路径上的节点移出并删除,或者干脆用栈模拟后序遍历逐个reset。一般规模的树不必担心这个问题。
第二个坑是误用拷贝。比如有人想把队列声明成std::queue<std::unique_ptr<Node>>然后q.push(std::move(...)),这样每出队一个节点,那个子树就从原树脱落了,遍历到一半树就没了,函数结束后整棵树被销毁。除非你的目的就是“边遍历边销毁”,否则一律存裸指针。
第三个坑是函数返回树时忘记移动语义。C++17之后编译器会自动做返回值优化,直接return root;即可;但在C++11的某些场景下需要显式std::move。建议统一使用make_unique加直接返回的写法,让编译器处理剩余细节。
进阶一点,如果需要把每层结果存成vector<vector<int>>(比如提交算法题),只需把打印换成往二维数组里追加:
std::vector<std::vector<int>> levelOrderValues(
const std::unique_ptr<Node>& root) {
std::vector<std::vector<int>> result;
if (!root) return result;
std::queue<const Node*> q;
q.push(root.get());
while (!q.empty()) {
size_t levelSize = q.size();
std::vector<int> currentLevel;
currentLevel.reserve(levelSize);
for (size_t i = 0; i < levelSize; ++i) {
const Node* cur = q.front();
q.pop();
currentLevel.push_back(cur->value);
if (cur->left) q.push(cur->left.get());
if (cur->right) q.push(cur->right.get());
}
result.push_back(std::move(currentLevel));
}
return result;
}最后补一个完整的main函数方便验证:
int main() {
auto root = buildSampleTree();
printByLevels(root);
// 输出:
// 1
// 2 3
// 4 5 6 7
return 0; // root离开作用域,整棵树自动递归析构
}总结一下核心要点:建树阶段用unique_ptr确立清晰的所有权链;遍历阶段用get()取裸指针做非拥有式观察;逐层打印靠记录每层的队列长度来切分边界。掌握这三点,基于智能指针的二叉树层序遍历就再也难不倒你了。