二叉樹的序列化,就那幾個框架,枯燥至極


二叉樹的序列化和反序列化

JSON 的運用非常廣泛,比如我們經常將變成語言中的結構體序列化成 JSON 字符串,存入緩存或者通過網絡發送給遠端服務,消費者接受 JSON 字符串然后進行反序列化,就可以得到原始數據了。這就是「序列化」和「反序列化」的目的,以某種固定格式組織字符串,使得數據可以獨立於編程語言。

那么假設現在有一棵用 Java 實現的二叉樹,我想把它序列化字符串,然后用 C++ 讀取這棵並還原這棵二叉樹的結構,怎么辦?這就需要對二叉樹進行「序列化」和「反序列化」了。

一、題目描述

「二叉樹的序列化與反序列化」就是給你輸入一棵二叉樹的根節點 root,要求你實現如下一個類:

public class Codec {

    // 把一棵二叉樹序列化成字符串
    public String serialize(TreeNode root) {}

    // 把字符串反序列化成二叉樹
    public TreeNode deserialize(String data) {}
}

我們可以用 serialize 方法將二叉樹序列化成字符串,用 deserialize 方法將序列化的字符串反序列化成二叉樹,至於以什么格式序列化和反序列化,這個完全由你決定。

比如說輸入如下這樣一棵二叉樹:

serialize 方法也許會把它序列化成字符串 2,1,#,6,3,#,#,其中 # 表示 null 指針,那么把這個字符串再輸入 deserialize 方法,依然可以還原出這棵二叉樹。也就是說,這兩個方法會成對兒使用,你只要保證他倆能夠自洽就行了。

想象一下,二叉樹結該是一個二維平面內的結構,而序列化出來的字符串是一個線性的一維結構。所謂的序列化不過就是把結構化的數據「打平」,其實就是在考察二叉樹的遍歷方式

二叉樹的遍歷方式有哪些?遞歸遍歷方式有前序遍歷,中序遍歷,后序遍歷;迭代方式一般是層級遍歷。本文就把這些方式都嘗試一遍,來實現 serialize 方法和 deserialize 方法。

二、前序遍歷解法

前文 學習數據結構和算法的框架思維 說過了二叉樹的幾種遍歷方式,前序遍歷框架如下:

void traverse(TreeNode root) {
    if (root == null) return;

    // 前序遍歷的代碼

    traverse(root.left);
    traverse(root.right);
}

真的很簡單,在遞歸遍歷兩棵子樹之前寫的代碼就是前序遍歷代碼,那么請你看一看如下偽碼:

LinkedList<Integer> res;
void traverse(TreeNode root) {
    if (root == null) {
        // 暫且用數字 -1 代表空指針 null
        res.addLast(-1);
        return;
    }

    /****** 前序遍歷位置 ******/
    res.addLast(root.val);
    /***********************/

    traverse(root.left);
    traverse(root.right);
}

調用 traverse 函數之后,你是否可以立即想出這個 res 列表中元素的順序是怎樣的?比如如下二叉樹(# 代表空指針 null),可以直觀看出前序遍歷做的事情:

那么 res = [1,2,-1,4,-1,-1,3,-1,-1],這就是將二叉樹「打平」到了一個列表中,其中 -1 代表 null。

那么,將二叉樹打平到一個字符串中也是完全一樣的:

// 代表分隔符的字符
String SEP = ",";
// 代表 null 空指針的字符
String NULL = "#";
// 用於拼接字符串
StringBuilder sb = new StringBuilder();

/* 將二叉樹打平為字符串 */
void traverse(TreeNode root, StringBuilder sb) {
    if (root == null) {
        sb.append(NULL).append(SEP);
        return;
    }

    /****** 前序遍歷位置 ******/
    sb.append(root.val).append(SEP);
    /***********************/

    traverse(root.left, sb);
    traverse(root.right, sb);
}

StringBuilder 可以用於高效拼接字符串,所以也可以認為是一個列表,用 , 作為分隔符,用 # 表示空指針 null,調用完 traverse 函數后,StringBuilder 中的字符串應該是 1,2,#,4,#,#,3,#,#,

PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種算法套路后投再入題海就如魚得水了。

至此,我們已經可以寫出序列化函數 serialize 的代碼了:

String SEP = ",";
String NULL = "#";

/* 主函數,將二叉樹序列化為字符串 */
String serialize(TreeNode root) {
    StringBuilder sb = new StringBuilder();
    serialize(root, sb);
    return sb.toString();
}

/* 輔助函數,將二叉樹存入 StringBuilder */
void serialize(TreeNode root, StringBuilder sb) {
    if (root == null) {
        sb.append(NULL).append(SEP);
        return;
    }

    /****** 前序遍歷位置 ******/
    sb.append(root.val).append(SEP);
    /***********************/

    serialize(root.left, sb);
    serialize(root.right, sb);
}

現在,思考一下如何寫 deserialize 函數,將字符串反過來構造二叉樹。

首先我們可以把字符串轉化成列表:

String data = "1,2,#,4,#,#,3,#,#,";
String[] nodes = data.split(",");

這樣,nodes 列表就是二叉樹的前序遍歷結果,問題轉化為:如何通過二叉樹的前序遍歷結果還原一棵二叉樹?

PS:一般語境下,單單前序遍歷結果是不能還原二叉樹結構的,因為缺少空指針的信息,至少要得到前、中、后序遍歷中的兩種才能還原二叉樹。但是這里的 node 列表包含空指針的信息,所以只使用 node 列表就可以還原二叉樹。

根據我們剛才的分析,nodes 列表就是一棵打平的二叉樹:

那么,反序列化過程也是一樣,先確定根節點 root,然后遵循前序遍歷的規則,遞歸生成左右子樹即可

/* 主函數,將字符串反序列化為二叉樹結構 */
TreeNode deserialize(String data) {
    // 將字符串轉化成列表
    LinkedList<String> nodes = new LinkedList<>();
    for (String s : data.split(SEP)) {
        nodes.addLast(s);
    }
    return deserialize(nodes);
}

/* 輔助函數,通過 nodes 列表構造二叉樹 */
TreeNode deserialize(LinkedList<String> nodes) {
    if (nodes.isEmpty()) return null;

    /****** 前序遍歷位置 ******/
    // 列表最左側就是根節點
    String first = nodes.removeFirst();
    if (first.equals(NULL)) return null;
    TreeNode root = new TreeNode(Integer.parseInt(first));
    /***********************/

    root.left = deserialize(nodes);
    root.right = deserialize(nodes);

    return root;
}

我們發現,根據樹的遞歸性質,nodes 列表的第一個元素就是一棵樹的根節點,所以只要將列表的第一個元素取出作為根節點,剩下的交給遞歸函數去解決即可。

PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種算法套路后投再入題海就如魚得水了。

三、后序遍歷解法

二叉樹的后續遍歷框架:

void traverse(TreeNode root) {
    if (root == null) return;
    traverse(root.left);
    traverse(root.right);

    // 后序遍歷的代碼
}

明白了前序遍歷的解法,后序遍歷就比較容易理解了,我們首先實現 serialize 序列化方法,只需要稍微修改輔助方法即可:

/* 輔助函數,將二叉樹存入 StringBuilder */
void serialize(TreeNode root, StringBuilder sb) {
    if (root == null) {
        sb.append(NULL).append(SEP);
        return;
    }
    
    serialize(root.left, sb);
    serialize(root.right, sb);

    /****** 后序遍歷位置 ******/
    sb.append(root.val).append(SEP);
    /***********************/
}

我們把對 StringBuilder 的拼接操作放到了后續遍歷的位置,后序遍歷導致結果的順序發生變化:

null,null,null,4,2,null,null,3,1,

關鍵的難點在於,如何實現后序遍歷的 deserialize 方法呢?是不是也簡單地將關鍵代碼放到后序遍歷的位置就行了呢:

/* 輔助函數,通過 nodes 列表構造二叉樹 */
TreeNode deserialize(LinkedList<String> nodes) {
    if (nodes.isEmpty()) return null;

    root.left = deserialize(nodes);
    root.right = deserialize(nodes);

    /****** 后序遍歷位置 ******/
    String first = nodes.removeFirst();
    if (first.equals(NULL)) return null;
    TreeNode root = new TreeNode(Integer.parseInt(first));
    /***********************/

    return root;
}

沒這么簡單,顯然上述代碼是錯誤的,變量都沒聲明呢,就開始用了?生搬硬套肯定是行不通的,回想剛才我們前序遍歷方法中的 deserialize 方法,第一件事情在做什么?

deserialize 方法首先尋找 root 節點的值,然后遞歸計算左右子節點。那么我們這里也應該順着這個基本思路走,后續遍歷中,root 節點的值能不能找到?再看一眼剛才的圖:

可見,root 的值是列表的最后一個元素。我們應該從后往前取出列表元素,先用最后一個元素構造 root,然后遞歸調用生成 root 的左右子樹。注意,根據上圖,從后往前在 nodes 列表中取元素,一定要先構造 root.right 子樹,后構造 root.left 子樹

看完整代碼:

/* 主函數,將字符串反序列化為二叉樹結構 */
TreeNode deserialize(String data) {
    LinkedList<String> nodes = new LinkedList<>();
    for (String s : data.split(SEP)) {
        nodes.addLast(s);
    }
    return deserialize(nodes);
}

/* 輔助函數,通過 nodes 列表構造二叉樹 */
TreeNode deserialize(LinkedList<String> nodes) {
    if (nodes.isEmpty()) return null;
    // 從后往前取出元素
    String last = nodes.removeLast();
    if (last.equals(NULL)) return null;
    TreeNode root = new TreeNode(Integer.parseInt(last));
    // 限構造右子樹,后構造左子樹
    root.right = deserialize(nodes);
    root.left = deserialize(nodes);
    
    return root;
}

至此,后續遍歷實現的序列化、反序列化方法也都實現了。

四、中序遍歷解法

先說結論,中序遍歷的方式行不通,因為無法實現反序列化方法 deserialize

序列化方法 serialize 依然容易,只要把字符串的拼接操作放到中序遍歷的位置就行了:

/* 輔助函數,將二叉樹存入 StringBuilder */
void serialize(TreeNode root, StringBuilder sb) {
    if (root == null) {
        sb.append(NULL).append(SEP);
        return;
    }

    serialize(root.left, sb);
    /****** 中序遍歷位置 ******/
    sb.append(root.val).append(SEP);
    /***********************/
    serialize(root.right, sb);
}

但是,我們剛才說了,要想實現反序列方法,首先要構造 root 節點。前序遍歷得到的 nodes 列表中,第一個元素是 root 節點的值;后序遍歷得到的 nodes 列表中,最后一個元素是 root 節點的值。

你看上面這段中序遍歷的代碼,root 的值被夾在兩棵子樹的中間,也就是在 nodes 列表的中間,我們不知道確切的索引位置,所以無法找到 root 節點,也就無法進行反序列化。

五、層級遍歷解法

首先,先寫出層級遍歷二叉樹的代碼框架:

void traverse(TreeNode root) {
    if (root == null) return;
    // 初始化隊列,將 root 加入隊列
    Queue<TreeNode> q = new LinkedList<>();
    q.offer(root);
    
    while (!q.isEmpty()) {
        TreeNode cur = q.poll();

        /* 層級遍歷代碼位置 */
        System.out.println(root.val);
        /*****************/

        if (cur.left != null) {
            q.offer(cur.left);
        }
        
        if (cur.right != null) {
            q.offer(cur.right);
        }
    }
}

上述代碼是標准的二叉樹層級遍歷框架,從上到下,從左到右打印每一層二叉樹節點的值,可以看到,隊列 q 中不會存在 null 指針。

不過我們在反序列化的過程中是需要記錄空指針 null 的,所以可以把標准的層級遍歷框架略作修改:

void traverse(TreeNode root) {
    if (root == null) return;
    // 初始化隊列,將 root 加入隊列
    Queue<TreeNode> q = new LinkedList<>();
    q.offer(root);
    
    while (!q.isEmpty()) {
        TreeNode cur = q.poll();

        /* 層級遍歷代碼位置 */
        if (cur == null) continue;
        System.out.println(root.val);
        /*****************/

        q.offer(cur.left);
        q.offer(cur.right);
    }
}

這樣也可以完成層級遍歷,只不過我們把對空指針的檢驗從「將元素加入隊列」的時候改成了「從隊列取出元素」的時候。

那么我們完全仿照這個框架即可寫出序列化方法:

String SEP = ",";
String NULL = "#";

/* 將二叉樹序列化為字符串 */
String serialize(TreeNode root) {
    if (root == null) return "";
    StringBuilder sb = new StringBuilder();
    // 初始化隊列,將 root 加入隊列
    Queue<TreeNode> q = new LinkedList<>();
    q.offer(root);
    
    while (!q.isEmpty()) {
        TreeNode cur = q.poll();
        
        /* 層級遍歷代碼位置 */
        if (cur == null) {
            sb.append(NULL).append(SEP);
            continue;
        }
        sb.append(cur.val).append(SEP);
        /*****************/

        q.offer(cur.left);
        q.offer(cur.right);
    }
    
    return sb.toString();
}

層級遍歷序列化得出的結果如下圖:

可以看到,每一個非空節點都會對應兩個子節點,那么反序列化的思路也是用隊列進行層級遍歷,同時用索引 i 記錄對應子節點的位置

/* 將字符串反序列化為二叉樹結構 */
TreeNode deserialize(String data) {
    if (data.isEmpty()) return null;
    String[] nodes = data.split(SEP);
    // 第一個元素就是 root 的值
    TreeNode root = new TreeNode(Integer.parseInt(nodes[0]));

    // 隊列 q 記錄父節點,將 root 加入隊列
    Queue<TreeNode> q = new LinkedList<>();
    q.offer(root);

    for (int i = 1; i < nodes.length; ) {
        // 隊列中存的都是父節點
        TreeNode parent = q.poll();
        // 父節點對應的左側子節點的值
        String left = nodes[i++];
        if (!left.equals(NULL)) {
            parent.left = new TreeNode(Integer.parseInt(left));
            q.offer(parent.left);
        } else {
            parent.left = null;
        }
        // 父節點對應的右側子節點的值
        String right = nodes[i++];
        if (!right.equals(NULL)) {
            parent.right = new TreeNode(Integer.parseInt(right));
            q.offer(parent.right);
        } else {
            parent.right = null;
        }
    }
    return root;
}

這段代碼可以考驗一下你的框架思維。仔細看一看 for 循環部分的代碼,發現這不就是標准層級遍歷的代碼衍生出來的嘛:

while (!q.isEmpty()) {
    TreeNode cur = q.poll();

    if (cur.left != null) {
        q.offer(cur.left);
    }
    
    if (cur.right != null) {
        q.offer(cur.right);
    }
}

只不過,標准的層級遍歷在操作二叉樹節點 TreeNode,而我們的函數在操作 nodes[i],這也恰恰是反序列化的目的嘛。

到這里,我們對於二叉樹的序列化和反序列化的幾種方法就全部講完了。

相關推薦:

_____________

我的 在線電子書 有 100 篇原創文章,手把手帶刷 200 道力扣題目,建議收藏!對應的 GitHub 算法倉庫 已經獲得了 70k star,歡迎標星!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM