接触并理解 快速排序【基础+优化+三向切分】

要点

算法思想与实现,优化思路,性能分析,三向切分,空间,优势

前言

快速排序之所以被称作“快速”,是因为快速排序是我们接触到的最快的通用排序算法,至于原因我们会在后面予以解释。鉴于快速排序的诸多优点,它成为应用最广泛的排序算法,具有优秀的性能,具体我们会在后面进行分析。

相对于初级排序算法、归并排序等,快速排序显现出了它的脆弱性,我们需要在实现时非常小心,才能避免隐藏在深处的性能漏洞。这就像爬山一样,你可以选择走缓慢的、没有风险的缓路,也可以选择可以快速登顶的险路,这样便需要承担风险的代价,但是这会促使我们不断磨炼登山的技巧。同样的道理,快速排序中遇到的性能漏洞会使我们不断改进快速排序算法,让它的使用范围更加广泛。

算法思想

快速排序利用了分治的思想,将一个待排序的数组分为两个较小的子数组,再将两个子数组独立排序。

我们先在待排序的数组中选择(如何选择?下面讲)一个元素a作为基准元素,从数组的头部和尾部开始遍历数组,从头部找到第一个比a大的元素b和从尾部找到第一个比a小的元素c,将b和c的位置互换,重复这样的工作直到头部和尾部的指针相遇,这样原数组就被a切分成了小于a(左部)和大于a(右部)两个部分,再进一步将两个子数组用相同的方式切分只到数组只有一个元素,达到分而治之的目标。

切分后的a的位置应该就是最终排序结果中a应该处于的最终位置,因为不管它左部(或右部)的元素是否已经排序,小于a(大于a)元素的个数是不变的。

算法实现

根据上述的思路,我们可以写出我们的伪代码

  1. 获取待排序数组
  2. 从数组选出一个元素作为基准
  3. 将数组按照左右遍历并相互交换的方式切分为两个子数组A, B
  4. 将数组A排序
  5. 将数组B排序

根据伪代码写出排序代码

    /*
     * 快速排序的驱动函数
     */
    public static void sort(char[] src)
    {
        int n = src.length;
        sort(src, 0, n - 1);
    }

    /*
     * 递归调用
     * lo, hi用于跟踪递归进度
     */
    public static void sort(char[] src, int lo, int hi)
    {
        if (lo >= hi)
            return ;

        int splitIndex = partition(src, lo, hi);        //切分
        sort(src, lo, splitIndex - 1);
        sort(src, splitIndex + 1, hi);
    }

接下来便是快速排序的核心部分partition函数,它的目的是将数组按照上述讨论的方式对数组调整、切分并返回切分元素最终的位置

    /*
     * 快速排序的核心
     * 用段落的第一个元素作为切分的标准
     * 将小于它的都放在左部分,大于它的都放在右部分
     * 从左到右找大的,从右到左找小的,做交换
     * 最后找到最靠右的小于标准元素的元素与标准元素进行交换
     */
    public static int partition(char[] src, int lo, int hi)
    {
        int base = src[lo];
        int l = lo, h = hi + 1;
        while (true)
        {
            while (src[++l] < base)
                if (l == hi)
                    break;
            while (src[--h] > base)
                if (h == lo)
                    break;

            if (l < h)
                exchange(src, l, h);          
            else
                break;
        }

        /*
         * 这里的h换成l-1都可以,表示左侧部分最右的一个元素
         */
        exchange(src, lo, h);

        return h;
    }

这里exchange方法表示交换数组指定下标的两个元素的位置。

这是最基础的实现方式,所以我们只是简单地图方便地选择了数组的第一元素作为基准元素,后续我们会看到更多的选择方式。

在这里,左右指针扫描的时候检测的时候采用的是遇到相等的元素的时候停止扫描而不是跳过(例:<和<=的区别),这样做可以让等值的元素更接近于最终位置,目的是避免算法在遇到大量重复元素时运行时间变成平方级别(虽然会有不必要的等值元素的交换)。

下面我们会通过跟踪排序过程的方式帮助大家理解快速排序算法


快速排序算法轨迹跟踪

其中粗体元素表示切分元素,用“[”和“]”表示切分的子数组的边界。

性能

快速排序的核心是切分函数,其算法的性能关键决定因素也是切分的效果

如果每次切分都可以将数组分成等长的两个子数组分别处理,切分效果应该是最佳的,这类似于二分法。我们用C来表示比较的成本
CN= C + C + N=2CN/2 + N
解递推公式得,这种情况的成本~NlgN

最差的结果应该是每次切分都有一个子数组为
空,显然这种情况的成本~N2

平均情况的性能
书中给出了两个命题,证明过程有兴趣的朋友可以细读书中说明

在长度为N的无重复数组排序,快速排序平均需要~2NlnN次比较(以及1/6的交换)

在这里,2NlnN约等于1.39NlgN,所以平均情况的比较次数比排序算法最好情况多出39%

快速排序最多需要约为N2/2次比较,但随机打乱数组能够预防这种情况

这种情况在小数组出现的概率相对大一些,随着N的增大,运行时间会逐渐接近平均情况,出现平方级别的概率越来越小。

书中举了个例子,对于一个有100万个元素的数组,快速排序运行时间是平均情况所需时间10倍的概率小于0.00001,所以达到平方级别的概率更会小很多,这需要概率知识中的Chebyshev不等式的支持。所以我们完全可以说随机打乱可以预防出现平方级别的情况。

算法优化

我们在算法优化的过程中可以尝试很多种方式,我们可以使用一个辅助数组让切分的实现更加容易,但是复制数组的开销以及辅助数组的空间代价会使我们很容易放弃这个想法(失去了相对于归并排序不需要辅助空间的优势)。下面的几种方式是有效的

随机预处理

刚刚我们说到选择基准元素的方式是选择第一个元素,这是最简单的,也是性能最差的,因为这对于输入很敏感,对于特殊性的输入会有非常差的性能。我们可以在排序之前将所有元素顺序随机打乱,对每个元素平等对待,这时再选择第一个元素相当于是待排序数组中的一个随机元素。在之前的性能分析中,我们了解到这种随机化处理很有必要。

    /*
     * 优化1
     * 加入随机性
     */
    public static void sort(char[] src)
    {
        int n = src.length;
        shuffle(src);
        sort(src, 0, n - 1);
    }

shuffle方法表示随机打乱数组元素顺序

去掉边界测试

细心的朋友可以发现,基础实现中的左端边界测试条件(h == lo)是多余的,因为一个元素a[lo]不可能比自己还小,通过这里得到灵感,我们同样可以去掉右端的边界测试条件,在随机打乱数组的时候将最大值移动到数组末尾作为哨兵,如果指针指向的元素小于base进入循环,那它一定不是在数组末尾,因为末尾的元素是最大值,这样就起到了哨兵的作用,免去边界条件测试。

结合插入排序

我们知道,插入排序对于有很少乱序元素的待排序数组来说是最快的,因为它仅仅对于元素进行必要的比较和在需要的地方进行元素移动,接近线性级别。

快速排序在即将排序成功的时候,对于较小的数组我们仍需要对其进行切分、递归调用,我们采取的措施显得有点大材小用了,基于插入排序的优势,我们可以在数组大小小于阈值M的时候采用插入排序,完成排序的最后一步,这会有效的提升算法效率,阈值可以根据不同场景进行调整。

    /*
     * 优化3
     * 加入插入排序
     */
    public static void sort(char[] src, int lo, int hi)
    {
        if (lo + M>= hi)
        {
            insertSort(src, lo, hi);
            return ;
        }

        int splitIndex = partition(src, lo, hi);
        log(src, lo, hi, splitIndex);
        sort(src, lo, splitIndex - 1);
        sort(src, splitIndex + 1, hi);
    }

其中M为阈值,insertSort方法利用插入排序将指定的数组排序

三取样切分

这是另一种选择切分元素的方式,比之前的随机化处理后选择第一个元素更合理,我们可以选择待排序数组中的一小部分元素的中位数来切分数组,但是要付出找中位数的代价。所以我们常选用三取样切分的方式,我们从数组中随机取得三个元素选择它们中的中位数作为基准元素,这样做还有一个好处,取样元素还可以作为哨兵放在尾部,免去边界测试。

随机三次取样的代价较大,我们可以最开始随机打乱数组,并指定第一个、最后一个和处于中间位置的元素中的中位数作为基准元素。有的地方把这种方法称为三数取中法

三向切分的快速排序

在实际应用中,我们很少会处理无重复元素的数组,经常会遇到含有大量重复元素的数组,而我们之前的基础排序算法对于重复的元素仍对其进行比较和大量的交换,这显然是没有必要的,在其中我们便可以发现巨大的优化潜力,在特定的情况下将线性对数级别的性能进一步改进。

三向切分的思路

之前的算法是将数组分为两个部分,小于基准元素和大于基准元素,三向切分将数组分为三个部分:小于、等于和大于基准元素。所以我们需要更多的指针跟踪(lo, lt, i, gt, hi),我们让a[ lo ... lt - 1 ]存储小于基准元素的元素,a[ lt ... i - 1]存储等于基准元素的元素,a[ i ... gt ]存储未访问到、未确定大小的元素,a[ gt + 1 ... hi ]存储大于基准元素的元素。

实现

基于上面对于指针的要求,我们写出下面三向切分的代码

    /*
     * 这里是和普通快速排序不一样的地方
     * 这里维护几个索引
     * 用i跟踪当前待比较的那一个元素
     * 通过不断地递归将数组切分成三个部分
     * src[lo...lt-1]:小于部分
     * src[lt...gt]:等于部分
     * src[gt+1, hi]:大于部分
     */
    public static void sort(char[] src, int lo, int hi)
    {
        if (lo >= hi)
            return ;
        int lt = lo, gt = hi, i = lt + 1;
        char base = src[lo];

        while (i <= gt)
        {
            /*
             * 对三种情况的处理
             */
            if (src[i] < base)
                exchange(src, lt++, i++);
            else if (src[i] > base)
                exchange(src, gt--, i);
            else
                i++;
        }

        sort(src, lo, lt - 1);
        sort(src, gt + 1, hi);
    }
  1. 当前元素小于基准元素:lt-1是小于部分的最右元素,i-1是等于部分的最右元素,小于部分需要增加一个元素,所以lti均加一。
  2. 当前元素大于基准元素:gt+1是大于部分的最左元素,大于部分需要增加一个元素,所以gt减一。
  3. 当前元素等于基准元素:i-1是等于部分的最右元素,等于部分需要增加一个元素,所以i加一。

下面我们通过跟踪排序过程的方式加深对于三向切分快速排序的理解


三项切分快速排序轨迹跟踪

其中粗体字符表示当前迭代相对于上一次的变化点。

优化原理

其实我们也可以把和基准元素相等的元素看成一个整体,每一次切分都会将与基准元素相等的元素排好。避免了在一次迭代后大量重复的元素会零散地分布在基准元素左部和右部的情况。递归的边界变为数组头部到重复元素集合的左部(lo到lt-1)和重复元素集合右部到数组末尾(gt+1到hi),而不是只关注基本元素这一个元素,减少递归次数,极大地提高效率。

性能分析

三向切分排序在面对只有若干个不同主键的随机时,时间复杂度可以达到数组长度的线性倍数级别。

有的朋友会有疑问,归并排序的~NlgN次比较不应该是最优的时间复杂度吗?这是因为归并排序中的最优讨论的是在任何输入下的最差性能,而三向切分关注的是含有大量重复元素的输入情况,也就是我们已经知道了输入的一些特性,所以对于这种情况,归并排序无法达到最佳。

信息熵

上面所说的一个含有重复数组的重复情况如何,如何量化,以便于我们研究三向切分的性能?我们需要熟悉一下信息熵的概念,信息熵表示所含信息量的大小,这与其中每个元素的概率相关。我们用k表示数组中主键的个数,用pi表示第i个主键在一次随机抽取行为中出现的概率,则我们待排序数组的信息熵为

所以信息熵的大小,也是信息量的大小,由元素的概率分布情况影响。通过计算信息熵,我们可以得出三项切分比较次数的结论,见书中命题

对于大小为N的数组,三向切分的快速排序需要~(2ln2)NH次比较。其中H为由主键值出现频率定义的香农信息量。

基于信息熵的意义,当数组中所有元素都不重复时其所包含的信息量最大,信息熵的值最大,所以三向切分快速排序的最差情况是所有元素均不重复,即p1= p2 = ··· = pk = 1 / N,H = lgN,此时~(2ln2)NH = ~1.39NlgN,回到了之前命题的结论。

其实这种情况也只比归并排序多出39%,只要重复的元素稍多便会在性能上好于归并排序,而在实际应用中,存在大量重复元素的例子非常常见。

空间

快速排序采用的是原地排序,不需要辅助空间,它的递归性决定了它需要递归深度等量的栈空间,所以空间复杂度在logN在N之间。

为了减少递归深度,可以递归排序两个子表中较小的,再用循环代替第二个递归,这样能让我们的递归深度最多是logN,这样当然对于时间复杂度不友好,但是我们在一些对于栈空间有严格要求的场景不妨使用这种方法。

快速排序的优势

相对于那些初级排序算法(插入排序、选择排序等),快速排序的优势不用多说,平均情况的线性对数级别比平方级别在性能上要好太多。而最差情况的平方级别完全可以通过随机化避免。

相对于归并排序。在空间上,归并排序的劣势显露无疑,归并排序需要线性级别的空间,所以只需要对数级别的空间。在时间上,上面已经进行了详细讨论,三向切分快速排序在实际应用中的性能优于归并排序很多。

相对于堆排序。堆排序在时间和空间上都有非常好的效果,但是它总是跳跃式地访问元素,无法利用缓存,而快速排序总会顺序地访问元素,可以利用缓存。而缓存对于性能的提升是非常有用的。

快速排序的内循环中的指令很少,运行时间的增长数量级为~cNlgN,并且三向切分的快速排序对于实际应用中可能出现的某些分布的输入变成了线性级别,这使得其他的排序算法望而却步。

所以,我们会说快速排序是最快的通用排序算法,而三向切分的快速排序也成为了排序库函数的最佳算法选择。也如书中所说

我们需要在运行时间至关重要的任何排序应用中认真地考虑使用快速排序

结束语

文章参考

  • 《算法(第4版)》——Robert Sedgewick & Kevin Wayne 著
  • 《算法设计与分析(第3版)》——王晓东 著
  • 《数据结构(C++描述)》——金远平 著

并结合本人自身的理解,希望对大家有所帮助,有不足之处望朋友们指出,欢迎大家积极评论,遇到问题一起讨论,谢谢!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,732评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,496评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,264评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,807评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,806评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,675评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,029评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,683评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,704评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,666评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,773评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,413评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,016评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,204评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,083评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,503评论 2 343

推荐阅读更多精彩内容

  • 1 初级排序算法 排序算法关注的主要是重新排列数组元素,其中每个元素都有一个主键。排序算法是将所有元素主键按某种方...
    深度沉迷学习阅读 1,391评论 0 1
  • 算法简介 是一种分治的排序算法,特点就是快,而且效率高。 基本思路 通过一趟排序将待排元素分隔成独立的两部分,其中...
    TinyDolphin阅读 3,415评论 0 3
  • 最近在读< >时,了解到了很多常用的排序算法,故写一篇读书笔记记录下这些排序算法的思路和实现. 冒泡排序 冒泡排序...
    SylvanasSun阅读 681评论 0 0
  • 目标:将一个数组按照由低到高(或者由高到低)的顺序排序。 快速排序是历史上最著名的算法之一。1959年由 Tony...
    W1NFRED阅读 296评论 0 0
  • 2017-12-7 基本概念 类比Table. 控件GridPanel 每一行record整体. store作为数...
    hi句身阅读 531评论 0 0