数据结构<堆>

🎇🎇🎇作者:
@小鱼不会骑车
🎆🎆🎆专栏:
《数据结构》
🎓🎓🎓个人简介:
一名专科大一在读的小比特,努力学习编程是我唯一的出路😎😎😎
在这里插入图片描述

1. 优先级队列

1.1 概念

小鱼在之前的一篇博客中详细的介绍过队列,队列是一种先进先出(FIFO)的数据结构,但是有些情况下,操作的数据可能带有优先级,一般出队列时,可能需要优先级高的元素先出队列,例如我们在追剧时,如果是电池没电了,手机会优先提醒我们该充电了,而不是在等到你退出追剧的时候才弹出来,这时候就体现了优先级的重要性,包括打游戏时突然来电话,万一是很重要的电话,没有第一时间接到,是不是就可能产生很严重的后果!

在这种情况下,数据结构应该提高两个最基本的操作,一个是返回最高优先级对象,一个是添加新的对象.这种数据结构就是优先级队列(Priority Queue).

2. 优先级队列的模拟实现

JDK1.8中的PriorityQueue底层使用了堆这种数据结构,而堆实际就是在完全二叉树的基础上进行了一些调整.

2.1 堆的概念

如果有一个关键码的集合K = {k0,k1, k2,…,kn-1},把它的所有元素按完全二叉树的顺序存储方式存储 在一个一维数组中,并满足:Ki <= K2i+1 且 Ki<= K2i+2 (Ki >= K2i+1 且 Ki >= K2i+2) i = 0,1,2…,则称为 小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。

堆的性质

  • 堆中某个节点的值总是不大于或不小于其父节点的值;
  • 堆总是一棵完全二叉树。

如下图:

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

2.2 堆的存储方式

从堆的概念可知,堆是一颗完全二叉树,因此可以按照层序遍历的规则采用顺序的方式来高效存储.

对于非完全二叉树实现的堆,如下图,该二叉树会造成空间上的浪费.

在这里插入图片描述
因此:对于非完全二叉树,则不适合使用顺序方式进行存储,因为为了能够还原二叉树,空间中必须要存储空节点,就会导致空间利用率比较低

完全二叉树实现的堆如下图:

在这里插入图片描述

2.3 堆的创建

2.3.1 堆向下调整

对于集合{ 23,25,64,35,18,29,33,14,16,27 }中的数据,如果将其创建成堆呢?

我们先把它的逻辑结构画出来!

在这里插入图片描述

我们需要将该结构调整为大根堆.

大家先看过程,一边观看过程,一边理解思路.

步骤一: 找到最后一颗含有孩子节点的树

在这里插入图片描述

步骤二:

  • 判断左右孩子结点是否存在
  • 找出左右孩子节点的最大值(大根堆)
  • 比较该子树的根节点也就是下标4对应的结点,判断下标为九的结点是否大于根节点
  • 如果大于根节点,那就交换,如果小于则不进行交换

步骤三:由于大于子树的根节点,那么进行交换

在这里插入图片描述

步骤四:下标为4的这个结点对应的子树已经是大根堆了,下一步对下标为3的这个结点,将该树改为大根堆

在这里插入图片描述

步骤五:重复步骤二,将下标为2的结点对应的子树调整为大根堆

在这里插入图片描述

步骤六:对下标为1的结点所对应的树进行调整,依旧是重复上述操作

在这里插入图片描述

步骤七:此时该子树并没有调整完,需要注意!!!由于下标为1的结点和下标为3的结点进行了交换,那么还需要进行一步操作就是判断,被交换值的下标为3的结点是否是下标为3的节点所对应的这颗树中的最大值!

在这里插入图片描述

步骤八:现在对下标为0的结点进行调整(下标为2和下标为0的结点进行了交换),红色框对应的是该结点所对应的树,绿色的框是正在进行比较的树

在这里插入图片描述

步骤九:由于前面进行了交换,将下标为2的值进行了修改,此时并不能确认下标为2所对应的树是否还是大根堆,所以需要重复步骤二的操作,也就是找出左右孩子结点的最大值进行比较.

在这里插入图片描述

此时,该完全二叉树也就变成了一个大根堆,任何一颗子树的根节点都大于它的任何一个孩子节点.

如下图(每一个框框所对应的树的根节点都大于它的孩子结点):

在这里插入图片描述
上述的操作我们称之为向下调整.

此时我们再去观看下面的过程是不是更容易理解.

向下过程(以大堆为例):

  1. 让parent标记需要调整的节点,child标记parent的左孩子(注意:parent如果有孩子一定先是有左孩子)
  2. 如果parent的左孩子存在,即:child < size, 进行以下操作,直到parent的左孩子不存在:
  • parent右孩子是否存在,存在找到左右孩子中最大的孩子,让child指向左右孩子最大值所对应的下标.
  • 将parent与较大的孩子child比较,如果:
  • parent大于较小的孩子child,调整结束
  • 否则:交换parent与较大的孩子child,交换完成之后,parent中小的元素向下移动,可能导致子树不满足对的性质,因此需要继续向下调整,即parent = child;child=parent*2+1; 然后继续2的操作。

难点1:其实对于这道题的难点,就是找向下调整的边界,什么时候停止调整呢?
难点2:为什么要从最后一颗含有子节点的树开始向下调整.

讲解难点一:首先我们要知道,如果想要进行向下调整那么需要最基本的条件就是它有孩子结点,也就是至少存在左孩子,如果左孩子都不存在,那么该节点就不需要进行调整,此时问题就变成了,如何判断左孩子是否存在!

我们看下图:

在这里插入图片描述

下标为4的结点是有孩子结点的,孩子结点的下标是9,在二叉树中我们学过,给父亲节点下标,求左右孩子结点的下标,根据公式(求左孩子下标)child = parent * 2 +1.那么它是否有右孩子结点呢?我们看图片可以很清楚的看到他是没有右孩子结点的,我们假设它有右孩子结点,我们求出右孩子结点的下标:4*2+2 = 10,此时右孩子结点的下标为10,但是我们回归该图,该图只有十个结点,又因为下标是从0开始的,那么下标值最高为9,此时右孩子下标是10,说明以及越界了,并不包含右孩子,此时我们就可以找到这个边界,也就是child < 二叉树结点的个数,为了证明该判断的合理性我们试求下标5的孩子节点是否存在,答案是不存在( 5 * 2 + 1 = 11 11 > 二叉树结点的个数,不符合条件),下标6也如此,都是不存在孩子结点的.

讲解难点2:为什么要从最后一个含有子节点的树进行向下调整?

我们先举个反例:

在这里插入图片描述

若我们只对0下标对应的结点进行向下调整,大家看上图,此时左右子树都小于根节点,所以不再进行向下调整,但是可以看到,绿色框框里面的树还并不是大根堆,所以从0开始调整是有可能会出错的!

由于我们是从最后一颗子树进行调整的,那么假如我们调整到了下标为1的结点所对应的子树,我们可以保证,比下标1大的下标(例如2,3,4…)所对应的结点的子树一定是大根堆, 而且我们还可以得到一个信息就是:

在这里插入图片描述

代码如下:

   public void createHeap(){
   //从最后一个结点的父亲结点开始向下调整
        for (int parent = (UsedSize-1-1)/2; parent >=0 ; parent--) {
         	//UsedSize就是结束条件,也就是节点个数
            shiftDown(parent,UsedSize);
        }
    }
    /**
     * 父亲下标和每棵树的结束下标
     * @param parent
     */
    //大根堆
    private void shiftDown(int parent,int len) {
    //接受了父亲节点
    //求出子节点下标
        int child=2*parent+1;
        //判断孩子节点下标是否越界
        while (child<len) {
        //判断右节点是否存在,如果存在找出左右节点最大值(顺序不能颠倒)
          if(child+1<len && elem[child]<elem[child+1]) {
              child++;
          }
          //子节点大于父亲节点就交换
          if(elem[child]>elem[parent]) {
              swap(elem,parent,child);
              //此时的父亲节点指向被修改后的子节点
              parent=child;
              //子节点依旧是该父亲结点所对应的左孩子结点
              child=2*parent+1;
          }else {
          //如果没有交换就直接退出
              break;
          }
        }
    }
    private void swap(int[]array,int left,int right) {
        int tmp=array[left];
        array[left]=array[right];
        array[right]=tmp;
    }

我们来算一下向下调整的时间复杂度,大家看下图:

在这里插入图片描述

时间复杂度分析
最坏的情况即图示的情况,从根一路比较到叶子,比较的次数为完全二叉树的高度,即时间复杂度为O(logN)

2.3.2 建堆的时间复杂度

我们按照最坏情况考虑,也就是每个结点都需要进行调整,并且每个结点调整的高度都是它所在层数到叶子层数的距离.

在这里插入图片描述
则需要移动总的步数为:

T(n) = 2 ^ 0 * ( h - 1 ) + 2 ^ 1*(h - 2) + 2 ^ 2 * ( h - 3) + 2 ^ 3 ( h - 4 ) +…+ 2 ^ ( h - 3 ) * 2 + 2 ^ ( h - 2 ) * 1 ①
2 * T(n) = 2 ^ 1 * ( h - 1 ) + 2 ^ 2
(h - 2) + 2 ^ 3 * ( h - 3) + 2 ^ 4 ( h - 4 ) *+…+ 2 ^ ( h - 2 ) * 2 + 2 ^ ( h - 1 ) * 1 ②
② - ① 错位相减:

T(n) = 1 - h + 2 ^ 1 + 2 ^ 2 + 2 ^ 3 + 2 ^ 4 +…+2(h-2)+2^(h-1)
T(n) = 2 ^ 0 + 2 ^ 1 + 2 ^ 2 + 2 ^ 3 + 2 ^ 4 +…+2(h-2)+2^(h-1) - h
T(n) = 2 ^ h - 1 - h
n = 2 ^ h -1
h = log(n+1)
T(n) = n - log(n+1) ≈ n

因此:建堆的时间复杂度为O(N)

2.3.3 堆的向上调整

刚才讲完了向下调整,那么这次我们讲解一下什么是向上调整.

向上调整一般使用的范围是在建堆的时候,还有插入结点的时候.

插入举例:

向上调整需要一个前提,就是当前的堆需要是大根堆(小根堆),我们以大根堆举例:

在这里插入图片描述

假如99这个结点是新插入的,需要将该树的结构重新改为大根堆,那么需要进行向上调整,具体过程就是:

99和它的父亲节点,也就是下标为4的结点进行比较,如果比父亲节点大,那就交换,此时就跟上述图片的第二步一样,再去用99的结点和它当前的父亲节点进行比较(也就是下标为1的结点),如果比父亲结点大,那就交换,重复上述操作.

问题:不需要左右孩子结点比较吗?

答案:不需要,因为在插入之前,该树就是一个大根堆.

我们用红色框框里面的内容举例:在没有插入99这个结点时,下标为1的结点比下标为3,4结点的值都要大,所以我们可以知道,即使我们的99和下标为4的元素交换,由于下标为3的结点并没有改变,所以我们下标为1的结点还是比下标为3的结点大的,所以就不需要用新的下标为4的结点和下标为3的结点进行比较了.只需要和它的父亲节点进行比较就行.

代码入下:

    //插入、
    public void offer(int val) {
    //判满
        if(isFUll()) {
          elem= Arrays.copyOf(elem,elem.length*2);
        }
        //末尾添加新元素然后UsedSize++
        //UsedSize是记录数组长度的
        elem[UsedSize++]=val;
        //进行向上调整,传入最后一个叶子结点
        shiftUp(UsedSize-1);
    }
    //向上调整
    public void shiftUp(int child) {
    //找到该结点的父亲结点
        int parent = (child-1)/2;
        //边界就是孩子结点不能为0,如果孩子结点为0说明不需要调整了
        while (child != 0) {
            
            //不需要左右孩子结点进行比较,因为该孩子节点的另一个兄弟结点一定小于父亲节点
            //子节点大于父亲节点就交换
            if(elem[child] > elem[parent]) {
                swap(elem,child,parent);
            }else {
            //如果不大于那就直接退出
                break;
            }
            //不断地进行向上调整,
            //可以对照上述画的图片进行理解
            child = parent;
            parent = (parent-1)/2;
        }
    }

时间复杂度:最坏的情况即图示的情况,从叶子一路比较到根,比较的次数为完全二叉树的高度,即时间复杂度为O(logN)

建堆举例:

大家看图:

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
通过上述的过程,我们就通过向上调整建好了一个大根堆.

代码入下:

  //建堆
   public void createHeap(int[] array){
        for (int i = 0; i < array.length ; i++) {
            offer(array[i]);
        }
    }
    //插入、
    public void offer(int val) {
    //判满
        if(isFUll()) {
          elem= Arrays.copyOf(elem,elem.length*2);
        }
        //尾插要新增的结点
        elem[UsedSize++]=val;
        //将新插入的结点进行向上调整
        shiftUp(UsedSize-1);
    }
    //向上调整
    public void shiftUp(int child) {
        int parent = (child-1)/2;
        while (child != 0) {
            //子节点大于父亲节点
            if(elem[child] > elem[parent]) {
                swap(elem,child,parent);
            }else {
                break;
            }
            //子节点 = 父节点
            child = parent;
            //父节点 = 父节点的父节点
            parent = (parent-1)/2;
        }
    }
    //交换
      private void swap(int[]array,int left,int right) {
        int tmp=array[left];
        array[left]=array[right];
        array[right]=tmp;
    }

2.3.4 堆的删除

由于删除的是堆顶元素,我们思考一下,如何删除?有人会想到:将堆顶元素删除,然后所有元素向前移一位,最后再对每个结点进行向下调整.其实这个方法是行得通的,但却不是一个好的方法,因为所有元素前移一位就会打乱堆本身的顺序,再对堆中的每个元素向下调整,那么时间复杂度最坏可以达到O(N),那有没有什么好一些的方法吗?

答案是:有的.

注意:堆的删除一定删除的是堆顶元素。具体如下:

  1. 将堆顶元素对堆中最后一个元素交换
  2. 将堆中有效数据个数减少一个
  3. 对堆顶元素进行向下调整

我们看图:

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

    /**
     * 删除
     * @return
     */
    public int pop() {
        if(isEmpty()) {
            return -1;
        }
        int tmp = elem[0];
        //交换
        swap(elem,0,UsedSize-1);
        //元素个数-1
        UsedSize--;
        //调用向下调整,从0开始
        shiftDown(0,UsedSize);
        return tmp;
    }

时间复杂度 : O(logN) 即树的高度

评论 29
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值