Quick Sort 快速排序
快排大致分为三种: 单路快排, 双路快排 和 三路快排
单路快排
单路快排思想非常简单: 将数组进行partition 操作, 将数组分成 <= partition 和 > partition元素两个部分, 随后对左右子数组执行同样操作.

用代码表示如下:
public class QuickSort {
private QuickSort(){}
private static Random RandomGenerator=new Random();//随机数生成器
public static <E extends Comparable<E>> void sortOneWay(E[] arr){
sortOneWay(arr,0,arr.length-1);//单路快排
}
private static <E extends Comparable<E>> void sortOneWay(E[] arr, int l, int r){
if (l>=r){
return;
}
int p=partition1(arr,l,r);
sortOneWay(arr,l,p-1);
sortOneWay(arr,p+1,r);
}
}
partition 操作
partition 操作是快速排序的核心代码.
我们定义了循环不变量 arr[ l+1, i] <= p, arr[ i+1, j-1] > p
当 j >r 时, 索引越界, 说明已经遍历完了 arr [ l, r ] 中的所有元素
最后交换 arr [ l ] 和 arr [ i ], return i 完成partition 操作

用代码表示如下:
//单路快排 partition
private static <E extends Comparable<E>> int partition1(E[] arr, int l ,int r){
//在arr中随机选择一个元素,与arr[l]进行交换,防止对于有序数组,退化成O(n^2)
int randomIdx= l+ RandomGenerator.nextInt(r-l+1);
swap(arr,l,randomIdx);
int i=l;
int j=l+1;
while ( j<=r ){
if (arr[j].compareTo(arr[l])<=0){
swap(arr,i+1,j);
i++;
j++;//时时刻刻都要照顾到循环控制变量
}else {
j++;
}
}
//交换arr[l] arr[j]
swap(arr,l,i);
return i;
}
特别的注意: 我们随机的选取了一个 partition 元素 与 arr [ l ] 进行交换
假设不这么做, 数据完全有序, 那么快排的左边子数组将会缺失, 只剩下右边的子数组, 快速排序的二分性质将不复存在! 时间复杂度将退化成O(N^2)
交换数组元素的代码非常简单:
//静态方法只能调用静态方法,若不写static,则调用的时候会报错
private static <E>void swap(E[] arr,int a, int b){
E temp=arr[a];
arr[a]=arr[b];
arr[b]=temp;
}
双路快排
上面的单路快排仍然是有缺陷的. 假设数据全部相等, 那么partition 后右边的子数组将会缺失, 快速排序的二分性质将不复存在! 时间复杂度将退化成O(N^2).
为了解决这一问题,我们使用了双路快排, 其核心思想是将等于 partition的元素平均地分散到数组两端, 维持快速排序的等分性质

接口函数的逻辑和单路快排完全一致, 差别在于partition部分
public static <E extends Comparable<E>> void sortTwoWays(E[] arr){
sortTwoWays(arr,0,arr.length-1);//双路快排
}
private static <E extends Comparable<E>> void sortTwoWays(E[] arr, int l, int r){
if (l>=r){
return;
}
int p=partition2(arr,l,r);
sortTwoWays(arr,l,p-1);
sortTwoWays(arr,p+1,r);
}
partition 操作
双路快排的partition操作较为复杂, 我们需要将 等于partition的元素均匀地分散到数组两边, 用到的索引指针非常容易出错!!!
我们定义了循环不变量 arr[ l+1, i-1] <= p, arr[ j+1, r ] >= p
当 i >j 时, 索引越界, 说明已经遍历完了 arr [ l, r ] 中的所有元素
最后交换 arr [ l ] 和 arr [ j ], return j 完成partition 操作

双路快排的 partition 操作的核心是如何更新索引 i 和 j, 这两个索引非常容易出错 !!!
首先判断 arr [ i ] 和 partition 的关系, 若 arr [ i ]< partiotion, i++ 即可. 若 arr [ i ] >= partition, 则将 arr[ i ] 与 arr [ j ] 进行交换, 相当于把一个可能和partition 相等的元素放到了数组的右边, 随后 j - - .
由于我们遍历数组的条件是 i<=j , 所以一旦更新了 i 和 j 就需要判断是否已经完成了遍历, 否则后续判断 arr [ j ] 和 partition 的关系时 会抛出索引越界的异常
接下来再判断arr [ j ] 和 partition 的关系. 若 arr [ j ]> partiotion, j - - 即可. 若 arr [ j ] <= partition, 则将 arr[ i ] 与 arr [ j ] 进行交换, 相当于把一个可能和partition 相等的元素放到了数组的左边边, 随后 i + + .
遍历完数组以后, 将 arr [ l ] 和 arr [ j ] 交换, 而不能和 arr [ i ]交换 因为 arr [ i ]可能比partition要大
最后 return j, 完成 partition 操作.
整个过程代码如下:
//双路快排 partition
private static <E extends Comparable<E>> int partition2(E[] arr, int l ,int r){
int randomIdx= l+ RandomGenerator.nextInt(r-l+1);
swap(arr,l,randomIdx);
int i=l+1,j=r;
while (i<=j){
//先判断 i 这边
if (arr[i].compareTo(arr[l])<0){
i++;
}else{//若 arr[i] 与 partition元素 相等, 放到左边
swap(arr,i,j);
j--;
}
//一个准则: 循环控制量必须要和循环判断条件紧密结合!!!
//一旦改变循环控制量,必须马上进入循环条件判断,否则接下来的执行代码压根不满足在循环内的条件
if (i>j)
break;
//先判断 j 这边
if (arr[j].compareTo(arr[l])>0){
j--;
}else {//若 arr[i] 与 partition元素 相等, 放到右边
swap(arr,i,j);
i++;
}
}
//遍历结束后, j==i-1, arr[l,j]<= partition, arr[i,r]>=partition
//arr[l] 必须要和 arr[j] 交换, 而不能和arr[i]交换 因为arr[i]可能比partition要大
swap(arr,l,j);
return j;
}
三路快排
上面的双路快排仍然是有优化空间的. 假设我们在partition中将数组分成三个部分,一部分<= partition元素, 一部分等于partition元素, 一部分大于partition元素, 那么在面对所有数据相等的情况下,只需要进行一次partition操作即可, 时间复杂度进化成O(N).
一句话, 三路快排在双路快排基础上进一步优化了 大量数据重复 情况下的性能

方法接口部分和双路快排逻辑略有不同, partition3返回的不再是一个索引,而是两个索引的数组
public static <E extends Comparable<E>> void sortThreeWays(E[] arr){
sortThreeWays(arr,0,arr.length-1);//三路快排
}
private static <E extends Comparable<E>> void sortThreeWays(E[] arr, int l, int r){
if (l>=r){
return;
}
int[] p=partition3(arr,l,r);
sortThreeWays(arr,l,p[0]-1);
sortThreeWays(arr,p[1]+1,r);
}
partition
我们定义了循环不变量 arr[ l+1, it] < p, arr [ lt+1, i-1 ] == p, arr[ gt, r ] > p
当 i >= gt 时, 索引越界, 说明已经遍历完了 arr [ l, r ] 中的所有元素
最后交换 arr [ l ] 和 arr [ it ], return [lt, gt - 1 ] 完成 partition 操作

整个过程代码如下:
//三路快排 partition
private static <E extends Comparable<E>> int[] partition3(E[] arr, int l ,int r){
int randomIdx= l+ RandomGenerator.nextInt(r-l+1);
swap(arr,l,randomIdx);
int lt=l,i=l+1,gt=r+1;
while (i<gt){
if (arr[i].compareTo(arr[l])<0){
swap(arr,i,lt+1);
lt++;
i++;
}else if(arr[i].compareTo(arr[l])==0){
i++;
}else {
swap(arr,i,gt-1);
gt--;
}
}
swap(arr,l,lt);
int[] ret={lt,gt-1};
return ret;
}
性能测试与分析
根据上面的分析,我们可以轻易写出不同版本的快速排序时间复杂度
快排版本
完全随机
完全有序
元素完全相等
单路快排(无随机化partition元素)
NlogN
N^2
N^2
单路快排(随机化partition元素)
NlogN
NlogN
N^2
双路快排(随机化partition元素)
NlogN
NlogN
NlogN
三路快排(随机化partition元素)
NlogN
NlogN
N
我们测试一下不同版本的快排:
public static void main(String[] args) {
int n =1000000;
//对于完全随机的数组,归并和单路快排均为O(NlogN)
//但单路快排在分治过程中,避免了频繁的数组复制操作,性能略优
Integer[] arr1=ArrayGenerator.generateRandomArray(n,n);//随机数据
Integer[] arr2= Arrays.copyOf(arr1,arr1.length);
System.out.println("随机数据");
SortingHelper.sortTest("MergeSort",arr1,n);
SortingHelper.sortTest("QuickSortOneWay",arr2,n);
//对于有序数组, 归并排序会进化成O(N)
arr1=ArrayGenerator.generateOrderedArray(n);//有序数据
arr2= Arrays.copyOf(arr1,arr1.length);
System.out.println("有序数据");
SortingHelper.sortTest("MergeSort",arr1,n);
SortingHelper.sortTest("QuickSortOneWay",arr2,n);
//对于全部元素相等的数组,归并排序进化成(logN), 单路快排会退化成O(n^2),双路快排依然是O(NlogN), 三路快排进化成O(N)
arr1=ArrayGenerator.generateRandomArray(n,1);//重复数据
arr2= Arrays.copyOf(arr1,arr1.length);
Integer[] arr3= Arrays.copyOf(arr1,arr1.length);
System.out.println("重复数据");
SortingHelper.sortTest("MergeSort",arr1,n);
//单路直接达到最大递归深度, 栈溢出
SortingHelper.sortTest("QuickSortTwoWays",arr2,n);
SortingHelper.sortTest("QuickSortThreeWays",arr3,n);
}
输出结果
随机数据
n=1000000 ,MergeSort花费时间0.6884032秒
n=1000000 ,QuickSortOneWay花费时间0.5798061秒
有序数据
n=1000000 ,MergeSort花费时间0.0128925秒
n=1000000 ,QuickSortOneWay花费时间0.1713661秒
重复数据
n=1000000 ,MergeSort花费时间0.0103178秒
n=1000000 ,QuickSortTwoWays花费时间0.2680609秒
n=1000000 ,QuickSortThreeWays花费时间0.0181259秒
Process finished with exit code 0
可以看出和我们预料的结果完全一样.
Last updated
Was this helpful?