一文帶你看懂二叉樹的序列化

lucifer發表於2020-07-27

我們先來看下什麼是序列化,以下定義來自維基百科:

序列化(serialization)在電腦科學的資料處理中,是指將資料結構或物件狀態轉換成可取用格式(例如存成檔案,存於緩衝,或經由網路中傳送),以留待後續在相同或另一臺計算機環境中,能恢復原先狀態的過程。依照序列化格式重新獲取位元組的結果時,可以利用它來產生與原始物件相同語義的副本。對於許多物件,像是使用大量引用的複雜物件,這種序列化重建的過程並不容易。物件導向中的物件序列化,並不概括之前原始物件所關係的函式。這種過程也稱為物件編組(marshalling)。從一系列位元組提取資料結構的反向操作,是反序列化(也稱為解編組、deserialization、unmarshalling)。

可見,序列化和反序列化在電腦科學中的應用還是非常廣泛的。就拿 LeetCode 平臺來說,其允許使用者輸入形如:

[1,2,3,null,null,4,5]

這樣的資料結構來描述一顆樹:

([1,2,3,null,null,4,5] 對應的二叉樹)

其實序列化和反序列化只是一個概念,不是一種具體的演算法,而是很多的演算法。並且針對不同的資料結構,演算法也會不一樣。本文主要講述的是二叉樹的序列化和反序列化。看完本文之後,你就可以放心大膽地去 AC 以下兩道題:

<!-- more -->

前置知識

閱讀本文之前,需要你對樹的遍歷以及 BFS 和 DFS 比較熟悉。如果你還不熟悉,推薦閱讀一下相關文章之後再來看。或者我這邊也寫了一個總結性的文章二叉樹的遍歷,你也可以看看。

前言

我們知道:二叉樹的深度優先遍歷,根據訪問根節點的順序不同,可以將其分為前序遍歷中序遍歷, 後序遍歷。即如果先訪問根節點就是前序遍歷,最後訪問根節點就是後續遍歷,其它則是中序遍歷。而左右節點的相對順序是不會變的,一定是先左後右。

當然也可以設定為先右後左。

並且知道了三種遍歷結果中的任意兩種即可還原出原有的樹結構。這不就是序列化和反序列化麼?如果對這個比較陌生的同學建議看看我之前寫的《構造二叉樹系列》

有了這樣一個前提之後演算法就自然而然了。即先對二叉樹進行兩次不同的遍歷,不妨假設按照前序和中序進行兩次遍歷。然後將兩次遍歷結果序列化,比如將兩次遍歷結果以逗號“,” join 成一個字串。 之後將字串反序列即可,比如將其以逗號“,” split 成一個陣列。

序列化:

class Solution:
    def preorder(self, root: TreeNode):
        if not root: return []
        return [str(root.val)] +self. preorder(root.left) + self.preorder(root.right)
    def inorder(self, root: TreeNode):
        if not root: return []
        return  self.inorder(root.left) + [str(root.val)] + self.inorder(root.right)
    def serialize(self, root):
        ans = ''
        ans += ','.join(self.preorder(root))
        ans += '$'
        ans += ','.join(self.inorder(root))

        return ans

反序列化:

這裡我直接用了力扣 105. 從前序與中序遍歷序列構造二叉樹 的解法,一行程式碼都不改。

class Solution:
    def deserialize(self, data: str):
        preorder, inorder = data.split('$')
        if not preorder: return None
        return self.buildTree(preorder.split(','), inorder.split(','))

    def buildTree(self, preorder: List[int], inorder: List[int]) -> TreeNode:
        # 實際上inorder 和 preorder 一定是同時為空的,因此你無論判斷哪個都行
        if not preorder:
            return None
        root = TreeNode(preorder[0])

        i = inorder.index(root.val)
        root.left = self.buildTree(preorder[1:i + 1], inorder[:i])
        root.right = self.buildTree(preorder[i + 1:], inorder[i+1:])

        return root

實際上這個演算法是不一定成立的,原因在於樹的節點可能存在重複元素。也就是說我前面說的知道了三種遍歷結果中的任意兩種即可還原出原有的樹結構是不對的,嚴格來說應該是如果樹中不存在重複的元素,那麼知道了三種遍歷結果中的任意兩種即可還原出原有的樹結構

聰明的你應該發現了,上面我的程式碼用了 i = inorder.index(root.val),如果存在重複元素,那麼得到的索引 i 就可能不是準確的。但是,如果題目限定了沒有重複元素則可以用這種演算法。但是現實中不出現重複元素不太現實,因此需要考慮其他方法。那究竟是什麼樣的方法呢? 接下來進入正題。

DFS

序列化

我們來模仿一下力扣的記法。 比如:[1,2,3,null,null,4,5](本質上是 BFS 層次遍歷),對應的樹如下:

選擇這種記法,而不是 DFS 的記法的原因是看起來比較直觀

序列化的程式碼非常簡單, 我們只需要在普通的遍歷基礎上,增加對空節點的輸出即可(普通的遍歷是不處理空節點的)。

比如我們都樹進行一次前序遍歷的同時增加空節點的處理。選擇前序遍歷的原因是容易知道根節點的位置,並且程式碼好寫,不信你可以試試。

因此序列化就僅僅是普通的 DFS 而已,直接給大家看看程式碼。

Python 程式碼:

class Codec:
    def serialize_dfs(self, root, ans):
        # 空節點也需要序列化,否則無法唯一確定一棵樹,後不贅述。
        if not root: return ans + '#,'
        # 節點之間通過逗號(,)分割
        ans += str(root.val) + ','
        ans = self.serialize_dfs(root.left, ans)
        ans = self.serialize_dfs(root.right, ans)
        return ans
    def serialize(self, root):
        # 由於最後會新增一個額外的逗號,因此需要去除最後一個字元,後不贅述。
        return self.serialize_dfs(root, '')[:-1]

Java 程式碼:

public class Codec {
    public String serialize_dfs(TreeNode root, String str) {
        if (root == null) {
            str += "None,";
        } else {
            str += str.valueOf(root.val) + ",";
            str = serialize_dfs(root.left, str);
            str = serialize_dfs(root.right, str);
        }
        return str;
    }

    public String serialize(TreeNode root) {
        return serialize_dfs(root, "");
    }
}

[1,2,3,null,null,4,5] 會被處理為1,2,#,#,3,4,#,#,5,#,#

我們先看一個短視訊:

(動畫來自力扣)

反序列化

反序列化的第一步就是將其展開。以上面的例子來說,則會變成陣列:[1,2,#,#,3,4,#,#,5,#,#],然後我們同樣執行一次前序遍歷,每次處理一個元素,重建即可。由於我們採用的前序遍歷,因此第一個是根元素,下一個是其左子節點,下下一個是其右子節點。

Python 程式碼:

    def deserialize_dfs(self, nodes):
        if nodes:
            if nodes[0] == '#':
                nodes.pop(0)
                return None
            root = TreeNode(nodes.pop(0))
            root.left = self.deserialize_dfs(nodes)
            root.right = self.deserialize_dfs(nodes)
            return root
        return None

    def deserialize(self, data: str):
        nodes = data.split(',')
        return self.deserialize_dfs(nodes)

Java 程式碼:

    public TreeNode deserialize_dfs(List<String> l) {
        if (l.get(0).equals("None")) {
            l.remove(0);
            return null;
        }

        TreeNode root = new TreeNode(Integer.valueOf(l.get(0)));
        l.remove(0);
        root.left = deserialize_dfs(l);
        root.right = deserialize_dfs(l);

        return root;
    }

    public TreeNode deserialize(String data) {
        String[] data_array = data.split(",");
        List<String> data_list = new LinkedList<String>(Arrays.asList(data_array));
        return deserialize_dfs(data_list);
    }

複雜度分析

  • 時間複雜度:每個節點都會被處理一次,因此時間複雜度為 $O(N)$,其中 $N$ 為節點的總數。
  • 空間複雜度:空間複雜度取決於棧深度,因此空間複雜度為 $O(h)$,其中 $h$ 為樹的深度。

BFS

序列化

實際上我們也可以使用 BFS 的方式來表示一棵樹。在這一點上其實就和力扣的記法是一致的了。

我們知道層次遍歷的時候實際上是有層次的。只不過有的題目需要你記錄每一個節點的層次資訊,有些則不需要。

這其實就是一個樸實無華的 BFS,唯一不同則是增加了空節點。

Python 程式碼:


class Codec:
    def serialize(self, root):
        ans = ''
        queue = [root]
        while queue:
            node = queue.pop(0)
            if node:
                ans += str(node.val) + ','
                queue.append(node.left)
                queue.append(node.right)
            else:
                ans += '#,'
        return ans[:-1]

反序列化

如圖有這樣一棵樹:

那麼其層次遍歷為 [1,2,3,#,#, 4, 5]。我們根據此層次遍歷的結果來看下如何還原二叉樹,如下是我畫的一個示意圖:

容易看出:

  • level x 的節點一定指向 level x + 1 的節點,如何找到 level + 1 呢? 這很容易通過層次遍歷來做到。
  • 對於給的的 level x,從左到右依次對應 level x + 1 的節點,即第 1 個節點的左右子節點對應下一層的第 1 個和第 2 個節點,第 2 個節點的左右子節點對應下一層的第 3 個和第 4 個節點。。。
  • 接上,其實如果你仔細觀察的話,實際上 level x 和 level x + 1 的判斷是無需特別判斷的。我們可以把思路逆轉過來:即第 1 個節點的左右子節點對應第 1 個和第 2 個節點,第 2 個節點的左右子節點對應第 3 個和第 4 個節點。。。(注意,沒了下一層三個字)

因此我們的思路也是同樣的 BFS,並依次連線左右節點。

Python 程式碼:


    def deserialize(self, data: str):
        if data == '#': return None
        # 資料準備
        nodes = data.split(',')
        if not nodes: return None
        # BFS
        root = TreeNode(nodes[0])
        queue = [root]
        # 已經有 root 了,因此從 1 開始
        i = 1

        while i < len(nodes) - 1:
            node = queue.pop(0)
            #
            lv = nodes[i]
            rv = nodes[i + 1]
            i += 2
            # 對於給的的 level x,從左到右依次對應 level x + 1 的節點
            # node 是 level x 的節點,l 和 r 則是 level x + 1 的節點
            if lv != '#':
                l = TreeNode(lv)
                node.left = l
                queue.append(l)

            if rv != '#':
                r = TreeNode(rv)
                node.right = r
                queue.append(r)
        return root

複雜度分析

  • 時間複雜度:每個節點都會被處理一次,因此時間複雜度為 $O(N)$,其中 $N$ 為節點的總數。
  • 空間複雜度:$O(N)$,其中 $N$ 為節點的總數。

總結

除了這種方法還有很多方案, 比如括號表示法。 關於這個可以參考力扣606. 根據二叉樹建立字串,這裡就不再贅述了。

本文從 BFS 和 DFS 角度來思考如何序列化和反序列化一棵樹。 如果用 BFS 來序列化,那麼相應地也需要 BFS 來反序列化。如果用 DFS 來序列化,那麼就需要用 DFS 來反序列化。

我們從馬後炮的角度來說,實際上對於序列化來說,BFS 和 DFS 都比較常規。對於反序列化,大家可以像我這樣舉個例子,畫一個圖。可以先在紙上,電腦上,如果你熟悉了之後,也可以畫在腦子裡。

(Like This)

更多題解可以訪問我的 LeetCode 題解倉庫:https://github.com/azl3979858... 。 目前已經 30K star 啦。

關注公眾號力扣加加,努力用清晰直白的語言還原解題思路,並且有大量圖解,手把手教你識別套路,高效刷題。

相關文章