文章目录
📜前言
🚀 快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,运用到了分治思想。
🚀 其基本的过程可以总结为:任取待排元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两个子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后就是左右子列重复该过程,直到所有元素都排列在相应位置上为止。
🚀本章内容将会介绍快速排序的四个版本,即 Hoare版本、挖坑法、前后指针法、非递归方法,以及对快速排序的优化方法:三数取中,小区间优化。
📜Hoare版本
📅思路分析
Hoare版本的过程可以简单分成如下几个步骤:
🔔将首位数设置为key
值,把left
设置为首位数的下标,把right
设置为最后一位数的下标。
🔔然后,left
寻找比key
值小的后停下,right
寻找比key
值大的后停下(老铁们注意,这里需要让right
先走,至于原因我们后面会解释。)
🔔交换这里的light
位置的数和right
位置的数
🔔继续移动right
和left
🔔直到left
和right
相遇,停下,将这个位置的值与key
交换。
如此,便实现了key
前的值都比key
小,后面的值都比key
大,并且key
到了排好序要放的位置这个目的。
📅代码实现
void Swap(int* x, int* y)
{
int tmp = *x;
*x = *y;
*y = tmp;
}
//快速排序(hoare版本)
//注意是二叉树的分治思想
int PartSort1(int* a, int left,int right)
{
int keyi = left;
while (left < right)
{
//R找比key小的
while (a[right] >= a[keyi] && left<right)
{
right--;
}
//L找比key大的
while (a[left] <= a[keyi] && left<right)
{
left++;
}
Swap(&a[left], &a[right]);
}
//相遇了,要交换key和相遇点
Swap(&a[keyi], &a[left]);
return left;
}
📅注意
1.为什么我们还需要再用一个left<right
来约束中间的循环呢?
这里就是对极端情况的考虑:如果整个数组的值均大于key
,就会导致right
一直向前移动,直到指向了原数组的前面,相当于“野指针”。为了避免这样的情况,便加上了这个条件进行约束。下一个循环同理。
2.中间循环的约束条件,我们可不可以用a[right]<a[keyi]
a[left]>a[keyi]
?
这里是不可以的。这里如果不加上等号,就会导致死循环的产生。我们来考虑一个极端情况,如果一个数组中的数组全部相同,那么left和right将不会移动,使程序陷入死循环。
3.在交换的时候,我们能不能将key值存到临时变量当中,然后在交换时,使用Swap(&key,&a[left])
?
这里也是不可以的,key此时存放在临时变量当中,而我们想实现的是数组的首个位置和left进行交换,所以此时交换key的临时变量,是无法实现数组首个位置与left进行交换这个条件的。
📅Hoare版本中常见的问题
相遇位置的值为什么可以直接和a[keyi]交换,如果相遇位置的值比key大呢?
这里先给出结论:是由于right先走这个重点步骤,确保了相遇位置的值一定小于key。
接下来我们具体讲解一下:
首先,我们要明白,left
和right
并不是同时移动的,而是一个停下后,另一个再开始走,所以left
和right
相遇,其实无非就是两种情况:
- 第一种情况是
right
不动,left
移动走到right
的位置,两者相遇; - 第二种就是
left
不动,right
移动走到left
的位置,两者相遇。
我们接着分析:
如果是第一种情况,他们相遇的位置就是原本right
的位置,而由于right
先走,遇到比key
小的停下了,所以此处的值肯定是小于key
的;
如果是第二种情况,他们相遇的位置就是原本left
的位置,这时要注意,left
开始走了,说明right
已经开始走了一次了,所以这个位置的值已经在上一次发生了交换了,所以此时left
位置的数依然是比key
小的.
📜挖坑法
📅思路分析
大家看完上面的讲解之后,是不是觉得Hoare
版本的快速排序有太多的坑了?这时,一些大佬们想着改进快速排序的实现方法,便出现了:挖坑法。
大家认真观察上图,有没有明白挖坑法的具体过程?
🔔其实就是现将首位置的值放到临时变量key
当中,此时,就形成了一个坑位;
🔔我们还是仿照Hoare的版本,设置right
和left
,right
先走,遇到比key
小的便停下,将该值移放到坑中,这时right
变成了新的坑;
🔔接着移动left
,遇到比key
大的便停下,将该值移放到坑中,这时left
变成了新的坑;
🔔直到left
和right
相遇后,就停止循环,再将原本存放在临时变量中的key
放进坑位当中。
如此一来,我们便不用像Hoare
版本一样考虑很多种情况,便可以直接实现快速排序。
📅代码实现
//挖坑法
int PartSort2(int* a, int left, int right)
{
int hole = left;
int keyi = a[left];
while (left < right)
{
//R找比key小的
while (a[right] > keyi && left < right)
{
right--;
}
a[hole] = a[right];
hole = right;
//L找比key大的
while (a[left] < keyi && left < right)
{
left++;
}
a[hole] = a[left];
hole = left;
}
a[hole] = keyi;
return hole;
}
📜前后指针
接下来,我们再来讲解一个实现快速排序的方式——前后指针法。前后指针法其实和双指针法有着相似的地方,比起Hore
版本的实现更加方便理解。
下面我们来总结一下前后指针法的步骤:
🔔开始:prev
指针设立在首位,cur
指针设立在第二位;
🔔接着cur
指针移动,寻找比key
小的值;
🔔当cur
遇到比key
小的值之后停下,prev+1
之后的位置和cur
进行交换;
🔔接着cur
找比key
小的值,直到cur
数组越界,循环停下
🔔最后,将prev
的位置的值和key
进行交换。
📅思路分析
这里prev
和cur
的关系无非是以下两种情况:
- 第一种情况:在
cur
还没有遇到比key
还要大的值之前,prev
是紧跟着cur
的,而此时的交换则是原地交换,不改变数组的排序; - 第二种情况,
cur
遇到了比key
大的数据,这时cur
自己向前走,寻找比key
小的数据,prev
不动,而prev+1
的数据就是比key
大的数据,所以当cur
遇到比key
小的数据的时候,交换cur
和prev+1
即可实现大的在后,小的在前。
📅代码实现
//前后指针版本
int PartSort3(int* a, int left, int right)
{
int keyi = left;
int prev = left;
int cur = prev+1;
while (cur <=right)
{
if (a[cur] < a[keyi])
{
//这里注意是++prev,如果是prev++,那么返回值依然是原来的prev
Swap(&a[++prev], &a[cur]);
}
cur++;
}
Swap(&a[keyi], &a[prev]);
return prev;
}
📜递归形式
思路分析
刚才,我们已经讲解了每一次单趟排序的过程以及代码的实现,接下来我们通过一个简单的递归图来简单粗暴地理解一下快速排序的算法思想:
所以,用递归的方式实现快速排序,其结束条件就是只剩下一个数据时。
📅代码实现
void QuickSort(int* a, int begin, int end)
{
//这里要注意我们要考虑的是两种情况,第一种情况是区间不存在。第二种情况是区间中只有一个数字
if (begin >= end)
{
return;
}
int keyi = PartSort3(a,begin, end);
//[begin,keyi-1] keyi [keyi+1,end]
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
📜快速排序的优化方法
📅快速排序的时间复杂度分析
首先,我们先要声明一下,由于每一次单趟排序都是遍历一遍数组,所以其时间复杂度均为N。
最好的情况:O(logN*N)
分析,我们来考虑一下什么时候快速排序的效率最高呢?肯定是当key的值取中位数或者近似中位数的时候,这个时候,快速排序相当于一个二叉树,其高度为 logN,总的时间复杂度则为O(logN*N)
最差的情况:O(N^2)
那么如果是在有序数组中,每次选择数组的首个位置作为key,由于数组是有序的,所以就会出现下图的情况,那么此时的时间复杂度就为O(N^2)。
📅三数取中法
经过刚才的分析,我们得出了一个结论:有序数组会导致快速排序的时间复杂度较大。那么为了避免这一情况,我们应该怎么办呢?
直接来讲,就是需要一个合适的key,最好是中位数,这样key
值就不会过小或者过大了。但是又由于如果把key
值设计在数组的中间,代码的实现又有些困难,我们便考虑到了三数取中法。
这个方法通俗来讲就是将数组的首值和末位的值以及中间的值进行比较,取出次大值(次小值)作为key
值,并将该值交换到数组的首位上。
下面将代码附上~
int GetMidi(int* a, int left, int right)
{
int mid = (left + right) / 2;
// left mid right
if (a[left] < a[mid])
{
if (a[mid] < a[right])
{
return mid;
}
else if (a[left] > a[right]) // mid是最大值
{
return left;
}
else
{
return right;
}
}
else // a[left] > a[mid]
{
if (a[mid] > a[right])
{
return mid;
}
else if (a[left] < a[right]) // mid是最小
{
return left;
}
else
{
return right;
}
}
}
📅小区间非递归法
我们在学习二叉树的时候,一定对二叉树最后几层节点的巨大数据量有了一定的认识,所以,对于比较小的区间,我们没有必要使用递归形式进行排序,小区间非递归法就是在递归到小的子区间时(一般是区间长度低于10时),可以考虑使用插入排序的优化方式。
📜非递归
📅思路分析
我们上面讲解了快速排序的递归方法,但是递归会存在深度太深的风险,所以非递归的形式我们也需要掌握。
要实现快速排序的非递归,我们需要利用栈:“先进后出”的特性进行程序的设计。下面我们用图来直观理解一下。
💡这里我们简要提一下,将递归形式转化成非递归形式,一般来说有两种解法,一种是利用for循环
,比如:斐波那契数列的实现;而另一种方法,就是用栈的“先进后出”的思想。
📅代码实现
//用栈实现快速排序的非递归方法
void QuickSortNoR(int* a, int begin, int end)
{
struct stack s1;
STInit(&s1);
//接着再将右区间先放进去,再将左区间放进去
STPush(&s1, end);
STPush(&s1, begin);
while (!STEmpty(&s1))
{
int left = STTop(&s1);
STPop(&s1);
int right = STTop(&s1);
STPop(&s1);
int keyi = PartSort3(a, left, right);
//[left,keyi-1] keyi [keyi+1,right]
//当区间只剩下一个数据或者区间不存在的时候,不再放进栈中
if (keyi + 1 < right)
{
STPush(&s1, right);
STPush(&s1, keyi + 1);
}
if (left < keyi - 1)
{
STPush(&s1, keyi - 1);
STPush(&s1, left);
}
}
STDestroy(&s1);
}
今天的内容就分享到这里,欢迎各位老铁们订阅专栏🎓《数据结构初阶》 ,后续会持续更新!
🎁今天的内容就分享到这里,博主还是新手小白,希望大佬们可以多多批评指正,创作不易,希望得到您的支持和喜爱!感谢您的陪伴,草莓base将会努力将更多优质内容分享给大家!欢迎各位老铁在评论区讨论!