本文介绍了数据结构中常用的排序算法:交换类:冒泡、选择、插排、归并、快排、堆排;非交换类:计数排序、基数排序。(python实现)
1.冒泡排序
一轮循环中:两两相比,大就交换,这样在一轮循环结束的时候,就会放好1个数。
- 时间复杂度:O( N^2 ),额外空间复杂度O(1)
1 | def bubble_sort(li): |
1 | 0 ~ N-1 |
2.选择排序
- 时间复杂度O( N^2 ),额外空间复杂度O(1)
1 | def selection_sort(li): |
1 | 0 ~ N-1 |
3.插入排序
与数据的状况是有关系的,所以产生以下两种情况:
- 最好情况 O( N ) , (数组是有序的时候)
- 最坏情况 O( N^2 ) , (数组是倒序的,从大到小)
- 平均情况 按最差数据状况下。
1 | # 方式一: |
演示过程:
1 | 1. 5,| 3, 4, 0, 6 |
递归函数的时间复杂度分析
master公式:
$$
T(N)=aT(\frac{N}{b}) + O(N^d)
$$
当log(b, a) > d 时:复杂度为 O(N^log(b, a))
或者:b^d > a
当log(b, a) = d 时:复杂度为 O(N^d * logN)
或者:b^d = a
当log(b, a) < d 时:复杂度为 O(N^d)
或者:b^d < a
master公式的适用范围:划分子问题的规模是一样的
在引例中,我们的时间复杂度为:
$$
T(N)=2T(\frac{N}{2}) + O(1)
$$
N :(父问题的样本量)
N/b :(子过程样本量)中间切一刀,左右的样本量为 N/2
a :(子过程发生多少次)左边跑完跑右边,所以样本量为 N/2 的情况发生了两次
N^d :(除去子过程调用外,剩下的过程的时间复杂度)比对较大的数是一个常数操作
结论:
b = 2 , a = 2 , d = 0
log(b, a) = log(2, 2) = 1 > d
复杂度为 O( N )
note:不需要管整个递归怎么样,只需要看自己写的这个代码中,样本量为n,它是怎么划分。(因为子问题的划分也是同当前的,不管它展开几次,只需要分析父问题化子问题这一步就可以)
扩展1:
如果我们的剩余过程中含有循环的话,公式为:
$$
T(N)=2T(\frac{N}{2}) + O(N)
$$
b = 2 , a = 2 , d = 1
log( b, a ) = 1 = d
复杂度为 O( N * logN )
扩展2:
如果我们的剩余过程的时间复杂度为 O( N ^ 2 ),公式为:
$$
T(N)=2T(\frac{N}{2}) + O(N^2)
$$
b = 2 , a = 2 , d = 2
log( b, a ) = 1 < d
复杂度为 O( N^2 )
扩展3:
如果我们的子问题的规模不同,剩余过程的时间复杂度为 O( N ^ 2 ),公式为:
$$
T(N)=T(\frac{N}{3}) + T(\frac{2N}{3}) + O(N^2)
$$
这里就不能再用master公式计算时间复杂度了。
4.归并排序
- 时间复杂度O( N*logN ),额外空间复杂度O( N )
思路
- 分治
- 先左侧部分排好序,在右侧部分排好序,准备一个辅助数组,用外排序的方式,小的填,依次动,一个移动到末尾后,把另一个后面的内容拷贝到辅助数组中,最后将辅助数组的内容拷贝到原数组。
1 | 5 3 6 | 2 0 1 |
时间复杂度分析:
$$
T(N)=2T(\frac{N}{2}) + O(N)
$$
a = 2, b = 2, d = 1
log(b, a) = 1 = d
所以时间复杂度为:O( N*logN )
疑惑:如果左边比右边多一个,或者右边多一个,不影响。样本量不估计常数(抹掉常数),只估计规模。
代码实现
1 | def merge_sort(li): |
铺垫(荷兰国旗问题)
引例
给定一个数组arr,和一个数num,请把小于等于num的数放在数组的左边,大于num的数放在数组的右边。要求额外空间复杂度O(1),时间复杂度O(N)。(两层)
思想:
- 准备一个 小于等于num的区域
- 如果大于num,跳到下一个数字
- 如果小于num,将当前这个数 与 小于等于区域的前一个数 进行交换,区域向右扩大一下
- 小于等于区域推着 待定区域 向右扩张。(小于等于区域、待定区域)
代码实现:
1 | def sort(arr, num): |
partition
给定一个数组arr,和一个数num,请把小于num的数放在数组的左边,等于num的数放在数组的中间,大于num的数放在数组的右边。要求额外空间复杂度O(1),时间复杂度O(N)。(三层)
思想:
- 准备一个小于num的区域,一个大于num的区域
- 如果当前这个数等于num,则跳到下一个
- 如果当前这个数小于num,则将当前这个数与 小于区域 的前一个数交换,小于区域向右扩大一下
- 如果当前这个数大于num,则将当前这个数与 大于区域 的前一个数交换,大于区域向左扩大一下
- 当 cur(当前位置) 和 more(大于区域的边界)相遇时,停止
- 小于区域推着 等于区域 向右扩张。(小于区域、等于区域、待定区域、大于区域 )
ps:就像发货一样
代码实现:
1 | def nether_land_flag(arr, l, r, num): |
5.快排
经典快排
思想:每次解决一个数num的位置(<= num放左边,>num放右边)。
经典快排的问题
- 最好情况:每次划分,左右各一半(大于和小于区域一样大),这样利用master公式得出 O(N*logN)
- 最坏情况:每次划分,中心位置偏了,如这个例子: 6,5,4,3,2,1 —》 1,6,5,4,3,2 (只有大于/小于区域)
总拿最后一个数进行划分,跟数据状况就有关系了,如果是最坏情况,这样会蜕变成 O(N^2)
代码实现:
note:我们这里使用了荷兰国旗去进行了一个加速,不再是每次只搞定一个数x,而是将区域划分为三部分:小于区域、等于区域和大于区域,搞定一堆x。
1 | import random |
优化(随机快排)
- 时间复杂度O(N*logN),空间复杂度O(logN) (只是随机快排)
- 它也是最常用的排序,因为它代码简洁,常数项就很低。
以前我们是选最后一个数作为划分标准,这样是存在最好和最坏情况的。但是我们随机从列表中选择一个数放在最后的位置,这样的话就不存在最好和最坏情况了,它变成了一个概率事件,通过期望来计算时间复杂度,长期期望是 O(N*logN)。
为什么随机快排的空间复杂度是O(logN) ?
- 假设:对于位置 从 0 - 7 这八个数来打断点进行划分(大于断点,小于断点)(不弄断点就不知道在左侧划分结束后,右侧的位置)。
- 最好情况:[1,2,3,4,5,6,7] 最开始断点打在中间位置4处,然后每次都打在中间的位置,这样数组能被二分为多少次,就需要多少个断点。
- 最差情况:[1,2,3,4,5,6,7] 最开始断点在 7 位置处,这样向下递归的话,每一个数字需要打一个断点,所以是O(N)。
- 因为最好情况,最坏情况都是概率事件,所以它的空间复杂度为长期期望O(logN)
代码实现:
1 | import random |
ps:
- 除了使用概率的方式规避最好与最坏情况(原始的数据状况),我们还可以使用hash来进行规避。
- 工程中是不允许递归出现的,所以工程中的快排使用的是非递归的形式。
6.堆排序
- 时间复杂度O(N*logN),额外空间复杂度O(1)
堆结构非常重要:
- 堆结构的heapInsert与heapify
- 堆结构的增大与减少
- 如果只是建立堆的过程,时间复杂度为O(N)
- 优先级队列结构,就是堆结构(例如,在python中,PriorityQueue用到了heapq模块(小根堆))
- 堆是一颗完全二叉树
- 堆是在数组上进行伸缩的
完全二叉树:
- 满二叉树
- 上面的层构成满二叉树,最下面这一层是从左到右依次补齐的。
在这里我们将一个数组抽象成一颗完全二叉树:
- 位置 i 处的左孩子的下标是 2*i+1 ;
- 位置 i 处的右孩子的下标是 2*i+2 ;
- 位置 i 处的父节点是 (i-1) / 2
大根堆
- 任何一颗子树的最大值都是这颗子树的根部
建立大根堆heapinsert
时间复杂度:O(logN)
思想:依次把 i 位置上的数字加进来,让它在 0-i 之间形成大根堆。
特点:一个新加入的数,只需要和它的父亲,祖先比较(这个向上调整的过程称为heapinsert)。
代码:
1 | def heapinsert(arr, index): |
当一个节点的值发生改变时heapify
- 思想:改变的这个值和它的子孙进行比较然后向下沉(这个向下调整的过程称为heapify)。
代码:
1 | def heapify(arr, index, heapsize): |
小根堆
- 任何一颗子树的最小值都是这颗子树的根部
堆排序
利用堆结构完成的排序
- 先形成大根堆;
- 将堆顶与heapsize的值进行交换,然后heapsize–;
- 进行heapify后,继续执行2;
这样每一次执行过程2后,就会搞定一个数。
代码:
1 | def heapsort(arr): |
应用场景
添加一个数值,计算一次中位数
- 时间复杂度是O(logN)(将栈顶与heapsize的值互换、heapsize–以及heapify的过程)
- 如果不用这种方式,每次使用排序然后计算中位数的话,时间复杂度是O(N*logN)
思路:
大根堆保存较小的N/2个数,小根堆保存较大的N/2个数,这样想要的中位数就是堆顶了!
做法:
这个数小于等于大根堆的堆顶,进大根堆;大于大根堆的堆顶,进小根堆。当两个堆的heapsize的差值大于1时,进行调整。
ps:几乎一切贪心都可以用堆来做!
排序的稳定性
相同的值会不会因为排序算法而改变它们相对的次序。
O(N^2):
- 冒泡算法:如果当前这个值与后面相等的值不发生交换,让后面这个值继续向后冒泡,那这样就是稳定的,交换了就不稳定了。
- 插入排序:不插到相等值的前面就是稳定的。
- 选择排序:不稳定。(如:5,5,5,4,0 找到最小的数与0号位置交换,这样就直接不稳定了)
O(N*logN):
- 归并排序:在merge过程中,两个列表中遇到相同的值,先拷贝左边列表的,这样就是稳定的。
- 快排:不稳定,partition是不稳定的。(如:4,4,4,3)(能做到的是论文级别的)
- 堆排:不稳定。(如:4,4,4,5 在建大根堆的时候就不稳定了)
ps:冒泡的常数项高,是因为太多次的交换;选择排序的一个缺点是完整遍历后才能确定一个最值得位置。
稳定性的意义
- 保证原始信息不会被抹去(排序之前的信息)
- 如先根据身高进行排序后,再根据age进行排序,对于age相同的,可以按照之前的根据身高进行排序的结果。
工程中的综合排序算法
- 如果数组长度很短(60),会直接使用插排。(常数项很低,小样本中N^2劣势也表现不出来)
- 如果是基础类型,使用快排。(不用区分相对次序,不需要保证稳定性)
- 如果是自定义的结构体,会使用归并排序。(需要区分相对次序,需要保证稳定性)
- 在递归的过程中,如果子样本量小于60,则会使用插排。
有关排序问题的补充
- 规避排序的额外空间复杂度可以变成O(1),但是非常非常难,参考“归并排序,内部缓存法”;原地归并空间是有问题的,时间复杂度弄到了O(N^2)。
- 快排可以做到稳定,但非常难,参考“01 stable sort”
- 有一道题目,是奇数放在数组左边,偶数放在数组右边,要求原始的相对次序不变,碰到这个问题,直接怼吧,非良人。要求:时间复杂度O(N),空间复杂度O(1)
- 它的本质和快排一样是 0 1 标准。
以上涉及的排序都为基于比较的排序
桶排序
- 非基于比较的排序,与被排序的样本的实际数据状况很有关系,所以在实际中并不经常使用。(当数据量特别大的时候 -40亿到40亿,就不好用了,难道要准备这么多的桶吗?)
- 时间复杂度O(N),额外空间复杂度O(N)
- 稳定的排序
- 桶排序是一个大的概念,在它下面,计数排序和基数排序是具体实现。
- 桶是一个容器,每一个桶用来装一个词的词频。
7.计数排序
计数排序是一种在特定范围中基于次数的一种排序
原理:给定的输入序列中的每一个元素x,确定该序列中值小于等于x元素的个数,然后将x直接存放到最终的排序序列的正确位置上。
空间复杂度和时间复杂度
假定原始数列的规模是N,最大值和最小值的差是M,计数排序的时间复杂度是O(N+M),如果不考虑结果数组,只考虑中间数组大小的话,空间复杂度是O(M)
局限性
- 当数列的最大和最小值差距过大时,并不适用计数排序
- 当数列元素不是整数,并不适用计数排序
实现代码:
1 | def CountingSort(arr): |
扩展练习
给定一个数组,求如果排序之后,相邻两数的最大差值,要求时间复杂度O(N),且要求不能用非基于比较的排序。
如:3,1,6,2,7 -> 1,2,3,6,7 然后返回相邻两数的最大差值!
思路:借用了桶的概念,但没有使用桶排序。
- 准备桶,N个数,准备N+1个桶。
- 先遍历,找最小值和最大值。如果最小值和最大值相等,那就只有一种数,直接返回0。
- 0号桶放最小值,N号桶里放最大值。
- 中间进行等分,放入该桶中。
例如:
9个数字,准备10个桶,这9个数字中,最小0,最大值为99,那么中间的数就放到各自的桶内。编号0的桶存放0-9、编号1的桶存放10-19、。。。编号9的桶内存放90-99.
- 中间必存在一个空桶。(设置空桶是为了保证,最大差值一定不是来自相同桶的数)
- 最大差值就是空桶左边桶的最大值与右边桶最小值的差值!
- 每个桶统计三个信息:最小值、最大值、是否有值
- 遇到空桶,跳过;
- 遇到非空桶,找前一个非空桶(左边最近的非空桶),用当前非空桶的最小值减去前一个非空桶中的最大值,并将这个差值设为一个全局变量。
note:答案可不一定就在非空桶的两侧,(如:桶1存放19、桶2为空、桶3存放30、桶4存放49这样两个桶之间虽然没有空桶,但他们的差值是最大的)。
代码:
1 | def max_gap(arr): |
8.基数排序
分为两类:
- 最低位优先法,简称LSD:先从最低位开始排序,再对次低位排序,直到对最高位排序后得到一个有序序列;
- 第二类:最高位优先法,简称MSD法:先从最高位开始排序,再逐个对各分组按次高位进行子排序,循环直到最低位。
原理:将整数按位数切割成不同的数字,然后按每个位数分别比较。由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。
时间复杂度 :O(d(n+rd))
- 设待排序列为n个记录,d个关键码,关键码的取值范围为radix,则进行链式基数排序的时间复杂度为O(d(n+radix)),其中,一趟分配时间复杂度为O(n),一趟收集时间复杂度为O(radix),共进行d趟分配和收集。
空间复杂度:O(rd)
- 需要2*radix个指向队列的辅助空间,以及用于静态链表的n个指针。
实现代码(LSD):
1 | # LSD 最低位优先法 |