看了编程珠玑Programming Perls第11章关于快速排序的讨论,发现本身终年用库函数,已经忘了快排怎么写。因而整理下思路和资料,把至今所了解的快排的方方面面记录与此。算法
快速排序由C.A.R.Hoare于1962年提出,算法至关简单精炼,基本策略是随机分治。
首先选取一个枢纽元(pivot),而后将数据划分红左右两部分,左边的大于(或等于)枢纽元,右边的小于(或等于枢纽元),最后递归处理左右两部分。
分治算法通常分红三个部分:分解、解决以及合并。快排是就地排序,因此就不须要合并了。只须要划分(partition)和解决(递归)两个步骤。由于划分的结果决定递归的位置,因此Partition是整个算法的核心。编程
对数组S排序的形式化的描述以下(REF[1]):数组
快速排序最佳运行时间O(nlogn),最坏运行时间O(N^2),随机化之后指望运行时间O(nlogn),关于这些任何一本算法数据结构书上都有证实,就不写在这了,一下两点很重要:数据结构
因此诉时间复杂度的分析都是围绕枢纽元的位置展开讨论的。函数
为了方便讨论,将Partition从QuickSort函数里提出来,就像算法导论里同样。实际实现时我更倾向于合并在一块儿,就一个函数,减小了函数调用次数。oop
划分又分红两个步骤:选取枢纽元
和按枢纽元将数组分红左右两部 测试
一样是为了方便,将选取枢纽元单独提出来成一个函数:select_pivot(T A[], int p, int q),该函数从A[p...q]中选取一个枢纽元并返回,且枢纽元放置在左端(A[p]的位置)。优化
对于彻底随机的数据,枢纽元的选取不是很重要,每每直接取左端的元素做为枢纽元。ui
可是实际应用中,数据每每是部分有序的,若是仍用两端的元素最为枢纽元,则会产生很很差的划分,使算法退化成O(n^2)。因此要采用一些手段避免这种状况,我知道的有“随机选取法”和“三数取中法”。spa
顾名思义就是从A[p...q]中随机选择一个枢纽元,这个用库函数能够很容易实现
其中randInt(p, q)随机返回[p, q]中的一个数,C/C++里可由stdlib.h中的rand函数模拟。
即取三个元素的中间数做为枢纽元,通常是取左端、右断和中间三个数,也能够随机选取。(REF[1])
虽说分割方法只影响算法时间复杂度的系数,可是一个好系数也是比较重要的。这也就是为何实际应用中宁愿选择可能退化成O(n^2)的快速排序,也不用稳定的堆排序(堆排序交换次数太多,致使系数很大)。
常见的分割方法有三种:
单向扫描代码很是简单,只有短短的几行,思路也比较清晰。该算法由N.Lomuto提出,算法导论上也采用了这种算法。对于数组A[p...q], 该算法用一个循环扫描整个区间,并维护一个标志m,使得循环不变量(loop invariant)A[p+1...m] < A[p] && A[m+1, i-1] >= x[l]始终成立。(REF[2],REF[3])
顺便废话几句,在看国外的书的时候,发现老外在分析和测试算法尤为是循环时,很是重视不变量(invariant)的使用。确立一个不变量,在循环开始以前和结束以后检查这个不变量,是一个很好的保持算法正确性的手段。
事实上第一种算法须要的交换次数比较多,并且若是采用选取左端元素做为枢纽元的方法,该算法在输入数组中元素所有相同时退化成O(n^2)。第二种方法能够避免这个问题。
双向扫描用两个标志i、j,分别初始化成数组的两端。主循环里嵌套两个内循环:第一个内循环i从左向右移太小于枢纽元的元素,遇到大元素时中止;第二个循环j从右向左移过大于枢纽元的元素,遇到小元素时中止。而后主循环检查i、j是否相交并交换A[i]、A[j]。
双向扫描能够正常处理全部元素相同的状况,并且交换次数比单向扫描要少。
这种方法是Hoare在62年最初提出快速排序采用的方法,与前面的双向扫描基本相同,可是更难理解,手算了几组数据才搞明白:(REF[2])
须要注意的是,返回值j并非枢纽元的位置,可是仍然保证了A[p..j] <= A[j+1...q]。这种方法在效率上于双向扫描差异甚微,只是代码相对更为紧凑,而且用A[p]作哨兵元素减小了内层循环的一个if测试。
http://www.see2say.com/channel/music/player.aspx?v_album_id=9804
枢纽元保存在一个临时变量中,这样左端的位置可视为空闲。j从右向左扫描,直到A[j]小于等于枢纽元,检查i、j是否相交并将A[j]赋给空闲位 置A[i],这时A[j]变成空闲位置;i从左向右扫描,直到A[i]大于等于枢纽元,检查i、j是否相交并将A[i]赋给空闲位置A[j],而后 A[i]变成空闲位置。重复上述过程,最后直到i、j相交跳出循环。最后把枢纽元放到空闲位置上。
这种相似迭代的方法,每次只需一次赋值,减小了内存读写次数,而前面几种的方法一次交换须要三次赋值操做。因为没有哨兵元素,不得不在内层循环里判 断i、j是否相交,实际上反而增长了不少内存读取操做。可是因为循环计数器每每被放在寄存器了,而若是待排数组很大,访问其元素会频繁的cache miss,因此用计数器的访问次数换取待排数组的访存是值得的。
1.内层循环中的while测试是用“严格大于/小于”仍是”大于等于/小于等于”。
通常的想法是用大于等于/小于等于,忽略与枢纽元相同的元素,这样能够减小没必要要的交换,由于这些元素不管放在哪一边都是同样的。可是若是遇到全部 元素都同样的状况,这种方法每次都会产生最坏的划分,也就是一边1个元素,令一边n-1个元素,使得时间复杂度变成O(N^2)。而若是用严格大于/小 于,虽然两边指针每此只挪动1位,可是它们会在正中间相遇,产生一个最好的划分,时间复杂度为log(2,n)。
另外一个因素是,若是将枢纽元放在数组两端,用严格大于/小于就能够将枢纽元做为一个哨兵元素,从而减小内层循环的一个测试。
由以上两点,内层循环中的while测试通常用“严格大于/小于”。
2.对于小数组特殊处理
按照上面的方法,递归会持续到分区只有一个元素。而事实上,当分割到必定大小后,继续分割的效率比插入排序要差。由统计方法获得的数值是50左右(REF[3]),也有采用20的(REF[1]), 这样原先的QuickSort就能够写成这样。
分治这里看起来没什么可说的,就是一枢纽元为中心,左右递归,实际上也有一些技巧。
快排算法和大多数分治排序算法同样,都有两次递归调用。可是快排与归并排序不一样,归并的递归则在函数一开始, 快排的递归在函数尾部,这就使得快排代码能够实施尾递归优化。第一次递归之后,变量p就没有用处了, 也就是说第二次递归能够用迭代控制结构代替。虽然这种优化通常是有编译器实施,可是也能够人为的模拟:
采用这种方法能够缩减堆栈深度,由原来的O(n)缩减为O(logn)。