持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第10天,点击查看活动详情
前言
快速排序是常见排序的一种,属于交换排序,本文就来简单分享一波笔者的学习经验与心得,这是下篇,继续介绍快排的三种版本以及递归与非递归实现。
笔者水平有限,难免存在纰漏,欢迎指正交流。
hoare版本
不足之处
书接上回,我们发现,当前快排代码一旦遇到有序或接近有序就完蛋。
因为我们目前选key都是选最左边或最右边的值,接近最小或最大,这样一来,在区间划分和递归时就会出现“一边倒”情况(因为key左边找不到比key大的,右边找不到比key小的),导致递归层数过深容易栈溢出,时间复杂度就变为了O(n2),效率一下子就低了。
选key思路的优化(三数取中)
那我们可不可以针对这种有序或接近有序的情况来优化一下选key的逻辑呢?
三数取中,也就是从第一个位置、中间位置和最后一个位置的值中选出中间大小的值,把这个中间值换到最前面再继续排序。这样的话怎样都不会选到数组的最大值或最小值,有效规避了有序或接近有序可能带来的不良结果。
写个函数来比较三个数,返回的就是中间值的下标,再把中间值和第一个值交换。
int GetMidIndex(int* arr, int left, int right)
{
int mid = (right - left) / 2 + left;
if(arr[left] > arr[mid])
{
if(arr[mid] > arr[right])
return mid;
else if(arr[right] < arr[left])
return right;
else
return left;
}
else
{
if(arr[left] > arr[right])
return left;
else if(arr[mid] > arr[right])
return right;
else
return mid;
}
}
int PartSort_1(int* arr, int left, int right)
{
assert(arr);
int mid = GetMidIndex(arr, left, right);
Swap(&arr[left], &arr[mid]);
int keyi = left;
while(left < right)
{
while(left < right && arr[right] >= arr[keyi])
--right;
while(left < right && arr[left] <= arr[keyi])
++left;
if(left < right)//不是因为相遇而停下来才交换,若是相遇就要出循环
Swap(&arr[left], &arr[right]);
}
int meeti = left;
Swap(&arr[meeti], &arr[keyi]);
return meeti;//返回相遇位置的下标是为了后续分割子区间
}
还可以使用随机数取key来进行优化,只不过随机数选key还是不能确保选到的值不是最大或最小的,而且随机数生成器还会带来时间上的开销,所以不那么推荐。
小区间优化
当递归到小的子区间时,递归二叉树的结点比较多,可以考虑对小区间使用直接插入排序减少递归量。
由二叉树的性质可知,倒数一二三层加起来的结点数几乎占了总结点数的87.5%,每个结点就是一次递归,当数据量很大的时候,这三层的递归量是很大的,而这三层的区间基本上都是小区间,就几个数比起递归还不如直接排序成本更低。我们这里取8为小区间元素个数基准,只要是小区间就放弃递归而采用直接插入排序。
void InsertSort(int* arr, int sz)
{
assert(arr);
for(int i = 0; i < sz - 1; ++i)
{
int end = i;
int tmp = arr[end + 1];
while(end >= 0)
{
if(arr[end] > tmp)
{
arr[end + 1] = arr[end];
--end;
}
else
break;
}
arr[end + 1] = tmp;
}
}
void QuickSort(int* arr, int left, int right)
{
assert(arr);
if(left >= right)
return;
if(right - left + 1 <= 8)
{
InsertSort(arr + left, right - left + 1);
}
else
{
int meeti = PartSort_1(arr, left, right);
QuickSort(arr, left, meeti - 1);
QuickSort(arr, meeti + 1, right);
}
}
提醒
有一说一,这个版本要注意的细节比较多,不熟练者容易写bug,所以不太推荐使用。
挖坑法
这是hoare的改编版,大体的逻辑框架没有什么区别,主要是一些细节实现的区别,可能让人更好理解接受。
在单趟排序的思路上,虽然仍是最左边的值作为key,L找较大值,R找较小值,但是并不是要交换L和R找到的两个值,而是“填坑”。一开始就把最左边的值暂存到key,此时就形成一个“坑位”,然后让R先走,找较小值,找到的话就把这个值“填坑”,这时候也把较小值所在位置“挖了一个坑”对吧,这就是新的“坑”,挖了就要填,要填得先挖,然后L走也是同理。直到L和R相遇,此时相遇位置也是“坑位”,再把key的值“填坑”即完成本趟排序。
int PartSort_2(int* arr, int left, int right)
{
assert(arr);
int mid = GetMidIndex(arr, left, right);
Swap(&arr[left], &arr[mid]);
int key = arr[left];
int hole = left;
while (left < right)
{
while (left < right && arr[right] >= key)
--right;
if (left < right)
{
arr[hole] = arr[right];
hole = right;
}
while (left < right && arr[left] <= key)
++left;
if (left < right)
{
arr[hole] = arr[left];
hole = left;
}
}
arr[hole] = key;
return hole;
}
其他内容没有改变,只是单趟排序的思路有些许变动。
void QuickSort(int* arr, int left, int right)
{
assert(arr);
if(left >= right)
return;
if(right - left <= 8)
{
InsertSort(arr + left, right - left + 1);
}
else
{
int meeti = PartSort_2(arr, left, right);
QuickSort(arr, left, meeti - 1);
QuickSort(arr, meeti + 1, right);
}
}
前后指针法
这也是hoare的改编版,大体的逻辑框架没有什么区别,主要是一些细节实现的区别。
在单趟排序的思路上,设定两个指针prev和cur,从数组左边向右走,cur先走,cur遇到较小值就停下来,prev走一步,此时交换prev和cur位置的值,然后cur继续走,直到cur走出数组界限,最后把keyi位置的值和prev位置的值交换。
int PartSort_3(int* arr, int left, int right)
{
assert(arr);
int mid = GetMidIndex(arr, left, right);
Swap(&arr[left], &arr[mid]);
int keyi = left;
int prev = left;
int cur = left + 1;
while (cur <= right)
{
if (arr[cur] < arr[keyi])
{
++prev;
Swap(&arr[prev], &arr[cur]);
}
++cur;
}
Swap(&arr[prev], &arr[keyi]);
return prev;
}
还可以有一点小小优化:当prev和cur在同一位置时交换就没有意义了,可以多判断一下,省去这些交换。
int PartSort_3(int* arr, int left, int right)
{
assert(arr);
int mid = GetMidIndex(arr, left, right);
Swap(&arr[left], &arr[mid]);
int keyi = left;
int prev = left;
int cur = left + 1;
while(cur <= right)
{
if(arr[cur] < arr[keyi] && ++prev != cur)
Swap(&arr[prev], &arr[cur]);
++cur;
}
Swap(&arr[keyi], &arr[prev]);
return prev;
}
其他内容没有改变,只是单趟排序的思路有些许变动。
void QuickSort(int* arr, int left, int right)
{
assert(arr);
if(left >= right)
return;
if(right - left <= 8)
{
InsertSort(arr + left, right - left + 1);
}
else
{
int meeti = PartSort_3(arr, left, right);
QuickSort(arr, left, meeti - 1);
QuickSort(arr, meeti + 1, right);
}
}
非递归实现
使用递归实现在数据量大的时候可能会因为递归太深而栈溢出,要克服这个缺陷可以考虑非递归实现,而想要用非递归来实现,需要对递归实现有着较为深入的理解。
实际上就是要用循环模拟递归,我们这里采用一个栈来存取区间的左右边界,原来的单趟排序PartSort根据传入的左右边界来排序并返回左右子区间的分割点,可以再根据分割点来得到左右子区间的左右边界。
入栈按先左后右顺序,出栈就按先右后左顺序。一开始就把原数组左右边界入栈,进入循环后只要栈不为空就继续。实质上就是利用栈的先进后出,先放右区间再放左区间,取的时候就先取出左区间,左区间PartSort一下后又把它的右区间先入栈、左区间后入栈,等下一次取的时候还是左区间,以此类推,是不是就模拟了递归时先左后右呢?
一定注意不要漏了if(left >= right)continue;,区间为空或仅有一个元素时就不需要再PartSort了。
void QuickSortNonR(int* arr, int begin, int end)
{
assert(arr);
ST st;
StackInit(&st);
int left = begin;
StackPush(&st, left);
int right = end;
StackPush(&st, right);
while(!StackEmpty(&st))
{
right = StackTop(&st);
StackPop(&st);
left = StackTop(&st);
StackPop(&st);
if (left >= right)
continue;
int keyi = PartSort_1(arr, left, right);
//先放右区间
StackPush(&st, keyi + 1);
StackPush(&st, right);
//再放左区间
StackPush(&st, left);
StackPush(&st, keyi - 1);
}
StackDestroy(&st);
}
以上就是本文全部内容,感谢观看,你的支持就是对我最大的鼓励~