算法分析(5)——详解Quicksort算法

284 阅读6分钟

一、What is Quicksort

开始分析之前,我们先看一个动图便于我们理解

Quicksort,与归并排序一样使用了divide-and-conquer(分治策略)。我们先用分治算法的范式来进行初步的简单分析:

Divide: 将一个数组A[p…r]分区为两部分:A[p…q-1]与A[q+1…r],子数组A[p…q-1]的每个元素都<=A[q], A[q+1…r]的每个元素都>=A[q]。

Conquer: 对两个子数组递归调用Quicksort进行排序。

Combine: 在之前的两个步骤中,我们将原数组分为两个子数组与一个枢纽元素A[q],子数组 A[p…q-1]的每个元素 <= A[q] <= A[q+1…r]的每个元素 ,且在第二个步骤中,我们对两个子数组进行了排序,因此,在第三步的合并中,我们不需要进行任何操作。

对比之前的归并算法,QuickSort的主要区别之一就在于它不用进行第三步的合并操作。

ps:因为在第一步已经进行了大小区间的划分:A[p…q-1] <=A[q]<=A[q+1…r]

伪代码如下:

在Quicksort中,一般来说,我们总是选择x=A[r]作为主元,并围绕它来划分A[p...r]。随着程序的执行数组被划分为4个(可能为空的)区域。在第一轮迭代之前,以及每一次迭代后,这四个区域中的每一个区域都满足一定的性质: 对任意数组下标k,有:

  1. 若p<= k <= i,则A[k] <= x
  2. 若i+1 <= k <= j-1,则A[k] > x
  3. 若k == r,则A[k] == x
  4. 若j <= k < r,对应位置的值与主元之间不存在特定的大小关系 这四个区域满足的性质,就是快速排序的循环不变量

对这段伪代码进行分析:

Initialization: 初始化循环迭代,令I = p-1,j = p,因为没有处于p和i之间的值,也没有处于i+1与j-1之间的值,循环不变量的前两个条件满足,而第一行的赋值满足第三个条件

Maintenance: 主要步骤,选择x=A[r]作为主元,j往后移,当j的值小于x时,i增加1,交换A[i]与A[j],直到j=q-1。这个时候,A[p…i]<=x,A[i+1…j]>=x。

Termination: 最后,我们将数组分成了三集合:小于等于x的,大于x的,以及包含x的单粒子集。

最后两行就是用大于x的最左边的元素交换主元。现在就满足了分治算法的第一步:divide。

A[q] is strictly less than every element of A[q+1…r].

下面的示例图可以更好的帮助理解:

二、Performance of Quicksort

Quicksort的运行时间取决于分区是否平衡,而分区的平衡又取决于主元的选择。 如果分区是平衡的,那么算法的运行效率近似于Merge Sort,相反如果极不平衡,效率近似于Insert Sort。

下面我们分最坏与最坏讨论:

Worst-case partitioning

最坏的情况下,数组被分区为两个子问题:一个有n-1个元素,一个有0个元素。我们假设每次递归调用都进行的是不平衡的分区,分区花费了O(n)的时间,返回大小为0的数组,T(0)=O(1)。因此,它的重复运行时间为:

我们可以用替换法得出时间复杂度为O(n2),具体分析下文将进行讲解

Best-case partitioning

在大多数spilt可能下,分区划分为两个子问题,每个子问题大小不超过n/2。在这种情况下,近似于Merge Sort,运行时间为

Balanced partitioning

平均情况下的运行时间更接近与Base case,而不是Worse case,当数组按照9:1的比例分割时:

乍一看很不平衡,循环的时间为:

但实际上,时间复杂度仍然为O(n lgn),因为每一层花费的时间为cn直到边界log10n=O(lgn),之后,每层递归最多花费cn时间,在深度为log 10/9n=O(lgn)处终止。 这与在中间位置分割的时间复杂度一样。 以此类推,不管以何种比例风格,时间复杂度都为O(n lgn)

"The behavior of quicksort depends on the relative ordering of the values in the array elements given as the input "

但是在工程中提出不会按照理想情况进行,我们应当结合Worse-case与Base-case来分析:

"At the root of the tree,the cost is n for partitioning, and the subarrays produced have sizes n- 1 and 0:the worst case.

At the next level, the subarray of size n -1 undergoes best-casepartitioning into subarrays of size(n – 1)/=2 - 1 and(n - 1)/=2."

这种混合模式将数组分为了大小为0,(n-1)/2-1和(n-1)/2的三种,分区成本为:O(n)+O(n-1)=O(n)。

也就是说,一个单一级别的分区可以产生两个大小为n/2的子数组,代价是O(n)。然而后者时平衡的。直观的说,worse-case可以被吸收到base-case的O(n)成本之中,得到的分割就是好的。因此,当worse与base交替运行时,Quicksort的运行时间就类似于base-case拆分单独的运行时间:结果仍然是O(nlgn)。唯一的区别就是O的符号隐藏了一个较大的常量。

三、A randomized version of quicksort

此前,我们讨论的Quick sort都是基于主元选取的最后一位元素,这样的分析难免让人无法信服,因此,为了得到更为普遍的结论,我们在Quick sort的每次迭代中都随机选取主元进行分析。

伪代码如下:

实际上,random-version 只是在我们进行Quick sort之前进行一次随机选取,实际上并没有影响到运行效率,时间复杂度不变。

四、Worst-case analysis

我们知道,当我们使用quick sort进行分区时,我们会分为两个区间,大小处于0-(n-1),因此,花费的时间如下:

使用替代法证明: q位于0-(n-1),因为划分的总大小为n-1,我们猜测T(n)<=cn2,因此可以得到:

又因为:

所以:

因此,时间复杂度为O(n2)。

五、代码示例

import java.util.Arrays;
public class QuickSort {

    public static void main(String[] args) {
        int array[]={32, 12, 7, 78, 23, 45};
        quickSort(array,0,array.length-1);
        System.out.println(Arrays.toString(array));
    }
    public static void quickSort(int array[],int left,int right)
    {
        if(left>=right)
        {
            return ;
        }
        int i=left;
        int j=right;
        int key=array[left];
        while(i<j)
        {
            while(i<j&&array[j]>key)
            {
                j--;
            }
            array[i]=array[j];
            //从后往前找到第一个比key小的数与array[i]交换;
            while(i<j&&array[i]<key)
            {
                i++;
            }
            array[j]=array[i];
            //从前往后找到第一个比key大的数与array[j]交换;
        }
        array[i]=key;
        //一趟快排之后已经将key的位置找到。
        quickSort(array,left,i-1);
        //对key左边的进行排序
        quickSort(array,i+1,right);
        //对key右边的进行排序
    }
}

六、总结

设要排序的数组是A[0]……A[N-1],首先任意选取一个数据(通常选用数组的第一个数)作为关键数据,然后将所有比它小的数都放到它左边,所有比它大的数都放到它右边,这个过程称为一趟快速排序。值得注意的是,快速排序不是一种稳定的排序算法,也就是说,多个相同的值的相对位置也许会在算法结束时产生变动。 快速排序的算法是

  1. 设置两个变量i、j,排序开始的时候:i=0,j=N-1;
  2. 以第一个数组元素作为关键数据,赋值给key,即key=A[0];
  3. 从j开始向前搜索,即由后开始向前搜索(j--),找到第一个小于key的值A[j],将A[j]和A[i]的值交换;
  4. 从i开始向后搜索,即由前开始向后搜索(i++),找到第一个大于key的A[i],将A[i]和A[j]的值交换;
  5. 重复第3、4步,直到i==j; (3,4步中,没找到符合条件的值,即3中A[j]不小于key,4中A[i]不大于key的时候改变j、i的值,使得j=j-1,i=i+1,直至找到为止。找到符合条件的值,进行交换的时候i, j指针位置不变。另外,i==j这一过程一定正好是i+或j-完成的时候,此时令循环结束)。

Quicksort是一种不稳定的算法,在经过排序后,可能会对相同元素的位置造成改变,但它基本上被认为是相同数量级中所有排序算法中平均性能最好的。