代表性面试主题

算法面试:如何序列化和反序列化二叉树?

编程题困难
OfferCC 编辑团队发布 更新

题干

为任意一棵节点值为 32 位有符号整数的二叉树实现 serialize(root) 和 deserialize(data)。还原后的树必须保持相同的值与结构,并说明正确性、复杂度、非法输入处理和递归深度边界。

题干与适用场景

为任意二叉树实现两个函数:

  • serialize(root):把树转换成字符串。
  • deserialize(data):还原出值和结构都相同的树。

假设节点值为 32 位有符号整数,树可以为空,序列化字符串只需要与本题的解码器互通。递归面试实现还假设 树高没有超过语言调用栈限制。下方解码器会拒绝格式错误的文本,不会在只解析出部分树时静默返回。

对于这棵树:

text
1
       / \
      2   3
         / \
        4   5

采用的格式是:

text
1,2,#,#,3,4,#,#,5,#,#

每个整数记录一个节点,# 记录一个缺失子节点,逗号分隔 token,前序顺序决定 token 的消费方式。2026 年 更新的公开面试材料仍使用前序和层序方案讲解这道题;interviewing.io 的面试回放也展示了 Meta 工程师 参与的模拟面试中出现树序列化题。这足以证明它是当前有代表性的编码练习,但不能扩张成所有公司都会考。

面试官考察点

第一个信号是候选人能否在选择遍历顺序前先定义“可逆”。只保存前序节点值并不够。根节点为 1、左孩子 为 2 的树,与根节点为 1、右孩子为 2 的树,都会产生 [1, 2]。加入空节点后,两者才不同:

text
左孩子: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 或显式栈。”

分步骤深入解答

第一步:用反例淘汰“只保存节点值”。

单独的前序、中序或后序节点值都不能唯一确定任意二叉树。允许重复值时,即便组合前序和中序也可能产生歧义。 格式必须同时保存值和结构;对面试字符串而言,为每个空孩子写一个哨兵是最直接的结构信号。

第二步:选择可以从左到右解码的语法。

格式可以递归描述为:

text
tree := "#"
      | integer "," tree "," tree

实际代码会先按逗号拆成 token,所以每次递归先消费一个 token;如果是整数,再消费两棵后续子树。带符号 整数文本不包含 , 或 #。空树是 #,值为 7 的叶子是 7,#,#。

这个语法还能导出一个检查不变量。含 n 个真实节点的二叉树有 n + 1 个空孩子指针,所以序列化会输出 n 个数值 token 和 n + 1 个空 token,总数为 2n + 1。但 token 数为奇数不代表输入一定合法, 解析仍要检查每个位置。

第三步:实现互相镜像的递归操作。

python
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 更合适。基础面试中,前序方案的语法和证明都更短。

第七步:验证往返结果和非法输入。

往返测试至少包括:

用例预期编码
空树#
单节点 77,#,#
根 1,左孩子 21,2,#,#,#
根 1,右孩子 21,#,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。

追问五:层序序列化能安全删除尾部空标记吗?

可以,前提是解码器明确把最后一个真实节点之后省略的位置都视为空,而且编码器只删除末尾连续的空标记。 内部空位不能删除,否则后续节点的孩子对齐会变化。删除后仍要重新验证往返契约和非法输入规则;字符串看起来 更短并不能证明两种格式等价。

公开来源

同类题目

相关面试工具

用 Screenshot 处理算法题

截图题目后,按顺序看约束、解法、代码、边界条件和复杂度。

查看工具