Java二叉树序列化/反序列化(两序、层序、前序三种办法)

全文主角

class TreeNode {
    
    
	int value;
	TreeNode left;
	TreeNode right;
	
	TreeNode(int val){
    
    
		value = val;
	}
}

主角二叉树
本次讨论二叉树的序列化 / 反序列化,下面按照 两序、层序、前序 三种办法依次分析并给出参考代码实现,这三种方式按思考形式由浅至较浅,代码量由多到较少。

一. 两序遍历(前序 + 中序)

对于上面的主角,其前序、中序遍历的结果分别是

前序:1 2 3 4 6 7 5
中序:2 1 6 4 7 3 5

我们都知道,

前序遍历的特点是,对于任意节点,先打印自己的值,再遍历左子树,再遍历右子树
中序遍历的特点是,对于任意节点,先遍历左子树,再打印自己的值,再遍历右子树

我们从根节点出发,对比前序和中序遍历的结果

前序:1 2 3 4 6 7 5
中序:2 1 6 4 7 3 5

可以看到,

  1. 前序的第一个值是根节点的值;
  2. 从中序找到根节点的值,发现其左序列包含了根节点左子树的全部值,右序列包含了根节点右子树全部的值

如果把中序的结果以根节点为分界线,拆成两个子序列,然后对这两个子序列重新进行前序遍历,可得到如下结果

(1)原中序遍历的 左子序列

前序:2
中序:2

(2)原中序遍历的 右子序列

前序:3 4 6 7 5
中序:6 4 7 3 5

将(1)(2)的结果同整体的遍历比较,发现原中序的左子序列、右子序列,在原前序中是连续的

在这里插入图片描述
为了进一步验证不是巧合,我们拿绿色部分的序列再次尝试得到如下结果
在这里插入图片描述
再拿蓝色部分验证
在这里插入图片描述
经多次比对,我们得出了结论:

  1. 前序的第一个值,就是当前节点的值
  2. 找到中序中对应的当前节点值,则其左、右子序列在前序中是连续的
  3. 对于中序的 任意左、右子序列 都满足以上两点

根据以上三点结论,我么可以通过二叉树的前序+中序遍历结果,还原出原二叉树

//前序遍历:1,2,3,4,6,7,5,
public void frontScan(TreeNode node, StringBuilder sb){
    
    
    if(node != null){
    
    
        sb.append(node.value).append(',');
        frontScan(node.left, sb);
        frontScan(node.right, sb);
    }
}
//中序遍历:2,1,6,4,7,3,5,
public void mediumScan(TreeNode node, StringBuilder sb){
    
    
    if(node != null){
    
    
        mediumScan(node.left, sb);
        sb.append(node.value).append(',');
        mediumScan(node.right, sb);
    }
}
//序列化
public String serialize(TreeNode root){
    
    
	//前序和中序遍历结果用 '&' 分隔
	StringBuilder res = frontScan(root);
	if(res.length() > 0) {
    
    
		res.setCharAt(res.length() - 1, '&');
	}
	res.append(mediumScan(root));
	if(res.length() > 0){
    
    
		res.setLength(res.length() - 1)
	}
	return res.toString();
}

反序列化步骤:
① 把序列化的String还原成俩数组
② 以前序为主,序列第一个值就是当前节点
③ 在中序中找到该值,以此作为分界点,把中序拆成左、右两部分
④ 把前序也拆成两部分,元素数量同中序左、右部分相等(忽略首元素)

//把序列化得到的字符串还原成前序、中序数组
public TreeNode decode(String serial){
    
    
    if(serial == null || serial.equals("")) return null;
    String[] fm = serial.split("&");
    int[] frontScan = parse(fm[0]);
    int[] mediumScan = parse(fm[1]);
    return build(frontScan, mediumScan);
}
//把 String[] 转换成 int[]
private int[] parse(String array){
    
    
    String[] elements = array.split(",");
    int[] res = new int[elements.length];
    for (int i = 0; i < elements.length; i++) {
    
    
        res[i] = Integer.parseInt(elements[i]);
    }
    return res;
}
//重建二叉树 
//fs:前序遍历结果 ms:中序遍历结果
private TreeNode build(int[] fs, int[] ms){
    
    
	//前序的首元素 是当前节点值
    TreeNode node = new TreeNode(fs[0]);
    //从中序里找到当前节点值的索引位置
    int index = findIndex(ms, fs[0]);
    //如果当前节点还有左子树
    if(index > 0){
    
    
    	//原中序的左子序列
        int[] msNew = new int[index];
        System.arraycopy(ms, 0, msNew, 0, index);
        //原中序的右子序列
        int[] fsNew = new int[index];
        System.arraycopy(fs, 1, fsNew, 0, index);
        //重建左子树
        node.left = build(fsNew, msNew);
    }
    //如果当前节点还有右子树
    if(index != -1 && index < ms.length - 1){
    
    
        int[] msNew = new int[ms.length - index - 1];
        System.arraycopy(ms, index + 1, msNew, 0, msNew.length);
        int[] fsNew = new int[msNew.length];
        System.arraycopy(fs, index + 1, fsNew, 0, fsNew.length);
        node.right = build(fsNew, msNew);
    }
    return node;
}
private int findIndex(int[] ms, int aim){
    
    
    for (int i = 0; i < ms.length; i++) {
    
    
        if(ms[i] == aim) return i;
    }
    return -1;
}

缺点:

  1. 序列化时需要遍历两次二叉树,当节点数量较多时,效率不高,而且序列化字符串偏长
  2. 反序列化时创建的左、右子序列数组对象较多
  3. 如果二叉树内意外地出现了重复节点,则反序列化结果可能错误,比如下面这种情况

在这里插入图片描述
前序:3 2 3 4
中序:3 2 3 4

经反序列化 重建完的树是这样的
在这里插入图片描述

二. 层序

前序 / 中序遍历是逮着一条道走到黑的方式,叫深度搜索,而层序遍历是一层一层的遍历,叫广度搜索

如果将二叉树按层编号,就是下面这个样子

在这里插入图片描述

如果二叉树的父节点编号为 i,则其左儿子编号为 2*i+1 ,右儿子编号为 2*i+2
如果二叉树的最大高度为 n,则其满节点数为 2n-1

按照上图的编号,我们可以把所有节点装进一个数组里 —— 序列化;
根据父子节点编号关系,重建二叉树 —— 反序列化。

序列化步骤:
① 开辟空间足够大的数组 2n-1
② 层序遍历,把节点装进数组对应的位置上

//确定树高度
public int getN(TreeNode node){
    
    
    //null节点 n = 0
    if(node == null){
    
    
        return 0;
    }
    //选取左、右子树较高的那个叉
    int leftN = getN(node.left);
    int rightN = getN(node.right);
    return Math.max(leftN, rightN) + 1;
}
//层序遍历二叉树,填装节点
public String fillNodes(TreeNode root){
    
    
    if(root == null) return "";
    //开辟足够空间的节点数组
    TreeNode[] nodes = new TreeNode[ (int)Math.pow(2, getN(root)) - 1 ];
    //借助队列结构,层序遍历
    //nodeQueue暂存节点,indexQueue暂存节点对应的编号
    Queue<TreeNode> nodeQueue = new LinkedList<>();
    Queue<Integer> indexQueue = new LinkedList<>();
    nodeQueue.offer(root);
    indexQueue.offer(0);

    while(!nodeQueue.isEmpty()){
    
    
        TreeNode node = nodeQueue.poll();
        int index = indexQueue.poll();

        nodes[index] = node;

        //填node.left
        if(node.left != null){
    
    
            nodeQueue.offer(node.left);
            indexQueue.offer(index * 2 + 1);
        }
        //填node.right
        if(node.right != null){
    
    
            nodeQueue.offer(node.right);
            indexQueue.offer(index * 2 + 2);
        }

    }
    //二叉树的最后一层可能有很多null(该树最后一层有2个null)
    //优化一下TreeNode[]长度,把最后一层的null去掉(优化可选)
    int length = nodes.length;
    for (int i = length - 1; i >= 0; i--) {
    
    
        if(nodes[i] == null){
    
    
            length--;
        }else{
    
    
            break;
        }
    }
    //序列化
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < length; i++) {
    
    
        //该树中编号为 3 4 7 8 9 10 的都是null节点(13 14已被优化掉)
        if(nodes[i] == null){
    
    
            sb.append("null,");
        }else{
    
    
            sb.append(nodes[i].value).append(',');
        }
    }
    return sb.toString();
}

反序列化步骤:
① 将序列化字符串拆成 String 数组 nums(字符串数字)
② 创建和 nums 等长的 TreeNode 数组
③ 扫描 nums 把有效的数值 包装成 TreeNode 装填到 TreeNode 数组
④ 按照 父子节点关系 关联引用
⑤ 返回 TreeNode 数组的首元素 —— root

public TreeNode deSerial(String serial){
    
    
    if(serial == null || serial.equals("")) return null;
    String[] nums = serial.split(",");
    //临时节点数组,用于关联各节点之间的父子关系
    TreeNode[] nodes = new TreeNode[nums.length];
    //倒序装填节点,方便关联父子引用
    for (int i = nodes.length - 1; i >= 0; i--) {
    
    
        if(!nums[i].equals("null")){
    
    
            nodes[i] = new TreeNode(Integer.parseInt(nums[i]));

            if(2*i+1 < nodes.length)
                nodes[i].left = nodes[2*i+1];
            if(2*i+2 < nodes.length)
                nodes[i].right = nodes[2*i+2];

        }
    }
    return nodes[0];
}

缺点:

  1. 序列化时一次深搜、一次广搜,效率不高
  2. 当二叉树只有右子树的极端情况产生(线性链表)时,序列化字符串 记录了太多无用的 “null”

在这里插入图片描述

三. 前序

如果序列化按前序遍历的方式进行,那反序列化是否也能按前序的方式重构二叉树呢?

方式一 中,我们通过比对中序遍历,以中序遍历是否还有左、右子序列为基础,判断前序是否还有拆分的必要,如果单依靠前序,就需要找到另一种界限标志,判断下一步的走向。

递归方式前序遍历
① 首先打印自己的值;
② 然后向左遍历,当一个节点的左子节点为空,返回到该节点,继续向右遍历;
③ 当一个节点的右子节点为空,返回到该节点,该节点及其子树遍历完成,向上回溯。

是的,也许我们也可以用空节点作为改变方向的遍历条件

还是这棵树,这次我们给他加上叶子节点的空节点,它长这样

在这里插入图片描述
这次的前序遍历结果为:1, 2, null, null, 3, 4, 6, null, null, 7, null, null, 5, null, null

我们把以上序列添加到序列化字符串里

public String serialize(TreeNode root) {
    
    
    StringBuilder res = new StringBuilder();
    //利用栈暂存节点
    LinkedList<TreeNode> stack = new LinkedList<>();
    stack.push(root);
    while(!stack.isEmpty()){
    
    
        TreeNode node = stack.pop();
        if(node == null){
    
    
            res.append("null,");
        }else{
    
    
            res.append(node.value).append(',');
            stack.push(node.right);
            stack.push(node.left);
        }
    }
    return res.toString();
}

反序列化步骤:
① 把序列化的字符串拆成 字符串数字数组 String[] nums
② 用递归的方式,每次方法调用都只初始化一个非 null 节点
③ 如果遇到 “null”,证明该节点是叶子节点的子节点(空节点),向上回溯;反之继续原来的路线。

//反序列化
public TreeNode deserialize(String data) {
    
    
	//new int[]{-1} 是 data[] 的指针,保证 rDeserialize() 每次都能获取到 它应该处理的位置
    return rDeserialize(data.split(","), new int[]{
    
    -1});
}
//递归重构二叉树
private TreeNode rDeserialize(String[] nums, int[] p){
    
    
	//每次方法调用都先把 p 指针定位到 本次方法 应该处理的位置上(线性扫描)
    p[0]++;
    //如果该位置是null,向上回溯
    if(nums[p[0]].equals("null")){
    
    
        return null;
    }
    //如果该位置不是null,按前序遍历的方式递归重构二叉树
    TreeNode node = new TreeNode( Integer.parseInt(nums[p[0]]) );
    node.left = rDeserialize(nums, p);
    node.right = rDeserialize(nums, p);
    return node;
}

三种方式相比下,单前序的序列化 / 反序列化在时间效率和空间消耗下平均相对较好。

感谢浏览!

猜你喜欢

转载自blog.csdn.net/qq_44676331/article/details/118877353