【算法日积月累】7-两路快排
一、第 1 版快速排序的两个优化和问题
1、两个优化
2、问题
在有很多重复元素的情况下,放在中间的那个 j 的位置也会使得递归的过程变得很不平衡,这个时候我们也可以采取一定的优化措施。
我们可以编写一个测试用例,构造出一个有很多个重复键值的数组,分别使用“归并排序”和“快速排序”,看看它们的耗时。
from sort.sort_helper import generate_random_array
from sort.C_merge_sort_1 import merge_sort
from sort.D_quick_sort import quick_sort
from sort.sort_helper import check_sorted
import time
# 最小值是 10,最大值是 20,都可以取到
# 取了 10000 个元素,用快排1和归并排序测试一下
nums = generate_random_array(10, 20, 10000)
print(nums)
nums_for_merge_sort = nums[:]
nums_for_quick_sort_1 = nums[:]
begin = time.time()
merge_sort(nums_for_merge_sort)
print('归并排序耗时:', time.time() - begin)
begin = time.time()
quick_sort(nums_for_quick_sort_1)
print('快速排序耗时:', time.time() - begin)
check_sorted(nums, nums_for_merge_sort)
check_sorted(nums, nums_for_quick_sort_1)
运行结果:
可以看到,“快速排序”比我们第 1 版没有优化过的“归并排序”都慢很多。
我们不妨将待测试数组的重复元素搞得多一些。
可以看到,此时“归并排序”可以完成排序任务,而我们第 1 版的“快速排序”已经抛出异常了,这个异常不是因为我们编写的逻辑有严重错误,而是因为我们这个测试用例太极端了,这个异常就是“递归深度太深”,因为重复元素太多,都被分到了数组的同一侧,而导致递归深度太深,导致系统栈都不够用了。
二、第 2 版快速排序:双路快排
发现问题:在有很多重复元素的情况下,放在中间的那个 j
的位置也会使得递归的过程变得很不平衡。
基本思想:指针对撞的双路快速排序,在有很多键重复的情况下,重复的键能够比较“均匀地”分布在数组的前后,即将与标定点相等的元素等概率分散到递归函数的两边。
实现方式:把等于标定点的元素“等概率地”分散到了标定元素左右两边。
小技巧:在编写与“指针”(不是 C++ 中的指针)相关的逻辑的时候,我们一定要把握住我们设置的指针的含义,在遍历的过程中,位置这个指针的含义不变,这样才能编写出正确的代码。
对于一些边界条件,一定要思考清楚,如果刚开始写有困难的,可以考虑以下几种方式把代码写对:
1、参考他人优秀的代码,即使是抄代码也要抄明白,抄完以后自己复现一下;
2、在代码中输出一些打印语句,或者使用代码编辑器的 debug 功能对代码进行调试;
3、使用小规模的测试用例在纸上走一下代码逻辑,把设置的指针的含义,循环不变量是如何维持的写出来,很多问题就看得比较清晰了。
对于这种比较抽象的逻辑,如果在脑子里不能想得特别清楚,在纸上写写画画是一个很不错的选择,我在写这个逻辑的时候,把“指针”含义和循环不变量是怎么维持的写出来以后,一些边界条件,例如,1、什么时候退出循环;2、退出循环以后,标定点(pivot)和哪个指针交换;3、指针 i 和指针 j 的初始值是多少;这 3 个问题就看得非常清楚了。聪明的你或许不用像我一样写这么多,不过我想写写画画会加速你的思考过程,也能加深你对问题的理解,这其实也是我们常常写代码时“用空间换时间”的一种体现吧。
第 2 版基于“指针对撞”的 partition 的快速排序:
def __partition_2(nums, left, right):
p = nums[left]
i = left + 1
j = right
while True:
while i <= right and nums[i] < p:
i += 1
while j >= left + 1 and nums[j] > p:
j -= 1
if i > j:
break
nums[i], nums[j] = nums[j], nums[i]
i += 1
j -= 1
nums[left], nums[j] = nums[j], nums[left]
return j
def __quick_sort(nums, left, right):
if left >= right:
return
p_index = __partition_2(nums, left, right)
__quick_sort(nums, left, p_index - 1)
__quick_sort(nums, p_index + 1, right)
def quick_sort(nums):
__quick_sort(nums, 0, len(nums) - 1)
此时,我们可以把测试用例弄得再极端一些,发现“快速排序”不仅可以完成排序任务,而且比“归并排序”还要快一些。
总结
这一版“快速排序”最重要的优化就是针对数组中有大量和标定元素重复的元素,我们通过“指针对撞”的方式把它们分散到数组的两端,以减少递归的深度。
关于“指针对撞”其实是一个常用的算法技巧,LeetCode 上有很多关于“双指针”的问题,当然有些是链表中的,有些不是“对撞”,而是一前一后,感兴趣的朋友们不妨练习一下。
其实,我们还可以做得更好一些,我们可以把与标定点相等的元素都赶到数组的中间去,这样在有很多重复元素的数组中,一下子就可以把中间的很多元素排定,同时递归调用的深度也大大减少了,这就是我们第 3 版的快速排序,它用到的技巧我们刚刚提到过,也是“双指针”,只不过不是“对撞”,而是“一前一后”。