快速排序及其优化

一、引言

顾名思义,快速排序是实践中的一种快速排序算法,在C++或对Java基础类型的排序中特别有用。它的平均运行时间是O(NlogN);但最坏情形性能为O(N2)。我会先介绍快速排序过程,再讨论如何优化。

二、快速排序(quicksort)

  • 算法思想

采用分治法,将数组分为两部分,并递归调用。将数组S排序的快排过程

  1. 如果S中元素个数是0或1,则直接返回;
  2. 取S中任一元素v,称之为枢纽元(pivot);【枢纽元的选取策略很重要,下面会详述
  3. 将S-{v}(S中除了枢纽元中的其余元素)划分为两个不相交的集合S1和S2,S1集合中的所有元素小于等于枢纽元v,S2中的所有元素大于等于枢纽元;
  4. 返回quicksort(S1),枢纽元v,quicksort(S1</sub2)。
  • 枢纽元的选取策略
  1. 取第一个或者最后一个:简单但很傻的选择(啊,9龙,上面这图???)。当输入序列是升序或者降序时,这时候就会导致S1集合为空,除枢纽元外所有元素在S2集合,这种做法,最坏时间复杂度为O(N2)。
  2. 随机选择:这是比较安全的做法。除非随机数发生器出现错误,并且连续产生劣质分割的概率比较低。但随机数生成开销较大,这样就增加了运行时间。
  3. 三数中值分割法:一组序列的中值(中位数)是枢纽元最好的选择(因为可以将序列均分为两个子序列,归并排序告诉我们,这时候是O(NlogN);但要计算一组数组的中位数就比较耗时,会减慢快排的效率。但可以通过计算数组的第一个,中间位置,最后一个元素的中值来代替。比如序列:[8,1,4,9,6,3,5,2,7,0]。第一个元素是8,中间(left+right)/2(向下取整)元素为6,最后一个元素为0。所以中位数是6,即枢纽元是6。显然使用三数分割法消除了预排序输入的坏情形,并且实际减少了14%的比较。
  • 快排过程
  1. 将枢纽元与数组最后一个元素调换,使枢纽元离开要被分割的数据段;

  2. 初始化两个索引left和right,分别指向数组第一个与倒数第二个元素;

  3. 如果left索引指向的元素小于枢纽元,则left++;否则,left停止。right索引指向的元素大于枢纽元,right--;否则,right停止。

  4. 如果left<right,则交换两个元素,循环继续3,4步骤;否则跳出循环,将left对应的元素与枢纽元交换(这时候完成了分割)。递归调用这两个子序列。

    假设所有元素互异(即都不相等)。下面会说重复元素怎么处理。

    接下来要做的就是将小于枢纽元的元素移到数组左边,大于枢纽元的元素移到数组右边。

    当left在right的左边时,我们将left右移,移过那些小于枢纽元的元素,并将right左移,移过那些大于枢纽元的元素。当left和right停止时,left指向一个大于枢纽元的元素,right指向一个小于枢纽元的元素,如果left<right,则将这两个元素交换。这样是将一个大于枢纽元的元素推向右边而把小于枢纽元的元素推向左边。我们来图示过程:left不动,而right左移一个位置,如下图:

    我们交换left与right指向的元素,重复这个过程,直到left>right。

    至此,我们可以看到,left左边的元素都小于枢纽元,右边的元素都大于枢纽元。我们继续递归左右序列,最终可完成排序。

    上面我们假设的是元素互异,下面我们讨论重复元素的处理情况。

  • 重复元素的处理:简单说是遇到与枢纽元相等的元素时,左右索引需要停止吗?
  1. 如果只有其中一个停止:这包含两种,如果只停止左、或者右索引,这将导致等于枢纽元的元素都移动到一个集合中。考虑序列所有元素都是重复元素,会是最坏情形O(N2)。
  2. 如果都不停止:这需要防范左右索引越界,并且不用交换元素。但正如上面图示的正确过程是,枢纽元需要与left索引指向的元素进行交换。还是考虑所有元素相同的情况,这会导致序列全分到左边,这样还是最坏情形O(N2)
  3. 都停止:还是考虑元素全都相等的情况,这样看似会进行很多次“无意义”的交换;但正面的效果却是,left与right交错是发生在中间位置,这时刚好将序列均分为两个子序列,还是归并排序的原理,这是O(NlogN)。我们分析指出,只有这种情况可以避免二次方。

在大规模输入量中,重复元素还是挺多的。考虑能将这些重复元素进行有效排序,还是很重要。

快速排序真的快吗?其实也不一定,对于小数组(N<=20)的输入序列,快速排序不如插入排序并且在我们上面的优化中,采用三数中值分割时,递归得到的结果可以是只有一个,或者两个元素,这时会有错误。所以,继续优化是将小的序列用插入排序代替,这会减少大约15%的运行时间。较好的截止范围是10(其实5-20产生的效果差不多)。

对于三数中值分割还可以进行优化:假设输入序列为a,则选择a[left],a[center],a[right],选择出枢纽值,并将最小,与最大值分别放到a[left],a[right],将枢纽值放到a[right-1]处,这样放置也是正确的位置,并且可以防止right向右进行比较时不会越界;这样左右起始位置就是left+1,right-2。

三、优化汇总的java实现快速排序:

public class Quicksort {
    /**
     * 截止范围
     */

    private static final int CUTOFF = 10;

    public static void main(String[] args) {
        Integer[] a = {8149635270};
        System.out.println("快速排序前:" + Arrays.toString(a));
        quicksort(a);
        System.out.println("快速排序后:" + Arrays.toString(a));
    }

    public static <T extends Comparable<? super T>> void quicksort(T[] a) {
        quicksort(a, 0, a.length - 1);
    }

    private static <T extends Comparable<? super T>> void quicksort(T[] a, int left, int right) {
        if (left + CUTOFF <= right) {
            //三数中值分割法获取枢纽元
            T pivot = median3(a, left, right);

            // 开始分割序列
            int i = left, j = right - 1;
            for (; ; ) {
                while (a[++i].compareTo(pivot) < 0) {
                }
                while (a[--j].compareTo(pivot) > 0) {
                }
                if (i < j) {
                    swapReferences(a, i, j);
                } else {
                    break;
                }
            }
            //将枢纽元与位置i的元素交换位置
            swapReferences(a, i, right - 1);
            //排序小于枢纽元的序列
            quicksort(a, left, i - 1);
            //排序大于枢纽元的序列
            quicksort(a, i + 1, right);
        } else {
            //插入排序
            insertionSort(a, left, right);
        }
    }

    private static <T extends Comparable<? super T>> median3(T[] a, int left, int right) {
        int center = (left + right) / 2;
        if (a[center].compareTo(a[left]) < 0) {

            swapReferences(a, left, center);
        }
        if (a[right].compareTo(a[left]) < 0) {
            swapReferences(a, left, right);
        }
        if (a[right].compareTo(a[center]) < 0) {
            swapReferences(a, center, right);
        }
        // 将枢纽元放置到right-1位置
        swapReferences(a, center, right - 1);
        return a[right - 1];
    }

    public static <T> void swapReferences(T[] a, int index1, int index2) {
        T tmp = a[index1];
        a[index1] = a[index2];
        a[index2] = tmp;
    }

    private static <T extends Comparable<? super T>> void insertionSort(T[] a, int left, int right) {
        for (int p = left + 1; p <= right; p++) {
            T tmp = a[p];
            int j;

            for (j = p; j > left && tmp.compareTo(a[j - 1]) < 0; j--) {
                a[j] = a[j - 1];
            }

            a[j] = tmp;
        }
    }

}
//输出结果
//快速排序前:[8, 1, 4, 9, 6, 3, 5, 2, 7, 0]
//快速排序后:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

四、快速排序分析

  1. 最坏时间复杂度:即元素都分到一个子序列,另一个子序列为空的情况,时间复杂度为O(N2)。
  2. 最好时间复杂度:即序列是均分为两个子序列,时间复杂度是O(NlogN),分析与归并排序差不多。
  3. 平均时间复杂度:O(NlogN)
  4. 空间复杂度:O(logN)

五、总结

本篇从如何较好选择枢纽元,分析重复元素的处理及递归分成小数组时更换为插入排序三个方面进行快速排序的优化,系统全面详述了快速排序原理、过程及其优化。快速排序以平均时间O(NlogN)进行,是java中基础类型使用的排序算法。可以去看一下Arrays.sort方法。到这里,我就要回过头去完善求解topK问题了,可以利用快速排序的思想,达到平均O(N)求解topK。

觉得可以的小伙伴们点个推荐或小赞支持啊。

posted @ 2019-05-05 09:47  9龙  阅读(4614)  评论(4编辑  收藏  举报