快速排序的优化

简介: 快速排序的优化

1.前言

前面的一篇文章www.cnblogs.com/backnullptr…讲了快速排序的基本概念、核心思想、基础版本代码实现等,让我们对快速排序有了一个充分的认识,但还无法达到面试中对快速排序灵活应对的程度。

快速排序是图领奖得主发明的算法,被誉为20世纪最重要的十大算法之一,快速排序为了可以在多种数据集都有出色的表现,进行了非常多的优化,因此对我们来说要深入理解一种算法的最有效的手段就是不断优化提高性能。

通过本文你将了解到以下内容:

  • 快速排序和归并排序的分治过程对比
  • 快速排序分区不均匀的影响
  • 快速排序的随机化基准值
  • 快速排序的三分区模式
  • 快速排序和插入排序的混合

2.快速排序的分区过程

快速排序和归并排序采用的基本思想都是分治思想Divide&Conquer,从D&C思想来看最主要的部分就是分割和合并,两种算法在使用D&C时侧重点有一些差异:

  • 归并排序在分割时处理很简单,在合并时处理比较多,重点在合并。
  • 快速排序在分割时处理比较复杂,由于交换的存在递归结束时就相当于合并完成了,重点在分割。

2.1 归并排序分治示意图

01ebd755782e4c909dad0843d3544acf.jpeg

2.2 快速排序分治示意图

注:快排的过程就不写具体的数字了 仅为达意 点到即可。

01ebd755782e4c909dad0843d3544acf.jpeg

可以明显看出来,快速排序在选择基准值时对整个分治过程影响很大,因为下一个环节的分治是基于前一环节的分割结果进行的。

2.3 快速排序划分不均匀情况

考虑一种极端的情况下,如果基准值选取的不合理,比如是最大的或者最小的,那么将导致只有一边有数据,对于已经排序或者近乎有序的数据集合来说就可能出现这种极端情况,还是来画个图看下:

01ebd755782e4c909dad0843d3544acf.jpeg

图中展示了每次分治都选择第一个元素作为基准值,但是每次的基准值都是最小值,导致每次基准值左侧没有子序列,除了基准值之外全部元素都在右子序列。

2.4 概率和复杂度计算

每次分割排序之后,只能在有序序列中增加1个元素递归树变成了单支树并且递归深度变大,极端情况的出现概率和最坏复杂度计算如下:

01ebd755782e4c909dad0843d3544acf.jpeg

极端情况概率就是每次在剩余所有元素中挑出最小的,这样每次的概率都是1/(n-i),所以组合起来就是1/(n!),所以随机数据集合出现最差情况的概率非常低,但是有序数据下固定基准值选择就可能造成极端情况的出现。

最坏复杂度相当于每次从n-i个元素中只找到1个数据,将所有情况累加也就达到了O(n^2)级别,并不是递归过程全都挑选了最值作为基准值才会出现O(n^2)的复杂度,复杂度是一个概率化的期望值,具体的系数不同影响也很大。

3. 快速排序基准值选取优化

3.1 分割越均匀速度越快

从上面的几张图可以清晰看到基准值的不同对于D&C过程的分割会产生很大的影响,为了保证快速排序的在通用数据集的效率,因此我们需要在基准值的选取上做一些决策,换句话说就是让选取的基准值每次都可以尽可能均匀地分割数据集,这样的效率是最高的。

3.2 随机选取基准值

网上有很多选择方法比如固定选取第一个、固定选取最后一个、固定选择中间值、三值平均选取等,不过个人觉得每一次都随机选取某个位置的数据作为基准值,然后与第一个值互换,这样就相当于每次的基准值都是随机选择的,就将固定index带来的问题,大大降低了。

3.3 随机vs固定对比试验

接下来做一组对比试验,生成一个0-100000的有序数组,代码增加了很多选择项和时间测量代码,测试代码如下:

#include<iostream>
#include<sys/time.h>
#include<stdlib.h>
#define SIZE 100000
using namespace std;
//获取从[start-end)之间的随机index
int getrandom(int start,int end){
    srand((unsigned int)time(NULL));
    return (rand()%(end-start))+start;
}
//获得毫秒时间戳
long getCurrentTime() { 
    struct timeval tv; 
    gettimeofday(&tv,NULL); 
    return tv.tv_sec*1000 + tv.tv_usec/1000; 
} 
template <typename T>
void quick_sort_recursive(T arr[], int start, int end, string& method) {
    if (start >= end)
        return;
    //产生随机索引 并与end交换 之后与原来的处理一致
    if(method=="random"){
        int randindex = getrandom(0,end-start);
        std::swap(arr[end],arr[randindex]);
    }
    T mid = arr[end];
    int left = start, right = end - 1;
    //在整个范围内搜寻比枢纽元值小或大的元素,然后将左侧元素与右侧元素交换
    while (left < right) {
            //试图在左侧找到一个比枢纽元更大的元素
        while (arr[left] < mid && left < right)
            left++;
                //试图在右侧找到一个比枢纽元更小的元素
        while (arr[right] >= mid && left < right)
            right--;
                //交换元素
        std::swap(arr[left], arr[right]);
    }
    if (arr[left] >= arr[end])
        std::swap(arr[left], arr[end]);
    else
        left++;
    quick_sort_recursive(arr, start, left-1, method);
    quick_sort_recursive(arr, left+1, end, method);
}
//模板化
template <typename T> 
void quick_sort(T arr[], int len, string& method) {
    quick_sort_recursive(arr, 0, len-1, method);
}
int main(int argc,char *argv[])
{
    if(argc < 2){
        cout<<"give me a method:fix or random"<<endl;
        return -1;
    }
    string method = string(argv[1]);
    cout<<method<<endl;
    int a[SIZE]={};
    //产生有序数据
    for(int i=0;i<100000;i++)
        a[i]=i+1;
    int len = sizeof(a)/sizeof(int);
    long start = getCurrentTime();
    quick_sort(a,len-1,method);
    long end = getCurrentTime();
    cout<<len<<"有序数据耗时统计:"<<endl;
    cout<<"START@:"<<start<<"ms"<<endl;
    cout<<"END@:"<<end<<"ms"<<endl;
    cout<<"COST:"<<end-start<<"ms"<<endl;
}

笔者使用相同的数据集在fix和random模式下,后者的耗时只有前者的大约1/10,不过在我的电脑上上面的代码耗时比我预期大很多,还是存在优化空间,所以某些场景下随机化带来的性能提升很明显,是一个惯用的优化方法。

4. 快速排序的三分区模式原理

前面的路子都是以基准值为准分为小于子序列和大于子序列,考虑一种特殊的数据集,数据集中有大量重复元素,这种情况下使用两分区递归会对大量重复元素进行处理。

一个优化的方向就是使用三分区模式:小于区间、等于区间、大于区间,这样在后续的处理中则只需要处理小于区和大于区,降低了等于基准值区间元素的重复处理,加速排序过程。

如图为三分区模式中某个时刻的快照,其中展示了几个关键点和区间,包括基准值、小于区、等于区、处理值、待处理区、大于区。

01ebd755782e4c909dad0843d3544acf.jpeg

在实际过程中根据处理值与基准值的大小关系,进行相应分区合并和交换,再进行下标移动就可以了,实际中分三种情况,这也是写代码的依据:

  1. 处理值e==p,将e合并到等于区,i++;
  2. 处理值e
  3. 处理值e>p,将e与(gt-1)位置的数据交换,扩展大于区gt--,此时i不变,交换后的值是之前待处理区的尾部数据;
  • e==p的合并

01ebd755782e4c909dad0843d3544acf.jpeg

  • e

01ebd755782e4c909dad0843d3544acf.jpeg

  • e>p的合并

01ebd755782e4c909dad0843d3544acf.jpeg

  • 分区最终调整

处理完待处理区的全部数据之后的调整也非常重要,主要是将基准值P与lt位置的数据交换,从而实现最终的三分区,如图所示:

01ebd755782e4c909dad0843d3544acf.jpeg01ebd755782e4c909dad0843d3544acf.jpeg

从最终的分区可以看到,我们下一次的循环可以不处理等于区的数据而只处理两端分区数据,这样在大量重复场景下优化效果会非常明显。

4. 三分区模式代码试验

#include<iostream>
#include <ctime>
#include<stdlib.h>
#include<sys/time.h>
using namespace std;
#define SIZE 1000000
void quickSort3Way(int arr[], int l, int r) {
    if (l >= r)
        return;
    //随机选择要做比较的值
    swap(arr[l], arr[rand() % (r - l + 1) + l]);
    int v = arr[l];
    int i = l + 1;
    int lt=l;
    int gt = r;
    while (i<gt+1) {
         //处理值小于基准值 合并到小于区
        if (arr[i] < v) {
            swap(arr[i], arr[lt+1]);
            lt++;
            i++;
        }
        //处理值大于基准值 合并到大于区
        else if (arr[i] > v) {
            swap(arr[i], arr[gt]);
            gt--;
        }
        //处理值等于基准值 合并到等于区
        else{
            i++;
        }
    }
    //最终调整
    swap(arr[l], arr[lt]);
    //获取下一次处理的左右边界 小于区右边界lt-1 大于区左边界gt
    quickSort3Way(arr, l, lt-1 );
    quickSort3Way(arr, gt, r);
}
//获得毫秒时间戳
long getCurrentTime() { 
    struct timeval tv; 
    gettimeofday(&tv,NULL); 
    return tv.tv_sec*1000 + tv.tv_usec/1000; 
} 
int main() 
{
    int arr[SIZE]={};
    for(int i=0;i<SIZE;i++)
        arr[i]=i%10;
    long start = getCurrentTime();
    quickSort3Way(arr,0,SIZE-1);
    long end = getCurrentTime();
    int len = sizeof(arr)/sizeof(int);
    cout<<len<<"有序数据耗时统计:"<<endl;
    cout<<"START@:"<<start<<"ms"<<endl;
    cout<<"END@:"<<end<<"ms"<<endl;
    cout<<"COST:"<<end-start<<"ms"<<endl;
    return 0;
}

代码测试了100w数据,数据是0-10的循环重复,测试耗时如下:

1000000有序数据耗时统计:
START@:1575212629852ms
END@:1575212629878ms
COST:26ms

笔者使用相同的数据集在二分区模式下测试10w数据规模耗时大约是1800ms,数据集减少10倍耗时却增大了几十倍,或许二分区代码还是存在优化空间,不过这个对比可以看到存在大量重复元素时三分区性能还是很不错的。

5. 快速排序和插入排序混合

插入排序在数据集近乎有序的前提下效率可以到达O(n),快速排序在递归到末尾时当序列的元素数较少时,可以用插入排序来代替后续的递归处理过程,从而结合二者的优点进行加速,写一段简单的伪代码表示:

//当序列中的数据数量小于15时 采用插入排序
if(r-l < 15){
   insertsort(arr,l,r)
}

6. 总结

快速排序是基于D&C思想实现的,最核心的部分就是分区Partition的过程,该过程可以有很多写法。

对快速排序的优化主要体现在基准值选取、数据集分割、递归子序列选取、其他排序算法混合等方面,换句话说就是让每次的分区尽量均匀且没有重复被处理的元素,这样才能保证每次递归都是高效简洁的。




目录
相关文章
|
10月前
|
前端开发 小程序 JavaScript
A2UI 规范与 AG-UI 协议:打造高效协同的界面开发体系
A2UI规范与AG-UI协议构建“设计-开发”协同闭环:前者统一视觉与交互标准,后者定义组件通信与跨平台适配,二者融合提升协作效率、保障体验一致,推动界面开发向标准化、可复用、高效能演进。
3686 0
|
10月前
|
机器学习/深度学习 算法
费曼学习法:为什么你应该通过写博客来掌握任何知识
你是否学完就忘?诺贝尔奖得主费曼提出:若无法简单解释,便是未真正理解。其学习法四步——学习、教授、反思、简化——结合写博客,能深度巩固知识。写作倒逼思维清晰,暴露理解盲区,构建个人知识库。无需完美,从“初学者视角”出发,边学边写,用输出驱动输入。写博客不仅是记录,更是成为专家的路径。开始吧,让文字见证你的深度学习之旅。
824 163
费曼学习法:为什么你应该通过写博客来掌握任何知识
|
7月前
|
人工智能 开发框架 机器人
智能体(Agent)全面解析:什么是智能体agent
智能体(Agent)是具备自主思考、决策与工具调用能力的AI执行者,超越传统问答机器人。它以LLM为大脑、工具为手脚、AgentType为思维模式,支持低代码(如Coze)与专业开发(如LangChain)双路径构建,可融合RAG增强知识,实现复杂任务自动化。(239字)
6916 0
|
12月前
|
网络安全 数据安全/隐私保护 Windows
分享5款小众且无广告的小工具
推荐5款小众无广告软件:Krita(绘图)、WinSCP(文件传输)、燃精灵(微信检测)、GIDOT TYPESETTER(排版)、Sketchable(手绘笔记),功能强大且专注,提升创作与工作效率。
545 0
|
存储 传感器 编解码
ROS机器视觉入门:从基础到人脸识别与目标检测
前言 从本文开始,我们将开始学习ROS机器视觉处理,刚开始先学习一部分外围的知识,为后续的人脸识别、目标跟踪和YOLOV5目标检测做准备工作。我采用的笔记本是联想拯救者游戏本,系统采用Ubuntu20.04,ROS采用noetic。 颜色编码格式,图像格式和视频压缩格式 (1)RGB和BGR:这是两种常见的颜色编码格式,分别代表了红、绿、蓝三原色。不同之处在于,RGB按照红、绿、蓝的顺序存储颜色信息,而BGR按照蓝、绿、红的顺序存储。 rgb8图像格式:常用于显示系统,如电视和计算机屏幕。 RGB值以8 bits表示每种颜色,总共可以表示256×256×256=16777216种颜色
832 70
|
存储 算法 数据可视化
【Python】实现二维装箱Bottom-Left算法及用人工蜂群算法改进
本文介绍了二维装箱问题的Bottom-Left算法,并提供了Python实现,包括主函数、装箱顺序、重叠检测、最终位置计算等,同时指出了算法的缺点并提出了使用人工蜂群算法进行改进的方法,最后提供了完整代码的下载链接。
1478 1
|
存储 机器学习/深度学习 人工智能
文档智能与RAG技术在LLM中的应用评测
本文介绍了阿里云在大型语言模型(LLM)中应用文档智能与检索增强生成(RAG)技术的解决方案,通过文档预处理、知识库构建、高效检索和生成模块,显著提升了LLM的知识获取和推理能力,尤其在法律、医疗等专业领域表现突出。
1674 1
|
Web App开发 JSON JavaScript
Chrome 插件各模块之间的消息传递
Chrome 插件各模块之间的消息传递 一、消息传递 1. 消息传递分类 Chrome 插件的 Action、Background 和 content_script 三个模块之间的信息传输 插件和插件之间的信息传输 网页向插件进行信息传输 与原生应用进行消息传递
1495 0
|
Linux 数据安全/隐私保护 Windows
Kali Linux(3)-安装Windows与Kali Linux双系统
本文目录 1. Windows+Kali Linux双系统安装 2. 步骤
1515 0
|
自然语言处理
掩码语言模型(MLM)
掩码语言模型(MLM)

热门文章

最新文章