快速排序是一种有趣的算法,也是软件工程师的最爱,它具有一些独特的优势和怪癖值得研究。快速排序可以非常高效,通常优于合并排序,尽管在某些情况下可以使其像气泡排序一样缓慢。与往常一样,我们将首先深入探讨此特定算法的工作原理,然后再探讨其行为方式的更细微之处。
我们将从一个未排序的数组开始:
arr = [9,7,4,2,3,6,8,5,1]
快速排序的工作原理是从数组内部的某个位置选择一个项目,然后将所有项目与该项目进行比较。我们将此项目称为枢轴。对数组进行排序后,枢轴左侧的所有内容都将小于枢轴,右侧的所有内容都将更大。快速排序使它从未排序数组的末端到中间。当它在左侧找到应该在右侧的项目,然后又在右侧找到了应该在左侧的项目时,它将交换这两个项目。
您可以将数据透视表左侧的数组部分和数据透视表右侧的数组部分视为自己的子数组。现在,我们将它们视为自己的独特子数组,然后将算法递归应用于每个子数组。此递归除法和比较方案与合并排序采用相同的除法方法,因此,此处的并行操作可以轻松了解为什么平均需要O(n * log(n))时间。
为了说明这一点并分析分治法的实现方式,我们将选择元素尽可能靠近数组的中间位置。在算法的第一次迭代中,我们将选择数字3
,以中间为中心。选择我们的支点之后,这就是开始之前我们的子数组的样子:
那么,如何围绕枢轴有效地对这两个子数组进行排序?我们可以简单地遍历数组以查看右侧是否小于枢轴,然后将它们移至左侧,反之亦然。如果我们在左侧和右侧进行迭代,移动适当的项目,最终将得到一个位于轴心一侧的数组,以及一个包含其他未排序元素的数组。我们将需要遍历未排序数组的其余部分,并将属于另一个数组的项目推到另一个数组上。
最终得到一对看起来像这样的数组:
现在,我们知道左侧数组中的所有内容都属于枢轴的左侧,而右侧数组中的所有内容都属于枢轴的右侧。现在,我们可以递归地将此逻辑应用于所有这些子数组,直到对每个项目进行排序为止。至少,这就是分而治之的方法。
实际的快速排序算法不会将任何内容分解为较小的子数组。在这里,在执行比较之前将数组递归地分成几对的行为仅用于直观地说明为什么其平均复杂度是O(n * log(n)),我们将在后面进行探讨。
虽然我们在先前的安装中已经讨论了很多时间复杂性,但是我们还需要讨论的一件事是空间复杂性。如果执行得当,快速排序算法实际上不会递归地划分馈入自身的子数组。在介绍它做什么之前,让我们看一下为什么它不这样做。我们可以参考本系列前面部分之一的“合并排序”的 Python 代码:
def merge_sort(unsorted):
if len(unsorted) > 1:
mid = len(unsorted)//2
left = merge_sort(unsorted[:mid])
right = merge_sort(unsorted[mid:])
result = merge(left, right)
return result
else:
return unsorted
在这里,我们可以开始分析它如何利用空间。它需要一个未排序的数组,并分配另外两个数组,每个数组的大小是传递的数组大小的一半。然后,将这两个数组都馈送到同一函数中,该函数再次递归地为另外 2 个数组分配空间。因此,例如,让我们看一个包含 8 个元素的数组。在第一次迭代中,我们总是为新数组分配n / 2 个空间,从整个左侧向下移动,然后递归地向上移动并移至右侧。这里确切的空间复杂度并不重要,要理解的重要一点是它需要额外的空间,并且为这些操作分配和释放内存会影响性能。
除了分配额外的空间来保存正在处理的子数组外,还可以仅传递概述原始数组上正在处理的子数组的索引的函数。这允许通过直接在实际数组上执行操作来对数组进行排序,这称为就地排序。
就地排序具有仅占用O(1)额外空间的优点。假设您的“快速排序”功能只有 3 个变量:枢轴,左侧和右侧边界。如果使用 C 语言编写,则意味着每个函数调用仅需为 3 个变量分配空间,这可能只是 4 个字节的无符号整数或总共 12 个字节。传递给它的数组是否为 40,000,000(40,000,000)无关紧要,它在调用时仍只需要分配 12 个字节。这就是为什么在进行原位排序时认为它具有O(1)空间复杂性,所需空间量恒定且不会增长的原因。
在较早的概述中,该算法被解释为在子数组上进行手动迭代,并且仅在将项目与数据点进行比较之后才移动项目。就地执行此操作需要稍有不同的方法来执行相同的操作。考虑我们原始的未排序数组arr
, [9,7,4,2,3,6,8,5,1]
。有 9 个项目,如果我们选择了中间项目, arr[4]
,我们的重点是 3
。与其创建单独的左右数组,不如通过设置一个left index
还有一个
[https://www.objectx.cn/forum.php?mod=forumdisplay&fid=45&filter=typeid&typeid=13:] https://www.objectx.cn/forum.php?mod=forumdisplay&fid=45&filter=typeid&typeid=13 "left index"
,它将从数组的左右边界开始。
我们从左边的项目开始,然后将其与我们的数据中心进行比较。如果左侧的项目小于枢轴,也就是说,左侧枢轴指向的项目属于枢轴的左侧,则我们将left index
向前转发并比较该数字。我们继续前进[left index][https://www.objectx.cn/forum.php?mod=forumdisplay&fid=45&filter=typeid&typeid=13]
向前,直到找到不属于枢轴左侧的项目。找到此类物品后,我们将停止left index
然后开始比较 right index
到枢纽。当左侧的项目属于右侧,而右侧的项目属于左侧时,将交换这两个项目。自第一项以来left index
看着, arr[0]
,是 9
,它属于枢轴的右侧,我们从 arr[0]
。自第一项以来right index
右侧枢轴选择的平均时间比中位数 3 的时间长 30 倍!右侧枢轴选择比其他方法快得多,但只有在传递了真正随机的,未排序的数组时才起作用。如果该优点通过了大部分或完全已经排序的数组,则该优点会很快消失并严重损害性能。
虽然在完全随机和完全排序的列表中,随机选择的枢轴的行为几乎完全相同,但是在处理可以进行某种排序的数组时,中位数 3 枢轴选择显然是赢家。实际上,在所有类别中,“中位数 3”选择方案花费了大约 2/3 的时间作为随机选择方案。这是因为在这种情况下,中位数 3 始终会创建完美平衡的数组,而随机选择的 3 会生成合理平衡但仍不平衡的数组。