排序算法小析 by tarsylia from xjtu
目录:
6 ,线性时间排序 — 计数排序 (counting sort)
比较图:
| 时间复杂度 | 是否稳定排序 | 是否原地排序 |
冒泡排序 | O(n^2) | 稳定 | 原地 |
插入排序 | O(n^2) | 稳定 | 原地 |
选择排序 | O(n^2) | 不稳定 | 原地 |
快速排序 | O(n*lgn) | 不稳定 | 原地 |
合并排序 | O(n*lgn) | 稳定 | 不原地 |
堆排序 | O(n*lgn) | 不稳定 | 原地 |
计数排序 | O(n+k) k-- 数据范围 | 稳定 | 不原地 |
桶排序 | O(n) | 稳定 | 不原地 |
基数排序 | O(dn) d-- 维度 | 稳定 | 不愿地 |
希尔排序 | 不确定, <O(n^2) | 不稳定 | 原地 |
所有的排序算法分析都按从小到大来处理。。。
1 ,冒泡排序 (bubble sort)
每次冒泡操作都比较相邻的两个元素,看是否满足大小关系要求,如果不满足就交换它俩。所以一次冒泡可以使一个元素安排到应该在的位置。
例如:初始元组 4 5 6 3 2 1 终态 1 2 3 4 5 6
冒泡操作:
第 1 次: 4 5 3 2 1 6
第 2 次: 4 3 2 1 5 6
第 3 次: 3 2 1 4 5 6
第 4 次: 2 1 3 4 5 6
第 5 次: 1 2 3 4 5 6
分析:
我们引入有序度 ( 记为 sq) 的概念 : sq 一元组中具有有序关系的元素对个数
例如 1 2 3 4 5 6 sq = 15
即完全有序关系的满有序度为 n*(n - 1) / 2 。
冒泡排序的过程就是一个增加有序度的过程,当到达满有序度时,元组中所有的元素就都有序了。拿我们的例子来说:
初态: 4 5 6 3 2 1 , 因为 (4 5) (4 6) ,所以有序度 sq1 = 2 ,终态有序度 15
开始 4 5 6 3 2 1 2
第 1 次: 4 5 3 2 1 6 6
第 2 次: 4 3 2 1 5 6 9
第 3 次: 3 2 1 4 5 6 12
第 4 次: 2 1 3 4 5 6 14
第 5 次: 1 2 3 4 5 6 15
冒泡排序包含两个操作原子,比较和交换。每交换一次,有序度就加 1 ,所以算法怎么改进交换次数总是确定的,即 ( 满有序度 – 初始有序度 ) 。此例中即为 15 – 2 = 13, 要进行 13 次交换操作。但我们可以在算法比较次数上做优化。
优化方法:
1 ,简单优化,设置一个标志位,当一次冒泡操作已经没有数据交换时,说明已经达到完全有序,算法退出。 [ 见代码 1_1_bubblesort_1.c ]
2 ,设置一个游标,记录一次冒泡操作中最后一次元素交换的位置 lastp, 则 [lastp+1…n - 1] 已有序,下次只要在 [0…lastp] 区间做冒泡就可以了。 [ 见代码 1_1_bubblesort_2.c ]
总结:冒泡排序算法是一个原地稳定的排序算法,但性能不好,时间复杂度为 O(n2) ,当初始有序度比较大时,性能会好些。
2 , 插入排序 (insertion sort)
此排序算法将元组分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素就是初始元组中的第一个元素。算法思想是取未排序区间的第一个元素,在已排序区间中找合适的插入位置将其插入,每次操作未排序区间中的一个元素,直到未排序区间中元素为空,算法结束。其中对于怎样找合适的插入点的方法有很多,详细见下文介绍。
举例:
初始元组 4 5 6 1 3 2 终态 1 2 3 4 5 6
插入操作:
开始 4 5 6 1 3 2
第 1 次: 4 * 5 6 1 3 2 à 4 5 6 1 3 2
第 2 次: 4 5 * 6 1 3 2 à 4 5 6 1 3 2
第 3 次: * 4 5 6 1 3 2 à 1 4 5 6 3 2
第 4 次: 1 * 4 5 6 3 2 à 1 3 4 5 6 2
第 5 次: 1 * 3 4 5 6 2 à 1 2 3 4 5 6
分析:
其实算法的关键是查找合适的插入点,方法有
(1) 在已排序区间内从头到尾逐个与待插入元素比较,找寻第一个不满足有序关系的元素
(2) 在已排序区间内从尾到头逐个与待插入元素比较,找寻第一个不满足有序关系的元素
[ 见代码 1_2_insertsort_1.c ]
(3) 在已排序区间内使用二分查找来查找插入点 [ 见代码 1_2_insertsort_2.c ]
(4) 其他查找算法
算法复杂度涉及到两方面;元素移动次数和比较次数。不管采用哪种查找插入点的方法,对于一个给定的初始序列,元素的移动次数总是固定的,但对于不同的查找插入点方法,元素的比较次数是有区别的。
之所以我们在罗列查找插入点方法时,有一个是从头到尾,另一个是从尾到头,是基于这样一种情况:
例如,如果初始元组是有序的 [1 2 3 4 5 6] ,当我们使用插入排序,并从尾到头的方法来查找插入点,则比较次数为 n – 1 次;而如果是从头到尾部查找插入点,则比较的次数是 n*(n-1)/2 次。如果初始元素倒序 [6 5 4 3 2 1] ,则情况就反过来了。。。当然这只是比较特殊的情况
所以对于有序度大的初始元组,我们采用从尾到头的方法查找插入点比较好;对于有序度小的我们使用从头到尾的方法查找插入点会比较好。但这只是一个感性的认识,不足以作为选择的衡量标准。下面的定性分析:
我们引入新的概念,逆序度,即元组中逆序对的个数,我们发现有下面关系:
逆序度 = 满有序度 – 有序度
插入排序的元素移动次数总和就是 初始元组的的逆序度,也就是满有序度 – 初始元组的有序度,这跟冒泡排序情况一样。当寻找插入点的方法是在已排序区间从尾到头逐一比较的话,需要总的比较次数是初始元组的逆序度,当寻找插入点的方法是在已排序区间从头到尾逐一比较的话,需要总的比较次数是初始元组的有序度。
求元组的有序度或逆序度可以使用分治思想,时间复杂度 O(n*lgn) 。
[ 见算法导论习题解答 2-4]
总结:插入排序是一个稳定的原地排序,时间复杂度 O(n2) 。
3 ,选择排序 (selection sort)
选择排序 是一种简单直观的排序算法。它的工作原理如下。首先在未排序序列中找到最小元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小元素,然后放到排序序列末尾。以此类推,直到所有元素均排序完毕。 [ 见代码 1_3_selectsort.c ]
举例
初始元组 4 5 6 1 3 2 终态 1 2 3 4 5 6
开始 4 5 6 1 3 2
第一次: 1 5 6 4 3 2
第二次: 1 2 6 4 3 5
第三次: 1 2 3 4 6 5
第四次: 1 2 3 4 6 5
第五次: 1 2 3 4 5 6
选择排序的不稳定性分析:
选择排序每次找剩余未排序元组中的最小值,并和前面的元素交换位置,这样的元素交换破坏了稳定性。例如: 5 8 5 2 9 ,第一次找到的最小元素 2 ,与第一个 5 交换位置,则第一个 5 和中间的 5 顺序就变了,所以不稳定了。
总结:选择排序是原地不稳定排序,平均时间复杂度 O(n2)
4 ,合并排序 (merge sort)
合并排序使用思想是分治的思想。可以将一个大问题分解成一模一样的子问题,只是子问题的规模比原问题小了,解决的方法却没有变。一般分治都是用递归实现 [ 见代码 1_4_mergesort.c ] 的,当然用循环理论上也完全可以,只是可能麻烦些。
分治模式在每一层递归上都有三个步骤:
分解:将原问题分解成一系列子问题;
解决:递归地解各子问题,若子问题足够小,则直接求解;
合并:将子问题的结果合并成原问题。
我们发现两个关键的地方:
(1) 当问题足够小时,可以求解;
(2) 有方法合并子问题为更大的原问题,而这个合并操作的复杂度不能太高,否则就起不到减小总体算法复杂度的效果了。
合并排序举例:
复杂度分析:
我们可以采用递推的方法,设完成 n 个元素的合并排序需要时间 T(n) ,根据图有这样的递推关系: T(n) = 1 n=1 时
2*T(n/2) + n; n>1 时
递推求解此递推方程得到: T(n) = nlgn + n;
总结:合并排序是一个稳定,但非原地排序算法,时间复杂度为 O(nlgn) 。
5 ,快速排序 (quick sort)
与合并排序一样,快速排序也是基于分治的思想,比较两端伪代码:
[ 见代码 1_5_quicksort_1.c ]
我们发现快速排序的关键步骤是 partition 函数。下面我们分析这个函数:
将元组分为四个部分 : 小于等于 a[r] 部分,大于 a[r] 部分,还未处理部分, a[r] 。
我们使用两个游标 i 和 j 来划分开这个几个部分。其中 i 指向第一部分的最后一个元素, j 指向第三部分的第一个元素。开始时,第一部分为空,故 i=p-1, j=p;
伪代码:
一个 patition 过程举例:
快速排序的性能讨论,是一个比较重要的话题,下面我们来看看:
其实快速排序的关键是 partition 操作,而 partition 操作包括两部分:一是将元组划分为两个部分,二是将 a[r] 放在了正确的位置。划分出来的两个小规模元组继续 quicksort 递归排序。
如果划分出来的是均等的两个小元组,则时间复杂度的计算与合并排序类似,都是分治嘛,即: T(n) = 2*T(n/2) + n ,故时间复杂度是典型的分治复杂度 O(nlgn) ,这也是快速排序最好的情况。但是, partitioin 过程并非那么如人意,它并非每次都是平均分隔元组为两个大小均等的部分,有可能按 9:1 或 8:1 等等的比例偏分,这时候的时间复杂度的递推计算公式为:
T(n) = T(9*n/10) + T(n/10) + n 这个式子我们同样可以使用递推的方式把它展开来求解,在这里我们使用另一种方法,递归树法:
我们发现每层递推代价是 cn ,最深的递推到 ,而我们必须知道 =O(lgn) ,所以时间复杂度仍然是 O(lgn) ,好似跟我们先前预料的不一样,从渐近意义上来看,这与划分是在正中间进行的效果是一样的,事实上按 99:1 划分时间为 O(nlgn) 。其原因在于,任何一种按常数比例进行的划分都会产生深度为 O(lgn) 的递归树,其中每一层的代价为 O(n) ,因而,按常数比例进行划分时,总的运行时间都是 O(nlgn) 。
好了,最好情况和一般情况我们都分析了,现在来分析最差情况,也就是一个特殊情况:
如果每次划分都是按最大程度的不对称来划分的话,时间复杂度是多少呢?它的递推计算公式应该是: T(n) = T(n-1) + n 。很明显,时间复杂度为 O(n2) 。是否存在最不对称的划分呢?当然了,当初始元组为已经排好序了或按倒序排好了,那么划分就是最大不对称的了。。。快速排序是一个很自傲的排序算法,它完全置初始元组的有序度于不顾,当元组已经有序时,它效率反而到了最坏的情况,比插入和冒泡都差,在初始元组有序的情况下,插入和冒泡都可以做到 O(n) 的时间复杂度。
快速排序的不稳定性:
快速排序的不稳定性发生在 partition 过程中元素交换的过程,当 a[j] <= a[r] 时,需要交换 a[i + 1] 和 a[j], 而 a[i + 1] 和 a[j] ,将 a[i+1] 元素放到 j 位置上,而 i+1 到 j 之间还有可能存在跟 a[i+1] 相等的元素,所以此时就出现了不稳定性。例如: 3 5 5 4 4 在 partition 过程中,两个 5 循序发生了变化,算法不稳定。
总结:快速排序是一个不稳定原地排序算法 ( 合并排序不是原地的 ) ,平均情况的运行时间与其最佳情况运行时间很接近,而不是非常接近于最坏情况运行时间,这也是我们比较青睐快速排序的原因。快速排序的运行时间与划分是否对称有关,而后者又与选择了哪一个元素来进行划分有关,如果划分是对称的,那么本算法从渐近意义上来讲,就与合并排序算法一样快了;如果划分是不对称的,那么本算法渐近上就和插入算法一样慢了。
快速排序的随机化版本
这种方法我们不是始终采用 a[r] 作为主元,而是从子数组 a[p…r] 中随机选择一个元素,即就将 a[r] 和从 a[p…r] 中随机选出的一个元素交换。在这种情况下,因为主元是随机选择的,我们期望在平均情况下,对输入数组的划分能够比较对称。
其实我们给出的 partition 算法并不是其最初的版本,最初的版本是由 C.A.R.Hoare 设计的划分算法:
The current PARTITION procedure separates the pivot value (originally in A[r]) from the two partitions it forms. The HOARE-PARTITION procedure, on the other hand, always places the pivot value (originally in A[p]) into one of the two partitions A[p ‥ j] and A[j + 1 ‥ r]. Since p ≤ j < r, this split is always nontrivial.
6 ,线性时间排序 — 计数排序 (counting sort)
计数排序之所以能做到线性时间复杂度是因为使用了索引的思想,算法假设待处理的数据在 0 到 k 之间,当然 k 不能很大,也就是说这个算法所解决的问题有局限性,对于 k 与 n 相当的输入数据可以达到很好的效率,但是当 k 比 n 大很多时,就得不偿失了。。。
计数排序算法的基本思想就是对每一个输入元素 x ,确定出小于 x 的元素个数。有了这一信息就可以直接将元素放到该放的地方了。
举例:输入元组 A[8] 2 5 3 0 2 3 0 3 终态 0 0 2 2 3 3 5
我们需要一个辅助的数组,其实就类似一个索引 C[k + 1] 。此次为 C[6] 。
首先将 C[6] 用 0 填充,即
0 | 1 | 2 | 3 | 4 | 5 |
0 | 0 | 0 | 0 | 0 | 0 |
我们扫描输入元组 A[8] 2 5 3 0 2 3 0 3 ,得到每个元素的计数。
0 | 1 | 2 | 3 | 4 | 5 |
2 | 0 | 2 | 3 | 0 | 1 |
然后扫描 C[6] 得到每个小于等于此元素的元素个数
0 | 1 | 2 | 3 | 4 | 5 |
2 | 2 | 4 | 7 | 7 | 8 |
然后,我们还需要另一个存放结果的数据 R[8]
我们从后到前扫描一遍 A[8] 2 5 3 0 2 3 0 3, 将得到元组中的元素应该放置在 R 中的位置 R[C[A[i]] – 1 ] = A[i]; C[A[i]]--;
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
0 | 0 | 2 | 2 | 3 | 3 | 3 | 5 |
其中从后到前扫描 A[8] 的原因是为了做到稳定排序。 [ 见代码 1_6_countsort.c ]
总结:我们发现计数排序总的运行时间是 O(n + k ) .在实践中,当 k=O(n) 时,我们常常采用计数排序,这时其运行时间为 O(n) 。
7 ,线性时间排序 — 基数排序
基数排序有点扯, 它是一种利用多关键字排序的思想,即借助 " 分配 " 和 " 收集 " 两种操作对单逻辑关键字进行排序的方法。基数排序的方法是:一个逻辑关键字可以看成由若干个关键字复合而成的,可把每个排序关键字看成是一个 d 元组。各个关键字的权重不一,一般是左大右小,存在两种处理方法: [ 见代码 1_7_radixsort.c ]
基数排序的方式可以采用 LSD ( Least significant digital )或 MSD ( Most significant digital ), LSD 的排序方式由键值的最右边开始,而 MSD 则相反,由键值的最左边开始。
以 LSD 为例,假设原来有一串数值如下所示:
73, 22, 93, 43, 55, 14, 28, 65, 39, 81
首先根据个位数的数值,在走访数值时将它们分配至编号 0 到 9 的桶子中:
0
1 81
2 22
3 73 93 43
4 14
5 55 65
6
7
8 28
9 39
接下来将这些桶子中的数值重新串接起来,成为以下的数列:
81, 22, 73, 93, 43, 14, 55, 65, 28, 39
接着再进行一次分配,这次是根据十位数来分配:
0
1 14
2 22 28
3 39
4 43
5 55
6 65
7 73
8 81
9 93
接下来将这些桶子中的数值重新串接起来,成为以下的数列:
14, 22, 28, 39, 43, 55, 65, 73, 81, 93
这时候整个数列已经排序完毕;如果排序的对象有三位数以上,则持续进行以上的动作直至最高位数为止。
LSD 的基数排序适用于位数小的数列,如果位数多的话,使用 MSD 的效率会比较好, MSD 的方式恰与 LSD 相反,是由高位数为基底开始进行分配,其他的演算方式则都相同。
总结:基数排序的关键是保证每维关键的排序应该使用稳定排序算法,否则无法正确基数排序。假设维度为 d ,如果单维排序我们使用计数排序算法,则基数排序时间复杂度是 O(dn), 也就是 O(n) 。
8 ,线性排序 — 桶排序 (bucket sort)
桶排序感觉不伦不类,即不是索引方法,也不是归并方法。它是将要排序的数分成几组,每组单独进行排序。它对输入的数据也是有要求的,输入的数据要求大体上是均匀分布的,这样才能发挥桶排序的优点。
复杂度分析:
输入数据为 n 个, m 个桶,假设 n 是均匀分布的,将 n 个数分配到 m 个桶中,每个桶 n/m 个元素,其中每个桶内部使用快速排序 ( 一般都是采用插入排序 ) ,则时间复杂度 O((n/m)*lg(n/m)) ,则总的复杂度为: O(n + m * (n/m)*lg(n/m)) ,即 O(n + n*lg(n/m)) ,当 m 和 n 相当时,可以做到 O(n) 的时间复杂度。
Poj2353 Ministry
总结分析线性排序:
计数排序,基数排序,桶排序,三种线性排序,可以达到 O(n) 的时间复杂度。但我们发现它们并不是普遍适用的,它们对输入数据都有特殊的要求,计数排序要求数据在某一个小范围内,桶排序要求数据大体平均分布,而基数排序要想达到 O(n) 时间复杂度,要求每一维都使用线性排序算法,比如计数排序或桶排序,因此也引入了限制。除了输入数据的特殊之外,它们其实都用到了索引的思想,这也是它们达到 O(n) 的一个原因,不过,也因此需要额外的空间存储 ” 索引 ” ,所以他们都不是原地排序,但他们都可以做到稳定排序。
9 ,希尔排序 (shell sort)
希尔排序因计算机科学家 Donald L.Shell 而得名,他在 1959 年发现了希尔排序算法。希尔排序基于插入排序。但是增加了一个新的特性,大大地提高了插入排序的执行效率。
依靠这个特别的实现机制,希尔排序对于多达几千个数据项的,中等大小规模的数组排序表现良好。希尔排序不像快速排序和其他时间复杂度为 O(N*logN) 的排序算法那么快,因此对非常大的文件排序,它不是最优选择。但是,希尔排序比选择排序和插入排序这种时间复杂度为 O(N*N) 的排序算法还是要快的多,并且它非常容易实现:希尔排序算法的代码既短又简单。
它在最坏情况下的执行效率和在平均情况下的执行效率相比没有差很多。一些专家提倡差不多任何排序工作在开始时都可以使用希尔排序算法,若在实际中证明它不够快,再改成诸如快速排序这样更高级的排序算法。
希尔排序通过加大插入排序中元素之间的间隔,并在这些有间隔的元素中进行插入排序,从而使数据项能大跨度的移动。当这些数据项排过一趟序后,希尔排序算法减小数据项的间隔进行排序,一次进行下去。进行这些排序时数据项之间的间隔被称为增量,并且习惯上用字母 h 来表示。
减小间隔: 举例来说,含有 1000 个数据项的数组可能先以 364 为增量,然后以 121 位增量,以 40 位增量,以 13 位增量,以 4 位增量,最后以 1 为增量进行希尔排序。用来形成间隔的数列 (364,121,40,12,4,1) 被称为间隔序列。数列以逆向的形式从 1 开始,通过递归表达式 h=3*h+1 来产生,初始值为 1 。
在排序算法中,首先在一个短小的循环中使用序列的生成公式来计算出最初的间隔。 h 值最初被赋为 1 ,然后应用公式 h=3*h+1 生成序列 1 , 4 , 13 , 40 , 121 , 364 ,等等。当间隔大于数组大小的时候这个过程停止。对于一个含有 1000 个数据项的数组,序列的第七个数字, 1093 就太大了。因此,使用序列的第六个数字作为最大的数字来开始这个排序过程,作 364 增量排序,然后每完成一次 排序例程的外部循环,用前面提供的公式的倒推式来减小间隔: h=(h-1)/3 。 [ 见代码 1_9_shellsort.c ]
举例:初始元组 2 5 4 3 1 6 2 8 7
我们只做步长为 3 的一次插入排序:
2 5 4 3 1 6 2 8 7
2 | 5 | 4 | 3 | 1 | 6 | 2 | 8 | 7 |
p |
|
| q |
|
|
|
|
|
2 | 1 | 4 | 3 | 5 | 6 | 2 | 8 | 7 |
| p |
|
| q |
|
|
|
|
2 | 1 | 4 | 3 | 5 | 6 | 2 | 8 | 7 |
|
| p |
|
| q |
|
|
|
2 | 1 | 4 | 2 | 5 | 6 | 3 | 8 | 7 |
|
|
| p |
|
| q |
|
|
2 | 1 | 4 | 3 | 5 | 6 | 2 | 8 | 7 |
|
|
|
| p |
|
| q |
|
2 | 1 | 4 | 3 | 5 | 6 | 2 | 8 | 7 |
|
|
|
|
| p |
|
| q |
over
其他间隔序列:
选择间隔序列可以称得上是一种魔法。除了上述公式之外,应用其他序列也可取得不同程度的成功。只有一个绝对的条件,就是逐渐缩小的间隔最后一定要等于 1 ,因此最后一趟排序是一次普通的插入排序。
间隔序列中的数字互质通常被认为很重要:也就是说,除了 1 之外它们没有公约数。这个约束条件使每一趟排序更有可能保持前一趟排序以排好的效果,希尔最初以 N/2 为间隔的低效率性就是归咎于它没有遵守这个准则。
或许还可以设计出像如上讲述的间隔序列一样好的 ( 甚至更好的 ) 间隔序列。但是不管这个间隔序列是什么,都应该能够快速的计算,而不会降低算法的执行速度。
希尔排序的效率:
迄今为止,除了在一些特殊的情况下,还没有人能够从理论上分析希尔排序的效率。有各种基于试验的评估,估计它的时间级从 O(N3/2) 到 O(N7/6) 。
虽然插入排序是一个稳定的排序算法,但希尔排序不是稳定的。