Java数据结构与算法(树的入门)

一、树的基本概念


树(Tree)是n(n>=0)个结点的有限集。当n=0时成为空树,在任意一棵非空树中:

  • 有且仅有一个特定的称为根(Root)的结点;
  • 当n>1时,其余结点可分为m(m>0)个互不相交的有限集T1、T2、…、Tm,其中每一个集合本身又是一棵树,并且称为根的子树(SubTree)。

在这里插入图片描述
注意:

  • n>0时,根结点是唯一的,坚决不可能存在多个根结点。
  • m>0时,子树的个数是没有限制的,但它们互相是一定不会相交的。
    比如,下图中的就不符合树的定义:
    在这里插入图片描述
    树中结点的分类:
    在这里插入图片描述

如图中所示,每一个圈圈我们就称为树的一个结点。结点拥有的子树数称为结点的度-(Degree),树的度取树内各结点的度的最大值。

  • 度为0的结点称为叶结点(Leaf)或终端结点;
  • 度不为0的结点称为分支结点或非终端结点,除根结点外,分支结点也称为内部结点。

树中结点之间的关系:
在这里插入图片描述

  • 结点的子树的根称为结点的孩子(Child),相应的,该结点称为孩子的双亲(Parent),同一双亲的孩子之间互称为兄弟(Sibling)。
  • 结点的祖先是从根到该结点所经分支上的所有结点。

结点的层次:
在这里插入图片描述

  • 结点的层次(Level)从根开始,根为第一层,根的孩子为第二层。
  • 其双亲在同一层的结点互为堂兄弟。
  • 树中结点的最大层次称为树的深度(Depth)或高度。

二 、树的存储结构

要存储树,简单的顺序存储结构和链式存储结构是不行的!不过如果充分利用它们各自的特点,结合两种存储结构完全可以间接地来实现树的存储。

双亲表示法

  • 双亲表示法,言外之意就是以双亲作为索引的关键词的一种存储方式。
  • 我们假设以一组连续空间存储树的结点,同时在每个结点中,附设一个指示其双亲结点在数组中位置的元素。
  • 也就是说,每个结点除了知道自己是谁之外,还知道它的粑粑麻麻在哪里。
  • 根结点没有双亲结点,其Parent用 -1 表示。
    在这里插入图片描述

这样的存储结构,我们可以根据某结点的parent指针找到它的双亲结点,所用的时间复杂度是O(1),索引到parent的值为-1时,表示找到了树结点的根。
可是,如果我们要知道某结点的孩子是什么?那么不好意思,请遍历整个树结构。那么我们是不是可以一起改进一下呢?
在这里插入图片描述

那现在我们又比较关心它们兄弟之间的关系呢?当然也是可以的:
在这里插入图片描述

但是我们可以发现,这张表格中存在大量的 -1,也就是说这样的存储方式浪费了大量的存储空间,这并不是我们所期望看到的,因此,我们来看一种更高效的树的存储结构。

孩子表示法
将树中的每个结点的孩子结点排列成一个线性表,用链表存储起来。对于含有 n 个结点的树来说,就会有 n 个单链表,将 n 个单链表的头指针存储在一个线性表中,这样的表示方法就是孩子表示法。
在这里插入图片描述

是不是瞬间感觉清爽多了,但是孩子表示法正好与双亲表示法相反,适用于查找某结点的孩子结点,不适用于查找其父结点。可以将两种表示方法合二为一,存储效果如下所示:
在这里插入图片描述

孩子兄弟表示法
使用链式存储结构存储普通树。链表中每个结点由 3 部分组成:
在这里插入图片描述

通过孩子兄弟表示法,普通树转化为了二叉树,所以孩子兄弟表示法又被称为“二叉树表示法”或者“二叉链表表示法”。如下图所示:
在这里插入图片描述

三、二叉树

二叉树(Binary Tree)是n(n>=0)个结点的有限集合,该集合或者为空集(空二叉树),或者由一个根结点和两棵互不相交的、分别称为根结点的左子树和右子树的二叉树组成。
在这里插入图片描述

二叉树的特点

  • 每个结点最多有两棵子树,所以二叉树中不存在度大于2的结点。(注意:不是都需要两棵子树,而是最多可以有两棵,没有子树或者有一棵子树也都是可以的。)
  • 左子树和右子树是有顺序的,次序不能颠倒。
  • 即使树中某结点只有一棵子树,也要区分它是左子树还是右子树,下面是完全不同的二叉树:
    在这里插入图片描述

二叉树的五种基本形态
在这里插入图片描述

若只从形态上来考虑,拥有三个结点的普通树只有两种情况:两层或者三层。而对于二叉树来说,由于要区分左右,所以就演变成五种形态(这也是为什么考试面试笔试总选择二叉树进行考察):
在这里插入图片描述

二叉树还可以继续分类,衍生出满二叉树和完全二叉树。

满二叉树
如果二叉树中除了叶子结点,每个结点的度都为 2,则此二叉树称为满二叉树。
在这里插入图片描述

满二叉树除了满足普通二叉树的性质,还具有以下性质(请注意图中的标注):

  1. 满二叉树中第 n 层的节点数为 2 n − 1 2^{n-1} 2n1 个。
  2. 深度为 k 的满二叉树必有 2 k − 1 2^{k}-1 2k1 个节点 ,叶子数为 2 k − 1 2^{k-1} 2k1
  3. 满二叉树中不存在度为 1 的节点,每一个分支点中都两棵深度相同的子树,且叶子节点都在最底层。
  4. 具有 n 个节点的满二叉树的深度为 l o g 2 ( n + 1 ) log_2(n+1) log2(n+1)

完全二叉树
如果二叉树中除去最后一层节点为满二叉树,且最后一层的结点依次从左到右分布,则此二叉树被称为完全二叉树。
在这里插入图片描述

完全二叉树除了具有普通二叉树的性质,它自身也具有一些独特的性质,比如说,n 个结点的完全二叉树的深度为 ⌊ l o g 2 n log_2n log2n⌋+1(后面有证明)。
l o g 2 n log_2{n} log2n⌋ 表示取小于 l o g 2 n log_2{n} log2n 的最大整数,向下取整。例如,⌊ l o g 2 8 log_2{8} log28⌋ = 3,而 ⌊ l o g 2 10 log_2{10} log210⌋ 结果也是 3。
在这里插入图片描述

对于任意一个完全二叉树来说,如果将含有的结点按照层次从左到右依次标号(如图上图)),对于任意一个结点 i ,完全二叉树还有以下几个结论成立:

  1. 当 i>1 时,父亲结点为结点 [i/2] 。(i=1 时,表示的是根结点,无父亲结点)
  2. 如果 2i>n(总结点的个数) ,则结点 i 肯定没有左孩子(为叶子结点);否则其左孩子是结点 2i 。
  3. 如果 2i+1>n ,则结点 i i i 肯定没有右孩子;否则右孩子是结点 2i+1 。

二叉树的性质
在这里插入图片描述

  • 二叉树的性质一:在二叉树的第i层上至多有 2 i − 1 2^{i-1} 2i1个结点(i>=1)
    这个性质其实很好记忆,考试的时候懂得画出二叉树的图便可以推出

  • 二叉树的性质二:深度为k的二叉树至多有 2 k − 1 2^k-1 2k1个结点(k>=1)

  • 二叉树的性质三:对任何一棵二叉树T,如果其终端结点数为 n 0 n_0 n0,度为2的结点数为 n 2 n_2 n2,则 n 0 n_0 n0= n 2 n_2 n2+1
    在这里插入图片描述

  • 二叉树的性质四:具有n个结点的完全二叉树的深度为⌊log₂n⌋+1

二叉查找树的创建:

  • 二叉树的结点类:
private class Node<Key,Value>{
     //存储键
     public Key key;
     //存储值
     private Value value;
     //记录左子结点
     public Node left;
     //记录右子结点
     public Node right;
 
     public Node(Key key, Value value, Node left, Node right) {
         this.key = key;
         this.value = value;
         this.left = left;
         this.right = right;
     }
 }
  • 二叉查找树实现:
public class BinaryTree<Key extends Comparable<Key>, Value> {
    //记录根结点
    private Node root;
    //记录树中元素的个数
    private int N;

    private class Node {
        //存储键
        public Key key;
        //存储值
        private Value value;
        //记录左子结点
        public Node left;
        //记录右子结点
        public Node right;

        public Node(Key key, Value value, Node left, Node right) {
            this.key = key;
            this.value = value;
            this.left = left;
            this.right = right;
        }
    }

    //获取树中元素的个数
    public int size() {
        return N;
    }

    //向树中添加元素key-value
    public void put(Key key, Value value) {
        root = put(root, key, value);
    }

    //向指定的树x中添加key-value,并返回添加元素后新的树
    private Node put(Node x, Key key, Value value) {
        //如果x子树为空,
        if (x==null){
            N++;
            return new Node(key,value, null,null);
        }

        //如果x子树不为空
        //比较x结点的键和key的大小:

        int cmp = key.compareTo(x.key);
        if (cmp>0){
            //如果key大于x结点的键,则继续找x结点的右子树
            x.right = put(x.right,key,value);

        }else if(cmp<0){
            //如果key小于x结点的键,则继续找x结点的左子树
            x.left = put(x.left,key,value);
        }else{
            //如果key等于x结点的键,则替换x结点的值为value即可
            x.value = value;
        }
        return x;
    }

    //查询树中指定key对应的value
    public Value get(Key key) {
        return get(root,key);
    }

    //从指定的树x中,查找key对应的值
    public Value get(Node x, Key key) {
        //x树为null
        if (x==null){
            return null;
        }

        //x树不为null

        //比较key和x结点的键的大小
        int cmp = key.compareTo(x.key);
        if (cmp>0){
            //如果key大于x结点的键,则继续找x结点的右子树
            return get(x.right,key);

        }else if(cmp<0){
            //如果key小于x结点的键,则继续找x结点的左子树
            return get(x.left,key);
        }else{
            //如果key等于x结点的键,就找到了键为key的结点,只需要返回x结点的值即可
            return x.value;
        }

    }


    //删除树中key对应的value
    public void delete(Key key) {
        delete(root, key);
    }

    //删除指定树x中的key对应的value,并返回删除后的新树
    public Node delete(Node x, Key key) {
        //x树为null
        if (x==null){
            return null;
        }

        //x树不为null
        int cmp = key.compareTo(x.key);
        if (cmp>0){
            //如果key大于x结点的键,则继续找x结点的右子树
            x.right = delete(x.right,key);

        }else if(cmp<0){
            //如果key小于x结点的键,则继续找x结点的左子树
            x.left = delete(x.left,key);
        }else{
            //如果key等于x结点的键,完成真正的删除结点动作,要删除的结点就是x;

            //让元素个数-1
            N--;
            //得找到右子树中最小的结点
            if (x.right==null){
                return x.left;
            }

            if (x.left==null){
                return x.right;
            }

            Node minNode = x.right;
            while(minNode.left!=null){
                minNode = minNode.left;
            }

            //删除右子树中最小的结点
            Node n = x.right;
            while(n.left!=null){
                if (n.left.left==null){
                    n.left = n.left.right;
                    break;
                }else{
                    //变换n结点即可
                    n = n.left;
                }
            }

            //让x结点的左子树成为minNode的左子树
            minNode.left = x.left;

            //让x结点的右子树成为minNode的右子树
            if (minNode == x.right) {
                minNode.right = x.right.right;
            }else {
                minNode.right = x.right;
            }
            //假如删除的是根节点,这个方法首先会传入root节点,然后令x=root,所以对x进行的操作只能是对指向root的对象属性的操作。
            //如果只是单纯的x=minNode,这只能修改根节点之外的其他的节点,因为x只是一个引用,让x=minNode,这只是修改了赋给x的地址
            //而没有修改指向root的地址,因为遍历是从root节点开始的,所以这会导致遍历出现问题
            if (x==root) {
                root = minNode;
            }else {
                x = minNode;
            }
        }
        return x;
    }
}

查找二叉树中最小的键

在某些情况下,我们需要查找出树中存储所有元素的键的最小值,比如我们的树中存储的是学生的排名和姓名数据,那么需要查找出排名最低是多少名?这里我们设计如下两个方法来完成:

//查找整个树中最小的键
    public Key min(){
        return min(root).key;
    }

    //在指定树x中找出最小键所在的结点
    private Node min(Node x){

        //需要判断x还有没有左子结点,如果有,则继续向左找,如果没有,则x就是最小键所在的结点
        if (x.left!=null){
            return min(x.left);
        }else{
            return x;
        }
    }

查找二叉树中最大的键
在某些情况下,我们需要查找出树中存储所有元素的键的最大值,比如比如我们的树中存储的是学生的成绩和学生的姓名,那么需要查找出最高的分数是多少?这里我们同样设计两个方法来完成:

//在整个树中找到最大的键
    public Key max(){
        return max(root).key;
    }

    //在指定的树x中,找到最大的键所在的结点
    public Node max(Node x){
        //判断x还有没有右子结点,如果有,则继续向右查找,如果没有,则x就是最大键所在的结点
        if (x.right!=null){
            return max(x.right);
        }else{
            return x;
        }
    }

二叉树的基础遍历
很多情况下,我们可能需要像遍历数组数组一样,遍历树,从而拿出树中存储的每一个元素,由于树状结构和线性结构不一样,它没有办法从头开始依次向后遍历,所以存在如何遍历,也就是按照什么样的搜索路径进行遍历的问题。

  1. 前序遍历;
    先访问根结点,然后再访问左子树,最后访问右子树
//使用前序遍历获取树中所有的键
    public Queue<Key> preErgodic(){
        Queue<Key> keys = new Queue<Key>();
        preErgodic(root, keys);
        return keys;
    }

    //使用前序遍历,获取指定树x中所有的键,并存放到key中
    private void preErgodic(Node x,Queue<Key> keys){
        if (x==null){
            return;
        }

        //把x结点的key放入到keys中
        keys.enqueue(x.key);

        //递归遍历x结点的左子树
        if (x.left!=null){
            preErgodic(x.left,keys);
        }

        //递归遍历x结点的右子树
        if (x.right!=null){
            preErgodic(x.right,keys);
        }

    }
  1. 中序遍历;
    先访问左子树,中间访问根节点,最后访问右子树
 //使用中序遍历获取树中所有的键
    public Queue<Key> midErgodic(){
        Queue<Key> keys = new Queue<Key>();
        midErgodic(root,keys);
        return keys;
    }

    //使用中序遍历,获取指定树x中所有的键,并存放到key中
    private void midErgodic(Node x,Queue<Key> keys){
        if (x==null){
            return;
        }
        //先递归,把左子树中的键放到keys中
        if (x.left!=null){
            midErgodic(x.left,keys);
        }
        //把当前结点x的键放到keys中
        keys.enqueue(x.key);
        //在递归,把右子树中的键放到keys中
        if(x.right!=null){
            midErgodic(x.right,keys);
        }

    }
  1. 后序遍历;
    先访问左子树,再访问右子树,最后访问根节点
//使用后序遍历,把整个树中所有的键返回
    public Queue<Key> afterErgodic(){
        Queue<Key> keys = new Queue<Key>();
        afterErgodic(root,keys);
        return keys;
    }

    //使用后序遍历,把指定树x中所有的键放入到keys中
    private void afterErgodic(Node x,Queue<Key> keys){
        if (x==null){
            return ;
        }

        //通过递归把左子树中所有的键放入到keys中
        if (x.left!=null){
            afterErgodic(x.left,keys);
        }
        //通过递归把右子树中所有的键放入到keys中
        if (x.right!=null){
            afterErgodic(x.right,keys);
        }
        //把x结点的键放入到keys中
        keys.enqueue(x.key);
    }

二叉树的层序遍历
所谓的层序遍历,就是从根节点(第一层)开始,依次向下,获取每一层所有结点的

//使用层序遍历,获取整个树中所有的键
    public Queue<Key> layerErgodic(){
        //定义两个队列,分别存储树中的键和树中的结点
        Queue<Key> keys = new Queue<Key>();
        Queue<Node> nodes = new Queue<Node>();

        //默认,往队列中放入根结点
        nodes.enqueue(root);

        while(!nodes.isEmpty()){
            //从队列中弹出一个结点,把key放入到keys中
            Node n = nodes.dequeue();
            keys.enqueue(n.key);
            //判断当前结点还有没有左子结点,如果有,则放入到nodes中
            if (n.left!=null){
                nodes.enqueue(n.left);
            }
            //判断当前结点还有没有右子结点,如果有,则放入到nodes中
            if (n.right!=null){
                nodes.enqueue(n.right);
            }
        }
        return keys;
    }

二叉树的最大深度问题

需求:
给定一棵树,请计算树的最大深度(树的根节点到最远叶子结点的最长路径上的结点数)

//获取整个树的最大深度
    public int maxDepth(){
        return maxDepth(root);
    }


    //获取指定树x的最大深度
    private int maxDepth(Node x){
        if (x==null){
            return 0;
        }
        //x的最大深度
        int max=0;
        //左子树的最大深度
        int maxL=0;
        //右子树的最大深度
        int maxR=0;

        //计算x结点左子树的最大深度
        if (x.left!=null){
            maxL = maxDepth(x.left);
        }
        //计算x结点右子树的最大深度
        if (x.right!=null){
            maxR = maxDepth(x.right);
        }
        //比较左子树最大深度和右子树最大深度,取较大值+1即可

        max = maxL>maxR?maxL+1:maxR+1;

        return max;
    }

折纸问题

需求:
请把一段纸条竖着放在桌子上,然后从纸条的下边向上方对折1次,压出折痕后展开。此时 折痕是凹下去的,即折痕突起的方向指向纸条的背面。如果从纸条的下边向上方连续对折2 次,压出折痕后展开,此时有三条折痕,从上到下依次是下折痕、下折痕和上折痕。

给定一 个输入参数N,代表纸条都从下边向上方连续对折N次,请从上到下打印所有折痕的方向 例如:N=1时,打印: down;N=2时,打印: down down up

分析:
我们把对折后的纸张翻过来,这时把第一次对折产生的折痕看做是根结点,那第二次对折产生的下折
痕就是该结点的左子结点,而第二次对折产生的上折痕就是该结点的右子结点,这样我们就可以使用树型数据结构来描述对折后产生的折痕。

这棵树有这样的特点:

  1. 根结点为下折痕;
  2. 每一个结点的左子结点为下折痕;
  3. 每一个结点的右子结点为上折痕;
public class PagerFoldingTest {

    public static void main(String[] args) {

        //模拟这只过程,产生树
        Node<String> tree = createTree(2);
        //遍历树,打印每个结点
        printTree(tree);

    }

    //通过模拟对折N次纸,产生树
    public static Node<String> createTree(int N){
        //定义根结点
        Node<String> root=null;
        for (int i = 0; i < N; i++) {

            //1.当前是第一次对折
            if (i==0){
                root = new Node<>("down",null,null);
                continue;
            }
            //2.当前不是第一次对折
            //定义一个辅助队列,通过层序遍历的思想,找到叶子结点,叶子结点添加子节点
            Queue<Node> queue = new Queue<>();
            queue.enqueue(root);

            //循环遍历队列
            while(!queue.isEmpty()){
                //从队列中弹出一个结点
                Node<String> tmp = queue.dequeue();
                //如果有左子结点,则把左子结点放入到队列中
                if (tmp.left!=null){
                    queue.enqueue(tmp.left);
                }
                //如果有右子结点,则把右子结点放入到队列中
                if (tmp.right!=null){
                    queue.enqueue(tmp.right);
                }
                //如果同时没有左子结点和右子结点,那么证明该节点是叶子结点,只需要给该节点添加左子结点和右子结点即可
                if (tmp.left==null && tmp.right==null){
                    tmp.left = new Node<String>("down", null,null);
                    tmp.right = new Node<String>("up",null,null);
                }
            }
        }
        
        return root;
    }


    //打印树中每个结点到控制台
    public static void printTree(Node<String> root){
        //需要使用中序遍历完成
        if (root==null){
            return;
        }

        //打印左子树的每个结点
        if (root.left!=null){
            printTree(root.left);
        }
        //打印当前结点
        System.out.print(root.item+" ");
        //打印右子树的每个结点
        if (root.right!=null){
            printTree(root.right);
        }

    }



    //结点类
    private static class Node<T>{
        public T item;//存储元素
        public Node left;
        public Node right;

        public Node(T item, Node left, Node right) {
            this.item = item;
            this.left = left;
            this.right = right;
        }
    }

}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

陌上人如玉এ

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值