快速排序(Quicksort)是对
冒泡排序的一种改进。
-
中文名
- 快速排序算法 外文名
- quick sort 别 称
- 快速排序
-
提出者
- C. A. R. Hoare 提出时间
- 1962 应用学科
- 计算机科学 适用领域范围
- Pascal,c++等语言
目录
数组是A[0]……A[N-1],首先任意选取一个数据(通常选用数组的第一个数)作为关键数据,然后将所有比它小的数都放到它前面,所有比它大的数都放到它后面,这个过程称为一趟快速排序。值得注意的是,快速排序不是一种稳定的
排序算法,也就是说,多个相同的值的相对位置也许会在算法结束时产生变动。
设要排序的
一趟快速排序的算法是:
1)设置两个变量i、j,
排序开始的时候:i=0,j=N-1;
2)以第一个数组元素作为关键数据,赋值给
key,即
key=A[0];
3)从j开始向前搜索,即由后开始向前搜索(j--),找到第一个小于
key的值A[j],将A[j]和A[i]互换;
4)从i开始向后搜索,即由前开始向后搜索(i++),找到第一个大于
key的A[i],将A[i]和A[j]互换;
5)重复第3、4步,直到i=j; (3,4步中,没找到符合条件的值,即3中A[j]不小于
key,4中A[i]不大于
key的时候改变j、i的值,使得j=j-1,i=i+1,直至找到为止。找到符合条件的值,进行交换的时候i, j指针位置不变。另外,i==j这一过程一定正好是i+或j-完成的时候,此时令循环结束)。
示例
假设用户输入了如下数组:
下标
|
0
|
1
|
2
|
3
|
4
|
5
|
数据
|
6
|
2
|
7
|
3
|
8
|
9
|
创建变量i=0(指向第一个数据), j=5(指向最后一个数据), k=6(
赋值为第一个数据的值)。
我们要把所有比k小的数移动到k的左面,所以我们可以开始寻找比6小的数,从j开始,从右往左找,不断递减变量j的值,我们找到第一个下标3的数据比6小,于是把数据3移到下标0的位置,把下标0的数据6移到下标3,完成第一次比较:
下标
|
0
|
1
|
2
| 3 |
4
|
5
|
数据
|
3
|
2
|
7
|
6
|
8
|
9
|
i=0 j=3 k=6
接着,开始第二次比较,这次要变成找比k大的了,而且要从前往后找了。递加变量i,发现下标2的数据是第一个比k大的,于是用下标2的数据7和j指向的下标3的数据的6做交换,数据状态变成下表:
下标
|
0
|
1
|
2
|
3
|
4
|
5
|
数据
|
3
|
2
|
6
|
7
|
8
|
9
|
i=2 j=3 k=6
称上面两次比较为一个循环。
接着,再递减变量j,不断重复进行上面的循环比较。
在本例中,我们进行一次循环,就发现i和j“碰头”了:他们都指向了下标2。于是,第一遍比较结束。得到结果如下,凡是k(=6)左边的数都比它小,凡是k右边的数都比它大:
下标
|
0
|
1
|
2
|
3
|
4
|
5
|
数据
|
3
|
2
|
6
|
7
|
8
|
9
|
如果i和j没有碰头的话,就递加i找大的,还没有,就再递减j找小的,如此反复,不断循环。注意判断和寻找是同时进行的。
然后,对k两边的数据,再分组分别进行上述的过程,直到不能再分组为止。
注意:第一遍快速排序不会直接得到最终结果,只会把比k大和比k小的数分到k的两边。为了得到最后结果,需要再次对下标2两边的数组分别执行此步骤,然后再分解数组,直到数组不能再分解为止(只有一个数据),才能得到正确结果。
调用函数
用 法:
void qsort(void *base, int nelem, int width, int (*fcmp)(const void *,const void *));
GO
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
|
// 第一种写法
func quickSort(values []
int
, left, right
int
) {
temp := values[left]
p := left
i, j := left, right
for
i <= j {
for
j >= p && values[j] >= temp {
j--
}
if
j >= p {
values[p] = values[j]
p = j
}
if
values[i] <= temp && i <= p {
i++
}
if
i <= p {
values[p] = values[i]
p = i
}
}
values[p] = temp
if
p-left > 1 {
quickSort(values, left, p-1)
}
if
right-p > 1 {
quickSort(values, p+1, right)
}
}
func QuickSort(values []
int
) {
if
len(values) <= 1 {
return
}
quickSort(values, 0, len(values)-1)
}
// 第二种写法
func Quick2Sort(values []
int
) {
if
len(values) <= 1 {
return
}
mid, i := values[0], 1
head, tail := 0, len(values)-1
for
head < tail {
fmt.Println(values)
if
values[i] > mid {
values[i], values[tail] = values[tail], values[i]
tail--
}
else
{
values[i], values[head] = values[head], values[i]
head++
i++
}
}
values[head] = mid
Quick2Sort(values[:head])
Quick2Sort(values[head+1:])
}
|
Ruby
1
2
3
|
def
quick_sort(a)
(x=a.pop) ? quick_sort(a.select { |i| i <= x }) + [x] + quick_sort(a.select { |i| i > x }) : []
end
|
Erlang语言
1
2
3
4
5
6
|
超简短实现:
q_sort([])->
[];
q_sort([H|R])->
q_sort([X||X<-R,X<H])++[H]++
q_sort([X||X<-R,X>=H]).
|
Haskell语言
1
2
3
|
q_sort n=
case
n
of
[]->[]
(
x:xs
)->q_sort [a|a<-xs,a<=x]++[x]++q_sort [a|a<-xs,a>x]
|
C++语言
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
|
#include <iostream>
using
namespace
std;
void
Qsort(
int
a[],
int
low,
int
high)
{
if
(low >= high)
{
return
;
}
int
first = low;
int
last = high;
int
key = a[first];
/*用字表的第一个记录作为枢轴*/
while
(first < last)
{
while
(first < last && a[last] >= key)
{
--last;
}
a[first] = a[last];
/*将比第一个小的移到低端*/
while
(first < last && a[first] <= key)
{
++first;
}
a[last] = a[first];
/*将比第一个大的移到高端*/
}
a[first] = key;
/*枢轴记录到位*/
Qsort(a, low, first-1);
Qsort(a, first+1, high);
}
int
main()
{
int
a[] = {57, 68, 59, 52, 72, 28, 96, 33, 24};
Qsort(a, 0,
sizeof
(a) /
sizeof
(a[0]) - 1);
/*这里原文第三个参数要减1否则内存越界*/
for
(
int
i = 0; i <
sizeof
(a) /
sizeof
(a[0]); i++)
{
cout << a[i] <<
""
;
}
return
0;
}
/*参考数据结构p274(清华大学出版社,严蔚敏)*/
|
C语言版本
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
|
void
sort(
int
*a,
int
left,
int
right)
{
if
(left >= right)
/*如果左边索引大于或者等于右边的索引就代表已经整理完成一个组了*/
{
return
;
}
int
i = left;
int
j = right;
int
key = a[left];
while
(i < j)
/*控制在当组内寻找一遍*/
{
while
(i < j && key <= a[j])
/*而寻找结束的条件就是,1,找到一个小于或者大于key的数(大于或小于取决于你想升
序还是降序)2,没有符合条件1的,并且i与j的大小没有反转*/
{
j--;
/*向前寻找*/
}
a[i] = a[j];
/*找到一个这样的数后就把它赋给前面的被拿走的i的值(如果第一次循环且key是
a[left],那么就是给key)*/
while
(i < j && key >= a[i])
/*这是i在当组内向前寻找,同上,不过注意与key的大小关系停止循环和上面相反,
因为排序思想是把数往两边扔,所以左右两边的数大小与key的关系相反*/
{
i++;
}
a[j] = a[i];
}
a[i] = key;
/*当在当组内找完一遍以后就把中间数key回归*/
sort(a, left, i - 1);
/*最后用同样的方式对分出来的左边的小组进行同上的做法*/
sort(a, i + 1, right);
/*用同样的方式对分出来的右边的小组进行同上的做法*/
/*当然最后可能会出现很多分左右,直到每一组的i = j 为止*/
}
|
JavaScript
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
|
function
quickSort(array){
function
sort(prev, numsize){
var
nonius = prev;
var
j = numsize -1;
var
flag = array[prev];
if
((numsize - prev) > 1) {
while
(nonius < j){
for
(; nonius < j; j--){
if
(array[j] < flag) {
array[nonius++] = array[j];
//a[i] = a[j]; i += 1;
break
;
};
}
for
( ; nonius < j; nonius++){
if
(array[nonius] > flag){
array[j--] = array[nonius];
break
;
}
}
}
array[nonius] = flag;
sort(0, nonius);
sort(nonius + 1, numsize);
}
}
sort(0, array.length);
return
array;
}
|
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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
|
class
Quick
{
public
void
sort(
int
arr[],
int
low,
int
high)
{
int
l=low;
int
h=high;
int
povit=arr[low];
while
(l<h)
{
while
(l<h&&arr[h]>=povit)
h--;
if
(l<h){
int
temp=arr[h];
arr[h]=arr[l];
arr[l]=temp;
l++;
}
while
(l<h&&arr[l]<=povit)
l++;
if
(l<h){
int
temp=arr[h];
arr[h]=arr[l];
arr[l]=temp;
h--;
}
}
print(arr);
System.out.print(
"l="
+(l+
1
)+
"h="
+(h+
1
)+
"povit="
+povit+
"\n"
);
if
(l>low)sort(arr,low,l-
1
);
if
(h<high)sort(arr,l+
1
,high);
}
}
/*//方式二*/
更高效点的代码:
public
<TextendsComparable<?superT>>
T[]quickSort(T[]targetArr,intstart,intend)
{
inti=start+
1
,j=end;
Tkey=targetArr[start];
SortUtil<T>sUtil=newSortUtil<T>();
if
(start>=end)
return
(targetArr);
/*从i++和j--两个方向搜索不满足条件的值并交换
*
*条件为:i++方向小于key,j--方向大于key
*/
while
(
true
)
{
while
(targetArr[j].compareTo(key)>
0
)j--;
while
(targetArr[i].compareTo(key)<
0
&&i<j)i++;
if
(i>=j)
break
;
sUtil.swap(targetArr,i,j);
if
(targetArr[i]==key)
{
j--;
}
else
{
i++;
}
}
/*关键数据放到‘中间’*/
sUtil.swap(targetArr,start,j);
if
(start<i-
1
)
{
this
.quickSort(targetArr,start,i-
1
);
}
if
(j+
1
<end)
{
this
.quickSort(targetArr,j+
1
,end);
}
returntargetArr;
}
/*//方式三:减少交换次数,提高效率/*/
private
<TextendsComparable<?superT>>
voidquickSort(T[]targetArr,intstart,intend)
{
inti=start,j=end;
Tkey=targetArr[start];
while
(i<j)
{
/*按j--方向遍历目标数组,直到比key小的值为止*/
while
(j>i&&targetArr[j].compareTo(key)>=
0
)
{
j--;
}
if
(i<j)
{
/*targetArr[i]已经保存在key中,可将后面的数填入*/
targetArr[i]=targetArr[j];
i++;
}
/*按i++方向遍历目标数组,直到比key大的值为止*/
while
(i<j&&targetArr[i].compareTo(key)<=
0
)
/*此处一定要小于等于零,假设数组之内有一亿个1,0交替出现的话,而key的值又恰巧是1的话,那么这个小于等于的作用就会使下面的if语句少执行一亿次。*/
{
i++;
}
if
(i<j)
{
/*targetArr[j]已保存在targetArr[i]中,可将前面的值填入*/
targetArr[j]=targetArr[i];
j--;
}
}
/*此时i==j*/
targetArr[i]=key;
/*递归调用,把key前面的完成排序*/
this
.quickSort(targetArr,start,i-
1
);
/*递归调用,把key后面的完成排序*/
this
.quickSort(targetArr,j+
1
,end);
}
|
C#
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
|
using
System;
using
System.Collections.Generic;
using
System.Linq;
using
System.Text;
namespace
test
{
class
QuickSort
{
static
void
Main(
string
[] args)
{
int
[] array = { 49, 38, 65, 97, 76, 13, 27 };
sort(array, 0, array.Length - 1);
Console.ReadLine();
}
/**一次排序单元,完成此方法,key左边都比key小,key右边都比key大。
**@param array排序数组
**@param low排序起始位置
**@param high排序结束位置
**@return单元排序后的数组 */
private
static
int
sortUnit(
int
[] array,
int
low,
int
high)
{
int
key = array[low];
while
(low < high)
{
/*从后向前搜索比key小的值*/
while
(array[high] >= key && high > low)
--high;
/*比key小的放左边*/
array[low] = array[high];
/*从前向后搜索比key大的值,比key大的放右边*/
while
(array[low] <= key && high > low)
++low;
/*比key大的放右边*/
array[high] = array[low];
}
/*左边都比key小,右边都比key大。//将key放在游标当前位置。//此时low等于high */
array[low] = key;
foreach
(
int
i
in
array)
{
Console.Write(
"{0}\t"
, i);
}
Console.WriteLine();
return
high;
}
/**快速排序
*@paramarry
*@return */
public
static
void
sort(
int
[] array,
int
low,
int
high)
{
if
(low >= high)
return
;
/*完成一次单元排序*/
int
index = sortUnit(array, low, high);
/*对左边单元进行排序*/
sort(array, low, index - 1);
/*对右边单元进行排序*/
sort(array, index + 1, high);
}
}
}
|
运行结果:27 38 13 49 76 97 65
13 27 38 49 76 97 65
13 27 38 49 65 76 97
13 27 38 49 65 76 97
快速排序就是
递归调用此过程——在以49为中点分割这个数据序列,分别对前面一部分和后面一部分进行类似的快速排序,从而完成全部数据序列的快速排序,最后把此数据序列变成一个有序的序列,根据这种思想对于上述
数组A的快速排序的全过程如图6所示:
初始状态 {49 38 65 97 76 13 27} 进行一次快速排序之后划分为 {27 38 13} 49 {76 97 65} 分别对前后两部分进行快速排序{27 38 13} 经第三步和第四步交换后变成 {13 27 38} 完成排序。{76 97 65} 经第三步和第四步交换后变成 {65 76 97} 完成排序。图示
F#
1
2
3
4
5
6
|
let rec qsort
=
function
[] -> []
|x
::
xs ->
qsort [
for
i in xs
do
if
i < x then
yield
i]
@
x
::
qsort [
for
i in xs
do
if
i >
=
x then
yield
i]
|
PHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
<?php
function
quickSort(&
$arr
){
if
(
count
(
$arr
)>1){
$k
=
$arr
[0];
$x
=
array
();
$y
=
array
();
$_size
=
count
(
$arr
);
for
(
$i
=1;
$i
<
$_size
;
$i
++){
if
(
$arr
[
$i
]<=
$k
){
$x
[]=
$arr
[
$i
];
}
elseif
(
$arr
[
$i
]>
$k
){
$y
[]=
$arr
[
$i
];
}
}
$x
=quickSort(
$x
);
$y
=quickSort(
$y
);
return
array_merge
(
$x
,
array
(
$k
),
$y
);
}
else
{
return
$arr
;
}
}
?>
|
Pascal
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
|
这里是完全程序,过程部分为快排
program
qsort;
var
n,p:
integer
;
a:
array
[
0..100000
]
of
integer
;
procedure
qs(l,r:
integer
);
//假设被排序的数组是a,且快排后按升序排列)
var
i,j,m,t:
integer
;
begin
i:=l;
j:=r;
//(l(left),r(right)表示快排的左右区间)
m:=a[(l+r)div2];
//注意:本句不能写成:m:=(l+r)div2;
repeat
while
a[i]<m
do
inc(i);
while
a[j]>m
do
dec(j);
//若是降序把'<'与‘>'互换;
if
i<=j
then
begin
t:=a[i];
a[i]:=a[j];
a[j]:=t;
inc(i);
dec(j);
end
;
until
i>j;
if
l<j
then
qs(l,j);
//递归查找左区间
if
i<r
then
qs(i,r);
//递归查找右区间
end
;
begin
readln(n);
//有n个数据要处理
for
p:=
1
to
n
do
read(a[p]);
//输入数据
qs(
1
,n);
for
p:=
1
to
n
do
write
(a[p],
''
);
//输出快排后的数据
end
.
或者
procedure
quickSort(
var
a:
array
of
integer
;l,r:
Integer
);
var
i,j,x:
integer
;
begin
if
l>=r
then
exit;
i:=l;
j:=r;
x:=a[i];
while
i<=j
do
begin
while
(i<j)
and
(a[j]>x)
do
dec(j);
if
i<j
then
begin
a[i]:=a[j];
inc(i);
end
;
while
(i<j)
and
(a[i]<x)
do
inc(i);
if
i<j
then
begin
a[j]:=a[i];
dec(j);
end
;
a[i]:=x;
quicksort(a,l,i-
1
);
quicksort(a,i+
1
,r);
end
;
end
;
|
Python递归
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
#quick sort
def
quickSort(
L
, low, high):
i = low
j = high
if
i >= j:
return
L
key =
L
[i]
while
i < j:
while
i < j
and
L
[j] >= key:
j = j-
1
L
[i] =
L
[j]
while
i < j
and
L
[i] <= key:
i = i+
1
L
[j] =
L
[i]
L
[i] = key
quickSort(
L
, low, i-
1
)
quickSort(
L
, j+
1
, high)
return
L
|
三平均分区法
关于这一改进的最简单的描述大概是这样的:与一般的快速排序方法不同,它并不是选择待排数组的第一个数作为中轴,而是选用待排数组最左边、最右边和最中间的三个元素的中间值作为中轴。这一改进对于原来的快速排序算法来说,主要有两点优势:
(1) 首先,它使得最坏情况发生的几率减小了。
(2) 其次,未改进的快速排序算法为了防止比较时数组越界,在最后要设置一个哨点。
(1) 首先,它使得最坏情况发生的几率减小了。
(2) 其次,未改进的快速排序算法为了防止比较时数组越界,在最后要设置一个哨点。
根据分区大小调整算法
这一方面的改进是针对快速排序算法的弱点进行的。快速排序对于小规模的数据集性能不是很好。可能有人认为可以忽略这个缺点不计,因为大多数排序都只要考虑大规模的适应性就行了。但是快速排序算法使用了分治技术,最终来说大的数据集都要分为小的数据集来进行处理。由此可以得到的改进就是,当数据集较小时,不必继续递归调用快速排序算法,而改为调用其他的对于小规模数据集处理能力较强的排序算法来完成。Introsort就是这样的一种算法,它开始采用快速排序算法进行排序,当递归达到一定深度时就改为堆排序来处理。这样就克服了快速排序在小规模数据集处理中复杂的中轴选择,也确保了堆排序在最坏情况下O(n log n)的复杂度。
另一种优化改进是当分区的规模达到一定小时,便停止快速排序算法。也即快速排序算法的最终产物是一个“几乎”排序完成的有序数列。数列中有部分元素并没有排到最终的有序序列的位置上,但是这种元素并不多。可以对这种“几乎”完成排序的数列使用插入排序算法进行排序以最终完成整个排序过程。因为插入排序对于这种“几乎”完成的排序数列有着接近线性的复杂度。这一改进被证明比持续使用快速排序算法要有效的多。
另一种快速排序的改进策略是在递归排序子分区的时候,总是选择优先排序那个最小的分区。这个选择能够更加有效的利用存储空间从而从整体上加速算法的执行。
另一种优化改进是当分区的规模达到一定小时,便停止快速排序算法。也即快速排序算法的最终产物是一个“几乎”排序完成的有序数列。数列中有部分元素并没有排到最终的有序序列的位置上,但是这种元素并不多。可以对这种“几乎”完成排序的数列使用插入排序算法进行排序以最终完成整个排序过程。因为插入排序对于这种“几乎”完成的排序数列有着接近线性的复杂度。这一改进被证明比持续使用快速排序算法要有效的多。
另一种快速排序的改进策略是在递归排序子分区的时候,总是选择优先排序那个最小的分区。这个选择能够更加有效的利用存储空间从而从整体上加速算法的执行。
不同的分区方案考虑
对于快速排序算法来说,实际上大量的时间都消耗在了分区上面,因此一个好的分区实现是非常重要的。尤其是当要分区的所有的元素值都相等时,一般的快速排序算法就陷入了最坏的一种情况,也即反复的交换相同的元素并返回最差的中轴值。无论是任何数据集,只要它们中包含了很多相同的元素的话,这都是一个严重的问题,因为许多“底层”的分区都会变得完全一样。
对于这种情况的一种改进办法就是将分区分为三块而不是原来的两块:一块是小于中轴值的所有元素,一块是等于中轴值的所有元素,另一块是大于中轴值的所有元素。另一种简单的改进方法是,当分区完成后,如果发现最左和最右两个元素值相等的话就避免递归调用而采用其他的排序算法来完成。
对于这种情况的一种改进办法就是将分区分为三块而不是原来的两块:一块是小于中轴值的所有元素,一块是等于中轴值的所有元素,另一块是大于中轴值的所有元素。另一种简单的改进方法是,当分区完成后,如果发现最左和最右两个元素值相等的话就避免递归调用而采用其他的排序算法来完成。
并行的快速排序
由于快速排序算法是采用分治技术来进行实现的,这就使得它很容易能够在多台处理机上并行处理。
在大多数情况下,创建一个线程所需要的时间要远远大于两个元素比较和交换的时间,因此,快速排序的并行算法不可能为每个分区都创建一个新的线程。一般来说,会在实现代码中设定一个阀值,如果分区的元素数目多于该阀值的话,就创建一个新的线程来处理这个分区的排序,否则的话就进行递归调用来排序。
对于这一并行快速排序算法也有其改进。该算法的主要问题在于,分区的这一步骤总是要在子序列并行处理之前完成,这就限制了整个算法的并行程度。解决方法就是将分区这一步骤也并行处理。改进后的并行快速排序算法使用2n个指针来并行处理分区这一步骤,从而增加算法的并行程度。
在大多数情况下,创建一个线程所需要的时间要远远大于两个元素比较和交换的时间,因此,快速排序的并行算法不可能为每个分区都创建一个新的线程。一般来说,会在实现代码中设定一个阀值,如果分区的元素数目多于该阀值的话,就创建一个新的线程来处理这个分区的排序,否则的话就进行递归调用来排序。
对于这一并行快速排序算法也有其改进。该算法的主要问题在于,分区的这一步骤总是要在子序列并行处理之前完成,这就限制了整个算法的并行程度。解决方法就是将分区这一步骤也并行处理。改进后的并行快速排序算法使用2n个指针来并行处理分区这一步骤,从而增加算法的并行程度。
随机化快排
快速排序的最坏情况基于每次划分对主元的选择。基本的快速排序选取第一个元素作为主元。这样在
数组已经有序的情况下,每次划分将得到最坏的结果。一种比较常见的优化方法是随机化算法,即随机选取一个元素作为主元。这种情况下虽然最坏情况仍然是O(n^2),但最坏情况不再依赖于输入数据,而是由于
随机函数取值不佳。实际上,随机化快速排序得到理论最坏情况的可能性仅为1/(2^n)。所以随机化快速排序可以对于绝大多数输入数据达到O(nlogn)的期望
时间复杂度。一位前辈做出了一个精辟的总结:“随机化快速排序可以满足一个人一辈子的人品需求。”
随机化快速排序的唯一缺点在于,一旦输入数据中有很多的相同数据,随机化的效果将直接减弱。对于极限情况,即对于n个相同的数排序,随机化快速排序的时间复杂度将毫无疑问的降低到O(n^2)。解决方法是用一种方法进行扫描,使没有交换的情况下主元保留在原位置。
平衡快排
每次尽可能地选择一个能够代表中值的元素作为关键数据,然后遵循普通快排的原则进行比较、替换和
递归。通常来说,选择这个数据的方法是取开头、结尾、中间3个数据,通过比较选出其中的中值。取这3个值的好处是在实际问题中,出现近似顺序数据或逆序数据的概率较大,此时中间数据必然成为中值,而也是事实上的近似中值。万一遇到正好中间大两边小(或反之)的数据,取的值都接近最值,那么由于至少能将两部分分开,实际效率也会有2倍左右的增加,而且利于将数据略微打乱,破坏退化的结构。
外部快排
与普通快排不同的是,关键数据是一段buffer,首先将之前和之后的M/2个元素读入buffer并对该buffer中的这些元素进行排序,然后从被
排序
数组的开头(或者结尾)读入下一个元素,假如这个元素小于buffer中最小的元素,把它写到最开头的空位上;假如这个元素大于buffer中最大的元素,则写到最后的空位上;否则把buffer中最大或者最小的元素写入数组,并把这个元素放在buffer里。保持最大值低于这些关键数据,最小值高于这些关键数据,从而避免对已经有序的中间的数据进行重排。完成后,数组的中间空位必然空出,把这个buffer写入数组中间空位。然后
递归地对外部更小的部分,循环地对其他部分进行排序。
三路基数快排
(
Three-way Radix Quicksort,也称作M
ultikey Quicksort、Multi-key Quicksort):结合了
基数排序(radix sort,如一般的
字符串比较排序就是基数排序)和快排的特点,是字符串排序中比较高效的算法。该算法被排序
数组的元素具有一个特点,即multikey,如一个字符串,每个字母可以看作是一个key。算法每次在被排序数组中任意选择一个元素作为关键数据,首先仅考虑这个元素的第一个key(字母),然后把其他元素通过key的比较分成小于、等于、大于关键数据的三个部分。然后
递归地基于这一个key位置对“小于”和“大于”部分进行排序,基于下一个key对“等于”部分进行排序。
非随机
QUICKSORT(
A,
p,
r)
1
if
p<
r
2
then
q ←PARTITION(
A,
p,
r)
3 QUICKSORT(
A,
p,
q-1)
4 QUICKSORT(
A,
q+1,
r)
为排序一个完整的
数组
A,最初的调用是QUICKSORT(
A,
1,
length[
A])。
快速
排序算法的关键是PARTITION过程,它对子数组A[p..r]进行就地重排:
PARTITION(
A,
p,
r)
1
x←
A[
r]
2
i←
p-1
3
for
j←
p
to
r-1
4
do if
A[
j]≤
x
5
then
i←
i+1
6 exchange
A[
i]←→
A[
j]
7 exchange
A[
i+1]←→
A[
r]
随机
对PARTITION和QUICKSORT所作的改动比较小。在新的划分过程中,我们在真正进行划分之前实现交换:
RANDOMIZED-PARTITION(
A,
p,
r)
1
i← RANDOM(
p,
r)
2 exchange
A[
r]←→
A[
i]
3
return PARTITION(
A,
p,
r)
新的快速排序过程不再调用PARTITION,而是调用RANDOMIZED-PARTITION。
RANDOMIZED-QUICKSORT(
A,
p,
r)
1
if
p<
r
2
then
q← RANDOMIZED-PARTITION(
A,
p,
r)
3 RANDOMIZED-QUICKSORT(
A,
p,
q-1)
性能分析
这里为方便起见,我们假设算法Quick_Sort的范围阈值为1(即一直将线性表分解到只剩一个元素),这对该
算法复杂性的分析没有本质的影响。
我们先分析函数
partition的性能,该函数对于确定的输入复杂性是确定的。观察该函数,我们发现,对于有n个元素的确定输入L[p..r],该函数运行时间显然为θ(n)。
最坏情况
无论适用哪一种方法来选择pivot,由于我们不知道各个元素间的相对大小关系(若知道就已经排好序了),所以我们无法确定pivot的选择对划分造成的影响。因此对各种pivot
选择法而言,最坏情况和最好情况都是相同的。
我们从直觉上可以判断出最坏情况发生在每次划分过程产生的两个区间分别包含n-1个元素和1个元素的时候(设输入的表有n个元素)。下面我们暂时认为该猜测正确,在后文我们再详细证明该猜测。
对于有n个元素的表L[p..r],由于函数Partition的计算时间为θ(n),所以快速排序在序坏情况下的复杂性有
递归式如下:
T(1)=θ(1),T(n)=T(n-1)+T(1)+θ(n) (1)
用迭代法可以解出上式的解为T(n)=θ(n
2)。
这个最坏情况运行时间与
插入排序是一样的。
下面我们来证明这种每次划分过程产生的两个区间分别包含n-1个元素和1个元素的情况就是最坏情况。
设T(n)是过程Quick_Sort作用于规模为n的输入上的最坏情况的时间,则
T(n)=max(T(q)+T(n-q))+θ(n),其中1≤q≤n-1 (2)
我们假设对于任何k<n,总有T(k)≤ck,其中c为常数;显然当k=1时是成立的。
将归纳假设代入(2),得到:
T(n)≤max(cq
2+c(n-q)
2)+θ(n)=c*max(q
2+(n-q)
2)+θ(n)
因为在[1,n-1]上q2+(n-q)2关于q递减,所以当q=1时q
2+(n-q)
2有最大值n
2-2(n-1)。于是有:
T(n)≤cn
2-2c(n-1)+θ(n)≤cn
2
只要c足够大,上面的第二个小于等于号就可以成立。于是对于所有的n都有T(n)≤cn。
这样,
排序算法的最坏情况运行时间为θ(n
2),且最坏情况发生在每次划分过程产生的两个区间分别包含n-1个元素和1个元素的时候。
时间复杂度为o(n
2)。
最好情况
如果每次划分过程产生的区间大小都为n/2,则快速排序法运行就快得多了。这时有:
T(n)=2T(n/2)+θ(n),T(1)=θ(1) (3)
解得:T(n)=θ(nlogn)
快速排序法最佳情况下执行过程的递归树如下图所示,图中lgn表示以10为底的对数,而本文中用logn表示以2为底的对数.
由于快速排序法也是基于比较的排序法,其运行时间为Ω(nlogn),所以如果每次划分过程产生的区间大小都为n/2,则运行时间θ(nlogn)就是最好情况运行时间。
但是,是否一定要每次平均划分才能达到最好情况呢?要理解这一点就必须理解对称性是如何在描述运行时间的
递归式中反映的。我们假设每次划分过程都产生9:1的划分,乍一看该划分很不对称。我们可以得到递归式:
T(n)=T(n/10)+T(9n/10)+θ(n),T(1)=θ(1) (4)
请注意树的每一层都有代价n,直到在深度log10n=θ(logn)处达到边界条件,以后各层代价至多为n。递归于深度log10/9n=θ(logn)处结束。这样,快速排序的总时间代价为T(n)=θ(nlogn),从渐进意义上看就和划分是在中间进行的一样。事实上,即使是99:1的划分时间代价也为θ(nlogn)。其原因在于,任何一种按常数比例进行划分所产生的
递归树的深度都为θ(nlogn),其中每一层的代价为
O(n),因而不管常数比例是什么,总的运行时间都为θ(nlogn),只不过其中隐含的常数因子有所不同。(关于
算法复杂性的渐进阶,请参阅
算法的复杂性)
平均情况
快速排序的平均运行时间为θ(nlogn)。
我们对平均情况下的性能作直觉上的分析。
要想对快速排序的平均情况有个较为清楚的概念,我们就要对遇到的各种输入作个假设。通常都假设输入数据的所有排列都是等可能的。后文中我们要讨论这个假设。
当我们对一个随机的输入
数组应用快速排序时,要想在每一层上都有同样的划分是不太可能的。我们所能期望的是某些划分较对称,另一些则很不对称。事实上,我们可以证明,如果选择L[p..r]的第一个元素作为支点元素,Partition所产生的划分80%以上都比9:1更对称,而另20%则比9:1差,这里证明从略。
平均情况下,
Partition产生的划分中既有“好的”,又有“差的”。这时,与Partition执行过程对应的
递归树中,好、差划分是随机地分布在树的各层上的。为与我们的直觉相一致,假设好、差划分交替出现在树的各层上,且好的划分是最佳情况划分,而差的划分是最坏情况下的划分。在根节点处,划分的代价为n,划分出来的两个子表的大小为n-1和1,即最坏情况。在根的下一层,大小为n-1的子表按最佳情况划分成大小各为(n-1)/2的两个子表。这儿我们假设含1个元素的子表的边界条件代价为1。
在一个差的划分后接一个好的划分后,产生出三个子表,大小各为1,(n-1)/2和(n-1)/2,代价共为2n-1=θ(n)。一层划分就产生出大小为(n-1)/2+1和(n-1)/2的两个子表,代价为n=θ(n)。这种划分差不多是完全对称的,比9:1的划分要好。从直觉上看,差的划分的代价θ(n)可被吸收到好的划分的代价θ(n)中去,结果是一个好的划分。这样,当好、差划分交替分布划分都是好的一样:仍是θ(nlogn),但θ记号中隐含的常数因子要略大一些。关于平均情况的严格分析将在后文给出。
在前文从直觉上探讨
快速排序的平均性态过程中,我们已假定输入数据的所有排列都是等可能的。如果输入的分布满足这个假设时,快速排序是对足够大的输入的理想选择。但在实际应用中,这个假设就不会总是成立。
解决的方法是,利用随机化策略,能够克服分布的等可能性假设所带来的问题。
一种随机化策略是:与对输入的分布作“假设”不同的是对输入的分布作“规定”。具体地说,在排序输入的线性表前,对其元素加以随机排列,以强制的方法使每种排列满足等可能性。事实上,我们可以找到一个能在O(n)时间内对含n个元素的
数组加以随机排列的算法。这种修改不改变算法的最坏情况运行时间,但它却使得运行时间能够独立于输入数据已排序的情况。
另一种随机化策略是:利用前文介绍的选择支点元素pivot的第四种方法,即随机地在L[p..r]中选择一个元素作为支点元素pivot。实际应用中通常采用这种方法。
快速排序的随机化版本有一个和其他随机化算法一样的有趣性质:没有一个特别的输入会导致最坏情况性态。这种算法的最坏情况性态是由随机数产生器决定的。你即使有意给出一个坏的输入也没用,因为随机化排列会使得输入数据的次序对算法不产生影响。只有在随机数产生器给出了一个很不巧的排列时,随机化算法的最坏情况性态才会出现。事实上可以证明几乎所有的排列都可使快速排序接近平均情况性态,只有非常少的几个排列才会导致算法的近最坏情况性态。
一般来说,当一个算法可按多条路子做下去,但又很难决定哪一条保证是好的选择时,随机化策略是很有用的。如果大部分选择都是好的,则随机地选一个就行了。通常,一个算法在其执行过程中要做很多选择。如果一个好的选择的获益大于坏的选择的代价,那么随机地做一个选择就能得到一个很有效的算法。我们在前文已经了解到,对快速排序来说,一组好坏相杂的划分仍能产生很好的运行时间
[2]
。因此我们可以认为该算法的随机化版本也能具有较好的性态。
-
参考资料