五、快速排序
1.简介
快速排序是Hoare于1962年提出的,主要采取了分治的思想。快速排序首先确定一个基准值,然后以这个选出的基准值为标准,将整个数组进行按大小进行区分,使得小于该基准值的位于其一侧,大于基准值的位于另一侧。这样就将整个数组分为了小于标准和大于标准两部分,再对每一部分进行同样的处理方法由此完成排序。
快速排序在每一趟的执行中,因为基准值左侧均是小于,右侧都是大于,所以一定会确定基准值的位置,从而使得分治的下一步会分别处理左侧右侧两部分而不再包含该基准值,所以递归可以顺利进行下去。
快速排序之所以能命名为快速排序,就说明其排序就是主打个快速。快速排序在处理完当前基准值后再处理左右部分,可以看出其实际上就是链式二叉树中所介绍的前序遍历的过程,大家可以慢慢领悟。
2.思路与代码
快速排序一般常用的有三种版本,分别是最初的Hoare版本,以及后世改进的挖坑法和前后指法。除此之外,针对快排中可能出现的影响效率的特殊情况,我们会做出一些小优化。以及最后我们会使用非递归完成快排的实现。
(1)快速排序基本形式(递归Hoare版本)
假设a数组区间[begin,end]需要排序,要求begin<end(否则无需排序),我们首先需要确定基准值,一般会选择首元素即下标为begin的元素为基准值,然后开始对数组进行数据比较与处理,右找小左找大,相遇后与基准值交换。具体流程与分析如下:
①首先定义keyi=begin。这是确定基准值,我们使用下标来标识基准值而不采用key=a[begin],这是因为我们在最后涉及到交换,需要将基准值与另一位置的值进行交换。如果采取key=a[begin],相当于引入了一个变量key,在最后交换的时候我们仍需要找到基准值在数组的位置进行交换,而不是和key进行交换(因为key只是个变量,改变key的值并不会影响到数组中选定为基准值位置的值)。
②给定两个指针(实际是两个下标)left,right,分别从左侧begin和右侧end开始遍历处理数组数据,即left=begin,right=end。这里会有想法,既然基准值是begin处的值,那么能不能让left从begin+1开始处理,即left=begin+1?这是不可以的,因为left不只有找大与right交换的作用,left还担任着最后和基准值交换的作用。这就意味着left不可以随便指示,因为left最后需要基准值交换,left所指示的值交换后必然位于基准值位置或左侧,所以要求left所指示的值必须清楚可控地小于等于基准值,否则就会出现错误。而令left=begin+1明显将left拱手让给了一个未知值,如果left在一次遍历中不移动,那么这就会把这个位置值换到基准值左侧,这很明显是不允许的。
③右找小左找大。在前置工作都完成了之后,可以真正开始处理数组了,此时就采取右找小左找大的策略。让right指针从右开始自减,直到找到比基准值小的元素或者与left相遇;在right完成寻找后,left再动身从左开始自增,直到找到比基准值大的元素后者与right相遇。在二者都完成一次寻找之后,就将二者指向的元素进行交换,这个交换只右两种情况:a.较大值与较小值进行换位;b.left与right相遇,不发生交换(自己和自己交换)。当left与right相遇后我们便不再进行下一次寻找换位的处理,否则继续寻找再完成一组交换。
④将left位置的值与keyi位置的基准值交换。我们之所以现在能够无所顾虑的将二者交换,在前几步的处心积虑功不可没。[1]让left从begin开始进行寻找大数,使得left可以在不移动的情况下一定指向基准值自身。[2]处理数组的过程中是先让右找小,在让左找大,并且加入left<right的判断。这样的安排使得有如下几种情况:a.右找到小,左找到大——顺利交换;b.右找到小,左找不到大——左右重合与右指针找到的小数,与基准值发生合理的交换;c.右找不到小——右与左相遇在左指针,由于左还没有移动,所以左指针所指数据为上次与右指针交换的小数或基准值,与基准值的交换是合理的。
有了这些限制,我们才可以保证left所指示的数一定可以与基准值产生的交换是合理的。
⑤在left与right自增自减的过程中,应该限制其只能在找到大数和小数时停止,换言之就是当遇见相等值时不停止,即a[right]>=a[keyi],a[left]<=a[keyi]下自减自增,这样是为了防止left与right同时找到两个与基准值相等的值而陷入死循环。
明确思路后,于是我们终于可以写出递归形式Hoare版本的代码。
//快速排序(hoare版本)
void QuickSort_Hoare(int* a, int begin, int end)
{
//需要对[begin,end]进行排序
if (begin >= end)
{
return;
}
int keyi = begin; //如果设置key=a[begin],在后续交换时穿参需要变化,不可以是&key
int left = begin, right = end; //如果left从begin+1开始,对于如1 2 3 4 5序列,途中left不移动,但是keyi和left的交换依旧会发生
while (left < right)
{
while (left < right && a[right] >= a[keyi]) //判断条件需要left<right,防止越界访问
{
right--;
}
while (left < right && a[left] <= a[keyi]) //判断时需要<=或>=,防止对于如6 1 2 6 5 6 9 8有重复数字序列陷入死循环
{
left++;
}
swap(&a[left], &a[right]);
}
swap(&a[left], &a[keyi]);
keyi = left;
//[begin,keyi-1] keyi [keyi+1,end]
QuickSort_Hoare(a, begin, keyi - 1);
QuickSort_Hoare(a, keyi + 1, end);
}
(2) 快速排序优化
[1]小区间优化
快排的分治递归思想,使得处理排序问题实际上是一个二叉树形式的问题。对于一颗二叉树,我们知道其最后一层结点数量要占到整棵树的结点数量的50%还要多,倒数第二层则是占到了所有结点的25%以上,倒数第三层则是12.5%左右。于是我们发现倒数三层的总和占到了接近90%的数量,而每个结点背后都是一次递归调用。为了减少递归调用的开销,完全可以让结点靠下的部分排序使用其他类型的排序,这样相当于将树最后几层的结点砍去,这样处理递归出的小区间排序可以大大减小递归开销。
但是当下编译器对于递归性能的优化是很棒的,几乎无需考虑递归的效率开销,所以这个优化了解到就好啦。
[2]三数取中
在排序中,我们最理想的状态应该是每一次分治分开的两部分都是均分,这样可以使得效率最高,这种情况下结点数量我们可以在忽略拿出去的基准值的情况下进行估算。不考虑在递归分治中排除基准值所造成的结点数减少一个的变化,整个快排就是标准的二分,每次调用快排都需要遍历,而每一层恰好是整个数组所分割而成的不同组别,所以每一层的开销看成一个整体,每一层调用就是遍历整个数组,所以一层树的开销就是n。
所以整体的开销就是层数*n,所以当下计算有多少层即可。因为是标准的二分,所以层数很明显的是,因此我们可以算出最佳情况的开销,也即时间复杂度为。
但是在实际中很难有这么理想的情况。一旦我们选择了最小的或最大的极端数据,就会导致我们处理结束后基准值一侧元素很少甚至没有,而另一侧元素数量仅仅是整个数组减去几个元素,这种情况快排就不再“快乐”。顺序序列或者逆序序列正是这种很极端的序列,我们可以以同样的方法估计一下这种情况下的开销。
每次调用后由于基准值一侧无数据,所以交给下次调用的数据量只能是本次处理的数据量减一。每次调用数据量减一,那么要处理完整个数组,意味着调用次数等于数据量。由此我们可以发现端倪,这种情况下开销就是的一个标准等差数列,这也就意味着其时间复杂度变为了。
铺垫了这么多,这都是基准值key取得不合理所导致的。为了一定程度避免这种极端情况(无法避免),我们采取三数取中的方案来更合理的选择基准值key。所谓三数取中就是对于一个区间 [begin,end]选择begin、end和(begin+end)/2三个位置的值进行比较,找到三者的中间数,让其作为key值。这样的话更有希望对整个区间进行二分,事实也证明这种方法是有效果的。
此处给出两种优化下的快排代码:
//快速排序--优化方案
int GetMidi(int* a, int begin, int end)
{
int midi = (begin + end) / 2;
//取begin,end,midi三者的中位数
return a[begin] > a[end] ? (a[end] > a[midi] ? end : (a[begin] > a[midi] ? midi : begin)) : a[begin] > a[midi] ? begin : (a[end] > a[midi] ? midi : end);
}
void QuickSort_Hoare_Opt(int* a, int begin, int end)
{
//需要对[begin,end]进行排序
if (begin >= end)
{
return;
}
//快速排序--优化1--小区间优化
//小区间优化:当所处理数据长度足够小时,使用其他更合适的排序方式处理,因为快排实际上类似于二叉树,小区间优化可以对树的最后几层进行剪枝,大大减小递归开销(因为编译器对递归优化很好,所以效果不明显)
//*******************************************
if (end - begin + 1 <= 3)
{
InsertSort(a + begin, end - begin + 1);
return;
}
//*******************************************
else
{
//快速排序--优化2--三数取中
//三数取中:区间第一个,最后一个,中间三个位置数字相比,将三者的中位数作为key,这样使得每一次的递归分治尽量均分,提高处理效率
//*******************************************
int midi = GetMidi(a, begin, end);
swap(&a[begin], &a[midi]);
int keyi = begin;
//*******************************************
int left = begin, right = end;
while (left < right)
{
while (left < right && a[right] >= a[keyi])
{
right--;
}
while (left < right && a[left] <= a[keyi])
{
left++;
}
swap(&a[left], &a[right]);
}
swap(&a[left], &a[keyi]);
keyi = left;
QuickSort_Hoare_Opt(a, begin, keyi - 1);
QuickSort_Hoare_Opt(a, keyi + 1, end);
}
}
(3)快速排序三种方法(递归)
在写这三种方法前,我们可以将快排的核心部分封装成函数独立出去,这样我们写出方法的核心代码,再使用另一个我们的快排套壳(只有代码框架)即可。
//快速排序递归版
void QuickSort(int* a, int begin, int end)
{
if (begin >= end)
{
return;
}
//int keyi = Sort1_Hoare(a, begin, end);
//int keyi = Sort2_Hole(a, begin, end);
//int keyi = Sort3_Pointer(a, begin, end);
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
[1]Hoare版本
Hoare版本我们在上面已经详细讲过,这里就不再赘述了。
//1.Hoare版本
//right找小,left找大,相遇后放入key
int Sort1_Hoare(int* a, int begin, int end)
{
int midi = GetMidi(a, begin, end);
swap(&a[begin], &a[midi]);
int keyi = begin;
int left = begin, right = end;
while (left < right)
{
while (left < right && a[right] >= a[keyi])
{
right--;
}
while (left < right && a[left] <= a[keyi])
{
left++;
}
swap(&a[left], &a[right]);
}
swap(&a[left], &a[keyi]);
keyi = left;
return keyi;
}
[2]挖坑法
挖坑法是Hoare版本的小改造,Hoare版本在左右指针均完成寻找后直接交换左右指针位置的值。挖坑法则是引入了一个“坑位”,实际上就是中间变量,当右指针完成寻找后就用其值“填充”坑位,左指针完成寻找后也如此。在寻找过程整个完成后,将之前存储的key值放入最后挖出的坑即可。本质就是引入了中间变量hole来记录交换位置,从而可以使左右指针完成寻找后即时交换,想必Hoare减少了一些处理的细节。
//2.挖坑法
//right找小,left找大,每次找到后即刻将key填入
int PartSort2(int* a, int left, int right)
{
int keyi = GetMidi(a, left, right);
swap(&a[left], &a[keyi]);
int hole = left;
int key = a[left];
while (left < right)
{
while (left < right && a[right] >= key)
{
right--;
}
a[hole] = a[right];
hole = right;
while (left < right && a[left] <= key)
{
left++;
}
a[hole] = a[left];
hole = left;
}
a[hole] = key;
return hole;
}
[3]前后指针法
前后指针的方法改变了以往右指针向左左指针向右的遍历方式,而是同时从头开始向后遍历。靠前的指针cur标识着正在判断和基准值关系的元素,而prev则是指向左侧边界区间。可以将prev当做数组到cur为止基准值key位置的标识,而区间(prev,cur)则是所有应该放在基准值右侧的元素。
通过cur的判断从而改变prev的大小,当cur所示位置值大于等于基准值key时,说明key当前位置(prev)满足左小右大的原则,因此prev无需变动,cur自增指向下一个待判断。另一种理解方式,当a[cur]>=key证明cur当前位置的值能够存在于区间(prev,cur)中,于是只需要cur自增即可。
当cur所示位置小于基准值key时,说明cur位置的值应该位于key左侧,prev指向的是key所在位置,因为要将cur位置的值置于key左侧,所以要首先腾个位置出来,于是prev自增向右移动一位,因为右移了一位所以prev指向的值一定是右侧只,于是可以放心将prev和cur位置的值进行交换,完成后cur自增即可。另一种理解方式则是cur当前位置的值不应该在区间(prev,cur)中,于是需要prev自增后和prev换位,相当于区间边界后移为其腾空间,cur自增调整。
完成所有元素调整后,将prev与key值(a[begin])交换即可完成单趟排序。
//3.前后指针法
//利用cur指针遍历整个数组,cur指向正在辨别的元素,prev指向左侧区间边界(也是key应该处在的位置),两个指针之间(prev,cur)是右侧分治的元素
//所以当cur遇到小于key的数据,需要将其放在prev的下一个位置,再挪动cur;遇到大于key的数据,则不必移动prev,只需要挪动cur即可
int Sort3_Pointer(int* a, int begin, int end)
{
int midi = GetMidi(a, begin, end);
swap(&a[midi], &a[begin]);
int key = a[begin];
int prev = begin, cur = begin + 1;
while (cur <= end)
{
//if (a[cur] < key)
//{
// prev++;
// swap(&a[prev], &a[cur]);
//}
if (a[cur] < key && ++prev != cur)
{
swap(&a[prev], &a[cur]);
}
cur++;
}
swap(&a[prev], &a[begin]);
return prev;
}
(4)快速排序非递归方案
递归虽然用的爽,但是耐不住它具有致命弱点,即有可能会栈溢出。因为递归是在栈中不断开辟空间,而栈空间是有限的,所以也就注定递归排序没有办法处理一些很大规模的数据,于是我们需要创造快排的非递归版本。
递归改非递归一般会用两种方法:循环和栈。此处我们处理快排这种前序遍历递归改非递归,一般会使用栈的方法。
递归也是通过调用函数,开辟栈帧实现同一段代码的不同参数实现,于是我们完全可以将所需要的参数放在我们自己开的栈数据结构中,循环同样的代码来实现递归的效果。因为快排处理需要指明边界的参数,所以我们约定存数据先右后左,那么取数据就刚刚相反。这样利用栈的数据结构进行同一段代码不同参数,即可成功取代递归方案。
void QuickSort1(int* a, int left, int right)
{
if (left >= right)
{
return;
}
int mid = PartSort1(a, left, right);
//[left,mid-1] mid [mid+1,right]
QuickSort1(a, left, mid - 1);
QuickSort1(a, mid + 1, right);
}
void QuickSort2(int* a, int left, int right)
{
if (left >= right)
{
return;
}
int mid = PartSort2(a, left, right);
//[left,mid-1] mid [mid+1,right]
QuickSort2(a, left, mid - 1);
QuickSort2(a, mid + 1, right);
}
void QuickSort3(int* a, int left, int right)
{
if (left >= right)
{
return;
}
int mid = PartSort3(a, left, right);
//[left,mid-1] mid [mid+1,right]
QuickSort3(a, left, mid - 1);
QuickSort3(a, mid + 1, right);
}
//快速排序非递归版
void QuickSortNonR(int* a, int begin, int end)
{
ST s;
STInit(&s);
//将待处理区间左右端点存入栈中,以两个为一组。存:先右后左;取:先左后右
STPush(&s, end);
STPush(&s, begin);
while (!STEmpty(&s))
{
int begin = STTop(&s);
STPop(&s);
int end = STTop(&s);
STPop(&s);
//int keyi = PartSort1(a, begin, end);
//int keyi = PartSort2(a, begin, end);
//int keyi = PartSort3(a, begin, end);
if (keyi - 1 > begin)
{
STPush(&s, keyi - 1);
STPush(&s, begin);
}
if (keyi + 1 < end)
{
STPush(&s, end);
STPush(&s, keyi + 1);
}
}
STDestroy(&s);
}
3.复杂度与稳定性分析
(1)时间复杂度
快排我们在三数取中部分已经分析过了其时间复杂度,完美的二分结构是最佳序列,时间复杂度为。顺序逆序是最坏序列,时间复杂度退化为。
所以一般认为快排的时间复杂度为。
(2)空间复杂度
快排涉及到递归,所以要看其递归深度。同理,完美的二分结构是最佳序列,空间复杂度为。顺序逆序是最坏序列,空间复杂度为。
一般认为快排的空间复杂度为。
(3)稳定性
快速排序是不稳定的。
快速排序中数据存在大量相互交换,无论怎样限制其相对位置总是不可控的。