快速排序(Quicksort)详解

引言

这篇文章是我在2015年写的,当时正在看算法导论中关于快排的部分,所以写下文章总结一下当时对快排的理解。这几天我一直在review一下我先前写的blog,发现有些地方写的不算太好,还有一些错误的地方。今天我从新修改一下这篇文章,把错误的地方修正过来,并补充一些新的内容。java

初识Quicksort

Quicksort是一个分而治之的算法,它根据主元把一个大数组分红2个小数组:其中1个数组的元素要比主元小,另外一个要比主元大。Quicksort至今依然是一个经常使用的排序算法,若是算法实现好的状况下,它的速度要比merge sort 和 heapsort快2到3倍。一个有效实现地Quicksort 并非一个stable sort,即相等元素的相对顺序不能被保证。Quicksort 也能够在一个数组上进行原址排序。数学分析代表,quicksort排序n个元素平均须要 O ( n l o g n ) 比较,在最坏的状况下,它须要 O ( n 2 ) 次比较,虽然这样的行为不常见。web

Quicksort的步骤

Quicksort主要包含如下3步:算法

  1. 从数组中取出一个元素,叫作主元(pivot)
  2. 重排序数组,使得全部小于pivot的元素在它前面,全部大于pivot的元素在它后面,等于pivot的元素放在哪面都行。这样的划分之后,pivot的位置已经排好了。这个过程叫作partition操做
  3. 递归地应用步骤2到小于pivot的子数组和大于pivot的子数组

在上面的步骤中,递归的base case是数组的大小为0或1,由于这样的数组已经有序,不须要再排序。咱们有不少方式来选择pivot,不一样地方式会对算法的性能有很大地影响。下图是我从普林斯顿算法书的官方网站上找到的,它演示了 Quicksort 的排序过程,你们参考一下。编程

Quicksort

Lomuto partition scheme

因为编程珠玑和算法导论这2本颇有名的书中介绍的都是这种 partition 模式,所以它被你们所熟知。这个 partition 模式选择数组中的最后一个元素做为 pivot. As this scheme is more compact and easy to understand, it is frequently used in introductory material, although it is less efficient than Hoare’s original scheme. This scheme degrades to O ( n 2 ) when the array is already in order. 关于这种模式的 pseudocode 以下:数组

algorithm quicksort(A, lo, hi) is
    if lo < hi then
        p := partition(A, lo, hi)
        quicksort(A, lo, p - 1 )
        quicksort(A, p + 1, hi)

algorithm partition(A, lo, hi) is
    pivot := A[hi]
    i := lo - 1    
    for j := lo to hi - 1 do
        if A[j] < pivot then
            i := i + 1
            swap A[i] with A[j]
    if A[hi] < A[i + 1] then
        swap A[i + 1] with A[hi]
    return i + 1

Sorting the entire array is accomplished by quicksort(A, 0, length(A) - 1).less

Hoare partition scheme

Hoare’s scheme is more efficient than Lomuto’s partition scheme because it does three times fewer swaps on average, and it creates efficient partitions even when all values are equal. Like Lomuto’s partition scheme, Hoare partitioning also causes Quicksort to degrades to O ( n 2 ) when the input array is already sorted; it also doesn’t produce a stable sort. 下面是我从算法导论中习题7-1中截取下来的 pseudocode.dom

Hoare partition scheme

下面我来回答一下这个习题!ide

关于这个 partition 的循环不变式为:svg

  • 对于任意下标a, p a < i , 有 A [ a ] x
  • 对于任意下标b, j < b r , 有 A [ b ] x

一、When Hoare-Partition terminates, it returns a value j such that p j < r 性能

从程序能够看出,当 while 循环开始时,j = j - 1,此时 j = r 了; 紧接着程序执行,i=i+1=p,循环终止,因为A[p] 是 pivot,因此此时A[i]=x,这就又给了j循环一次的机会,所以 j < r . 根据循环不变式, p a < i 的元素都是小于或等于x的,所以j最多到p必定会终止。

二、The indices i and j are such that we never access an element of A outside the subarray A[p … r]

在问题1中,已经证实了 p j < r . 相似的逻辑,咱们能够证实 p i r

原始版本Java实现及其性能分析

代码以下:

public static void quickSort(int[] a, int lo, int hi) {
        if (lo < hi) {
            int pivot = partition(a, lo, hi);
            quickSort(a, lo, pivot - 1);
            quickSort(a, pivot + 1, hi);
        }
    }

public static int partition(int[] a, int lo, int hi) {
        int x = a[lo];
        int j = hi + 1;

        for (int i = hi; i > lo; i--) {
            if (a[i] >= x) {
                j--;
                swap(a, i, j);
            }
        }
        swap(a, lo, j - 1);
        return j - 1;
    }

算法时间复杂度:上面算法若是在最坏状况(数组中的元素已经有序)下,时间复杂度为Θ(n^2);指望运行时间为Θ(nlogn)。

算法空间复杂度:因为快速排序为原址排序,因此其主要的空间复杂度来自于递归调用,每一次递归调用将在调用栈上建立一个栈帧。假设每一次传递数组的信息是采用指针的方式,那么每一次递归调用能够认为其空间复杂度为O(1)。那么算法在最坏状况下,有Θ(n)次递归调用,因此此时其空间复杂度为Θ(n);若是算法在平均状况下,其空间复杂度为O(logn)。

随机化版本Java实现及其性能分析

代码以下:

public static int randomized_partition(int[] a, int lo, int hi) {
        int i = new Random().nextInt(hi - lo + 1) + lo;
        swap(a, lo, i);
        return partition(a, lo, hi);
    }

这个版本的实现只是对原始版本的代码稍做改动,只不过将随机在数组中选择一个主元,并与数组中的第一个元素进行交换。上述代码中的partition方法与原始版本相同。

算法时间复杂度:虽然咱们对程序在最坏状况下的运行时间感兴趣,可是在随机化版本中,它并无改变最坏状况下的运行时间,它只是减小了出现最坏状况的可能性。所以,咱们只分析算法的指望运行时间,而不是其最坏运行时间。它的指望运行时间是Θ(nlogn)。

算法空间复杂度:因为这个版本的算法只是减小最坏状况出现的可能性,因此其空间复杂度与原始版本的分析一致。

Hoare版本Java实现及其性能分析

代码以下:

public static void quickSort(int[] a, int lo, int hi) {
        if (lo < hi) {
            int pivot = hoare_partition(a, lo, hi);
            quickSort(a, lo, pivot);
            quickSort(a, pivot + 1, hi);
        }
}

public static int hoare_partition(int[] a, int lo, int hi) {
        int x = a[lo];
        int i = lo - 1;
        int j = hi + 1;

        while (true) {
            do
                j--;
            while (a[j] > x);

            do
                i++;
            while (a[i] < x);

            if (i < j)
                swap(a, i, j);
            else
                return j;
        }
}

这个版本中的quickSort方法,第一行递归不是pivot - 1而是pivot。这是由于当hoare_partition结束时,只能保证a[p…j]中的每个元素都小于或等于a[j+1…r]中的元素,其所选取的主元并无就位。

这个版本的算法在含有许多重复元素的状况下,能够避免其出现最坏状况的划分。

算法时间复杂度:因为这个版本的算法并无杜绝最坏状况的出现,因此分析同上面两个版本。

算法空间复杂度:因为这个版本的算法并无杜绝最坏状况的出现,因此分析同上面两个版本。

经过尾递归改变最坏状况下的空间复杂度

咱们知道,对于任何一种算法改进的版原本说,都不可能彻底避免最坏状况的出现,它们只是减少其出现的机率。可是改变最坏状况下的空间复杂度是可能作到的。咱们经过递归调用元素少的那部分,对于元素多的那部分,咱们改写尾递归。只要元素少的那部分老是小于或等于输入规模的一半,那么递归调用至多为O(logn)。

代码以下:

public static void tailRecursiveQuickSort(int[] a, int lo, int hi) {
        while (lo < hi) {
            int pivot = partition(a, lo, hi);//partition方法与上述版本相同
            if ( pivot - lo < hi - pivot ) {
                quickSort(a, lo, pivot - 1);
                lo = pivot + 1;
            } else {
                quickSort(a, pivot + 1, hi);
                hi = pivot - 1;
            }
        }
}

算法时间复杂度:因为这个版本的算法并无杜绝最坏状况的出现,因此分析同上面两个版本。

算法空间复杂度:这个版本的算法其递归深度至多为logn,因此其空间复杂度为O(logn)