排序(4) 快速排序与顺序统计值

来源:互联网 发布:非法软件下载 编辑:程序博客网 时间:2024/06/07 03:07

基本思想

快排是应用相当广泛的排序算法,被投票评为21世纪十大算法,足见快排在算法界的地位。快排跟归并排序一样,也应用了分治法的思想。首先在待排序数组中确定一个主元,把数组中小于等于主元的数放到主元的前面(左边),而大于主元的数放到主元的后面(右边),这样就确定了主元在数组中的相对位置,对主元左右两边的子数组递归的调用该过程,直到所有元素都已确定位置。快排的内循环精短,这使得快排无论是理论上还是实际情况下都有不错的性能,但是快排的结构脆弱,我们需要考虑待排序数组的各种极端情况,防止造成慢性能甚至造成程序栈溢出而崩溃的情况发生。

快排三步分治过程

分解:将数组A划分为两个子数组(可能为空)A[0,m-1]和A[m+1,n],使得A[0,m-1]中每一个元素都不大于A[m],而A[m+1,n]中的每个元素都大于A[m],其中计算下标m也是划分过程的一部分。
解决:递归调用快速排序对A[0,m-1]和A[m+1,n]进行排序。
合并:子数组为原址排序,这里就不需要合并操作,数组A已经有序。

算法关键部分是划分过程,我们将数组第一个元素作为主元,后面的元素依次和这个元素比较,不大于主元的放在数组靠前位置,大于主元的放在数组靠后位置,最后调整主元到合适的位置,返回主元的位置。
划分过程实现如下:

#快速排序划分子过程 O(n)def Partition(A,first,last):    i = first    for j in range(first+1,last+1):        if A[j] < A[first]:            i += 1            A[i],A[j] = A[j],A[i]               A[first],A[i] = A[i],A[first]    return i

划分(Partition)执行过程可如下图所示:
这里写图片描述

这里找到一个《算法导论》作者雷瑟尔森制作的动态图,这个过程更加明了。

这里写图片描述

当然还有一个划分的实现方法是从数组两端向中间同时扫描,如i从左至右扫描,j从右至左扫描,把左边大于主元的元素与右边小于主元的元素交换,直到这里的元素都被分成了两拨,再把主元交换到合适的位置。这这种划分法的实现就不再多说了,塞克威奇的《算法4》中是这样实现的,大家自己去参考。

快速排序的实现代码:

#快速排序  期望时间O(nlgn)def QuickSort(A,first,last):    if first < last:        nearlyMid = Partition(A,first,last)        QuickSort(A,first,nearlyMid-1)        QuickSort(A,nearlyMid+1,last)

快排的性能分析

快排的运行时间依赖于划分是否平衡,如果划分是平衡的,快排的性能将和归并排序一样。如果划分不平衡,那么快排的性能就接近于直接插入排序。

快排最坏情况的划分情况:当划分产生的两个子问题分别包含了n-1和0个的元素时,最坏情况产生。如果在算法的每一层递归上,划分都是最大程度的不平衡,此时可得到其递归方程T(n) = T(n-1)+cn,解递归式得T(n) = O(n^2)

最好情况的划分:当每次划分都把问题划分为两个n/2(或者几乎接近)的子问题规模时,快排的性能非常好,算法时间递归式为:T(n) = 2T(n/2)+cn,解之,T(n) = O(nlgn)

平均情况:快排的平均运行时间更接近其最好情况。假设划分算法总是产生9:1的划分,乍一看,这种划分很不平衡。此时得到的递归式为:T(n) = T(9/10n)+T(1/10n)+cn,用递归树法解该递归式得:T(n) = O(nlgn)。实际上,即便是99:1的划分,其复杂度依然为O(nlgn),任何常数比例的划分都是如此。

优化与改进

实际应用中经常有这样的一种情况:如果待排序数组有一些元素是相同的,甚至极端情况下整个数组的元素都相等。这时候若用上述实现就会发现性能会变慢,整个数组元素都相等这种极端情况下最坏划分情况将会发生,递归栈深度将会变为O(n),若n稍微大一点就会导致程序栈溢出而崩溃。我用python3运行程序,对1000个数排序出现最坏情况下程序就崩溃了。面对这些问题那么该如何改进呢?三向切分快排可以完美优化解决这个情况。
三向切分快排:对于有重复元素的情况,我们可以在数组中段增加维护一个子数组段,该子数组段里的元素都是等于主元大小的。这样就变为了需要维护四段子数组,小于主元的、等于主元的、大于主元的以及未知的。其处理过程状态如下图所示。
这里写图片描述
在前面的Partition基础上增加一个下标k用以维护等于主元的子数组段。
一次划分之后,只需要再次对A[first,i-1]和A[k+1,last]递归调用。
三向切分快排实现:

#三向切分过程def ThreeWayPartition(A,first,last):    i=k=first    for j in range(first+1, last+1):        if A[j] < A[first]:            i += 1            k += 1            A[k],A[j] = A[j],A[k] #注意这里的交换顺序:先k、j再i、k交换            A[i],A[k] = A[k],A[i]        elif A[j] == A[first]:            k += 1            A[k],A[j] = A[j],A[k]        else:            pass    A[first],A[i] = A[i],A[first]    return i,k #从A[i]到A[k]大小都等于主元#三向切分快排def ThreeWayQuickSort(A,first,last):    if first < last:        midStartPos,midEndPos = ThreeWayPartition(A,first,last)        ThreeWayQuickSort(A,first,midStartPos-1)        ThreeWayQuickSort(A,midEndPos+1,last)

这个改进的快排算法对于绝大多数情况发挥良好,但是如果待排序数组本身就是有序(或者绝大部分有序,不管正序还是逆序)的情况下呢?
在前面实现的快排算法中的划分过程始终将首元素作为每一次划分的主元,如果原数组是有序的(或者接近有序的),那么绝大多数的情况下都会出现最坏的划分。针对这一点,如果我们随机的选择主元,只要不是每次(或者多数时候)都选到最值作为主元(多数情况下这是一个小概率事件),那么就可以避免最坏情况的发生。实现如下:

from random import randint #从random模块插入randint函数#随机化三向切分子过程def RandomThreeWayPartition(A,first,last):    i = randint(first,last)    A[first],A[i] = A[i],A[first]    return ThreeWayPartition(A,first,last)#随机三向切分快排   def RandomThreeWayQuickSort(A,first,last):    if first < last:        midStartPos,midEndPos = RandomThreeWayPartition(A,first,last)        RandomThreeWayQuickSort(A,first,midStartPos-1)        RandomThreeWayQuickSort(A,midEndPos+1,last)

实际上,上述实现的第二个递归调用并不是必须的,可以用一个循环控制结构代替它,这种技术称为尾递归,有些编译器可以优化尾递归,但是Python解释器并没有优化尾递归,大抵是为了在发生异常时能还原完整的堆栈轨迹。不过我们这里可以自己模拟。

def RandomThreeWayQuickSortTailRec(A,first,last):    while first < last:        midStartPos,midEndPos = RandomThreeWayPartition(A,first,last)        RandomThreeWayQuickSortTailRec(A,first,midStartPos-1)        first = midEndPos + 1

随机三向切分快排不再依赖初始数组是否有序、是否相同,在任何情况下都能获得不错的性能和健壮性,尤其是极大的保证了递归栈深度的安全级别。

另外,像前面所说的归并排序一样,可以对长度小于m的小数组运用直接插入排序来提高效率。这样的排序算法时间复杂度为O(nm+nlg(n/m)),不难得出m的最佳大小为lgn,当然前提是能保证这m个元素构成的小数组能完全缓存在cpu cache中,否则这种优化可能得不偿失。

def RandomThreeWayQuickSortTailRecIns(A,first,last,m=0):    while first + m < last:        midStartPos,midEndPos = RandomThreeWayPartition(A,first,last)        RandomThreeWayQuickSortTailRecIns(A,first,midStartPos-1)        first = midEndPos + 1    InsertionSort(A,first,last) 

顺序统计量

我们会遇到求解一个集合的第k小的元素,或者求topk小的元素,这里假设集合内元素互异。朴素方法,将集合排序能在O(nlgn)时间内解决。topk问题,前面的文章说到利用堆优先队列,可以在O(nlgk)内解决。这里我们利用快排的分治切分过程逼近k,可以在O(n)时间内解决问题。

from random import randint#随机化的双向切分过程。与前面的三向切分相区别def RandomPartition(A,first,last):    i = randint(first,last)    A[first],A[i] = A[i],A[first]    return Partition(A,first,last)#找出第k小元素def RandomSelect(A,first,last,k):    if first <= last:        i = RandomPartition(A,first,last)        k2 = i - first + 1 #求得A[first,i]中的元素个数        if k == k2:            return A[i]        elif k < k2:  #第k小元素位于A[first,i-1]区域内的情况            return RandomSelect(A,first,i-1,k)        else: #第k小元素位于A[i+1,last]区域内的情况            return RandomSelect(A,i+1,last,k-k2)

看上述实现过程是不是看到了二分查找的影子。不错,这里也相当于用了减治法,每一次递归,平均把问题分为了两部分,但是只处理了一部分。
递归式 T(n) = T(n/2) + cn,求解得T(n) = O(n)。
当然可以向二分查找一样,可以改成非递归版本。

def RandomSelectLoop(A,first,last,k):    while first <= last:        i = RandomPartition(A,first,last)        k2 = i - first + 1 #求得A[first,i]中的元素个数        if k == k2:            return A[i]        elif k < k2:  #第k小元素位于A[first,i-1]区域内的情况            last = i-1        else: #第k小元素位于A[i+1,last]区域内的情况            first = i + 1            k -= k2

至于topk问题,由于找到了第k小元素,下标k之前的元素必然就是topk小的元素,自然就在O(n)时间内找出了无序的topk,即A[0,…,k-1]。如果要将这个k的元素排序的话,需要O(klgk)。所以topk问题在O(n + klgk)时间内解决了。


几个问题

①:设计一个O(n)时间的算法,对于一个含n个互异元素的集合S和一个正整数k<=S,确定最接近中位数的k个元素。
思路:首先求得S的中位数mid,其次将S中的元素依次与mid作差值,此时mid左边部分为负数,右边部分为正数,把包含正数和负数的该数组求得topk小的元素(这里不是简单的求法,对于负数要取负数的绝对值作为参与比较,需要修改切分过程partition),最后加上mid还原数组元素值大小即可。
②:两个已排序的数组,个数相同且都为n,求在lgn时间内求出两数组共同的中位数。
思路:分别求出两数组的中位数mid1,mid2,如果相等则即为所求中位数,如果mid1 < mid2,则取第一个数组的后半部分,第二个的前半部分,否则相反。递归调用这一过程,直到每一个数组中只有一个数字,则取这两个数字中较小的(默认为下中位数)数字为两个数组的中位数。

1 0
原创粉丝点击