接下来让我们看看大名鼎鼎的快速排序,光名字就觉得牛哄哄。
快速排序
快速排序使用分治法(Divide and conquer)策略来把一个序列(list)分为两个子序列(sub-lists)。步骤如下:
从数列中挑出一个元素,称为“基准”(pivot)
分区(partition): 遍历数列,比基准小的元素放左边,比它大的放右边。
于是此次分区结束后,该基准就处于数列的中间位置,其左边的数全比它小(称为小与子序列),右边的数全比他大(称为大于子序列)。这样一次排序就造成了整体上的有序化。- 子数列排序: 将小与子数列和大于子序列分别继续快速排序。
- 递归到最底部时,数列的大小是零或一,至此就都排序好了,递归结束。
针对具体的基准数的选择方式和分区方式的不同,主要有四种快排:
- 普通快排
- 随机普通快排
- 双路快排
- 三路快排
要理解他们的区别,我们思考以下问题:
1. 渐进有序的数组和一般乱序的数组,对快排效率有什么区别?
答: 对于快速排序,有点就在于通过分治法从顶到底的渐进有序,选择的基准数使得分成的左右两个子序列长度越接近(即分区越平衡),快排的效率越高。反之,选择的数使分区不平衡,快排的效率就会降低。
回到问题,当序列渐进有序时,意味着大量元素已经处于有序状态,左边的数普遍比右边的小。对于普通快排,默认选择左边第一个元素作为基准数,这就导致小与基准的数会相当少,而大于基准的数相当多,造成分区不平衡的问题,普通排序就会退化,严重的将退化成O(n^2)。所以对其改进:不再默认选择第一个数,而是随机选一个数作为基准,这样的快排称为随机普通快排。
实现上,随机普通快排随机选一个数与第一个数交换,然后在将第一个数作为基准(这样代码好写),进行普通快排即可。所以随机普通快排只是对普通快排进行了一下预处理而已。
2. 分区时等于的数怎么办?
答: 好问题。对于普通快速排序,我们将等于的数一律放到左边或者一律放在右边,在一般情况下,排序效率都很快,能达到O(nlogn)。但是当序列含有大量相等数字时,普通快排会使得大量等于的数集中位于某一边,造成分区不平衡的问题,使得普通快排退化成O(n^2),效率急剧下降。 这时对于等于的数的处理就显得很重要了,针对普通快速排序的改进版本——双路排序和三路排序,就应运而生了。
- 双路快排:从两端向中间扫描,等于的数可以被分在任意一边,这样就缓解了分区不平衡问题。
- 三路快排:也是从两端向中间扫描,不同的是,它将等于的数通通放到中间,即新增了一个等于区。接下去分别对小与区和大于区继续快排,这样不仅避免了分区不平衡,还有个额外的好处:等于区的数从此不必再处理。
3. 所以双/三路快排一定比普通快排和随机普通快排快吗?
答: 不一定。双路快排和三路快排只有在序列含有大量相等元素时性能才比普通快排好,否则性能会比普通快排稍差,这是因为,双/三路快排比普通快排稍复杂,会多维护一些指针,就会多出一些额外的赋值和比较的开销。
总结如下:
快排的各种版本及来由
根据直观含义得到普通快速排序:从左到右或从右到左的单向扫描。设立两个区:小于区,大于等于区
普通快排的问题:
问题1:对于渐进有序的数组,效率很差
改进:随机选择基准。得到随机化快速排序。
问题2:对于含有大量重复元素的序列,即使是随机化快排效率也很差
于是再次改进,得到
1.双路快排: 从两端向中间挺近,设立两个区:小于等于区,大于等于区
2.三路快排: 从两端向中间挺近,设立三个区:小与区,等于区,大于区
接下来分别介绍各种快排算法,并给出图示,帮助快速理解操作细节。
1. 普通快速排序
从左到右或从右到左的单向扫描。 设立两个区:小于区,大于等于区
图例如下
比照了示意图,很容易写出代码:
def QuickSort(arr):
n = len(arr)
_quickSort(arr,0,n-1)
def _quickSort(arr,l,r):
if l >= r:
return
p = _partition_1way(arr,l,r) # 分割,返回分割点p
_quickSort(arr,l,p) # 递归地对左部分快排
_quickSort(arr,p+1,r) # 递归地对右部分快排
'''
_partition()对arr[l...r]部分进行partition操作
返回p,使得arr[l...p-1] < arr[p] ; arr[p+1...r] >= arr[p]
'''
def _partition(arr,l,r):
'''单路版本:只有一个标记点j,作为<和>=的分界,从左到右扫描 '''
v = arr[l] # 直接将第一个元素作为分割值
j = l # arr[l+1...j]<v
for i in range(l+1,r+1):
if arr[i]<v: # 小与则交换
j += 1
arr[j],arr[i] = arr[i],arr[j] # 交换
else: # 大于等于则继续右移
pass
arr[l],arr[j] = arr[j],arr[l]
return j
普通快排的问题
问题1:对于渐进有序的数组,效率不高
原因:快排中分治的不平衡性
我们知道,归并排序复杂度O(nlogn)中logn的原因是每次归并都是高度平衡的,即左右两支长度相等。平衡度越好,性能越接近logn。快排每次都从左边第一个数作为比较数,而对于渐进有序的数组来说,每次区分其实都是极其不平衡的(如下图),甚至会退化成O(n^2).

归并排序高度平衡,快速排序平衡性差

改进方式:随机化基准数,得到随机普通快排
平均意义上,对于任何数组(包括渐进有序数组),快排遇到最差情况的概率将大大降低。代码如下
import random
def _partition_1way_advanced(arr,l,r):
'''
单路改进版: 随机选取分割值,避免分出来的两块大小不均匀问题带来的性能下降
'''
# 改进点:随机选取一个元素开始分割,而不是第一个元素
rd_i = random.randint(l,r)
arr[l],arr[rd_i] = arr[rd_i],arr[l]
v = arr[l] # 将第一个元素(已随机化了)作为分割值
j = l+1 # j为分割点,具体就是小与区末尾
# i遍历范围: [l+1,r]
for i in range(l+1,r+1):
if arr[i]<v:
j += 1
arr[j],arr[i] = arr[i],arr[j] # 交换
arr[l],arr[j] = arr[j],arr[l]
return j
问题2:对于含有大量重复元素的数组,即使是改进版的单路快排效率也很差
原因
对于含有大量重复元素的数组,则对于与基准数相同的数,(根据所写代码不同)要么都分到了左边,要么都分到了右边。同样会造成分治不平衡的问题,造成性能退化。如下图所示:

改进算法
1.双路快排: 从两端向中间挺近,设立两个区:小于等于区,大于等于区
2.三路快排: 从两端向中间挺近,设立三个区:小与区,等于区,大于区
2. 双路快排
从两端向中间挺近,设立两个区:小于等于区,大于等于区
如何克服含大量重复元素的数组导致不平衡问题:
等于基准的数在两边均有分布,避免集中在一边,从而克服了不平衡问题。
import random
def _partition_2ways(arr,l,r):
'''
双路版: 两个标记点i,j,分别从两端向中间挺近
arr[l+1...i) <= v
arr(j...r] >= v
'''
rd_i = random.randint(l,r)
arr[l],arr[rd_i] = arr[rd_i],arr[l]
v = arr[l]
i = l+1 # i: <=区的右标记点
j = r # j: >=区的左标记点
while True:
# i,j相遇时停止
while i<=j and arr[i]<=v:
i += 1 # i一直右移到大于v的值处
while j>=i and arr[j]>=v:
j -= 1 # j一直左移到小于v的值处
if i>j:
break
arr[j],arr[i] = arr[i],arr[j]
i += 1
j -= 1
arr[l],arr[j] = arr[j],arr[l]
return j
3. 三路快排
从两端向中间挺近,设立三个区:小与区,等于区,大于区
如何克服含大量重复元素的数组导致不平衡问题:
等于基准的数在正好集中在了中间,而不是任意一边,从而克服了不平衡问题。
三路快排的额外的好处:
在继续递归时,中间的arr[lt+1,gt-1]是等号区,不用管了,这样在含有大量相同元素的时候就可以避免大量的运算
这也是3路快排在含有大量相同元素的状况下,保持优势的地方.
def QuickSort3ways(arr):
n = len(arr)
_quickSort3ways(arr,0,n-1)
import random
def _quickSort3ways(arr,l,r):
'''
三路版: 三个标记点lt,gt,i,分别从两端向中间挺近
arr[l+1,...,lt]<v
arr(lt,...,i)==v
arr[i,...,gt): 待排序
arr[gt,...,r]>v
'''
# 结束条件
if l>=r:
return
rd_i = random.randint(l,r)
arr[l],arr[rd_i] = arr[rd_i],arr[l]
v = arr[l]
lt = l # arr[l+1...lt] < v
gt = r+1 # arr[gt...r] > v
i = l+1 # arr[lt+1...i) == v
# i,gt相遇时停止
while i<gt:
if arr[i]<v:
arr[lt+1],arr[i] = arr[i],arr[lt+1]
lt += 1
i += 1 # 交换后,i右移,因为i指向了一个已处理的数(从等号区换来的)
elif arr[i]>v:
arr[gt-1],arr[i] = arr[i],arr[gt-1]
gt -= 1
# 交换后,i不需要动,因为i仍指向一个未处理的数(从乱序区换来的)
else: #arr[i] == v
i += 1
arr[l], arr[lt] = arr[lt],arr[l]
lt -= 1
_quickSort3ways(arr,l,lt) # 继续对arr[l..lt]部分快排
_quickSort3ways(arr,gt,r) # 继续对arr[gt,r]部分快排
# 而中间的arr[lt+1,gt-1]是等号区,不用管了。
# 这样在含有大量相同元素的时候就可以避免大量的运算
# 这也是3路快排在含有大量相同元素的状况下,保持优势的地方