快速排序(Qucik Sort)可以说是应用最广泛的排序算法之一。它的基本思想是分治法:选择一个pivot(中轴点),将小于pivot放在左边,将大于 pivot放在右边,针对左右两个子序列重复此过程,直到序列为空或者只有一个元素。实现快速排序的具体过程如下(采用左端点做pivot(《算法导论》):
数组划分:Partition(关键,它对数组A[p..r]进行就地重排:将小于pivot放在左边,将大于 pivot放在右边)
具体算法实现:quickSort(int[] arr, int low, int high)
1: static void quickSort(int[] arr, int low, int high) {
2: int index;
3: // 长度大于1
4: if (low < high) {
5: index = partitionImprove3(arr, low, high);
6: //System.out.println("pivot位置是:"+index);
7: quickSort(arr, low, index - 1);
8: quickSort(arr, index + 1, high);
9: }
10: }
数组划分算法:partition(int[] arr, int low, int high)
1: static int partition(int[] arr, int low, int high) {
2: // 选择左端点为pivot
3: int pivot = arr[low];
4: int i = low + 1;
5: int j = low + 1;
6: for (; j <= high; j++) {
7: // 小于pivot的放到左边
8: if (arr[j] < pivot) {
9: swap(arr, i, j);
10: i++;
11: }
12: }
13: // 经过上述过程后,得到的结果为:ARR[pivot,x1,x2,x3,y1,y2,..]
14: // 其中pivot为轴,x1,x2,x3为小于pivot的值,y1,y2...为大于pivot的值
15: // 最后将i-1项即x3与pivot交换,得到ARR[x1,x2,x3,pivot,y1,y2,..],完成一次快排
16: // 交换左端点和pivot位置
17: swap(arr, low, i - 1);
18: return i - 1;
19: }
快排的运行时间与Partition的划分有关
最坏情况是输入的数组已经完全排好序,那么每次划分的左、右两个区域分别为n-1和0,效率为O( n^2 )。而对于其他常数比例划分,哪怕是左右按9:1的比例划分,效果都是和在正中间划分一样快的(算法导论上有详细分析)。即任何一种按照常数比例进行划分,总运行时间都是O(n lg n)。
时间复杂度分析
最佳情况下的时间复杂度O(n logn)。
最坏情况下的时间复杂度是O(n^2)。
平均情况下的平均复杂度接近于最佳情况,是O(nlog n),这也是基于比较的排序算法的比较次数下限。
下面针对快速排序做一些优化。这里主要参考了资料http://rdc.taobao.com/team/jm/archives/252,对快速排序算法所进行的一些改进方案以及对算法性能改观的对比分析。下面主要介绍几种常见的优化方法。
快排中Partition所产生的划分中可能会有”差的“,而划分的关键在于主元A【r】的选择。我们可以采用一种不同的、称为随机取样的随机化技术,把主元A【r】和A【p..r】中随机选出一个元素交换,这样相当于,我们的主元不在是固定是最后一个A【r】,而是随机从p,...,r这一范围随机取样。 这样可以使得期望平均情况下,Partition的划分能够比较对称。具体过程如下:
随机化选择pivot后的方法为:partitionImprove1(int[] arr, int low, int high)
1: // 随机选择pivot的位置
2: static int partitionImprove1(int[] arr, int low, int high) {
3: // 随机选择pivot
4: int rdm = low + rand.nextInt(high - low + 1);
5: swap(arr, low, rdm);
6:
7: return partition(arr, low, high);
8: }
现在的划分算法partition(arr,low,high)只使用了一个索引i,i从左向右扫描,遇到比pivot小的,就跟从p+1开始的位置(由j索引进行递增标 志)进行交换,最终的划分点落在了j,然后将pivot调换到j上,再递归排序左右两边子序列。一个更高效的划分过程是使用两个索引i和j,分别从左右两 端进行扫描,i扫描到大于等于pivot的元素就停止,j扫描到小于等于pivot的元素也停止,交换两个元素,持续这个过程直到两个索引相遇,此时的 pivot的位置就落在了j,然后交换pivot和j的位置,后续的工作没有不同,示意图
改进后的方法为:partitionImprove2(int[] arr, int low, int high)
1: // 使用两个索引i和j,分别从左右两 端进行扫描,i扫描到大于等于pivot的元素就停止,j扫描到小于等于pivot的元素也停止,交换两个元素,持续这个过程直到两个索引相遇,此时的 pivot的位置就落在了j,然后交换pivot和j的位置
2: static int partitionImprove2(int[] arr, int low, int high) {
3: int pivot = arr[low];
4: //从表两端交替向中间扫描,直到low==high结束,此时位置才是轴的最后位置,避免了多次交换操作
5: while (low < high) {
6: while (low < high && arr[high] >= pivot)
7: --high;
8: arr[low] = arr[high];
9: while (low < high && arr[low] <= pivot)
10: ++low;
11: arr[high] = arr[low];
12: }
13:
14: arr[low] = pivot;
15: return low;
16: }
插入排序在小数组的排序上是非常高效的,这给我们一个 提示,在快速排序递归的子序列,如果序列规模足够小,可以使用插入排序替代快速排序,因此可以在快排之前判断数组大小,如果小于一个阀值就使用插入排序。
改进后的方法为:partitionImprove3(int[] arr, int low, int high)
1: // 在数组大小小于7的情况下使用插入排序
2: static int partitionImprove3(int[] arr, int low, int high) {
3: // 在数组大小小于7的情况下使用插入排序
4: /*if (high - low + 1 < 7) {
5: for (int i = low; i <= high; i++) {
6: for (int j = i; j > low && arr[j - 1] > arr[j]; j--) {
7: swap(arr, j, j - 1);
8: }
9: }
10: }*/
11:
12: // 在数组大小小于7的情况下使用插入排序
13: if (high - low + 1 < 7) {
14: for (int i = low; i < high - low + 1; i++) {
15: int x = arr[i];
16: int j;
17: for (j = i - 1; j >= 0 && x < arr[j]; j--) {
18: arr[j + 1] = arr[j];
19: }
20: arr[j + 1] = x;
21: }
22: }
23:
24: // 随机选择pivot
25: int rdm = low + rand.nextInt(high - low + 1);
26: swap(arr, low, rdm);
27:
28: return partitionImprove2(arr, low, high);
29: }
用随机数产生器来选择pivot,是希望pivot能尽量将数组划分得均匀一些,但随机数产生器选择pivot带来的开销太大,可以选 择一个替代方案来替代随机数产生器来选择pivot。比如三数取中,通过对序列的first、middle和last做比较,选择三个数的中间大小的那一 个做pivot,从概率上可以将比较次数下降到12/7 ln(n)。
median-of-three对小数组来说有很大的概率选择到一个比较好的pivot,但是对于大数组来说就不足以保证能够选择出一个好的pivot, 因此还有个办法是所谓median-of-nine,这个怎么做呢?它是先从数组中分三次取样,每次取三个数,三个样品各取出中数,然后从这三个中数当中 再取出一个中数作为pivot,也就是median-of-medians。取样也不是乱来,分别是在左端点、中点和右端点取样。什么时候采用 median-of-nine去选择pivot,这里也有个数组大小的阀值,这个值也完全是经验值,设定在40。大小大于40的数组使用median-of-nine选择pivot,大小在7到40之间的数组使用three-of-median选择pivot,大小等于7的数组直接选择中数作为pivot,大小小于7的数组则直接使用插入排序。
改进后的方法:partitionImprove4(int[] arr, int low, int high)
1: // 大小大于40的数组使用median-of-nine选择pivot,大小在7到40之间的数组使用three-of-median选择pivot,
2: // 大小等于7的数组直接选择中数作为pivot,大小小于7的数组则直接使用插入排序
3: static int partitionImprove4(int[] arr, int low, int high) {
4:
5: // 在数组大小小于7的情况下使用插入排序
6: if (high - low + 1 < 7) {
7: for (int i = low; i <= high; i++) {
8: for (int j = i; j > low && arr[j - 1] > arr[j]; j--) {
9: swap(arr, j, j - 1);
10: }
11: }
12: }
13:
14: // 计算数组长度
15: int len = high - low + 1;
16: // 求出中点,大小等于7的数组选择pivot
17: int m = low + (len >> 1);
18: // 大小大于7
19: if (len > 7) {
20: int l = low;
21: int n = low + len - 1;
22: if (len > 40) { // 大数组,采用median-of-nine选择
23: int s = len / 8;
24: l = med3(arr, l, l + s, l + 2 * s); // 取样左端点3个数并得出中数
25: m = med3(arr, m - s, m, m + s); // 取样中点3个数并得出中数
26: n = med3(arr, n - 2 * s, n - s, n); // 取样右端点3个数并得出中数
27: }
28: m = med3(arr, l, m, n); // 取中数中的中数
29: }
30: // 交换pivot到左端点,后面的操作与qsort4相同
31: swap(arr, low, m);
32:
33: return partitionImprove2(arr, low, high);
34: }
其中的med3函数用于取三个数的中数:
1: // 用于取三个数的中数
2: static int med3(int arr[], int a, int b, int c) {
3: return arr[a] < arr[b] ? (arr[b] < arr[c] ? b : arr[a] < arr[c] ? c : a)
4: : arr[b] > arr[c] ? b : arr[a] > arr[c] ? c : a;
5: }
另外针对数组中出现的大量重复元素,为了降低equals元素参与递归的开销,采用了所谓"split-end"的划分算法。
同时还可以使用尾递归实现。