算法设计与分析:深入理解快速排序

本文参考UCAS卜东波老师的计算机算法设计分析课程完成

前言

快速排序是复杂度 O ( n l o g n ) O(nlogn) 的排序算法中最常用的一个,也是分治思想的一个具体应用,关于分治思想的理解可以参考我的这篇文章分治思想。在了解快速排序前,我建议先理解分治思想和归并排序,掌握之后再来看快速排序,会有更好的效果。

快速排序

原理

快速排序与归并排序最明显的不同点是,前者基于数值划分,后者基于下标划分。快速排序会从数组中找一个中间数pivot,将数组小于piovt的放到左边,大于pivot的放到右边。这个貌似很容易理解(先不管pivot如何选取),我们可以得到如下伪代码

quick_sort(A):
	S_l = [],S_r = []  # s_l存储小于pivot的值,s_r存储大于pivot的值
	choose pivot A[p]  # 随机抽取
	for i = 0 to |A|-1:
		if A[i] < A[p]:
			S_l += A[i]  # 若比pivot小,则将A[i]放到pivot左边
		else:
			S_r += A[i] # 否则将A[i]放到pivot右边
	quick_sort(S-)
	quick_sort(S+)
	output S-,A[p],S+  # 按照顺序输出三者

将上面的过程转换成图例如下:
在这里插入图片描述

第一次pivot选择4(随机),213放左边,5放右边,第二次pivot选择3,21放左边,右边为空,第三次pivot选择1,左边为空,2放右边。得到有序数组。

时间复杂度

  • 估算时间复杂度
    上述过程的时间复杂度是多少呢?好像是 O ( n l o g n ) O(nlogn) ?我们考虑两种极端情况,pivot每次都选中了中位数和pivot每次都选了边边的数。两种划分的结果如下:
    在这里插入图片描述
    发现时间相差较大,写出递推公式分别如下(每一次选定pivot,所有数要和其比较,额外开销时间复杂度 O ( n ) O(n) ):
    • 最好情况
      T ( n ) = 2 T ( n 2 ) + O ( n ) = O ( n l o g n ) T(n)=2T(\frac{n}{2})+O(n) = O(nlogn)
    • 最坏情况
      T ( n ) = T ( n 1 ) + O ( n ) = O ( n 2 ) T(n)=T(n-1)+O(n) = O(n^2)

那么一般情况下是什么样的呢?
假设取pivot的时候使得划分比例是3:1,那么划分得到的树如下所示(也就是分治思想一文中提到的不均匀划分):
在这里插入图片描述
容易知道这棵树的高度是 l o g 4 3 n log_\frac{4}{3}n ,由于每一次划分的额外开销是 O ( n ) O(n) 所以总的时间复杂度是 O ( n l o g 4 3 n ) = O ( n l o g n ) O(nlog_\frac{4}{3}n)=O(nlogn) ,和最好情况一样的复杂度,同样的,按8:1,100:1的划分也都有 O ( n l o g 9 8 n ) = O ( n l o g 101 100 n ) = O ( n l o g n ) O(nlog_\frac{9}{8}n)=O(nlog_\frac{101}{100}n)=O(nlogn)

有人可能不理解这里,为什么划分100:1这么大,仍然复杂度还是 O ( n l o g n ) O(nlogn) ,可以从两个角度解释:
1: 无论你分的比例是多少,相对于n都是一个固定的数,100虽然很大,但当n是1000000时,100就显得很小。所以从时间复杂度的角度看是不变的。
2:根据换底公式,有 O ( n l o g 101 100 n ) = O ( n l o g 101 100 2 l o g 2 n ) = O ( c n l o g 2 n ) = O ( n l o g n ) O(nlog_\frac{101}{100}n)=O(nlog_\frac{101}{100}2*log_2n)=O(cnlog_2n)=O(nlogn) ,其中 c = l o g 101 100 2 c=log_\frac{101}{100}2是 常数对于复杂度不影响

所以,可以发现大部分的情况下,快速排序的时间复杂度都是 O ( n l o g n ) O(nlogn)

  • 计算时间复杂度
    上面我们大致估计了快排的时间复杂度,由于privot不确定,我们选择用期望来计算真实的时间复杂度。注意快排中耗时的地方主要是元素之间比较,被pivot分到两侧的元素之间不会继续进行比较(因为左边一定小于右边,这个结论一会要用到所以标记为结论1)。如下图所示:
    在这里插入图片描述
    由上可知快排中任意两个元素之间至多比较一次。那么总共要进行多少次比较?为此,定义 X i j X_{ij} 如下:
    { 1 if  A [ i ] A [ j ] 0 else \begin{cases} 1 &\text{if } A[i]与A[j]进行了比较 \\ 0 &\text{else} \end{cases}
    那么A[i]到A[j]之间的比较次数即
    X = i = 0 n 1 j = i + 1 n 1 X i j E ( X ) = E [ i = 0 n 1 j = i + 1 n 1 X i j ] = i = 0 n 1 j = i + 1 n 1 E ( X i j ) = i = 0 n 1 j = i + 1 n 1 P r ( A [ i ] A [ j ] ) = i = 0 n 1 j = i + 1 n 1 2 j i + 1 , k = j i = i = 0 n 1 k = 1 n i 1 2 k + 1 i = 0 n 1 k = 1 n 1 2 k + 1 , ( 1 + 1 2 + . . . + 1 n ) = O ( n l o g n ) X = \sum_{i=0}^{n-1}\sum_{j=i+1}^{n-1}X_{ij}\\ E(X) = E[ \sum_{i=0}^{n-1}\sum_{j=i+1}^{n-1}X_{ij}]\\ = \sum_{i=0}^{n-1}\sum_{j=i+1}^{n-1}E(X_{ij})\\ = \sum_{i=0}^{n-1}\sum_{j=i+1}^{n-1}Pr(A[i]与A[j]比较的期望)\\ = \sum_{i=0}^{n-1}\sum_{j=i+1}^{n-1}\frac{2}{j-i+1},令k=j-i \\ = \sum_{i=0}^{n-1}\sum_{k=1}^{n-i-1}\frac{2}{k+1}\\ \le \sum_{i=0}^{n-1}\sum_{k=1}^{n-1}\frac{2}{k+1}, (1+\frac{1}{2}+...+\frac{1}{n}是调和级数)\\ = O(nlogn)
    公式比较好理解,唯一需要解释的是为什么A[i]与A[j]比较的期望是 2 j i + 1 \frac{2}{j-i+1} ?
    我们从最简单的例子入手:
    在这里插入图片描述

    红色圈圈选中的数字是pivot

    如图所示,选取(i=1,j=3),可以发现选取1,2,3为pivot的概率等同,而只有在选中1和3为piovt的时候,i与j才会比较,期望为 1 3 + 1 3 = 2 3 = 2 j i + 1 \frac{1}{3}+\frac{1}{3}=\frac{2}{3}=\frac{2}{j-i+1} 。同样可证,i=1,j=2等等其他情况下的期望。
    更一般地,对于4个元素,有如下图示:
    在这里插入图片描述
    推广到最一般的情况,我们采用数学归纳法。假设对 k n 1 k\leq n-1 都有 E ( X i j ) = 2 j i + 1 E(X_{ij})=\frac{2}{j-i+1} 成立(对数学归纳法不了解的同学可以参考一下百度百科)。我们需要考虑集中pivot选择的可能性:

    • 1、pivot选在i之前或pivot选择j之后(即i,j在同一边)
      那么此时i与j比较就可以递归到一边。此时数据规模小于n,满足归纳假设,所以每一个的概率都是 1 n 2 j i + 1 \frac{1}{n}*\frac{2}{j-i+1} 1 n \frac{1}{n} 是每一个元素被选为pivot的概率, 2 j i + 1 \frac{2}{j-i+1} 运用了之前的归纳假设),那有多少个呢?显然有 n ( j i + 1 ) n-(j-i+1) 个,所以这部分的概率是 2 n ( j i + 1 ) ( n ( j i + 1 ) ) \frac{2}{n(j-i+1)}*(n-(j-i+1))
    • 2、pivot选到了i或j
      此时i与j必然比较一次,对应概率 1 n \frac{1}{n} ,这种情况有两个,所以这部分的概率为 1 n 2 \frac{1}{n}*2
    • 3、pivot选到了i和j中间
      此时i,j不可能比较,基于结论1,所以这部分的概率是0

    在这里插入图片描述

    综合三种情况,可以得到 E ( X i j ) = 2 n ( j i + 1 ) ( n ( j i + 1 ) ) + 1 n 2 = 2 j i + 1 E(X_{ij}) =\frac{2}{n(j-i+1)}*(n-(j-i+1))+\frac{1}{n}*2 \\ = \frac{2}{j-i+1}
    因此,有快排的时间复杂度是 O ( n l o g n ) O(nlogn)

快速排序的pivot选择

快速排序的好坏与pivot有关,根据上面的推论可以知道,大部分情况下我们都能使得pivot选的不错。为了使得这个可能性更大(即要使pivot选的更接近中间),可以有以下几种思路提高划分效果:

  • 1、随机选三个数,取三个数的中位数作为pivot
  • 2、取首中尾三个元素,取中位数作为pivot
  • 3、添加一层循环,每一层划分之后判断一下 S n 4 , S + n 4 |S_-|\geq\frac{n}{4},|S_+|\geq\frac{n}{4} 两个条件是否成立,如果不成立,则重新选择,否则退出循环

普通快速排序实现代码

所谓普通快速排序,是不考虑存储空间消耗的非原地排序方式,pivot选择第一个元素,依据上文给出的快排伪代码实现,如下(采用python):

def quick_sort(items,
              reverse = False):
    '''
    快速排序
    '''
    length = len(items)
    if length <= 1:
        return items
    else:
        mid_item = items[0]
        right_items = [item for item in items[1:] if item > mid_item]
        left_items = [item for item in items[1:] if item <= mid_item]
        if reverse:
            return list(reversed(quick_sort(left_items) + [mid_item] + quick_sort(right_items)))
        return quick_sort(left_items) + [mid_item] + quick_sort(right_items)


if __name__ == '__main__':
    items = [4, 3.6, 2, 8.5, 10.5]
    new_items = quick_sort(items)
    new_reversed_items = quick_sort(items, reverse = True)
    print(new_items)
    print(new_reversed_items)

总结

关于快速排序,还有一个很重要的特性,就是可以实现原地排序(不需要额外的存储空间),这一点使得它地位要高于归并排序(需要额外一倍存储空间存储排序数组)。这个内容留到下次添加。

如果你觉得文章对你有用,不妨顺手点个赞哦~

发布了46 篇原创文章 · 获赞 99 · 访问量 6万+

猜你喜欢

转载自blog.csdn.net/GentleCP/article/details/101108695
今日推荐