目录
前言:
今天也算是我🐏了之后快要康复的第一天吧,真是一天也不敢歇息写代码,不对,是我爱学习,我要进步。
今天我给大家带来的是对快速排序的各种写法、讲解还有优化,其中包括了霍尔法、挖坑法、双指针法、非递归实现快速排序、还有防止占空间开太多的优化,取Key值不合理的情况,还有有大量重复数据出现的极端情况处理,内容有点多,请各位看官耐心看下去。
一、霍尔法
霍尔法本身并没有什么含义,只是有一个叫做霍尔的大佬发明了快速排序这种方法,所以以他命名。
霍尔法的思路:
霍尔排序首先需要注意的就是三个东西,左区间、右区间、Key值,只要我们控制好了这两个之间的对应关系,和转换关系,就能很轻易地理解快速排序。
可能大伙有点疑惑,那么请先看下图。
首先看到它的初始状态,我们默认将Key值定为最左边的哪一个值,然后有两个变量指向这一串数字的最左边和最右边,表示整个区间大小。
这里我需要先提一下,如果将Key定义在右边,那么移动箭头的顺序就需要与我相反。
因为Key值定义在左边,所以先移动右边的箭头,找到了比Key小的值停下。
右边停下之后就开始移动左边的箭头,当找到了一个大于Key的值停下,此时还是有效区间,就可以交换两个箭头对应的值。
然后再重复之前的移动箭头操作,先移动右边的箭头找到比Key小的停下,再移动左边的箭头找到比Key大的停下,然后交换。最终结果如下:
最后在这一组数据当中右箭头会撞上左箭头,其他数据可能是左箭头撞上右箭头,不过这不是重点,重点是我们已经将一组数据比较完成了,只差最后一步,那就是将相遇位置值与Key位置的值交换得:
以上,就是快速排序霍尔法单趟排序得完整思想,你们再看上面的那一组数据发现了什么?是不是比Key大的都在右边,比Key小的都在左边?而且最重要的是,Key也就是4到了正确的位置,接下来我们就可以划分区间,递归重复实现该操作了。
整体来说霍尔法的完整排序就是单趟排序加上递归操作罢了,只要控制区间在不断缩小,且出现不存在区间时就不再继续递归就能控制好整段程序。至于为什么能行,还记得我们单趟排序实现了什么吗,那就是将Key回到了正确的位置,然后左边比Key小,右边比Key大,分别递归,不会影响现有的逻辑关系。
霍尔法代码:
void QuickSort(int* arr, int begin, int end)
{
//当出现无效区间,或者只有一个值的情况,表示已经排序完成
if (begin >= end)
{
return;
}
//定义左边下标和右边下标
int Left = begin;
int Right = end;
//定义key值为左边第一个数据
int key = arr[begin];
//在左下标小于右下标时,继续比较
while (Left < Right)
{
//再次比较两个下标防止越界风险,因为有一直找不到的风险
//当右下标大于等于key值,会一直往后走,直到找到,才会停下来
while (Left < Right && arr[Right] >= key)
{
Right--;
}
//左下标于key值比较,如果小于等于key,一直走,直到找到,停下来
while (Left < Right && arr[Left] <= key)
{
Left++;
}
//当两个都停下来的时候,交换两个下标对应的值,让其继续走下去
//有可能停下的条件是两个下标相等了,但是由于是同一个值,所以交换也没事
Swap(&arr[Left], &arr[Right]);
}
//最后相交点与key交换,区分两个大小区间,并使key到达正确位置
Swap(&arr[begin], &arr[Left]);
//让后将上述操作细分为子问题,把左右两个无序区间再次按照该操作执行
QuickSort(arr, begin, Left - 1);
QuickSort(arr, Left + 1, end);
}
二、挖坑法
挖坑法的思想其实与我们的霍尔法差不多,甚至就我而言,我认为挖坑法就是霍尔法的易理解版本。
挖坑法的思路:
挖坑法和霍尔法相同,也有左区间、右区间、Key值,不过它多了一个东西,那就是坑,请看下图:
我们把初始位置的值保留给了Key值,那么原来那个位置我们就可以当作没有数据了,把他看作为一个坑。
此时也是先移动右箭头,找到了比Key小的值,把该值放入坑当中,这个时候右箭头所指的位置就是一个新的坑,然后再移动左箭头,找到了比Key大的值,又将该值放入新坑当中,然后又会产生一个坑在左箭头下面,如此反复,不断地挖坑,填坑,其实细细品味就会觉得它就是将霍尔法的交换分为两步走完。
最后在相遇的时候也会剩下一个坑,这个坑就是留给我们的Key值的。然后就完成了我们的单趟排序,它的完成排序和霍尔排序一样,完全没变,就是在单趟排序完成之后递归两个区间。
挖坑法代码:
void PitQuickSort(int* arr, int begin, int end)
{
//划分到最小区间
if (begin >= end)
{
return;
}
//指向前方,后方,key值
int front = begin;
int rear = end;
int mid = Get_Medium_Num(arr, front, rear);
Swap(&arr[front], &arr[mid]);
int key = arr[begin];
//还没相遇,一直走
while (front < rear)
{
//大于key,往前走
while (front<rear && arr[rear]>=key)
{
rear--;
}
//与坑交换
arr[front] = arr[rear];
//小于key,向后走
while (front < rear && arr[front] <= key)
{
front++;
}
//与坑交换
arr[rear] = arr[front];
}
//最后将key值放入坑中,此时坑就是它的正确位置
arr[front] = key;
//进入下一次递归啦
PitQuickSort(arr, begin, front - 1);
PitQuickSort(arr, front + 1, end);
}
三、双指针法
双指针发不同于前面两种方式,它是通过将大的往后面推,将小的往前丢的方式进行排序,当有一个指针指向结束,该次单趟结束。
双指针法思路:
首先,需要定义两个指针变量,指向第一个数据和第二个数据,不用担心有没有可能不存在两个数据,然后定义最左边为Key值。
当前箭头大于Key值不做变动,直接指向下一个值。
当前箭头找到了比Key小的值,后箭头先往前移动一位,然后与前箭头交换得:
交换完成之后,前箭头因为逻辑会自己往前走一步,然后与下一次循环对应得:
交换两个箭头的值。
交换之后前箭头会自己向前移动,然后进入下一次循环,找到了比Key大的,不移动,前箭头再往前走一步,越界退出。
然后再把后箭头的值与Key值位置交换得:
此时,单趟排序结束,左右区间划分完毕,Key值回到正确位置,可以进行递归了。
双指针法代码:
//双指针法
void PointQuickSort(int* arr, int begin, int end)
{
if (begin >= end)
{
return;
}
int front = begin+1;
int rear = begin;
int key = begin;
while (front <= end)
{
//同一句话执行也有先后逻辑之分,如果前面已经错误,那么后面的就不会执行
//在&&的条件下
if (arr[front] < arr[key] && ++rear != front)
{
Swap(&arr[front], &arr[rear]);
}
front++;
}
Swap(&arr[rear], &arr[key]);
PointQuickSort(arr, begin, rear - 1);
PointQuickSort(arr, rear + 1, end);
}
四、非递归
非递归快排思想:
我们的非递归实现快排需要用到另外一种数据结构栈,用来代替我们的递归过程。
注意,我们这里压入栈的数据是区间,也就是每一个区间需要压入两个边界,取值时也需要注意,不能取反了。具体思想就是,每一个旧区间被出栈,就有两个新区间被压入,当区间不存在,就跳过本次循环。
同时需要注意压栈的顺序为后区间先压,前区间后压,虽然更换顺序不会影响实际的排序,但是不过不更换,就逻辑上而言它变得不再合理。
非递归代码:
非递归需要添加栈的源代码,有需求去找我的博客。
//非递归实现快排
void NoneRecursionQuickSort(int* arr, int begin, int end)
{
Stack ps;
StackInit(&ps);
StackPush(&ps, begin);
StackPush(&ps, end);
while (!StackEmpty(&ps))
{
int right = StackTop(&ps);
StackPop(&ps);
int left = StackTop(&ps);
StackPop(&ps);
int key = left;
if (left >= right)
{
continue;
}
int front = left + 1;
int rear = left;
while (front <= end)
{
if (arr[front] < arr[key] && ++rear != front)
{
Swap(&arr[front], &arr[rear]);
}
front++;
}
Swap(&arr[rear], &arr[key]);
key = rear;
StackPush(&ps, key + 1);
StackPush(&ps, end);
StackPush(&ps, left);
StackPush(&ps, key - 1);
}
}
优化:
三数取中:
我们可以看到,每一次我们的Key值都被默认选择为了最左边的哪一个,但是这么做就会出现一个问题,那就是,这个数比所有数都小,或都大怎么办,这样划分的区间会让我们的快排重新回到O(n^2)的效率的,所以这个时候就需要对数据进行Key值进行整改,我们将最左边,最右边,和中间三个值进行比较,谁是中间值就与我们的最左边的值更改,此时的最左边还是Key,但是Key值已经变得合理。
代码:
在选Key之前,调用此函数。
int Get_Medium_Num(int* arr, int left, int right)
{
int mid = left+rand()%(right-left);
if (arr[left] > arr[right])
{
if (arr[left] < arr[mid])
{
return left;
}
else
{
if (arr[right]>arr[mid])
{
return right;
}
else
{
return mid;
}
}
}
else
{
if (arr[right] < arr[mid])
{
return right;
}
else
{
if (arr[mid]>arr[left])
{
return mid;
}
else
{
return left;
}
}
}
}
小区间优化:
小区间优化是为了防止整个排序过程当中,栈空间开的太多从而导致程序崩溃。具体优化过程就是递归结束条件需要更改,我们知道,在最后一层两层其实没有必要再递归下去了,所以结束条件就是区间小于5或者其它数都行,合理即可。然后再用我们的其它排序手段为其完成最后的排序。代码我就不列写了,灵活性很高。
三区间划分法:
三区间划分法,用于防止一串数据当中,全是相同数据,或者大量的重复数据。它的事项其实和我们的双指针法有一点相识,但是这一次,我们将单次比较完成之后的数据划分成为了三个区间,小于Key,等于Key,大于Key。
单趟实现方式就是有三个指针,分别指向头,尾,第二个数据,我们定义为front,rear,cur,当cur比rear小的时候,整个操作继续,当cur对应的值比Key小交换cur和front的值,front++,cur++,当比Key大时,交换rear和cur的值,rear--,cur之所以不++了是因为与rear交换之后不能确定此时cur所占位置的值的大小,当cur等于Key时,cur++。整个操作就实现了将大的往右边丢,小的往左边丢,等于的排到中间。
代码:
//三区间划分法
void Quick_Sort_Three(int* arr, int begin, int end)
{
if (begin >= end)
{
return;
}
int mid = Get_Medium_Num(arr, begin, end);
Swap(&arr[begin], &arr[mid]);
int key = arr[begin];
int front = begin;
int rear = end;
int cur = front + 1;
while (cur <= rear)
{
if (cur <= rear && arr[cur] < key)
{
Swap(&arr[cur], &arr[front]);
front++;
cur++;
}
if (cur <= rear && arr[cur] > key)
{
Swap(&arr[cur], &arr[rear]);
rear--;
}
if (cur <= rear && arr[cur] == key)
{
cur++;
}
}
Quick_Sort_Three(arr, begin, front - 1);
Quick_Sort_Three(arr, rear+1, end);
}
以上就是我对快排的全部理解,能帮到你最好,谢谢。