八、快速排序
在前面介绍的几种排序算法,
希尔排序相当于直接插入排序的升级,它们属于插入排序类,
而堆排序相当于简单选择排序的升级,它们是属于选择排序类,
而接下来介绍的快速排序就是冒泡排序的升级,它们属于交换排序类。
快速排序(Quick Sort)的基本思想是:
通过一趟排序将待排序记录分割成独立的两部分,
选取一个元素(一般是无序表首元素)作为枢轴,其中目的是
使枢轴左边的元素都比不比枢轴大,枢轴右边的元素都不比枢轴小,
通过不断计算新的枢轴,(当出现第一个枢轴的时候,无序表就被分成了两个部分,分别又可以对左边和右边求新的枢轴)
当无序表中所有的元素都成为了枢轴的时候,那么整个表就有序了。
如上图:选定无序表的第一个元素6作为枢轴(这样不太严谨,后面有改进)
取出6,空出来一个位置,黑线画出的就是未排序的元素,
我们记枢轴左边是一个组,枢轴右边是一个组,
放到左边组的元素都是比枢轴小的,慢慢的黑线画出的
未排序元素越来越少,而且我们发现两个 “5”,一个是5a,一个是5b,
他们俩在排序的过程中,5b排在了5a的前面,说明快速排序是一种不稳定的排序算法。
下面给出实现的快速排序的基本算法代码:
r[ ] = {50,10,90,30,70,40,80,60,20}
// 快速排序
void QuickSort(SqList *L){
QSort(L, 0, L->length - 1);
}
// 对待排序序列L中的子序列L->r[low...high]做快速排序
void QSort(SqList *L, int low, int high){
int pivot;
if (low < high){
// 将L->r[low...high]一分为二,算出枢轴值pivot
pivot = Partition(L, low, high);
// 对低子序列递归排序
QSort(L, low, pivot - 1);
// 对高子序列递归排序
QSort(L, pivot + 1, high);
}
}
// 交换待排序序列L中子表的记录,使枢轴记录到位,并返回其所在位置
// 并使得其之前位置的值小于它,后面位置的值大于它
int Partition(SqList *L, int low, int high){
int pivot_key;
// 初始值设置为子表的第一个记录
pivot_key = L->r[low];
while (low < high){
while (low < high && L->r[high] >= pivot_key)
high--;
// 将小于枢轴记录的值交换到低端
swap(L, low, high);
while (low < high && L->r[low] <= pivot_key)
low++;
// 将大于枢轴记录的值交换到高端
swap(L, low, high);
}
return low;
}
上述代码同样是使用了递归,
其中Partition()函数要做的就是先选取待排序序列中的一个关键字:枢轴,
然后将其放在一个位置,这个位置左边的值小于它,右边的值都大于它。
快速排序的时间性能取决于快速排序递归的深度。
在最优情况下,Partition()每次都划分得很均匀,
如果排序n个关键字,其递归树的深度就是⌊ logn ⌋+1,
即需要递归 log2n 次,其时间复杂度是O(nlogn) 。
而最坏的情况下,待排序的序列是正序或逆序,
得到的递归树是斜树,最终其时间复杂度是 O(n2) 。
平均情况可以得到时间复杂度是O(nlogn),而空间复杂度的平均情况是O(logn)。
但是由于关键字的比较和交换是跳跃进行的,所以快速排序也是不稳定排序。
快速排序的优化
快速排序算法是有许多地方可以优化的,下面给出一些优化的方案。
优化选取枢轴
枢轴的值太大或者太小都会影响快速排序的性能,
比如每次选取的枢轴都是 当前未选中的的里面最大或者是最小的一个
一个改进方法是三数取中法,
即取三个关键字先进行排序,将中间数作为枢轴,一般是取左端、右端和中间三个数。
需要在Partition()函数中做出下列修改:
int pivot_key;
// 使用三数取中法选取枢轴
int m = low + (high - low) / 2;
if (L->r[low] > L->r[high])
// 保证左端最小
swap(L, low, high);
if (L->r[m] > L->r[high])
// 保证中间较小
swap(L, high, m);
if (L->r[m] > L->r[low])
// 保证左端较小
swap(L, m, low);
pivot_key = L->r[low];
三数取中对小数组有很大的概率取到一个比较好的枢轴值,
但是对于非常大的待排序的序列还是不足以保证得到一个比较好的枢轴值,
因此还有一个办法是九数取中法,它先从数组中分三次取样,每次取三个数,
三个样品各自取出中数,然后从这三个中数当中再取出一个中数作为枢轴。
优化不必要的交换
优化代码如下:
pivot_key = L->r[low];
int temp = pivot_key;
while (low < high){
while (low < high && L->r[high] >= pivot_key)
high--;
// 将小于枢轴记录的值交换到低端
// swap(L, low, high);
// 采用替换而不是交换的方式进行操作
L->r[low] = L->r[high];
while (low < high && L->r[low] <= pivot_key)
low++;
// 将大于枢轴记录的值交换到高端
// swap(L, low, high);
// 采用替换而不是交换的方式进行操作
L->r[high] = L->r[low];
}
// 将枢轴值替换回L.r[low]
L->r[low] = temp;
return low;
这里可以减少多次交换数据的操作,性能上可以得到一定的提高。
优化小数组时的排序方案
当数组比较小的时候,快速排序的性能其实还不如直接插入排序(直接插入排序是简单排序中性能最好的)。
其原因是快速排序使用了递归操作,在有大量数据排序时,
递归操作的影响是可以忽略的,但如果只有少数记录需要排序,这个影响就比较大,所以下面给出改进的代码。
#define MAX_LENGTH_INSERT_SORT 7
// 对待排序序列L中的子序列L->r[low...high]做快速排序
void QSort(SqList *L, int low, int high){
int pivot;
if ((high - low) > MAX_LENGTH_INSERT_SORT){
// 当high - low 大于常数时用快速排序
// 将L->r[low...high]一分为二,算出枢轴值pivot
pivot = Partition(L, low, high);
// 对低子序列递归排序
QSort(L, low, pivot - 1);
// 对高子序列递归排序
QSort(L, pivot + 1, high);
}
else{
// 否则使用直接插入排序
InsertSort(L);
}
}
上述代码是先进行一个判断,当数组的数量大于一个预设定的常数时,
才进行快速排序,否则就进行直接插入排序。
这样可以保证最大化地利用两种排序的优势来完成排序工作。
优化递归操作
递归对性能是有一定影响的,QSort()在其尾部有两次递归操作,
如果待排序的序列划分极端不平衡,递归的深度将趋近于n,
而不是平衡时的log2n,这就不仅仅是速度快慢的问题了。
栈的大小是很有限的,每次递归调用都会耗费一定的栈空间,
函数的参数越多,每次递归耗费的空间也越多。
因此,如果能减少递归,将会大大提高性能。
下面给出对QSort()实施尾递归优化的代码。
// 对待排序序列L中的子序列L->r[low...high]做快速排序
void QSort(SqList *L, int low, int high){
int pivot;
if ((high - low) > MAX_LENGTH_INSERT_SORT){
// 当high - low 大于常数时用快速排序
while (low < high){
// 将L->r[low...high]一分为二,算出枢轴值pivot
pivot = Partition(L, low, high);
// 对低子序列递归排序
QSort(L, low, pivot - 1);
// 尾递归
low = pivot + 1;
}
}
else{
// 否则使用直接插入排序
InsertSort(L);
}
}