排序算法(二)选择排序与其改进之堆排序(python)

1 选择排序

1.1 执行流程

选择排序和冒泡排序非常相似,都是每次找出未排序元素中最大的那个,将其放置到末尾(升序),然后进行n-1一次。但和冒泡排序不同的是,他不需要进行大量的交换操作,只需先找到最大的元素,然后与末尾元素交换位置即可。代码思路也很简单,通过整形变量记录最大元素的索引,进行一次遍历比较并对索引进行更新,最后将索引元素与尾部元素交换。

#选择排序
def selection_sort(array):
    n=len(array)
    for e in range(n-1,-1,-1):
        max_index=0
        for s in range(0,e+1):
            if array[max_index]<array[s]:
                max_index=s
        array[e],array[max_index]=array[max_index],array[e]

同样通过一些辅助函数进行测试。

import numpy as np
import time

def order_check(array):
    for i in range(len(array)-1):
        if array[i]>array[i+1]:
            print('排序失败')
    print('排序成功')

def sort(sort_algorithm,ori_array):
    #先复制一份数组,再进行更改
    array = np.copy(ori_array)
    start=time.clock()
    sort_algorithm(array)
    end=time.clock()
    total_time=float(end-start)
    print(sort_algorithm.__name__+" : %0.5f" % total_time)
    order_check(array)
#选择排序
def selection_sort(array):
    n=len(array)
    for e in range(n-1,-1,-1):
        max_index=0
        for s in range(0,e+1):
            if array[max_index]<array[s]:
                max_index=s
        array[e],array[max_index]=array[max_index],array[e]

array=np.random.randint(0,10000,10000,dtype=np.int)
sort(selection_sort,array)

可以看到排序是无误的。
在这里插入图片描述

2 堆排序

当我们在选择排序中重复寻找最大元素时,是否会想到一种存储最大值(或最小)的数据结构——堆。通过二叉堆,我们可以通过O(logn)的时间复杂度获取到最大元素(找到最大元素是O(1),但删除最大元素后维护堆的性质需要O(logn)),而之前通过遍历获取最大元素,则是O(n)的复杂度,显然使用堆是一个更好的选择。

下图是一个二叉堆的示例,绿色节点上的蓝色数字表示的是数组的索引,因为二叉堆是一棵完全二叉树,所有节点从上往下、从左往右排布,利用这个规则我们可以对其进行编号,也就对应了数组中的索引,而树状的结构其实只是逻辑结构(虽然也可以通过节点以树的形式实现,但用数组已经满足要求,且更节省空间),它的物理结构还是数组。关于堆的详细介绍可见堆的概念与二叉堆的实现详细图解
在这里插入图片描述
在这里插入图片描述

2.1 执行流程

  1. 将数组维护为堆
  2. 交换数组第一个元素与最后一个元素
  3. 将堆的大小减一
  4. 将索引为0的元素进行下滤
  5. 重复2

如果想要使用堆,那我们肯定需要在一开始就将数组维护成一个堆,所以第一步是,批量建堆。
将数组维护为堆之后,第一个元素(索引为0的元素)就是堆中最大的元素,我们需要将他放置到尾部,因此我们要对第一个元素与最后一个元素作交换。
此时数组中最大的元素已经放置到了最后,不用再对其进行考虑,因此我们将堆的大小减一,也防止下一步的下滤继续将最大的元素向上移动。
刚才我们将最后一个元素放置到了最前面,但他的值是不确定的,按堆的规则,堆顶元素应该是堆中最大的元素,因此我们需要对堆进行维护,方法是对第一个元素进行下滤。
堆维护好之后,我们又可以重复操作2,找到剩余元素中最大的元素,放置到堆元素的末尾(刚才放置到最后的元素的前面),直到堆大小为一,则不用再进行操作,排序结束。

2.2 批量建堆

这一步我们需要将数组维护为一个二叉堆。在此我们使用从下向上的下滤的方法,说来拗口,其实思路也很简单,就是通过下滤操作将最底层的节点维护为堆,再用同样的方法处理上层的节点(底层的节点已经有堆的性质了,可以将底层的元素看作一个小堆,再对上层元素进行下滤,上层元素也就成了堆,不断向上,直到顶端)。下面是一个批量建堆的例子。

在这里插入图片描述
我们可以发现最后一层是叶子节点,根本不需要维护,也不需要下滤,所以我们进入上一层。
在这里插入图片描述

来到上一层后,我们可以发现,叶子节点还是不需要维护,因为在实际操作中,我们应该直接来到非叶子节点的位置,再进行向上的下滤。而在此时89是非叶子节点,我们发现它的值比子节点都要大,所以无需进行下滤。
在这里插入图片描述
再次向上,发现78小于它的左子节点89,所以需要与89进行交换,同样的14小于45和29,但我们只选择最大的元素进行交换,否则交换结束依旧会产生小于子节点的情况。
在这里插入图片描述
可以发现62小于89,所以进行交换。
在这里插入图片描述

由于还存在子节点的值大于62,所以62的下滤并没有结束,继续与78交换位置(下滤需要不断向下,直到子节点都大于当前元素)。
在这里插入图片描述
可以发现,每个节点的值都是大于其子节点的,建堆成功。

2.2.1 找到最后一个非叶子节点

通过以上示例我们发现,在建堆时叶子根本不需要进行操作,所以我们应该找到最后一个非叶子节点再向上进行下滤。那么如何找到最后一个非叶子节点,这就涉及到完全二叉树的知识,这里也可以不看解释,直接记住完全二叉树非叶子节点的数量是n/2(向下取整,n为元素数量),而最后一个非叶子节点的索引则是n/2-1。

推导过程:

我们用 n0、n1、n2 分别表示叶子节点、有一个子节点的叶子、有两个子节点的节点的数量,那么n0+n1+n2=n。
再考虑一棵树的边数=n-1,有两个子节点的节点有2条边,有一个子节点的节点有1条边,所以 2 * n2+n1=n-1。
将两式子联立:2n2+n1+1=n2+n1+n0,有n2=n0-1,带入上式有 n=2n0-1+n1,而对于完全二叉树,n1只能是0或1。那么当n为偶数时,n0=n/2;当n为奇数时,n0=(n+1)/2=ceiling(n/2),ceiling表示向上取整,综合两者,n0=ceiling(n/2),那么非叶子节点的个数为 n1+n2=n-n0=floor(n/2),floor表示向下取整。所以非叶子节点的个数为n/2(自动向下取整)。

2.2.2 代码实现
#批量建堆
def heapfiy(array):
    n=len(array)
    half=n>>1#位运算,相当于n/2
    for i in range(half-1,-1,-1):#对所有非叶子节点进行下滤
        sift_down(i,array,n)#下滤的代码见下

2.3 下滤操作

从上面的示例中我们可以知道,下滤操作其实就是和子节点比大小,找到最大的子节点进行交换,直到子节点都小于当前元素,相信看到这里大家都有实现的思路了,显然只需要先判断左右子节点是否存在,再找到其中的最大元素,再和当前元素比较,比当前元素大的话交换,再进入子元素比较,否则,下滤就结束。听起来好像并不难,就是有点麻烦,其实这里可以进行一些简便的处理,因为我们时直接从最后一个非叶子节点可以向上下滤的,而完全二叉树的非叶子节点一定是有左节点的(节点从左往右排布),所以我们可以直接将子节点默认为左子节点,然后只需判断右节点的存在性与大小等,进行一系列操作。还需要注意的是,随着堆排序的进行,我们堆的大小也在进行着改变,而下滤操作和堆大小是相关的,所以我们需要再传入一个参数heap_size来获取当前堆的大小。实现的代码如下。

def sift_down(i,array,heapsize):#下滤操作
    half=heapsize>>1#非叶子节点个数
    while i<half:#如果是非叶子节点,就不用下滤了
        child_index=(i<<1)+1#一定有左子节点
        child_value=array[child_index]
        right_index=child_index+1
        #先找到最大的子节点
        if right_index<heapsize and array[right_index]>child_value:#左子节点存在
            child_value=array[right_index]
            child_index=right_index
        if(child_value<array[i]):
            return
        array[i],array[child_index]=child_value,array[i]#交换
        i=child_index

2.4 完成堆排序

完成了上面这些方法后,终于松了口气,可以进行堆排序了,操作也很简单,交换首尾元素,维护堆,这些操作不断循环就可以了,只不过还需要注意一些变量取值的小细节,如末尾元素的位置、当前堆的大小等,具体代码如下。

#堆排序
def heap_sort(array):
    heapify(array)
    n=len(array)
    for e in range(n - 1, 0, -1):
        array[0],array[e]=array[e],array[0]#将最大元素放置末尾
        sift_down(0,array,e)#修改堆大小为e

2.5 测试性能与准确性

用选择排序和堆排序对同一组数据进行排序,从耗时上比较一下区别。

array=np.random.randint(0,10000,5000,dtype=int)
sort(selection_sort,array)
sort(heap_sort,array)

在这里插入图片描述
可以发现堆排序快太多了,也是它更低的时间复杂度在起作用。

2.6 时间空间复杂度

批量建堆的操作需要对n个数据进行下滤(实际为n/2个,还是n数量级),每次下滤需要O(logn)的时间复杂度,所以这里已经用了nlog(n)的时间复杂度。而堆排序时也需要寻找n-1次的最大值,每次找完最大值还需要进行时间复杂度为O(logn)的堆的维护,所以也是nlog(n),两者相加取最大,所以可知:最好、最坏、平均时间复杂度:O(nlogn)。
而堆排序也是直接在原数组上进行的操作,没有使用额外的内存空间,空间复杂度:O(1)。

2.7 稳定性

由于本选择排序是从左往右寻找最大值,且遇到相等的元素最大值索引不发生改变,所以选择排序是不稳定的,一般对选择排序稳定性的评价都是如此,但是,只要稍作修改,选择排序也可以是稳定的。
堆排序将堆顶元素放置到最后方,哪怕子节点元素和堆顶元素相等(子元素索引更大),为此颠倒排序前相等元素的先后位置,属于不稳定排序。

  • 4
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值