二叉树的序列化,说白了就是把一棵树压缩成一个字符串,方便存到磁盘里或者在网络间传输;反序列化则是逆向操作,把字符串重新还原成一棵结构完全相同的树。听起来简单,但真正动手写的时候,不少人会在空节点如何表示、指针如何回退这些问题上栽跟头。本文用C++配合深度优先遍历的思路,把整个实现过程掰开揉碎讲清楚。

一、为什么序列化必须处理空节点
先看一个核心问题:为什么不能直接把节点值按先序遍历输出就完事?原因很简单,只输出值无法还原树的结构。举个例子,先序遍历序列是 1 2 3,它可能对应一棵根为1、左孩子为2、右孩子为3的树,也可能对应一棵一直向左倾斜的链状树,仅凭值序列无法区分。
解决思路是在遍历时把空指针也记录下来。习惯上用一个特殊符号表示,比如 # 或者 null。这样序列 1 2 # # 3 # # 就唯一确定了一棵只有两个节点的树:根为1,左孩子为2,右孩子为3,其中2的两个孩子都是空。
另一个细节是分隔符。如果节点值可以是多位数或者负数,直接拼接 12 和 3 会产生歧义(123 到底是一个节点还是两个?),所以每个值后面要跟一个逗号或空格。这两点想清楚了,整个算法的骨架就有了。
二、基于先序遍历的递归实现
先定义节点结构,这里用最常见的 struct TreeNode:
#include <string>
#include <sstream>
using namespace std;
struct TreeNode {
int val;
TreeNode* left;
TreeNode* right;
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};序列化函数的写法非常直观:访问当前节点就追加值,遇到空指针就追加 #,然后递归处理左右子树。用 ostringstream 拼字符串比反复调用 string::operator+= 效率更稳定,代码也更清爽。
class Codec {
public:
// 序列化:先序遍历,空节点记为 #
string serialize(TreeNode* root) {
ostringstream out;
serializeHelper(root, out);
return out.str();
}
void serializeHelper(TreeNode* node, ostringstream& out) {
if (node == nullptr) {
out << "# ";
return;
}
out << node->val << " ";
serializeHelper(node->left, out);
serializeHelper(node->right, out);
}
};反序列化是本文的重点,也是最容易被写乱的部分。技巧是借助 istringstream 逐个读取令牌,并用一个辅助函数递归构造:读到一个 # 就返回空指针,否则新建节点,接着先递归构造左子树,再构造右子树。因为读取位置由流对象内部维护,不需要手动传递下标,代码可读性很好。
class Codec {
public:
// 反序列化:从流中逐个取令牌重建树
TreeNode* deserialize(string data) {
istringstream in(data);
return deserializeHelper(in);
}
TreeNode* deserializeHelper(istringstream& in) {
string token;
if (!(in >> token) || token == "#") {
return nullptr;
}
TreeNode* node = new TreeNode(stoi(token));
node->left = deserializeHelper(in);
node->right = deserializeHelper(in);
return node;
}
};注意递归顺序必须和序列化顺序严格一致:序列化是根、左、右,反序列化也必须先建根、再建左子树、再建右子树。任何一处顺序不匹配,还原出的树结构就是错的。
三、常见坑与进阶优化
第一个坑是递归深度。如果树退化成一条长链,节点数达到百万级别时递归可能栈溢出。生产环境中可以考虑显式用栈把递归改写成迭代版,或者改用后面提到的层序遍历方案。
第二个坑是字符冲突。如果节点值本身是字符串类型且可能包含 #,占位符就会失效。稳妥的做法是引入长度前缀(类似网络协议中的TLV格式),或者换用不太可能出现的转义序列。面试中一般说明清楚约定即可。
第三个坑是内存管理。上面代码中 new 出来的节点需要手动释放,实际项目里建议用 std::unique_ptr<TreeNode> 管理所有权,或者自底向上析构,避免重复 delete 导致未定义行为。
最后简单对比一下层序遍历方案:它借助队列按广度优先展开,序列更接近树的直观形状,天然适合迭代实现,不存在栈溢出风险;缺点是空节点占比高时序列冗长,且代码稍复杂。对于绝大多数面试和中小规模数据场景,先序递归方案胜在简洁清晰,是首选答案。
完整跑一遍验证:对树 1 -> (2, 3) 做序列化得到 1 2 # # 3 # #,再反序列化后先序遍历输出 1 2 3,结构与原树完全一致。掌握这套思路后,N叉树、图等更复杂结构的序列化也只需在空节点标记和遍历顺序上稍作调整即可迁移。