排序算法汇总(三)希尔排序和堆排序(修改版)

一.希尔排序

1.算法原理

希尔排序法是D.L.Shell在1959年发明的一种排序法,可以减少插入排序法中数据搬移的次数,以加速排序的进行.排序的原则是将数据区分成特定间隔的几个小区块,以插入排序法排完区块内的数据后再渐渐减小间隔的距离.
其实希尔排序是我觉得最难写的排序算法.虽然能够感觉到通过这样划分子序列可以减小数据搬移的次数,但究竟是一种怎样的划分,不同的划分对算法的效率能提高到一个怎样的时间复杂度?这些都是未解的谜团.
关于希尔排序法的时间复杂度问题我暂时确实没有很明了,很是抱歉,等我第一时间理解后再补发一条单独分析.事实上,增量序列的选取很值得研究,在这篇文章[1]中就比较明确地提到当增量序列(increments)的选取不同时,希尔排序的时间复杂度也不尽相同,但几乎是科普性质地给出,也是引用了其他论文的直接结论而已.
这里只是介绍最容易实现而且时间复杂度也确实好于 O ( n 2 ) O(n^2) O(n2)的增量序列为1,2,4,8…的希尔排序法的情况.首先可以通过图示来直观地描述.
在这里插入图片描述
图1

这里是采用最简单的2次幂的划分形式,
因为在一开始划分时,划分数较大,所有划分的子序列比较多,而每个子序列最多有两个元素,这样做插入排序的时候就至多移动两个元素,随着希尔排序的进行,划分的子序列越来越少,子序列中元素的实际间隔也越来越小,而由于较远间隔的元素基本有序,所以虽然子序列的元素多了,但元素移动次数却不会显著增加.最后到对一整个序列排序时,这个序列已经基本有序了,只有部分元素顺序不对,但也不用移动太多次元素.所以希尔排序本质上还是直接插入排序,只不过是将序列整理成基本有序的情况,而在整理的过程中,算法的时间复杂度也不会因此而比直接插入排序大.所以是直接插入排序的改进算法.其对应的时间复杂度为 O ( n 1.5 ) O(n^{1.5}) O(n1.5).

2.算法实现

由于希尔排序算法是在插入排序算法的基础上实现的,所以建议读者复习一下之前的插入排序算法,插入排序算法的代码很简洁,这里回顾一下.

#插入排序法
SIZE = 8
def showdata(data):
	for i in range(SIZE):
		print(data[i], end=" ")
	print()

def insert(data):
	for i in range(len(data)-1):
		for j in range(i+1, 0, -1):
			if data[j] < data[j-1]:
				data[j],data[j-1] = data[j-1],data[j]
	return data
				
	
					
if __name__ == "__main__":
	data = [16, 25, 39, 27, 12, 8, 45, 63]
	print("初始数组为: ")
	showdata(data)
	print("经过插入排序后的数组为: ")
	showdata(insert(data))

插入排序法参考代码

而希尔排序法相当于做了多次的插入排序,我们只要每一次能够准确的得到待排序的元素的下标,设置好希尔排序结束的终止条件,那么用一个while循环就可以做到.
就以图1的元素序列为例,假设我们要对序列data[63, 92, 27, 36, 45, 71, 58, 7]排序,首先确定划分数,划分数是不可以超过序列长度的,而这里因为是利用2次幂的增量序列(增量等于划分数),记划分数为dNum,一开始dNum=len(data)/2,(要得到结果为整数最好用"//“来替代”/"),这里也就是4.
得到了划分数,接下来就是通过这个划分数确定待排序的子序列了,这里是需要用到for循环的,假设用i,i不可以超过dNum-1,否则会导致下标超过数组长度的情况,比如这里i就不能超过3,如果i超过3比如4,那么arr[i]=arr[4]=12,那么它应该对应第4+dNum个元素,也就是第8个元素(注意这里是从0开始的,是按照习惯上的数组下标的记法),超过了数组的最大索引7,所以不可以.因此,我们设计"for i in range(dNum):"才会合理.在这个循环里,每找到一个间隔为dNum的元素,就应该先比较,就像插入排序一样,因为我们不想开辟太多的空间,增大空间复杂度,所以对arr[0]来说,第一个距离为dNum的元素arr[4]与之比较,再根据比较结果执行交换操作.这个在排序算法汇总2中有详细的伪码思路.再继续找到arr[8],因为超过了数组下标,所以退出循环.这也符合第一次希尔排序时子序列只有至多两个元素的情况.因为有4个子序列,所以要进行四次的插入排序,这之后第1次希尔排序结束.划分数除以2,再判断划分数是否为0,如果不为0,继续之前的操作.
代码如下:

#希尔排序法,增量序列为1,2,4,8,...
def shell_sort(data):
	size = len(data)
	dNum = size // 2			#划分数(增量)
	while dNum != 0:			#划分数为零时希尔排序算法终止
		for i in range(dNum):	#i不可以超过增量,否则会有遍历超过数组下标的错误,i的存在是为了根据划分数不重复且不遗漏的得到data的所有子列
			for j in range(i+dNum, size, dNum):			#j标记根据划分数得到的data子列,相当于二维数组的一列
				k = j									#这里用k替代j是必要的,为了防止out of index情况出现
				tmp = data[k]							#tmp临时变量用于储存当前待比较的元素,一旦比前面的元素小,就利用tmp执行元素的交换操作
				while k > i and data[k] < data[k-dNum]:	#对划分后得到的子列进行直接插入排序
					data[k] = data[k-dNum]
					k -= dNum
					data[k] = tmp
				data[k] = tmp
		print("当划分数为",dNum,"时,排序后的数组为:\n",data)
		dNum = dNum // 2		#划分数除以2
if __name__ == "__main__":
	data = [63, 92, 27, 36, 45, 71, 58, 7]
	print("初始数组为:\n", data)
	shell_sort(data)
	print("排序后的数组为:\n", data)	

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

注意到这里的临时变量tmp的作用其实就类似于C语言里的交换,一开始将待比较元素data[j]赋值给tmp,是为了防止在后续操作中丢失这个值.但是在python里其实也不用那么麻烦,可以用"data[k], data[k-dNum] = data[k-dNum], data[k]“这一个语句代替"tmp = data[k], data[k-dNum] = data[k], data[k] = tmp”.其实就相当于这样:
在这里插入图片描述在这里插入图片描述

其中椭圆标记相关改动.这并没有对算法造成任何变化,只是在实际上更好理解了,少写了几行代码,也避免了出错.(我觉得这可能是python特有的写法)

3.补充

在这里向大家推荐一种方法用于验证.比如说如何确定上面的算法是否正确.这需要我们在演算纸上验算.不推荐类似于图一的那种方式,过于麻烦,我们自己写的时候一定不会像那样画图,各种箭头交叉,这样自己也会乱.在一篇外国文献中,对于希尔排序的理解显然更有意思.我们是要对一个一维数组进行排序(数的大小比较属于一维),设置增量(划分数)可以看作是将一维数组转化成一个二维数组,而增量就是这个二维数组的第二个维度(即通常意义下的列数).就拿上面的例子来说,需要比较的一维数组是[63, 92, 27, 36, 45, 71, 58, 7],第一次希尔排序时,划分数为4,也就相当于原来的一维数组转化成包含四个一维数组的二维数组,它的写法是[[63, 45], [92, 71], [27, 58], [36, 7]].直观一点来说就是这个意思:
在这里插入图片描述
图2

这样省去了前面演示原理时过多的交换操作,也比较直观,最重要的是,这个思想也的确让我们对希尔排序法多了不一样的理解.(从二维数组的角度).
另外,这真的只是希尔排序的冰山一角,因为增量序列实在过于简单,但即使如此,我也没有搞清楚它的时间复杂度为什么是 O ( n 1.5 ) O(n^{1.5}) O(n1.5).(留待后几日解决).先将就着看看吧.
在这里插入图片描述

二.堆排序

前面说到希尔排序法师插入排序法的改进版.那么堆排序法也是选择排序法2.0.选择排序法是一种不稳定的排序方法,它的算法原理是每次从待排序数组中找一个当前最小的元素放在数组开头,直到待排序数组为空.前面也已经介绍过,它的算法时间复杂度为 O ( n 2 ) O(n^2) O(n2).这是弊端很大的.而堆排序要做的就是将待排序数组进行一定的排序,使得每次选择最小元素的时候能够少移动一些数组元素(或者说少比较).
这里首先要感谢ZinanJau转载的一篇博文堆积排序(HeapSort) - 改良的选择排序

可以说写的很好,思路分析的也很清楚,最重要的是连图做的也很详细.珠玉在前,木渎在后.我不得不承认我自己接下来的讲解或多或少地会有这篇博客的影子,为了尊重原创,将此博文先行列出.

1.算法原理

要想弄清楚堆排序法(Heap Sort Method)的原理,我们首先要搞明白堆积树的概念.
堆积树指的是一类二叉树,它分为最大堆积树和最小堆积树.
最大堆积树的特点是:
1.它是一个完全二叉树;
2.它满足所有结点的值都大于或等于其左右孩子的值.
类似地,最小堆积树的特点是:
1.它是一个完全二叉树;
2.它满足所有结点的值都小于或等于其左右孩子的值.
由于我们的习惯是从小到大排序,所以我们的关注点是最大堆积树,反之,则要关注最小堆积树.
完全二叉树有一个很有用的性质,它使得将数组中元素放到二叉树中时,可以通过数组元素的索引找到它在二叉树中的位置.具体来说,这个性质就是:
如果父节点对应于数组的索引是i,那么它的左孩子对应于数组的索引就是2i+1,它的右孩子对应于数组的索引是2i+2.

- [ ] List item
此外,由堆积二叉树的第二个性质可以知道堆积树的根节点一定存的是数组中的最大元素,而我们需要的正是这个最大元素.这跟选择排序好像有点不太一样,事实上殊途同归,选择排序每次选择最小元素放在数组开头,然后数组长度减一,再对剩余长度数组执行相同操作.而堆积排序得到的最大堆积树的根节点是我们想要的最大元素,每次把这样的根节点放到数组末尾,然后接下来将它删除掉(并不是真正意义上的删除,后面会解释),对剩余的数组再构造最大堆积树.进行类似操作,直到剩余数组为空.读者也许注意到了,这里面如果把构造最大堆积树的过程替换成选择排序中寻找最小元素的过程,它们实际上是等价的,为了统一,可以把选择排序法的原理做个小改动,即每次寻找最大元素放在元素末尾,并删除该元素.直到剩余数组为空.所以说,堆积排序算法是选择排序法的改进.而改进就在于构造最大堆积树来代替原来没有排序的剩余数组,这样做是能提高效率的,后面会解释.

现在,我们举一个例子,讲述堆积排序法的算法原理.
假设有一组数据32, 17, 16, 24, 35, 87,他们在数组data中及在完全二叉树中的对应位置是这样的.
- [ ] List item
构造最大堆积树的步骤如下:
1.从根结点开始,将它作为目标结点T.如果T小于它的左孩子,则T与它的左孩子交换,如果T小于它的右孩子,那么T与它的右孩子交换(先左后右);
2.以T的左孩子作为目标结点T,对T执行形如1的操作;
3.以T的右孩子作为目标结点T,对T执行形如1的操作.
4.当遍历到最后一个非叶子结点时,并且它和它的孩子们构成了一个最大堆积树时,算法结束.

下面是我手绘的图示(找不到合适的作图,只能手绘):
(1)首先,根节点32先跟它的左孩子17比较,大,再跟它的右孩子16比较,大,故不交换,从32的左孩子继续向下调整.
(2)结点17与它的左孩子24比较,小,交换,24与它的右孩子35比较,小,交换,这个时候35上去了.要重新对35和它的父节点进行比较(反向比较),35>32,故交换,35变成了根结点,这个时候第一个左孩子调整好了,再对第一个右孩子16调整.
(3)因为16左孩子87,16<87,故交换,没有右孩子,或者说它的右孩子索引超过了整个数组的长度-1.87要跟它的父节点35比较,87>35,故交换
(4)这个时候根结点和它的左右子树的根结点已经构成了一个最大对堆积树了,按照先序原则,对左子树进行相同操作,再对右子树进行相同的操作.

在这里插入图片描述

这里比较难以理解的就是反向(向上)比较过程.因为一旦执行了交换操作,对当前结点i来说,它的值已经变大了,那么这个值会不会影响到i的子结点呢,显然不会,因为这个时候我们还没有把标记结点后移,但是会影响i的父结点,因为在目标结点到i的时候,结点i之前的结点(包括i和它的兄弟结点)已经满足最大堆积树的性质了.但当结点i的值变大以后就不好说了,所以必须让结点i跟它的父结点再比较一次,而i的兄弟结点(如果有的话)就不用再跟它的父结点再比较了,因为i的兄弟结点一定小于等于i的父结点.这就是为什么要进行反向比较的原因.
到目前为止我们只完成了二分之一的工作,接下来就是 不断构造最大堆积树,得到它的根结点.将得到的根结点与最后的叶子结点交换,然后将最后一个叶子删除.这里的删除实际上就是接下来要构造的最大堆积树是不包含原来的最大堆积树的根结点的.否则的话,没有意义,因为不把当前最大元素去掉的话,再怎么构造最大堆积树,它的根结点永远都是那个元素.
1.得到最大堆积树以后,将它的根结点和它的最后一个叶子结点交换,即交换arr[0]和arr[size-1],其中size=len(arr)-1.
2.去掉最后一个元素,对这个最大堆积树的剩余结点重新构造成一个最大堆积树,再执行如1的操作.
3.当最大堆积树为空时,这个算法结束.把删除掉的结点按照从右到左排序就是从小到大的排序结果.
在这里插入图片描述

2.算法实现

首先我们给出构造最大堆积树的算法:

'''
创建一个最大堆积树的方法采用递归,顺序采用先序
'''
def createHeap(arr, i, size):
    #左孩子和右孩子
    lchild = 2 * i + 1
    rchild = 2 * i + 2
    #这是一个判断是否发生交换操作的标记
    flag = False
    #递归边界条件,到最后一个非叶子结点为止
    if i < size // 2:
		#如果根小于左孩子,那么交换,如果交换后的根小于右孩子,再交换,注意无论是左孩子还是右孩子索引都不能超过数组的长度
        if lchild < size and arr[lchild] > arr[i]:
            arr[i], arr[lchild] = arr[lchild], arr[i]
            flag = True
        if rchild < size and arr[rchild] > arr[i]:
            arr[i], arr[rchild] = arr[rchild], arr[i]
            flag = True
        #如果发生交换且此时的arr[i]是非根结点,反向比较开始
        while flag and i > 0:
            if i % 2 == 1:
                parent = (i-1) // 2
            else:
                parent = (i-2) // 2
            if arr[parent] < arr[i]:
                arr[parent], arr[i] = arr[i], arr[parent]
                i = parent
            else:
                flag = False
        #根和它的左右子树的根结点已经确定,接下来先从左子树出发使成为最大堆积树,再从右子树出发使成为最大堆积树
        createHeap(arr, lchild, size)
        createHeap(arr, rchild, size)

然后再给出堆排序算法:

'''
堆排序算法
'''
def heapSort(arr):
    size = len(arr)
    for i in range(size-1, 0, -1):
        arr[0], arr[i] = arr[i], arr[0]
        print("处理过程为:")
        for j in range(size):
            print(arr[j], end=" ")   
        print()     
        createHeap(arr, 0, i)

我们打印出每一次构造最大堆积树以后,二叉树元素的中序遍历情况(也就是这个时候的数组是什么样子)
最后,main函数:

if __name__ == "__main__":
    arr = [32, 17, 24, 35, 16, 87]
    print("原始数组是:\n", arr)
    createHeap(arr, 0, len(arr))
    print("原始数组对应的最大堆积树是:\n", arr)
    heapSort(arr)
    print("排序结果为:")
    for j in range(len(arr)):
        print(arr[j], end=" ")
    print()

代码运行结果如下:
在这里插入图片描述接下来的话是我一本书上的原话,摘录如下,也许对于何时选择它心里有点数.

堆排序方法对记录较少的文件效果一般,但对于记录较多的文件还是很有效的,其运行时间主要耗费在创建堆和反复调整堆上.堆排序即使在最坏情况下,其时间复杂度也为O(nlogn).它是一种不稳定的排序方法.

算法并没有照搬书中,所以可能会有bug,希望读者可以自行调试,多选几个数组尝试,若出现报错或者结果与理论不相符的情况,欢迎下方评论或私信.
反复修改,不断斟酌,只为了大家能够明白,也为了自己能够彻底搞懂.如果对你理解有帮助,将是我无上荣幸.
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值