堆排序算法精讲

堆排序序言

什么是堆排序?为什么要用堆排序?堆排序的优势是什么?这些都是值得我们思考的问题,在讲解堆排序之前,我们需要理清这些问题,熟悉堆的概念。

堆是什么?

想要详细了解可以看这个网页

这里我可以大概说说:
堆可以分为两种,分别是大根堆和小根堆,二者有什么区别呢?

最大堆:

在最大堆中,父节点的值比其每一个子节点的值都要大,如图所示:
在这里插入图片描述

由图易得,20比18和15大,18比9和10大,15比8和9大。

最小堆:

最小堆的性质与最大堆相反,父节点的值比其每一个子节点的值都要小,这里不再举例。

为什么要用堆排序?

堆排序是一个很重要的排序算法,它是高效率的排序算法,复杂度是O (nlogn),堆排序不仅是面试进场考的重点,而且在很多实践中的算法会用到它,比如经典的TopK算法、小顶堆用于实现优先级队列。

堆排序的有什么优势?

堆排序的时间复杂度仅为(ologn),样本数据大时展现出极大的优势,而且堆的逻辑思维清晰,与完全二叉树相似。

堆的逻辑结构

堆的逻辑结构与完全二叉树类似。
左边子节点位置设为 j,右边子节点的位置设为k,其父结点的1位置设为i。
则 j =2i +1 ; k=2i+2 ;这里我引用《清华Python》讲解。
在这里插入图片描述

堆的向下调整结构:

首先,有一个没有构建好的堆:
在这里插入图片描述
可以看出,2不能放在堆的根部!
那么我们现在开始调整2的位置,使其放在合适的位置:
首先将2从根部“移走”:
在这里插入图片描述
2移走后,那么我们来看看2是否能作为第二级的父节点,显然不可以,但是我们发现,9比2和7都大(所有数里最大),很明显,9可以作为根部的父节点,因此我们现在把9“填补”到上面的空位之中。
在这里插入图片描述
很好,现在我们完成了根部父节点的填充,但是现在第二层又缺少了一个父节点数值,2比8,5小又填补上,所以就让8上位吧(儿子足够强甚至能当爹):
在这里插入图片描述

同样的,8原来的位置又缺了,2又比(6,4)小,所以让6填充。
在这里插入图片描述

6的位置又缺了,但是6没有儿子欸,2又必须得找一个归宿,那就让2当儿子吧(没实力只能当儿子)。
总结一下,就是这样:
在这里插入图片描述
向下调整过程中,任何数值该放在哪里,还是得比较该数值和子节点的大小关系,总之一句话,牛逼儿子能当爹。

堆排序的实现:

1.先建立一个堆,堆顶的元素为最大元素。
2.去掉堆顶的元素(把最大元素提出去)
3.把最后一个元素放到堆顶当中,来一次堆的向下调整,获得堆的第二大元素(此时放在堆顶)。
4.重复操作 2和3,直到把整个堆榨干挖空。

疑惑解答:

为什么这么做?

你们肯定很好奇,为什么要这样子做呢?
其实这样子做,就是在一步步地把堆里面的元素按照从小到大(从大到小)丢出去,不知不觉之中就已经排好序啦,是不是很神奇?

为什么”去掉堆顶元素后,非要把最后一个元素放到堆顶,然后再进行向下调整呢?为什么不直接就向下调整了呢?

好,我们先看看直接向下调整会发生什么事情。
在这里插入图片描述
如果直接向下调整,那么就很有可能出现上面的情况,4和5的孩子节点都不完整,已经破坏了堆的结构!
那么我现在再来展示以下“3”的做法:
在这里插入图片描述
这种做法既能够把最大数提取到堆顶,也能够不破坏堆的原本结构。数字3变成了4的孩子节点。

是不是要开辟一个新的空间去存放丢出来的数据呢?

数据量小的话倒还好,如果数据量一大,那么占用的空间就忒大了,你还不如用快速排序呢,而且堆排序的优势更是体现在大数据的情况下,所有更不能开辟新的空间去储存序列数据了。

不开辟新的空间那么我们该怎么办?

解决办法还是会有的,虽然我们不开辟新的空间去储存,但是我们可以把”丢出来“的数据存到”被放到堆顶的数据“的位置,只要我们下一次堆排序时,忽略该位置就好了,如图所示:
在这里插入图片描述在这里插入图片描述
方法就是上面那样,第二张图之后,开始堆排序时,认为2是末尾元素。

我们如何构造一个堆?

我们采用由下到上的方式,逐一构建堆(先建好小堆,然后再建好一个大堆):
在这里插入图片描述
按照顺序去建堆,1–>5, 层层嵌套。
在这里插入图片描述

红色表示变化移动的节点,各位需要跟着我的思维手动去画一画动态图,就能把堆建好啦。

代码实现以及讲解:

代码部分使用python实现:

1.堆向下调整结构的实现:

def sift(li,low,high):
  #li:列表
  #low:根堆
  #high:堆最后一个元素
  i=low #最开始指向根节点
  j=2*i+1 #j表示左孩子的位置
  tmp=li[low] #先保留根节点的数值
  while j<=high : #保证向下调整的过程中,序号不越界(不超过最后一个元素),这个也是向下调整的大前提
      if li[j]<li[j+1]  and  j+1<=high: #如果右孩子存在,并且数值比左孩纸大,那么我们下一步(必须判断右孩子是否存在,因为右孩子可能没有!!)
          j=j+1 #把目标指向右孩子
    if li[j]>tmp: #现在开始比较大小,如果孩子节点比父节点大,替换数值
        li[i]=li[j]
        i=j  #现在我们已经解决了父节点了,现在我们开始往下看一层,那么这一层的孩子节点就变成了父节点。
        j=2*i+1 #j往下移动。
        
    

我们先讲到这里,暂停一会,现在我来展示以下变换过程:
在这里插入图片描述
i 和 j不断向下移动,这也就是上面代码最后两行的意思了。
继续讲解:

    elif li[j]<tmp:
        li[i]=tmp  #孩子节点小于根节点的数值,那么直接把根节点的数值放到其父节点的位置上即可

在这里插入图片描述
比如这张图片,6与4比较,6更大,所以6放到了空位(父节点)上,那么一次向下调整就已经结束了。

  else:
        #不满足while条件,此时孩子节点已经越界了(不存在),空位已经移到了最底部。那么直接将根节点元素放到该位置即可!
        li[i]=tmp

图片解释如下:
在这里插入图片描述

那么,我们向下调整的函数接讨论完了!

构造堆的实现:

假设现在咱们有一个乱序的数组(完全二叉树),那我们开始建堆:
在这里插入图片描述
这个小堆“(3,5)”的代码如下:
假设左孩子节点是 j ,父节点是 i
那么 j=2*i+1 同样,i=(j-1)//2
理论上假设右孩子节点是K,那么 i=(k-2)//2,但是计算i =(k-1)//2 同样也能找到父节点,所以我们统一认为,通过孩子节点找到其父结点的关系式是:
i=(j-1)//2

tmp = li[(high-1)//2] #保留父节点3
li[(high-1)//2]=li[high]
li[high]=tmp 

上述代码实现了3和5的互换(小堆建成)
现在·我们开始建这个堆:
在这里插入图片描述(9,2,4)
上面我们已经找到了父节点3的位置:(high-1)//2,那么父节点的位置就是
(high-1)//2 -1 ,这里记为 位置K,同样的方法,先把目标指向4(找出最大的孩子),然后4和9比较,很明显,这个堆本来就已经建好了,所以不需要调整。同样的道理,我们继续去建下一个堆(1,0,7)等。
有没有发现,在我们建堆的过程中,其实二叉树的都可以以最后一个叶节点作为边界(以5的原始位置作为边界),这个在(9,2,4)(8,9,3,2,4,5)以及最大堆中都容易理解,可能在(1,0,7)这个堆里面不好理解。你肯定会问,为什么(1,0,7)这个堆不以7的位置作为边界呢?其实也可以,但是逻辑上就更难了。不着急,我现在和你讲讲(1,0,7)为什么同样也可以以“5”作为边界。图片如下:
假设0和7下面也有孩子节点:
在这里插入图片描述

A,B,C,D的位置显然已经超越了“5”的边界,所以这就是为什么我们可以以“5”作为边界的原因!
明白了这一些,现在开始代码展示:

def sift(li,low,high):
  #li:列表
  #low:根堆
  #high:堆最后一个元素
  i=low #最开始指向根节点
  j=2*i+1 #j表示左孩子的位置
  tmp=li[low] #先保留根节点的数值
  while j<=high : #保证向下调整的过程中,序号不越界(不超过最后一个元素),这个也是向下调整的大前提
      if li[j]<li[j+1]  and  j+1<high: #如果右孩子存在,并且数值比左孩纸大,那么我们下一步(必须判断右孩子是否存在,因为右孩子可能没有!!)
          j=j+1 #把目标指向右孩子
    if li[j]>tmp: #现在开始比较大小,如果孩子节点比父节点大,替换数值
        li[i]=li[j]
        i=j  #现在我们已经解决了父节点了,现在我们开始往下看一层,那么这一层的孩子节点就变成了父节点。
        j=2*i+1 #j往下移动。
    elif li[j]<tmp:
        li[i]=tmp  #孩子节点小于根节点的数值,那么直接把根节点的数值放到其父节点的位置上即可
        break
  else:
        #不满足while条件,此时孩子节点已经越界了(不存在),空位已经移到了最底部。那么直接将根节点元素放到该位置即可!
        li[i]=tmp
def heap_sort(li):
    n=len(li) #获取列表长度,便于找到父节点的位置
    for i in range((n-1-1)//2,-1,-1): #开启循环建堆,遍历每一个父节点
    # i 代表的是父节点, n-1 是指最后一个元素,所以最后一个元素的父节点是:(n-1-1)//2,这里不管最后一个元素是左节点还是右节点,公式都成立,上面内容有解释
       sift(li,i,n-1) #从父节点开始向下调整结构,我们始终可以认为“n-1”作为边界。
    #每一次都向下调整,循环结束后,堆全部建立完成


筛选最大值的实现:

  for i in range(n-1,-1,-1): #循环遍历,从末尾开始
        tmp = li[0] #保存堆顶的元素
        li[0]=li[i] #最末尾的元素与堆顶元素做交换,实际是把末尾的元素放到堆顶
        li[i]=tmp #把原来堆顶的元素放到堆的末尾
        sift(li,0,i-1) #i表示堆的最后一个元素,但是此时最后一个元素的位置已经用于存放原来的堆顶元素了(文章上面的内容有提到忽略最后一个元素),所以传入sift函数的末尾序列应该是i-1.
        

结束

那么到现在,堆排序的各个部分都已经讲完了,全部代码如下:

#空位表示父节点
def sift(li,low,high):
  #li:列表
  #low:根堆
  #high:堆最后一个元素
  i=low #最开始指向根节点
  j=2*i+1 #j表示左孩子的位置
  tmp=li[low] #先保留根节点的数值
  while j<=high : #保证向下调整的过程中,序号不越界(不超过最后一个元素),这个也是向下调整的大前提
      if li[j]<li[j+1]  and  j+1<=high: #如果右孩子存在,并且数值比左孩纸大,那么我们下一步(必须判断右孩子是否存在,因为右孩子可能没有!!)
          j=j+1 #把目标指向右孩子
      if li[j]>tmp: #现在开始比较大小,如果孩子节点比父节点大,替换数值
        li[i]=li[j]
        i=j  #现在我们已经解决了父节点了,现在我们开始往下看一层,那么这一层的孩子节点就变成了父节点。
        j=2*i+1 #j往下移动。
      else:
        li[i]=tmp  #孩子节点小于根节点的数值,那么直接把根节点的数值放到其父节点的位置上即可
        break
  else:
        #不满足while条件,此时孩子节点已经越界了(不存在),空位已经移到了最底部。那么直接将根节点元素放到该位置即可!
        li[i]=tmp
def heap_sort(li):
    n=len(li) #获取列表长度,便于找到父节点的位置
    for i in range((n-1-1)//2,-1,-1): #开启循环建堆,遍历每一个父节点
    # i 代表的是父节点, n-1 是指最后一个元素,所以最后一个元素的父节点是:(n-1-1)//2,这里不管最后一个元素是左节点还是右节点,公式都成立,上面内容有解释
       sift(li,i,n-1) #从父节点开始向下调整结构,我们始终可以认为“n-1”作为边界。
    #每一次都向下调整,循环结束后,堆建立完成
    for i in range(n-1,-1,-1): #循环遍历,从末尾开始
        tmp = li[0] #保存堆顶的元素
        li[0]=li[i] #最末尾的元素与堆顶元素做交换,实际是把末尾的元素放到堆顶
        li[i]=tmp #把原来堆顶的元素放到堆的末尾
        sift(li,0,i-1) #i表示堆的最后一个元素,但是此时最后一个元素的位置已经用于存放原来的堆顶元素了(文章上面的内容有提到忽略最后一个元素),所以传入sift函数的末尾序列应该是i-1.


#测试部分:
li=[20,13,41,2,5,98,11,42,57]
print(li)
heap_sort(li)
print(li)


结果展示如下:
在这里插入图片描述

堆排序是最复杂的排序算法了,但是逻辑非常地清晰严谨,仔细研究非常有意思,希望大家在看完本教程后,也要自己能够复现出逻辑代码,多回顾,多敲,才能真真正正地记住!

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值