排序算法系列——快速排序

快速排序同冒泡排序,是交换排序的一种。快速排序是C.R.A.Hoare于1962年提出的一种划分交换排序。它采用了一种分治的策略,通常称其为分治法(Divide-and-ConquerMethod)。快速排序的时间复杂度是O(nlogn),比其他O(n^2)的排序算法快很多,不过实现起来还是有一定难度的。
分治法的基本思想是:将原问题分解为若干个规模更小但结构与原问题相似的子问题。递归地解这些子问题,然后将这些子问题的解组合为原问题的解。

基本思想

快速排序使用分治法思想,从待排序序列中取出某个元素作为基准,以此基准将待排序序列划分成左右两个子序列,并使基准左边的元素都小于基准元素,右边的元素都大于等于基准元素,这样基准元素的位置就是正确的了,然后通过递归对每个子序列都做同样的操作,即可完成排序。

实现要点

实现的难点是在选定一个基准之后如何将其余元素按大小分别移动到基准的两侧。下面介绍一种空间复杂度为O(1)的方法:
下面以一组待排序元素(data)为例,简单描述下将序列以基准划分为两部分的过程
|0|1|2|3|4|5|6|7|8|9|
|-|-|-|-|-|-|-|-|-|-|
|72|38|54|87|96|17|57|84|25|91|
首先我们取data[0]作为基准,用一个变量temp保存data[0],然后将所有小于temp的元素移动到序列头部,大于等于temp的元素移动到序列尾部,用i=0,j=n-1分别记录序列头部以及尾部,然后从j开始遍历,data[j]>=temp不做任何交换,同时j–,继续比较下一个,如果data[j]小于temp,则将data[j]与data[i]交换,然后i++,并比较data[i]与temp,data[i]小于temp,则不做任何交换,同时i++,继续比较下一个,如果data[i]>=temp,则将data[i]与data[j]交换,j–,再比较data[i]与temp。从描述可以发现,首先我们从尾部开始,如果发生交换则切换到头部,再发生交换再切回来,如此交换进行,直至i=j,最终i的位置就是我们一开始选择的基准元素的位置。这样一次排序就完成了,接着递归排序子序列,这里需要判断如果最终的基准位置左边没有子序列则无需递归排序左子序列,同样的如果基准位置右边没有子序列则无需递归排序右子序列。
Java实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
package com.vicky.sort;

import java.util.Random;

/**
* 交换排序:快速排序
*
* 时间复杂度:O(nlogn)
*
* @author Vicky
*
*/

public class QuickSort {
public static <T extends Comparable<T>> void sort(T[] data) {
long start = System.nanoTime();
if (null == data) {
throw new NullPointerException("data");
}
if (data.length == 1) {
return;
}
devideSort(data, 0, data.length - 1);
System.out.println("use time:" + (System.nanoTime() - start) / 1000000);
}

private static <T extends Comparable<T>> void devideSort(T[] data,
int start, int end)
{

int i = start;
int j = end;
T temp = data[start];
boolean direct = false;
while (i < j) {
if (direct) {
if (data[i].compareTo(temp) >= 0) {
SortUtils.swap(data, i, j);
j--;
direct = false;
} else {
i++;
}
} else {
if (data[j].compareTo(temp) < 0) {
SortUtils.swap(data, i, j);
i++;
direct = true;
} else {
j--;
}
}
}
if (start < i - 1) {
devideSort(data, start, i - 1);
}
if (i + 1 < end) {
devideSort(data, i + 1, end);
}
}

public static void main(String[] args) {
Random ran = new Random();
Integer[] data = new Integer[2];
for (int i = 0; i < data.length; i++) {
data[i] = ran.nextInt(10000);
}
QuickSort.sort(data);
SortUtils.printArray(data);
}
}
效率分析

(1)时间复杂度
O(nlogn)

(1)最坏时间复杂度
最坏情况是每次划分选取的基准都是当前无序区中关键字最小(或最大)的记录,划分的结果是基准左边的子区间为空(或右边的子区间为空),而划分所得的另一个非空的子区间中记录数目,仅仅比划分前的无序区中记录个数减少一个。因此,快速排序必须做n-1次划分,第i次划分开始时区间长度为n-i+1,所需的比较次数为n-i(1≤i≤n-1),故总的比较次数达到最大值:Cmax = n(n-1)/2=O(n2)。如果按上面给出的划分算法,每次取当前无序区的第1个记录为基准,那么当文件的记录已按递增序(或递减序)排列时,每次划分所取的基准就是当前无序区中关键字最小(或最大)的记录,则快速排序所需的比较次数反而最多。
(2)最好时间复杂度
在最好情况下,每次划分所取的基准都是当前无序区的”中值”记录,划分的结果是基准的左、右两个无序子区间的长度大致相等。总的关键字比较次数:0(nlgn)。
注意:用递归树来分析最好情况下的比较次数更简单。因为每次划分后左、右子区间长度大致相等,故递归树的高度为O(lgn),而递归树每一层上各结点所对应的划分过程中所需要的关键字比较次数总和不超过n,故整个排序过程所需要的关键字比较总次数C(n)=O(nlgn)。因为快速排序的记录移动次数不大于比较的次数,所以快速排序的最坏时间复杂度应为0(n2),最好时间复杂度为O(nlgn)。
(3)基准关键字的选取
在当前无序区中选取划分的基准关键字是决定算法性能的关键。
①”三者取中”的规则
“三者取中”规则,即在当前区间里,将该区间首、尾和中间位置上的关键字比较,取三者之中值所对应的记录作为基准,在划分开始前将该基准记录和该区伺的第1个记录进行交换,此后的划分过程与上面所给的Partition算法完全相同。
②取位于low和high之间的随机数k(low≤k≤high),用R[k]作为基准
选取基准最好的方法是用一个随机函数产生一个取位于low和high之间的随机数k(low≤k≤high),用R[k]作为基准,这相当于强迫R[low..high]中的记录是随机分布的。用此方法所得到的快速排序一般称为随机的快速排序。
注意:随机化的快速排序与一般的快速排序算法差别很小。但随机化后,算法的性能大大地提高了,尤其是对初始有序的文件,一般不可能导致最坏情况的发生。算法的随机化不仅仅适用于快速排序,也适用于其它需要数据随机分布的算法。
(4)平均时间复杂度
尽管快速排序的最坏时间为O(n2),但就平均性能而言,它是基于关键字比较的内部排序算法中速度最快者,快速排序亦因此而得名。它的平均时间复杂度为O(nlgn)。

(2)空间复杂度
O(lgn)
快速排序在系统内部需要一个栈来实现递归。若每次划分较为均匀,则其递归树的高度为O(lgn),故递归后需栈空间为O(lgn)。最坏情况下,递归树的高度为O(n),所需的栈空间为O(n)。
(3)稳定性
不稳定
快速排序的稳定性要跟其如何选择基准以及如果进行交换有关。