最近中期答辩结束,稍微有点空闲的时间捋一捋数据结构和算法方面的知识。虽然现在用python实现排序就一个sort()函数的事,但是还是想锻炼下自己的思维,从底层代码学习一下10种经典排序的实现方式。
对于时间复杂度和空间复杂度的计算,自己还是一知半解,每种排序方式后面放了自己的理解,有错误会继续修改,最后有一张菜鸟教程总结的图可以参考。
本篇笔记的内容主要是跟着b站up主做的,代码整理来自英雄哪里出来的个人空间
我这里先定义一个生成随机整数序列的函数,后面都会调用,就不重复写了:
1 | import random |
1. 选择排序
从第一个元素到最后一个元素中选择最小的元素,和第一个元素进行交换;然后从第二个元素到最后一个元素选择最小的元素,和第二个元素交换,依此类推。通过对未排序的元素比较和交换,选择出最小的,直到最后成为一个升序序列。
1 | def SelectionSort(a): |
这个算法时间复杂度(算法中循环执行的次数,量级估算)为O(n^2),其中n是输入序列的长度。空间复杂度(算法运行过程中临时占用存储大小,量级估算)为O(1),因为它只需要使用常数级别的额外空间。
2. 冒泡排序
通过不断比较相邻元素,将数值大的元素往后排。第一个元素和第二个元素比较,如果第一个元素大,则进行交换,再比较第二个元素和第三个元素大小,以此类推,直到最大的元素移动到最后一个位置,然后进行第二轮比较。每一轮中数值较大的元素,不断到达数组的尾部。
1 | def BubbleSort(a): |
这个算法的时间复杂度为O(n^2)(最好的情况下数据本来有序,复杂度O(n)),其中n是待排序数组的长度。空间复杂度为O(1),因为只使用了常数级别的额外空间。和选择排序算法是一样。
3. 插入排序
对前i-1个数已经有序的情况下,将第i个数插入到合适的位置。
将第二个元素和第一个元素比较,如果第二个元素小于等于第一个元素,则将第一个元素向后移动,并将第一个元素执行插入,这样前两个元素就是有序的。接着进行第二轮比较,也就是将第三个元素依次和第二元素和第一个元素比较,并插入到合适的位置,使前三个元素有序。以此类推,迭代执行n-1次插入,每次插入都是将元素插入到有序序列中。
1 | def InsertionSort(a): |
这个算法的时间复杂度为O(n^2),其中n是待排序数组的长度。空间复杂度为O(1),因为只使用了常数级别的额外空间。
要改善选择排序的时间复杂度,可以考虑使用其他更高效的排序算法,例如快速排序(Quick Sort)或归并排序(Merge Sort)。这些算法的时间复杂度通常为O(nlogn),比上面三个排序算法的O(n^2)更快。此外,还可以考虑使用内置的排序函数,如Python中的sorted()
函数,它使用了优化的排序算法。如果待排序数组已经基本有序,可以通过引入一些优化措施来提高插入排序的性能,例如使用二分查找来确定插入位置,减少比较和交换的次数。
4. 归并排序
利用分治的思想,采用递归的形式,对元素进行排序。当有两个长度为n的有序数组时,可以通过两个指针的移动,在O(n)的时间复杂度内,快速合并成一个有序数组。而这两个长度为n的有序数组,也可以通过两个n/2的有序数组合并而来。因此,只要不断对数组进行分治,就可以把一个无序数组变成有序。
对数组拆分的过程用到递归,对数组组合的过程用到了合并,故命名归并排序。
1 | # 实现一个合并函数(a列表start到mid的元素,以及mid+1到end的元素,需要分别按照递增顺序排列),执行后a列表start到end的元素也按照递增顺序排序 |
其实也可以不用递归的方法,用迭代的方式来实现归并排序:
1 | # 实现一个合并函数 |
在这个实现中,使用了一个临时数组temp
来存储排序的结果。首先,设置初始步长为1,然后在每一轮迭代中,按照步长将原始数组分为多个子数组,并调用merge
函数将这些子数组进行合并。合并后的结果存储在临时数组temp
中。最后,将临时数组的结果复制回原始数组。迭代的思想在于通过不断迭代地合并子数组,直到得到完整的有序数组。
归并排序的时间复杂度是O(nlogn),其中n是待排序数组的大小。空间复杂度是O(n),因为在每次合并操作中需要创建一个临时列表来存储合并后的结果。
时间复杂度的算法可以参考如何计算归并排序算法的时间复杂度?
空间复杂度的算法可以参考归并排序的空间复杂度
5. 桶排序
生成一些桶,让数字散列在不同桶中,对桶中元素分别执行排序,再将元素依次取出。
比如建立4个桶,遍历所有数字并依次分散到4个桶中,保证第2个桶所有数字都大于第1个桶,第3个桶所有数字都大于第2个桶。每个桶中分别进行选择排序,4个桶的元素都有序之后,再将元素依次取出。
1 | # 确定桶内排序方法(这里用选择排序) |
桶的数量可以根据待排序元素的分布情况和排序的要求来决定,一般将待排序的元素均匀分配到桶中,桶内的排序算法可以是任意一种排序算法,取决于应用场景和性能要求。这个算法挺有意思的,可以看到每个桶的元素数量不一定一样,桶排序一般不会直接用,往往会做变形。
这个桶排序算法的时间复杂度为O(n + k^2),其中n是待排序元素的数量,k是桶的数量。具体来说,遍历列表并将元素放入桶中的时间复杂度为O(n),对每个桶进行选择排序的时间复杂度为O(k^2),遍历每个桶并将元素放回原列表的时间复杂度为O(n)。因此,总的时间复杂度为O(n + k^2),也就是O(n)。
空间复杂度方面,除了原始列表外,额外使用了一个大小为k的桶列表来存储元素。因此,空间复杂度为O(n + k),也就是O(n)。
6. 计数排序
利用哈希表的思想,对数据类型和范围有要求。
首先生成一个计数器数组,并且一开始所有值的计数都为0,然后遍历枚举原数组的所有元素,在元素值对应的计数器上执行计数操作。最后遍历枚举计数器数组,按照数组中元素个数放回到原数组中,这样所有元素都是升序排列了。
1 | def CountingSort(a): |
很明显,这种排序方式对数据有较大的限制,适用于数据范围较小的非负整数,且数据分布较均匀的情况(并不是说负数就完全不能用)。这很好理解,数据范围太广,会导致计数数组很大,占用大量内存空间,如果数据分布不均匀,计数的效率也会降低。
本质上来说这种计数排序算法是一种简单的桶排序算法,一个计数器就是一个桶。计数排序算法**时间复杂度是O(n),空间复杂度是O(n)**。
7. 基数排序
和上面的计数排序很像,本质上也是桶排序,只不过用数位来划分桶。
首先建立0-9的10个桶,对待排序的每个元素,按照个位数的值放入对应的桶中,按顺序遍历桶中元素,取出来放回原数组。对于待排序的每个数字,按照十位数的值放入对应桶中,按顺序遍历桶中元素,取出来放回原数组。以此类推,按照百位数、千位数的值放入桶中,遍历,取出,直到排序完成。
1 | def RadixSort(a): |
上面的代码只适用于正整数,我们通过循环遍历每个数位,所以外层循环的次数是位数d。内层循环中,我们遍历了待排序数组并将每个元素放入对应的桶中,所以内层循环的时间复杂度是O(n)。最后,我们遍历每个桶,按顺序将元素放回原列表,这也需要O(n)的时间复杂度。
总的来说,基数排序的时间复杂度为O(d * (n + k)),其中d表示位数,n表示待排序数组的长度,k表示桶的数量。空间复杂度为O(dk+n)。
如果有负整数,可以把原数组元素分为正整数和负整数,分别进行基数排序后合并:
1 | def RadixSort(a): |
8. 快速排序
找到一个基准点,把小于它的和大于它的数分开,分别递归执行快速排序。也是用了分治的思想,属于冒泡排序的改进算法。
1 | # 从a列表start到end之间寻找基准数下标,并且将所有小于等于它的数放在它左边,大于它的数放在右边 |
这种排序算法也有一个缺点,如果很不巧每次选取的基准点都是序列的最大值或者最小值,那么时间复杂度将会是最大值O(n^2)。
时间复杂度:
- 在每一次划分操作中,需要遍历待排序数组的所有元素,这需要O(n)的时间。
- 在每一次划分操作中,将数组划分为两个子数组,每个子数组的长度大约是原数组的一半(最好的情况)。因此,划分操作的时间复杂度为O(n)。
- 快速排序的递归深度为logn,因为每次划分操作都将数组的规模减半。
- 因此,最好的情况下时间复杂度为O(nlogn),最坏情况下时间复杂度为O(n^2)。
空间复杂度:
- 快速排序使用递归调用来对子数组进行排序,每次递归调用都需要保存当前的函数调用信息(包括参数、局部变量等)。
- 快速排序的递归深度通常为logn,因此需要的栈空间也是logn。
- 因此,总的空间复杂度为O(logn)。最坏的情况下是O(n),随机化基准值pivot可以防止最坏情况发生。
可以用随机快速排序,每次找基准点采用了一次随机,规避快速排序最坏的情况发生。
1 | import random |
9. 希尔排序
本质是一种改进后的插入排序,又称“缩小增量排序”,增量在这里是指按照一定规则选择的间隔值(这里也是分组数),通常设置为数组长度的一半,每次缩小增量直到增量为1,组内排序方法为插入排序。每轮希尔排序的分组数越来越小,也就是说组内元素越来越多,最后一组就是整个数组。
1 | def ShellSort(a): |
希尔排序的时间复杂度是根据增量序列的选择而变化的。在最坏情况下(原序列为逆序),时间复杂度是O(n^2);最好的情况下(原序列本身有序),时间复杂度是O(n)。平均情况下,希尔排序的时间复杂度可以达到O(nlogn)。
希尔排序的空间复杂度是O(1),即不需要额外的空间来存储数据。希尔排序是一种原地排序算法,它通过交换元素的位置来实现排序,不需要额外的辅助数组或链表。因此,希尔排序的空间复杂度是常数级别的。
10. 堆排序
堆是一颗完全二叉树,假设一个节点编号为idx
,则左子树树根编号为idx*2
,右子树树根编号为idx*2+1
,把每个结点的编号和顺序表下标对应,就可以把这颗二叉树用顺序表形式存储起来。
对于一个给定的顺序表,首先构造成大顶堆(所有根结点大于左右子结点),方法是从顺序表的最后一个元素开始,自底向上构造堆。对于某个元素,取左右子树中的大者和该元素比较,如果比该元素大,则与之交换(该元素所在位置下沉)。由于每个堆左右子树都是满足堆的性质的,当枚举完根结点后大顶堆就构造完毕了。
这个时候堆顶的元素是最大的,把堆顶元素和顺序表最后一个元素进行交换(弹出),最大值就在最后一位了。继续利用堆的下沉操作,除了最后一位以外继续构造大顶堆,把次大元素交换到倒数第二位,依此类推,最终整个顺序表就是升序排列的有序顺序表了。
1 | # 实现大顶堆下沉操作,heap整个顺序表start到end之间组成合法的堆,start为根结点坐标 |
堆排序的时间复杂度为O(nlogn),n是待排序元素的数量,空间复杂度为O(1),因为它是原地排序算法,不需要额外的空间来存储元素。
最后是一张来自菜鸟教程的排序算法总结图: