二叉树序列化与反序列化:从先序DFS到层序BFS的完整解法与边界处理
2026/9/9 5:06:52 网站建设 项目流程

LeetCode Hot100 里有一类题,初看没什么,真到面试手写的时候特别容易翻车,297 题“二叉树的序列化与反序列化”就是典型。它的要求听起来很简单:把一棵二叉树变成字符串,再从这个字符串还原出完全一样的树。但往往越是这种题,越能考出对树遍历、递归终止条件、空节点处理的敏感度。

我第一次做这题时,序列化十几行就写完了,反序列化却卡了一个下午,始终在想“哪里该创建节点、哪里该返回 null”。后来刷了几遍,也在真实面试里被问过,才把套路理清楚。这篇内容就把我对这道题的理解完整拆开:先说它到底考察什么,再给出两套我能直接“默写”的实现方案,最后把空树、负数、叶子节点这些容易翻车的点一次说清。无论你是在刷 Hot100,还是准备面试,希望这篇能帮你省下我之前踩坑的时间。

1. 题目拆解:二叉树序列化到底需要满足什么条件

1.1 题目要求的本质是什么

题目会让你实现两个方法:serialize把一棵二叉树转成字符串,deserialize再把这个字符串还原成原树。LeetCode 对序列化格式没有唯一要求,不需要和前人的题解完全一致,只要最终整棵树能被恢复出来就可以。

很多第一次做这题的人会把注意力全放在“格式”上,比如纠结到底是用1,2,#,#,3还是[1,2,3,null,null]。其实格式不重要,重要的是两个隐藏前提:你必须能把空节点也表达出来,并且反序列化时能确定当前节点是左孩子还是右孩子。只要这两点满足了,随便你怎么编码都能通过。

这个概念放到工程里很好理解。你做缓存、RPC 传输、数据库存储时,经常需要把一个对象变成可传输的字节流,再在另一端恢复。二叉树序列化只是把这个问题限制在树结构上,反而比通用对象序列化更简单,因为它天然有递归结构。

1.2 不标记空节点,只靠一次遍历为什么不行

很多人第一反应是:我把树做一次先序遍历,把节点值连成字符串不就行了吗?比如一棵“根节点是 1,左孩子是 2”的树,先序遍历结果是1,2。可如果另一棵树是“根节点是 1,右孩子是 2”,先序遍历结果也是1,2

于是问题来了:反序列化时看到1,2,根本不知道 2 是 1 的左孩子还是右孩子。除非我在序列化时连空节点也一起写出来。树 A 可以写成1,2,#,#,#,树 B 写成1,#,2,#,#,这样两个不同的形状就区分开了。

这其实是整道题最核心的认知:序列化的关键不是“遍历”,而是“把结构信息补全”。你在遍历过程中遇到 null 就打印一个特殊的占位符,把这个节点的孩子位置占住,反序列化时才知道什么时候该收手返回。

1.3 为什么标记空节点后,任何一次遍历都可行

一旦给空节点留了位置,先序、中序、后序、层序理论上都能还原一棵唯一的树。原因很简单:你输出的每个节点,都知道它的完整子树边界在哪里。

以先序为例,序列化输出“根,左子树完整串,右子树完整串”。反序列化时读到数字就创建节点,接着继续读它的左子树,直到遇到连续的#才认为左子树走完,然后去还原右子树。这个顺序和序列化顺序完全一致,天然不会出现歧义。

所以刷这题时,不用纠结“哪种遍历最好”,反而应该先想清楚自己擅长哪种代码风格。我不建议为了炫技去写奇奇怪怪的编码,面试时最吃亏的不是方案不够高级,而是代码在极端用例下有逻辑漏洞。

2. 先序 DFS:代码最短,但反序列化的索引最容易写错

2.1 序列化只做一件事:把空孩子也写进结果

先用 C++ 给出一套我认为最容易理解、最不容易走神的写法。TreeNode 定义我就不写了,LeetCode 环境里有,主要是 Codec 类的两个方法:

class Codec { public: string serialize(TreeNode* root) { if (root == nullptr) return "#"; return to_string(root->val) + "," + serialize(root->left) + "," + serialize(root->right); } TreeNode* deserialize(string data) { vector<string> tokens; split(data, tokens); int idx = 0; return build(tokens, idx); } private: void split(const string& data, vector<string>& tokens) { string cur; for (char ch : data) { if (ch == ',') { tokens.push_back(cur); cur.clear(); } else { cur.push_back(ch); } } if (!cur.empty()) tokens.push_back(cur); } TreeNode* build(const vector<string>& tokens, int& idx) { if (idx >= (int)tokens.size()) return nullptr; const string& cur = tokens[idx++]; if (cur == "#") return nullptr; TreeNode* node = new TreeNode(stoi(cur)); node->left = build(tokens, idx); node->right = build(tokens, idx); return node; } };

序列化的逻辑非常直白:当前节点是空就返回#;非空则输出当前值,然后递归输出左子树,再递归输出右子树。比如一棵 root 为 1、左孩子为 2 的树,输出会是1,2,#,#,#。这里2,#,#表示节点 2 没有左右孩子,整体作为节点 1 的左子树。

很多题解喜欢把空节点标记成null,C++ 里写#在字符串解析上更省心。节点值之间用逗号隔开是必须的,不然像 12 和 1、2 这种多位数根本无法区分。

2.2 反序列化最关键的一个细节:索引必须跟着走

反序列化的框架是:先把字符串按逗号拆成 token 数组,然后模拟一次先序遍历重建节点。每次从数组里取一个 token,如果是#就返回空指针;如果是数字就创建节点,继续递归构造左孩子和右孩子。

这里最容易写错的地方不是递归本身,而是build函数里的索引idx怎么处理。我可以很确定地说,我见过的初版错误代码,十有八九是下面这种:

// 错误写法示例 TreeNode* build(vector<string>& tokens, int idx) { if (idx >= tokens.size()) return nullptr; string cur = tokens[idx++]; if (cur == "#") return nullptr; TreeNode* node = new TreeNode(stoi(cur)); node->left = build(tokens, idx); node->right = build(tokens, idx); return node; }

问题在于参数idx是按值传递的。递归左子树时,函数内部会把“自己的 idx 副本”往后挪,可一旦返回到当前层,原函数里的idx并没有变。于是右子树从旧的索引开始读取,读到的内容是错的,甚至可能把同一个值重复创建好几次。

正确做法是把idx改成引用传递,也就是int& idx,或者用一个成员变量记录当前消费位置。这一步做对后,整个递归读取就会变得非常顺滑:读一个数字,递归建左子树,再递归建右子树,索引按顺序一直往前走,永不走回头路。

2.3 递归版本更适合用 Python 来写

如果你平时刷题用 Python,反而会更明显。Python 没有指针概念,如果用局部变量去维护索引,同样会被函数作用域坑到。我一般用一个列表包住索引,或者直接把索引作为类属性用。下面是一个很实用的版本:

class Codec: def serialize(self, root): if root is None: return "#" return f"{root.val},{self.serialize(root.left)},{self.serialize(root.right)}" def deserialize(self, data): tokens = data.split(",") self.idx = 0 return self.build(tokens) def build(self, tokens): if self.idx >= len(tokens): return None val = tokens[self.idx] self.idx += 1 if val == "#": return None node = TreeNode(int(val)) node.left = self.build(tokens) node.right = self.build(tokens) return node

Python 版本把idx放在self上,每次反序列化前重置为 0,避免多次调用之间互相污染。这个细节很容易被忽略,我第一次写的时候把idx做成了类变量,结果连续反序列化两棵树时,第二次的起始位置变成了上一次结束的位置,排查了半天才发现。

2.4 复杂度别只说 O(N),递归深度也要聊

先序 DFS 方案的时间复杂度是 O(N),每个节点和每个空标记都只会访问一次。空间复杂度,理论上字符串就需要 O(N) 空间,递归过程最坏情况下也会占 O(N) 的调用栈。

很多人会忽略一个极端场景:当二叉树退化成一条链,比如每个节点都只有左孩子没有右孩子时,递归深度会达到 N。如果 N 足够大,比如几十万层,C++ 和 Python 都可能栈溢出。LeetCode 的测试数据一般不卡这个点,但面试官如果追问“你能不能用非递归实现”,本质上就是想知道你懂不懂这个隐患。

所以我的结论是:先序 DFS 是刷题和面试答题时最快的路径,代码也最好背;但如果要在生产环境处理深度极大、节点特别多的树,建议用层序 BFS 或者显式栈迭代,不要在递归深度上赌运气。

3. 层序 BFS:不依赖递归的第二种完整实现

3.1 序列化:队列里同时出现空节点与位置信息

层序方案的核心是“按层扩张”。用一个队列把节点一层一层往外扫,遇到空节点不递归、而是输出一个#,占住那个孩子位置。话不多说,先看 C++ 代码:

class Codec { public: string serialize(TreeNode* root) { if (root == nullptr) return "#"; queue<TreeNode*> q; q.push(root); string res; while (!q.empty()) { TreeNode* cur = q.front(); q.pop(); if (cur == nullptr) { res += "#,"; continue; } res += to_string(cur->val) + ","; q.push(cur->left); q.push(cur->right); } return res; } TreeNode* deserialize(string data) { vector<string> tokens; split(data, tokens); if (tokens.empty() || tokens[0] == "#") return nullptr; TreeNode* root = new TreeNode(stoi(tokens[0])); queue<TreeNode*> q; q.push(root); int idx = 1; while (!q.empty()) { TreeNode* cur = q.front(); q.pop(); if (idx >= (int)tokens.size()) break; if (tokens[idx] != "#") { cur->left = new TreeNode(stoi(tokens[idx])); q.push(cur->left); } idx++; if (idx >= (int)tokens.size()) break; if (tokens[idx] != "#") { cur->right = new TreeNode(stoi(tokens[idx])); q.push(cur->right); } idx++; } return root; } private: void split(const string& data, vector<string>& tokens) { string cur; for (char ch : data) { if (ch == ',') { tokens.push_back(cur); cur.clear(); } else { cur.push_back(ch); } } if (!cur.empty()) tokens.push_back(cur); } };

序列化时,我并没有只在遇到非空节点才 push 子节点,而是无论孩子是否为空,都 push 进队列。空节点被pop出来时,统一输出#,这样就把一棵二叉树“补”成了一个完全的结构序列。比如根节点 1 有左孩子 2、没有右孩子,序列化结果是1,2,#,#,#,和先序可能恰好一样,但本质思路完全不同。

3.2 反序列化:建好根节点后用队列补全孩子

BFS 反序列化的思路很像“搭积木”:先根据第一个 token 建好根节点,把根节点放入队列;然后每次从队列里取出一个已经创建好的节点,尝试从 token 流里读两个值,分别作为它的左孩子和右孩子。

这里要注意,读取到#时,不要创建节点,也不需要把它放进队列;只有读取到数字时,才new出节点并放入队列,等待后续处理它的孩子。队列的顺序天然保证了父节点被处理的顺序和序列化时的层序顺序一致。

我个人的体会是,BFS 反序列化比 DFS 更容易理解,因为它的状态推进是显式的:你有一个明确的idx在 token 数组里往后走,每处理一个父节点就消费两个 token。相比递归版本,反而少了很多“函数什么时候回到上一层”的心智负担。

3.3 先序 DFS 和层序 BFS,到底该怎么选

刷这道题的时候,我把两个方案都写了一遍,然后总结了一个非常实际的选择逻辑:

对比项先序 DFS层序 BFS
代码量更少,天然递归稍多,需要自己控制队列和索引
可读性依赖对递归的理解对初学者更直观
极端树形下的递归深度退化链表时可能栈溢出无递归深度问题
反序列化容易出错点索引没引用传递出队顺序和 token 顺序没对齐
工程化倾向适合小规模、代码简洁优先适合处理深层树、流式构建场景

如果是面试,我会先写先序 DFS,因为代码最短、最容易让对方看懂;如果面试官追问“会不会栈溢出”或“写个非递归版本”,再切换到 BFS 也来得及。如果是自己工作里要用,我更倾向于 BFS,因为不用赌调用栈,逻辑也更适合扩成“边读边建”的流式反序列化。

4. 边界与报错速查:负数、空树和“#”的细节

4.1 空树不是“什么都没有”,而是字符串 "#"

很多人在序列化时处理了 root 为空的情况,却在反序列化时忘了对称还原。如果你把空树序列化成空字符串"",那么反序列化第一步split之后会得到空数组,连tokens[0]都不敢访问。

正确做法是:空树统一序列化成#;反序列化先判断第一个 token 是否为#,是就直接返回nullptr。这个对称逻辑在两种方案里都必须有,否则 LeetCode 的空树用例就会挂。

// 反序列化第一行的标准写法 if (tokens.empty() || tokens[0] == "#") return nullptr;

4.2 负数和多位数:别把 '-' 和 ',' 搞混

题目给的节点值是 int,可能是负数,比如-3。这就意味着你不能写“只有遇到某个符号才算是数字”的解析逻辑,而应该老老实实按逗号把完整 token 切出来,再用stoiint()转换。

常见错误是有人想省分隔符,或者想用空格做分割。遇到负数时字符串会变成1 -3这种形式,一旦树形复杂或连续两个#,split 出来的 token 数量和预期对不上,报错会很诡异。因此我强烈建议节点之间统一用逗号,任何 token 内部都不要再出现逗号。用to_string序列化、用split按逗号切,是最不会出问题的组合。

4.3 递归里索引没推进是最隐蔽的错误

我最想强调的,是 DFS 反序列化里的索引问题。你在草稿纸上推演时,递归函数看起来一切正常,可实际运行就是只能还原左子树,右子树全空。原因通常就是索引没有同步更新。

除了按值传参的经典错误,还有一种情况是:你在创建节点前已经idx++了,但递归左右子树时还在用旧的idx。要记住,反序列化的过程就是“消费 token 流”的过程,每读一个 token,索引必须向后移动一次,且这个移动要对递归中的每一层可见。

Python 里如果不用self.idx,也可以用一个小技巧:把索引放进单元素列表,比如idx = [0],函数里通过idx[0] += 1来更新。这样虽然不优雅,但能很快解决问题。

4.4 高频问题和排查速查表

现象可能原因排查方向
反序列化出来只有根节点递归里的 idx 没有引用传递检查int idx是否应为int& idx
程序在stoi处崩溃#token 当成数字解析了先判断 token 是否为#,再做类型转换
负数丢失或解析错乱分隔符选择不当,-被当成特殊符号统一用逗号分隔,不写自定义分隔逻辑
空树反序列化报错没有处理tokens[0] == "#"增加空树保护
输出字符串末尾多了逗号序列化拼接时直接res += ","用当前代码每次都追加逗号没问题,但要保证 split 丢弃末尾空串
还原出来的树左右孩子反了反序列化读取孩子 token 顺序错误手动模拟一棵三层树,检查出队和 token 消费顺序

5. 这类题的底层联想:从 297 到 105/106/449

5.1 “一次遍历+空标记”和“两次遍历还原”是两种思路

很多人刷完 297,会把它和 105、106 题混淆。其实这两类题解决的是同一个问题,只是约束不同。297 是“一次遍历,但允许用空标记补位”,而 105、106 是“不允许额外标记,但给你两次不同顺序的遍历”:比如前序遍历+中序遍历,或后序遍历+中序遍历。

原理也不难理解。单独的中序遍历无法确定根节点是整个序列里的哪个位置;一旦有了前序或后序,就能知道根是谁,再用中序把左右子树切分出来。但需要注意,编码复杂度和理解成本都比 297 高,不是这道题的正解。

面试如果追问“为什么空标记可行”,你可以先举例说明两棵树没有空标记时会产生相同先序串,然后补一句“空标记本质上等价于把二叉树补成了每个节点都有两个孩子的扩展二叉树,所以遍历顺序能唯一确定结构”。这样说会显得你真的理解原理,而不是只会背模板。

5.2 括号表示法和其它序列化格式只是风格差异

除了先序和层序,还有一种常见的写法是括号表示法,格式类似1(2)(3(4)(5))。这种格式和先序 DFS 本质上是同一套思想:节点值后面紧跟左右子树表达式,空子树用一对空括号()表示。

写代码的时候,你还需要处理括号配对和解析下标的逻辑,比逗号分隔的 token 流要复杂一些。除非题目有明确要求,我在面试时不会主动选用括号表示法,因为它虽看起来简洁,但反序列化要手动解析嵌套层级,很容易在括号计数上出错。

5.3 相关的 Hot100 题目和衍生题怎么串

297 是整个二叉树序列化体系里的基础题。刷完它以后,我建议顺势做几道关联题,有助于把知识连成网:

  • 105:从前序与中序遍历序列构造二叉树,体会“两次遍历如何定位根和左右子树”。
  • 106:从后序与中序遍历序列构造二叉树,代码思路和 105 完全对应。
  • 449:序列化和反序列化二叉搜索树。因为 BST 有“左小右大”的性质,序列化时不一定要记录空节点,只用一个先序序列,反序列化时靠大小关系恢复树。
  • 144/145/94:树的三种深度优先遍历。297 的反序列化本质上就是一次带着索引的遍历重建。

如果你把这几题放在一起刷,会发现二叉树题目的套路高度集中:核心就是“你需要哪些信息才能唯一定位一棵树”以及“如何按某种顺序消费这些信息”。

5.4 面试时怎么把这题答得比背题模板高一个层次

面试遇到这道题,我会建议按四步走:

第一步,先说明“序列化格式没有唯一解,我选择先序 DFS + 空节点标记”。这不只是在交代方案,也是在向面试官展示你能在约束下做设计决策。

第二步,花二十秒解释为什么需要空节点标记。不需要讲得很深,举一个根节点只有左孩子和只有右孩子会产生相同1,2的例子就够了。

第三步,直接写序列化,再写反序列化。写反序列化前先强调“我的索引是引用传递,确保 token 在递归中被逐个消费”,这句话能提前打消面试官对常见 bug 的疑虑。

第四步,主动补复杂度。O(N) 时间、O(N) 空间,并且提一句“如果担心链状树递归过深,可以改用层序 BFS 方案”。这一句话往往会让面试官觉得你考虑过工程边界,而不仅仅是在刷题。

我个人在实际操作中的体会是,这题并不需要去背各种花式编码,把先序 DFS 和理解“空标记为什么能补全位置”吃透,比背十种写法都管用。等你能在三分钟内把代码完整默写出来,再把 BFS 版本也练一遍,这题就基本不会再给你带来麻烦了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询