原文:https://stackabuse.com/quicksort-in-python/
作者:Marcus Sanatan
译者:老齐
欢迎在 bilibili 搜索 freeCodeCamp 官方账号或者直接访问 https://space.bilibili.com/335505768 观看我们的技术视频介绍
快速排序是一种流行的排序算法,经常与归并排序一起使用。快速排序是一个高效的排序算法,平均复杂度为 O(nlogn)。它受欢迎的部分原因还在于易于实施。
在本文中,我们将先使用整数集合演示快速排序算法,然后会用自定义对象深入探讨这种算法的实现方式。
在分治法、原地排序和非稳定排序中,都有快速排序算法。
- 在分治法中,对大数组使用递归进行排序之前,使用快速排序算法将数组拆分为更小的数组,直到最后得到一个空数组或只有一个元素的数组。
- 在原地排序中,快速排序不创建数组的任何副本,它在内存中完成所有的操作。
- 稳定排序指相同的值在数组中的顺序也是相同的,非稳定的排序算法不能保证这一点,只能说这样的顺序当然有可能出现,但不能保证。这在针对对象排序而不是对基本类型排序时变得很重要。例如,假设有几个自定义的 Person 类实例具有相同的 age,即 21 岁的 Dave 和 21 岁的 Mike。如果要对包含 Dave 和 Mike 的集合使用快速排序法按年龄排序,则无法保证每次运行算法时 Dave 都会出现在 Mike 之前,反之亦然。
快速排序
快速排序算法的执行流程如下:
- 将集合分成两个(大致)相等的部分,取一个伪随机元素并将其用作主元(注:“主元”,原文中 pivot,在多数介绍快速排序算法的中文资料中,并不对齐单独命名,只简单说成是“分割点”,即划分集合的元素,但本文作者使用了 pivot 这个词来表示“分割点”,译者认为很便于理解和表述,并将其翻译为“主元”)。
- 小于主元的元素将移动到其左侧,大于主元的元素将移动到其的右侧。
- 分别对于主元左侧和右侧的元素,重复此上述,直到对整个数组完成排序。
当我们将某个元素描述为比另一个元素“大”或“小”时,并不一定意味着这些元素是整数,我们可以自定义对象,并根据选择的任何属性进行排序。
假设有一个自定义类 Person,每个实例都有 name 和 age 属性,我们可以按姓名(词典顺序)或按年龄(升序或降序)进行排序。
快速排序算法的原理
对于快速排序算法,很多时候,数组是不能等分的,这是因为整个过程取决于如何选择主元。我们需要选择一个主元,使其比一半的元素大,比另一半的元素小。尽管这个过程看起来很直观,但很难做到。
想一想:如何为数组选择合适的主元?关于这个问题的解决方法,在快速排序算法的发展史上有过好多种。如果随机选择,是行不通的,因为这不能保障主元是合适的,随机选择的代价会非常“昂贵”。此外,还曾经有人提出从中间选择一个元元素,或者选择第一个、或者后半部分中间的,设置用更复杂的递归公式选择,等等。
最直接的最简单的方法是选择第一个(或最后一个)元素作为主元,具有讽刺意味的是,这会导致快速排序法对已经排序(或几乎排序了)的数组执行效果非常糟糕。
但是,大多数人还是用这种方法来实现快速排序算法,因为它很简单,而且这种选择主元的方式是一种非常有效的操作(我们需要重复执行),这也正是我们下面要做的。
既然我们选择了一个主元,接下来该用它做些什么?同样,有几种方法可以处理各个分区。我们要设置三个指针,有一个指向主元,另外两个分别指向“较小”元素和“较大”元素。
然后移动元素,使所有小于主元的元素都在左侧,而所有较大的元素都在右侧。更小或更大的元素不一定会被排序,我们只希望它们位于主元的适当的一侧。然后,用递归方法遍历主元的左侧和右侧。
一步一步来看看我们计划要做的事情,从而理解以上所说的快速排序算法的执行过程。使用下面显示的数组,我们选择了第一个元素作为主元(29),low 表示指向较小元素的指针,最右边的 high 表示指向较大元素的指针。
- 29 是第一主元,low 指向 99,high 指向 44
29 | 99 (low),27,41,66,28,44,78,87,19,31,76,58,88,83,97,12,21,44 (high)
- high 从右向左移动,直到找到一个小于主元的值
29 | 99 (low),27,41,66,28,44,78,87,19,31,76,58,88,83,97,12,21 (high),44
- 现在 high 指向了 21,它是一个比主元小的元素,我们想在数组的开头附近找到一个值,使之可以用于与 21 交换。用一个比主元还小的值交换是没有意义的,所以如果 low 指向一个更小的元素,我们试着找到一个更大的元素。
- 将 low 变量向右移动,直到找到一个大于主元的元素。幸运的是, low 已经定位在 99,它就比主元大
- 交换 high 和 low 指向的元素:
29 | 21 (low),27,41,66,28,44,78,87,19,31,76,58,88,83,97,12,99 (high),44
- 在我们这样做之后,就将 high 继续向左移动,将 low 向右边移动(21 和 99 现在所处的位置是正确的)。
- 再次,将 high 向左移动,下一个数字就是小于主元的 12。
- 现在我们通过将 low 向右移动来找一个大于主元的值,就是 41 了,它是第一个这样的值
不断重复上述过程,直到 low 指针和 high 指针最终在某个元素相遇:
29 | 21,27,12,19,28 (low/high),44,78,87,66,31,76,58,88,83,97,41,99,44
- 不再使用 29 作为主元了,所以剩下唯一要做的就是交换主元和 high 所指元素 28,然后我们就完成了这个递归步骤:
28,21,27,12,19,29,44,78,87,66,31,76,58,88,83,97,41,99,44
如你所见,我们已经让小于 29 的所有值现在都在 29 的左侧,大于 29 的所有值都在右侧。
然后,算法对 28、21、27、12、19(左侧)集合和44、78、87、66、31、76、58、88、83、97、41、99、44(右侧)集合执行相同的操作。
实现
对数组排序
快速排序是一种自然递归算法,将输入数组分成较小的数组,将元素移动到主元的适当一侧,然后重复。
让我们来看看几个递归调用:
- 当第一次调用算法时,我们考虑所有的元素——从索引 0 到 n-1,其中 n 是数组中的元素数量。
- 如果主元最终位于位置 k,那么我们将对从 0 到 k-1 和从 k+1 到 n-1 的元素重复该过程。
- 在将元素从 k+1 排到 n-1 时,当前主元将在某个位置 p 结束。然后我们将元素从 k+1 序到 p-1,从 p+1 排序到 n-1,依此类推。
也就是说,我们将使用两个函数:partition() 和 quick_sort()。quick_sort() 函数将首先用 partition() 函数对集合分组,然后在每组上递归调用自己。
下面从 partition() 函数开始:
def partition(array, start, end):
pivot = array[start]
low = start + 1
high = end
while True:
# If the current value we're looking at is larger than the pivot
# it's in the right place (right side of pivot) and we can move left,
# to the next element.
# We also need to make sure we haven't surpassed the low pointer, since that
# indicates we have already moved all the elements to their correct side of the pivot
while low <= high and array[high] >= pivot:
high = high - 1
# Opposite process of the one above
while low <= high and array[low] <= pivot:
low = low + 1
# We either found a value for both high and low that is out of order
# or low is higher than high, in which case we exit the loop
if low <= high:
array[low], array[high] = array[high], array[low]
# The loop continues
else:
# We exit out of the loop
break
array[start], array[high] = array[high], array[start]
return high
最后,让我们实现 quick_sort() 函数:
def quick_sort(array, start, end):
if start >= end:
return
p = partition(array, start, end)
quick_sort(array, start, p-1)
quick_sort(array, p+1, end)
有了这两个函数,就可以对一个简单的数组执行 quick_sort():
array = [29,99,27,41,66,28,44,78,87,19,31,76,58,88,83,97,12,21,44]
quick_sort(array, 0, len(array) - 1)
print(array)
输出:
[12, 19, 21, 27, 28, 29, 31, 41, 44, 44, 58, 66, 76, 78, 83, 87, 88, 97, 99]
由于此算法是非稳定的,所以不能保证这两个 44 的顺序总是这样的,也许会交换 —— 虽然这在整数数组中意义不大。
对自定义对象进行排序
有几种方法可以重写此算法,以便在 Python 中对自定义对象进行排序。一种非常典型的 Python 方法是实现给定类的比较运算符,这意味着我们实际上不需要更改算法实现,因为 >、=、<= 等也可以应用于类对象。
另一个选择是以参数的方式给函数传入一个比较函数,用这个方法来执行对象的实际比较。用这种方式重写算法函数以用于自定义对象是相当简单的。但是请记住,算法并不稳定。
让我们从 Person 类开始:
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def __str__(self):
return self.name
这是一个非常基本的类,只有两个属性,name 和 age。我们希望使用 age 作为排序键,这将通过为排序算法提供自定义 lambda 函数来实现。
但首先,让我们看看如何在函数中实现算法。我们没有直接使用 <= 或 >= 运算符进行比较,而是在函数中来判断哪个 Person 实例的年龄更高:
def partition(array, start, end, compare_func):
pivot = array[start]
low = start + 1
high = end
while True:
while low <= high and compare_fun(array[high], pivot):
high = high - 1
while low <= high and not compare_fun(array[low], pivot):
low = low + 1
if low <= high:
array[low], array[high] = array[high], array[low]
else:
break
array[start], array[high] = array[high], array[start]
return high
def quick_sort(array, start, end, compare_func):
if start >= end:
return
p = partition(array, start, end, compare_func)
quick_sort(array, start, p-1, compare_func)
quick_sort(array, p+1, end, compare_func)
现在,让我们对这些实例对象的集合进行排序。你可以看到,在 quick_sort 函数的参数中,有 lambda 函数,它会对 age 属性的值进行实际的比较:
p1 = Person("Dave", 21)
p2 = Person("Jane", 58)
p3 = Person("Matthew", 43)
p4 = Person("Mike", 21)
p5 = Person("Tim", 10)
array = [p1,p2,p3,p4,p5]
quick_sort(array, 0, len(array) - 1, lambda x, y: x.age for person in array:
print(person)
输出是:
Tim
Dave
Mike
Matthew
Jane
通过这种方式实现算法,只要提供适当的比较函数,它就可以用于我们选择的任何自定义对象。
优化快速排序算法
考虑到快速排序独立地对给定数组的“一半”进行排序,那么它就非常便于实现并行计算。我们可以用一个单独的线程对数组的每个“一半”进行排序,理想情况下,可以将排序所需的时间减半。
但是,如果我们在选择主元时特别不走运,快速排序法可能会递归太深,此时即使用并行计算,其效率也不如归并排序。
对小数组进行排序时,建议使用非递归算法。即使是像插入排序这样的简单操作,在小数组上也比快速排序更有效。因此,理想情况下,我们可以检查排序对象是否只有少量元素(大多数建议是 10 个或更少),如果是这样,就用插入排序代替。
快速排序的一个流行变体是多主元快速排序,它使用 n-1 个主元将原始数组分解为 n 个较小的数组。然而,大多数情况下只使用两个主元,而不是更多。
有趣的事实:Java 7 的排序实现中使用了双主元快速排序,针对较小数组的则是插入排序。
结论
正如我们前面提到的,快速排序的效率很大程度上取决于主元的选择——它可以成就或突破算法时间(和堆栈空间)的复杂度。在使用自定义对象时,算法的非稳定性也是一个潜在的问题。
然而,尽管如此,快速排序的平均时间复杂度 O(nlogn) 和相对低的内存使用、简单的实现方法,使它成为一种非常有效和流行的算法。
推荐阅读:
30 行 Python 代码实现 Twitch 主播上线实时通知
从软件工程专业思考到的大前端技术栈
非营利组织 freeCodeCamp.org 自 2014 年成立以来,以“帮助人们免费学习编程”为使命,创建了大量免费的编程教程,包括交互式课程、视频课程、文章等。线下开发者社区遍布 160 多个国家、2000 多个城市。我们正在帮助全球数百万人学习编程,希望让世界上每个人都有机会获得免费的优质的编程教育资源,成为开发者或者运用编程去解决问题。
你也想成为 freeCodeCamp 社区的贡献者吗?欢迎了解 招募丨freeCodeCamp 翻译计划。
点击【在看】,与朋友交流?