题干与适用场景
为任意二叉树实现两个函数:
serialize(root):把树转换成字符串。deserialize(data):还原出值和结构都相同的树。
假设节点值为 32 位有符号整数,树可以为空,序列化字符串只需要与本题的解码器互通。递归面试实现还假设 树高没有超过语言调用栈限制。下方解码器会拒绝格式错误的文本,不会在只解析出部分树时静默返回。
对于这棵树:
1
/ \
2 3
/ \
4 5采用的格式是:
1,2,#,#,3,4,#,#,5,#,#每个整数记录一个节点,# 记录一个缺失子节点,逗号分隔 token,前序顺序决定 token 的消费方式。2026 年 更新的公开面试材料仍使用前序和层序方案讲解这道题;interviewing.io 的面试回放也展示了 Meta 工程师 参与的模拟面试中出现树序列化题。这足以证明它是当前有代表性的编码练习,但不能扩张成所有公司都会考。
面试官考察点
第一个信号是候选人能否在选择遍历顺序前先定义“可逆”。只保存前序节点值并不够。根节点为 1、左孩子 为 2 的树,与根节点为 1、右孩子为 2 的树,都会产生 [1, 2]。加入空节点后,两者才不同:
左孩子:1,2,#,#,#
右孩子:1,#,2,#,#第二个信号是能否把编码器和解码器设计成逆操作。前序解码时先读一个 token:# 直接完成一棵空子树; 整数则创建节点,紧随其后的完整子树属于左孩子,再下一棵完整子树属于右孩子。格式不需要额外保存子树长度, 递归结构本身就给出了边界。
第三个信号是有没有真正的正确性说明。含 n 个节点的二叉树有 n + 1 个空孩子指针,所以编码恰好包含 2n + 1 个 token。更关键的不变量是:一次解码调用必须恰好消费一棵子树的 token,并把迭代器停在下一棵 子树的开头。这个性质可以用结构归纳证明。
最后还要看工程边界:非法输入、负数和重复值、递归深度、输出大小,以及何时应该改用 BFS 或正式序列化协议。
回答前需要澄清的问题
- 这是任意二叉树,还是二叉搜索树? 任意树需要结构标记;BST 在重复值策略明确时,可以只用前序和边界还原。
- 字符串是否必须遵循现有传输格式? 本题允许私有格式。跨服务存储还需要版本、兼容规则和正式 codec。
- 节点值会不会包含分隔符或哨兵? 本题只有整数,逗号和
#不会冲突;通用字符串需要转义或长度前缀。 - 树可以为空吗? 可以,编码为
#。 - 树会不会非常深,或由不可信输入构造? 递归解答假设树高受限;极深或对抗输入应使用显式栈和资源限制。
deserialize是否只接收serialize的可信输出? 代码仍严格拒绝空串、非法整数、截断树和尾随 token。- 目标是可读性还是最少字节? 前序文本便于讲解和测试;紧凑二进制协议会用不同的标签与整数编码。
30 秒回答框架
“我用前序遍历,并为每个缺失孩子输出 #。读到整数就创建节点,再递归解码左右子树;读到 # 就返回 空节点。空标记不能省,因为只保存节点值无法区分左孩子和右孩子。编码和解码结构完全镜像,用结构归纳可以 证明每次解码恰好消费一棵子树。两个操作都是 O(n) 时间,输出为 O(n),树高为 h 时调用栈为 O(h)。我还会拒绝截断或尾随输入;若深度无界,就改用迭代 BFS 或显式栈。”
分步骤深入解答
第一步:用反例淘汰“只保存节点值”。
单独的前序、中序或后序节点值都不能唯一确定任意二叉树。允许重复值时,即便组合前序和中序也可能产生歧义。 格式必须同时保存值和结构;对面试字符串而言,为每个空孩子写一个哨兵是最直接的结构信号。
第二步:选择可以从左到右解码的语法。
格式可以递归描述为:
tree := "#"
| integer "," tree "," tree实际代码会先按逗号拆成 token,所以每次递归先消费一个 token;如果是整数,再消费两棵后续子树。带符号 整数文本不包含 , 或 #。空树是 #,值为 7 的叶子是 7,#,#。
这个语法还能导出一个检查不变量。含 n 个真实节点的二叉树有 n + 1 个空孩子指针,所以序列化会输出 n 个数值 token 和 n + 1 个空 token,总数为 2n + 1。但 token 数为奇数不代表输入一定合法, 解析仍要检查每个位置。
第三步:实现互相镜像的递归操作。
from __future__ import annotations
from dataclasses import dataclass
MIN_INT32 = -(2**31)
MAX_INT32 = 2**31 - 1
@dataclass
class TreeNode:
val: int
left: TreeNode | None = None
right: TreeNode | None = None
class Codec:
NULL = "#"
SEP = ","
def serialize(self, root: TreeNode | None) -> str:
tokens: list[str] = []
def visit(node: TreeNode | None) -> None:
if node is None:
tokens.append(self.NULL)
return
if node.val < MIN_INT32 or node.val > MAX_INT32:
raise ValueError("node value is outside signed 32-bit range")
tokens.append(str(node.val))
visit(node.left)
visit(node.right)
visit(root)
return self.SEP.join(tokens)
def deserialize(self, data: str) -> TreeNode | None:
if data == "":
raise ValueError("serialization cannot be empty")
tokens = iter(data.split(self.SEP))
def build() -> TreeNode | None:
try:
token = next(tokens)
except StopIteration:
raise ValueError("serialization is truncated") from None
if token == self.NULL:
return None
try:
value = int(token)
except ValueError:
raise ValueError(f"invalid integer token: {token}") from None
if value < MIN_INT32 or value > MAX_INT32:
raise ValueError("node value is outside signed 32-bit range")
node = TreeNode(value)
node.left = build()
node.right = build()
return node
root = build()
try:
extra = next(tokens)
except StopIteration:
return root
raise ValueError(f"trailing token: {extra}")序列化先收集 token,避免递归中反复拼接长字符串。所有递归解码调用共享一个迭代器,子树不会每次从开头 重读。根节点完成后再检查剩余 token,可以拒绝 1,#,#,9,#,# 这类“前缀合法、后面仍有垃圾”的输入。
第四步:证明解码确实是编码的逆操作。
对树 T 做结构归纳:
- 基础情形:
T为空,编码输出#。解码器读到#后返回None,并恰好消费这棵空子树的一个 token。 - 归纳情形:假设左右子树都满足结论。编码先输出根值,再依次输出完整的左、右子树。解码器创建相同根节点;
第一次递归按归纳假设恰好消费左子树,第二次恰好消费右子树,于是值和结构完全还原,并停在 T 之后。
因此 deserialize(serialize(T)) 与 T 结构相同,而且每次调用都会把迭代器留在下一段未读子树。最后的 尾随 token 检查进一步确认根节点消费了全部输入。
第五步:计算成本,不隐藏输出和调用栈。
两个操作都会访问每个真实节点和空指针一次,所以时间是 O(n)。序列化结果和拆分后的 token 都是 O(n);新建的树本身也是 O(n)。递归调用栈是 O(h),其中 h 为树高:平衡树为 O(log n), 完全偏斜的树为 O(n)。
树高受限且优先代码清晰时,递归版很适合面试。若输入可以由攻击者提供,一条超过运行时递归限制的长链就会 耗尽调用栈。此时应使用显式栈或层序队列,并限制最大节点数、token 数、字节数和深度。
第六步:比较前序 DFS 与层序 BFS。
只要保留空节点信息,两者都能用 O(n) 时间和输出空间完成可逆编码。
| 格式 | 主要优点 | 主要代价 | |---|---|---| | 带空标记的前序 DFS | 编码和解码具有相同递归结构 | 递归版需要 O(h) 调用栈 | | 带空标记的层序 BFS | 迭代实现,接近常见数组树表示 | 队列可能保存 O(w) 个节点,稀疏树文本较长 | | 只保存值 | 字符串短 | 丢失任意二叉树结构 | | 带版本的标准二进制格式 | 可互通,并能紧凑保存类型字段 | 协议复杂度超出本题范围 |
递归深度是首要风险,或周边系统已经使用层序格式时,BFS 更合适。基础面试中,前序方案的语法和证明都更短。
第七步:验证往返结果和非法输入。
往返测试至少包括:
| 用例 | 预期编码 | |---|---| | 空树 | # | | 单节点 7 | 7,#,# | | 根 1,左孩子 2 | 1,2,#,#,# | | 根 1,右孩子 2 | 1,#,2,#,# | | 两个负数重复孩子 | 结构和两个重复值都保留 | | 32 位有符号整数两端 | 上下界都能解析并往返 |
还要拒绝 ""、1,#、x,#,#、2147483648,#,# 和 1,#,#,2,#,#。对随机生成的树,递归比较 原树和还原树,并断言 serialize(deserialize(serialize(root))) == serialize(root)。偏斜树测试应接近允许的高度边界, 让调用栈假设成为显式契约。
高质量示范回答
“我先确认这是任意二叉树,节点值是 32 位有符号整数,而且格式只需要由自己的解码器读取。由于允许重复值, 结构必须显式编码。
我采用前序遍历。真实节点先输出值,再输出左右子树;缺失孩子输出 #。这样即使前序节点值相同,也能区分 左孩子和右孩子。解码时共享一个 token 迭代器,读到 # 返回空;否则创建节点,再依次还原左右子树。
正确性可以做结构归纳。空树对应一个 #;对真实根节点,如果两次递归都能还原并恰好消费一棵孩子子树, 它们就会按编码顺序消费左右部分并还原原根节点。我还会拒绝输入提前结束、非法或越界整数和尾随 token。
每个真实节点和空指针只处理一次,所以两个操作都是 O(n)。文本和 token 使用 O(n) 空间,递归栈为 O(h)。若树高可能是对抗性的,我会改用显式栈或 BFS,并设置大小和深度限制。测试覆盖空树、单节点、 只有左孩子和只有右孩子、重复值、负数、整数边界、非法字符串及随机往返。”
常见错误
- 只序列化节点值 → 不同结构可能得到相同遍历 → 输出空标记或其他明确的结构边界。
- 分隔符可能出现在节点值中 → token 边界产生歧义 → 转义节点值、增加长度,或选择值语法之外的分隔符。
- 每次递归都创建新迭代器 → 每棵子树都从第一个 token 重读 → 共享一个持续前进的迭代器或索引。
- 解出根节点后忽略剩余文本 → 合法前缀会掩盖后续损坏数据 → 要求完整消费输入。
- 让缺失 token 抛出无关异常 → 截断数据难以定位 → 把提前耗尽转换成明确解析错误。
- 声称辅助空间始终为
O(log n)→ 偏斜树高度为n,拆分 token 也占线性空间 → 分别说明输出、token、树和调用栈。 - 未定义重复值就宣称 BST 前序不需要空标记 → 相同键可能产生歧义 → 先定义排序与重复值策略。
- 对不可信深度直接使用递归 → 长链会耗尽调用栈 → 改用显式栈并设置资源上限。
- 往返后比较对象身份 → 重建过程会创建新节点 → 比较节点值和结构。
- 只测试平衡树示例 → 左右歧义和栈风险不会暴露 → 加入空树、单侧树、重复值、极值、非法和偏斜用例。
追问及应对
追问一:二叉搜索树可以省略空标记吗?
通常可以。若 BST 采用严格大小关系且键唯一,可以在解码前序序列时携带上下界:落在当前范围内的值属于 当前子树,第一个越界值留给祖先。若允许重复值,契约必须说明相等值放左边、右边,还是在节点中计数;没有 这条规则,紧凑格式仍然有歧义。任意二叉树的基础题不能采用这个优化。
追问二:如果节点值是任意字符串,如何处理?
逗号和 # 都可能出现在字符串中,简单 split 不再能确定边界。可以使用 5:hello 这样的长度前缀, 并另外保留节点与空节点标签;也可以使用带 schema 的标准序列化库。转义方案也可行,但解码器要区分被转义 的分隔符与结构分隔符,并处理非法转义。长度前缀的消费规则通常更容易证明。
追问三:树有数百万节点或极端深度时怎么办?
避免递归调用;若峰值内存也重要,不要一次 split 完整输入。让迭代解析器流式读取 token,用显式栈记录尚未 填充的孩子位置;编码端写入流,而非先收集全部 token。分配无界状态前限制最大字节、token、节点、整数长度 和深度。时间仍为 O(n),内存取决于活动栈或队列以及输出缓冲策略。
追问四:生产系统如何给格式加版本?
在树负载外增加格式标识和版本,明确整数宽度与文本编码,并规定未知字段和未知版本是拒绝还是忽略。需要发现 损坏时可增加完整性校验,但校验和不能当作身份认证。发布过程要支持“读旧写新”,并为每个受支持版本保存 固定测试样本。跨语言服务通常应采用维护中的 schema 格式,而不是继续扩展这段面试 codec。
追问五:层序序列化能安全删除尾部空标记吗?
可以,前提是解码器明确把最后一个真实节点之后省略的位置都视为空,而且编码器只删除末尾连续的空标记。 内部空位不能删除,否则后续节点的孩子对齐会变化。删除后仍要重新验证往返契约和非法输入规则;字符串看起来 更短并不能证明两种格式等价。