算法导论-上课笔记3:快速排序与插入排序


1 快速排序

1.1 快速排序的描述

与归并排序一样,快速排序也使用了分治的思想。下面是对子数组A[p…r]进行快速排序的三步分治过程:

(1)分解:数组A[p…r]被划分为两个子数组A[p…q-1]和A[q+1…r],其中这两个子数组可能为空。在两个子数组中,A[p…q-1]中的每一个元素都小于等于A[q],而A[q]也小于等于A[q+1…r]中的每个元素。其中,计算下标q也是划分过程的一部分。

(2)解决:通过递归调用快速排序,对子数组A[p…q-1]和A[q+1…r]进行排序。

(3)合并:因为子数组都是就地排序的,所以不需要合并操作,即数组A[p…r]已经有序。

实现快速排序的伪代码如下:

QUICKSORT(A,p,r)
    if p<r
        q=PARTITION(A,p,r)
        QUICKSORT(A,p,q-1)
        QUICKSORT(A,q+1,r)

为了将数组A的全部元素排序,应该调用的是QUICKSORT(A,1,length[A])。

快排的关键是PARTITION过程,它实现了对子数组A[p…r]的就地重排。PARTITION的伪代码如下:

PARTITION(A,p,r)
    x=A[r]
    i=p-1
    for j=p to r-1
        if A[j]<=x
            i++
            exchange A[i] with A[j]
    exchange A[i+1] with A[r]
    return i+1

PARTITION总是选择x=A[r]作为主元(pivot element),并围绕它来划分子数组A[p…r]。随着程序的执行,数组被划分成4个(可能有空的)区域。在第4-7行的for循环的每一轮迭代的开始,每一个区域都满足一定的性质,如下图所示:
在这里插入图片描述
在子数组A[p…r]上,PARTITION维护了4个区域:① A[p…i]区间内的所有值都小于等于x;② A[i+1…j-1]区间内的所有值都大于x;③ A[r]=x;④ 子数组A[j…r-1]中的值与主元x=A[r]的大小关系不确定。

下图是PARTITION在一个包含8个元素的数组上进行操作的过程:
在这里插入图片描述
A[r]是主元x。浅阴影部分的数组元素都在划分的第一部分,其值都不大于x。深阴影部分的元素都在划分的第二部分,其值都大于x。无阴影的元素则还未分入这两个部分中的任意一个。最后的白色元素就是主元x。其中:

(a)初始的数组和变量设置。数组元素均未被放入前两个部分中的任何一个。

(b)2与它自身进行交换,并被放入了元素值较小的那个部分。

(c)-(d)8和7被添加到元素值较大的那个部分中。

(e)1和8进行交换,数值较小的部分规模增加。

(f)数值3和7进行交换,数值较小的部分规模增加。

(g)-(h)5和6被包含进较大部分,循环结束。

(i)在伪代码第8-9行中,主元被交换,这样主元就位于两个部分之间。

将以下性质作为循环不变量——在PARTITION伪代码第4-7行循环体的每一轮迭代开始时,对于任意数组下标k,有:

1、若p≤k≤i,则A[k]≤x。

2、若i+1≤k≤j-1,则A[k]>x。

3、若k=r,则A[k]=x。

上述三种情况没有覆盖下标j到r-1,对应位置的值与主元之间也不存在特定的大小关系。下面证明上述循环不变量在第一轮迭代之前是成立的,并且在每一轮迭代后仍然都成立,在循环结束时,该循环不变量还可以为证明正确性提供有用的性质:

1、初始化:在循环的第一轮迭代开始之前,i=p-1和j=p。因为在p和i之间、i+1和j-1之间都不存在值,所以循环不变量的前两个条件显然都满足。伪代码第2行中的赋值操作满足了第三个条件。

2、保持:如下图所示:
在这里插入图片描述
根据伪代码第5行中条件判断的不同结果,需要考虑两种情况:

(1)图(a)显示当A[j]>x时的情况:循环体的唯一操作是j的值加1。在j值增加后,对A[j-1],条件2成立,且所有其他项都保持不变。

(2)图(b)显示当A[j]≤x时的情况:将i值加1,交换A[i]和A[j],再将j值加1。因为进行了交换,现在有A[i]≤x,所以条件1得到满足。根据循环不变量,被交换进A[j-1]的值总是大于x的,即A[j-1]>x。

3、终止:终止时j=r。现在已经将数组中的所有元素划分成了三个集合:① 包含了所有小于等于x的元素的集合;② 包含了所有大于x的元素的集合;③ 只有一个元素x的集合。

在PARTITION伪代码的最后两行中,通过将主元与最左的大于x的元素进行交换,就可以将主元移到它在数组中的正确位置上,并返回主元的新下标i+1。此时,PARTITION的输出满足划分步骤规定的条件。PARTITION在子数组A[p…r]上的时间复杂度是Θ(n),其中n=r-p+1。

1.2 快速排序性能的非形式化分析

快速排序的运行时间依赖于划分是否平衡,而平衡与否又依赖于用于划分的元素。如果划分是平衡的,那么快速排序算法性能与归并排序一样。如果划分是不平衡的,那么快速排序的性能就接近于插入排序了。下面给出划分为平衡或不平衡时快速排序QUICKSORT性能的非形式化的分析。

1.2.1 最坏情况划分

当划分产生的两个子问题分别包含了n-1个元素和0个元素时,快速排序的最坏情况就会发生。不妨假设算法的每一次递归调用中都出现了这种不平衡划分。划分操作的时间复杂度是Θ(n)。由于对一个大小为0的数组进行递归调用会直接返回,因此T(0)=Θ(1),于是QUICKSORT算法运行时间的递归式可以表示为:

T(n)=T(n-1)+T(0)+Θ(n)=T(n-1)+Θ(n)

从直观上来看,每一层递归的代价可以被累加起来,从而得到一个算术级数:
在这里插入图片描述
其结果为Θ(n2),即T(n)=T(n-1)+Θ(n)=Θ(n2)。

因此,如果在算法的每一层递归上,划分都是最大程度不平衡的,那么算法的时间复杂度就是Θ(n2)。也就是说,在最坏情况下,快速排序算法的运行时间并不比插入排序更好。此外,当输入数组已经完全有序时,快速排序的时间复杂度仍然为Θ(n2)。而在同样情况下,插入排序的时间复杂度为O(n)。

1.2.2 最好情况划分

在可能的最平衡的划分中,PARTITION得到的两个子问题的规模都不大于n/2。这是因为其中一个子问题的规模为⌊n/2⌋,而另一个子问题的规模为⌈n/2⌉-1。在这种情况下,快速排序的性能非常好。此时,算法运行时间的递归式为:

T(n)=2T(n/2)+Θ(n)

上式忽略了一些余项以及减1操作的影响。根据主方法的情况2(《算法导论》第三版P54最上面),上述递归式的解为T(n)=Θ(n·lgn)。通过在每一层递归中都平衡划分子数组,可以得到一个渐近时间上更快的算法。

1.2.3 平衡的划分

快速排序的平均运行时间更接近于最好情况,而非最坏情况。理解这一点的关键是理解划分的平衡性是如何反映到描述运行时间的递归式上的。

假设划分算法总是产生9:1的划分,乍一看,这种划分是很不平衡的。此时,我们得到的快速排序时间复杂度的递归式为:

T(n)=T(9n/10)+T(n/10)+cn

上面显式地写出了Θ(n)项中所隐含的常数c。下图显示了这一递归调用所对应的递归树:
在这里插入图片描述
上图中,每个结点的值表示子问题的规模,每一层的代价显示在最右边。每一层的代价包含了Θ(n)项中隐含的常数c。树中每一层的代价都是cn,直到在深度log10n=Θ(lgn)处达到递归的边界条件时为止,之后每层代价至多为cn。递归在深度为log10/9n=Θ(lgn)处终止。因此,快速排序的总代价为O(n·lgn)。因此,即使在递归的每一层上都是9:1的划分,直观上看起来非常不平衡,但快速排序的运行时间是O(n·lgn),与恰好在中间划分的渐近运行时间是一样的。实际上,即使是99:1的划分,其时间复杂度仍然是O(n·lgn)。事实上,任何一种常数比例的划分都会产生深度为Θ(lgn)的递归树,其中每一层的时间代价都是O(n)。因此,只要划分是常数比例的,算法的运行时间总是O(n·lgn)。

1.2.4 对于平均情况的直观观察

为了对快速排序的各种随机情况有一个清楚的认识,现在对遇到各种输入的出现频率做出假设。快速排序的行为依赖于输入数组中元素的值的相对顺序,而不是某些特定值本身。假设输入数据的所有排列都是等概率的,当对一个随机输入的数组运行快速排序时,想要像上面非形式化分析中所假设的那样,在每一层上都有同样的划分是不太可能的,但是可以假设某些划分会比较平衡,而另一些则会很不平衡。

在平均情况下,PARTITION所产生的划分同时混合有“好”和“差”的划分。此时,在与PARTITION平均情况执行过程所对应的递归树中,好和差的划分是随机分布的。基于直觉,假设好和差的划分交替出现在树的各层上,并且好的划分是最好情况划分,而差的划分是最坏情况划分。下图显示了递归树的连续两层上的划分情况:
在这里插入图片描述
在根结点处,划分的代价为n,划分产生的两个子数组的大小为n-1和0,即最坏情况。在下一层上,大小为n-1的子数组按最好情况划分成大小分别为(n-1)/2-1和(n-1)/2的子数组。在这里,假设大小为0的子数组的边界条件代价为1。在一个差的划分后面接着一个好的划分,这种组合产生出三个子数组,大小分别为0、(n-1)/2-1和(n-1)/2。这一组合的划分代价为Θ(n)+Θ(n-1)=Θ(n)。该代价并不比下图中的更差:
在这里插入图片描述
上图中,一层划分就产生出大小为(n-1)/2的两个子数组,划分代价为Θ(n)。从直观上看,较差划分的代价Θ(n-1)可以被吸收到较好划分的代价Θ(n)中去,而得到的划分结果也是好的。因此,当好和差的划分交替出现时,快速排序的时间复杂度与全是好的划分时一样,仍然是O(n·lgn),其区别只是O符号中隐含的常数因子要比Θ的因子略大一些。

1.3 快速排序的随机化版本

可在算法中引入随机性,从而使得算法对于所有的输入都能获得较好的期望性能。采用随机抽样(random sampling),可使得分析变得简单。与始终采用A[r]作为主元的方法不同,随机抽样是从子数组A[p…r]中随机选择一个元素作为主元。为达到这一目的,首先将A[r]与从A[p…r]中随机选出的一个元素交换。通过对序列p,…,r的随机抽样,可保证主元元素x=A[r]是等概率地从子数组的r-p+1个元素中选取的。因为主元元素是随机选取的,故在平均情况下,对输入数组的划分是比较均衡的。

在新的PARTITION伪代码中,只在真正进行划分前进行一次交换:

RANDOMIZED-PARTITION(A,p,r)
    i=RANDOM(p,r)
    exchange A[r] with A[i]
    return PARTITION(A,p,r)

新的快速排序不再调用PARTITION,而是调用RANDOMIZED-PARTITION:

RANDOMIZED-QUICKSORT(A,p,r)
    if p<r
        q=RANDOMIZED-PARTITION(A,p,r)
        RANDOMIZED-QUICKSORT(A,p,q-1)
        RANDOMIZED-QUICKSORT(A,q+1,r)

1.4 快速排序性能的更严谨性分析

下面给出关于快速排序性能的比【1.2 快速排序性能的非形式化分析】一节中更加严谨的分析。

1.4.1 最坏情况分析

在最坏情况下,快速排序的每一层递归的时间复杂度是Θ(n2),直观来看,这也就是最坏情况下的运行时间。下面来证明这一点。

利用置换法可证明快速排序的时间复杂度为O(n2)。假设T(n)是最坏情况下QUICKSORT在输入规模为n的数据集合上所花费的时间,则有递归式:
在这里插入图片描述
因为PARTITION函数生成的两个子问题的规模总共为n-1,所以参数q的变化范围是0到n-1。不妨猜测T(n)≤cn2成立,其中c为常数。将此式代入上图的递归式中,得:
在这里插入图片描述
表达式q2+(n-q-1)2在参数q的取值区间[0,n-1]的两个端点上取得最大值(n-1)2。由于该表达式对于q的二阶导数是4,是正的,可得:
在这里插入图片描述
的上界为(n-1)2=n2-2n+1,将其代入上上一张图的T(n)中,得:
在这里插入图片描述
由于总可以选择一个足够大的常数c,使得c(2n-1)项能显著大于Θ(n)项,所以有T(n)=O(n2)。

而递归式:
在这里插入图片描述
有另一个解T(n)=Ω(n2),不准备写详细过程哦。因此,快速排序在最坏情况下的运行时间是Θ(n2)。

1.4.2 期望运行时间

如果在递归的每一层上,RANDOMIZED-PARTITION将任意常数比例的元素划分到一个子数组中,则算法的递归树的深度为Θ(lgn),并且每一层上的工作量都是O(n)。即使在最不平衡的划分情况下,会增加一些新的层次,但总的运行时间仍然保持是O(n·lgn)。要准确地分析RANDOMIZED-QUICKSORT的期望运行时间,首先要理解划分操作是如何进行的,然后在此基础之上,推导出期望运行时间的一个O(lgn)的界。有了这一期望运行时间的上界,再加上【1.2.2 最好情况划分】一节中得到的最好情况界Θ(n·lgn),就能得到Θ(n·lgn)这一期望运行时间。在这里,假设待排序的元素始终是互异的。

QUICKSORT和RANDOMIZED-QUICKSORT除了如何选择主元元素有差异以外,其他方面完全相同。因此可以在讨论QUICKSORT和PARTITION的基础上分析RANDOMIZED-QUICKSORT。其中,RANDOMIZED-QUICKSORT随机地从子数组中选择元素作为主元元素。

QUICKSORT的运行时间是由在PARTITION操作上所花费的时间决定的。每次调用PARTITION时,都会选择一个主元元素,而且该元素不会被包含在后续的对QUICKSORT和PARTITION的递归调用中。因此,在快速排序算法的整个执行期间,至多只可能调用PARTITION操作n次。调用一次PARTITION的时间为O(1)再加上一段循环时间。这段时间与PARTITION伪代码第4-7行中for循环的迭代次数成正比。该for循环的每一轮迭代都要在第5行进行一次比较——比较主元元素与数组A中另一个元素的大小关系。因此,如果可以统计第5行被执行的总次数,就能够给出在QUICKSORT的执行过程中,for循环所花时间的界了。下面是一个引理:

当在一个包含n个元素的数组上运行QUICKSORT时,假设在PARTITION的第5行中所做比较的次数为X,那么QUICKSORT的运行时间为O(n+X)。

现在的目标是计算出X,即所有对PARTITION的调用中,所执行的总的比较次数。现在并不打算分析在每一次PARTITION调用中做了多少次比较,而是希望能够推导出关于总的比较次数的一个界。为此必须了解算法在什么时候对数组中的两个元素进行比较,什么时候不进行比较。为了便于分析,将数组A的各个元素重新命名为z1,z2,…,zn,其中zi是数组A中第i小的元素。定义Zij={zi,zi+1,…,zj}为zi与zj之间(含i和j)的元素集合。

算法何时比较zi和zj呢?

为回答这问题,注意到每一对元素至多比较一次——因为各个元素只与主元元素进行比较,并且在某一次PARTITION调用结束之后,该次调用中所用到的主元元素就再也不会与任何其他元素进行比较了。定义:

Xij=Ⅰ{zi与zj进行比较}

Xij的值有两种情况:① 当zi与zj进行比较时,值为1;② 当zi与zj不进行比较时,值为0。

考虑比较操作是否在算法执行过程中任意时间发生,而不是局限在循环的一次迭代或对PARTITION的一次调用中是否发生。因为每一对元素至多被比较一次,所以可以很容易地刻画出算法的总比较次数:
在这里插入图片描述
对上式两边取期望,再利用期望值的线性特性和下面的引理:

给定一个样本空间S和S中的一个事件A,设XA=Ⅰ{A},那么E[XA]=Pr{A}。

可以得到:
在这里插入图片描述
上式中的【Pr{zi与zj进行比较}】还需要进一步计算。在下面的分析中,假设RANDOMIZED-PARTITION随机且独立地选择主元。

现在考虑两个元素何时不会进行比较的情况:考虑快速排序的一个输入,它是由数字1到10所构成(顺序可以是任意的),并假设第一个主元是7。那么,对PARTITION的第一次调用就将这些输入数字划分成两个集合:{1,2,3,4,5,6}和{8,9,10}。在这一过程中,主元7要与所有其他元素进行比较。但是,第一个集合中任何一个元素(例如2)没有(也不会)与第二个集合中的任何元素(例如9)进行比较。

假设每个元素的值是互异的,因此,一旦一个满足zi<x<zj的主元x被选择后,就知道zi与zj以后再也不可能被比较了。另一种情况,如果zi在Zij中的所有其他元素之前被选为主元,那么zi就将与Zij中除了它自身以外的所有元素进行比较。类似地,如果zj在Zij中其他元素之前被选为主元,那么zj将与Zij中除自身以外的所有元素进行比较。在上述例子中,值7和9要进行比较,因为7是Z7,9中被选为主元的第一个元素。与之相反的是,值2和9则始终不会被比较,因为从Z2,9中选择的第一个主元为7。

因此有:zi与zj会进行比较⇔Zij中将被选为主元的第一个元素是zi或zj

现在计算这一事件发生的概率:在Zij中的某个元素被选为主元之前,整个集合Zij的元素都属于某一划分的同一分区。因此,Zij中的任何元素都会等可能地被首先选为主元。因为集合Zij中有j-i+1个元素,并且主元的选择是随机且独立的,所以任何元素被首先选为主元的概率是1/(j-i+1),于是有:
在这里插入图片描述
上式中第二行成立的原因在于其中涉及的两个事件是互斥的。将上图与上上一张图中的公式综合起来,有:
在这里插入图片描述
在求上述累加和时,可以将变量做个变换(k=j-i),并利用公式:
在这里插入图片描述
中给出的有关调和级数的界,得到:
在这里插入图片描述
于是可得出结论——使用RANDOMIZED-PARTITION时,在输入元素互异的情况下,快速排序算法的期望运行时间为O(n·lgn)。


2 插入排序

插入排序INSERTION-SORT的参数是一个数组A[1…n],即包含长度为n的待排序的一个序列,在INSERTION-SORT结束时,输入数组A包含的就是已排序的输出序列。INSERTION-SORT的伪代码如下:

INSERTION-SORT(A)
    for j=2 to length[A]
        key=A[j]
        //insert A[j] into the sorted sequence A[1...j-1]
        i=j-1
        while i>0 and A[i]>key
            A[i+1]=A[i]
            i=i-1
        A[i+1]=key

下图是对A=(5,2,4,6,1,3)时,INSERTION-SORT算法的工作过程:
在这里插入图片描述
上图中,长方形上方是数组下标,长方形中是数组中对应位置存储的值。(a)-(e)是INSERTION-SORT伪代码中第2-9行for循环的迭代。每次迭代中,黑色的长方形保存取自A[j]的关键字,在伪代码中第6行的测试中将它与其左边的加阴影的长方形中的值进行比较。加阴影的箭头指出数组值在伪代码中第7行向右移动一个位置,黑色的箭头指出在伪代码中第9行关键字被移到的地方。(f)是最终排序好的数组。

在伪代码中,下标j相当于打牌中的正要被插入到手中的“当前牌”。在for循环(循环变量为j)的每次迭代的开始,包含元素A[1…j-1]的子数组构成了当前排序好的左手中的牌,剩余的子数组A[j+1…n]对应于仍在桌子上的牌堆。事实上,元素A[1…j-1]就是原来在位置1到j-1的元素,只不过现在已按序排列。把A[1…j-1]的性质形式地表示为一个循环不变量:

在伪代码中第2-9行的for循环的每次迭代开始时,子数组A[1…j-1]由原来在A[1…j-1]中的元素组成,但已按序排列。

循环不变量用来证明算法的正确性。关于循环不变量,必须证明三条性质:

(1)初始化:循环的第一次迭代之前,它为真。

(2)保持:如果循环的某次迭代之前它为真,那么下次迭代之前它仍为真。

(3)终止:在循环终止时,不变量提供了一个有用的性质,该性质有助于证明算法是正确的。

当上述前两条性质成立时,在循环的每次迭代之前循环不变量为真。这类似于数学归纳法,其中为了证明某条性质成立,需要证明一个基本情况和一个归纳步

(1)证明第一次迭代之前不变量成立⇔基本情况;

(2)证明从一次迭代到下一次迭代不变量成立⇔归纳步。

循环不变量的终止性不同于数学归纳法:

(1)在归纳法中,归纳步是无限地使用的;

(2)在循环不变量中,当循环终止时,停止“归纳”

下面讨论插入排序的循环不变量:

(1)初始化:首先证明在第一次循环迭代之前(当j=2时),循环不变量成立。初始时数组A[1…j-1]仅由单个元素A[1]组成,显然该数组是排序好的。这表明第一次循环迭代之前循环不变量成立。

(2)保持:伪代码的for循环体的第5-8行将A[j-1]、A[j-2]、A[j-3]等元素向右移动一个位置,直到找到A[j]的适当位置,第9行将A[j]的值插入该位置。这时子数组A[1…j]由原来在A[1…j]中的元素组成,但已按序排列。这表明对for循环的下一次迭代增加j将保持循环不变量。

(3)终止:导致for循环终止的条件是j>length[A]=n,有j=n+1。在循环不变量的表述中用n+1代替j,子数组A[1…n]由原来在A[1…n]中的元素组成,但已按序排列。而子数组A[1…n]就是整个数组,即现在整个数组已排序。

综上所述,INSERTION-SORT算法是正确的。


END

  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值