具代表性的面試主題

演算法面試:如何序列化與反序列化二元樹?

程式題困難
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 工程師 參與的模擬面試中出現樹序列化題。這足以支持它是目前具代表性的 coding 練習,但不能延伸成所有公司都會問。

面試官考察重點

第一個訊號是候選人能否在選擇走訪順序前,先定義「可逆」。只保存前序節點值並不夠。根節點為 1、 左子節點為 2 的樹,與根節點為 1、右子節點為 2 的樹,都會產生 [1, 2]。加入空節點後才會不同:

text
左子節點:1,2,#,#,#
右子節點:1,#,2,#,#

第二個訊號是能否把編碼器與解碼器設計成逆操作。前序解碼先讀一個 token:# 直接完成一棵空子樹; 整數則建立節點,緊接著的完整子樹屬於左子節點,再下一棵完整子樹屬於右子節點。格式不必另外保存子樹長度, 遞迴結構本身就給出邊界。

第三個訊號是有沒有真正的正確性說明。含 n 個節點的二元樹有 n + 1 個空子節點指標,所以編碼剛好 包含 2n + 1 個 token。更重要的不變量是:一次解碼呼叫必須剛好消耗一棵子樹的 token,並讓 iterator 停在下一棵子樹的開頭。這項性質可以用結構歸納證明。

最後還要看工程邊界:格式錯誤、負數與重複值、遞迴深度、輸出大小,以及何時該改用 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,避免在遞迴中反覆串接長字串。所有遞迴解碼呼叫共享同一個 iterator,子樹不會每次從 開頭重讀。根節點完成後再檢查剩餘 token,可以拒絕 1,#,#,9,#,# 這種「前綴合法、後面仍有垃圾」的輸入。

第四步:證明解碼確實是編碼的逆操作。

對樹 T 做結構歸納:

  • 基礎情況:T 為空,編碼輸出 #。解碼器讀到 # 後回傳 None,並剛好消耗這棵空子樹的一個 token。
  • 歸納情況:假設左右子樹都滿足結論。編碼先輸出根值,再依序輸出完整的左、右子樹。解碼器建立相同根節點;

第一次遞迴依歸納假設剛好消耗左子樹,第二次剛好消耗右子樹,因此值與結構完全還原,並停在 T 之後。

所以 deserialize(serialize(T)) 與 T 結構相同,而且每次呼叫都會讓 iterator 留在下一段未讀子樹。 最後的尾隨 token 檢查進一步確認根節點消耗了全部輸入。

第五步:計算成本,不隱藏輸出與呼叫堆疊。

兩個操作都會拜訪每個真實節點與空指標一次,因此時間是 O(n)。序列化結果與拆開後的 token 都是 O(n);新建的樹本身也是 O(n)。遞迴呼叫堆疊為 O(h),其中 h 是樹高:平衡樹為 O(log n), 完全偏斜的樹為 O(n)。

樹高受限且重視程式清楚時,遞迴版很適合面試。若輸入可由攻擊者提供,一條超過執行環境遞迴限制的長鏈就會 耗盡呼叫堆疊。此時應使用明確堆疊或層序 queue,並限制最大節點數、token 數、位元組數與深度。

第六步:比較前序 DFS 與層序 BFS。

只要保留空節點資訊,兩者都能以 O(n) 時間與輸出空間完成可逆編碼。

格式主要優點主要代價
帶空標記的前序 DFS編碼與解碼具有相同遞迴結構遞迴版需要 O(h) 呼叫堆疊
帶空標記的層序 BFS迭代實作,接近常見陣列樹表示queue 可能保存 O(w) 個節點,稀疏樹文字較長
只保存值字串短遺失任意二元樹結構
帶版本的標準二進位格式可互通,並能精簡保存型別欄位協定複雜度超出本題範圍

遞迴深度是首要風險,或周邊系統已經使用層序格式時,BFS 較合適。基礎面試中,前序方案的語法與證明更短。

第七步:驗證往返結果與格式錯誤。

往返測試至少包括:

案例預期編碼
空樹#
單一節點 77,#,#
根 1,左子節點 21,2,#,#,#
根 1,右子節點 21,#,2,#,#
兩個負數重複子節點結構與兩個重複值都保留
32 位元有號整數兩端上下界都能解析並往返

也要拒絕 ""、1,#、x,#,#、2147483648,#,# 與 1,#,#,2,#,#。對隨機產生的樹,遞迴 比較原樹與還原樹,並斷言 serialize(deserialize(serialize(root))) == serialize(root)。偏斜樹測試應接近允許的高度邊界, 讓呼叫堆疊假設成為明確契約。

高品質示範回答

「我先確認這是任意二元樹,節點值是 32 位元有號整數,而且格式只需由自己的解碼器讀取。因為允許重複值, 結構必須明確編碼。

我採用前序走訪。真實節點先輸出值,再輸出左右子樹;缺少的子節點輸出 #。如此即使前序節點值相同, 也能區分左子節點與右子節點。解碼時共享一個 token iterator,讀到 # 回傳空;否則建立節點,再依序 還原左右子樹。

正確性可用結構歸納。空樹對應一個 #;對真實根節點,若兩次遞迴都能還原並剛好消耗一棵子樹,它們就會 依編碼順序消耗左右部分並還原原本根節點。我也會拒絕輸入提早結束、非法或越界整數及尾隨 token。

每個真實節點與空指標只處理一次,因此兩個操作都是 O(n)。文字與 token 使用 O(n) 空間,遞迴堆疊 為 O(h)。若樹高可能是對抗性的,我會改用明確堆疊或 BFS,並設定大小與深度限制。測試涵蓋空樹、單一 節點、只有左子節點和只有右子節點、重複值、負數、整數邊界、非法字串及隨機往返。」

常見錯誤

  • 只序列化節點值 → 不同結構可能得到相同走訪 → 輸出空標記或其他明確的結構邊界。
  • 分隔符號可能出現在節點值中 → token 邊界產生歧義 → 跳脫節點值、增加長度,或選擇值語法外的分隔符號。
  • 每次遞迴都建立新 iterator → 每棵子樹都從第一個 token 重讀 → 共享一個持續前進的 iterator 或索引。
  • 解出根節點後忽略剩餘文字 → 合法前綴會掩蓋後續損壞資料 → 要求完整消耗輸入。
  • 讓缺少 token 丟出無關例外 → 截斷資料難以定位 → 把提早耗盡轉成明確解析錯誤。
  • 宣稱輔助空間永遠為 O(log n) → 偏斜樹高度為 n,拆開 token 也占線性空間 → 分別說明輸出、token、樹與呼叫堆疊。
  • 未定義重複值便宣稱 BST 前序不需要空標記 → 相同鍵可能產生歧義 → 先定義排序與重複值策略。
  • 對不可信深度直接使用遞迴 → 長鏈會耗盡呼叫堆疊 → 改用明確堆疊並設定資源上限。
  • 往返後比較物件 identity → 重建過程會建立新節點 → 比較節點值與結構。
  • 只測試平衡樹範例 → 左右歧義與堆疊風險不會暴露 → 加入空樹、單側樹、重複值、極值、非法與偏斜案例。

追問與應對

追問一:二元搜尋樹可以省略空標記嗎?

通常可以。若 BST 使用嚴格大小關係且鍵唯一,可在解碼前序序列時帶著上下界:位於目前範圍內的值屬於 目前子樹,第一個超出範圍的值留給祖先。若允許重複值,契約必須說明相等值放左邊、右邊,或在節點內計數; 沒有這項規則,精簡格式仍有歧義。任意二元樹的基礎題不能使用這項最佳化。

追問二:如果節點值是任意字串,如何處理?

逗號與 # 都可能出現在字串中,簡單 split 無法再確認邊界。可使用 5:hello 這類長度前綴,並另外保留 節點與空節點標籤;也可以使用帶 schema 的標準序列化函式庫。跳脫方案也可行,但解碼器要區分被跳脫的 分隔符號與結構分隔符號,並處理非法跳脫。長度前綴的消耗規則通常更容易證明。

追問三:樹有數百萬節點或極端深度時怎麼辦?

避免遞迴呼叫;若尖峰記憶體也重要,不要一次 split 完整輸入。讓迭代解析器以串流方式讀 token,用明確 堆疊記錄尚未填入的子節點位置;編碼端寫入 stream,而非先收集全部 token。配置無界狀態前限制最大位元組、 token、節點、整數長度與深度。時間仍為 O(n),記憶體取決於活動堆疊或 queue 及輸出緩衝策略。

追問四:正式系統如何替格式加版本?

在樹 payload 外增加格式識別與版本,明確指定整數寬度和文字編碼,並規定未知欄位與未知版本要拒絕或忽略。 需要偵測損壞時可加入完整性檢查,但 checksum 不能當成身分驗證。發布流程要支援「讀舊寫新」,並替每個 受支援版本保存固定測試樣本。跨語言服務通常應採用維護中的 schema 格式,而非繼續擴充這段面試 codec。

追問五:層序序列化可以安全刪除尾端空標記嗎?

可以,前提是解碼器明確把最後一個真實節點之後省略的位置都視為空,而且編碼器只刪除尾端連續的空標記。 內部空位不能刪除,否則後續節點的子節點對齊會改變。刪除後仍要重新驗證往返契約與格式錯誤規則;字串看起來 較短並不能證明兩種格式等價。

公開來源

同類題目

相關面試工具

用 Screenshot 處理演算法題

截圖題目後,依序看約束、解法、程式碼、邊界條件和複雜度。

查看工具