注:本文为《算法导论》中排序相关内容的笔记。对此感兴趣的读者还望支持原作者。
基本概念
快速排序最早由C. A. R. Hoare在1962年提出,一经面世,就备受瞩目。不过,快速排序并不快!对于包含n个数的输入数组来说,快速排序是一种最坏情况下时间复杂度为 Θ ( n 2 ) \Theta(n^2) Θ(n2)的排序算法。然而,快速排序真的很快!因为,它的平均性能非常好,期望时间复杂度为 Θ ( n lg n ) \Theta(n\lg n) Θ(nlgn),而且 Θ ( n lg n ) \Theta(n\lg n) Θ(nlgn)中隐含的常数因子非常小。另外,它还能够进行原址排序,甚至在虚存环境中也能很好地工作。因此,快速排序通常是实际排序应用中最好的选择。
算法思想
与归并排序一样,快速排序也采用了分治思想。下面是对一个典型的子数组 A [ p … r ] A[p \dots r] A[p…r]进行快速排序的三步分治过程:
- 分解:数组 A [ p … r ] A[p \dots r] A[p…r]被划分为两个(可能为空)子数组 A [ p … q − 1 ] A[p \dots q-1] A[p…q−1]和 A [ q + 1 … r ] A[q+1 \dots r] A[q+1…r],使得 A [ p … q − 1 ] A[p \dots q-1] A[p…q−1]中的每一个元素都不大于 A [ q ] A[q] A[q],而 A [ q ] A[q] A[q]也不大于 A [ q + 1 … r ] A[q+1 \dots r] A[q+1…r]中的每个元素。其中,计算下标 q q q也是划分过程的一部分
- 解决:通过递归调用快速排序,对子数组 A [ p … q − 1 ] A[p \dots q-1] A[p…q−1]和 A [ p … q − 1 ] A[p \dots q-1] A[p…q−1]进行排序。
- 合并:因为子数组都是原址排序的,所以不需要合并操作——数组 A [ p … r ] A[p \dots r] A[p…r]已经有序。
是不是感觉有点抽象?这样就对一个无序数组完成排序操作了?没错,还真是!不信,咱们看看下面实现快速排序的伪代码。
从上图中,我们不难看出,为了排序一个数组 A A A的全部元素,我们初始调用QUICKSORT( A , 1 , A . l e n g t h A, 1, A.length A,1,A.length)。此外,我们可以得到算法的关键部分是PARTITION部分,它总是选择一个 x = A [ r ] x = A[r] x=A[r]作为主元,并围绕它来划分子数组 A [ p … r ] A[p \dots r] A[p…r],实现了对子数组 A [ p … r ] A[p \dots r] A[p…r]的原址重排。因此,我们不妨详细看看它的实现细节。
上图给出了一个样例数组的PARTITION过程,我们选择4作为主元来划分子数组,而且淡灰色表示不大于主元的元素,深灰色表示不小于主元的元素。首先, 2 < 4 2<4 2<4,根据PARTITION过程,与它自身进行交换,并被放入元素值较小的那部分;接着,8和7被添加到元素值较大的那个部分中;之后,因为 1 < 4 1<4 1<4,它将8进行交换,数值较小的部分规模增加;同样的,在下一步中,3和7进行了交换;然后,5和6都被包含进较大部分,循环至此结束。最后,主元被交换,这样主元就位于两个部分之间,也完成了对原数组的划分。
从上图中,我们不难看出, i i i始终指向不大于主元的元素的上界。因此,在PARTITION过程中,对于任意数组下标 k k k,有:
- 若 p ≤ k ≤ i p \le k \le i p≤k≤i,则 A [ k ] ≤ x A[k] \le x A[k]≤x;
- 若 i + 1 ≤ k ≤ j − 1 i + 1 \le k \le j-1 i+1≤k≤j−1,在 A [ k ] > x A[k] > x A[k]>x;
- 若 k = r k = r k=r, 则 A [ k ] = x A[k] = x A[k]=x。
通过PARTITION过程,我们将原数组划分成不大于划分元素和不小于划分元素的两部分,原数组变得相对有序。因此,不难想象,只要不断地对划分后的子数组递归调用PARTITION过程,我们就能完成对一个无序数组的排序。
算法分析
从快速排序的伪代码实现中,我们不难看出,快速排序的时间复杂度主要取决于PARTITION过程。具体而言,快速排序的运行时间依赖于划分是否平衡,而平衡又依赖于用于划分的元素。如果划分是平衡的,那么快速排序算法性能与归并排序一样。如果划分是不平衡的,那么快速排序的性能就接近于插入排序了。因此,为了更好地探究快速排序的时间复杂度,我们将分析快速排序在最坏情况划分和最好情况划分的分析。
在最坏情况划分下,划分产生的两个子数组分别包含 n − 1 n-1 n−1个元素和0个元素。不妨假设快速排序过程中,每一次递归调用中都出现了这种不平衡划分。划分操作为 Θ ( n ) \Theta(n) Θ(n)。由于对于一个大小为0的数组进行递归调用会直接返回,因此 T ( 0 ) = Θ ( 1 ) T(0) = \Theta(1) T(0)=Θ(1)。于是,在最坏情况划分下,快速排序的递归式可以表示为:
T ( n ) = T ( n − 1 ) + T ( 0 ) + Θ ( n ) = T ( n − 1 ) + Θ ( n ) T(n) = T(n-1) + T(0) + \Theta(n) = T(n-1) + \Theta(n) T(n)=T(n−1)+T(0)+Θ(n)=T(n−1)+Θ(n)
我们可以解得上式为 T ( n ) = Θ ( n 2 ) T(n)=\Theta(n^2) T(n)=Θ(n2)。因此,如果快速排序在每一层递归上,划分都是最大程度的不平衡,那么算法的时间复杂度为 Θ ( n 2 ) \Theta(n^2) Θ(n2)。而输入数组完全有序,或输入数组元素相同都会导致最坏情况划分的出现。
说完最坏情况划分,再来说说最好情况划分吧。其实,最好情况划分也就是最平衡的划分,即PARTITION得到的两个子问题的规模都不大于 n / 2 n/2 n/2。这是因为,其中一个子问题的规模是 ⌊ n / 2 ⌋ \lfloor n/2 \rfloor ⌊n/2⌋,另一个子问题的规模是 ⌊ n / 2 ⌋ − 1 \lfloor n/2 \rfloor - 1 ⌊n/2⌋−1。在这种情况下,快速排序的性能非常好,因为,其算法运行时间的递归式为:
T ( n ) = 2 T ( n / 2 ) + Θ ( n ) T(n)=2T(n/2) + \Theta(n) T(n)=2T(n/2)+Θ(n)
我们可以求解上式得到 T ( n ) = Θ ( n lg n ) T(n)=\Theta(n \lg n) T(n)=Θ(nlgn),十分令人满意。
然而,在实际应用中,最坏情况划分与最好情况划分毕竟都是少数,那么快速排序算法的平均运行时间又是怎样的呢?令人欣慰的是,快速排序的平均运行时间更接近于其最好情况划分,而不是最坏情况划分。没错,就是这么神奇!可是,为什么呢?
我们不妨假设快速排序算法在每一层递归中,划分比例为 1 − a : a ( 0 < a ≤ 1 / 2 ) 1-a:a(0 < a \le 1/2) 1−a:a(0<a≤1/2),则此时快速排序的运行时间递归式为:
T ( n ) = T [ ( 1 − a ) n ] + T ( a n ) + c n T(n)=T[(1-a)n]+T(an)+cn T(n)=T[(1−a)n]+T(an)+cn
则我们依然可以求解得到 T ( n ) = Θ ( n lg n ) T(n)=\Theta(n\lg n) T(n)=Θ(nlgn)。此外,从直观上看,即使快速排序算法有常数次递归,划分比例不是 1 − a : a 1-a:a 1−a:a,甚至有常数次划分比例为 n − 1 : 0 n-1:0 n−1:0,其并不影响快速排序算法的时间复杂度的量级为 Θ ( n lg n ) \Theta(n\lg n) Θ(nlgn)(当然, T ( n ) T(n) T(n)隐藏的常数因子不尽相同)。快速排序算法的平均运行时间更接近其最好情况划分的特点也正是其无与伦比之处!
算法改进
前面说到,在最坏情况划分下,快速排序的时间复杂度为 Θ ( n 2 ) \Theta(n^2) Θ(n2),这是令人难以接受的。而输入数组完全有序和输入数组元素相同等情况都会导致最坏情况划分的出现。因此,我们极力避免此类情况的发生,以下是几种改进方案。
- 随机化输入数组。显而易见,打乱输入数组主要针对输入数组完全有序的情形;
- 三路划分。它主要针对输入数组元素相同的情况;
- 三数取中划分。它更细致地选择划分过程中的主元元素以避免最坏情况划分。
接下来,我们将一一详解上述三种改进方案。
首先是随机化输入数组。没什么好说的,就是打乱输入数组以尽可能避免最坏情况划分的出现,示例程序如下。
接下来是三路划分。与原始的PARTITION过程不同,它修改了PARTITION过程——排列 A [ p … r ] A[p \dots r] A[p…r],返回值是两个数组下标 q q q和 t t t(其中, p ≤ q ≤ t ≤ r p \le q \le t \le r p≤q≤t≤r),且有:
- A [ q … t ] A[q \dots t] A[q…t]中的所有元素都相等;
- A [ p … q − 1 ] A[p \dots q-1] A[p…q−1]中的所有元素都小于 A [ q ] A[q] A[q]
- A [ t + 1 … r ] A[t+1 \dots r] A[t+1…r]中的所有元素都大于 A [ q ] A[q] A[q]
根据三路划分的要求,我们可以得到如下的示例程序:
不难看出,当输入数组元素全部相同时,改进后的快速排序只需要调用一次修改后的PARTITION过程,即可完成排序,十分高效。
最后再来看看三数取中划分。它从子数组中随机选出三个元素,取其中位数作为主元。它通过此方法以避免快速排序算法过程中发生最坏情况划分,示例程序如下:
以上就是快速排序的三种改进方案,而且它们之间可以相互融合共同改进快速排序算法(下面的快速排序算发的实现就是采取此种方案)。当然,快速排序的改进方案远不止这三种,感兴趣的读者可以自行查阅相关文献,在此不再赘述。
算法实现
好了,快速排序相关概念介绍了这么多,是时候将其付诸实践了!毕竟,“光说不练假把式”!下面给出了快速排序的Java版本。
import java.util.Random;
/**
* 快速排序(对输入随机化处理,三路划分)
* @author 爱学习的程序员
* @version V1.0
*/
public class QuickSort{
/**
* 交换两个数(借助数组,无法直接交换)
* @param arr 原数组
* @param m 待交换数的下标
* @param n 另一个待交换数的下标
* @return 无
*/
public static void exchange(int[] arr, int m ,int n){
int temp = arr[m];
arr[m] = arr[n];
arr[n] = temp;
return;
}
/**
* 打乱数组
* @param arr 待打乱数组
* @return 无
*/
public static void shuffle(int[] arr){
Random rand = new Random();
int index = rand.nextInt(arr.length);
exchange(arr, 0, index);
for(int i = 1; i < arr.length; i++){
index = rand.nextInt(arr.length-i) + i;
exchange(arr, i, index);
}
}
/**
* 三数取中法选取划分元素
* @param arr 原数组
* @param low 数组下界的下标
* @param high 数组上界的下标
* @return 无
*/
public static int median(int[] arr, int low, int high){
Random rand = new Random();
int[] index = new int[3];
for(int i = 0; i < 3; i++)
index[i] = rand.nextInt(high-low+1) + low;
// 可利用快排过程选出第k大的数的特点选择中位数,但是既然确定是三数取中法,就不用快排了
if(arr[index[0]] >= arr[index[1]]){
if(arr[index[0]] <= arr[index[2]])
return index[0];
else if(arr[index[1]] >= arr[index[2]])
return index[1];
else
return index[2];
}
else{
if(arr[index[0]] >= arr[index[2]])
return index[0];
else if(arr[index[1]] >= arr[index[2]])
return index[2];
else
return index[1];
}
}
/**
* 快速排序中的三路划分
* @param arr 原数组
* @param low 数组的下界
* @param high 数组的上界
* @return 三路划分的结果的下标(小于划分元素的部分的上界的下标,大于划分元素的部分的下界的下标)
*/
public static int[] partition(int[] arr, int low, int high){
if(low >= high)
return null;
else{
// 用于划分的元素
int pivot = 0;
// 划分元素取中位数
if((high - low) >= 2){
int index = median(arr, low, high);
pivot = arr[index];
exchange(arr, low, index);
}
else{
// 交换数组的下界与上界
pivot = arr[high];
exchange(arr, low, high);
}
// i表示数组中小于划分元素部分的上界,j表示数组中等于划分元素部分的上界
int i = low - 1, j = low + 1;
for(int k = low + 1; k < high+1; k++){
if(arr[k] < pivot){
i++;
exchange(arr, i, k);
exchange(arr, j, k);
j++;
continue;
}
if(arr[k] == pivot){
exchange(arr, j, k);
j++;
continue;
}
}
int[] result = {i, j};
return result;
}
}
/**
* 快速排序
* @param arr 原数组
* @param low 数组的下界
* @param high 数组的上界
* @return 无
*/
public static void quickSort(int[] arr, int low, int high){
int[] result = new int[2];
while(low < high){
result = partition(arr, low, high);
if((high - result[1]) > (result[0] - low)){
quickSort(arr, low, result[0]);
low = result[1];
}
else{
quickSort(arr, result[1], high);
high = result[0];
}
}
}
public static void main(String[] args){
// 测试数组
Random rand = new Random();
int[] arr = new int[10];
int i = 0;
System.out.println("测试数组:");
for(i = 0; i < arr.length ;i++){
arr[i] = rand.nextInt(15) + 1;
System.out.print(arr[i]+"\t");
}
// 打乱数组以尽可能避免最坏情况
shuffle(arr);
System.out.println("\n"+"打乱结果:");
for(i = 0; i < arr.length; i++)
System.out.print(arr[i]+"\t");
System.out.println("\n"+"排序结果:");
//快速排序
quickSort(arr, 0, arr.length - 1);
for(i = 0; i < arr.length; i++)
System.out.print(arr[i]+"\t");
}
}
算法总结
最后再简单总结一下快速排序算法的优缺点吧。
-
优点
快速排序平均性能非常好,它的期望运行时间是 Θ ( n lg n ) \Theta(n\lg n) Θ(nlgn),而且隐藏的常数因子非常小。另外,它还能够进行原址排序,甚至在虚存环境中也能很好地工作,是实际排序应用中最好的选择!
-
缺点
快速排序是不稳定的,它最坏划分情况下的时间复杂度为 Θ ( n 2 ) \Theta(n^2) Θ(n2)。虽然,快速排序发展至今,已经有不少学者提出了改进方案,但仍未彻底解决此问题。