简介
今天介绍排序算法中最重要的快速排序,顾名思义,快速排序之所以能在历史的长河中脱颖而出以“快速”两字命名,就是因为经多年实践证明它是已知最快的泛型排序算法。并且到目前为止还没有哪个算法能撼动其位置。所以它的重要性是不言而喻的,是我们一定要熟悉并掌握的排序算法。
原理
前不久我们刚讲过归并排序(归并排序),快速排序同归并排序一样,也是一种“分治”的递归排序。我们需要慢慢讲解它的原理,首先我们提供一张数据表,选取数据表中一项数据 x x x,然后我们将数据表分为三组,比 x x x小的一组,和 x x x相等的一组,比 x x x大的一组。然后按照相同的方法将第1、3组递归的进行排序,最后三组按序连接起来。这便是快速排序的基础,我们发现这和归并排序及其相似,并且也看不出相比归并排序的优越性。我们还需要对其进行优化,思想是我们避免创造第二组,并且尽量避免使用附加内存(归并排序使用了较多的附加内存),接下来我们描述一下快速排序最经典的实现。
我们将其分为4步,输入一个数组 S S S,并且不使用任何附加内存:
- 判断 S S S中元素个数是否大于1,否,直接返回,是,进入下一步;
- 取 S S S中任意元素 v v v为枢纽元;
- 将 S − { v } S- \lbrace v \rbrace S−{v}( S S S中其他元素)划分为两个集合: S 1 = { x ∈ S − { v } ∣ x ≤ v } S_1=\lbrace x \in S- \lbrace v \rbrace | x \leq v \rbrace S1={x∈S−{v}∣x≤v}, S 2 = { x ∈ S − { v } ∣ x ≥ v } S_2=\lbrace x \in S- \lbrace v \rbrace | x \geq v \rbrace S2={x∈S−{v}∣x≥v}。
- 对 S 1 、 S 2 S_1、S_2 S1、S2进行上述递归调用。
第三步中对等于枢纽元的元素的处理不是唯一的,这需要我们自己设计,一种比较好的思想是将等于枢纽元的元素尽可能平分至两集合。下面我们通过一组数据讲解其过程,我们对元素集合{13,81,92,43,65,31,57,26,75,0}进行快速排序,选取的枢纽元为65。
由此可知,快速排序划分的两个大小集合数量并不一定相等,这是一个隐患。因此快速排序的高效率取决于枢纽元的选取。通过选取合适的枢纽元来弥补递归调用的不足,也是其效率优于归并排序的原因。下面我们就步骤2、3的两个重要细节进行补充。
枢纽元的选取
方法1:
直观的方法是选取头元素当做枢纽元,但这具有重大的隐患,如果我们拿到的是一个预排序的数组,那将使得数组中的元素不是被分到
S
1
S_1
S1就是
S
2
S_2
S2中,并且这种情况还会发生在后续的递归中,也就意味着枢纽元没有起到应有的作用,因此不能这样选取。选取前两个相异元素的最大者作为枢纽元也不安全,这具有同样的风险。
方法2:
随机选取枢纽元是一个较为安全的方法,这需要使用到随机数发生器,但随机数发生器会带来额外的开销,因为快速排序应用于大量的数据,因此这种开销不会小。另外随机数发生器有可能出现问题(不要以为这种概率很小)。
方法3:
数组(
N
N
N个元素)的中值是枢纽元的最好选择,它是第
⌈
N
⌉
\lceil N \rceil
⌈N⌉个最大值。但这个元素很难找到,可以通过随机选取三个数然后取中值来近似,但随机数其实没什么作用,因此可知直接取头、尾和中间位置的值来近似中值,这种方法称为三数中值分割法,经实践检验,这种方法减少了14%的比较次数。
分割策略
分割策略有很多,我们使用一种已被证实可以得到好的结果的方法。我们使用这种方法排序数组{8,1,4,9,6,3,5,2,7,0},首先我们通过三数中值分割法取枢纽元为6,然后让枢纽元与尾元素交换使其离开要被分割数据段。 i i i指向第一个元素 j j j指向倒数第二个元素。
下 标 0 1 2 3 4 5 6 7 8 9 元 素 8 1 4 9 0 3 5 2 7 6 ↑ ↑ 游 标 i j \begin{array}{c|c} 下标 & 0 & 1 & 2 & 3 & 4 & 5 & 6 & 7 & 8 &9 \\ \hline 元素 & 8 & 1 & 4 & 9 & 0 & 3 & 5 & 2 & 7 &6 \\ & \uparrow & & & & & & & & \uparrow& \\ 游标 & i & & & & & & & & j & \\ \end{array} 下标元素游标08↑i1124394053657287↑j96
我们先假定所有元素互异,然后将元素分割为大于枢纽元和小于枢纽元两部分,当
i
<
j
i<j
i<j时让
i
i
i右移
j
j
j左移,直至
i
i
i指向一个大于枢纽元的元素,
j
j
j指向一个小于枢纽元的元素。
下
标
0
1
2
3
4
5
6
7
8
9
元
素
8
1
4
9
0
3
5
2
7
6
↑
↑
游
标
i
j
\begin{array}{c|c} 下标 & 0 & 1 & 2 & 3 & 4 & 5 & 6 & 7 & 8 &9 \\ \hline 元素 & 8 & 1 & 4 & 9 & 0 & 3 & 5 & 2 & 7 &6 \\ & \uparrow & & & & & & & \uparrow& \\ 游标 & i & & & & & & & j & \\ \end{array}
下标元素游标08↑i11243940536572↑j8796
然后两数互换。
下 标 0 1 2 3 4 5 6 7 8 9 元 素 2 1 4 9 0 3 5 8 7 6 ↑ ↑ 游 标 i j \begin{array}{c|c} 下标 & 0 & 1 & 2 & 3 & 4 & 5 & 6 & 7 & 8 &9 \\ \hline 元素 & 2 & 1 & 4 & 9 & 0 & 3 & 5 & 8 & 7 &6 \\ & \uparrow & & & & & & & \uparrow& \\ 游标 & i & & & & & & & j & \\ \end{array} 下标元素游标02↑i11243940536578↑j8796
重复该过程直至 i i i在 j j j右边。
下 标 0 1 2 3 4 5 6 7 8 9 元 素 2 1 4 9 0 3 5 8 7 6 ↑ ↑ 游 标 i j \begin{array}{c|c} 下标 & 0 & 1 & 2 & 3 & 4 & 5 & 6 & 7 & 8 &9 \\ \hline 元素 & 2 & 1 & 4 & 9 & 0 & 3 & 5 & 8 & 7 &6 \\ & & & & \uparrow & & & \uparrow& \\ 游标 & & & & i & & & j & \\ \end{array} 下标元素游标02112439↑i405365↑j788796
⇓ \Downarrow ⇓
下 标 0 1 2 3 4 5 6 7 8 9 元 素 2 1 4 5 0 3 9 8 7 6 ↑ ↑ 游 标 i j \begin{array}{c|c} 下标 & 0 & 1 & 2 & 3 & 4 & 5 & 6 & 7 & 8 &9 \\ \hline 元素 & 2 & 1 & 4 & 5 & 0 & 3 & 9 & 8 & 7 &6 \\ & & & & \uparrow & & & \uparrow& \\ 游标 & & & & i & & & j & \\ \end{array} 下标元素游标02112435↑i405369↑j788796
⇓ \Downarrow ⇓
下 标 0 1 2 3 4 5 6 7 8 9 元 素 2 1 4 5 0 3 9 8 7 6 ↑ ↑ 游 标 j i \begin{array}{c|c} 下标 & 0 & 1 & 2 & 3 & 4 & 5 & 6 & 7 & 8 &9 \\ \hline 元素 & 2 & 1 & 4 & 5 & 0 & 3 & 9 & 8 & 7 &6 \\ & & & & & & \uparrow& \uparrow& \\ 游标 & & & & & & j& i & \\ \end{array} 下标元素游标021124354053↑j69↑i788796
此时
i
i
i在
j
j
j右边,不再将两数交换,将
i
i
i与枢纽元
p
i
v
o
t
pivot
pivot交换,一次分割完成,之后对两部分递归调用其过程即可。
下
标
0
1
2
3
4
5
6
7
8
9
元
素
2
1
4
5
0
3
6
8
7
9
↑
↑
游
标
i
p
i
v
o
t
\begin{array}{c|c} 下标 & 0 & 1 & 2 & 3 & 4 & 5 & 6 & 7 & 8 &9 \\ \hline 元素 & 2 & 1 & 4 & 5 & 0 & 3 & 6 & 8 & 7 &9 \\ & & & & & & & \uparrow&&& \uparrow\\ 游标 & & & & & & & i & &&pivot\\ \end{array}
下标元素游标02112435405366↑i788799↑pivot
现在我们考虑如果数组中存在相同元素怎么办,当 i i i与 j j j遇到与枢纽元元素相同的元素是否应该停止并交换呢?答案是 i i i与 j j j都应该停止并交换,回顾一下前面所说的,我们应将等于枢纽元的元素尽可能平分至两集合,只有这样做才能达到这样的效果。
代码实现
快速排序不适合数组长度太小的情况,因此当数组长度小于10时使用插入排序。(完整代码见:https://github.com/kfcyh/sort/tree/master/quicksort)
#include <iostream>
#include <vector>
#include <algorithm>
#include <stdlib.h>
using namespace std;
inline void insertsort(vector<int>& L,int start,int end)
{
for (int i = start+1; i <= end ; ++i)
{
int tmp = std::move(L[i]);
int j=0;
for (j = i; j>0 && (L[j - 1] > tmp); --j)
{
L[j] = std::move(L[j - 1]);
}
L[j] = std::move(tmp);
}
}
inline void swap(vector<int>& L, int i, int j)
{
int temp = L[i];
L[i] = L[j];
L[j] = temp;
}
/**********************计算枢纽元*********************/
inline const int& Partition(vector<int>& L, int low, int high)
{
/*取头、尾和中间值中的中值*/
int m = (high + low) / 2;
if (L[m] < L[low])
swap(L, low, m);
if (L[high] < L[low])
swap(L, low, high);
if (L[high] < L[m])
swap(L, high, m);
/**************************/
swap(L, m, high - 1); //将枢纽元置于high-1处
return L[high - 1]; //返回枢纽元
}
/*************************快速排序************************/
void QuickSort(vector<int>& L, int low, int high)
{
if (low + 10 <= high)
{
const int pivot = Partition(L, low, high); //计算本次枢纽元
int i = low, j = high - 1; //取需要划分元素段的头尾游标
while (1)
{
while (L[++i] < pivot) {} //i指向大于枢纽元的元素
while (L[--j] > pivot) {} //j指向小于枢纽元的元素
if (i < j)
swap(L, i, j); //如果i在j左边交换两元素
else
break; //如果i在j右边退出
}
swap(L, i, high - 1); //恢复枢纽元
QuickSort(L, low, i - 1); //将小于枢纽元的部分排序
QuickSort(L, i + 1, high); //将大于枢纽元的部分排序
}
else //若长度小于10则使用插入排序
{
insertsort(L,low,high);
}
}
/***********外部接口**********/
void QuickSort(vector<int>& L)
{
QuickSort(L, 0, L.size() - 1);
}
性能分析
快速排序的平均时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn),最好情况为 O ( n l o g n ) O(nlogn) O(nlogn),最坏情况为 O ( n 2 ) O(n^2) O(n2),经过优化其最好情况很难达到,并且需要的辅助空间较归并排序较少,综合各项指标,快速排序是性能最好的排序算法,但具体使用时还应根据实际情况考虑。