引言:关于排序的经典算法,来来回回看过很多遍,都是追求其如何实现,看明白过程稍候又遗忘。
这些知识在算法导论上有很好的解释,再加上从其他书籍上了解对这两个算法了解的算是深入一些,于是概括如下。
常见排序有多种分类,其中插入、选择、希尔、归并、堆、快排等都属于基于比较的排序,基于比较的意思就是说在排序结果中,各元素的次序基于输入元素间的比较。比如某数组[1, 3, 9, 4, 7]中,需要输出递增的序列。通过比较1和3发现3比1大,因此说3在1后面。
相应的就有非基于比较的排序,比基数排序、计数排序、桶排序等都是以线性时间运行的非比较排序。
对于所有排序的对比网上、书中都有很多精彩、扼要的解说,这里摘抄一二(自己的笔记不见得有这图表直观,就放到后边再述)。
所谓排序是否稳定,意即:具有相同的元素在输出数组中的相对次序与他们在输入数组中的次序相同。
-------------------------------------------------------------------------------------------------------------------
摘自:http://hxraid.iteye.com/blog/646760
★ 基于“比较”操作的内部排序性能大PK
我们首先总结一下《排序结构专题1-4》中的十种方法的性能((N个关键字的待排序列)):
排序方法 | 平均时间 | 最坏时间 | 辅助存储空间 | 稳定性 |
直接插入排序 | O(N^2) | O(N^2) | O(1) | √ |
折半插入排序 | O(N^2) | O(N^2) | O(1) | √ |
希尔排序 | O(N*logN) | O(N*logN) | O(1) | × |
起泡排序 | O(N^2) | O(N^2) | O(1) | √ |
快速排序 | O(N*logN) | O(N^2) | O(logN) | × |
简单选择排序 | O(N^2) | O(N^2) | O(1) | √ |
树形选择排序 | O(N*logN) | O(N*logN) | O(N) | √ |
堆排序 | O(N*logN) | O(N*logN) | O(1) | × |
归并排序 | O(N*logN) | O(N*logN) | O(N) | √ |
1、 O(N^2) 级别的普通排序算法,我们用C++ 的随机函数rand() 产生的随机数进行排序,并计算耗费时间。
其中分别随机生成1W,3W,5W... 19W(增量为2W)共十组待排序列进行测试。得到直接插入排序、折半插入排序、起泡排序、简单选择排序的耗时统计图如下所示(SPSS软件做图统计)。
从上图可以发现,起泡排序的耗时最大,其他三者的耗时差不多。其中折半插入排序在待排数据量达到19W以后,其性能要比直接插入排序,和简单排序要好一些。另外,在数据量较小的情况下,插入排序的性能要比选择排序要略好。
普通算法分析:在数据规模较小时(9W之内),折半插入、直接插入、简单选择插入差不多。当数据量较大时,折半插入要好一些。而起泡排序算法的时间代价是最昂贵的。 另外,普通排序算法基本上都是相邻元素进行比较,因此O(N^2)基本的排序算法都是稳定的。
2、O(N*logN) 级别的先进排序算法,其时间复杂度要比普通算法要快得多。由于数据本身要小的多,因此我们没有拿它们和普通算法进行比较,而是另外选择从10W——140W(增量10W)的15组数据进行测试,耗时性能比较如下(SPSS软件做图统计):
从上图可以发现,先进排序的耗时代价远远小于普通排序算法。而先进算法之间也有区别。其中快速排序无疑是最优秀的。其次是归并排序和希尔排序,堆排序稍微差一些,而最差的就是树形选择排序了。
先进算法分析:
(1) 就时间性能而言, 希尔排序、快速排序、树形选择排序、堆排序和归并排序都是较为先进的排序方法。耗时远小于O(N^2)级别的算法。
(2) 先进算法之中,快排的效率是最高的。 但其缺点十分明显:在待排序列基本有序的情况下,会蜕化成起泡排序,时间复杂度接近 O(N^2)。
(3) 希尔排序的性能让人有点意外,这种增量插入排序的高效性完全说明了:在基本有序序列中,直接插入排序绝对能达到令人吃惊的效率。但是希尔排序对增量的选择标准依然没有较为满意的答案,要知道增量的选取直接影响排序的效率。
(4) 归并排序的效率非常不错,在数据规模较大的情况下,它比希尔排序和堆排序都要好。
(5)堆排序在数据规模较小的情况下还是表现不错的,但是随着规模的增大,时间代价也开始和上面两种排序拉开的距离。
(6)树形选择排序并不是较好的先进排序方法,数据规模越大,其耗时代价越高。而且它所需要的额外辅助空间较多,达到O(N)级别。想想看,排序140W数据,需要额外再开辟140W的空间,实在是无法忍受。
(7) 多数先进排序都因为跳跃式的比较,降低了比较次数,但是也牺牲了排序的稳定性。
总的来说,并不存在“最佳”的排序算法。必须针对待排序列自身的特点来选择“良好”的算法。下面有一些指导性的意见:
(1) 数据规模很小,而且待排序列基本有序的情况下,选择直接插入排序绝对是上策。不要小看它O(N^2)级别。
(2) 数据规模不是很大,完全可以使用内存空间。而且待排序列杂乱无序(越乱越开心),快排永远是不错的选择,当然付出log(N)的额外空间是值得的。
(3) 海量级别的数据,必须按块存放在外存(磁盘)中。此时的归并排序是一个比较优秀的算法。
书中对堆排序解释得和直观,堆排序本身是很容易明白,是一个优雅高效的算法。
堆主要分大端堆、小端堆,其中小端堆在应用上有著名的优先级队列,不过相对于堆排序的介绍其实可以说是多了几个操作,而这几个操作其实稍做修改也是可以放在大端堆上用的,不过效果可能就相反而已,这且不表,毕竟算法重在如何运用。
堆排序
- 堆排序通常用数组予以实现主要操作:maxHeapify用于调整堆、buildHeap建堆、HeapSOrt即使用前两法进行的排序。
2. 对于堆排序数组,通常是使用下标【1....n】也就是说待排序N个元素,占N+1位置,其中0号位置不用,这个需要注意
- 节点在堆中的高度定义为从本节点到叶子的最长简单下降路径的边的数目,定义堆的高度为树根的高度。
- 虽然堆排序是一个漂亮的算法,但在实际中,快速排序的一个好的实现往往优于堆排序 P80
// HeapSort void maxHeapify(int *data, int adjastPos, int elemTotal) { int leftChild = 2 * adjastPos; int rightChild = 2 * adjastPos + 1; int largestPos = adjastPos; if (leftChild <= elemTotal && data[leftChild] > data[largestPos]) { largestPos = leftChild; } if (rightChild <= elemTotal && data[rightChild] >= data[largestPos]) { largestPos = rightChild; } if (largestPos != adjastPos) { swap(&data[largestPos], &data[adjastPos]); maxHeapify(data, largestPos, elemTotal); } } void buildHeap(int *data, int elemTotal) { int i = 0; for (i = elemTotal / 2; i >= 1; --i) { maxHeapify(data, i, elemTotal); } } void heapSort(int *data, int elemTotal) { buildHeap(data, elemTotal); int i = elemTotal; while(i >= 2) { swap(&data[1], &data[i--]); maxHeapify(data, 1, i); } }
优先级队列的介绍处相对于堆排序来说,提供了几个功能函数,比如取堆顶元素、删除堆顶元素、插入新元素、修改某元素的值
这些操作并不是说只有优先级队列才可以有,只不过是在这里应用到了但是这些操作还是体现了一些操作,书中的设计还是不错的,比如对于:
删除堆顶元素:将1号元素和尾元素互换位置,减小堆的大小,对堆做heapify
修改堆某个元素:将元素值修改,依次往上parent(i)看这个值与当前值是否谁大,如果修改的值大的话就往上掉换
插入新元素:先在堆(也就是数组)尾添加一个元素负无穷大,然后调用将这个元素修改成新元素所对应的值,也就是调用修改堆某个元素
具体代码可看算法导论。
快速排序
1.快速排序最坏情况下运行O(n^2),但快排通常是用于排序的最佳的实用选择,这是因为其平均性能相当好,期望的运行时间为O(nlogn)且记号中隐含的常数因子很小,且是in-place排序,在虚存环境中也能很好的工作。
最坏情况下是每次的划分都是两个区域分别包含n-1个元素和1个0元素的时候。O(n^2)
2.P88页说到,即使每次划分都是99:1,乍一看划分不平衡,但是总的运行时间都是O(nlogn),深入地说就是任何一种当按照常数比例进行的划分都会产生深度为O(lgn)的递归树,其中每一层的代价为O(n),因而,总的运行时间都是O(nlgn)
这里要注意:别以为分得不平衡就是最坏情况,最坏情况是前面刚提到的 包含n-1个元素和1个0元素的时候。O(n^2)
当好、坏划分交替分布在各层中时,快排的运行时间就如全是好的划分时一样,仍然是O(NlogN),但是O记号中隐藏的常数因子要略大一些。
快速排序的随机化版本
随机选取其中某个元素作为主元,可以采用先随机获得一个位置,然后将这个位置与1号位置交换,再对新数组重新调用常规的划分算法,如下
Random-Partition(A, p, r)
I = Random(p, r)
exchange A[r]、A[i]
return Partiton(A, p, r)
Rand-QS(A, p, r)
if p < r
then q =Random-Partition(A, p, r)
rand-QS(A,p, q - 1)
rand-QS(A, q+ 1, r)
为什么要研究其随机版本呢
We may be interested in the worst-case performance, but in thatcase, the random-
ization is irrelevant: it won’t improve the worst case. What randomization can do
is make the chance of encountering a worst-case scenario smal
快速排序的尾递归法:
常见的快排中,包含有两个对其自身的递归调用,即分别对其左、右子数组作递归排序;但其实第二次递归调用并不是必须的,可以用迭代控制结构来替代,此法称尾递归
QuickSort(A, p, r)
{
while p < r
do : Partitionand sort left subarray
q =Partiton(A, p, r)
QuickSort(A, p, q - 1)
p = q + 1
}
栈深最坏为O(lgn)《对特殊部分做尾递归》
通常情况下,对于最坏的Worst-case partitioning can cause the stack depth ofQUICKSORT’ to be Θ(n).即栈深O(n)
但还是有法可避免
也就是在每次的递归调用时候,只对划分后的小数组部分做递归的QuickSort调用。而另一边就用上面所说的尾递归法。
The problem demonstrated by the scenario inpart (b) is that each invocation of
QUICKSORT
callsQUICKSORT
againwith almost the same range. To avoid
suchbehavior, we must change QUICKSORT
sothat the recursive call is on a
smallerinterval of the array. The following variation of QUICKSORT
checks
whichof the two subarrays returned from PARTITION is smaller and recurses
onthe smaller subarray, which is at most half the size of the current array.Since
thearray size is reduced by at least half on each recursive call, the number of
recursivecalls, and hence the stack depth, is (lg n) in the worst case. Note
thatthis method works no matter how partitioning is performed (as long as
thePARTITION procedure has the same functionality as the procedure given in
Section7.1).
QUICKSORT
(A, p, r)
whilep < r
do Partition and sort the small subarray Þrst
q ←PARTITION (A, p, r)
if q− p < r − q
thenQUICKSORT
(A, p, q − 1)
p ←q + 1
elseQUICKSORT
(A, q + 1, r)
r ←q − 1
Theexpected running time is not affected, because exactly the same work is
doneas before: the same partitions are produced, and the same subarrays are
sorted.
快速排序的不同划分方法
快速排序的划分并不仅仅只有一种划分法,通常的划分法会将主元放置在队列的左右两端中间,形成分隔。比如
1、2、3、5、4、7这样以3为主元将数组分成两半,但也有就是并不是把主元放置在中间而是使得左右两边左边小于等于主元、右边大于等于主元,如下1、2、4、5、7、3,划分位置在1号位也就是元素2所在位置,或者3、1、2、4、5、7划分位置在2号位元素2所在位置,而3、1、2都小于等于3;4、5、7都大于等于3
对于不同的划分方法,其主的QuickSort函数略有不同:
Normal:
quickSort(data, leftPos, partitionPos - 1);
quickSort(data, partitionPos + 1, rightPos);
HOAR:
quickSort(data, leftPos, partitionPos);
quickSort(data, partitionPos + 1, rightPos);
完整代码如下:
// QuickSort
int partitionQSort(int *data, int leftPos, int rightPos)
{
int signData = data[leftPos];
while (leftPos < rightPos)
{
while (leftPos < rightPos && data[rightPos] > signData)
{
--rightPos;
}
if (leftPos < rightPos)
{
data[leftPos++] = data[rightPos];
}
while (leftPos < rightPos && data[leftPos] < signData)
{
++leftPos;
}
if (leftPos < rightPos)
{
data[rightPos--] = data[leftPos];
}
}
data[leftPos] = signData;
return leftPos;
}
void quickSort(int *data, int leftPos, int rightPos)
{
if (leftPos < rightPos)
{
int partitionPos = partitionQSort(data, leftPos, rightPos);
quickSort(data, leftPos, partitionPos - 1);
quickSort(data, partitionPos + 1, rightPos);
}
}
对于Hoare
// Hoare
int hoare_Partiton(int *data, int leftPos, int rightPos)
{
int tempData = data[leftPos];
int i = leftPos - 1;
int j = rightPos + 1;
while (1)
{
do
{
j -= 1;
}while(data[j] > tempData);
do
{
i += 1;
}while(data[i] < tempData);
if (i < j)
{
swap(&data[i], &data[j]);
}
else
{
return j;
}
}
}
void hoare_QuickSort(int *data, int leftPos, int rightPos)
{
if (leftPos < rightPos)
{
int partitionPos = hoare_Partiton(data, leftPos, rightPos);
quickSort(data, leftPos, partitionPos);
quickSort(data, partitionPos + 1, rightPos);
}
}