快速排序期望时间复杂度为nlg(n),最坏情况下时间复杂度为n^2。其满足空间原址性:任何时候都只需要常数个额外空间存放临时数据。是否满足稳定性(假定在待排序的记录序列中,存在多个具有相同的关键字的记录,经过排序,这些记录的相对次序保持不变)由PARTITION函数的具体实现决定。
快速排序的原理是使用PARTITION函数将输入数组分成3部分:ARRAYa,ARRAYb,以及数字q,其中子数组可能为空。三者满足关系:x(x属于ARRAYa)<= q <= y(y属于ARRAYa)。这样对元输入的排序问题就变成了对更小规模的数组:ARRAYa和ARRAYb的排序,只需要对两个子数组递归调用快速排序,即可实现对原输入的排序。快速排序的伪代码如下:
QUICKSORT(A, p, r)
if p < r
q = PARTITION(A, p, r)
QUICKSORT(A, p, q)
QUICKSORT(A, q + 1, r)
如何实现PARTITION函数是快速排序的重点。下面介绍3种方法:
方法1
将A[r]作为分界点(主元),PARTITION函数在设计时,首先取出A[r]不动,将数据剩下的元素分成3类。小于等于A[r],大于A[r],未判断,如下图所示:
i:小于等于和大于的分界;j:进行过判断和没有进行过判断的分界。
从图示状态的下一次判断是A[j] <= A[r],此时先执行i++,再将A[j] 与 A[i]交换,如图所示:
随着j的增加,最终数组A除了A[r]外,被分为小于等于和大于两部分:
此时再交换A[i + 1]和A[r],交换后实现了A[i + 1]将原数组A分成了两部分,其前面的小于等于它,后面的大于它,如图所示:
初始状态时,i = p - 1,j = p,伪代码如下:
PARTITION(A, p, r)
x = A[r]
i = p
for j = p to r - 1
if A[j] <= x
exchange A[i] with A[j]
i = i + 1
exchange A[i] with A[r]
return i
这种PARTITION()函数只需要遍历一次数据A[] ,时间复杂度为Θ(n)。不满足稳定性,C代码如下:
#define exchange(p1, p2) \
do { \
int tmp; \
tmp = *p2; \
*p2 = *p1; \
*p1 = tmp; \
} while(0)
int partition(int A[], int p, int r)
{
int x, i, j;
x = A[r - 1];
i = p - 1;
for (j = p; j < r - 1; j++) {
if (A[j] <= x) {
i++;
exchange(&A[i], &A[j]);
}
}
exchange(&A[i + 1], &A[r - 1]);
return i + 1;
}
方法2
以A[p]为主元,指针i从p + 1开始从前往后寻找大于主元的数字,指针j从r开始从后向前寻找小于等于主元的元素。当指针i和指针j都找到了非法元素,且i < j时,交换A[i]和A[j]。如下图:
交换A[i]和A[j] ,保证A[p + 1] ~ A[i]都小于等于主元,A[j] ~ A[r]都大于主元:
此时 i < j,继续查找和交换非法元素:
交换A[i]和A[j],继续查找:
此时j < i,且p + 1到 j都小于等于主元,i到r都大于主元。交换A[p]和A[j]即完成PARTITION()函数,如图:
伪代码如下:
PARTITION(A, p, r)
x = A[p]
i = p
j = r
while TRUE:
repeat
j = j - 1
until A[j] <= x or i > j
repeat
i = i + 1
until A[j] > x or i > j
if i < j:
exchange A[i] with A[j]
else:
exchange A[p] with A[j]
return j
代码如下:
int partition(int A[], int p, int r)
{
int x, i, j;
x = A[p];
i = p;
j = r;
while (1){
do {
j--;
} while (A[j] > x && i < j);
do {
i++;
} while (A[i] <= x && i < j);
if (i < j){
exchange(&A[i], &A[j]);
}
else{
exchange(&A[p], &A[j]);
return j;
}
}
}
方法1、方法2时间复杂度分析
从方法1和方法2的PARTITION()函数的实现方式可以看出方法1和方法2都不是稳定排序算法。下面我们来讨论一下方法1和方法2的时间复杂度:
假设PARTITION()函数每次都将输入数组按照1:9分割,当子数组为1时不再分割。则如图所示,在对N个数据快速排序的递归树种,最浅树的深度为log10(N),最深的树深度为log9/10(N)。在深度低于log10(N)时,每层所有节点的数字的和为N,在log10(N)到log9/10(N)的深度,每层所有节点的数字的和小于N。所以有时间复杂度为O(nlg(n))。
上述分析中,我们假设每次PARTITION()函数以1:9的比例分割。在平均条件下,数据应该会按照1:1的比例被分割。即使数据按照1:99的平均比例分割,快速排序的时间复杂度也是nlg(n)。
但如果每次PARTITION()函数都将子数组分成1个和其它所有。此时PARTITION函数需要执行n - 1次,每次子数组的长度依次为:n,n-1,n-2……2,这种极端条件下,快速排序的时间复杂度为O(n^2)。这种极端情况常出现在被排序数组基本有序时。
方法3 稳定的PARTITION函数
这种稳定的PARTITION函数是在方法2的基础上修改而来。对于方法2,其不稳定性来源于元素的位置交换。如下图:
1和7先交换,2和6再交换,之后序列变为4 7 6 8 3 5 2 1,可以看到此时已经违背了稳定性。如果我们在改交换的时候不立即执行交换,而是用数组A存放左侧需要交换到右侧的数字,用数组B存放右侧需要交换到左侧的数字。而将不需要交换的数字分别在原数组中向左/向右移动填补空位。如此操作后,原数组备分成了4个部分:
此时对着4个小数组按照a d c b的顺序排列,即完成了PARTITION函数的功能,并且划分只把主元(4)进行了移动,其它元素的相对位置都没有发生变化,由此实现的快速排序是稳定的。
对应的C代码如下:
int partition(int A[], int p, int r)
{
int x, i, j, a1, a2, b1, b2, len;
len = r - p;
i = p;
j = r;
a1 = p;
a2 = r - 1;
b1 = -1;
b2 = len;
int *B = (int *)malloc(len * sizeof(int));
memset(B, 0, len * sizeof(int));
B[++b1] = A[i];
x = A[i];
while (1) {
while (A[--j] > x && i < j) {
A[a2--] = A[j];
}
while (A[++i] <= x && i <= j) {
A[a1++] = A[i];
}
if (i < j) {
B[++b1] = A[i];
B[--b2] = A[j];
} else {
break;
}
}
if (b2 < len) {
memcpy(&A[a1], &B[b2], sizeof(int)* (len - b2));
a1 += len - b2;
}
memcpy(&A[a1], &B[0], sizeof(int)* (b1 + 1));
return a1;
}
相关代码可从 git@github.com:a10222012/ARITH_LEARN.git 获取。
对排序算法测试可见:算法导论学习笔记01——算法时间复杂度 第4章节python脚本。
方法三参考文献:邵顺增. 稳定快速排序算法研究[J]. 计算机应用与软件, 2014, 31(7):263-266.