@laigary.com~/interview/coding/536-construct-binary….md$
$ cat ./coding/536-construct-binary-tree-from-string.md
[Coding]·2023-01-29·15 min read

536. Construct Binary Tree from String

536. Construct Binary Tree from String

4(2(3)(1))(6(5)) 這種括號字串還原成一棵二元樹。做這題之前可以先完成 606. Construct String from Binary Tree

思路

606 是把樹寫成字串,用的是前序;這題是反過來,要從字串把結構剖析出來。子樹的結構長這樣:

子樹 = 根節點(左子樹)(右子樹)

從這個結構來看,我們優先知道的是根節點 —— 它就在最前面,前面沒有任何括號擋著。所以這題也是前序的形狀:先把根拿出來,再處理兩棵子樹。

而括號裡面裝的東西,本身又是同樣的結構:

(左子樹)(右子樹)

    == (左子樹的根(左左)(左右))(右子樹的根(右左)(右右))

所以只要把子樹外面那層括號剝掉,剩下的字串就可以套用同一個函式遞迴下去。

整題於是拆成兩件事:怎麼取根節點的值、怎麼找出兩對括號的範圍。

取得根節點的值

根節點的值不會被括號影響,只要從當前位置往後掃到「不是數字也不是負號」為止就好。負號不用特別處理 —— Python 的 int() 直接吃得下 "-4"

end = begin
if begin == len(s):
    return None
while end < len(s) and (s[end].isdigit() or s[end] == '-'):
    end += 1
root = TreeNode(int(s[begin:end]))

if begin == len(s): return None 同時處理了「空字串」和「這棵子樹是空的」—— 也就是 606 留下的那個空 (),剝掉括號之後就是空字串。

找出括號的範圍

這裡不能只找下一個 ),因為括號會巢狀。要用棧來配對,跟 20. Valid Parentheses 是同一招:遇到 ( 推入、遇到 ) 彈出,棧空的那一刻就是這對括號的結尾

begin = end
if end < len(s) and s[end] == '(':
    stack = []
    stack.append(s[end])
    end += 1
    while end < len(s) and stack:
        if s[end] == '(':
            stack.append(s[end])
        if s[end] == ')':
            stack.pop()
        end += 1
    root.left = traverse(s[begin+1:end-1], 0)

s[begin+1:end-1] 就是「剝掉最外層那對括號」—— 這正是遞迴能成立的關鍵。找右子樹是完全一樣的一段,只是把結果接到 root.right

因為 606 的省略規則保證了「第一對括號一定是左子樹」,所以照順序處理就不會弄錯 —— 左子樹是空的時候,那對括號會是 (),剝掉之後遞迴回傳 None

解題方向

切片版

# class TreeNode:
#     def __init__(self, val=0, left=None, right=None):
#         self.val = val
#         self.left = left
#         self.right = right
class Solution:
    def str2tree(self, s: str) -> Optional[TreeNode]:       
        def traverse(s, begin):
            end = begin
            if begin == len(s):
                return None
            while end < len(s) and (s[end].isdigit() or s[end] == '-'):
                end += 1
            root = TreeNode(int(s[begin:end]))
            begin = end
            if end < len(s) and s[end] == '(':
                stack = []
                stack.append(s[end])
                end += 1
                while end < len(s) and stack:
                    if s[end] == '(':
                        stack.append(s[end])
                    if s[end] == ')':
                        stack.pop()
                    end += 1
                root.left = traverse(s[begin+1:end-1], 0)
            begin = end
            if end < len(s) and s[end] == '(':
                stack = []
                stack.append(s[end])
                end += 1
                while end < len(s) and stack:
                    if s[end] == '(':
                        stack.append(s[end])
                    if s[end] == ')':
                        stack.pop()
                    end += 1
                root.right = traverse(s[begin+1:end-1], 0)
            return root
        return traverse(s, 0)

兩段找括號的邏輯一模一樣,只差最後接到 left 還是 right,抽成一個小函式會更短。留成兩段的好處是「先左後右」的順序一眼就看得到。

這一版有個瑕疵:s[begin+1:end-1] 每遞迴一層就複製一份新字串。斜樹上每一層都在複製近乎整個字串,記憶體會爆掉,時間也白花。

傳索引版

改法很機械:不要切字串,改成把「這棵子樹的範圍」當成兩個索引傳下去。 原本的 len(s) 換成上界 stop,原本的 traverse(s[begin+1:end-1], 0) 換成 traverse(begin + 1, end - 1),其他一行都不用動。

# class TreeNode:
#     def __init__(self, val=0, left=None, right=None):
#         self.val = val
#         self.left = left
#         self.right = right
class Solution:
    def str2tree(self, s: str) -> Optional[TreeNode]:
        def traverse(begin, stop):
            if begin == stop:
                return None
            end = begin
            while end < stop and (s[end].isdigit() or s[end] == '-'):
                end += 1
            root = TreeNode(int(s[begin:end]))
            begin = end
            if end < stop and s[end] == '(':
                stack = []
                stack.append(s[end])
                end += 1
                while end < stop and stack:
                    if s[end] == '(':
                        stack.append(s[end])
                    if s[end] == ')':
                        stack.pop()
                    end += 1
                root.left = traverse(begin + 1, end - 1)
            begin = end
            if end < stop and s[end] == '(':
                stack = []
                stack.append(s[end])
                end += 1
                while end < stop and stack:
                    if s[end] == '(':
                        stack.append(s[end])
                    if s[end] == ')':
                        stack.pop()
                    end += 1
                root.right = traverse(begin + 1, end - 1)
            return root
        return traverse(0, len(s))

begin == stop 取代了原本的 begin == len(s),一樣同時處理「空字串」和「空的 ()」—— 剝掉括號之後 beginstop 會相等。

int(s[begin:end]) 那個切片留著沒關係,它的長度是數字的位數,跟子樹大小無關。

但要說清楚:這一版省掉的是複製,不是重掃。 找左子樹的括號範圍時,那個 while 已經把左子樹的字串整段走過一遍,遞迴進去之後又會再走一遍。所以時間量級跟切片版一樣是 O(nh),只是不再產生一堆暫時字串。真正的改善在空間。

單一游標版

前兩版都有同一個累贅:為了知道子樹的括號在哪結束,得先掃一遍。但其實根本不用先知道 —— 讓遞迴自己走到那裡就好。

把索引改成一個單向前進、所有層共用的游標,規則只有三條:

  1. 讀完數字,游標停在數字後面
  2. 看到 (吃掉它,遞迴下去
  3. 遞迴回來時,游標剛好停在配對的 ) 上,吃掉它

第 3 條是整個做法成立的關鍵:子函式讀到 ) 就會回傳(那是它的終止條件),所以它回來的時候,游標必定停在自己那對括號的收尾上。配對是遞迴自己完成的,不需要棧。

# class TreeNode:
#     def __init__(self, val=0, left=None, right=None):
#         self.val = val
#         self.left = left
#         self.right = right
class Solution:
    def str2tree(self, s: str) -> Optional[TreeNode]:
        index = 0

        def traverse():
            nonlocal index
            if index == len(s) or s[index] == ')':
                return None

            begin = index
            while index < len(s) and (s[index].isdigit() or s[index] == '-'):
                index += 1
            root = TreeNode(int(s[begin:index]))

            if index < len(s) and s[index] == '(':
                index += 1                  # 吃掉 '('
                root.left = traverse()
                index += 1                  # 吃掉配對的 ')'
            if index < len(s) and s[index] == '(':
                index += 1
                root.right = traverse()
                index += 1
            return root

        return traverse()

終止條件多了 s[index] == ')' 這一項,它就是在處理空子樹 —— 也就是 606 留下的那個 ()。進去之後立刻撞到 ),回傳 None,外層再把那個 ) 吃掉。

4(2)(5) 走一遍,看游標怎麼單向推進:

動作                  index
讀到 4                  1
吃掉 ( 進左子樹          2
  讀到 2                3
吃掉 ) 離開左子樹        4
吃掉 ( 進右子樹          5
  讀到 5                6
吃掉 ) 離開右子樹        7

index 從 0 到 7 只往前走,沒有回頭過 —— 這就是 O(n) 的來源。

代價是遞迴不再是純函式:它依賴並修改外層的 index,所以呼叫的順序有意義,root.left 那兩行和 root.right 那兩行絕對不能對調。前兩版把範圍當參數傳,反而沒有這個限制。

補充

共用游標這個手法在別的地方也出現過394. Decode String 的遞迴版用的就是 nonlocal index297 的前序反序列化則是用 deque 從左邊消耗。三題的心法是同一句:不要複製資料,也不要回頭重掃,只推進位置。

只用一個棧也能做,不用遞迴:掃字串時遇到數字就建節點掛到棧頂那個節點上(左邊沒滿掛左邊,否則掛右邊),遇到 )pop。我沒有用這個角度寫過,但那是這題的迭代解。

同一個題組

題目方向格式
606樹 → 字串括號
536字串 → 樹括號
297兩邊都要自己選(哨兵最省事)
449. Serialize and Deserialize BST兩邊都要前序,不用哨兵
428. Serialize and Deserialize N-ary Tree兩邊都要要多存「有幾個小孩」

整理見 Tree 遍歷模板

複雜度

n 是字串長度、h 是樹高(平衡時 O(logn)、斜樹 O(n))。

寫法時間空間
切片版O(nh)O(nh) — 每層都留著一份子字串
傳索引版O(nh)O(h) — 只有遞迴堆疊
單一游標O(n)O(h)

前兩版的 O(nh) 都來自同一件事:每一層都要掃一遍才能找到括號的配對位置,而那一遍的長度就是這棵子樹的字串長度。 斜樹上 h=n,所以最壞是 O(n2)

傳索引沒有改變掃描次數,改變的是「有沒有真的把那段字串複製出來」—— 所以時間量級一樣,空間差很多。