
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) { vectorstring tokens; split(data, tokens); int idx 0; return build(tokens, idx); } private: void split(const string data, vectorstring 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 vectorstring 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 的左子树。很多题解喜欢把空节点标记成nullC 里写#在字符串解析上更省心。节点值之间用逗号隔开是必须的不然像 12 和 1、2 这种多位数根本无法区分。2.2 反序列化最关键的一个细节索引必须跟着走反序列化的框架是先把字符串按逗号拆成 token 数组然后模拟一次先序遍历重建节点。每次从数组里取一个 token如果是#就返回空指针如果是数字就创建节点继续递归构造左孩子和右孩子。这里最容易写错的地方不是递归本身而是build函数里的索引idx怎么处理。我可以很确定地说我见过的初版错误代码十有八九是下面这种// 错误写法示例 TreeNode* build(vectorstring 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 nodePython 版本把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 #; queueTreeNode* 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) { vectorstring tokens; split(data, tokens); if (tokens.empty() || tokens[0] #) return nullptr; TreeNode* root new TreeNode(stoi(tokens[0])); queueTreeNode* 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, vectorstring 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 切出来再用stoi或int()转换。常见错误是有人想省分隔符或者想用空格做分割。遇到负数时字符串会变成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/4495.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 版本也练一遍这题就基本不会再给你带来麻烦了。