快速排序及其优化

算法简介

是一种分治的排序算法,特点就是快,而且效率高。

基本思路

通过一趟排序将待排元素分隔成独立的两部分,其中一部分元素的关键字均比另一部分的关键字小,然后分别对这两部分元素继续进行排序,以达到整个序列有序。

Q:对比归并排序,有何异同?
A:快速排序和归并排序是互补的:归并排序是将数组分成两个子数组分别排序,并将有序的子数组归并以整个数组排序;而快速排序是当两个子数组都有序时,整个数组也就自然有序了。

递归调用先后顺序不同归并排序 ~ 递归调用发生在处理整个数组之前快速排序 ~ 递归调用发生在处理整个数组之后

切分数组的位置不同归并 ~ 一个数组等分为两半;快速 ~ 切分的位置取决于数组的内容

运行轨迹

快速排序递归的将子数组 arr[lo...hi] 排序,先用 partition() 方法将 arr[indexJ] 放到一个适合位置,然后再用递归调用将其他位置的元素排序

算法的递归调用过程

快速排序关键在于切分方法,我们就是通过递归地调用切分来排序的,因为切分过程总是能排定一个元素

实现的一般策略
①、随意地取 arr[lo] 作为切分元素(将会排定的元素);
②、从数组的左端开始,向右端扫描,直到找到一个 >= 切分元素的元素;
③、从数组的右端开始,向左端扫描,直到找到一个 <= 切分元素的元素;
④、交换他俩的位置(因为显然他俩没有被排定);
⑤、如此继续,可以保证左指针 indexI 的左侧元素都 <= 切分元素、右侧指针 indexJ 的右侧元素都 >= 切分元素;
⑥、当两个指针相遇时,交换切分元素 arr[lo]arr[indexJ] 并返回 indexJ 即可。

代码实现

根据排序算法类的模板实现快速排序(提醒:点蓝字查看详情)

/**
 * 标准的快速排序
 *
 * @author TinyDolphin
 * 2017/11/13 14:20.
 */
public class Quick {

    public static void sort(Comparable[] arr) {
        shuffle(arr); // 打乱数组,避免切分不平衡,带来的低效
        sort(arr, 0, arr.length - 1);
    }

    private static void sort(Comparable[] arr, int lo, int hi) {
        if (hi <= lo) return;
        int indexJ = partition(arr, lo, hi); // 切分方法
        sort(arr, lo, indexJ - 1);
        sort(arr, indexJ + 1, hi);
    }

    // 打乱数组的方法
    private static void shuffle(Comparable[] arr) {
        int length = arr.length;
        Random random = new Random(System.currentTimeMillis());
        for (int index = 0; index < length; index++) {
            int temp = random.nextInt(length);
            exch(arr,index,temp);
        }
    }

    /**
     * 关键:切分方法
     * 该过程使得数组满足下面两个条件:
     *    ①、对于某个 indexJ、arr[indexJ] 已经排定;
     *    ②、arr[lo...indexJ-1] <= arr[indexJ] <= arr[indexJ+1...hi]
     */
    private static int partition(Comparable[] arr, int lo, int hi) {
        int indexI = lo;            // 左右扫描指针
        int indexJ = hi + 1;        
        Comparable temp = arr[lo];    //切分元素
        while (true) {
            // 从数组的左端开始,向右端扫描,直到找到一个 >= 切分元素的元素;
            while (less(arr[++indexI], temp)) {
                if (indexI == hi) break;
            }
            // 从数组的右端开始,向左端扫描,直到找到一个 <= 切分元素的元素;
            while (less(temp, arr[--indexJ])) {
                if (indexJ == lo) break;
            }
            // 指针相遇,退出循环
            if (indexI >= indexJ) break;
            // 交换他俩的位置(因为显然他俩没有被排定)
            exch(arr, indexI, indexJ);
        }
        exch(arr, lo, indexJ);      // 将 temp = arr[indexJ] 放入正确的位置
        return indexJ;             // arr[lo...indexJ-1] <= arr[indexJ] <= arr[indexJ+1...hi] 达成
    }
    ...
}

Q:打乱数组的方法(shuffle())消耗了大量的时间,这么做值得么?
A:值得,这样可以预防出现最坏情况并使时间可以预计。

实现细节必须注意

①、原地切分:如果使用一个辅助数组,我们可以很容易实现切分,但将切分后的数组复制回去的开销也许会使我们得不偿失。
②、别越界:如果切分元素是数组中最小或最大的那个元素,我们就要小心别让扫描指针跑出数组的边界。parttion() 实现可以进行明确的检测来预防。测试条件 ( indexJ==lo ) 是冗余的,因为切分元素就是 arr[lo],它不可能比自己小。数组右端也有相同的情况,他们都是可以去掉的。(练习 2.3.17)
③、保持随机性:数组元素的顺序是被打乱过的。保存随机性的另一种方法:在切分方法随机选择一个切分元素
④、终止循环:一个最常见的错误是没有考虑到数组中可能包含和切分元素的值相同的其他元素。
⑤、处理切分元素值有重复的情况:左侧扫描最好遇到 >= 切分元素值 的元素时停下,右侧扫描则是遇到 <= 切分元素值的元素时停下。(尽管存在一些等值交换,但可以避免算法的运行时间变为平方级别
⑥、终止递归:一定要保证将切分元素放入正确的位置,不然容易导致无限递归。

性能分析

最佳情况:T(n) = O(nlogn)
最差情况:T(n) = O(n²)
平均情况:T(n) = O(nlogn)

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

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

优化方案

①、切换到插入排序
对于小数组,快排比插入慢;因为递归,快排的 sort() 方法在小数组中也会调用自己。
②、三取样切分(用来选择切分元素)
使用子数组的一小部分元素的中位数来切分数组(取样大小为 3 并用大小居中的元素效果最好) || 将取样元素放在数组末尾作为去掉 partition() 中的数组边界测试。

优化代码 NO.1:插入排序+三取样切分
/**
 * 快速排序优化
 *
 * 插入排序+三取样切分
 *
 * @author TinyDolphin
 * 2017/11/14 13:41.
 */
public class QuickBar {

    private static final int CUTOFF = 8;

    public static void sort(Comparable[] arr) {
        //shuffle(arr); // 让数组成为随机数组,避免切分不平衡,带来的低效
        sort(arr, 0, arr.length - 1);
    }

    private static void sort(Comparable[] arr, int lo, int hi) {
        int n = hi - lo + 1;
        // 当子数组的长度为 8 时,调用插入排序
        if (n <= CUTOFF) {
            insertionSort(arr, lo, hi);
            return;
        }
        // 调用三取样切分
        int m = median3(arr, lo, lo + n / 2, hi);
        exch(arr, m, lo);
        int indexJ = partition(arr, lo, hi); // 切分方法
        sort(arr, lo, indexJ - 1);
        sort(arr, indexJ + 1, hi);
    }

    // 插入排序
    private static void insertionSort(Comparable[] arr, int lo, int hi) {
        for (int indexI = lo; indexI <= hi; indexI++) {
            for (int indexJ = indexI; indexJ > lo && less(arr[indexJ], arr[indexJ - 1]); indexJ--) {
                exch(arr, indexJ, indexJ - 1);
            }
        }
    }

    // 选择切分元素:取 arr[i]  arr[j]   arr[k]  三个元素值的中间元素的下标
    private static int median3(Comparable[] arr, int i, int j, int k) {
        return (less(arr[i], arr[j]) ?
                (less(arr[j], arr[k]) ? j : less(arr[i], arr[k]) ? k : i) :
                (less(arr[k], arr[j]) ? j : less(arr[k], arr[i]) ? k : i));
    }

    // 切分方法
    private static int partition(Comparable[] arr, int lo, int hi) {
        int indexI = lo;            // 左右扫描指针
        int indexJ = hi + 1;        // 切分元素
        Comparable temp = arr[lo];
        while (true) {
            // 从数组的左端开始,向右端扫描,直到找到一个 >= 切分元素的元素;
            while (less(arr[++indexI], temp)) {
                if (indexI == hi) break;
            }
            // 从数组的右端开始,向左端扫描,直到找到一个 <= 切分元素的元素;
            while (less(temp, arr[--indexJ])) {
                if (indexJ == lo) break;
            }
            // 指针相遇,退出循环
            if (indexI >= indexJ) break;
            // 交换他俩的位置(因为显然他俩没有被排定)
            exch(arr, indexI, indexJ);
        }
        exch(arr, lo, indexJ);      // 将 temp = arr[indexJ] 放入正确的位置
        return indexJ;             // arr[lo...indexJ-1] <= arr[indexJ] <= arr[indexJ+1...hi] 达成
    }
    ...
}

③、熵最优的排序
在应对大量重复元素的情况下,我们可以将数组切分为三部分,分别对应 小于、等于和大于 切分元素的数组元素。

三向切分的快速排序

Dijkstra 的解法如“三向切分的快速排序”中极为简洁的切分代码:它从左到右遍历数组一次,维护
一个指针 lt 使得 a[lo...lt-1] 中的元素都 < v;
一个指针 gt 使得 a[gt+1...hi] 中的元素都 > v ;
一个指针 i 使得 a[lt...i-1] 中的元素都 == v,a[i...gt] 中的元素都还未确定。


优化代码 NO.2:三向切分

对于存在大量重复元素的数组,这种方法比标准的快速排序的效率高得多。其他情况,可能不及优化方案 NO.1。

/**
 * 快速排序优化:三向切分(用于解决大量重复元素)
 *
 * @author TinyDolphin
 * 2017/11/14 14:12.
 */
public class Quick3way {

    public static void sort(Comparable[] arr) {
        shuffle(arr);
        sort(arr, 0, arr.length - 1);
    }

    private static void sort(Comparable[] arr, int lo, int hi) {
        if (hi <= lo) {
            return;
        }
        int lt = lo;
        int gt = hi;
        Comparable v = arr[lo];
        int i = lo;
        while (i <= gt) {
            int cmp = arr[i].compareTo(v);
            // arr[i] < v,交换 arr[lt] & arr[i],将 lt & i 加一
            if (cmp < 0) {
                exch(arr, lt++, i++);
            }
            // arr[i] > v,交换 arr[gt] & arr[i],将 gt 减一
            else if (cmp > 0) {
                exch(arr, i, gt--);
            }
            // arr[i] == v,将 i 加一
            else {
                i++;
            }
        }
        // arr[lo...lt-1] < v = arr[lt...gt] < arr[gt+1...hi]
        sort(arr, lo, lt - 1);
        sort(arr, gt + 1, hi);
    }

    // 打乱数组的方法
    private static void shuffle(Comparable[] arr) {
        int length = arr.length;
        Random random = new Random(System.currentTimeMillis());
        for (int index = 0; index < length; index++) {
            int temp = random.nextInt(length);
            exch(arr, index, temp);
        }
    }
    ...
}
优化代码 NO.3:插入排序+三取样切分+三向切分

优化了三向切分代码,加快了处理大量重复元素的速度,但是其他情况下,速度还是不及NO.1

/**
 * 快速排序优化
 *
 * 插入排序+三取样切分+三向切分
 *
 * @author TinyDolphin
 * 2017/11/14 17:11.
 */
public class Quick3wayBar {

    private static final int CUTOFF = 8;

    public static void sort(Comparable[] arr) {
        //shuffle(arr);
        sort(arr, 0, arr.length - 1);
    }

    private static void sort(Comparable[] arr, int lo, int hi) {
        int n = hi - lo + 1;
        // 当子数组的长度为 8 时,调用插入排序
        if (n <= CUTOFF) {
            insertionSort(arr, lo, hi);
            return;
        }
        // 调用三取样切分
        int m = median3(arr, lo, lo + n / 2, hi);
        exch(arr, m, lo);

        int lt = lo;
        int gt = hi;
        Comparable v = arr[lo];
        int i = lo;
        while (i <= gt) {
            int cmp = arr[i].compareTo(v);
            // arr[i] < v,交换 arr[lt] & arr[i],将 lt & i 加一
            if (cmp < 0) {
                exch(arr, lt++, i++);
            }
            // arr[i] > v,交换 arr[gt] & arr[i],将 gt 减一
            else if (cmp > 0) {
                exch(arr, i, gt--);
            }
            // arr[i] == v,将 i 加一
            else {
                i++;
            }
        }
        // arr[lo...lt-1] < v = arr[lt...gt] < arr[gt+1...hi]
        sort(arr, lo, lt - 1);
        sort(arr, gt + 1, hi);
    }

    // 插入排序
    private static void insertionSort(Comparable[] arr, int lo, int hi) {
        for (int indexI = lo; indexI <= hi; indexI++) {
            for (int indexJ = indexI; indexJ > lo && less(arr[indexJ], arr[indexJ - 1]); indexJ--) {
                exch(arr, indexJ, indexJ - 1);
            }
        }
    }

    // 取 arr[i]  arr[j]   arr[k]  三个元素值的中间元素的下标
    private static int median3(Comparable[] arr, int i, int j, int k) {
        return (less(arr[i], arr[j]) ?
                (less(arr[j], arr[k]) ? j : less(arr[i], arr[k]) ? k : i) :
                (less(arr[k], arr[j]) ? j : less(arr[k], arr[i]) ? k : i));
    }
优化代码 NO.4:快速三向切分

优化:插入排序 + 三取样切分 + Tukey's ninther + Bentley-McIlroy 三向切分

Tukey's ninther 方法选择切分元素:选择三组,每组三个元素,分别取三组元素的中位数,然后去三个中位数的中位数作为切分元素。

原理:将重复元素放置于子数组两端的方式实现一个信息量最优的排序算法。


/**
 * 三向切分-快速排序优化
 *
 * 插入排序 + 三取样切分 + Tukey's ninther + Bentley-McIlroy 三向切分
 *
 * @author TinyDolphin
 * 2017/11/15 15:16.
 */
public class QuickX {

    private static final int INSERTION_SORT_CUTOFF = 8;

    private static final int MEDIAN_OF_3_CUTOFF = 40;

    public static void sort(Comparable[] arr) {
        sort(arr, 0, arr.length - 1);
    }

    private static void sort(Comparable[] a, int lo, int hi) {
        int n = hi - lo + 1;

        // 当子数组大小 <= 8 时,切换到插入排序
        if (n <= INSERTION_SORT_CUTOFF) {
            insertionSort(a, lo, hi);
            return;
        }

        // 当子数组大小 <= 40 时,使用三取样切分(median-of-3)选择切分元素
        else if (n <= MEDIAN_OF_3_CUTOFF) {
            int m = median3(a, lo, lo + n / 2, hi);
            exch(a, m, lo);
        }

        // 当子数组大小 > 40 时,使用 Tukey's ninther 方法选择切分元素
        else {
            int eps = n / 8;
            int mid = lo + n / 2;
            int m1 = median3(a, lo, lo + eps, lo + eps + eps);
            int m2 = median3(a, mid - eps, mid, mid + eps);
            int m3 = median3(a, hi - eps - eps, hi - eps, hi);
            int ninther = median3(a, m1, m2, m3);
            exch(a, ninther, lo);
        }

        // 使用 Bentley-McIlroy 三向切分
        // 使数组 a[lo...p-1] & a[q+1...hi] == v ; a[p...i-1] < a[lo] < a[j+1...q]
        int i = lo, j = hi + 1;
        int p = lo, q = hi + 1;
        Comparable v = a[lo];
        while (true) {
            // 移动指针,使得 a[p..i-1] < a[lo] == v,直到一个 >= v 的元素a[i]
            while (less(a[++i], v))
                if (i == hi) break;
            // 移动指针,使得 a[lo] == v > a[j+1...q],直到一个 <= v 的元素a[j]
            while (less(v, a[--j]))
                if (j == lo) break;

            // 指针交叉时,刚好 a[i] == v 的情况下,交换以将 a[i] 归位
            if (i == j && eq(a[i], v))
                exch(a, ++p, i);
            // 排序完成,退出循环
            if (i >= j) break;

            // 交换 a[i] & a[j] 的值,使其归位
            exch(a, i, j);
            // 如果 a[i] == v,交换 a[p] & a[i],使其归位
            if (eq(a[i], v)) exch(a, ++p, i);
            // 如果 a[j] == v,交换 a[q] & a[i],使其归位
            if (eq(a[j], v)) exch(a, --q, j);
        }


        // 在切分循环结束后,将和 v 相等的元素交换到正确位置
        // 即使数组 a[lo...j-1] < v == a[j...i] < a[i+1...hi]
        i = j + 1;
        // 把 v == a[lo...p-1] 元素归位到 a[j...i] 中
        for (int k = lo; k <= p; k++)
            exch(a, k, j--);
        // 把 v == a[q+1...hi] 元素归位到 a[j...i] 中
        for (int k = hi; k >= q; k--)
            exch(a, k, i++);

        // 递归调用
        sort(a, lo, j);
        sort(a, i, hi);
    }


    // 插入排序
    private static void insertionSort(Comparable[] arr, int lo, int hi) {
        for (int indexI = lo; indexI <= hi; indexI++) {
            for (int indexJ = indexI; indexJ > lo && less(arr[indexJ], arr[indexJ - 1]); indexJ--) {
                exch(arr, indexJ, indexJ - 1);
            }
        }
    }

    // 取 arr[i]  arr[j]   arr[k]  三个元素值的中间元素的下标
    private static int median3(Comparable[] arr, int i, int j, int k) {
        return (less(arr[i], arr[j]) ?
                (less(arr[j], arr[k]) ? j : less(arr[i], arr[k]) ? k : i) :
                (less(arr[k], arr[j]) ? j : less(arr[k], arr[i]) ? k : i));
    }

    // 判断两个元素是否相等
    private static boolean eq(Comparable v, Comparable w) {
        return v.compareTo(w) == 0;
    }
    ...
}
优化代码 NO.5:最简单的实现之一

但是碰到大量重复元素的话,可能会变成 O(n²)

/**
 * 快速排序最简单的实现方式之一
 *
 * @author TinyDolphin
 * 2017/11/15 16:38.
 */
public class QuickKR {

    public static void sort(Comparable[] a) {
        sort(a, 0, a.length - 1);
    }

    private static void sort(Comparable[] a, int lo, int hi) {
        if (hi <= lo) return;
        exch(a, lo, (lo + hi) / 2);  // use middle element as partition
        int last = lo;
        for (int i = lo + 1; i <= hi; i++)
            if (less(a[i], a[lo])) exch(a, ++last, i);
        exch(a, lo, last);
        sort(a, lo, last-1);
        sort(a, last+1, hi);
    }
    ...
}
测试代码
    public static void main(String[] args) {
        int length = 1000000;  // 百万数据量级别
        Integer[] arr = new Integer[length];
        for (int index = 0; index < length; index++) {
            // 随机数组
            arr[index] = new Random().nextInt(length) + 1;
            // 大量重复元素的数组
            // arr[index] = new Random().nextInt(100) + 1;
        }

        long start = System.currentTimeMillis();
        sort(arr);
        long end = System.currentTimeMillis();
        System.out.println("耗费时间:" + (end - start) + "ms");
    }
测试结果
随机数组 重复数组 升序数组 降序数组
MergePlus.sort() 1127ms 827ms 60ms 401ms
Array.sort() 1706ms 1096ms 30ms 94ms
Quick 1573ms 900ms 1343ms 1242ms
QuickBar 846ms 659ms 251ms 溢出
Quick3way 2191ms 543ms 1635ms 1606ms
Quick3wayBar 1513ms 343ms 11146ms 11588ms
QuickX 1331ms 553ms 228ms 528ms
QuickKR 1325m 21270ms 347ms 423ms

Merge:归并排序
MergePlus:表示快排的优化实现
QuickBar:插入排序+三取样切分
Quick3way:三向切分
Quick3wayBar:插入排序+三取样切分+三向切分
QuickX:快速三向切分(插入排序 + 三取样切分 + Tukey's ninther + Bentley-McIlroy 三向切分)
QuickKR:快速排序的最简单实现方式之一

Q:QuickBar 排序降序数组时,为什么报
java.lang.StackOverflowError 异常?
*
A:因为调用层次过多
解决方案:排序之前调用 shuffle() 方法打乱数组即可,但是对效率有所影响。

总结

由上表可知:
①、在处理随机数组的时候,QuickBar(插入排序+三取样切分)速度较快
②、在处理大量重复元素数组的时候,Quick3wayBar(插入排序+三取样切分+三向切分)速度最快。
③、综合表现最好的是:QuickX(快速三向切分(插入排序 + 三取样切分 + Tukey's ninther + Bentley-McIlroy 三向切分)

注意:编译器默认不适用 assert 检测(但是junit测试中适用),所以要使用时要添加参数虚拟机启动参数-ea 具体添加过程,请参照eclipse 和 IDEA 设置虚拟机启动参数

推荐阅读更多精彩内容