算法:图解快速排序(Java 实现)

快速排序

快速排序的特点包括它是原地排序(只需要一个很小的辅助栈),且将长度为 N 的数组排序所需要的时间和 NlgN 成正比。时间复杂度度为 O(NlogN),空间复杂度为 O(logN)。

基本算法

快速排序是一种分治的排序算法,它将一个数组分成两个子数组,将两部分独立地排序。快速排序和归并排序是互补的:

  • 归并排序将数组分成两个子数组分别排序,并将有序的子数组归并以将整个数组排序;
  • 快速排序通过一个切分元素将数组分为两个子数组,左子数组小于等于切分元素,右子数组大于等于切分元素,再将这两个子数组排序也就将整个数组排序了。

在归并排序中,一个数组被等分为两半;而在快速排序中,切分元素的位置取决于数组的内容。快速排序的大致过程如下图所示。

算法:图解快速排序(Java 实现)_第1张图片

快速排序的实现代码:

public class QuickSort<T extends Comparable<T>> extends AbstractSort<T> {

    @Override
    public void sort(T[] arr) {
        shuffle(arr); // 打乱数组
        sort(arr, 0, arr.length - 1);
    }

    private void sort(T[] arr, int head, int tail) {
        if (head >= tail) {
            return;
        }
        int p = partition(arr, head, tail); // 切分
        sort(arr, head, p - 1); // 将左半部分 arr[head..p-1] 排序
        sort(arr, p + 1, tail); // 将右半部分 arr[p+1..tail] 排序
    }

    private void shuffle(T[] arr) {
        List<T> list = Arrays.asList(arr);
        Collections.shuffle(list);
        list.toArray(arr);
    }

    /**
     * 切分
     */
    private int partition(T[] arr, int head, int tail) {
        // 左右扫描指针
        int p1 = head, p2 = tail + 1;
        // 切分元素
        T value = arr[head];
        while (true) {
            // 左指针向右扫描,直到遇到大于等于切分元素的元素	
            while (less(arr[++p1], value) && p1 != tail) ;
            // 右指针向左扫描,直到遇到小于等于切分元素的元素	
            while (less(value, arr[--p2]) && p2 != head) ;
            if (p1 >= p2) {
            	// 左右指针相遇时,退出主循环
                break;
            }
            swap(arr, p1, p2);
        }
        swap(arr, head, p2);

        return p2;
    }
}

该方法的关键在于切分,这个过程使得数组满足下面三个条件:

  • 对于某个切分元素的下标 p, arr[p] 已经排定;
  • arr[head] 到 arr[p-1] 中的所有元素都不大于 arr[p];
  • arr[p+1] 到 arr[tail] 中的所有元素都不小于 arr[p]。

通过递归地对子数组调用切分来排序。

因为切分过程总是能排定一个元素,如果左子数组和右子数组都是有序的,那么由左子数组(有序且没有任何元素大于切分元素)、切分元素和右子数组(有序且没有任何元素小于切分元素)组成的结果数组也一定是有序的。

切分的策略是先随意地取 arr[head] 作为切分元素,即那个将会被排定的元素。然后我们从数组的左端开始向右扫描直到找到一个大于等于它的元素,再从数组的右端开始向左扫描直到找到一个小于等于它的元素,交换它们的位置。如此继续,就可以保证左指针 p1 的左侧元素都小于等于切分元素,右指针 p2 的右侧元素都大于等于切分元素。当两个指针相遇时,我们只需要将切分元素 arr[head] 和左子数组最右侧的元素 arr[p2] 交换然后返回 p2 即可。

切分方法的大致过程如下图所示(v 表示切分元素 value,lo 表示 head,hi 表示 tail,i 表示左指针 p1,j 表示右指针 p2)。

算法:图解快速排序(Java 实现)_第2张图片

切分方法的代码按照 arr[head] 的值 value 进行切分,当指针 p1 和 p2 相遇时主循环退出。在循环中,arr[p1] 小于 value 时我们增大 p1,arr[p2] 大于 value 时我们减少 p2,然后交换 arr[p1] 和 arr[p2] 来保证 p1 左侧的元素都不大于 value,p2 右侧的元素都不小于 value。当指针相遇时交换 arr[head] 和 arr[p2],切分结束。

一次主循环的切分轨迹如下图所示(v 表示切分元素 value,i 表示左指针 p1,j 表示右指针 p2)。

算法:图解快速排序(Java 实现)_第3张图片

保持随机性

数组元素的顺序是被打乱过的,这对于预测算法的运行时间很重要。保持随机性的另一种方法是在 partition() 中随机选择一个切分元素。

性能特点

快速排序的一个速度优势在于它的比较次数很少。排序效率最终还是依赖切分数组的效果,而这依赖于切分元素的值。快速排序最好的情况是每次都正好将数组对半分,这样递归调用次数才是最少的。

快速排序的最坏的情况,例如,第一次从最小的元素切分,第二次从第二小的元素切分,如此这般,每次调用只会移除一个元素。这会导致一个大子数组需要切分很多次。我们要在快速排序前将数组随机排序的主要原因就是要避免这种情况。它能够使产生糟糕的切分可能性降到极低。

算法:图解快速排序(Java 实现)_第4张图片

算法改进

切换到插入排序

和大多数递归排序算法一样,改进快速排序性能的一个简单办法基于以下两点:

  • 对于小数组,快速排序比插入排序慢;
  • 因为递归,快速排序的 sort() 方法在小数组中也会调用自己。

因此,在排序小数组时应该切换到插入排序。简单的将 sort() 中的语句

if (head >= tail) { return; }

替换成下面这条语句来对小数组使用插入排序:

if (head + M >= tail) { Insertion.sort(arr, head, tail); return; }

转换参数 M 的最佳值是和系统相关的,但是 5 ~ 15 之间的任意值在大多数情况下都能令人满意。

参考资料

  • 《算法(第4版)》
  • CS-Notes/算法 - 排序

你可能感兴趣的:(算法)