|
| 1 | +# 快速排序 |
| 2 | + |
| 3 | +快速排序名字可不是盖的,很多程序语言标准库实现的内置排序都有它的身影,我们就直奔主题吧。 |
| 4 | +和归并排序一样,快排也是一种分而治之的策略。归并排序把数组递归成只有单个元素的数组,之后再不断两两 |
| 5 | +合并,最后得到一个有序数组。这里的递归基本条件就是只包含一个元素的数组,当数组只包含一个元素的时候,我们可以认为它本来就是有序的(当然空数组也不用排序)。 |
| 6 | + |
| 7 | +快排的工作过程其实比较简单,三步走: |
| 8 | + |
| 9 | +- 选择基准值 pivot |
| 10 | + |
| 11 | +- 将数组分成两个子数组:小于基准值的元素和大于基准值的元素。这个过程称之为 partition |
| 12 | + |
| 13 | +- 对这两个子数组进行快速排序。 |
| 14 | + |
| 15 | +根据这个想法我们可以快速写出快排的代码,简直就是在翻译上边的描述: |
| 16 | + |
| 17 | +```py |
| 18 | +def quicksort(array): |
| 19 | + if len(array) < 2: # 递归出口,空数组或者只有一个元素的数组都是有序的 |
| 20 | + return array |
| 21 | + else: |
| 22 | + pivot_index = 0 # 选择第一个元素作为主元 pivot |
| 23 | + pivot = array[pivot_index] |
| 24 | + less_part = [i for i in array[pivot_index+1:] if i <= pivot] |
| 25 | + great_part = [i for i in array[pivot_index+1:] if i > pivot] |
| 26 | + return quicksort(less_part) + [pivot] + quicksort(great_part) |
| 27 | + |
| 28 | + |
| 29 | +def test_quicksort(): |
| 30 | + import random |
| 31 | + seq = list(range(10)) |
| 32 | + random.shuffle(seq) |
| 33 | + assert quicksort(seq) == sorted(seq) |
| 34 | +``` |
| 35 | +是不是很简单,下次面试官让你手写快排你再写不出来就有点过分啦。 当然这个实现有两个不好的地方: |
| 36 | + |
| 37 | +- 第一是它需要额外的存储空间,我们想实现 inplace 原地排序。 |
| 38 | +- 第二是它的 partion 操作每次都要两次遍历整个数组,我们想改善一下。 |
| 39 | + |
| 40 | +这里我们就来优化一下它,实现 inplace 排序并且改善下 partition 操作。新的代码大概如下: |
| 41 | + |
| 42 | +```py |
| 43 | +def quicksort_inplace(array, beg, end): # 注意这里我们都用左闭右开区间,end 传入 len(array) |
| 44 | + if beg < end: # beg == end 的时候递归出口 |
| 45 | + pivot = partition(array, beg, end) |
| 46 | + quicksort_inplace(array, beg, pivot) |
| 47 | + quicksort_inplace(array, pivot+1, end) |
| 48 | +``` |
| 49 | + |
| 50 | +能否实现只遍历一次数组就可以完成 partition 操作呢?实际上是可以的。我们设置首位俩个指针 left, right,两个指针不断向中间收拢。如果遇到 left 位置的元素大于 pivot 并且 right 指向的元素小于 pivot,我们就交换这俩元素,当 left > right 的时候推出就行了,这样实现了一次遍历就完成了 partition。如果你感觉懵逼,纸上画画就立马明白了。我们来撸代码实现: |
| 51 | + |
| 52 | +```py |
| 53 | +def partition(array, beg, end): |
| 54 | + pivot_index = beg |
| 55 | + pivot = array[pivot_index] |
| 56 | + left = pivot_index + 1 |
| 57 | + right = end - 1 # 开区间,最后一个元素位置是 end-1 [0, end-1] or [0: end),括号表示开区间 |
| 58 | + |
| 59 | + while True: |
| 60 | + # 从左边找到比 pivot 大的 |
| 61 | + while left <= right and array[left] < pivot: |
| 62 | + left += 1 |
| 63 | + |
| 64 | + while right >= left and array[right] >= pivot: |
| 65 | + right -= 1 |
| 66 | + |
| 67 | + if left > right: |
| 68 | + break |
| 69 | + else: |
| 70 | + array[left], array[right] = array[right], array[left] |
| 71 | + |
| 72 | + array[pivot_index], array[right] = array[right], array[pivot_index] |
| 73 | + return right # 新的 pivot 位置 |
| 74 | + |
| 75 | + |
| 76 | +def test_partition(): |
| 77 | + l = [4, 1, 2, 8] |
| 78 | + assert partition(l, 0, len(l)) == 2 |
| 79 | + l = [1, 2, 3, 4] |
| 80 | + assert partition(l, 0, len(l)) == 0 |
| 81 | + l = [4, 3, 2, 1] |
| 82 | + assert partition(l, 0, len(l)) |
| 83 | +``` |
| 84 | + |
| 85 | +大功告成,新的快排就实现好了。 |
| 86 | + |
| 87 | +# 时间复杂度 |
| 88 | +在比较理想的情况下,比如数组每次都被 pivot 均分,我们可以得到递归式: |
| 89 | + |
| 90 | +T(n) = 2T(n/2) + n |
| 91 | + |
| 92 | +上一节我们讲过通过递归树得到它的时间复杂度是 O(nlog(n))。即便是很坏的情况,比如 pivot 每次都把数组按照 1:9 划分,依然是 O(nlog(n)),感兴趣请阅读算法导论相关章节。 |
| 93 | + |
| 94 | + |
| 95 | + |
| 96 | + |
| 97 | +# 思考题 |
| 98 | +- 请你补充 quicksort_inplace 的单元测试 |
| 99 | +- 最坏的情况下快排的时间复杂度是多少?什么时候会发生这种情况? |
| 100 | +- 我们实现的快排是稳定的啵? |
| 101 | +- 选择基准值如果选不好就可能导致复杂度升高,算导中提到了一种『median of 3』策略,就是说选择 pivot 的时候 |
| 102 | +从子数组中随机选三个元素,再取它的中位数,你能实现这个想法吗?这里我们的代码很简单地取了第一个元素作为 pivot |
| 103 | +- 利用快排中的 partition 操作,我们还能实现另一个算法,nth_element,快速查找一个无序数组中的第 k 大元素,请你尝试实现它并编写单测 |
| 104 | + |
| 105 | + |
| 106 | +# 延伸阅读 |
| 107 | +- 《算法导论》第 7 章 |
0 commit comments