为什么叫快速排序
这个标题是带有歧义的,每一种排序都有自己的名字,有的是发明者+排序(Shell排序),有的是用的步骤名称+排序(插入排序)...
而快速排序是以它的属性+排序为名(这不是废话吗)。那么我再换个意义明确的标题:
快速排序为什么那么快
要弄明白这一点首先需要了解基于比较的排序模型:决策树对大小为n的输入,其位置关系有n!种可能。排序算法的工作就是在所有n!种可能中找出输入究竟是哪一种。
而基于比较的排序算法手头的工具就只有 比较 操作,通过不断地比较一对对元素来排除不可能的排列(或者说留下可能的排列)。
而比较的结果只能是>和<(不考虑=)这两种,所以每一次比较操作总有机会使得一半的可能留下来。所以基于比较的排序算法的下界为Ω(lgn!) =Ω(nlgn)。
也许你可能会问那为什么插入排序在数据本来有序的情况下只需花费O(n)呢。
花费O(n)是没错,但这毕竟是决策树中最短的一支啊,下界是针对最坏情况下至少得做多少次比较。
所以快速排序快的原因就是:其分区过程在很大程度上获得的信息量很大,可以快速地排除掉不可能的排列可能。
比如拿最好的情况来说——主元为中位数(也就是均匀地把区间分为两半),那么得到有n/2个元素比主元小,n/2个元素比主元大。
这样剩下来的排列可能为:(n/2)! * (n/2)! = n!/ 2^(n/2) 种,而这一过程只花费了O(n)。
当于在O(1)的比较下就只剩根号2分之1的可能,也是呈几何下降。
当然快也是相对而言的:就拿插入排序来讲,它为什么那么慢,就是因为每一次插入元素所获得的信息很少,排除的排列可能也很少。
比如对第n/2元素进行插入操作,花费了O(n/2),剩下来的排列可能为 n!/ (n/2)! 而插入之前的排列可能为 n! / (n/2 - 1)! 种,相当于在O(1)的比较下
只排除了2/n的可能,这一效率是远远没法跟快速排序比的!
所以快速排序高效的原因就在于利用主元划分这一模型可以很快地确定元素之间的关系。
Hoare分区算法
Hoare分区算法的优点是:首先常数很低(对于本身就已经处于正确区间的元素是不会做多余移动的),并且会把和主元相等的元素均匀地分到两边。
/***************************************************************
函数:随机选取主元的霍尔分区算法
参数:对[low, high)范围内的数据分区,range为区间的元素个数
***************************************************************/
int* hoarePartition(int* low, int* high, int range)
{
///随机选取主元
int pivot = ((int)(((double)rand() / RAND_MAX) * range) + low);
low--;
while(true)
{
while(*(++low) < pivot); ///扫描左区间
while(*(--high) > pivot); ///扫描右区间
///当两边都扫描到不属于自己区间的元素时
if(low < high)
{
///当两区间还没有交集说明有两个元素分别落在错误的区间,交换即可
int tmp = *low;
*low = *high;
*high = tmp;
}
///当区间相交时,Hoare分区只保证[0....pivot)中的元素 <= [pivot....n)中的元素
///并没有确定主元的位置,所以只得到两个确定大小关系的区间,中间没有主元相隔
else return low;
}
}
这里特别要注意的是Hoare分区算法并没有让主元隔在中间,只是
得到两个确定大小关系的区间。
所以在快速排序递归函数中对区间范围的选择会有所区别。
比较次数的期望
这里的比较次数是指整个排序算法运行中,所有扫描区间的动作的次数。即下面cnt的大小的期望:
int cnt = 0; ///比较次数
int* hoarePartition(int* low, int* high, int range)
{
int pivot = *((int)(((double)rand() / RAND_MAX) * range) + low);
low--;
while(true)
{
while(*(++low) < pivot)
cnt++; ///发生比较
cnt++; ///测试失败也是一次比较
while(*(--high) > pivot)
cnt++; ///发生比较
cnt++; ///测试失败也是一次比较
if(low < high)
{
int tmp = *low;
*low = *high;
*high = tmp;
}
else return low;
}
}
/***************************************
函数:快速排序函数
参数:对[low, high)范围内的数据排序
平均时间复杂度:O(nlgn)
***************************************/
void quickSort(int* low, int* high)
{
int range = high - low; ///区间元素个数
///当区间有不止1个元素时才进行分区
if(range > 1)
{
int* pivot = hoarePartition(low, high, range); ///返回主元地址
quickSort(low, pivot); ///对左区间进行快速排序
quickSort(pivot, high);
}
}
/***********************************
函数:测试比较次数
***********************************/
void testQuickSort(int* a, int n)
{
cnt = 0; ///初始化次数
quickSort(a, a + n);
cout << endl << cnt << endl;
}
这里得到的期望的常数和算法导论上是不一样的,因为这是
针对Hoare分区的期望分析,不过分析方法和算法导论是一样的。
首先得假设下返回的主元就是用来划分的主元。不然太难分析。
这里用算法导论上面的推导方法:任意一对相距gap的元素(i,j),在排序过程中会比较k次的概率为1 / (gap + 1)^(k - 1) * 2 / (gap + 1),
而相距gap远的元素对有(n - gap)对,所以得到下面的级数:
cnt的实际值应该会比上面的计算结果大,因为Hoare分区算法中选取的主元值也会和自己比较(上面的级数没有考虑这一点),所以还应该加上主元的个数,
这本来也是个概率问题(如果主元为最小值时可能使得区间不变),不过可以很快做出估计:O(n),因为这是一棵二叉树,每个内部节点都会选取一次主元,而内部节点 = 叶子节点 - 1。
所以期望次数最终为:2nln(n) + n
下面是我的测试数据(输入元素互异):
三数取中的优势
从区间随机选取三个数(可以重复选取同一元素),直观上很快可以知道越是大小接近中间的数越是容易成为主元,也就在很大程度上使得划分更加均匀。
/***************************************************************
函数:三数取中的霍尔分区算法
参数:对[low, high)范围内的数据分区,range为区间的元素个数
***************************************************************/
int* hoarePartition(int* low, int* high, int range)
{
///三数取中
int a = *((int)(((double)rand() / RAND_MAX) * range) + low);
int b = *((int)(((double)rand() / RAND_MAX) * range) + low);
int c = *((int)(((double)rand() / RAND_MAX) * range) + low);
int pivot = a > b ? (b > c ? b : (a > c ? c : a)) : (a > c ? a : (b > c ? c : b));
low--;
while(true)
{
while(*(++low) < pivot); ///扫描左区间
while(*(--high) > pivot); ///扫描右区间
///当两边都扫描到不属于自己区间的元素时
if(low < high)
{
///当两区间还没有交集说明有两个元素分别落在错误的区间,交换即可
int tmp = *low;
*low = *high;
*high = tmp;
}
///当区间相交时,Hoare分区只保证[0....pivot)中的元素 <= [pivot....n)中的元素
///并没有确定主元的位置,所以只得到两个确定大小关系的区间,中间没有主元相隔
else return low;
}
}
下面是具体的概率分布:
因为第k大的元素被选为主元的概率为(6(k - 1)(n - k) + 3n - 2) / n^3,对n取极限无穷得到概率分布函数 y = 6x(1 - x):
如果像算法导论里面那样定义一个“好”的划分为主元排在[n/3, 2n/3]之中,那么通过积分我们可以知道这一概率为13/27(接近一半的概率),比不用三数取中的1/3大出不少。
完全不会分析这种概率分布不均的东西,但直觉上肯定是能理解的,下面我把测试数据发出来(和上面一样元素互异):
提升还是不错的,但没有什么完美的东西,因为三数取中的“取”这一步又得花不少额外的时间。不过这一缺点可以被弥补一些,后面会讲使用插入排序。
尾递归优化
在快速排序的递归树中节点的信息是不需要被保存的,所以可以让一个父节点来代替其中一个孩子节点继续递归下去:
/***************************************
函数:尾递归优化版快速排序
功能:对[low, high)范围内的数据排序
期望运行时间:O(2nlgn)
***************************************/
void quickSortRoutine(int* low, int* high)
{
int range; ///区间元素个数
///当区间有不止1个元素时才进行分区
while((range = high - low) > 1) ///改为循环语句这样父节点就可以在下一次运算中充当孩子节点
{
int* pivot = hoarePartition(low, high, range); ///返回主元地址
quickSortRoutine(low, pivot); ///对左区间进行快速排序
low = pivot; ///注意区间范围
}
}
可以通过选择替代“较大”的孩子递归使得递归树的深度控制在lgn之内,不过快速排序本来就是靠概率吃饭的,这样会增加比较开销,我就不写了。
这里还有一点点优化的空间,那就是维护一个保存区间信息的数组,里面保存的数据为待分区的区间,这样就完全不用递归了。不过会额外增加空间上的消耗。
使“叶子”变粗
就像前面说的三数取中的花销很大,并且递归开销也很大,所以我们可以把快速排序的“触底”情况的限制放宽,最后对整个基本有序的数组应用插入排序。
于是得到完整快速排序:
#define FACTOR 37 ///叶子的宽度
/***************************************************************
函数:三数取中的霍尔分区算法
参数:对[low, high)范围内的数据分区,range为区间的元素个数
***************************************************************/
int* hoarePartition(int* low, int* high, int range)
{
///三数取中
int a = *((int)(((double)rand() / RAND_MAX) * range) + low);
int b = *((int)(((double)rand() / RAND_MAX) * range) + low);
int c = *((int)(((double)rand() / RAND_MAX) * range) + low);
int pivot = a > b ? (b > c ? b : (a > c ? c : a)) : (a > c ? a : (b > c ? c : b));
low--;
while(true)
{
while(*(++low) < pivot); ///扫描左区间
while(*(--high) > pivot); ///扫描右区间
///当两边都扫描到不属于自己区间的元素时
if(low < high)
{
///当两区间还没有交集说明有两个元素分别落在错误的区间,交换即可
int tmp = *low;
*low = *high;
*high = tmp;
}
///当区间相交时,Hoare分区只保证[0....pivot)中的元素 <= [pivot....n)中的元素
///并没有确定主元的位置,所以只得到两个确定大小关系的区间,中间没有主元相隔
else return low;
}
}
/***************************************
函数:尾递归优化版快速排序
功能:对[low, high)范围内的数据排序
期望运行时间:O(nlgn)
***************************************/
void quickSortRoutine(int* low, int* high)
{
int range; ///区间元素个数
///当区间有不止1个元素时才进行分区
while((range = high - low) > FACTOR) ///改为循环语句这样父节点就可以在下一次运算中充当孩子节点
{
int* pivot = hoarePartition(low, high, range); ///返回主元地址
quickSortRoutine(low, pivot); ///对左区间进行快速排序
low = pivot; ///注意区间范围
}
}
/********************************************************************
函数:优化一点点的插入排序
功能:对[low, high)内的数据进行排序
********************************************************************/
void improvedInsertionSort(int* low, int* high)
{
///因为最小值在第一位,所以直接从第三个元素开始插入
++low;
while(++low < high)
{
int tmp = *low; ///把待插入元素保存到临时变量中
int* destPos = low; ///计算插入位子
///把第一次测试单独提出来
if(*(--destPos) > tmp)
{
do
{
*(destPos + 1) = *destPos;
}while(*(--destPos) > tmp); ///测试上一个是否是目标位置
*(destPos + 1) = tmp; ///最后一次测试失败使得destPos比实际小1
}
}
}
/**********************************
函数:完整版快速排序
**********************************/
void quickSort(int* low, int* high)
{
///设置随机数种子
srand(time(nullptr));
///进行快速排序,叶子宽度为FACTOR
quickSortRoutine(low, high);
///找出最小值放到最开始作为插入排序的哨兵节点
int* minPos = low; ///最小值的位置
int* lastPos = low + FACTOR;
for(int* i = low + 1; i < lastPos; i++)
if(*i < *minPos) minPos = i;
int tmp = *low;
*low = *minPos;
*minPos = tmp;
///最后进行插入排序
improvedInsertionSort(low, high);
}
那么叶子的宽度该如何去选择呢。一般来说是8到20都不错,不过这里用了三数取中所以常数比普通随机选取主元的快速排序大,所以这里是个两位数就可以了。
上面那个37也是我主观选的,就像生活大爆炸里sheldon说:73是最美妙的数字,因为73是第21个素数,反过来37正好又是第12个素数,并且21正好又等于7和3的积,
另外把73转换为二进制后可以得到1001001,正读倒读都一样。感觉这样一写就很高端啊。
后记
内容有误或有更好的方法请在下面评论。谢谢。