前言
快速排序是排序中比较重要的一个,也是内容比较多的一个,所以就单独拿出来讲讲。
一、什么是快速排序?
首先,既然敢叫快速排序,那这个排序确实是有点说法的,C++的sort底层就是快速排序。
快速排序首先是交换排序,是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
核心代码逻辑(升序):
cpp
void _QuickSort(vector<int>&v,int left,int right)
{
if(left >= right) return ;
// 按照基准值对array数组的 [left, right)区间中的元素进行划分
//这里partion返回的就是基准值的下标,左边比他小,右边比他大
int div = partion1(v,left,right);
// 划分成功后以div为边界形成了左右两部分 [left, div) 和 [div+1, right)
// 递归排[left, div)
_QuickSort(v,left,div);
// 递归排[div+1, right)
_QuickSort(v,div + 1,right);
}
void QuickSort(vector<int>&v)
{
_QuickSort(v,0,v.size() - 1);
}
这个逻辑有点像二叉树的前序遍历,是比较好理解的,逐渐的有序起来。核心的实现还是在对于partion的实现上。
ps: 我这里用的是vector,当然用原生数组也一样,封装一层函数的原因是这样外面调用只需要传一个v就可以,如果不是很懂的,在实现下面我写的partion之后,可以每次排完打印一下数组,这样可以更详细的观察到它的排序实现.
二、partion(分区)实现的三种方式
ps :默认升序
我们一定要明白partion函数是干什么的!int partion(vector& v,int left,int right) ,这个函数的意义是对v中[left,right]区间进行以key值的分割,使得[left,right]的区间中key值下标的左边的值比key小,右边的值比key大! ! !返回值就是key的下标。
对于选取key,这里先用v[left]作为key,下面的讲解也默认是用v[left]作为key,之后还会细讲。
比如vector v = {4,7,8,5,1,2,3,9};在经历第一次分割后就是:1 3 2 4 5 8 7 9 ,4的左边比4小,右边比4大。
所以无论是哪种方式,目的是一样的!!!每种方法都是朝着这个目标努力! ! !
1.Hoare法
Hoare大佬的核心思路是什么呢? 让左边(从left出发 ++)找比key大的,右边(从right出发 --)找比key小的,找到了之后交换,直到相遇为止,最后将相遇点的值和key交换。 这种思路比较清晰也比较好理解,但是代码上有一点坑。
ps : 如果以v[left]为key,让右边先出发。
先解释一下为什么以v[left]为key,右边先出发。
首先要清楚,相遇点的值一定要<=key才能满足条件,因为和v[left]交换,它一定在最左边,那这个元素的值就一定要比key<=才能满足,如果右边先走,能确保相遇位置的元素一定≤key,这是分区正确的关键;如果左边先走,可能导致相遇位置的元素≥key,分区直接失败。
比如:1 2 3 4 5 6 7 8 9, 左边先走找到了2,右边找小找不到就到了2,2和1交换,这就不满足条件了! !!如果右边先走就找到了1,依然满足条件,所以要让对面先走 !!!
代码实现:
知道上面之后就比较好写了,注意别越界。
cpp
//霍尔法
int partion1(vector<int>&v,int left,int right)
{
int l = left,key = v[l];
//左边找大,右边找小,右边先走
while(left < right)
{
while(left < right && v[right] >= key) right--;
while(left < right && v[left] <= key) left++;
swap(v[left],v[right]);
}
swap(v[left],v[l]);
//Print(v);
return left;
}
2.挖坑法
挖坑法的思路是什么呢? ? ?
把key这个位置作为坑位,右边先走找小,找到之后把这个位置的值给key,自己变成新坑位,再让左边走,重复上述,知道left == right为止,最后把key赋值给相遇点的值。
这个挖坑法的整体思路和上面的差不多,只不过就需要一个坑位
这里有几个写代码的注意点:
1.左边作为key,让右边先走
2.相遇之后,需要把key的值赋值给v[hole] 因为最开始key的值没有保留下来
3.比较大小时是和key比较大小,不是和v[hole],因为hole一直在变化,大小不一定为key了。
代码实现
cpp
//挖坑法
//vector<int> v = {4,7,8,5,1,2,3,9};
int partion2(vector<int>&v,int left,int right)
{
int hole = left;
int key = v[left];
while(left < right)
{
while(left < right && v[right] >= key) right--;
v[hole] = v[right],hole = right;
while(left < right && v[left] <= key) left++;
v[hole] = v[left],hole = left;
}
v[hole] = key;
return left;
}
3.前后指针法
这种方法相对不好理解, 核心:维护了两个指针, prev 和 cur指针,cur指针用来遍历整个数组,prev指针的意义是" 它和它的左边不会出现比key大的值",怎么维护呢? ? ? 让prev = left,cur = left + 1,如果cur找到了比key小的值, ++prev然后和prev交换,这样prev前面的值一定不会比key大,直到cur越界为止,最后交换v[l] 和 v[prev]。
ps:1.如果++prev == cur 也没有交换的必要了,因为是一个位置。
2.位于prev和左边的都比key小,当cur走完,保证扫了一遍小的和prev交换,又保证了prev右边的比key大,此时prev的位置就是key的,交换即可
代码实现
cpp
//前后指针法
//vector<int> v = {4,7,8,5,1,2,3,9};
int partion3(vector<int>&v,int left,int right)
{
int prev = left,cur = left + 1,key = v[left],l = left;
while(cur <= right)
{
if(v[cur] < key && ++prev != cur) swap(v[cur],v[prev])
++cur;
}
swap(v[l],v[prev]);
return prev;
}
三、快速排序优化
一直以v[left]为key有什么问题? 比如数组9 1 4 3 5 7 2 8,第一次partion分割后和原来是一样的,所以这里提出选key的其他两种方法。
1.三数取中选key
哪三个数呢? v[left],v[right],v[(left + right) / 2],取大小是中间的那个数, 返回下标,因为要返回下标,这个函数写起来不难,但是很墨迹。
GetMiddle函数:
cpp
int GetMiddle(vector<int> &a, int left, int right)
{
int mid = (left + right) / 2;
if (a[left] < a[mid])
{
if (a[mid] < a[right]) return mid;
else if (a[left] > a[right]) return left;
else return right;
}
else
{
if (a[mid] < a[right]) return mid;
else if (a[right] > a[left]) return left;
else return right;
}
}
怎么融合到上面那三种方法里呢? 很简单,两句代码搞定,int mid = GetMiddle(v,left,right); swap(v[mid],v[left])
这两句代码为什么可以搞定? ? ?既然原来是选left作为key,现在我想三数取中,那我就找出来中间和left一交换不就完了,其他逻辑都不用动。
2.随机取key
ps: 这种方法不一定是优化,只是选key的一种方式.
cpp
int GetRan(vector<int>&v,int left,int right)
{
return (rand() ^ getpid()) % (right - left + 1);
}
这里和上面的用法类似,但是要注意:int ran = left + GetRan(v,left,right);
left不一定是0,所以随机出来的结果要加上left,当然在GetRan函数里面去处理也可以。
3.小区间优化
快速排序小区间用插入排序优化的本质是:放弃 "分治" 在小区间的低性价比优势,转而利用插入排序的低常数、高有序适应性优势。说人话:快速排序在大规模数据上有优势,但是到了小范围的时候,递归要建立栈帧有成本,本身的排序过程也有成本,但是对于插入排序,对于接近有序和小数据的范围,插入排序还是很高效的,所以可以这么优化。
代码:
cpp
#define MAX_LENGTH 7
void InsertSort(vector<int>& v,int left,int right)
{
for(int i = left;i <= right;++i)
{
int data = v[i]; //要插入的数据
int pos = i - 1; // 要比较的元素的位置
while(pos >= 0)
{
if(v[pos] > data)
{
v[pos + 1] = v[pos];
pos--;
}
else break;
}
//这里循环退出表示v[pos] > data 或者 pos = -1,pos + 1就是要插入的位置
v[pos + 1] = data;
}
}
void _QuickSort(vector<int> &v, int left, int right)
{
if (left >= right) return;
if(right - left > MAX_LENGTH)
{
int div = partion1(v, left, right);
_QuickSort(v, left, div);
_QuickSort(v, div + 1, right);
}
else InsertSort(v,left,right);
Print(v);
}
四、快速排序非递归
一般来说,递归都可以改为非递归,一般通过循环,效率方面可能差不多,但是递归毕竟要建立堆栈,可能会栈溢出。
这里的非递归是伪非递归,partion的部分不变,变的是前序遍历排序的那部分。用栈来实现,整体思路也比较简单,最开始入left,right,当stack里面没有要处理的区间就结束,每次取出一个区间,通过partion得到div,再入两个区间这样就可以了。
代码:
cpp
void QuickSortNone(vector<int>&v,int left,int right)
{
stack<int> st;
st.push(left),st.push(right);
while(st.size())
{
right = st.top();st.pop();
left = st.top();st.pop();
if(left >= right) continue ;
int div = partion1(v,left,right);
st.push(div + 1);
st.push(right);
st.push(left);
st.push(div);
}
}
细节:
这里有个细节,先push右边的区间,再push左边的区间,这样更优---为什么???这样你取出的时候,先取出的是左区间,再取出的是右区间,其实核心问题就是为什么要先左后右??前面的QuickSort也是先左后右,为什么??如果以v[left]为key,先左后右更好:
若「先压左、再压右」:每次处理的是大的右子区间,划分后又会压入新的大右子区间,栈深度会达到 O(n)(比如 n=5 时,栈深度最大为 4),可能导致栈溢出。
若「先压右、再压左」:每次先处理小的左子区间(长度 = 1,直接跳过),再处理右子区间,栈深度仅为 O(logn)(比如 n=5 时,栈深度最大为 2),显著更优
什么意思????
比如下标是[1.5],如果先右并且每次返回的div都是left(也就是已经是升序的数组了),第一次partion后stack里面存的是(0,0)(1,4),然后处理(1,4),stack内剩余(0,0)(1,1)(2,4),栈的size依次增大
如果是先左后右呢?? 第一次:(0,0)(1,4),处理(0,0)直接返回了,(1,4)就变成了(1,1)(2,4),之后类推,明显更优,如果是递归写法,这里的栈的size就是建立的栈帧,所以要先左后右。
五、快速排序特点
1.时间复杂度:O(NlogN) ---想象二叉树结构
2.空间复杂度:O(logN) --递归栈--最坏情况下,二叉树退化成单树 O(N)
3.稳定性:不稳定,稳定的NlogN级别的是归并排序 ---stable_sort()
4.快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
嘶,那我堆排序的时间复杂度是O(NlogN),空间复杂度还更优,为什么堆排序不叫快速排序? 这里还和CPU的缓存效率有关系,连续空间缓存更快,综合下来快排更优。
总结
无