数据结构系列之快速排序


前言

快速排序是排序中比较重要的一个,也是内容比较多的一个,所以就单独拿出来讲讲。


一、什么是快速排序?

首先,既然敢叫快速排序,那这个排序确实是有点说法的,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的缓存效率有关系,连续空间缓存更快,综合下来快排更优。

总结

相关推荐
qwepoilkjasd2 小时前
C++ 虚函数与多态详解
c++
卡提西亚2 小时前
一本通网站1130:找第一个只出现一次的字符
数据结构·c++·笔记·算法·一本通
lkbhua莱克瓦242 小时前
Java基础——集合进阶用到的数据结构知识点3
java·数据结构·github·平衡二叉树·avl
luoganttcc2 小时前
DiffusionVLA 与BridgeVLA 相比 在 精度和成功率和效率上 有什么 优势
人工智能·算法
CoovallyAIHub2 小时前
注意力机制不再计算相似性?清华北大新研究让ViT转向“找差异”,效果出奇制胜
深度学习·算法·计算机视觉
敲上瘾2 小时前
C++ ODB ORM 完全指南:从入门到实战应用
linux·数据库·c++·oracle·db
CoovallyAIHub2 小时前
从图像导数到边缘检测:探索Sobel与Scharr算子的原理与实践
深度学习·算法·计算机视觉
蒙奇D索大3 小时前
【算法】递归算法的深度实践:深度优先搜索(DFS)从原理到LeetCode实战
c语言·笔记·学习·算法·leetcode·深度优先
一叶之秋14123 小时前
玩转二叉树:数据结构中的经典之作
数据结构