三路快速排序法 Quick Sort 3 Ways
对拥有大量重复键值的数组进行排序时,上篇使用了双路快速排序进行优化,还有一个更加经典的实现方式 三路快速排序法。
三路快速排序的思想其实非常简单。我们之前实现的快速排序,都是将整个数组分成两部分,"<=v"的部分和">=v"的部分。而三路快速排序则把整个数组分成三部分,"<v"部分、"=v"部分和">v"部分。我们可以想象一下,这样分割成三部分之后,在递归的过程中,对于"=v"的部分就可以不用管了,只需要递归的继续对"<v"部分和">v"部分做同样的快速排序。
下面我们来看一下三路快速排序的具体思路。
下面这张图中,我们对整个数组进行处理,在处理过程中的某个时候,情况就可能是下面这个样子
把整个数组划分成三部分。
对于"<v"的部分,我们使用了一个"lt"的索引,指向了"<v"部分最后一个元素的地址 arr[l+1...lt]<v。
对于">v"的部分,我们使用了一个"gt"的索引,指向了">v"部分第一个元素的地址 arr[gt...r]>v。
当前正在要处理的元素是"i"这个位置的元素的话 arr[lt+1...i-1]=v。
现在我们要处理"i"这个位置的元素,我们分情况讨论,有这样几种可能。
第一种可能,最简单的情况,当前要处理的元素"e==v",这个元素"e"直接纳入"=v"的部分,相应的"i++"处理下一个元素。
第二种情况,当前要处理的元素"e<v",只需要把当前元素"e"和"=v"部分的第一个元素进行交换,此时"e"这个元素就是"<v"部分最后一个元素,橙色的"<v"部分多了一个元素,相应的"lt"这个索引需要向后移动一位。然后继续"i++"处理下一个元素。
第三种情况,当前要处理的元素"e>v",只需要把元素"e"和“gt-1”位置上的元素进行交换,此时元素"e"就是">v"部分的第一个元素,紫色的">v"对了一个元素,相应的"gt"索引需要向前移动一位。但是索引"i"不需要移动,依然指向一个没有被处理的元素,这个没有被处理过的元素是刚才从"gt-1"位置换过来的。
现在我们讨论完这三种情况,根据这个逻辑,将整个数组全部处理完成以后,大家可以看到整个数组应该是这样的,如下图。分成了三部分"<v"部分、"=v"部分和">v"部分,同时索引"lt"指向了"<v"部分的最后一个元素,索引"gt"指向了">v"部分的第一个元素。索引"i"和索引"gt"重合的时候,就是对整个数组操作完成的时候
最后我们只需要将"l"位置的元素和"lt"位置的元素进行交换,这样一来元素"v"就是"=v"部分的第一个元素。
此时整个数组就变成上图的样子,之后只需要对"<v"部分和">v"部分进行递归的快速排序,而"=v"的部分已经放在整个数组合适的部分,不需要做任何处理。这样方式的优点是不需要对大量的等于"v"的元素重复的进行操作,可以一次性的少考虑很多的元素。如果"=v"部分的元素非常多的话,那么这个优化就会变得非常明显。
另外大家要注意,当"l"位置的元素和"lt"位置的元素进行交换之后,如果不再维护"lt"这个索引的话,相应的"<v"部分是arr[l...lt-1]<v,而">v"部分是arr[gt...r]>v,都是前闭后闭的。对于这种边界的处理,要格外的小心。
上面这种三路快速排序的思路已经介绍完了,下面是具体的代码实现。
package com.zeng.sort;
/**
* 三路快速排序
* @author sks
*
*/
public class QuickSort3Ways {
/**
* 对数组arr排序
* @param arr
*/
public void quickSort3Ways(int[] arr){
quickSort3Ways(arr, 0, arr.length - 1);
}
/**
* 使用递归,对数组区间arr[left...right]进行排序,区间前闭后闭
* 先是partition过程,将arr[left...right]分为 <v,==v,>v三部分
* 然后递归对 <v,>v两部分分别继续进行排序
* @param arr
* @param left
* @param right
*/
private void quickSort3Ways(int[] arr, int left, int right){
//System.out.println(left + " " + right);
//递归退出条件
//对元素量较少的部分,用插入排序进行优化
if(right - left <= 15){
insertionSort(arr, left, right);
return;
}
//partition过程
//优化:随机化快速
//随机取一个元素和第一个元素交换位置,作为标记元素
swap(arr, left, (int)(Math.random() * (right - left + 1) + left));
int v = arr[left];
//定义并初始化下标索引,使得三部分区间为空
int lt = left; //arr[left+1...lt] < v
int gt = right + 1; //arr[gt...right] > v
int i = left + 1; //arr[lt + 1, i) == v
while(i < gt){
if(arr[i] < v){
swap(arr, i, lt + 1);
lt ++;
i ++;
}else if(arr[i] > v){
swap(arr, gt - 1, i);
gt --;
}else{ //arr[i] == v
i ++;
}
}
swap(arr, left, lt);
quickSort3Ways(arr, left, lt - 1); //继续对 <v 部分进行快速排序
quickSort3Ways(arr, gt, right); //继续对 >v 部分进行快速排序
}
/**
* 插入排序算法,对数组中子数组[left, right]进行排序.
* @param arr
* @param left
* @param right
*/
private void insertionSort(int[] arr, int left, int right){
for(int i = left + 1; i <= right; i ++){
int e = arr[i];
int j = i;
for(; j > left && arr[j - 1] > e; j --){
arr[j] = arr[j - 1];
}
arr[j] = e;
}
}
/**
* 交换数组中两个元素的值
* @param arr
* @param i
* @param j
*/
private void swap(int[] arr, int i, int j){
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
优化后的测试结果如下:
对一百万条随机的数据进行排序
MergeSort: 1000000 true 211ms
QuickSort: 1000000 true 97ms
QuickSort2: 1000000 true 106ms
QuickSort3: 1000000 true 129ms
对一百万条近乎有序的数据进行排序
MergeSort: 1000000 true 44ms
QuickSort: 1000000 true 38ms
QuickSort2: 1000000 true 32ms
QuickSort3: 1000000 true 68ms
对一百万条拥有大量重复元素的数据进行排序
MergeSort: 1000000 true 124ms
QuickSort: 1000000 true 31766ms
QuickSort2: 1000000 true 49ms
QuickSort3: 1000000 true 17ms
从上面的测试结果可以看出,对于拥有大量重复元素的数组进行排序时,三路快速排序算法比二路快速排序算法效率要比双路快速排序和归并排序的效率高很多,这就是三路快速排序的重要优势。
在其他两个测试情况中,三路快速排序要微微的慢与双路快速排序,但是整体上依然是在一个数量级上的,这点差距是完全可以接受的,并且都是好于归并排序的,这也是快速排序叫快速的原因。虽然时间复杂度都是O(nlogn)级别的排序算法,但是快速排序在性能上是要比归并排序要好的。
另外双路快速排序和三路快速排序各有优劣,一般系统级别的排序都会选着三路快速排序,就是因为三路快速排序在处理存在有重复键值的时候优势非常大,再处理没有重复键值数组的时候,它的速度也可以得到保证。
我们分析和学习快速排序的过程,从一点一点快速排序基础的思想,到遇到一个问题,换一种方式,解救一个问题,这个整个思路是非常重要的。如果在面试过程中,面试官让你谈谈对快速排序的看法,大家能够按住这个时候一点一点的介绍和改进快速排序算法,对每一种方式它们的得失有一个很好的分析,说能你对快速排序的理解已经非常深刻了,同时也会给面试官留下非常深刻的影响。
这里我们对快速排序的分析就告一段落了,虽然快速排序算法还有一些其他优化策略,这里就不一一列举出来,我们分析的这几点就是比较常见的优化策略。