来自算法导论第七章:快速排序
1. 算法描述
快速排序(quick-sort)与前面介绍的归并排序(merge-sort)(见算法基础——算法导论(1))一样,使用了分治思想。下面是对一个一般的子数组A[p~r]进行快速排序的分治步骤:
① 分解:数组A[p~r]被划分为两个子数组A[p~q]和A[q+1~r],使得 A[q] 大于等于 A[p~q] 中的每个元素,且小于等于 A[q+1~r] 中的每个元素。(需要注意的是, A[p~q] 和 A[q+1~r] 可能为空)
② 解决:对子数组 A[p~q] 和 A[q+1~r] 递归调用本程序。
③ 合并:因为子数组都是原址排序的,所以不需要合并操作,此时的A数组已经是排好序的。
ps:所谓原址排序是指:在对组进行排序的过程中,只有常数个元素被存储到数组外面。
快速排序的核心思想其实很简单,即:在数组中,如果某元素均比自己前面的元素大(或等于),而比自己后面的元素小(或等于),则该元素处于“正确位置”。
下面给出伪代码:
可以看出,算法的关键是partiton方法的实现。下面给出它的算法实现:
直接看可能觉得很晕,我们结合实例看看它是如何工作的:
上图(a~i)表示的是对子数组A[p~r] =[2,8,7,1,3,5,6,4]进行排序时,每次迭代之前数组元素和一些变量的值。
我们可以初步看出,在i和j移动的过程中,数组被分成了三个部分(分别用灰色,黑色,白色表示),其中i和j就是分割线,并且浅灰部分的元素均比A[r]小,黑色部分的元素均比A[r]大((i)图除外,因为循环完毕之后执行了exchange A[i+1] with A[j])。
我们再仔细分析一下具体细节:
① 首先看迭代之前的部分。它执行了x = A[r],称x为主元,其他的所有元素都是和它进行比较。它在迭代过程中值一直都没改变。然后执行i =p-1,使得i初始化为小于x的最后一个元素的位置,此时i在子数组A的左端。
② 再看迭代部分。迭代时j从子数组A的开头逐步移至A的倒数第二位。每次迭代中,会比较当前j位置的值和“x”的大小,如果小于或相等“x”,就将灰色部分的长度增加1(i=i+1),然后把j位置的值置换到灰色部分的末尾(exchange A[i] with A[j])。这样迭代下来,就能保证灰色部分的值都比“基准”小或相等,而黑色部分的值都比“基准”大。
③ 最后看迭代完成后的部分。就进行了一步 exchange A[i+1] with A[j]操作,就是把“x”置换到灰色部分与黑色部分之间的位置。
这样所有的操作下来,就产生了一个“临界”位置q,使得A[q]大于等于A[p~q]中的每个元素,而小于等于A[q+1~r]中的每个元素。
代码:
/*
p:算法导论第七章快速排序 7-1快速排序的描述
t:2018年4月21日 11:08:48
*/
#include <iostream>
using namespace std;
//遍历函数
void Display(int *a, int length)
{
for (int i = 0; i < length; ++i)
{
cout << a[i] << " ";
}
cout << endl;
}
//递归 数组的划分
//交换两个数的值
void Exchange(int &a, int &b)
{
if (a != b)
{
int temp = a;
a = b;
b = temp;
}
}
int Partition(int a[], int begin, int end)
{
int x = a[end];
int i = begin - 1;
for (int j = begin; j <= end-1; ++j)
{
if (a[j] <= x)
{
i++;
Exchange(a[j], a[i]);
}
}
Exchange(a[i + 1], a[end]);
//题目7.1-2的要求 如果元素都相同时,返回中间位置的元素
if (i + 1 == end)
{
return (begin + end) / 2;
}
return i + 1;
}
//非递归 数组的划分
int Partition1(int a[], int begin, int end)
{
return 1;
}
//快速排序
void QuickSort(int a[], int begin, int end)
{
if (begin < end)
{
int q = Partition(a, begin, end);
QuickSort(a,begin, q - 1);
QuickSort(a, q + 1, end);
}
}
int main()
{
int a[] = {2,8,7,1,3,5,6,4};
//int a[] = { 3, 2, 1 };
int length = sizeof(a) / sizeof(int);
Display(a, length);
//快速排序
QuickSort(a, 0, length-1);
Display(a, length);
return 0;
}
性能分析:
(1) 最坏情况划分
当每次划分把问题分解为一个规模为n-1的问题和一个规模为0的问题时,快速排序将产生最坏的情况(以后给出这个结论的证明,目前可以想象的出)。由于划分操作的时间复杂度为θ(n);当对一个长度为0的数组进行递归操作时,会直接返回,时间为T(0) = θ(1)。于是算法总运行时间的递归式为:
T(n) = T(n-1) + T(0) + θ(n) = T(n-1) + θ(n) 。
可以解得,T(n) = θ(n²)。
由此可见,在划分都是最大程度不平均的情况下,快速排序算法的运行时间并不比插入排序好,甚至在某些情况下(比如数组本身已按大小排好序),不如插入排序。
(2) 最好情况划分
当每次划分都是最平均的时候(即问题规模被划分为[n/2]和【n/2】-1时),快速排序性能很好,总运行时间的递归式为:
T(n) = 2T(n/2) + θ(n)
可以解得,T(n) = θ(nlg n)。
(3) 平均划分
快速排序算法的平均运行时间,更接近于最好情况划分时间而非最坏情况划分时间。理解这一点的关键就是理解划分的平均性是如何反映到描述运行时间的递归式上的。
我们举个例子,对于一个9:1的划分,乍一看,这种划分是很不平均的。此时的运行时间递归式为:
T(n) = T(9n/10) + T(n/10) + cn,
我们可以用如下递归树来更加形象地描述运行时间:
递归会在深度为log10/9n = θ(lg n )处终止,因此,快速排序的总代价为O(nlgn)。可见,在直观上看起来非常不平均的划分,其运行时间是接近最好情况划分的时间的。事实上,对于任何一种常数比例的划分,其运行时间总是O(nlgn)。