[C描述算法入门]快速排序(下篇)

116 阅读2分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第10天,点击查看活动详情

前言

        快速排序是常见排序的一种,属于交换排序,本文就来简单分享一波笔者的学习经验与心得,这是下篇,继续介绍快排的三种版本以及递归与非递归实现。

        笔者水平有限,难免存在纰漏,欢迎指正交流。

hoare版本

不足之处

        书接上回,我们发现,当前快排代码一旦遇到有序或接近有序就完蛋。

        因为我们目前选key都是选最左边或最右边的值,接近最小或最大,这样一来,在区间划分和递归时就会出现“一边倒”情况(因为key左边找不到比key大的,右边找不到比key小的),导致递归层数过深容易栈溢出,时间复杂度就变为了O(n2),效率一下子就低了。

image-20220907211327444

选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);
 }

以上就是本文全部内容,感谢观看,你的支持就是对我最大的鼓励~

src=http___c-ssl.duitang.com_uploads_item_201708_07_20170807082850_kGsQF.thumb.400_0.gif&refer=http___c-ssl.duitang.gif