快速排序

Author Avatar
Brian Lee 8月 10, 2018
  • 在其它设备中阅读本文章

《Algorithm》(Sedgewick)笔记:快速排序


原理

  • 快速排序是一种分治的排序算法
  • 通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。

与归并排序区别

  • 归并排序 将数组分成两个子数组分别排序,并将有序的子数组归并以将整个数组排序。递归调用发生在处理整个数组之前(处理为归并)。
  • 快速排序 将数组排序的方式则是当两个子数组都有序时,整个数组也自然有序了。递归调用发生在处理整个数组之后(处理为切分)。

动图演示

动图来源(侵删)https://zhuanlan.zhihu.com/p/40695917


复杂度

时间复杂度

最好&平均情况 :$O(nlogn)$

最坏情况 :$O(n^2)$

空间复杂度

最好情况 :$O(logn)$

最坏情况 :$O(n)$


切分

步骤

  • 先选取a[lo]作为切分元素(pivot),即将会排定的元素。
  • 然后从数组左端开始向右扫描直到找到一个大于等于pivot的元素,再从数组的右端开始向左扫描直到找到一个小于等于pivot的元素,交换它们的位置。
  • 继续此步骤,可以保证左指针i的左侧元素都不大于pivot,右指针j 的右侧元素都不小于pivot。
  • 当两个指针相遇时,将pivot和左子数组最右侧元素(a[j])交换然后返回j。

代码

public static int partition(Comparable[] a, int lo, int hi) {
        int i = lo, j = hi + 1;
        Comparable v = a[lo];  //pivot
        while (true) {
            while (less(a[++i], v)){
                if (i == hi)
                    break;
            }
            while (less(v, a[--j])) {
                if (j == lo)
                    break;
            }
            if (i >= j)
                break;
            exch(a, i, j);
        }
        exch(a, lo, j);
        return j;
    }

切分轨迹

切分轨迹


快速排序

步骤

每次切分后递归地把小于pivot的子数组和大于pivot的子数组排序。

代码

public static void sort(Comparable[] a) {
        shuffle(a);     //消除对输入依赖
        sort(a, 0, a.length - 1);
    }

public static void sort(Comparable[] a, int lo, int hi) {
        if (lo >= hi)
            return;
        int j = partition(a, lo, hi);
        sort(a, lo, j - 1);
        sort(a, j + 1, hi);
    }

图示

快速排序

保持随机性

代码中的shuffle(a)将数组a[]的顺序打乱,原因是当输入数组a[]有序(包括顺序和逆序)或接近有序时,会造成划分不均,此时快速排序退化成冒泡排序,时间复杂度退化为 $O(n^2)$ ,所以可在排序前先将a[]的顺序打乱,可将时间复杂度基本维持在 $O(nlogn)$ 。也可以通过在切分过程中随机选择切分元素来解决此问题。

重复元素情况

左侧扫描时遇到大于等于(而不仅是大于)pivot的元素便停下,右侧扫描遇到小于等于(而不仅是小于)pivot的元素便停下。虽然这样会不必要地将一些等值元素交换,但可以避免时间复杂度退化为 $O(n^2)$ 。(如当数组元素值一样时,若不等值交换,时间复杂度便会退至 $O(n^2)$ 。

源码

https://github.com/XutongLi/Algorithm-Learn/blob/master/src/S2_Sorting/S2_3_1_QuickSort/Quick.java


时间复杂度分析

最好情况

当每次都能将数组对半分时为最好情况。

此时比较次数为:$C_N=2C_{N-1}+N$ ,其中 $2C_{N-1}$ 为两个子数组排序的成本,$N$ 表示用pivot和所有数组元素进行比较的成本。

等同于归并排序的时间复杂度证明,可得此递归公式的解为:$C_N=NlogN$ 。

所以最好情况下时间复杂度为 $O(NlogN)$ 。

平均情况

令 $C_N$ 为将 $N$ 个不同元素排序平均所需的比较次数。

易知 $C_0=C1=0$

对于 $N>1$ ,易得归纳关系:

$C_N=N+1+(C_0+C_1+…+C_{N-1})/N+(C_{N-1}+…+C_0)/N$

第一项为切分的成本 $(N+1)$ ,第二项为将左子数组(长度可能为 $0$ 到 $N-1$ )排序的平均成本,第三项是将右子数组排序的平均成本。

两边都乘 $N$ 得到:

$NC_N=N(N+1)+2(C_0+C_1+…+C_{N-2}+C_{N-1})$

该等式减去 $(N-1)C_{N-1}=N(N-1)+2(C_0+C_1+…+C_{N-2})$ 得:

$NC_N-(N-1)C_{N-1}=2N+2C_{N-1}$

两边除以 $N(N+1)$ 得:

$C_N/(N+1)=C_{N-1}/N+2/(N+1)$

消除迭代可得:

$C_N\sim2(N+1)(1/3+1/4+…+1/(N+1))$

括号内的量是曲线 $2/x$ 下从 $3$ 到 $N$ 的离散近似面积加一,积分得到 $C_N\sim2NlogN$ 。

所以平均情况下时间复杂度为 $O(NlogN)$ 。

最坏情况

最坏情况为输入数组正序或逆序时,即每次切分两个子数组之一为空。

此时比较次数为:$C_N=N+(N-1)+(N-2)+…+2+1=\frac{N(N+1)}{2}$

所以最坏情况下时间复杂度为 $O(N^2)$ 。


优缺点

优点

  • 实现简单
  • 适用于各种不同的输入数据且在一般应用中比其他排序算法快
  • 是原地排序,只需要一个很小的辅助栈
  • 内循环比大多数排序算法短小

缺点

  • 非常脆弱,在实现时要非常小心才能避免低劣的性能

三取样切分

原理

  • 使用子数组的一小部分元素的中位数来切分数组可以改进快速排序性能
  • 将取样大小设为3并用大小居中的元素切分的效果最好

代码

public static void sort(Comparable[] a) {
        shuffle(a);     //消除对输入依赖
        sort(a, 0, a.length - 1);
    }

public static void sort(Comparable[] a, int lo, int hi) {
        if (lo >= hi)
            return;
        int n = hi - lo + 1;
        int m = mid3Pivot(a, lo, lo + n / 2, hi);
        exch(a, m ,lo);
        int j = partition(a, lo, hi);
        sort(a, lo, j - 1);
        sort(a, j + 1, hi);
    }

//返回a[i] a[j] a[k]中位数index
    private static int mid3Pivot(Comparable[] a, int i, int j, int k) {
        if (less(a[i], a[j])) {
            if (less(a[j], a[k]))  //ai<aj<ak
                return j;
            else if (less(a[i], a[k])) //ai<ak<aj
                return k;
            else  //ak<ai<aj
                return i;
        }
        else {
            if (less(a[k], a[j]))   //ak<aj<ai
                return j;
            else if (less(a[k], a[i]))  //aj<ak<ai
                return k;
            else    //aj<ai<ak
                return i;
        }
    }

步骤

  • 首先得到三个元素的中位数的索引
  • 通过索引将该中位数与第一个元素交换
  • 同上进行切分和排序

源码

https://github.com/XutongLi/Algorithm-Learn/blob/master/src/S2_Sorting/S2_3_2_Quick3Pivot/Quick3Pivot.java


三向切分

原理

  • 在有大量重复元素的情况下,快速排序的递归性会使元素全部重复的子数组经常出现
  • 为优化此问题,可将数组切分为三部分,分别对应小于、等于和大于pivot的数组元素

代码

public static void sort(Comparable[] a) {
        shuffle(a);     //消除对输入依赖
        sort(a, 0, a.length - 1);
    }

public static void sort(Comparable[] a, int lo, int hi) {
        if (hi <= lo)
            return;
        int lt = lo, i = lo + 1, gt = hi;
        Comparable v = a[lo];
        while (i <= gt) {
            int cmp = a[i].compareTo(v);
            if (cmp < 0)
                exch(a, lt++, i++);
            else if (cmp == 0)
                i++;
            else
                exch(a, i, gt--);
        }
        sort(a, lo, lt - 1);
        sort(a, gt + 1, hi);
    }

步骤

  • 从左到右遍历数组一次,维护一个指针lt使a[lo…lt-1]中的元素都小于v,一个指针gt使得a[gt+1…hi]中的元素都大于v,一个指针i使得a[lt..i-1]中的元素都等于v,a[i..gt]中的元素都还未确定
  • a[i]小于v,将a[lt]和a[i]交换,将lt和i加一
  • a[i]大于v,将a[gt]和a[i]交换,将gt减一
  • a[i]等于v,将i减一

图示

三向切分

香农信息量

给定包含 $k$ 个不同值的 $N$ 个主键,对于从 $1$ 到 $k$ 的每个 $i$ ,定义 $f_i$ 为第 $i$ 个主键值出现的次数, $p_i$ 为 $f_i/N$ ,即为随机抽取一个数组元素时第 $i$ 个主键值出现的概率。

那么所有主键的香农信息量 可以定义为:

$H=-(p_1lgp_1+p_2lgp_2+…+p_klgp_k)$

时间复杂度

最优 :$O(NH)$ (对于包含大量重复元素的数组,它将排序时间从线性对数级降低到了线性级别)

最差 :$O(NlogN)$ (所有主键均不相同)

源码

https://github.com/XutongLi/Algorithm-Learn/blob/master/src/S2_Sorting/S2_3_3_QuickSort3Way/Quick3Way.java


本博客采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!
本文链接:http://brianleelxt.top/2018/08/10/quickSort/