数据结构(下)

127 阅读58分钟

在学习二叉树之前,我们先来回顾下我们之前学习的线性表结构,在那个章节我们学习了数组与链表,这两种是数据结构

数组数据结构查询快,然而增删慢,链表则与之相反

而如果配合我们的二叉树结构的话,比方说我们用链表构建二叉树结构,那么我们的二叉树天生就具有增删快的特点,再结合二叉树结构,我们还能进一步提升其查询效率,这对于数组来说也是一样的,使用数组构建二叉树,能够在查询快的同时进一步提高其增删效率

二叉树的定义

那么接下来我们正式来学习下二叉树数据结构,首先我们来看看二叉树的定义

首先我们的二叉树数据结构可以想象成一个倒挂的树这样的结构,其特点有四,一是每个结点都有至少0个子节点,二是没有父节点的结点为根结点,如上图中的结点A,三是每一个非根结点都有父节点,最后是每个结点及其后代整体都可以看成是一个树,称之为当前结点的父节点的一个子树,比如对于A而言,F是其一个子树,而对于F而言,K,L,M都是其子树

树的相关术语

在学习二叉树数据结构之前,我们要先学习树的相关术语,便于我们后续的对数据结构的学习

首先是结点的度,比方说对于之前的图而言,A结点的度为6,J结点的度为2,P结点的度为0

其次是叶结点,度为0的结点被称为叶结点或终端节点,比方说P,Q两结点都是叶结点

然后是结点的层次,从根结点开始,层次往下依次递增,比方说A的层次为1,那么B,C,D,E,F,G的层次就为2,H,I,J,K,L,M,N的层次就为3,P,Q的层次为4

接着是结点的层次编号,从树中结点按照上下层从左到右排成一个线性序列,编成连续的自然数。比方说A的层序编号为1,那么B,C,D,E,F,G的层序编号为2,3,4,5,6,7依次类推

再来是树的度,就是树中所有结点的度的最大值,比方说对于之前的图而言,结点的度的最大值为6,那么树的度就为6

最后是树的高度,就是树中结点的最大层次,比方说对于之前的图,最大层次是4,那么树的高度就是4

接着我们来学习森林的定义

将一棵树的根结点去除之后,树就变成了森林,反之加上统一的根节点之后,森林就成了树

孩子结点指的是一个结点的直接后继结点,比如对于上图的E而言,I,J就是其孩子结点

双亲结点就是父节点,一般我们就叫父节点,指的是一个结点的直接前驱,比方说对于B,C,D,E而言,其父节点是A结点

兄弟结点指的是同一双亲的孩子结点间相互成为兄弟结点,比方说K是M的兄弟结点,L也是K的兄弟结点

二叉树的基本定义

二叉树,顾名思义,它应该跟二很有关系,结果也是这样的,二叉树其实就是度不超过2的树

接着我们来讲两个特殊的树,这两个树分别是满二叉树和完全二叉树

满二叉树

一个二叉树如果每一个层的结点树到达到最大值,那么就是满二叉树。由于二叉树度数最多为2,因此满二叉树每一层的结点数量为2^(k-1),其中k为树的层次

完全二叉树

一个二叉树其叶结点只出现在最下层和次下层,并且最下面异常的结点都集中在该层最左边的的若干位置,那么该树就是完全二叉树

比方说对于上图而言,如果L,F,G都去除了,那么就不是完全二叉树了。因此C作为叶结点出现在了非次外层和非最外层

二叉查找树的创建

同样的,之所以叫二叉查找树,这是因为我们的所创建的二叉树是要查找对应元素的,先来看看其二叉树所使用的结点的API设计

结点类

根据API设计我们可以构造其结点类如下

//二叉树的结点类
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;
    }

二叉树的结点与符号表的类似,都是键值对的方式存储数据,且其有两个指针域,分别用于指向左子树和右子树

接着我们来看看二叉查找树的API设计

由于要实现查找,所以当然我们的泛型Key要继承Comparable接口并再次限定存储的元素类型为我们之前所定义的泛型Key

我们先来看看我们实现put方法的思路

接下来看看其图示演示

再来看看我们实现get方法的思路

其实跟上面的方法就突出一个大同小异,不多谈

然后我们来看看实现delete方法的思路

然后我们来看看其图示演示

其原理是先找到被删除元素,然后再找到被删除元素的右子树的最小元素,其最小元素就是被删除结点的右子树的最左子树的结点,让该最小元素代替被删除元素的位置就完了

那么根据API设计以及刚刚原理的演示,我们可以实现其方法如下

package algorithm.sort;

//令key继承Comparable接口,给Key所代表的元素提供比较方式,便于实现排序
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就可以完成向树中添加元素,
     * 该方法内部会调用重载的put方法并传入树的根节点,要添加的key和
     * value,并最终将这个值返回给原先的树的根结点root,最后达成修
     * 改树的效果。树的根结点是最能够代表整个树的结点
     */
    //向树中添加元素key-value
    public void put(Key key,Value value) {
        root = put(root,key,value);
    }

    /*
     * 调用该方法需要传入树,要添加的key和value,我们在这个方法里先进行
     * 传入的树是否为null的判断,若是就直接产生一个新结点并返回就可以了。
     * 若不是则需要比较传入的值与树中各结点的key值的大小,若传入元素大于
     * 结点元素则往右子树上前进,反之则往左子树上前进,若相等则覆盖结点。
     * 这里我们往左右子树上前进的方式是采用了递归的方式的,比方说我们的键
     * 大于结点,那么我们就传入该右子树和key以及value递归调用put方法,并
     * 将该右子树的修改结果返回赋给原先的结点的右子树
     */
    //向指定的树x添加key-value并返回添加元素后的新的树
    private Node put(Node x,Key key,Value value) {
        //如果x子树为空
        if(x==null){
            N++;//元素个数+1
            //返回一个新结点,该结点没有任何子树,因此左右子树都没有指向
            return new Node(key,value,null,null);
        }
        //代码执行到此说明子树不为空

        //定义比较的结果并赋值给cmp
        int cmp = key.compareTo(x.key);
        //比较x结点的键与传入的key的大小
        if(cmp>0){
            /*
             * 如果key大于x结点的key,则往x结点的右子树上前进
             * 递归调用本方法,并且传入右子树的结点进入,且返回该结点的结果给原先结点的右子树
             * 之所以要把结果返回给x.right,是因为返回这个动作本身就带有赋值,指向的意义
             * 通过这个代码可以将新创建的结点正确地被其父节点指向。如果把不添加返回代码,那么
             * 到时候就会出现创建了对应的结点然而没有正确指向的情况。如果把x.right改为return
             * 那么最后的情况就是会正确创建对应结点并返回该结点,但是该结点却没有被正确指向
             */
            x.right = put(x.right,key,value);
        }else if(cmp<0){
            //如果key小于x结点的key,则往x结点的左子树上前进
            x.left = put(x.left,key,value);
        }else {
            //如果相等则覆盖value的值
            x.value=value;
        }
        //返回添加成功添加元素后的树
        return x;
    }

    /*
     * 该方法用于查找树中对于的key值的结点的value元素
     * 调用该方法只要传入一个key,会自动查找并返回value
     * 实际上该方法会调用重载的get方法,传入树和需要查找的key
     */
    //查询树中指定key对应的value
    public Value get(Key key){
        return get(root,key);
    }

    /*
     * 调用该方法需要传入树和key,先判断树是否为空,若为空则返回null
     * 代表在树中找不到对应元素的意义。若不为空同样判断key与结点中key
     * 的值,若传入key大于结点key则往右子树前进,反之则往左子树前进,
     * 若相等说明找到了,直接返回对应结点的value值就行了
     */
    //从指定的数x中,查找key对应的值
    public Value get(Node x,Key key) {
        //x所代表的树的结点为null
        if(x==null){
            return null;
        }

        //代码执行到此说明不为null,进行大小比较
        //定义比较的结果并赋值给cmp
        int cmp = key.compareTo(x.key);
        if(cmp>0){
            /*
             * 如果key大于x结点的key,则往x结点的右子树上前进,这里用return的原因
             * 是在于我们需要返回对应结点的值,因此我们需要使用return,因为我们不需
             * 指向,所以没必要跟上一个方法一样构造指向代码
             */
            return get(x.right,key);
        }else if(cmp<0){
            //如果key小于x结点的key,则往x结点的左子树上前进
            return get(x.left,key);
        }else {
            //如果相等说明找到了,直接返回x结点的值
            return x.value;
        }
    }

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

    //删除指定树中的key对应的value,并返回删除后的新树
    public Node delete(Node x,Key key) {
        //如果其定位结点为null,说明找不到要删除的目标结点,那就直接返回null
        if(x==null){
            return null;
        }

        //代码执行到此说明结点不为空
        //定义比较的结果并赋值给cmp
        int cmp = key.compareTo(x.key);
        if(cmp>0){
            /*
             * 如果key大于x结点的key,则往x结点的右子树上前进,这里之所以构建了指向
             * 代码,是因为当我们将被删除元素的位置用另外一个次小的元素替换时,需要令
             * 其被父节点指向,为了实现这个动作,因此这里设置了指向代码
             */
            x.right = delete(x.right,key);
        }else if(cmp<0){
            //如果key小于x结点的key,则往x结点的左子树上前进
            x.left = delete(x.left,key);
        }else {
            //令元素个数-1,放置于此能让所有情况都实现N--
            N--;

            //如果相等说明找到了需要删除的结点,进行删除的操作
            //先判断目标结点的右子树是否为空
            if(x.right==null){
                /*
                若为空则直接返回左节点,这里面的原理是对于一个被删除元素而言
                若其右子树为空,则其次小元素必然在左子树的第一个结点中,说实话
                这为啥知道他就行我也不是很懂,但是经过分析发现无论左子树中还有没有
                子树,最后都是成立的,因此这个方法没有问题。
                 */
                return x.left;
            }

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

            //先将目标结点的右子树记录于minNode中,因为次小的元素必然在右子树的
            //左子树中,所以我们直接将minNode定位在被删除结点的右子树中
            Node minNode = x.right;
            //Node deleteNode = null;
            //通过while循环将minNode定位到最左子树的结点中
            while (minNode.left!=null){
                //if(minNode.left.left==null){
                //    deleteNode=minNode;
                //}
                minNode = minNode.left;
            }

            //deleteNode.left=null;
            
            //接下来要删除右子树中最小的结点
            Node n = x.right;
            //构建循环定位目标结点
            while (n.left!=null){
                //判断当前结点的后后结点是否为null,若是则说明下一个结点就是要删除的结点
                if(n.left.left==null){
                    //当前结点指向最小数值结点的指针删除
                    n.left=null;
                }else {
                    //若不到目标结点则让结点前进一位
                    n = n.left;
                }
            }
            /*
            个人觉得这里再构建一个循环用来专门定位次小结点的父节点未免有些多此一举
            实际上大可以在第一个循环里就去把次小结点的父节点给记录下来,这样就可以
            省略一个循环了,有益于提高我们的效率,实际操作之后发现真的可以
            这里上面的被注释的代码是我写的代码
             */

            //让x结点的左子树成为minNode的左子树
            minNode.left = x.left;
            //让x结点的右子树成为minNode的右子树
            minNode.right = x.right;
            //让x结点的父节点指向minNode,直接让x变为minNode,然后返回x
            //这样通过递归可以让x自动被其父节点指向
            x = minNode;

        }
        return x;
    }
}

各种方法的解释都直接写在代码上了,自己去查看就行了

查找最大键和最小键

那此时我们的二叉树已经实现了,但是我们为了让我们的二叉树使用方便,最好再提供一个找出二叉树中最大键和最小键的方法,那我们应该怎么构造这两个方法呢?

我们先来看看查找最小键的API设计

再来看看最大键的API设计

由上面的API设计我们可以构建最大键和最小键的方法代码如下

/*
 * 调用该方法会继续调用重载的min(Node)方法,而重载的方法会返回
 * 最小键所在的结点回来,此时直接取该结点的key就是我们所需要的
 * 最小值了
 */
//查找整个树中最小键的方法
public Key min(){
    //min(Node x)方法
    return min(root).key;
}

/*
 * 其最小键所在的结点无非就是在树中的最左结点,这是根据左小右大
 * 原则推理就能够得来的,这里使用了递归的方法,如果左节点不为空
 * 则继续调用该重载方法进入左节点直到为空时返回x的结点。其实我
 * 个人觉得用迭代的方式也可以,而且应该比这个方法更加容易想到
 * 但是用递归也是可以的,没有什么问题
 */
//在指定的树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中找到最大键所在的结点
private Node max(Node x){

    //判断x还有无右节点,若无则是x最大键所在的结点
    if(x.right!=null){
        return max(x.right);
    }else {
        return x;
    }
}

二叉查找树的遍历

那我们完成了上面的方法之后,应该要给我们的二叉树增加遍历的方法,在构造这样的代码之前,我们要先学习其二叉树基础遍历的相关方式

二叉树的相关遍历有以下三种

这三种方式无非就是按照根来分的,而且左必然在右的前面,那么前序遍历就是根左右,中序遍历是左根右,后序遍历是左右根

为了便于我们的遍历,我们可以将树简化成如下结构

这样无论哪个结点都有左子树和右子树,那么有同学就会问了,不是说叶结点是没有左右子树的吗?其实是有的,只不过它的两个左右子树的值为null

接下来让我们来看看分别使用三种遍历方式会得到什么结果

前序遍历是根左右,所以BG必然在E后面,实际也是如此。但是为什么它们不是连续的呢?这是因为我们的遍历方式是按照根左右的形式来遍历的,我们的将E看作为根左右,那么先出E,之后就进入左子树B,但是左子树B又是根左右,于是出了B之后进入A左子树出A,之后进入右子树D,然后又是根左右,先进左子树C,然后右子树没有就不用了,此时E的左子树全部遍历完了,于是进入右子树G,G又分根左右,于是出G,再出左子树F,然后出右子树H,至此就全部遍历完了,最后的结果是EBADCGFH

另外两个遍历方式其实也是一样的,这里就不多赘述了

前序遍历

那么讲完了原理之后,现在我们先来实现前序遍历,我们前来看看前序遍历的API设计

这里值得一提的是第二个方法,第二个方法里需要传入一个根结点,以及一个队列,它要使用这样一个前序遍历的方式把指定树x中的所有键放到keys的队列中,而第一个方法是要求返回一个队列的,这里的一个设计方法是用第二个方法将键的值放入队列中,然后在第一个方法里将队列返回,这里需要用代码将两个方法联系起来

那么我们可以实现前序遍历的代码如下

/*
 * 因为要求要返回一个队列,因此在这个方法里先创建一个队列
 * 然后调用第二个方法将队列树中的元素按照前序遍历方式放入
 * 队列中,接着将该队列返回
 */
//获取整个树中所有的键
public Queue<Key> preErgodic(){
    Queue<Key> keys = new Queue<>();//创建队列
    preErgodic(root,keys);//调用第二个方法来修改队列
    return keys;
}

/*
 * 该方法是使用递归实现的,理论上我认为也可以用迭代实现,前序遍历的方式是根左右
 * 因此这里我们的代码的构造形式是先将结点中的值放入队列,此处代表先取出根中的值
 * 接着是先判断左子树是否为空,这里递归调用时若左子树不为空也是先进入左子树里去
 * 查找的,接着左子树查找完了再继续进行右子树的判断。说实话,我觉得这种方式并不
 * 好去想得到,起码我就肯定想不到,但是是可以理解的
 * 最后队列的元素放好之后再逐个取出队列中的元素就是我们所需要的排序后的值了
 */
//获取指定树x的所有键并放到keys队列中
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);
    }
}

中序遍历

接着我们来实现中序遍历,同样的我们来看看其API设计

其原理其实和前序遍历差不多,这里不赘述了,直接构造代码

//获取整个树中所有的键,此处是中序遍历的方式
public Queue<Key> midErgodic(){
    Queue<Key> keys = new Queue<>();//创建队列
    midErgodic(root,keys);//调用第二个方法来修改队列
    return keys;
}

/*
 * 同样用递归的方法实现这个代码,与之不同的是中序遍历的方式是左根右
 * 所以这里先进入进行左子树是否为空的判断,如果是则不断进入左子树,
 * 然后再取出其根的值,接着在进行右子树的判断,其实本质差不多,无非
 * 是顺序变换了而已,于是乎对应现实逻辑,我们的代码逻辑也是只换了位置
 */
//获取指定树x的所有键并放到keys队列中
private void midErgodic(Node x,Queue<Key> keys){
    //先判断传入的结点是否为空
    if(x==null){
        //若为空则结点结束方法
        return;
    }

    //递归遍历x结点的左子树
    if(x.left!=null){
        //若左子树不为空则重复调用该方法并将队列传入
        midErgodic(x.left,keys);
    }

    //每次调用都把x结点的key放入到keys中
    keys.enqueue(x.key);

    //递归遍历x结点的右子树
    if(x.right!=null){
        //若右子树不为空则重复调用此方法并将队列传入
        midErgodic(x.right,keys);
    }
}

后序遍历

API我都懒得放了,其实就是mid改成了after,直接看代码吧

//获取整个树中所有的键,此处是后序遍历的方式
public Queue<Key> afterErgodic(){
    Queue<Key> keys = new Queue<>();//创建队列
    afterErgodic(root,keys);//调用第二个方法来修改队列
    return keys;
}

//获取指定树x的所有键并放到keys队列中
private void afterErgodic(Node x,Queue<Key> keys){
    //先判断传入的结点是否为空
    if(x==null){
        //若为空则结点结束方法
        return;
    }

    //递归遍历x结点的左子树
    if(x.left!=null){
        //若左子树不为空则重复调用该方法并将队列传入
        afterErgodic(x.left,keys);
    }

    //递归遍历x结点的右子树
    if(x.right!=null){
        //若右子树不为空则重复调用此方法并将队列传入
        afterErgodic(x.right,keys);
    }

    //每次调用都把x结点的key放入到keys中
    keys.enqueue(x.key);
}

层序遍历

接着我们来实现二叉树中一个名为层序遍历的遍历方式,正所谓百闻不如一见,直接来看图理解什么是层序遍历吧

没错,所谓层序遍历就这样,按照一行一行的方式来把二叉树全部遍历完,那么我们要如何去实现这个层序遍历呢?我们先来看看其原理图示

首先我们要完成层序遍历需要两个队列,第一个队列用于存放键,第二个队列用于存放二叉树的结点,我们实现这个层序遍历的方式非常简单,首先先将根结点存放于结点队列中,然后弹出该结点并将该结点的值放入到另一个键队列中,然后我们查看其是否有左右子树,若有则将其左右子树的结点放到结点队列中,然后再继续弹出一个结点,同样将其值存入到键队列中之后我们查看其左右子树的结点,若有,则继续将左右字数的结点压入到节点队列中,就这样周而复始最终我们存储到键队列中的元素就是我们所需要的层序遍历之后的元素了

了解了其原理之后,我们来看看其实现步骤

最后我们来看看其API设计

那么根据其实现步骤和API设计,我们可以构建层序遍历的代码如下

/*
 * 层序遍历无非是使用了两个队列来辅助完成排序,不过这里并没有
 * 使用递归的方式,而是使用迭代的方式,我猜想也可以使用递归来
 * 完成这个方法
 */
//使用层序遍历,获取整个树中所有的键
public Queue<Key> layerErgodic(){
    //定义两个分别存储结点和键的队列
    Queue<Key> keys = new Queue<>();//存放键的队列
    Queue<Node> nodes = new Queue<>();//存放节点的队列

    //默认要往队列中先放入根结点,这是必然的,根结点都没有怎么遍历啊
    nodes.enqueue(root);//将结点压入到结点队列

    //只要结点队列不为空就继续执行循环
    while (!nodes.isEmpty()){
        //从结点队列中弹出结点并将其key放入到键队列中
        Node n = nodes.dequeue();//弹出结点
        keys.enqueue(n.key);//将弹出结点的值压入到键队列

        //判断当前结点还有无左子树,若有则将左子树放入到结点队列中
        if(n.left!=null){
            nodes.enqueue(n.left);//压入左子树结点到节点队列
        }

        //判断当前结点还有无右子树,若有则将右子树放入到结点队列中
        if(n.right!=null){
            nodes.enqueue(n.right);//压入右子树结点到节点队列
        }
    }
    return keys;//最后返回键队列
}

其实讲到这里,我们二叉树的入门就已经讲完了,接下来我们来讲关于二叉树的两个应用问题

二叉树的最大深度问题

先来看看需求

再来看看其API设计

接下来我们再来看看其实现步骤

简而言之其实我们计算其深度的方法是我们将任何结点都视作为根加上左子树和右子树,我们先计算左子树和右子树的最大深度,然后当前树的最大深度就等于左子树和右子树最大深度的较大值+1,其方式是调用递归来实现的,光说其实不太好理解,我们来把代码构造出来

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

/*
 * 主要发挥查询最大深度的方法是这个方法,上一个方法说是调用树的最大深度,其实本质是传入树的根
 * 然后调用这个方法,而这个方法是可以查询指定树的最大的深度。其原理是利用递归达成的,为了安全
 * 性所以首先判断传入结点是否为空,若为空就直接返回0就完了
 * 这个方法的主要思想是对每一个结点都进行左右深度的判断,谁大就返回谁并+1,经过不断地递归最终
 * 在我们一开始传入的结点里我们能够得到两个深度值,对其进行左右值的判断并返回+1即可。这里我们
 * 定义三个变量的原因是我们需要这三个变量来保存和判断,max是保存其深度的最大值,而maxL则是每
 * 次都记录其左子树的最大值而maxR则是右子树的最大值。说实话要我想我真想不到,但是可以理解
 */
//获取指定树x的最大深度
private int maxDepth(Node x){
    //结点的安全性判断,若为空则直接返回0
    if(x==null){
        return 0;
    }
    //定义max来保存x的最大深度
    int max=0;
    //定义maxL来保存左子树的最大深度
    int maxL=0;
    //定义maxR来保存右子树的最大深度
    int maxR=0;

    //计算x结点左子树的最大深度
    if(x.left!=null){
        //因为要用maxL进行判断所以每次递归时都要将最大值传给maxL,每次重复调用将左子树传入
        maxL = maxDepth(x.left);
    }
    //计算x结点右子树的最大深度
    if(x.right!=null){
        //因为要用maxR进行判断所以每次递归时都要将最大值传给maxR,每次重复调用将右子树传入
        maxR = maxDepth(x.right);
    }
    //比较左右子树的最大深度,取较大值+1并赋给max,这里运用三元运算符,其意思是判断maxL是否
    //大于maxR,若大于则返回maxL+1,小于则返回maxR+1。这里之所以用>符号就可以了是因为无论
    //让谁+1最后都会赋给max,都一样的其实,但之所以使用大于号,是为了便于最后的根结点的比较
    //如果用小于号的话,最后根结点返回的深度会是较小的那个,而我们的思路是返回较大的那个,因此
    //这里必须用大于号
    max = maxL>maxR?maxL+1:maxR+1;
    return max;
}

折纸问题

先来看看折纸问题到底是啥

简单来说就是折纸产生的折痕朝下就叫down,朝上就叫up,要属实想不明白就照着图自己那张纸恩折就明白了,很快的其实。

但是这跟我们的二叉树有什么关系?虽然看起来没什么关系,但是如果分析下就会发现其实是有关系的

其实就可以理解成我们每次对折都产生一层的新结点,第一次对折产生一个根结点,该结点为down,第二次对折产生两个新结点,这两个新结点都是第一个结点的子节点,其中左子树为down,右子树为up,再次对折也是如此,产生四个新结点,分别为down,up,down,up

可以分析出每次产生的左节点都为down,右节点都为up

而按照我们的题目要求对该树进行输出的话,就应该使用中序遍历方式,因为本来中序遍历方式也是我们说的能够按顺序输出的我们的结点元素的一种遍历方式,这样我们所输出的就是我们题目所要求了的,实际我们去对照也会发现中序遍历的结果的确就是跟纸张上从上往下数折痕的结果是一致的

然后我们来看看我们实现解决折纸问题的方法的步骤

这里我们的1.3步骤问题都不是很大,我们前面已经学习过了,问题比较大的在于第二个步骤,我们先来看看第二个步骤的具体实现

这里的步骤简而言之是利用队列辅助完成,先对对折的情况进行判断,如果是第一次对折则只创建根结点,若不是第一次对折,则利用循环和队列定位到最后的叶结点,然后将每个结点都添加两个子树就完了

那么根据上面所说的步骤,我们现在可以实现其代码如下

package algorithm.sort;

public class test {
    public static void main(String[] args) {
        //模拟折纸过程,产生树
        Node<String> tree = createTree(8);
        //遍历树,打印每个结点
        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){
                    //每次产生的左节点其元素必为down
                    tmp.left = new Node<String>("down",null,null);
                    //每次产生的右节点其元素必为up
                    tmp.right = new Node<String>("up",null,null);
                }
            }
        }
        //创建完毕之后返回新树的根
        return root;
    }

    /*
     * 这里采用了中序遍历的思想构建的方法,也就是左根右的遍历方式
     * 与我们之前定义的左根右的中序遍历的代码不同的是,这里的中序
     * 遍历的根是直接打印的,而我们之前定义的是将其放入队列,然后
     * 我们在主方法里打印队列的元素就完了,这里的代码是直接调用就
     * 可以一并完成打印的动作了
     */
    //打印树中的每个结点到控制台
    public static void printTree(Node 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位置处是不存放任何元素的,接下来我们可以从图中获得以下两个性质

这几个规律总结下来就是,如果一个结点的位置为k,则其父节点的位置为k/2,其子节点的位置则为2k和2k+1。其次是每个结点都大于其两个子节点,但两个子节点间的大小关系没有规定

堆的实现

接下来让我们看看其API设计

这里我们要对这里面的API设计具体讲讲,首先这里的构造方法要传入int参数用于指定空间,这主要是因为我们底层调用的是数组对象,而数组是要先指定空间创造的

其次是这里我们Heap里面的泛型元素T继承了Comparable方法,这是因为我们上面讲过的规则里有一条父节点必然大于子节点的规则,那么堆里面的元素就是有序的,起码是部分有序的,那么就需要用于排序的方法,因此这里继承了Comparable接口

最后是我们看到5.6两个方法,这两个方法说使用了上浮算法,下沉算法似乎我们没学过,其实这两个方法的主要目的就是为了让我们往堆里添加了元素之后我们要用这种算法使得我们的添加的元素处于一个正确的位置,因为我们的堆里的元素是部分有序的,因此我们不能添加之后就让其破坏我们堆里的这种有序,必须要用这种方法进行处理

而所谓上浮算法,其实就是将元素的位置往其父节点方向上移动的意思,下沉算法则反之

我们先来看看我们上浮算法的实现图示原理吧

我们只需要让我们添加的新元素不断地与其父节点的元素进行比较,确定到底谁大谁小,如果子节点的元素的确大于父节点,那么就交换位置,这样不断循环直到进入到合适的位置为止

再来说说我们的删除方法和下沉算法的原理,我们这里的删除方法是要删除堆中元素最大的值,显然,堆中元素最大的值就在根结点处,所以我们直接删除根结点其实就挺好,但这样就会导致让我们的树变成森林了,而这不是我们想要的,我们应该要让我们的删除方法能够删除最大的元素的同时又能够就行保持堆中的部分有序规则,那么我们应该怎么办呢?我们容易想到一个方法就是让P或者R去成为新的根结点,但这样的话由于子节点谁大谁小没比较,所以左子树的子树未必就小于右子树本身,这样的又需要比较,这样就太麻烦了,也不容易去实现

这里我们采用了一个比较讨巧的办法,我们先将首结点与最后一位结点交换,接着将最后一位结点删除,然后对首结点使用下沉算法,直到让改变后的首结点到达了正确位置为止,来看看其交换位置的图示吧

接着是使用下沉算法的图示

这样我们最终就可以得到一个有序的堆了

那么根据API我们就可以构造代码如下

package algorithm.sort;

//令T继承Comparable接口,给T所代表的元素提供比较方式,便于实现排序
public class Heap<T extends Comparable<T>> {
    //存储堆中的元素
    private T[] items;
    //记录堆中元素的个数
    private int N;

    /*
     * 此处之所以是要生成Comparable接口是因为我们在泛型里继承了该接口
     * 如果我们生成Object类型的话会报类型转换异常,这里之所以让指定的
     * 容量+1是因为我们我数组里是废弃了0索引的,因此要多给予一个空间用
     * 于存放元素
     */
    public Heap(int capacity) {
        this.items=(T[]) new Comparable[capacity+1];
        this.N=0;
    }

    //判断堆中索引i处的元素是否小于索引j处的元素
    private boolean less(int i,int j){
        return items[i].compareTo(items[j])<0;
    }

    //交换堆中索引i和索引j处的值
    private void exch(int i,int j){
        T temp = items[i];
        items[i] = items[j];
        items[j] = temp;
        //其实我觉得这里应该要做一个调用上浮算法的动作的,但是没有
    }

    //往堆中插入一个元素
    public void insert(T t){
        //这里采用++N的方式是因为我们堆中的元素的首位是不存放任何元素的
        //我们采用++N,如果N等于0,那么我们就可以正确填入元素到1位置处
        //如果N不等于0,那么就正确填入元素到数组末尾
        items[++N]=t;
        swim(N);//添加完毕之后使用上浮算法令堆重新有序
    }

    //使用上浮算法,使索引k处的元素能在堆中处于一个正确的位置
    private void swim(int k){
        //通过循环,不断比较当前结点和父节点的值
        while (k>1){//只要索引还大于1,说明还没到根结点,就继续执行循环
            //比较当前结点和父节点的大小
            if(less(k/2,k)){//如果当前结点大于父节点
                exch(k/2,k);//则交换字父结点的位置
            }

            k = k/2;//若不等于则令k结点的元素变化到其父节点的元素
        }
    }

    /*
     * 该方法只完成了交换和和调用下沉算法并且返回被删除的最大值的结点,这里删除元素的方式不是
     * 采用断开链表对其的指向,而且将其值赋值为null。在数组中就相当于不解除最后一个位置
     * 的空间,而将最后一个值赋值为null的意思。此处最大索引不是N-1而是N的原因是我们的首结点
     * 是只充当指引的元素,因为因此多占了一个位置,因此结尾可以到达N而不是N-1。在实际的代码上
     * 我们也是先从1索引处开始放置元素的,于情于理都可以理解N索引才是结尾而不是N-1
     * 我个人理解在本方法里交换最大和最小元素的意义在于,让最小元素成为根结点就可以保证下沉算法
     * 的不断实现,因为叶结点必然是最小的子节点,令其到头部可以不断使用下沉算法,通过下沉算法来
     * 让堆中的元素最终能达到一个部分排序的情况
     * 最后提一下这个方法只能用于删除堆中最大元素,而不是说删除指定元素
     */
    //删除堆中最大的元素,并返回这个最大元素
    public T delMax(){
        T max = items[1];//定义max用于记录最大元素

        //交换索引1处和最大索引处的元素
        exch(1,N);
        //删除最大索引处的元素
        items[N]=null;
        //元素个数-1
        N--;
        //通过下沉调整堆,让堆重新有序
        sink(1);
        //返回被删除的最大值
        return max;
    }

    //使用下沉算法使k在堆中处于一个正确的位置
    private void sink(int k){
        while (2*k<=N){//只要2*k<=N存在,那么就说明还有左子树,还能继续进行循环比较
            int max;//定义max用于记录两子节点较大结点所在的索引
            if(2*k+1<=N){//判断有无右子树
                if(less(2*k,2*k+1)){//若有则判断左右子树大小
                    max = 2*k+1;//右子树大则让max记录右子树的索引
                }else {
                    max = 2*k;//反之则记录左子树的索引
                }
            }else {
                //若没有右子树则直接让max记录左子树的索引
                max = 2*k;
            }

            //比较当前结点和较大结点的值
            if(!less(k,max)){
                //如果当前结点大于较大结点,则说明已经处于正确位置,直接结束循环
                break;
            }

            //若小于交换索引k和较大子节点max所代表的值
            exch(k,max);

            //变化k的值,让k进入较大子节点的索引,实际上k也是与max进行的交换,因此将k的值定位到max上也容易理解
            k = max;
        }
    }
}

堆排序

上面我们实现了堆的数据结构,接下来我们来实现下堆排序,所谓堆排序简而言之就是要完成如图所示的需求

我们先来看看其API设计

image-20220817204506608

这里我们要对第五个方法进行说明,这个范围是什么意思?其实是当我们对堆中元素进行动作的时候,实际上某些时候我们是不用对某些元素做下沉动作的,比如说只要我们把range定义到数组结尾的前一个元素,那么我们的下沉范围就不会对最后一个元素进行这个动作的,这样有助于我们提高某些时候的下沉效率

那么我们应该如何对数组实现堆排序呢?一个简单想法就是我们创建一个新数组,然后遍历原数组不断添加往新数组里添加元素,边添加边排序直到排序完毕,最后就能构造一个堆排序数组了,但是这样方法太简单,不适合我们,所以我们要用更加聪明的办法

这个办法就是我们先创建一个新数组,将原数组中的所有数据传到新数组的1~length处,然后对新数组长度的一半开始往1处进行下沉算法的动作,最后就能得到一个堆排序数组了,为什么是从新数组长度的一半开始扫描?这是因为对于堆里的完全二叉树而言的,其2k之后的数组都是其子树,那么数组长度的一半之后的结点都是叶结点,叶结点没有左右子树无法做下沉算法,因此对其进行下沉算法的过程是可以省略的,所以我们从新数组长度的一半往1索引处开始扫描

来看看图示吧

image-20220817204629253

接下来是第二张

image-20220817204643750

经过上面的过程,我们就成功创建了一个有序堆,但是我们的需求是将数组按堆的方式从小到大排序,而堆是从大到小排序,现在我们的实现的排序和我们所需要的正好反过来了,那我们应该怎么办呢?有一个简单想法就是利用栈来达成我们的需求,但是这种方式效率不是很高,所以我们要用另外一个更加聪明的办法

先来看看这个方法的步骤

image-20220817204658564

首先我们都知道在堆里是按照父子节点由大到小的方式来排序的

那我们就可以先将根结点与最大索引处的元素交换位置,之后对根结点进行下沉算法,此时我们将最大值放到的最大索引处,那么进行下沉算法时,我们就不对最后一个元素进行下沉了,下沉完毕之后在根结点里的元素必然是次大元素,接着重复执行上述过程,那么就会在次大索引处理放上次大值,接着不断执行这个过程最后我们就能实现数组中元素的大小按从小到大排序,来看看其图示过程吧

image-20220817204717745

只放一个就行了,不放太多图了,咱们这里以小见大好吧

根据API设计和上述的讲解我们可以构造代码如下

package cn.itcast.algorithm.heap;

public class HeapSort {
    //判断heap堆中索引i处的元素是否小于索引j处的元素
    private static boolean less(Comparable[] heap, int i, int j) {
        return heap[i].compareTo(heap[j])<0;
    }

    //交换heap堆中i索引和j索引的值,这里与此前不同的是这里还要将数组也传入给该方法用于比较
    private static void exch(Comparable[] heap,int i, int j) {
        Comparable tmp = heap[i];
        heap[i] = heap[j];
        heap[j] = tmp;
    }

    //根据原数组source,构造出堆heap
    private static void createHeap(Comparable[] source,Comparable[] heap) {
        //把source中的元素赋值到heap中,heap中的元素是一个无序堆
        //调用API进行数组的复制,传入对应的值
        System.arraycopy(source,0,heap,1,source.length);

        //对堆中元素做下沉(从长度的一半处开始,往索引1处扫描)
        for (int i = (heap.length)/2; i > 0 ; i--) {
            sink(heap,i,heap.length-1);
            //范围之所以定位长度-1,是因为对于堆底部的数组而言,一开始是多创建了一个空间的,这是因为
            //我们将0位置的元素废弃了的缘故,但我们比较时是从1开始的,因此我们传入的范围需要-1,代表
            //堆中最大索引的范围,这行代码的意思也是说下沉算法的边界就在最大索引处的意思
        }
    }

    //对source数组中的数据从小到大排序
    public static void sort(Comparable[] source) {
        //构建堆,给其指定的空间+1,因为我们废弃了0位置处的空间,从1处开始添加元素,因为指定的空间要+1
        Comparable[] heap = new Comparable[source.length+1];
        createHeap(source,heap);//调用创造排序堆的方法
        //代码执行到此说明已经产生了有序堆
        //定义用于记录未排序元素最大索引的变量
        int N = heap.length-1;//N最开始定义到最大索引处
        while (N!=1){//只要索引没到1说明元素的交换没有到根结点处,那就继续进行交换
            //交换最大索引与最小索引的值
            exch(heap,1,N);
            //排序交换最大元素所在的索引,并令其不参加下沉
            N--;//直接减少N的值,这样N定义到次大索引处
            //对索引1处的元素执行下沉算法,传入N代表的边界值,N此时所代表的边界值就是我们所需要的边界值
            sink(heap,1,N);
        }

        //把heap中的数据赋值到原数组source中
        System.arraycopy(heap,1,source,0,source.length);
    }

    /*
     * 这个下沉算法与我们之前学习的下沉算法的代码逻辑只有细微区别,就不再赘述了
     * 不过有所不同的是这里我们比较的边界值range,而我们每次都要传入用于下
     * 沉算法的索引target,如果其二倍大于range,说明没有符合条件的子树,那么就
     * 跳出循环。其他代码没啥不同,不多说了
     */
    //在heap堆中,对target处的元素做下沉,范围是0~range
    private static void sink(Comparable[] heap,int target,int range) {
        while (2*target<=range){
            //找出当前结点的较大的子结点
            int max;
            if(2*target+1<=range){
                if(less(heap,2*target,2*target+1)){
                    max = 2*target+1;
                }else {
                    max = 2*target;
                }
            }else {
                max = 2*target;
            }

            //比较当前结点的值与较大子节点的值
            if(!less(heap,target,max)){
                break;
            }

            exch(heap,target,max);

            target = max;
        }
    }
}

优先队列

什么是优先队列?我们之前只学习过队列,在队列里的元素都是按照先进出的原则来存储,但如果我们有这么一个需求,我们需要先取出队列中元素的最大值那我们应该怎么实现?我们容易想到先把所有的元素取出来比较再删除最大的元素,但是这样的方式效率太低,那有没有这么一种方式能够实现这个目的而且效率又高呢?这时候我们就要学习优先队列了

image-20220817204938867

优先队列按照作用不同可以分为两种队列,分别是最大优先队列和最小优先队列

image-20220817204951177

优先队列是基于堆实现的,而堆是基于数组实现的,堆同时可以抽象成完全二叉树

最大优先队列

我们首先先来实现最大优先队列,先来看看其API设计

image-20220817205042195

为什么这里有数组?因为我们说过,队列可以用数组或者链表实现,我们这里用链表实现而已

那么根据API设计我们可以构造其代码如下

package algorithm.sort;

//令T继承Comparable接口,给T所代表的元素提供比较方式,便于实现排序
public class MaxPriorityQueue<T extends Comparable<T>> {
    //存储堆中的元素
    private T[] items;
    //记录堆中元素的个数
    private int N;

    public MaxPriorityQueue(int capacity) {
        this.items = (T[]) new Comparable[capacity+1];
        this.N=0;
    }

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

    //判断队列中是否为空
    public boolean isEmpty() {
        return N==0;
    }

    //判断堆中索引i处的元素是否小于索引j处的元素
    private boolean less(int i,int j){
        return items[i].compareTo(items[j])<0;
    }

    //交换堆中i索引和j索引处的值
    private void exch(int i,int j) {
        T tmp = items[i];
        items[i] = items[j];
        items[j] = tmp;
    }

    //往堆中插入一个元素
    public void insert(T t){
        items[++N] = t;
        swim(N);
    }

    //删除堆中最大的元素,并返回这个最大元素
    public T delMax() {
        T max = items[1];
        exch(1,N);
        N--;
        //此处和之前我们建立堆代码不同的是这里没有将值赋予null
        //而且直接将N--,其实是都可以的,因为我们没有进行赋予空
        //的值到最终还是会被覆盖的,没什么影响
        sink(1);
        return max;
    }

    //使用上浮算法,是索引k处的元素在堆中处于一个正确的位置
    private void swim(int k){
        while (k>1){
            if(less(k/2,k)){
                exch(k/2,k);
            }
            k = k/2;
        }
    }

    //使用下沉算法,是索引k处的元素在堆中处于一个正确的位置
    private void sink(int k){
        while (2*k<=N){
            int max;
            if(2*k+1<=N){
                if(less(2*k,2*k+1)){
                    max=2*k+1;
                }else {
                    max=2*k;
                }
            }else {
                max=2*k;
            }

            if(!less(k,max)){
                break;
            }

            exch(k,max);

            k=max;
        }
    }
}

是不是有点眼熟?这其实不就是堆么?没错,其实最大优先队列的底层结构就是堆,可以简单理解为堆其实就是最大优先队列,又叫最大堆

最小优先队列

还记得当初我们说堆有什么性质吗?堆的性质有二,首先是堆的父节点必然大于等于子节点,其次是对于堆中在位置k的元素而言,2k与2k+1均为其子节点,那样的堆就是最大堆,也是最大优先队列

在最大堆里,我们使用上浮算法时是对我们指定要进行上浮算法的结点令其与父节点比较,若其比父节点大,那么就与父节点交换位置,这样循环往复直到其没有父节点或者比父节点小

而下沉算法则是先比较两个子节点谁大,挑出更大的那个,然后和父节点比较,若父节点比起小,则令其与父节点交换位置,这样不断循环往复

那么对于最小堆而言,它的第一个性质是反过来的,我们是把最小的元素放到最前面,所以父节点一定是小于等于子节点,第二个性质不变

而对于最小堆里的上浮算法,我们是指定结点令其与父节点比较,若其比父节点小,那么就与父节点交换位置,这样不断循环往复直到其没有父节点或者比父节点大

而对于最小堆里的下沉算法,我们是先比较两个子节点谁小,挑出更小的那个,然后和父节点比较,若其比父节点小,则与父节点交换位置,这样不断循环往复

那么我们最小堆的代码就可以构造如下

package algorithm.sort;

//令T继承Comparable接口,给T所代表的元素提供比较方式,便于实现排序
public class MinPriorityQueue<T extends Comparable<T>> {
    //存储堆中的元素
    private T[] items;
    //记录堆中元素的个数
    private int N;

    public MinPriorityQueue(int capacity) {
        this.items = (T[]) new Comparable[capacity+1];
        this.N=0;
    }

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

    //判断队列中是否为空
    public boolean isEmpty() {
        return N==0;
    }

    //判断堆中索引i处的元素是否小于索引j处的元素
    private boolean less(int i,int j){
        return items[i].compareTo(items[j])<0;
    }

    //交换堆中i索引和j索引处的值
    private void exch(int i,int j) {
        T tmp = items[i];
        items[i] = items[j];
        items[j] = tmp;
    }

    //往堆中插入一个元素
    public void insert(T t){
        items[++N] = t;
        swim(N);
    }

    //删除堆中最大的元素,并返回这个最大元素
    public T delMin() {
        T min = items[1];
        exch(1,N);
        N--;
        //此处和之前我们建立堆代码不同的是这里没有将值赋予null
        //而且直接将N--,其实是都可以的,因为我们没有进行赋予空
        //的值到最终还是会被覆盖的,没什么影响
        sink(1);
        return min;
    }

    //使用上浮算法,是索引k处的元素在堆中处于一个正确的位置
    private void swim(int k){
        while (k>1){
            if(less(k,k/2)){
                exch(k,k/2);
            }
            k = k/2;
        }
    }

    //使用下沉算法,是索引k处的元素在堆中处于一个正确的位置
    private void sink(int k){
        while (2*k<=N){
            int min;
            if(2*k+1<=N){
                if(less(2*k,2*k+1)){
                    min =2*k;
                }else {
                    min =2*k+1;
                }
            }else {
                min =2*k;
            }

            if(less(k, min)){
                break;
            }

            exch(k, min);

            k= min;
        }
    }
}

其实,直接把compareTo方法的<改成>就可以了的,但是这样改动的话无法在代码上体现我们的思想,虽然说其实结果是一样的,但是不能不该耍这种小聪明,所以我们不这么构造,而是对一个个方法进行逐一改造

索引优先队列

之前我们讲了最大优先队列和最小优先队列,但是他们都有缺陷,其缺陷就是,我们无法对队列本身的元素进行修改,因为我们提供的方法里面都没有给予用户获取对应位置的元素的方法,所以元素都拿不到,元素都拿不到那修改个der。那为了解决这种缺陷,所以我们要学习索引优先队列

那么我们要怎么实现索引优先队列呢?首先我们应该要实现能够在队列里修改元素,这里我们以最小优先队列为例来实现索引优先队列,以后有兴趣可以自己用最大堆去实现下

先来看看其实现思路

image-20220817205347710

简而言之是我们可以创建一个新数组来存放元素和元素的索引,每次我们插入元素时,我们不但要传入元素,还要传入索引才能够完成插入

image-20220817205406652

可以看到插入完毕之后我们的数组就拥有了元素,而且有了索引,但是我们此时我们的数组是无序的,这不符合堆有序的特点,而如果我们的改造数组令其有序,那么索引又会乱,因此我们创造一个数组pq用于保存当前的索引

再来看看其图示

image-20220817205537793

那么此时我们的堆排序就可以用pq数组来表示了,此时pq数组就是存在父节点比子节点大于等于的关系的,当然了,这其实是一个抽象结构,实际上并不是这样的,但是我们可以这么理解

但是这样的堆还是有缺陷,比方说,如果我们修改S的值为H,那么修改之后进行上浮算法对整个堆进行再排序,这时候我们的pq的位置也是要进行相应的改变的,那么pq就应该要改变0的位置,但问题在于,我们要如何获得0在qp的索引呢?一个简单想法就是遍历获得其位置,但是问题在于,这样的效率是比较低的,那我们有什么其他的办法能够快速获取到pq中0位置处的索引吗?有。我们只要再构建一个辅助数组,让辅助数组存储pq的逆序就可以了,至于什么是逆序存储,请看图里的解释

image-20220817205554929

最后我们再来看看图里的演示

这样我们就可以通过pq数组迅速定位到0在pq中的索引了,这是一种典型的以空间换时间的做法,而且还不是递归,太爽了

那么现在我们来看看其API设计

image-20220817205628419

根据API设计和上面的演示我们可以构造代码如下

package algorithm.sort;

/*
 * 索引优先队列的实现原理是先创建一个数组用于存放元素和关联的索引,第一个数组里存放了元素,同时这个元素自带索引
 * 我们姑且称之为真索引,数组称为真数组,接着第二个数组用于保存真索引排序后的值,其中里面的值都有对应的真索引
 * 这个数组我们成为影数组,其索引我们称之为影索引,接着我们定义了第三个数组用于保存第二个数组的逆序情况,
 * 第三个数组里面的每一个值都是影索引,我们称该数组为逆数组,其索引为逆索引
 * 这三个数组发挥的作用各不相同,真数组的作用其实是为了让影数组能够通过其值来找到对应的元素,而影数组本身是
 * 堆的一种抽象结构,通过影数组可以实现最小索引队列的数据结构,而逆数组的作用主要是为了提高效率,逆数组的存在
 * 能够让我们快速找到影数组的值所在的索引,能够省去很多拉满效率和代码简洁性的操作
 */
//令T继承Comparable接口,给T所代表的元素提供比较方式,便于实现排序
public class IndexMinPriorityQueue<T extends Comparable<T>> {
    //用于存储堆中的元素的真数组
    private T[] items;
    //保存每个元素在items数组里的索引,pq数组需要堆有序,也就是影数组
    private int[] pq;
    //保存qp的逆序,将pq的值作为其索引,将其索引作为值,也就是逆数组
    private int[] qp;
    //记录堆中元素的个数
    private int N;

    //构造方法
    public IndexMinPriorityQueue(int capacity){
        //由于影数组是代表堆结构,因此影数组的首索引的位置废弃不用,影数组的值需要+1,逆数组自然也要+1
        //而真数组需要+1的原因我暂且蒙在鼓里,我怀疑即使不+1也是可以的
        this.items = (T[]) new Comparable[capacity+1];
        this.pq = new int[capacity+1];
        this.qp = new int[capacity+1];
        this.N = 0;

        //默认情况下,逆数组中没有任何数据,则让逆数组的元素都为-1,代表不存在任何元素
        //这里之所以给-1而不是null的原因是因为如果给null,容易造成空指针异常
        for (int i = 0; i < qp.length; i++) {
            qp[i]=-1;
        }
    }

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

    //判断队列是否为空
    public boolean isEmpty() {
        return N==0;
    }

    //判断堆中索引i处的元素是否小于索引j处的元素
    private boolean less(int i, int j) {
        return items[pq[i]].compareTo(items[pq[j]])<0;
        //我们要实际比较的元素必然是真数组里的元素,因此这里利用影数组来获得
        //真数组里的对应元素并比较,而不是直接比较影数组的值
    }

    /*
     * 这个方法只完成交换,不进行复原堆中有序性的操作,当然实际上堆中的有序性
     * 是需要保证的,但是,这不是我们这个方法应该要做的事情,因此这里没有调用
     * 相应的能够恢复堆中有序性的方法
     */
    //交换堆中i索引和j索引处的值
    private void exch(int i,int j){
        //交换影数组pq中的数据
        int tmp = pq[i];
        pq[i] = pq[j];
        pq[j] = tmp;
        //此处我们只交换索引的原因是我们的抽象堆结构是由影数组pq构建的
        //那么我们只需要调整抽象结构里的索引位置就能够达到我们调整抽象结
        //构中索引对应的元素交换位置的目的

        //更新qp中的数据,由于影数组pq进行交换,那么逆数组qp也要进行改变
        qp[pq[i]]=i;
        qp[pq[j]]=j;
        /*
         * 首先我们要知道,pq的值就是qp的索引,而pq的索引就是qp的值,其中
         * 是有着对应的关系的,如果我们修改pq中的值,那么qp中的值也要相应
         * 发生改变,而其改变的方法很简单,首先是找到pq的改变的值所对应的
         * 索引,利用该索引得到变化后的pq的值,该值同时也是qp的索引,那么
         * 我们利用该值定位到qp的索引所对应的值,将该值修改为pq的索引
         * 而这样做之所以能够正确的原理在于,既然在pq里修改了规定索引的值
         * 那么在qp里就要修改原先值所对应的索引的值,qp修改后的值应该要是
         * pq的原先的值所对应的新索引,因为这里是换位操作而不是简单的修改
         * 操作,所以不会发生没有对应索引的情况
         * 这有点套娃的感觉,不是很好理解,但细想之后是能够理解其意思的
         * 实在想不明白的话直接记结论得了
         */
    }

    //判断k对应的元素是否存在
    public boolean contains(int k) {
        return qp[k]!=-1;
        //这里能够这样构造代码来判断是因为我们先将qp逆数组全部赋值为-1
        //而在其他方法中,如果pq影数组索引k真的存放了对应元素,那么在
        //其逆数组pq里必然是做了对应动作令其不为-1的
    }

    //最小元素关联的索引
    public int minIndex() {
        return pq[1];
        //由于我们是最小堆,那么最小堆的pq的第一个索引所对应的值就是该最小元素关联的索引
    }

    //往队列中插入一个元素,并关联索引
    public void insert(int i,T t) {
        //判断i是否已经被关联,若是,则结束方法,不允许关联
        if(contains(i)){
            return;
        }
        //插入元素必然要让元素个数+1,这里先令其+1便于后面添加元素
        N++;
        //把数据存储到items对应的i位置处
        items[i] = t;
        //此处i代表的就是就是我们的真数组的索引,将真数组的索引存于影数组的最后一位
        //把i存储到pq中,直接存储到pq数组的下一位,这里其实也对应了堆的抽象结构,因为
        //我们平时往堆里添加元素也是直接在后面上添加的,所以这里直接添加到pq抽象结构
        //数组的末位,没有任何问题
        pq[N]=i;
        //令逆数组i处索引添加对应逆序值
        qp[i]=N;
        //存储之后堆的有序性被打破,那么就要恢复其有序性
        //通过上浮算法完成对堆的调整
        swim(N);
    }

    /*
     * 删除队列中最小元素的方法和我们之前给最小优先队列的删除方法差不多,都是先将最小的
     * 元素与最后一位元素交换位置之后删除最后一位元素然后调用下沉方法恢复堆的有序性
     * 但是不同的是这里由于有着影数组和逆数组的存在,因此我们在使用这种方法时要进行
     * 影数组和逆数组的对应修改
     */
    //删除队列中最小的元素,并返回该元素关联的索引
    public int delMin() {
        //获取最小元素关联的索引,pq的第一个存储的值就是对应的最小元素的索引
        int minIndex = pq[1];

        //交换pq中索引1处和最大索引处的元素
        exch(1,N);
        //先删除逆数组qp中对应的内容,这里之所以构造如下代码,是因为N代表pq最后一位的索引
        //其索引所对应的值就是qp的索引,既然我们在pq中删除了这个元素,
        //那么在qp中也应该要没有了对应该值的索引,但实际上索引肯定还在,因为索引没法删除
        //那么我们就在qp中将该元素的值赋值为-1,代表该索引不存在亦或是该索引还未指向任何元素
        qp[pq[N]] = -1;
        //元素交换后删除pq最大索引处的内容,赋予-1就代表删除的意思
        pq[N]=-1;
        //删除items中对应的内容,因为我们删除了元素,自然也删除了真数组
        //中对应的元素,因此要将真数组中原来存在的元素删除,这里我们前面
        //记录过最小元素关联的索引,因此我们用其进行删除操作,这里赋值为
        //null是因为真数组的类型是T[],只能赋值null代表删除
        items[minIndex] = null;
        //元素个数-1
        N--;
        //对堆进行下沉算法,恢复其有序性
        sink(1);
        //返回被删除的最小元素的关联的索引
        return minIndex;
    }

    /*
     * 删除索引i关联的元素其实是上一个删除最小元素的方法大同小异,都是要先将元素交换
     * 然后删除结尾的元素,同时由于影数组和逆数组的存在,所以要先对逆数组影数组修改
     * 最后再轮到真数组,虽然我们交换的不是根结点,而是中间的结点,但是这是最小索引
     * 优先队列,数组前面的元素必然小于后面的元素,因此当我们调换位置时,交换的元素
     * 必然是最大的元素,此时只需要调用下沉方法就可以了,但是即使我们调用了上浮也无妨
     * 由于黑马写的代码里调用了上浮,因此我们这里保留上浮的代码
     */
    //删除索引i关联的元素
    public void delete(int i) {
        //找到i在pq中的索引
        int k = qp[i];
        //交换pq中索引k处的值和索引N处的值
        exch(k,N);
        //删除qp的内容
        qp[pq[N]] = -1;
        //删除pq中的内容
        pq[N] = -1;
        //删除items中的内容
        items[k] = null;
        //元素的数量-1
        N--;
        //堆的调整
        sink(k);//下沉
        swim(k);//上浮
    }

    /*
     * 这个直接修改的方法与前面交换的方法不同,直接修改无法判断其大小关系究竟如何
     * 因此要恢复其堆有序则需要分别调用下沉和上浮算法
     */
    //把索引i关联的元素修改为t
    public void changeItem(int i,T t) {
        //修改items数组中i位置的元素为t
        items[i] = t;
        //找到i在pq中出现的位置并记录为k
        int k = qp[i];
        //堆调整
        sink(k);
        swim(k);
    }

    /*
     * 私以为这里有问题,因为调用了上浮算法,改变的是影数组的位置,但实际上我们的逆数组的位置
     * 也应该要相应进行改变,但是这里并没有做这样对应的改变,因此我觉得这一段其实是有问题的
     * 同样也包括下面的下沉算法,都是存在这个问题的。但是由于黑马的代码里没有提,因此这里也
     * 不多做修改,就这样吧
     */
    //使用上浮算法,使索引k处的元素处于正确位置
    private void swim(int k) {
        while (k>1){
            if(less(k,k/2)){
                exch(k,k/2);
            }
            k = k/2;
        }
    }

    //使用下沉算法,使索引k处的元素处于正确位置
    private void sink(int k) {
        while(2*k<=N){
            //找到子结点中的较小值
            int min;
            if (2*k+1<=N){
                if (less(2*k,2*k+1)){
                    min = 2*k;
                }else{
                    min = 2*k+1;
                }
            }else{
                min = 2*k;
            }
            //比较当前结点和较小值
            if (less(k,min)){
                break;
            }

            exch(k,min);
            k = min;
        }
    }
}

有兴趣的话还可以去实现下最大索引优先队列

2-3查找树

2-3查找树概述

之前我们学习了二叉查找树,其查询效率大部分时候都比链表要来得高,但是不幸的是,在最坏情况下,其查询效率仍然非常糟糕,因此我们要学习创建另外一种树,令其即使在最坏情况下,其查询效率仍然是良好的

2-3查找树的定义

那么什么是2-3查找树呢?

简而言之,2-3查找树就是含有两种特殊的结点,一种是2-结点,2-结点含有一个键值且连接另外两条链,左子树的2-结点的键钧小于2-结点,而右子树的的值都大于该结点,比如对于R结点而言,P结点的值小于R,而其右子树下的3-结点的S与X均大于R结点

另外一种结点是3-结点,含有两个键三条链,左子树的3-结点均小于该结点值,而中间的2-结点的值则正处于该两点的值之间,右边的2-结点的值则大于该3-结点的两个值,具体可以看图上的例子

其中2-结点与3-结点的命名方式是按照该结点其下有几条子树来命名的,而且其结点存储的值数量总为其子树数量-1,而且在2-3查找树中,只有2-结点和3-结点两种结点的存在

那么2-3查找树是怎么查找对应的值的呢?

以H为例,其先与M进行对比发现比M小则会进入3-结点EJ中查找,查找发现其在EJ中间进入2-结点H中查找,比较发现H就是所需要的值,那么查找成功

如果我们查找Z的值的话,最终会进入3-结点SX中,但SX后续已经没有结点了,那么就会查找失败

2-3查找树的插入

在我们实现2-3的查找树的插入之前,我们得先学习2-3查找树插入新结点的四种不同情况的处理方式。

首先我们来学习向2-结点中插入新建结点的情况的处理方式

可以看到我们要往2-3查找树立插入K,经过定位会发现要插入到2-结点L中,那么我们就应该将该2-结点转化为一个3-结点,其中K和L的谁放左边我们要通过比较来确定,这里经过比较发现L比K大,因此K放左边,L放右边

接着我们再来学习向一颗只含有一个3-结点的的树中插入新建结点的情况的处理方式

比方说我们要插入S,此时我们可以看到在3-结点AE中已经没有位置存储S了,那么我们就创建一个临时的4-结点,当然,元素的位置也是按照其大小排列的,接着我们将该4-结点分解,生成三个2-结点并令其构建连接,具体方式是将中间值E提出来创建一个2-结点,该2-结点分别指向左右两个值所创建的新2-结点

然后我们再来学习向一个父节点为2-结点的3-结点插入新结点的情况的处理方式

比方说我们这里要插入Z元素的话,我们显然会定位到3-结点SX中,然后我们同样要将SX转换为了一个临时的4-结点,然后将X提出来创建三个2-结点并联立,但是我们可以看到4-结点SXZ的父节点是一个2-结点,如果我们将X提出来并合并的话,我们就要对R做之前我们学习过的对2-结点里插入新结点的处理,因此我们这里将2-结点R转换为一个3-结点RX,经过比较发现X比R大,因此X放R的右边,其下的三条子树分别连接原先的2-结点和我们新创建的两个2-结点

最后我们来学习向一个父节点为3-结点的3-结点中插入新建的情况的处理方式

假设我们插入D,那么我们显然能定位到3-结点AC中,将其变为4-结点ACD,然后提出C会撞上其父节点EJ,因此再次构建4-结点CEJ,此时4-结点连接两个由原来的4-结点分解的新创建的2-结点和原先的两个2-结点

最后我们将E提出与M对撞,此时将M变为3-结点,分别连接新创建的两个2-结点CJ与原先的2-结点R,而4-结点CEJ分解产生的两个新2-结点CJ则分别连接原先4-结点其下的四个2-结点

俗话说,四大天王都有五个,因此我们接着要学习分解根结点的方法

同样我们假设要插入D结点,先遇上3-结点AC,将其变为4-结点之后,提出C遇上3-结点EJ,因此又有新的4-结点CEJ,且连接2-结点ADHL,此时将E提出,由于其没有父节点了,因此我们可以直接创建一个2-结点E出来,则E成为2-结点连接其下分解的两个2-结点CJ,2-结点CJ又分别连接原先的4个2-结点ADHL

此时我们的根结点由原先的3-结点EJ变为2-结点E,而且我们的树高+1

2-3树的性质

那么现在讲完了2-3查找树的原理之后我们接下来来学习2-3树的性质,首先2-3查找树的任意空连接到根结点的路径长度都是相等的,其次是当4-结点变换为3-结点时,除非是根结点是临时的4-结点,否则树高都不会发生变化,最后是2-3查找树与普通的二叉查找树最大的区别在于,普通的二叉查找树是自顶向下生成,而2-3树是自底向上生长,简单理解就是二叉查找树是直接往下增加结点来增加深度的,而2-3查找树增加深度的方式是以变换结点往上提的方式来增加深度的

2-3查找树的实现

显然我们知道如果我们要实现2-3查找树的话,属实是太麻烦了,因此我们这里就不实现了,那我们学习这个是为了什么?主要是为了给后面我们学习红黑树的B树打基础

红黑树

那么接着我们来学习红黑树,红黑树是2-3查找树的一种简单实现,红黑树背后的基本思想是用标准的二叉查找树和一些额外的信息来表示2-3树,而那些额外的信息就是不同的链接,我们将树中的链接分为两类,分别是红链接与黑链接,红链接表示的意思是将两个2-结点连接起来构成一个3-结点,而黑链接表示的意思是2-3树中的普通链接。

比方说对于上面的红黑树而言,E和J元原本是两个2-结点,但是用红链接联立起来,就成了3-结点EJ了,其对应的三个子树分别是CHL

接着我们再来看看红黑树的标准定义

对于第二条定义的理解,如果有一个结点同时和两条红链接相连,那么其就是一个4-结点了,而在2-3树里,是不允许4-结点的存在的,而红黑树又是2-3树的简单实现,其必然也遵从2-3树的规则,因此在红黑树里不存在同时和两条红链接相连的结点

对于第三条定义,我们可以直接看图

将红色链接水平绘制之后我们就比较容易理解,由红链接联立的两个结点是一个3-结点,那么我们在2-3查找树里说过2-3查找树的一个性质是其每一个叶结点到子节点的路径是相同的,那么在红黑树里,黑链接就是普通链接,红链接是将两个结点联立陈给一个3-结点的连接,我们算路径长度是就恶意忽略红链接,因此我们的任意叶结点到根结点上的路径是相同的,即其路径上的黑链接数量是相同的

如果我们将红链接去除,能够得到真正意义上的2-3树,当然,要自己合并结点就是

那我们应该怎么去实现红黑树呢?在这之前我们先来学习红黑树的结点类设计,因为我们首先需要确定其结点的API设计,先来看看其结点的API设计吧

显然,在红黑树里的节点都有父节点指向其的红链接或黑链接,那么我们可以用一个布尔类型的变量来表示红链接与黑链接,若为true,则说明是红链接,若为false,则说明是黑链接

根结点则相对特殊,其没有父节点的链接指向它,不过我们只要对根结点进行特殊处理就完了

红黑树的平衡化

在正式实现红黑树的数据结构之前,我们要先了解红黑树的平衡化方法,所谓平衡化,就是我们在添加元素时,我们所构造的树的结构可能会出现不符合红黑树数据结构的情况,此时我们需要通过平衡化方法令其恢复到红黑树的数据结构

左旋

红黑树的平衡化方法有两种,分别是左旋和右旋,这里我们先来讲讲左旋

当某个结点的左子节点为黑色,右子节点为红色时,此时需要左旋,这里我们设当前结点为h,其右子节点为x,请看下图

接下来我们来看看左旋的步骤

首先我们让当前结点h的右子树位置获得s的左子树,接着令h成为x的左子节点,第三步是将h结点的黑链接变为赋给x,这里之所以采用赋予的方式是为了令h的父节点能成功地指向x结点,不过这是我猜的,第四步由于只需要将h结点的链接颜色变为红就行了,因此直接将其color属性变为true

接着我们讲下右旋,什么时候我们需要右旋呢?当某个结点的左子树为红色,其左子树的左子树也为红色时,此时我们就需要右旋,同样的,我们这里也设置当前结点为h,h的左子树为x,请看下图

右旋

首先让x结点的右子树变为h结点的左子树,然后将当前结点变为其左子树的右子树,第三步是将当前结点的父指向赋予其左子树,第四步是将调转之后的结点h的链接颜色变为红

经过实际检验,我们会发现无论是左旋还是右旋,我们红黑树的有序性都是保持的。但问题在于右旋,显然,我们能够看到右旋之后产生了一个结点其左右子树都是红链接,这样显然不符合我们红黑树的定义,这个怎么解决呢?这个就要靠我们后面学习的颜色反转这一方式来进行解决

红黑树的插入

接着我们来学习红黑树的插入,由于红黑树的插入有多种情况,因此这里我们分情况来一个个进行讲解

首先是向单个2-结点中插入新键,这里要分两种情况,第一种情况是新建小于当前结点的键,如果是这种情况,那么我们只要将新建变为当前键的左子树并令当前键指向新建的链接变红即可

此时我们的红链接对应的ab结点所构成的整体是与3-结点无异的

第二种情况是我们的要插入的新键大于当前的键,那么我们就令新键成为当前键的右子树,同时令当前键指向改键的链接变红,这样还没完,因为在红黑树里,红链接不能出现在右子树中,此时我们需要左旋操作令红黑树恢复原来的结构

接着我们来讲第三种情况,向底部的2-结点插入新键

这个其实和我们上面讲解的两种方式比较类似,先来看看其理论解释

接下来我们来看看其图示

可以看到,我们这里也是先添加新键,然后用了一个经典的左旋操作完成了插入,唯一的不同应该在于这里的左旋操作需要让C准备地被E所指向,而最开始我们讲解的方法里显然不用,因为其只有两个结点,没有父节点指向,因此可以省略这一步骤

接着我们来讲讲颜色反转,所谓颜色反转,就是当我们的红黑树里出现一个结点其左右子树都是红链接时,此时我们需要使用颜色反转,令其恢复红黑树的数据结构

请看下图的颜色反转演示

一个结点的左右子树都为红链接时,此时可以将三个结点组成的正题视为一个4-结点,而我们的颜色反转模仿的其实就是4-结点分解为三个2-结点的过程(上图中左边表示我们的颜色反转过程,右边表示我们的2-3树里的分解过程),而我们之所以将其成为颜色反转,这是因为我们这个过程其实就是简单把黑链接变红链接,红链接变黑链接就能够完成了的,因此我们这里将这个行为称之为颜色反转

接着我们继续来讲我们的插入情况,接着我们讲向一棵双键树(即一个3-结点中插入新建)的插入方法

这里我们要分三种情况,第一种情况时新键大于原树中的两个键

这时我们按照二叉树的增加规则先将c结点插入到b的右子树中, 此时出现了一个结点里左右子树均为红链接的情况,这是使用颜色反转方法正式完成插入

第二种情况时新键小于原树中的两个键

同样先按照二叉树的增加规则先将a结点插入到b的左子树中,此时出现了一个结点里其左子树为红链接,其左子树的左子树同样为红链接的情况,那么我们首先调用右旋方法,之后再调用颜色反转方法即可完成插入

第三种情况是我们要插入的新键位于原树中的两个键之间

此时我们的b元素将插入到a的右子树之中,那么首先我们要调用左旋方法,接着调用右旋然后颜色反转即可完成插入

根结点的颜色总是黑色的

这里我们顺便讲一个只是,那就是根结点的颜色总是黑色的,因为我们之前讲的红黑树的结点类API设计里,我们是将红黑定义为父节点指向子节点的链接的颜色,而根结点是没有父节点的,因此我们每一次插入都要根结点的颜色设置为黑色

然后我们继续来讲我们的插入情况,现在我们来讲向树底部的3-结点插入新键的情况,同样会出现三种情况,同样我们只要调用我们之前学过的方法就能够解决这些问题,完成正确的插入

这里我们只讲一种情况来作为举例

可以看到我们将H结点插入到R结点的左子树中,那么首先我们调用右旋方法

记着再调用颜色反转方法,此时出现了E的左子树为黑链接而右子树为红链接的情况,再调用左旋方法即可完成正确插入

其实讲到这里我们也应该能总结出一些东西来了,就是在红黑树里面所有的结点都是2-结点,我们每插入新结点我们都期望其与原先的2-结点组成新的3-结点,因此我们无论在哪里插入新结点,其最开始的链接都必然是红链接

红黑树的实现

那么接下来我们就来正式用代码来实现红黑树这一数据结构,先来看看其API设计

这里我们之所以在成员变量里定义了RED和BLACK是因为,如果我们只用true和false来表示红黑的话,可读性不高,因此我们这里又额外创建了两个RED和BLACK链接标识来表示红黑,这样就能够有效提高其可读性

由API设计我们可以构造其代码如下

package cn.itcast.algorithm.heap;

public class ReaBlackTree<Key extends Comparable<Key>, Value> {
    //根结点
    private Node root;
    //记录树中元素的个数
    private int N;
    //红色链接
    private static final boolean RED = true;
    //黑色链接
    private static final boolean BLACK = false;

    //结点类
    private class Node {
        //存储键
        private Key key;
        //存储值
        private Value value;
        //记录左子结点
        public Node left;
        //记录右子节点
        public Node right;
        //由其父节点指向其的链接的颜色
        public boolean color;

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

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

    //判断当前结点的父指向链接是否为红色
    private boolean isRed(Node x) {
        //若结点为空则直接返回false
        if(x==null){
            return false;
        }
        //若结点不为空则返回比较值
        return x.color==RED;
    }

    /*
     * 这里之所以构建了x.color=h.color的代码而不是直接将x.color赋值为黑色
     * 的原因是h结点的颜色同时表达了其父节点对其自身的指向关系,而我们调用左旋
     * 方法是将两个结点换了位置之后还要让原先结点的子节点被原先结点的父节点给
     * 指向,因此我们这里要采用上面的代码,如果只是单纯的赋予黑色的话,就无法
     * 代表这种关系了,而且也有当前结点的父节点对其的指向其实是红色的可能,因此
     * 这里于情于理都是采用x.color=h.color的代码好
     */
    //左旋
    private Node rotateLeft(Node h){
        //获取h结点的右子节点,用结点x记录
        Node x = h.right;
        //让x结点的左子节点成为h结点的右子节点
        h.right=x.left;
        //让h成为x结点的左子节点
        x.left=h;
        //让x结点的color属性等于h结点的color属性
        x.color=h.color;
        //让h结点的color属性变为红色
        h.color = RED;
        //调用左旋方法之后原先结点与子节点交换了位置,因此返回原先结点的子节点
        return x;
    }

    //右旋
    private Node rotateRight(Node h){
        //获取h结点的左子节点,用结点x记录
        Node x = h.left;
        //让x结点的右子节点成为h结点的左子节点
        h.left=x.right;
        //让h成为x结点的右子节点
        x.right=h;
        //让x结点的color属性等于h结点的color属性
        x.color=h.color;
        //让h结点的color属性变为红色
        h.color = RED;
        //调用右旋方法之后原先结点与子节点交换了位置,因此返回原先结点的子节点
        return x;
    }

    /*
     * 按照颜色反转的方法演示实现方法,采用的是简单粗暴的直接赋值的方式,其实
     * 我个人觉得如果要在代码上表达颜色反转的意思的话,应该要构建if判断语句
     * 才比较合适,但是这里即使采用这种方式也并不不妥,所以没啥关系
     */
    //颜色反转
    private void flipColors(Node h) {
        //当前结点变为红色
        h.color=RED;
        //其左子树和右子树变为黑色
        h.left.color=BLACK;
        h.right.color=BLACK;
    }

    /*
     * 该方法将根结点与要插入的元素传入,然后调用真正执行插入的方法,将插入后的根结点重新赋值给root
     * 代表的意思是记录根结点的值root已经变换了,虽然说在红黑树里根结点一般都是不会变换的,一般而言
     * 这个操作似乎不用做,但我猜想这个方法存在的意义在于当根结点为临时的4-结点时,根结点会发生变换
     * 而且其深度会+1,此时我们将root更改为正确的根结点的方法就有了其意义
     */
    //在整个树上完成插入操作
    public void put(Key key, Value val) {
        root = put(root,key,val);
        //每次插入时重新赋予根结点的颜色为黑以确保根结点的链接不会因为在插入
        //时因为颜色反转等方法而改变颜色
        root.color=BLACK;
    }

    //在指定树中完成插入操作并返回添加元素后的新树
    private Node put(Node h,Key key, Value val) {
        //判断h是否为空,若为空则直接返回一个红结点,此处代表的意思是直接添加一个新的红结点到对应的子树中
        if(h==null){
            //结点数量+
            N++;
            return new Node(key,val,null,null,RED);
        }
        //比较h结点与要插入的值key的大小
        int cmp = key.compareTo(h.key);
        if(cmp<0){//如果插入值比当前的结点的值要更小
            //递归往左子树前进
            //这里构造将递归之后返回的结点重新赋值给h.left的原因是当我们调用了递归方法之后可能会因为各种
            //恢复其平衡性的方法而导致我们结点原先的指向变得混乱,这里构造这个代码的是为了每次递归结束之后
            //都要进行一次父节点对传入的子节点的重新指向,令红黑树本身变得有序,具体的过程我其实还不明白,
            //因为我没有拿例子去演示,但是我猜想应该就是这个原因
            h.left = put(h.left,key,val);
        }else if(cmp>0){
            //递归往右子树前进
            h.right = put(h.right,key,val);
        }else {
            //若相等,则发生值的替换
            h.value=val;
        }

        //插入完毕之后进行平衡性的恢复,其实我个人觉得这里是有问题的,因为我觉得恢复其平衡性并不是只要每次
        //都左右旋反转有必要就都执行一次就可以了,而是执行到结点确实不需要这些方法来恢复其平衡性的时候再停
        //止,但既然它都这么构建了,那就当是这样吧

        //进行左旋:当前结点的h的左子节点为黑色,右子节点为红色,需要左旋
        if(isRed(h.right) && !isRed(h.left)){
            //这里对h进行重新赋值,因为左旋会将结点值与其子节点值的位置互换
            //但是我们的h不应该发生变换,仍然是要定位到原先的位置,因此这里
            //每次左旋将该位置的新结点重新赋予给h,这样做也是为了上面插入方
            //法的重新指向的实现
            h = rotateLeft(h);
        }

        //进行右旋:当前结点h的左子节点及其左子节点的左子节点均为红色时,需要右旋
        if(isRed(h.left) && isRed(h.left.left)){
            h = rotateRight(h);
        }

        //颜色反转:当前结点h的左右子节点均为红色时,需要颜色反转
        if(isRed(h.left) && isRed(h.right)){
            flipColors(h);
        }

        //返回被修改的新结点
        return h;
    }

    //根据key,从树中找出对应的值
    public Value get(Key key) {
        return get(root,key);
    }

    /*
     * 红黑树如果我们忽略红黑颜色的特殊性来看的话,其实它就是普通的二叉查找树
     * 那么我们这里从树中查找key对应的值的方法其实跟二叉查找树别无二致,这个
     * 应该很好理解
     */
    //从指定的树x中,查找key对应的值
    public Value get(Node x,Key key) {
        if(x==null) {
            return null;
        }

        //比较x结点的键和key的大小
        int cmp = key.compareTo(x.key);
        if(cmp<0){
            return get(x.left,key);
        }else if(cmp>0){
            return get(x.right,key);
        }else {
            return x.value;
        }
    }
}

B+树

之前我们学习了二叉查找树以及红黑树,但他们每个结点最多存储两个key,比如在红黑树立就利用红链接组成3-结点存储两个key。那么接下来我们就学习能够存储多个key的数据结构,B树

B树

B树中允许一个结点包含多个key,能包含多少个key,我们就称该树为多少阶的B树,姑且将这个多少用参数M来代替,那么B树就有三个重要性质

1-每个结点最多有M-1个key,并且以升序排列

2-每个结点最多有M个子节点(两边各有一个结点,每两个数据间又会夹杂一个结点,总和为M)

3-根结点至少有两个子节点(至少两边各有一个结点)

在我们的实际场景中,B树的阶数通常是大于100的,所以即使存储大量的数据,B树的高度仍然比较小,在某些应用场景里就能体现出这个数据结构的树的高度小的优势

B树的插入原理

接下来我们来具体讲解下其是如何完成插入动作的,请看图,这里我们取M=5为例

可以看到我们先插入39,此时39最小因此我们将其置于最左边。接着插入22,97,41,全部按照升序排列,最终就将一个树插满了元素,然后我们再插入53

此时53比41大比97小,此时就会产生个一个临时的6-结点,其数据分别是22,39,41,53,97,而我们要将结点中间的数据往上提,就是把41往上提,然后分别构建三个新的未填满的5-结点

接着我们继续插入53,按照二叉查找树的查找原则查找就将其添加在根结点的右子树中的第一个中,再插入13,21,那么左子树就填满了

然后我们再插入40,此时40小于41又大于39,则根结点的左子树形成了一个临时的6-结点,其数据是13,21,22,39,40,此时我们将中间的数据往上提,那么22提出来和41组合,然后产生两个新未填满的5-结点,同样的还有一个先前存在的5-结点,他们都被根结点所连接

接着我们插入30,27,33,36,35,34,24,29,那么根结点和根结点其下的一个子树就都被填满了,接着我们插入26,显然26大于24但是小于27,于是在其左子树下形成一个临时的6-结点24,26,27,29,30,我们将中间的27往上提,剩下的两个数据形成两个新的5-结点,提上去的27又和根结点刑形成一个新的6-结点22,27,33,36,41,此时将33提出来,形成三个新的未满的5-结点,并最终将他们分别指向

其实整个过程和2-3树差不多,无非是这里复杂了些罢了,本质还是没变的

B树在磁盘文件中的应用

那么讲了这么久,我们还是要回归到一个本质问题,就是B树就什么用呢?这我们就得先讲解我们的磁盘保存和传入传出数据的原理

首先磁盘由盘片构成,每个盘片有两个盘面,盘片中央有可以旋转的主轴,使盘片以固定速率旋转,通常是5400rpm或7200rpm,盘片每个表面由一组成为磁道同心圆组成的,每个磁道被划分为了一组扇区,每个扇区包含相等数量的数据位,通常是512个字节扇区之间由一些空隙隔开,这些空隙不存储数据

一个磁盘中包含了多个这样的盘片封装在一个密封的容器内

那么磁盘是如何被读取的呢?这里我们要提及磁头这个概念,磁盘是用磁头来读写存储于盘片表面的数据的,一次对磁盘的访问要经过三个阶段,分别是利用移动臂将磁头定位到对应磁道的寻道时间,磁盘本身要进行旋转让数据被磁头读取旋转时间,以及将数据从磁头传送过来的传送时间

由于存储介质的特性,磁盘本身的存储是比较慢的,但是顺序读取的效率是比较快的(不需要寻道时间,只需要很少的旋转时间),为了提高效率,所以我们要尽量减少对磁盘的读取,为了达到这个目的,磁盘往往不是按需读取的,而是每次都会预读入一定长度的数据放入内存中,预读的长度一般是页的整数倍

那么什么是页呢?页是计算机管理存储器的逻辑块,硬件及操作系统往往将内存和磁盘存储区分割为连续的大小相等的块,每个存储块就成为一页(其大小为1024个字节或其整数倍)

内存和磁盘以页为单位交换数据,程度就在内存中读取数据,当程序要读取的数据不在内存中时,会触发缺页异常,此时系统会向磁盘发出读盘信号,磁盘会找到数据的其实位置并向后连续读取几页载入内存中,然后异常返回,程序继续运行

文件系统的设计者就利用了磁盘预读原理,将一个结点的设为等于一个页的大小,这样每个结点只要一次I/O就可以完全载入,那么三层的B树就可以容纳大概10亿个数据,如果换成二叉查找树,就需要30层

显然30层比3层的查找效率要低得多,因此B树在这里其高度小的优势就出来了,能够大大提高IO的操作效率

B+树

学习完了B树之后,现在我们来学习B+树,B+树其实是B树的一种变形树,其与B树主要有这两点差异

也就是说,B树只在叶结点处存储对应的数据,且所有的叶结点共同组成了一个链表

B+树的插入原理

现在让我们来学习下B+树的插入原理,同样的我们以M=5为例

我们现在空树中插入5,8,10,15,此时填满了第一个树,再插入16就形成了临时的6-结点5,8,10,16,15,我们将10的值提出,形成三个未满的5-结点,但与B树不同的是,我们只提出10的值创造一个新结点,而其他的值按照左二右三的分配方式分配到两个新创建的左右子树中

其他的过程和B树的几乎是一模一样,我这里就不多提一遍了

B树和B+树的对比

简而言之,B+树对比B树在同内存中B+树能存放更多的key,其次是B+树对整棵树进行遍历可以线性遍历叶子结点,而B树则要使用到递归遍历,显然前者更加简单方便

但是B树也有优点的,B树的优点在于只要找到key就能找到value,如果想要通过key找到value除非你在最坏情况下,不然肯定比B+树方便,因为B+树一定要定位到子节点才能找到对应的value

B+树的数据库引用

我们都知道在数据库的操作中,查询是最频繁的操作,因此我们在设计数据库中首先要考虑的就是数据库的查询效率,为了提高查询效率,我们可以基于某张表的某个字段建立索引来提高查询效率,其实这个索引就是B+树这种数据结构实现的

我们不妨先来看看这张表

假设我们要查找id为8的数据,那我们就只能从头开始遍历,那就要查找6次

接着我们来建立主键索引查询

那么建立完了主键索引查询之后,我们此时要查找到18的话,只需要三次查询就够了,第一次查询定位到第二个结点,第二次是12,第三次到18

而有时候我们需要建立区间查询,比如说我们要查找12-22区间的数据,这时由于B+树底部是按照升序排序的,因此区间查找也会很方便,效率也很高

并查集

接着我们来学习树的最后一种结构,并查集。并查集是一种树型的数据结构,并查集应该要可以高效执行查询元素PQ是否在同一组以及合并元素p和元素q所在的组这两个动作

并查集相对二叉树红黑树B树那些而言,其要求要简单得多,主要有以下四个

具体我们可以看下面的例子

可以看到我们一共有三组数据,每组数据都对应一棵树,而且树中的结点排序没有什么固定要求,两个不同组就对应不同的树,树与树之间也没有任何联系

那么我们要如何去实现并查集的两个重要方法呢?就第一个而言,判断两个结点是否是同一组,其实就是判断两个结点是否是同一棵树,那么我们只要判断这两个结点的根结点是不是一样的就行了,如果是则说明其为同一组,反之则说明不是同一组

而我们要实现合并两组数据的方法的思路也很简单,只要将要合并的两组数据的其中一个根结点指向另一个的根结点就行了

接下来我们来看看其API设计

这里我们成员变量主要有二,一是一个int类型的数组,该数组不但可以记录结点元素,还可以记录该元素所在分组的标识,而第二个int类型的变量则是用于记录并查集中的分组个数,注意是分组个数,而不是元素个数

在实现代码之前,我们要先讲讲其构造方法的实现

初始情况下,每一个元素都有一个独立的分组,因为当我们传入数据为N时,我们的并查集就就有N个组,而且我们将其索引的值都存储到对应索引中,这里就是将每个索引的对应的值赋给对应数字的位置用来视为分组的标识符,每个标识符都不一样,因此传入N,就会有N个组

这个可能不是特别好理解,但是多看几遍其实还是能理解到的,下面也有加深理解的案例,所以不用太慌

接着我们再来讲下合并方法的实现

我们之前讲过实现两个不同组的思路是将其中一组的头结点指向另一个组的头结点就完了,那么在数组底层中我们可以转变成将两个拥有不同标识符的元素的组中的其中一组的标识符变成另外一组的标识符,比方说在上图中的元素一共处于三个组中,其中013处于一个组,245处于一个组,678处于一个组,如果我们想要将013组与245组合并,我们只需要将013的索引处的值全变为2或者是245索引的值全变为0就行了

那么根据上面的理解我们可以构造其代码如下

package algorithm.sort;

/*
 * 这个并查集本身倒是不难理解,但是说实话,最开始讲并查集的时候是有
 * 树的,但后面实现并查集的代码是用数组实现的,只不过我们理解时可以
 * 将其抽象为树的实现,说实话这个抽象过程还是挺难理解的,如果直接理
 * 解成数组的话感觉要容易理解得多,而且并查集的底层用数组来实现的方
 * 式说实话效率不高
 * 但总之是实现了,实现了就行了
 */
public class UF {
    //记录结点元素和该元素所在分组的标识
    private int[] eleAndGroup;
    //记录并查集中数据的分组个数
    private int count;
    //初始化并查集
    public UF(int N){
        //初始化分组的数量,默认情况下,有N个分组
        this.count=N;
        //初始化eleAndGroup数组
        this.eleAndGroup = new int[N];
        //将数组中每个索引的值赋予数组内部,代表不同分组的标识符
        for (int i = 0; i < eleAndGroup.length; i++) {
            eleAndGroup[i]=i;
        }
    }

    //获取当前并查集中的数据有多少个分组
    public int count(){
        return count;
    }

    //元素p所在分组的标识符
    public int find(int p){
        return eleAndGroup[p];
    }

    //判断并查集中元素p和元素q是否在同一分组中
    public boolean connect(int p,int q){
        return find(p) == find(q);
    }

    //把p元素所在分组和q元素所在分组合并
    public void union(int p,int q){
        //判断元素pq是否已经在同一组中
        if(connect(p,q)){
            return;
        }
        //代码执行到此说明不为同一组
        
        //先找到p所在分组的标识符
        int pGroup = find(p);
        
        //找到q所在分组的标识符
        int qGroup = find(q);
        
        //合并组:将p所在组的所有元素的组标识符变为q所在组的标识符
        for (int i = 0; i < eleAndGroup.length; i++) {
            if(eleAndGroup[i]==pGroup){
                //如果其为p分组的表示符,则将其修改为q分组的标识符
                eleAndGroup[i] = qGroup;
            }
        }
        
        //分组个数-1
        this.count--;
    }
}

并查集的应用场景及其缺陷

那么并查集有什么用呢?我们可以假设我们并查集里存储的每一个整数是一个网络中的计算机,此时我们就可以通过connected(int p,int q)来检测其是否联通,若联通则说明可以通信,反之则不行。如果不连通的话,那么我们还可以调用union方法令其联通

但是,如果我们要对N个不同组的计算机全部连通的话,我们要调用几次union方法呢?答案是N-1次,而我们每次调用union方法又要进行一次数组遍历,那我们合并算法的时间复杂度就是O(N^2);,这显然是需要优化的算法

UF_Tree算法优化

那么为了提高union算法的性能,我们需要重新实现find和union方法,此时我们需要对数组中的含义进行重新定义

我们仍然让数组的索引作为某个结点的元素,但该结点的值不再是当前结点所在的分组表示,而是该结点的父节点(其实我一开始我就是这么理解的,结果一开始它居然不是这样的形式,终究还是错付了啊)

在API设计上,我们不用做什么改动,只需要将find和union方法进行修改就可以了,那么我们现在先来讲讲find方法的实现原理,请看下图

光看图理论可能难以理解,我们来直接看看演示图吧

我要说的话图上都说完了,不就不重复一遍了,这里我说点别的。

虽然这里底层是调用的是数组的结构,但其实我们是可以将其抽象为一个树的结构的,只不过这个树没有指向,只有连接而已,说实话这可比上一个并查集要好理解多了

那么接着我们来讲下如何实现合并方法,请看下图

我们先找到pq的根结点,再判断其是否在同一个树中,若是,则无需合并,若不是则进行合并,合并的过程很简单,只要将p元素的根结点设置为q元素的根结点即可,为什么这样设置就可以完成合并了呢?请看下图的演示过程

可以看到,我们通过数组能抽象出两个树,其根结点分别是5和8,我们只要将5索引处的值5改为8索引处的值8,那么就是完成了数组的合并了,因为此时我们同样进行树的抽象能够抽象出一个完整的具有先前两个树的结点的一个树来

而且这个树的根结点为8,左右子树分别为0135,76。这里我们是让长的树被短的树的根结点指向,但如果我们令其过来,令短树被长树指向,那么我们的树的就会变成根结点是5,左右子树分别为013,678的树,但无论是何种情况,我们都能够将树合并,只是树的样子有所不同罢了

那么综上我们可以实现并查集的改良代码如下

package algorithm.sort;

public class UF_Tree {
    //记录结点元素和该元素所在分组的标识
    private int[] eleAndGroup;
    //记录并查集中数据的分组个数
    private int count;
    //初始化并查集
    public UF_Tree(int N){
        //初始化分组的数量,默认情况下,有N个分组
        this.count=N;
        //初始化eleAndGroup数组
        this.eleAndGroup = new int[N];
        //将数组中每个索引的值赋予数组内部,代表不同分组的标识符
        for (int i = 0; i < eleAndGroup.length; i++) {
            eleAndGroup[i]=i;
        }
    }

    //获取当前并查集中的数据有多少个分组
    public int count(){
        return count;
    }

    //查找某个结点的父节点
    public int find(int p){
        //此处传入的p是我们要查找的结点
        while (true){//构建不断查找的循环
            //如果要查找的元素p(在数组中对应其索引)正好与其值相同
            //则说明我们找到了其根结点,返回p即可
            if(p == eleAndGroup[p]){
                return p;
            }
            //如果没找到,则令p变为p索引处的值,其动作意义是将p
            //在树中往根结点前进一位,在数组中表达的意义是将p定
            //位到其值所在的索引
            p = eleAndGroup[p];
        }
    }

    //判断并查集中元素p和元素q是否在同一分组中
    public boolean connect(int p,int q){
        return find(p) == find(q);
    }

    //把p元素所在分组和q元素所在分组合并
    public void union(int p,int q){
        //找到p元素和q元素所在组对应的树的根结点
        int pRoot = find(p);
        int qRoot = find(q);

        //判断pq是否已经在同一分组
        if(pRoot==qRoot){
            //如果已经在了则无需合并,直接结束方法
            return;
        }

        //让p所在的树的根结点的父节点为q所在树的根结点的父节点
        eleAndGroup[pRoot] = qRoot;
        
        //组的数量-1
        this.count--;
    }
}

那么我们同样来分析下改良后的并查集的时间复杂度,由于我们优化了union方法,此时我们只要调用N-1次union方法就能达到目的,而union自身每次运作只要一次就能定位成功,因此时间复杂度就变成了O(N);

但是不幸的是,由于我们还修改了find算法,而修改后的find算法里构建while循环,当我们的find算法遇到最坏情况时,其复杂度时O(N);

而我们又在union方法中调用了find方法,因此我们的时间复杂度仍然为O(N^2);

显然,我们还要对代码进行进一步的改良。那么我们改良的思路是什么呢?可以看到,我们之所以会出现时间复杂度为O(N^2);的情况是因为当我们的find算法遇见了最坏情况时其复杂度是O(N);,而之所以其会出现时间复杂度为O(N);的情况是因为我们的抽象的树的高度太高了,这样每次要取到父节点都要循环遍历到最底部才能找到父节点,那如果我们能够让树的高度保持在一定程度时,那是不是就能够有效降低其时间复杂度了呢?因为当我们的树总是在一定高度时,那我们无论是用寻找哪个结点的父节点,其总运算次数总是会小于该树的最大深度

那我们要怎么让树的高度降低呢?我们的一个简单想法就是当两个抽象树合并时,我们让结点数量较低的树的根结点被结点数量较高的树的根结点所指向,这样我们可以构造出一个高度不会增加的新树了,如下图所示

为了实现这个目的,我们需要另外一个数组来记录存储每个根结点对应的树中的元素,并且需要一些代码来调整数组中的值。同时我们也要修改union方法里的代码,再不能让其进行如此简单暴力的无脑指向,应该每次都准确地让小树被大树指向

来看看最终改良的并查集的API设计吧

除了多了一个数组之外,其他方法倒是没有什么不同

那么我们可以构造最终改良并查集的代码如下

package algorithm.sort;

public class UF_Tree {
    //记录结点元素和该元素所在分组的标识
    private int[] eleAndGroup;
    //记录并查集中数据的分组个数
    private int count;
    //用来存储每一个根结点对应的树中保存的结点的个数
    private int[] sz;
    //初始化并查集
    public UF_Tree(int N){
        //初始化分组的数量,默认情况下,有N个分组
        this.count=N;
        //初始化eleAndGroup数组
        this.eleAndGroup = new int[N];
        //将数组中每个索引的值赋予数组内部,代表不同分组的标识符
        for (int i = 0; i < eleAndGroup.length; i++) {
            eleAndGroup[i]=i;
        }
        this.sz = new int[N];
        //默认情况下,sz中每个索引处的值都是1,此处其抽象意义表示的是每个树中
        //都只存储着一个根结点
        for (int i = 0; i < sz.length; i++) {
            sz[i] = 1;
        }
    }

    //获取当前并查集中的数据有多少个分组
    public int count(){
        return count;
    }

    //查找某个结点的父节点,也是根结点,union方法里需要此方法
    public int find(int p){
        //此处传入的p是我们要查找的结点
        while (true){//构建不断查找的循环
            //如果要查找的元素p(在数组中对应其索引)正好与其值相同
            //则说明我们找到了其根结点,返回p即可
            if(p == eleAndGroup[p]){
                return p;
            }
            //如果没找到,则令p变为p索引处的值,其动作意义是将p
            //在树中往根结点前进一位,在数组中表达的意义是将p定
            //位到其值所在的索引
            p = eleAndGroup[p];
        }
    }

    //判断并查集中元素p和元素q是否在同一分组中
    public boolean connect(int p,int q){
        return find(p) == find(q);
    }

    /*
     * 说实话我觉得这里union方法还有问题,而且创建的数组不是为了表示抽象树的高度而是元素个数
     * 我也觉得有些奇怪,总感觉某些地方过不去又或者说是很奇怪,而且在抽象图上我也不能理解地特
     * 别明白,只能说明白个七七八八而已。但我又没法举出一个很好的反例出来,那就先这样吧
     */
    //把p元素所在分组和q元素所在分组合并
    public void union(int p,int q){
        //找到p元素和q元素所在组对应的树的根结点
        int pRoot = find(p);
        int qRoot = find(q);

        //判断pq是否已经在同一分组,因为都是父节点所以可以直接比较
        if(pRoot==qRoot){
            //如果已经在了则无需合并,直接结束方法
            return;
        }

        //先判断pRoot和qRoot谁的树大,并将较小的树合并到较大的树中去
        //都是父节点,因此可以直接传进sz[]中进行其值的比较
        if(sz[pRoot]<sz[qRoot]){
            //如果A小于B,则让A的根结点被B指向,代码上是令A索引处的值变为B索引的值
            eleAndGroup[pRoot] = qRoot;
            //将B树的元素数量加上A树元素的数量
            sz[qRoot]+=sz[pRoot];
        }else {
            eleAndGroup[qRoot] = pRoot;
            sz[pRoot]+=sz[qRoot];
        }

        //组的数量-1
        this.count--;
    }
}

畅通工程

最后我们用一个案例来加深我们对并查集的印象,请看题目

image-20220817210355580

注意这里的连通是只要A能到达B就算是连通的意思啊,比方说A连通B,B连通C,那么我们也算AC连通,因为A能到达C(这个正好对应并查集里的抽象的树的结构),那么显然这个题目要求就正好对应我们的并查集的情况,因此我们可以利用并查集求解

那我们的思路其实就可以是如图所示

image-20220817210409939

这个其实好理解,先将题目的状态表示出来,然后剩余组数-1就是我们还要修建的道路数目。因为其实每调用一次union方法就是让两个城市连通,而我们要让所有城市连通的话,就要调用N-1次union方法,因此我们只需要求出修建七条道路时还剩下的并查集的组数-1就是其还要修建的道路数了

那么我们可以构造代码如下

image-20220817210448766

最后我们来解释下这个代码,构建缓冲读入流的方法前面好理解,后面搞得这么复杂我属实没整明白,回去看JavaSe里的IO内容,也没有提及这种方式,有时间就去问问其他人吧

接着读入第一行代码,该方法是字符输入流BufferedReader的特有方法

然后利用了字符串里的split();方法,可以传入特定字符串然后将字符串按照传入字符串分割开并返回一个分割之后的字符串数组

这里两个数据之间隔着一个空格,因此传入一个空格字符将其分割之后返回

其他的代码就是调用并查集中的方法的代码了,不多提了