数据结构与算法三之树

为什么需要树?

顺序存储:
优点:通过下标方式访问元素,速度快。对于有序数组,还可使用二分查找提高检索速度。
缺点:如果要检索具体某个值,或者插入删除值(按一定顺序)会整体移动,效率较低

链式存储:
优点:在一定程度上对数组存储方式有优化(比如:插入一个数值节点,只需要将插入节点,链接到链表中即可, 删除效率也很好)。
缺点:在进行检索时,效率仍然较低,比如(检索某个值,需要从头节点开始遍历)

树:
能提高数据存储,读取的效率, 比如利用 二叉排序树(Binary Sort Tree),既可以保证数据的检索速度,同时也可以保证数据的插入,删除,修改的速度

二叉树:每个节点最多只能有两个子节点的一种形式称为二叉树。左右子节点不同。

满二叉树:二叉树的所有叶子节点都在最后一层,并且结点总数= 2^n -1 , n 为层数

创建二叉树:
思路:

  1. 定义节点类
  2. 定义树

代码实现:

class Node {
    int value;

    Node leftNode;
    Node rightNode;

    public Node(int value) {
        this.value = value;
    }
}
public class Tree {
    private Node root;

    public void setRoot(Node root) {
        this.root = root;
    }  

测试:

 		Tree tree = new Tree();
        Node root = new Node(0);
        tree.setRoot(root);

        Node left = new Node(1);
        Node right = new Node(2);
        root.setLeftNode(left);
        root.setRightNode(right);

        left.setLeftNode(new Node(3));
        left.setRightNode(new Node(4));
        right.setLeftNode(new Node(5));
        right.setRightNode(new Node(6));

遍历二叉树:

  1. 前序遍历
  2. 中序遍历
  3. 后序遍历

图示说明:

在这里插入图片描述思路:
不难发现,不管是哪种遍历,对于每一个节点来说,要做的事情都是一样的。
这就符合递归的思想。

代码实现:

public void frontShow() {	//如果根节点为空,这里少了判空的方式,可在调用处进行判断
        System.out.println(this.value);
        if (this.leftNode != null) {
            leftNode.frontShow();
        }
        if (this.rightNode != null) {
            rightNode.frontShow();
        }
    }

public void midShow() {
        if (this.leftNode != null) {
            leftNode.midShow();
        }
        System.out.println(this.value);
        if (this.rightNode != null) {
            rightNode.midShow();
        }
    }

public void lastShow() {
        if (this.leftNode != null) {
            leftNode.lastShow();
        }
        if (this.rightNode != null) {
            rightNode.lastShow();
        }
        System.out.println(this.value);
    }

注意树的方法应该由节点调用

查找二叉树的节点

需要用到遍历对比每一个节点,所以也有三种查找方式

代码实现:

public Node frontSearch(int i) {	//前序查找
        if (this.value == i) {
            return this;
        } else {
            Node temp = null;
            if (this.leftNode != null) {
                temp = leftNode.frontSearch(i);
            }
            if (temp != null) {
                return temp;
            } else {
                if (this.rightNode != null) {
                    temp = rightNode.frontSearch(i);
                }
            }
            return temp;
        }
    }

删除二叉树的节点

  1. 如果删除的节点是叶子节点,则删除该节点
  2. 如果删除的节点是非叶子节点,则删除该子树

思路:
在这里插入图片描述

代码实现:

    public void del(int i) {//这个方法默认不是删除根节点(已经进行判断后才进入此方法)
        Node parent = this;
        if (parent.leftNode != null && parent.leftNode.value == i) {
            parent.leftNode = null;
            return;
        }
        if (parent.rightNode != null && parent.rightNode.value == i) {
            parent.rightNode = null;
            return;
        }

        parent = leftNode;
        if (parent != null) {
            parent.del(i);
        }
        parent = rightNode;
        if (parent != null) {
            parent.del(i);
        }
    }

顺序存储的二叉树

上述定义节点类的存储方式是链式存储
下面将用数组实现(完全)二叉树。

为什么是完全二叉树呢?

  1. 因为数组的连续性,使得不满足完全二叉树条件的二叉树,逻辑结构与存储结构无法对应。
  2. 完全二叉树:如果该二叉树的所有叶子节点都在最后一层或者倒数第二层,而且最后一层的叶子节点在左边连续,倒数第二层的叶子节点在右边连续,我们称为完全二叉树。(即从上往下、从左往右依次数,只要不连续(有空缺)就不是完全二叉树)

数组模拟完全二叉树的特性:图示
在这里插入图片描述遍历:只给出前序遍历,思路与链式存储的一致。

public class ArarryTree {
    private int[] data;

    public ArarryTree(int[] data) {
        this.data = data;
    }

    public void frontShow() {
        frontShow(0);
    }

    public void frontShow(int index) {
        if (index > data.length || data.length == 0 || data == null) {
            return;
        }
        System.out.println(data[index]);
        if (2 * index + 1 < data.length) {
            frontShow(2 * index + 1);
        }
        if (2 * index + 2 < data.length) {
            frontShow(2 * index + 2);
        }
    }
}

堆排序

堆是具有以下性质的完全二叉树

  1. 每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆(注意 : 没有要求结点的左孩子的值和右孩子的值的大小关系 )
  2. 每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆

堆排序是利用堆这种数据结构而设计的一种选择排序算法,它的最坏,最好,平均时间复 杂度均为 O(nlogn),它也是不稳定排序。

堆排序的基本思想是:(数组实现) 一般升序采用大顶堆,降序采用小顶堆

  1. 将待排序序列构造成一个大顶堆
  2. 此时,整个序列的最大值就是堆顶元素。
  3. 将其与末尾元素进行交换,此时末尾就为最大值。(将最大元素沉到数组最末端
  4. 然后将前面剩余 n-1 个元素重新构造成一个大顶堆。

如此反复执行,便能得到一个有序序列了。(升序)

代码实现:

//构造大顶堆
//参数:待排序列、数组长度、最后一个非叶子节点下标(难点)
public static void maxHeap(int[] arr, int size, int index) {
        int left = 2 * index + 1;	//左子节点
        int right = 2 * index + 2;	//右子节点
        int max = index;	
        //找出父子节点的最大值
        if (left < size && arr[max] < arr[left]) {
            max = left;
        }
        if (right < size && arr[max] < arr[right]) {
            max = right;
        }
        //是否需要交换
        if (max != index) {
            int temp = arr[index];
            arr[index] = arr[max];
            arr[max] = temp;
            maxHeap(arr, size, max);	//难点:因为交换过后,有可能破环了原来的平衡,所以递归将它的子树调整为大顶堆。
        }
    }
//堆排序
public static void HeapSort(int[] arr) {
		//第一次构造大顶堆时需要 从最后一个非叶子节点开始 往前
        int start = (arr.length - 2) / 2;   //(arr.length - 1) / 2 
		//下标出错,但能得到正确答案,原因是:多做了一步无用功。
        for (int i = start; i >= 0; i--) {	//把所有非叶子节点都跑一遍
            maxHeap(arr, arr.length, i);
        }
        //到这里得到的是第一次构造的大顶堆,剩余的构造大顶堆都在下面循环中完成
        //将无序序列构造成大顶堆,和交换后形成的无序序列有差别,因此可以简化剩余的排序
        for (int i = arr.length - 1; i > 0; i--) {
            int temp = arr[0];
            arr[0] = arr[i];
            arr[i] = temp;
            maxHeap(arr,i,0);	//这里传入0:是利用上面子树递归构造大顶堆的功能
        }

    }
//测试
public static void main(String[] args) {
        int[] arr = new int[]{9, 6, 8, 7, 0, 1, 10, 4, 2};
        HeapSort(arr);
        System.out.println(Arrays.toString(arr));
    }

堆排序的重难点:
1、第一趟的排序,剩下趟的排序不同。主要是利用的子树构造大顶堆的便利性
2、第一趟的最后非叶子节点的下标即使出错也能得到正确答案,因为循环的原因:第一步无用,但下一步就是正确的下标,所以只是多了一次循环。

线索二叉树

引出:

  1. 二叉树的节点有左右指针域,但通常不能充分利用(没有指向左右子节点时都为空),造成浪费。
  2. 二叉树不能实现随机访问,不管要找哪个元素都要从头到尾遍历。

概述:

线索二叉树就是将没有左右子节点的节点,令其左子节点指向它的前一个节点(按遍历的顺序),右子节点指向它的后一个节点(同上),使其查找或遍历更加高效。

思路:(中序线索化)

  1. 虽然减少了空的指针域,但引起了歧义:到底是指向遍历时的前一个节点还是左子节点?
  2. 解决:节点类中定义一个 标识符 默认是0时,代表左右子节点;1时代表前后节点。
  3. 按中序遍历的顺序:先找到最左叶子节点,如果当前节点的左子节点为空,则让他指向前驱节点(上个节点);由于遍历过程中会丢失上一个节点,所有需要记录上一个节点(pre);同时处理上个节点的右子节点,空则指向后继节点(当前节点)
  4. 重复上述过程,处理所有的节点。

图示:
在这里插入图片描述
代码实现:

private Node pre;	//定义当前节点的上一个节点,与遍历顺序有关
//将二叉树 中序线索化
void threadNodes(Node node) {	
        if (node == null) {	//递归结束的条件
            return;
        }
        
        threadNodes(node.leftNode);	 //中序线索化先处理左子树
		//然后处理当前节点
		//每次分别处理当前节点的左边域 和 上个节点的右边域(重点)这样其实所有节点的左右都能被处理到
        if (node.leftNode == null) {	
            node.leftNode = pre;
            node.leftType = 1;
        }
        if (pre != null && pre.rightNode == null) {	//当前节点为遍历的第一个元素时,前驱结点为空,会报空指针异常
            pre.rightNode = node;
            pre.rightType = 1;

        }
        pre = node;	 //放在代码的这个位置有讲究!!刚好处理完当前节点,将pre指向当前节点,node指向下一个节点

        threadNodes(node.rightNode);	//最后处理右子树
    }

遍历中序线索化二叉树

思路:

  1. 先找到遍历时的首个元素
  2. 输出首个元素
  3. 依次输出它的后继元素
  4. 如果是右子节点则指向右子节点
  5. 重复3、4,直到右子节点为空循环结束

代码实现:

public void threadIterate(){
        Node node = root;

        while (node != null){	//到最后节点的右子节点为空,循环结束
            while (node.leftType ==0){
                node = node.leftNode;
            }
            //node.leftType ==1 首个元素特征
            System.out.println(node.value);

            while (node.rightType ==1){	 //持续输出后继节点
                node = node.rightNode;
                System.out.println(node.value);
            }
            //node.rightType == 0	表示右子节点
            node = node.rightNode;
        }
    }

赫夫曼树(最优二叉树)

概述:

叶子节点带权路径长度 = 权值 * 到此节点所经过的节点数;
树的带权路径长度(WPL) = 所有叶子节点的带权路径长度之和;
WPL 最小时 称作最优二叉树,也就是我们的赫夫曼树;

图示:
在这里插入图片描述

由上图可知:权值越大的节点 离根节点越近的二叉树 才是最优二叉树

构造赫夫曼树
思路:

  1. 将所有叶子节点按权值升序排序
  2. 取最小权值的两个节点,创建一棵新二叉树
  3. 它们的权值之和作为新树的根节点权值,它们分别作为左右子树;
  4. 将新二叉树的根节点放入剩下的节点集合中
  5. 重复上述过程,直到只剩一棵树,就得到赫夫曼树。

图示:

在这里插入图片描述在这里插入图片描述
代码实现:

public static Node createHaffmanTree(int[] arr){
        //使用数组中的所有元素创建若干二叉树(只有一个根节点)
        ArrayList<Node> nodes = new ArrayList<>();
        for (int val : arr) {
            nodes.add(new Node(val));
        }

        while (nodes.size()>1){
            //排序
            Collections.sort(nodes);	
            //取出权值最小的两棵二叉树
            Node left = nodes.get(0);
            Node right = nodes.get(1);
            //创建一颗新的二叉树(根节点的权值为上面两棵二叉树的权值之和)
            Node parent = new Node(left.value + right.value);
            parent.leftNode = left;
            parent.rightNode = right;
            //移除取出的两棵二叉树
            nodes.remove(left);
            nodes.remove(right);
            //加入新创建的二叉树到集合中
            nodes.add(parent);
        }
        return nodes.get(0);
    }
赫夫曼编码

引出:

  1. 定长编码:每个字符都用八位(一个字节表示)
  2. 非定长编码:统计每个字符出现的次数,出现次数多的字符用位数少的表示;次数少的字符用位数多的表示。
  3. 前缀编码:非定长编码有可能引起歧义。每个字符编码都不能是其他字符编码的前缀,满足条件称作前缀编码。
  4. 赫夫曼编码就是一种前缀编码。

举例说明:

在这里插入图片描述赫夫曼编码:
思路:

  • 统计字符次数
  • 以次数为权值构建赫夫曼树(字符也存入节点中)
  • 到这些节点的路径(唯一)作为该字符的编码,构造编码表。
  • 将原来的字符重新根据编码表 编码(压缩),得到经过压缩的数据。

图示:
在这里插入图片描述代码实现:

//准备工作
public class Node implements Comparable<Node> {
    int weight;
    Byte data;

    Node leftNode;
    Node rightNode;

    public Node(Byte data,int weight) {
        this.data = data;
        this.weight = weight;
    }

    @Override
    public int compareTo(Node o) {
        return this.weight-o.weight;
    }

    @Override
    public String toString() {...}
}

主函数:赫夫曼编码压缩

public static void main(String[] args) {
        String str = "can you can a can as a can canner can a can.";
        byte[] bytes = str.getBytes();	//得到字符的定长编码数组;
        byte[] b = HuffmanZip(bytes);	//得到经过压缩后的字符赫夫曼编码数组
    }

压缩过程:

     private static byte[] HuffmanZip(byte[] bytes) {
        //统计字符出现的次数,并创建出节点集
        List<Node> nodes = getNodes(bytes);		//1、
        //构造赫夫曼树
        Node tree = createHuffmanTree(nodes);	//2、
        //构造赫夫曼编码表
        Map<Byte, String> huffmanCodes = createHuffmanCodes(tree);	//3、
        //根据编码表,重新编码,进行压缩
        byte[] b = zip(bytes, huffmanCodes);	//4、
        return b;
    }

1、bytes 数组中每个元素都代表一个字符,统计这些字符出现的次数、以字符值、与出现次数创建节点,返回只有根节点的二叉树集合。

private static List<Node> getNodes(byte[] bytes) {
		//以字符数值为键,出现次数为值。
        Map<Byte, Integer> counts = new HashMap<>();
        for (byte b : bytes) {
            Integer count = counts.get(b);
            if (count == null) {
                counts.put(b, 1);
            } else {
                counts.put(b, count + 1);
            }
        }
        List<Node> nodes = new ArrayList<>();
        //遍历Map,将每个键值对创建成节点对象
        for (Map.Entry<Byte, Integer> entry : counts.entrySet()) {
            nodes.add(new Node(entry.getKey(), entry.getValue()));
        }
        return nodes;
    }

2、将二叉树集合(一个个的节点)构造成赫夫曼树。

    private static Node createHuffmanTree(List<Node> nodes) {
        while (nodes.size() > 1) {
            //排序
            Collections.sort(nodes);
            //取出权值最小的两棵二叉树
            Node left = nodes.get(0);
            Node right = nodes.get(1);
            //创建一颗新的二叉树(根节点的权值为上面两棵二叉树的权值之和)
            Node parent = new Node(null, left.weight + right.weight);
            parent.leftNode = left;
            parent.rightNode = right;
            //移除取出的两棵二叉树
            nodes.remove(left);
            nodes.remove(right);
            //加入新创建的二叉树到集合中
            nodes.add(parent);
        }
        return nodes.get(0);
    }

3、递归遍历赫夫曼树,用Map将每个叶子节点的字符数值和路径作为键值对,Map就是赫夫曼编码表

    //临时存储路径
    static StringBuilder sb = new StringBuilder();
    //存储编码表
    static Map<Byte, String> map = new HashMap<>();

    private static Map<Byte, String> createHuffmanCodes(Node tree) {
        if (tree == null) {
            return null;
        }
        createHuffmanCodes(tree.leftNode, sb, "0");
        createHuffmanCodes(tree.rightNode, sb, "1");
        return map;
    }

    private static void createHuffmanCodes(Node node, StringBuilder sb, String str) {
        StringBuilder sb2 = new StringBuilder(sb);	
        //这里容易出错,递归中不能只用一个全局变量,因为它不能及时清理掉上个节点的数据;
        sb2.append(str);
        if (node.data == null) {	//没到叶子节点就继续递归
            createHuffmanCodes(node.leftNode, sb2, "0");
            createHuffmanCodes(node.rightNode, sb2, "1");
        } else {
        	//说明到达叶子节点,将其存储进map
            map.put(node.data, sb2.toString());
        }
    }

4、拿到编码表后,就对原来的字符重新编码(原来八位一个字符,现在可能只用2位表示)这样就是压缩了

private static byte[] zip(byte[] bytes, Map<Byte, String> huffmanCodes) {
		//临时存放压缩后的二进制字符串
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) {
            sb.append(huffmanCodes.get(b));
        }
        
		//构造一个压缩后的byte数组,用于返回
        int len;	//数组的长度
        if (sb.length() % 8 == 0) {
            len = sb.length() / 8;
        } else {
            len = sb.length() / 8 + 1;
        }
        byte[] by = new byte[len];
        
		//将压缩后的二进制字符串,重新以每八位划分,放入byte数组
		int index = 0;
        for (int i = 0; i < sb.length(); i += 8) {
            String s;
            if (i + 8 > sb.length()) {
                s = sb.substring(i);
            } else {
                s = sb.substring(i, i + 8);
            }
            byte num =(byte) Integer.parseInt(s, 2);	//八位二进制转换为byte十进制
            by[index]= num;
            index++;
        }
        return by;
    }

最后:长度44的数据经过压缩后只剩下16

    public static void main(String[] args) {
        String str = "can you can a can as a can canner can a can.";
        byte[] bytes = str.getBytes();
        byte[] b = HuffmanZip(bytes);

        System.out.println(bytes.length);   //44
        System.out.println(b.length);       //16
    }

二叉排序树

二叉排序树又称 二叉查找树、二叉搜索树。

条件:
任意一个节点的左子节点小于此节点,右子节点大于此节点。

创建:
调用树的add的方法

public void add(Node node){
        if (root == null){
            root = node;
        }else {
            root.add(node);
        }
    }

调用节点的add方法

public void add(Node node) {
        if (node == null){
            return;
        }
        if (node.val<this.val){
            if (this.left==null){
                this.left = node;
            }else {
                this.left.add(node);
            }
        }else {
            if (this.right==null){
                this.right = node;
            }else {
                this.right.add(node);
            }
        }
    }

中序遍历:

    public void midShow(Node node) {
        if (node ==null){
            return;
        }
        midShow(node.left);
        System.out.println(node.val);
        midShow(node.right);
    }

查找:

//class BinarySortTree
public Node search(int i) {
        if (root==null){
            return null;
        }else {
           return root.search(i);
        }
    }

//class Node
 public Node search(int i) {
        if (this.val == i){
            return this;
        }else if (i <this.val){
            if (this.left != null){
                return this.left.search(i);
            }else
                return null;
        }else {
            if (this.right != null){
                return this.right.search(i);
            }else
                return null;
        }
    }

删除:
删除分为三种情况:

  1. 删除叶子节点。
  2. 删除只有一个子节点的节点。
  3. 删除有两个子节点的节点。

思路:(不管哪种情况,都需要拿到要删除节点的父节点)
第一种:

直接删除即可,将其父节点的左或右置为空。

第二种:

将父节点的指向改为要删除节点的子节点。

第三种:

要在该节点的右子树中找到一个最小值,将其摘下,替换掉要删除的节点即可。
最小值也可能有两种情况:
一是它本身是叶子节点;二是它还有一个右子节点(可参考第二种情况);

发布了33 篇原创文章 · 获赞 2 · 访问量 968

猜你喜欢

转载自blog.csdn.net/Rhin0cer0s/article/details/100982342