快速排序和它在工程实践中的应用
Posted 程序員Berlin
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了快速排序和它在工程实践中的应用相关的知识,希望对你有一定的参考价值。
前言
对于排序来说,虽然快速排序是一种在最坏情况下时间复杂度为平方级别(theta(n^2))的排序算法,但它却是在实际的排序应用中最好的选择,因为它的平均性能非常好,它的期望时间复杂度为 theta(nlgn),而且 theta(nlgn) 中隐含的常数因子非常小。
为什么说快速排序在实际的应用中是最好的选择呢?因为在工程实践中,我们通过各种手段去改进它在最坏情况下的时间复杂度,使得它在大概率情况下都能获得它的期望时间复杂度。
快速排序的基本实现
下面我们直接来看一下快速排序的提出者 Hoare 的原始版本的实现,依据《算法导论》写的:
void swap(int *a, int *b) // 交换两数的代码
{
int temp = *a;
*a = *b;
*b = temp;
}
下面是实现:
int partition(int *a, int left, int right)
{
int pivot = a[left];
int i = left - 1;
int j = right + 1;
while (1)
{
while (1)
{
j = j - 1;
if (a[j] <= pivot) break;
}
while (1)
{
i = i + 1;
if (a[i] >= pivot ) break;
}
if (i < j)
{
swap(&a[i], &a[j]);
}
else return j;
}
}
void quicksort(int *a, int left, int right)
{
if (left < right)
{
int p = partition(a, left, right);
quicksort(a, left, p); // 注意这里!!!见下面解释。
quicksort(a, p + 1, right);
}
}
可以看到,当 partition 结束时,它返回的值 j 满足 left <= j < right。
当 partition 结束时,a[left… j] 区间中的每一个元素都小于或等于 a[j + 1…right] 区间中的元素。
主元是存在于区间 a[left…j] 或 a[j + 1, rright] 中的。
下面又是另一种实现:
int hoare_partition(int *a, int start, int end)
{
int pivot = a[start];
int i = start;
int j = end + 1;
while (1)
{
while (a[--j] > pivot) if (j == start) break;
while (a[++i] < pivot) if (i == end) break;
if (i < j)
{
swap(&a[i], &a[j]);
}
else break;
}
swap(&a[start], &a[j]);
return j;
}
// int hoare_partition(int *a, int start, int end)
// {
// int pivot = a[start];
// int i = start;
// int j = end + 1;
// while (i < j)
// {
// while (a[--j] > pivot);
// while (a[++i] < pivot);
// if (i < j)
// {
// swap(&a[i], &a[j]);
// }
// else break;
// }
// swap(&a[start], &a[j]);
// return j;
// }
void HQSort(int *a, int start, int end)
{
if (start < end)
{
int q = hoare_partition(a, start, end);
HQSort(a, start, q - 1);
HQSort(a, q + 1, end);
}
}
原理:两跟指针分别从数组的一头一尾出发,右指针直到碰到比主元(pivot)大的才停下来,左指针直到碰到比主元小的才停下来,如果左指针的位置比右指针的位置要小,才交换两个指针所指的值。直到左指针的位置比右指针的位置要大或者左指针到达数组的右边或者右指针到达数组的左边,循环跳出,并交换此时主元所在位置的值和右指针所指的值。
还有一个《算法导论》上的实现:
int paratition(int *a, int start, int end)
{
int pivot = a[end];
int i = start - 1;
int temp = 0;
for (int j = start; j < end; j++)
{
if (a[j] <= pivot)
{
i = i + 1;
swap(&a[i], &a[j]);
}
}
swap(&a[i + 1], &a[end]);
return i + 1;
}
void qsort(int *a, int start, int end)
{
if (start < end)
{
int q = paratition(a, start, end);
qsort(a, start, q - 1);
qsort(a, q + 1, end);
}
}
paratition 维护了4个区域。在 a[p]~a[i] 区间内的所有值都小于等于 pivot,在 a[i + 1]~a[j - 1] 区间内的所有值都大于 pivot,a[start] = pivot,子数组 a[j]~a[end - 1] 中的值可能属于前面的任何一种情况。
+----------------------------------------------------+
| <= pivot | > pivot | 无限制 | pivot |
+----------------------------------------------------+
^ ^ ^ ^
| | | |
start i j end
快速排序用了分治的思想,通过递归调用快速排序,将划分的数组进行排序。
快速排序的性能
快速排序最好的时间复杂度为 theta(nlgn),最差的时间复杂度达到了平方级别。
最差的情况出现在划分的不平衡上。假设每次在划分时,产生的两个子问题分别包含了 n - 1 个元素和 0 个元素。那么在这种情况下时间复杂度就达到 theta(n^2)。相关的数学证明《算法导论》第三版上有,这里就不给出来了。
在可能的最平衡的划分中,即两个子问题的规模都不大于 n/2,此时的性能会非常好,也就是 theta(nlgn)。
在划分上,任何一种常数比例的划分都会产生深度为 theta(lgn) 的递归树,其中每一层的时间代价都是 O(n)。因此,只要划分是常数比例的,算法的运行时间总是 O(nlgn)。
这上面的代码中,为了方便并没有随机地打乱数组元素的顺序。但是在实际中,这个是很重要的。引入随机性,我们可以对所有的输入都能获得较好的期望性能。
算法改进
切换到插入排序
当数组里的元素基本有序的时候,插入排序的速度会很快。对于小数组,快速排序比插入排序慢,因为快速排序在小数组里也会递归调用自身。基于此,在数组基本有序或是小数组时,应该切换到插入排序。那这个小到底是怎样衡量的呢?这个会在后面说到。
三数取中划分
在选取主元时,我们通常会取数组中的第一个元素或者最后一个元素。这样就可能会导致划分的不平衡。所以当主元是随机选取的时候,对数组的划分会比较平衡。但是我们有更好的办法去更细致地选择作为主元的元素,而不是随机选取。人们发现,从数组中随机选取出3个元素并用它们中的中位数作为主元效果最好。
针对重复元素值的三向划分快速排序(或叫三向切分的快速排序)
当一个数组的含有大量重复元素的时候,特别是当划分出的子数组全是相同的值时,快速排序依然会继续划分,这就浪费了很多时间。从而也就有很大的改进空间。下面是实现:
void quick3way(int *a, int left, int right)
{
if (left < right)
{
int lo = left, i = left + 1, hi = right;
int pivot = a[left];
while (i <= hi)
{
if (a[i] < pivot) swap(&a[lo++], &a[i++]);
else if (a[i] > pivot) swap(&a[i], &a[hi--]);
else i++;
}
sort(a, left, lo - 1);
sort(a, hi + 1, right);
}
else return;
}
它从左到右遍历数组一次,维护一个指针 lo 使得 a[left…lo - 1] 中的元素都小于 pivot,一个指针 hi 使得 a[hi + 1…right] 中的元素都大于 pivot,一个指针 i 使得 a[lo…i - 1] 中的元素都等于 pivot,a[i…hi] 中的元素还未确定。
划分中:
+----------------------------------------------------+
| < pivot | = pivot | 未确定 | > pivot |
+----------------------------------------------------+
^ ^ ^
| | |
lo i hi
划分后:
+-----------------------------------------+
| < pivot | = pivot | > pivot |
+-----------------------------------------+
^ ^ ^ ^
| | | |
left lo hi right
三向字符串快速排序
待补充。。。
Java 中的排序库函数
在写 Java 程序时,我们经常会使用 Arrays 类里面的 sort 函数进行元素的排序。那么究竟这个 sort 函数是怎么实现的呢?现在就让我们深入到源代码里看看。首先调用 Arrays 类里面的 sort 函数时,底层是这样的:
// 以排序一个整型数组为例
public static void sort(int[] a) {
DualPivotQuicksort.sort(a, 0, a.length - 1, null, 0, 0);
}
咦???这个 DualPivotQuicksort 究竟是个什么东西?从名字就可以略知一二:双枢轴快速排序。所以,它下面的 sort 方法便是双枢轴快速排序的实现。Dual-Pivot Quicksort algorithm 是由 Vladimir Yaroslavskiy 发明的,我特意去找了他所写的关于这个算法的论文,觉得还是很有意思的。论文 在此 ,可以点击进行下载。那下面我们来详细看一下论文的内容。
Dual-Pivot Quicksort algorithm
Dual-Pivot Quicksort algorithm 简介
相比经典的快速排序算法,Dual-Pivot Quicksort 使用了两个 pivot 元素。Vladimir Yaroslavskiy 在论文里说 Dual-Pivot Quicksort 比经典的快速排序更快更高效,特别是在大数组上。这个算法经过了大量的数据验证,所以说挺可靠的了。所以他也说 Dual-Pivot Quicksort algorithm 能被推荐写到下一代 JDK 的发行版(这里也就是 JDK 7)里面。这里我用的是 JDK 8。
在 Dual-Pivot Quicksort algorithm 中,使用两个 pivot 把数组 T[] a,T 是基本数据类型,划分成3个部分,同时还有3个指针,分别是 L、K、G,还有两个指向数组中第一个元素和最后一个元素的指针,分别是 left 和 right。不妨命名这两个 pivot 为 P1 和 P2。下面是一个示意图:
+------------------------------------------------------------------------------------+
| P1 | < P1 | | P1 <= && <= P2 | | ? | | > P2 | P2 |
+------------------------------------------------------------------------------------+
^ part I ^ part II ^ part IV ^ part III ^
| | | | |
left L K G right
下面来说一个算法的执行步骤:
首先它先判断数组的元素个数,如果小于2(但 JDK 源码定义的 INSERTION_SORT_THRESHOLD 的值为47),则使用插入排序。这个 INSERTION_SORT_THRESHOLD 是一个私有的常量值,表示使用插入排序的阈值,如果数组的元素小于27(对于 JDK 来说是47)个,则用插入排序。
选择两个 pivot 元素,P1 和 P2。例如,我们可以取 a[left] 为 P1,a[right] 为 P2。
P1 必须要比 P2 小!如果不是的话就比较它们的大小,然后进行一个交换。所以,现在就可以划分成这些部分了:
part I 为区间 a[left + 1…L - 1],这个部分的元素全部都小于 P1,
part II 为区间 a[L …K - 1],这个部分的元素全部都大于等于 P1 并 小于等于 P2,
part III 为区间 a[G + 1…right - 1],这个部分的元素全部都大于 P2,
part IV 为区间 a[K…G],这个部分包含这个数组剩余的元素。
接下来,来自 part IV 的元素 a[K] 将与 P1 和 P2进行大小比较,比较之后将它放置到对应的部分,part I 或 part II 或 part III。
指针 L,K 和 G 在相应的方向上移动。
当 K <= G 时,重复步骤 4 - 5。
当 part IV 的所有元素都找到它们的归属之后,将 P1 与 part I 的最后一个元素进行交换,这样 P1 就纳入了 part I 里面了;将 P2 与 part III 的第一个元素进行交换,这样 P2 也纳入了 part III。
运用分治法,part I、part II、part III 将递归地重复步骤 1 - 7。
数学证明
Vladimir Yaroslavskiy 证明了 Dual-Pivot Quicksort algorithm 的平均比较次数为 2nln(n),平均元素交换次数为 0.8nln(n),而经典的快速排序相应的次数为 2nln(n) 和 1nln(n)。
算法实现
public class DualPivotQuicksort {
public static void sort(int[] a) {
sort(a, 0, a.length);
}
public static void sort(int[] a, int fromIndex, int toIndex) {
rangeCheck(a.length, fromIndex, toIndex); // toIndex 为 a[] 的 length
dualPivotQuicksort(a, fromIndex, toIndex - 1, 3);
}
public static void rangeCheck(int length, int fromIndex, int toIndex) {
// 边界检查
if (fromIndex > toIndex) {
throw new IllegalArgumentException("fromIndex > toIndex");
}
if (fromIndex < 0) {
throw new ArrayIndexOutOfBoundsException(fromIndex);
}
if (toIndex > length) {
throw new ArrayIndexOutOfBoundsException(toIndex);
}
}
private static void swap(int[] a, int i, int j) {
int temp = a[i];
a[i] = a[j];
a[j] = temp;
}
private static void dualPivotQuicksort(int[] a, int left, int right, int div) {
int len = right - left;
if (len < 27) { // 对于这样的微型数组,采用插入排序.值得注意的是,这里的 实现在 JDK 里面的插入排序比这里的考虑得更多
for (int i = left + 1; i <= right; i++) {
for (int j = i; j > left && a[j] < a[j - 1]; j--) {
swap(a, j, j - 1);
}
}
return;
}
int third = len / div; // 这里进行分段
// "medians"
int m1 = left + third;
int m2 = right - third;
if (m1 <= left) {
m1 = left + 1;
}
if (m2 >= right) {
m2 = right - 1;
}
if (a[m1] < a[m2]) {
swap(a, m1, left);
swap(a, m2, right);
}
// pivots
int pivot1 = a[left];
int pivot2 = a[right];
// pointers
int less = left + 1;
int great = right - 1;
// sorting
for (int k = less; k <= great; k++) {
if (a[k] < pivot1) {
swap(a, k, less++);
}
else if (a[k] > pivot2) {
while (k < great && a[great] > pivot2) {
great--; // 找到比 pivot2 小的或等于,比 pivot1 大的或等于的与 a[k] 交换,因为 a[great + 1...right - 1]
}
swap(a, k, great--);
if (a[k] < pivot1) {
swap(a, k, less++);
}
}
}
// swaps
int dist = great - less;
if (dist < 13) {
div++;
}
swap(a, less - 1, left); // 步骤7
swap(a, great + 1, right);
// subarrays
dualPivotQuicksort(a, left, less - 2, div);
dualPivotQuicksort(a, great + 2, right, div);
// equal elements 相等元素
if (dist > len - 13 && pivot1 != pivot2) {
for (int k = less; k <= great; k++) {
if (a[k] == pivot1) {
swap(a, k, less++);
}
else if (a[k] == pivot2) {
swap(a, k, great--);
if (a[k] == pivot1) {
swap(a, k, less++);
}
}
}
}
// subarray
if (pivot1 < pivot2) {
dualPivotQuicksort(a, less, great, div);
}
}
}
测试程序:
public static void main(String[] args) {
int[] a = new int[300000000];
Duration duration;
long millis;
for (int i = 0; i < a.length; i++) {
a[i] = new Random().nextInt(1000000);
}
Instant startInstant = Instant.now();
DualPivotQuicksort.sort(a);
Instant endInstant = Instant.now();
duration = Duration.between(startInstant, endInstant);
millis = duration.toMillis();
System.out.printf("first = %d milliseconds\n", millis);
startInstant = Instant.now();
Arrays.sort(a);
endInstant = Instant.now();
duration = Duration.between(startInstant, endInstant);
millis = duration.toMillis();
System.out.printf("second = %d milliseconds\n", millis);
}
3亿个1000000以内的整数排序,测试发现,Arrays.sort 比原始版本的 Dual-Pivot Quicksort 快了5倍多(本机 i5 + 8g),可见写进 JDK 源码里的 Dual-Pivot Quicksort 做了更多更复杂的优化。比如说使用插入排序时,JDK 源码里面使用了传统的插入排序和 pair insertion sort,详细的可以去看看源码。除了插入排序,还会使用到归并排序,因为归并排序的平均时间复杂度也是 theta(nlgn),这这是一个非常好的性能。除了这些之外,对于 byte、short 和 char 数组,对于一个特定的阈值,会使用计数排序,它是一种稳定的线性时间排序算法,一种非比较的排序算法。一般来讲,基于比较的排序突破不了下界 theta(nlgn)。
总结
可以看到,能在工程实践中使用的排序算法,肯定是考虑到了很多种情况,融合了各种排序算法的优点,以达到面对各种输入都能得到比较好的性能。其实,在 C++ 里面的 std::sort 方法里,也是基于快速排序设计的一种排序算法,融合插入排序、堆排序(在递归深度超过一定值的时候)和快速排序,这个排序我们叫它 introsort (内省排序),这个有机会再研究一下。
以上是关于快速排序和它在工程实践中的应用的主要内容,如果未能解决你的问题,请参考以下文章