关于算法:二叉树的序列化和反序列化

33次阅读

共计 7548 个字符,预计需要花费 19 分钟才能阅读完成。

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,#,#,

至此,咱们曾经能够写出序列化函数 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 列表的第一个元素就是一棵树的根节点,所以只有将列表的第一个元素取出作为根节点,剩下的交给递归函数去解决即可。

三、后序遍历解法

二叉树的后续遍历框架:

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],这也恰好是反序列化的目标嘛。

到这里,咱们对于二叉树的序列化和反序列化的几种办法就全副讲完了。

正文完
 0