二叉樹的序列化和反序列化
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,歡迎標星!