快速排序
前面讲了插入排序、选择排序、冒泡排序、归并排序以及冒泡排序的改进版鸡尾酒排序和插入排序的改进版希尔排序,下面来说一种很常用的排序方法:快速排序。
快速排序既然敢以快速命名,可以想见它的排序速度是很快的。事实也是如此,在实际应用中它的平均性能非常好,因此在一般情况下属于应用中的首选排序方式。
原理
快速排序与归并排序一样用了分治的思想。把一个要排序的数组按某个元素值(一般称作主元)进行划分,分成两边,大于主元的放在一边,小于的放在另一边,主元放两边的中间;然后再分别排序两边那两个子数组,这样就完成了排序。而那两个子数组排序的时候同样可以用快速排序再分成两个子数组进行分别排序。以此类推,可以一直分到分无可分,即数组中只有一个元素为止。
从上面的过程可以看出,快速排序是可以在原址上排序的,所以不需要额外的空间进行归并。其技巧在于如何按主元进行划分子数组。
划分的过程有多种,这里我们选择两种常见的。
- 选取最左边的元素为主元,从右往左过一遍,将大于主元的保持在右边区域,小于的放到左边区域,最后把主元放到两个区域的中间。右边区域与左边区域在最后一步之前始终是挨着的,左边区域要增加元素直接增加即可,右边区域要增加则需要从左边区域的最右边给腾出一个位置,放入该元素,并把左边区域的最右边元素移到左边区域的最左边。如此从右往左过一遍之后即可划分好。
- 同样选取最左边的元素为主元,采用两头往中间的方式过一遍所有元素,将大于主元的保持在右边区域,小于的放到左边区域,最后把主元放到两个区域的中间。右边区域从最右边开始增长,左边区域从最左边开始增长,两个区域直到最后才接触到一起。具体方法是先从右往左找到第一个小于主元的元素,将其放到左边区域的最右边,然后从左往右找到第一个大于主元的元素,将其放到右边区域的最左边;如此循环直到所有元素一遍过完,最后将主元放到中间。
实现
按照以上原理我们来用代码实现。
下面就是用C语言实现的代码。分成三个函数来实现。
- 要排序的数组a有n个元素。
- quick_sort 函数进行封装,调用 quick_sort_。
- quick_sort_ 调用 partition 函数进行子数组的切分,将数组a[low…high]切分成 a[low…pivot-1] 和 a[pivot+1…high] 两个子数组,然后分别对两个子数组递归调用 quick_sort_ 进行排序。
- partition 的实现分两种,partition1 按第一种划分方式,partition2 按第二种划分方式。
void quick_sort(int a[], int n)
{
if (n<=0) return;
quick_sort_(a, 0, n-1);
}
void quick_sort_(int a[], int low, int high)
{
if (low >= high) return;
int pivot = 0;
pivot = partition1(a, low, high);
//pivot = partition2(a, low, high);
quick_sort_(a, low, pivot-1);
quick_sort_(a, pivot+1, high);
}
int partition1(int a[], int low, int high)
{
int x = a[low]; //取a[low]的值x作为主元
/* 从右往左看,大于主元的保持在右边区域,小于的放到左边区域 */
int i = high + 1; //i指向右边区域的最左边
for (int j=high; j>low; j--) { //j指向左边区域的最左边
if (a[j] >= x) { //若有元素要放到右边区域
i--; //从左边区域的最右边给腾出一个位置
swap(&a[i], &a[j]); //放入该元素,并把左边区域的最右边元素移到左边
} // 区域的最左边,此时j依然指向左边区域的最左边
}
swap(&a[low], &a[i-1]); //将主元放到中间
return i-1; //返回主元所在位置下标
}
int partition2(int a[], int low, int high)
{
int x = a[low]; //取a[low]的值x作为主元
/* 把小于x的元素放到左边区域,其余放到右边区域,x放到中间 */
while (low < high) {
while (low<high && a[high]>=x) high--; //从右往左找到第一个小于主元x的元素
a[low] = a[high]; //将其放到左边区域的最右边
while (low<high && a[low]<=x) low++; //从左往右找到第一个大于主元x的元素
a[high] = a[low]; //将其放到右边区域的最左边
}
a[low] = x; //将主元放到中间
return low; //返回主元所在位置下标
}
void swap(int *a, int *b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
为了验证此函数的效果,加上了如下辅助代码,对3个数组进行排序,运行结果在最后,可见排序成功。
#include <stdio.h>
#include <stdlib.h>
#define SIZE_ARRAY_1 5
#define SIZE_ARRAY_2 6
#define SIZE_ARRAY_3 20
void quick_sort(int a[], int n);
void show_array(int a[], int n);
void main()
{
int array1[SIZE_ARRAY_1]={1,4,2,-9,0};
int array2[SIZE_ARRAY_2]={10,5,2,1,9,2};
int array3[SIZE_ARRAY_3];
for(int i=0; i<SIZE_ARRAY_3; i++) {
array3[i] = (int)((40.0*rand())/(RAND_MAX+1.0)-20);
}
printf("Before sort, ");
show_array(array1, SIZE_ARRAY_1);
quick_sort(array1, SIZE_ARRAY_1);
printf("After sort, ");
show_array(array1, SIZE_ARRAY_1);
printf("Before sort, ");
show_array(array2, SIZE_ARRAY_2);
quick_sort(array2, SIZE_ARRAY_2);
printf("After sort, ");
show_array(array2, SIZE_ARRAY_2);
printf("Before sort, ");
show_array(array3, SIZE_ARRAY_3);
quick_sort(array3, SIZE_ARRAY_3);
printf("After sort, ");
show_array(array3, SIZE_ARRAY_3);
}
void show_array(int a[], int n)
{
if(n>0)
printf("This array has %d items: ", n);
else
printf("Error: array size should bigger than zero.\n");
for(int i=0; i<n; i++) {
printf("%d ", a[i]);
}
printf("\n");
}
运行结果:
Before sort, This array has 5 items: 1 4 2 -9 0
After sort, This array has 5 items: -9 0 1 2 4
Before sort, This array has 6 items: 10 5 2 1 9 2
After sort, This array has 6 items: 1 2 2 5 9 10
Before sort, This array has 20 items: 13 -4 11 11 16 -12 -6 10 -8 2 0 5 -5 0 18 16 5 8 -14 4
After sort, This array has 20 items: -14 -12 -8 -6 -5 -4 0 0 2 4 5 5 8 10 11 11 13 16 16 18
分析
时间复杂度
从代码可见,partition的过程是遍历一次 n n n 个元素,而递归调用 partition 的次数与划分是否平衡有关。最好的情况是正好平衡,即每次划分成一半一半,这种情况下partition 的次数类似于归并排序中归并的次数,即 log n \log n logn,所以快速排序的最佳时间复杂度为 O ( n log n ) O(n \log n) O(nlogn)。
然而在最坏的情况下,划分极度不平衡,每次有一个组只有一个元素,此时快速排序将类似于插入排序,划分次数会达到 n n n,所以快速排序的最坏时间复杂度为 O ( n 2 ) O(n^2) O(n2)。
所幸最坏的情况一般不会发生,一般来说快速排序的性能还是相当不错的。如果期望更加靠谱一点,可以采用随机化选取主元的方式。从代码可见,我们前面的主元选取都是直接选了第一个元素,如果第一个元素正好就是最小的元素,则可能发生最坏的情况,而每次都随机化选择主元则可以避免这个情况。
空间复杂度
因为快速排序可以原址进行,所以这里需要的空间 O ( 1 ) O(1) O(1) 的。但是快速排序有递归调用,而调用的深度又取决于划分的情况,所以快速排序的最佳空间复杂度为 O ( log n ) O(\log n) O(logn),最坏空间复杂度为 O ( n ) O(n) O(n) 。