快速排序及优化(三路划分等)

本文详细介绍了快速排序的时间复杂度分析及优化方法,包括使用插入排序、选择中位数作为枢轴和三路划分等技巧。

快速排序, 是最经典的排序算法之一。快速排序拥有良好的时间复杂度,平均为 O(nlog2n) ,最差为 O(n2) 。在这里,我们不妨略略深入讨论一下快速排序:

时间复杂度分析

首先说平均时间复杂度。以比较常用的从两头进行扫描的算法为例,算法主要分两步:
1. 是快排的核心:“分趟”。就是“每一趟”下来,找到某一个元素应该待的位置,这个元素一般被称为pivot;
2.再分别对pivot前后两部分进行递归排序。

#include <iostream>
using namespace std;
int partition(int *a, int left, int right)
{
    int key = a[left];
    while(left < right){
        while(left < right && a[right] >= key) right--;     //从右找到第一个比key小的
        a[left] = a[right];
        while(left < right && a[left] <= key) left++;       //从左找到第一个比key大的
        a[right] = a[left];   
    }
    a[left] = key;                                          //基准归位
    return left;    
}
void Qsort(int *a, int left, int right)
{
    if(left < right){                                       //元素长度>1时
        int pos = partition(a, left, right);
        Qsort(a, left, pos - 1);                            //pos本身不需要再动了
        Qsort(a, pos + 1, right);
    }
}
int main()
{
    int a[] = {57, 68, 59, 52, 72, 28, 96, 33, 24};

    Qsort(a, 0, sizeof(a) / sizeof(a[0]) - 1);

    for(int i = 0; i < sizeof(a) / sizeof(a[0]); i++)
    {
        cout << a[i] << " ";
    }

    return 0;
}

显然,一趟下来,pivot被固定的位置越趋于中间,前后两部分子序列的递归调用就越均衡,这时候时间复杂度是最小的。

    T(n) <= n + 2T(n/2)
         <= 2n + 4T(n/4)
         <= 3n + 8T(n/8)
         ...
         <= (log n)n + nT(1) = O(nlog n)

因此,为 O(nlog2n)

最差的情况下,也就是pivot被固定后的位置总是在最前面或最后面,导致前后两部分子序列实际只是一个子序列。这也就意味着,原代排序列本身就是有序的,要么从小到大,要么从大到小。比如从小到大:此时,第一趟经过n-1次比较,将第一个元素固定在首位;第二趟经过n-2次比较,将第二个元素固定在第二位,以此类推,n个元素总共要比较 1+2+3+...+(n1)=n(n1)/2 次,所以复杂度为 O(n2) 。当然,如果从简单形象的角度去理解,一般的快排执行过程大概是二叉树形结构,而最差情况则是退化成了链表。

优化

优化大致有三种比较有效的方法。

使用插入排序

在子序列比较小的时候,其实插排是比较快的,因为对于有序的序列,插排可以达到 O(n) 的复杂度,如果序列比较小,则和大序列比起来会更加有序,这时候使用插排效率要比快排高。其实现方法也很简单:快排是在子序列元素个数变成1是,才停止递归,我们可以设置一个阈值n,假设为5,则大于5个元素,子序列继续递归,否则选用插排。(其实在C++的STL中,归并算法就是采用了这个思路,当子序列小到一定程度的时候,直接选用插排对子序列进行排序)

快排是在待排数列越趋近于有序时变得越慢,复杂度越高,调用插排可以很好的解决这个问题。

pivot选用中位数

对于一般的快排,我们直接简单的就取最左或最右的数据作为pivot,这样的话很可能遇到比较极端的pivot,使得划分出来的左右子序列变得不均衡。如果选取最左、中间、最右这三个值的中位数的话,显然会使得pivot更加“不偏激”,这样划分出来的左右子序列也会更加均衡。

选用中位数和调用插排一样,都能避免数列比较有序时复杂度变高的问题。

三路划分

快排是二路划分的算法。如果待排序列中重复元素过多,也会大大影响排序的性能。这时候,如果采用三路划分,则会很好的避免这个问题。

如果一个带排序列重复元素过多,我们先随机选取一个pivot,设为T,那么数列可以分为三部分:小于T,等于T,大于T:

等于T的部分就无需再参与后续的递归调用了,速度自然就大大提升了。

但是问题在于怎么高效地将序列划分为三部分!

如下图,我们可以设置四个游标,左端a、b,右端c、d。b、c的作用跟之前两路划分时候的左右游标相同,就是从两端向中间遍历序列,并将遍历到的元素与pivot比较,如果等于pivot,则移到两端(b对应的元素移到左端,c对应的元素移到右端。移动的方式就是拿此元素和a或d对应的元素进行交换,所以a和d的作用就是记录等于pivot的元素移动过后的边界),反之,如果大于或小于pivot,还按照之前两路划分的方式进行移动。这样一来,中间部分就和两路划分相同,两头是等于pivot的部分,我们只需要将这两部分移动到中间即可。


参考算法如下,摘自http://blog.youkuaiyun.com/jlqCloud/article/details/46939703

private void quickSort(int[] a, int left, int right) {
    if (right <= left)
        return;

    /* 
     * 工作指针
     * p指向序列左边等于pivot元素的位置
     * q指向序列右边等于Pivot元素的位置
     * i指向从左向右扫面时的元素
     * j指向从右向左扫描时的元素
     */
    int p, q, i, j;
    int pivot;// 锚点
    i = p = left;
    j = q = right - 1;
    /*
     * 每次总是取序列最右边的元素为锚点
     */
    pivot = a[right];
    while (true) {
        /*
         * 工作指针i从右向左不断扫描,找小于或者等于锚点元素的元素
         */
        while (i < right && a[i] <= pivot) {
            /*
             * 找到与锚点元素相等的元素将其交换到p所指示的位置
             */
            if (a[i] == pivot) {
                swap(a, i, p);
                p++;
            }
            i++;
        }
        /*
         * 工作指针j从左向右不断扫描,找大于或者等于锚点元素的元素
         */
        while (left <= j && a[j] >= pivot) {
            /*
             * 找到与锚点元素相等的元素将其交换到q所指示的位置
             */
            if (a[j] == pivot) {
                swap(a, j, q);
                q--;
            }
            j--;
        }
        /*
         * 如果两个工作指针i j相遇则一趟遍历结束
         */
        if (i >= j)
            break;

        /*
         * 将左边大于pivot的元素与右边小于pivot元素进行交换
         */
        swap(a, i, j);
        i++;
        j--;
    }
    /*
     * 因为工作指针i指向的是当前需要处理元素的下一个元素
     * 故而需要退回到当前元素的实际位置,然后将等于pivot元素交换到序列中间
     */
    i--;
    p--;
    while (p >= left) {
        swap(a, i, p);
        i--;
        p--;
    }
    /*
     * 因为工作指针j指向的是当前需要处理元素的上一个元素
     * 故而需要退回到当前元素的实际位置,然后将等于pivot元素交换到序列中间
     */
    j++;
    q++;
    while (q <= right) {
        swap(a, j, q);
        j++;
        q++;
    }

    /*
     * 递归遍历左右子序列
     */
    quickSort(a, left, i);
    quickSort(a, j, right);
}

private void quick(int[] a) {
    if (a.length > 0) {
        quickSort(a, 0, a.length - 1);
    }
}

private void swap(int[] arr, int a, int b) {
    int temp = arr[a];
    arr[a] = arr[b];
    arr[b] = temp;
}

三路划分可以避免很多重复元素再次参与递归,对于有大量重复元素的待排序列,效率提高了不少。

以上只是理论上的总结,当然实践起来代码也不难写。在这里推荐一篇有码有实验数据的文章,看后也是更加直观形象,受益匪浅。

快排的优化其实对于一个计算机科学与技术的入门者来讲,是一个不错的思维上的砥砺,这种类型的东西多多探索,计算机科学“素养”自然慢慢就上去了。

<think>嗯,用户想了解快速排序中的三路划分,我得先回忆一下相关的知识点。首先,快速排序本身是基于分治法的,传统的实现是选取一个基准元素,然后把数组分成两部分,一部分比基准小,另一部分比基准大。不过当数组中有很多重复元素时,这种两路划分的效率可能会下降,因为重复元素会被反复处理。 那三路划分应该就是解决这个问题的。对了,三路划分会把数组分成三个部分:小于基准、等于基准和大于基准的部分。这样的话,在递归处理时,只需要处理小于和大于的部分,等于基准的部分就不用再处理了,尤其是在有大量重复元素时,可以显著提高效率。 需要解释清楚三路划分的基本思想,可能还要对比传统的双路划分。然后,步骤分解应该包括如何选择基准,如何分区,以及递归处理。可能还需要提到具体实现时的指针移动,比如使用lt、gt和i这样的指针,或者类似的变量来控制分区过程。 还有,最好举一个例子,比如一个包含多个重复元素的数组,展示三路划分后的结果。这样用户能更直观地理解。另外,时间复杂度的分析也很重要,最好说明在重复元素多的情况下,三路划分如何将复杂度从O(n log n)优化到接近O(n),或者避免最坏情况的发生。 可能还需要提到三路划分的应用场景,比如处理包含大量重复元素的数据集时,或者作为快速排序优化手段之一。同时,注意语言要通俗易懂,避免过于专业的术语,但又要准确。最后,总结一下它的优缺点,或者和双路划分的对比,帮助用户全面理解。</think>### 快速排序中的三路划分 #### 1. **基本思想** 快速排序的**三路划分**(Three-way Partitioning)是一种优化策略,用于处理数组中存在**大量重复元素**的情况。它将数组划分为三个区域: - **小于基准值**的部分(左段) - **等于基准值**的部分(中段) - **大于基准值**的部分(右段) 通过将重复元素集中到中间段,后续递归时**只需处理左右两段**,避免对重复元素重复操作,从而提升效率。 --- #### 2. **具体步骤** 假设数组为 $$arr$$,基准值为 $$pivot$$,三路划分流程如下: 1. **初始化指针**: - $$lt$$(左指针):指向第一个元素,表示左段的末尾 - $$gt$$(右指针):指向最后一个元素,表示右段的开头 - $$i$$(遍历指针):从数组头部开始扫描 2. **遍历与交换**: - 若 $$arr[i] < pivot$$:交换 $$arr[i]$$ 与 $$arr[lt]$$,并移动 $$i$$ 和 $$lt$$ - 若 $$arr[i] > pivot$$:交换 $$arr[i]$$ 与 $$arr[gt]$$,仅移动 $$gt$$ - 若 $$arr[i] = pivot$$:仅移动 $$i$$ 3. **终止条件**: - 当 $$i$$ 与 $$gt$$ 相遇时停止遍历 4. **递归处理**: - 对左段 $$arr[0..lt-1]$$ 和右段 $$arr[gt+1..n-1]$$ 递归执行上述步骤 --- #### 3. **示例演示** 假设数组为 $$[3, 1, 2, 3, 5, 3, 4]$$,基准值 $$pivot = 3$$: - **划分后**: - 左段:$$[1, 2]$$ - 中段:$$[3, 3, 3]$$ - 右段:$$[5, 4]$$ - 后续递归仅需处理左段和右段。 --- #### 4. **时间复杂度分析** - **最好情况**(大量重复元素):$$O(n)$$ - **平均情况**:$$O(n \log n)$$ - **最坏情况**:$$O(n^2)$$(但概率极低) 与传统双路划分相比,三路划分在重复元素较多时**显著减少递归次数**。 --- #### 5. **代码框架(伪代码)** ```plaintext function quickSort3Way(arr, low, high): if low >= high: return pivot = arr[low] lt = low gt = high i = low while i <= gt: if arr[i] < pivot: swap(arr[i], arr[lt]) lt += 1 i += 1 elif arr[i] > pivot: swap(arr[i], arr[gt]) gt -= 1 else: i += 1 quickSort3Way(arr, low, lt - 1) quickSort3Way(arr, gt + 1, high) ``` --- #### 6. **适用场景** - 输入数据包含**大量重复元素** - 需要避免快速排序的最坏时间复杂度 - 例如:排序日志数据、用户评分等重复值较多的场景 --- #### 7. **总结** 三路划分通过将重复元素集中处理,优化了传统快速排序的性能瓶颈。其核心是通过**动态调整三个指针**($$lt$$、$$gt$$、$$i$$)将数组高效划分为三段,特别适合处理实际场景中的非均匀分布数据。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值