坐在马桶上看算法:快速排序
快速排序是一种有趣的算法,也是软件工程师的最爱,它具有一些独特的优点和怪癖值得研究。 快速排序可以非常高效,通常优于合并排序 ,尽管在某些情况下可以使其像气泡排序一样缓慢。 与往常一样,我们将首先深入探讨此特定算法的工作原理,然后再探讨其行为方式的更详细之处。
快速排序:概述
我们将从一个未排序的数组开始:
arr = [9 , 7 , 4 , 2 , 3 , 6 , 8 , 5 , 1 ]
快速排序的工作原理是从数组内部的某个位置选择一个项目,然后将所有项目与该项目进行比较。 我们将此项目称为枢轴 。 对数组进行排序后,枢轴左侧的所有内容都将小于枢轴,右侧的所有内容都将更大。 快速排序使它可以从未排序数组的末端到中间。 当它在左侧找到应该在右侧的项目,然后又在右侧找到了应该在左侧的项目时,它将交换这两个项目。
您可以将枢轴左侧的阵列部分和枢轴右侧的阵列部分视为自己的子阵列。 现在,我们将它们视为自己的独特子数组,然后将算法递归应用于每个子数组。 此递归除法和比较方案与合并排序采用相同的除法方法,因此,此处的并行操作可以轻松了解为什么平均需要O(n * log(n))时间。
为了说明这一点并分析它如何与分治法实现一起工作,我们将选择元素尽可能靠近数组的中间位置。 在算法的第一次迭代中,我们将选择中间的数字3
作为枢轴。 选择我们的支点之后,这就是开始之前我们的子数组的样子:
那么,如何围绕枢轴有效地对这2个子数组进行排序? 我们可以简单地遍历数组以查看右侧是否小于枢轴,然后将它们移至左侧,反之亦然。 如果我们在左侧和右侧进行迭代,移动适当的项目,最终将得到一个位于轴心一侧的数组,以及一个包含其他未排序元素的数组。 我们需要遍历未排序数组的其余部分,并将属于另一个数组的项目推到另一个数组上。
最终得到一对看起来像这样的数组:
现在,我们知道左侧数组中的所有内容都属于枢轴的左侧,而右侧数组中的所有内容都属于枢轴的右侧。 现在,我们可以递归地将此逻辑应用于所有这些子数组,直到对每个项目进行排序为止。 至少,这就是分而治之的方法。
实际的快速排序算法不会将任何内容分解为较小的子数组。 在这里,在执行比较之前将数组递归地分成几对的操作仅用于直观地说明为什么其平均复杂度是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
和一个right index
进行适当的排序,它们将从数组的左右边界开始。
我们从左边的项目开始,然后将其与我们的数据中心进行比较。 如果左边的项目小于枢轴,也就是说,左枢轴指向的项目属于枢轴的左侧,我们将left index
向前移动一个并比较该数字。 我们一直将left index
向前移动,直到找到不属于枢轴左侧的项。 找到此类项目后,我们停止left index
然后开始将right index
与数据透视表进行比较。 当左侧的项目属于右侧,而右侧的项目属于左侧时,将交换这两个项目。 由于left index
看的第一项arr[0]
是9
,属于枢轴的右边,因此我们从arr[0]
。 由于第一个项的right index
是arr[8]
,所以它是1
,属于枢轴的左侧,因此这两个项都切换位置。 切换之后, left index
递增, right index
递减,因为这两项现在都在应有的位置,并且过程再次开始。
这种行为可确保在任何时候, left index
左侧的所有内容始终都将属于左侧,而right index
右侧的所有内容都始终将属于轴心。
这种排序方法将一直持续到左索引和右索引彼此相遇并彼此通过为止。 因此,在此示例中,左索引指向大于3
7
,因此我们开始将右索引向左向下移动,直到找到属于3
左侧的项。 因此,我们向右下移,比较3
到8
,然后是6
,然后是2
。 左索引和右索引将始终忽略实际的枢轴,并跳过它,因为枢轴将在最后一步中正确放置。 因此,我们的数组现在如下所示:
现在, 7
和2
开关位置。 这样,左索引和右索引移动,但是现在指向同一项目arr[2]
,即4
。 即使他们指向相同的项目,我们仍会像以前一样继续使用相同的逻辑。 我们将4
与枢轴3
进行比较。 它属于它的右侧,因此我们开始移动右枢轴以查找小于3
东西。 由于4
不小于3
,因此我们减小了右枢轴。
这使我们进入了最后一步。 从前面我们知道,右索引右侧的所有内容都属于枢轴的右侧,而左索引左侧的所有内容都属于枢轴的左侧。 随着右支点移动经过左支点,我们现在知道除支点之外的所有东西都在它所属的最终位置。
右边的索引仅在其他所有内容都已排序时才通过左边,因此这意味着左边的索引指向最后一个未排序的项目,可以简单地将其与支点交换掉,从而获得相对于支点排序的数组。
现在看我们的数组,可以说枢轴的所有右边是它相对于枢轴的位置,而枢轴的所有左边是相对于枢轴的位置。 现在可以将该算法递归应用于数组的每一侧。 左侧将再次从其原始左索引开始它的左索引,而它的右索引将在pivot-1
。 右侧将从pivot+1
开始其左侧索引,右侧的右侧索引将是原始的右侧索引。
现在,我们已对“快速排序”如何放置项目进行了高级概述,我们可以开始讨论更详细的细节并探索其他问题,例如如何确定枢轴,从而使我们能够以最高效率对其进行排序。
枢轴选择
选择快速排序的枢轴是有效或无效时间复杂度的关键。 快速排序的最坏情况是O(n ^ 2) ,但是如果正确完成,则可以是O(n * log(n)) 。 通过记住我们对“合并排序”所做的操作,并查看以下两者:1)快速排序的递归,除法性质,以及2)完成比较的次数直接随输入速率的增长而增长,我们可以轻松地理解为什么快速排序可以是O(n * log(n)) 。 但是什么行为导致它降级为O(n ^ 2) ?
挑选枢轴的两种常见方法既简单又容易,但不一定是最好的:挑选第一个项目,然后挑选最后一个项目。 可以选择这些选项来代替使用伪随机数生成器,因为多次使用PRNG可能会减慢机器速度并影响性能。 让我们考虑这个已经排序的数组: arr = [1,2,3,4,5,6,7,8,9]
。 我们也不想使用伪随机数生成器,因此,为了快速起见,我们决定只选择数组未排序分区中的最后一项。 在这里, arr[-1]
是9
。 最后没有一个右侧的数组,整个左侧的数组就是其余的数组。 这意味着在第二遍,我们的枢轴是arr[-2] = 8
,然后我们继续。 实际上,对于长度为n的数组,我们使n-1越过它,从n-1个比较开始,然后是n-2个 ,依此类推,直到最后一个项目为止。 这表明该实现与Bubble Sort的工作原理非常相似,实际复杂度为n(n-1)/ 2 ,随着输入大小的增加,使我们获得O(n ^ 2)复杂度。 当然,当始终选择第一个或最后一个项目时,这种情况会发生在已经排序或排序主要的列表中。 因此,无论何时以已排序的方式将列表传递给它,快速排序都不应实现此数据透视选择方案。
知道了这一点,我们可以排除仅选择第一个或最后一个项目作为枢轴选择的理想方法。 鉴于此,可以采用几种选择方式。 随机选择一个数字意味着按顺序选择项目的几率使每个连续项目传递给O(n ^ 2)时间的机率较小。 因此,完全随机选择一个项目可能是一种有效的方法。 但是,使用PRNG创建“随机”数可能在计算上昂贵且缓慢,这可能会导致它不得不运行多次(如庞大的列表)时自身的性能问题。
优化和可伸缩性
为了以最高效率运行算法,目标应该是创建尽可能平衡的左右分区。 当列表尽可能不平衡时,会导致性能降低到O(n ^ 2) ,这种情况是所有元素都被分配到一侧。 当列表最平衡时,会出现其最佳情况性能O(n * log(n))的行为。 因此,为了创建算法的最有效实现,我们从分析中了解以下内容:
- 我们不应该总是选择第一项,因为这会导致O(n ^ 2)运行时。
- 我们不应该总是选择最后一项,因为这可能导致O(n ^ 2)运行时。
- 我们不应该使用伪随机数生成器,因为它们很慢并且会导致自身的性能问题。
- 我们应该以合理地期望获得最佳性能的最平衡的分区来结束每个分区。
这里的技巧是弄清楚如何从一个分区中选择一个枢轴,该分区将为您提供2个相对平衡的阵列。 一开始,仅选择最靠近数组中间的项似乎很有意义。 但是,如果这样做的话,您最终选择的是第一项或最后一项,那么即使您开始使用的阵列已经被均匀地分区,最终也会出现严重失衡的阵列。 尽管这不太可能每次都发生,但仍无法带来最佳,一致的性能。
有一种方法叫做“ 三位数中值” ,它可以得出合理平衡的列表。 此方法要求您选择第一项,最后一项和中间项。 这3个项目需要进行排序(由于只有3个项目,因此可以使用诸如Bubble Sort之类的简单名称而不必担心性能)。 通过选择第一个,最后一个和一个中间项目,我们获得了一个样本,了解了我们正在寻找的范围类型。 通过这3个项目的排序集,我们可以选择中位数项目,因为知道较大和较小的项目将创建一个更加不平衡的列表。 因此,三个位数的中位数将允许您创建最平衡的分区。
让我们再次看一下第一个列表: [9,7,4,2,3,6,8,5,1]
。 第一项是9
,最后一项是1
,中间是3
。 排序后,我们得到项目[1,3,9]
。 通过选择3
,我们将创建尽可能少的不平衡分区对,因为保证其他项目创建的分区甚至更多。 不平衡。
如果您发现自己实际上并不太担心所选择语言的PRNG的运行速度有多慢,则可以轻松地选择从正在使用的分区中随机选择一个项目并将其用作你的枢纽。 有时它会创建真正不平衡的分区,但平均而言,它将创建一对相当平衡的分区。 在现实世界中,对于大多数用例而言,这通常绰绰有余。
但是,如果您发现自己必须扩大规模,则需要返回到代码库,并使排序算法更高效。 取出PRNG并将其替换为“中位数三”实现可以在两个地方提供少量的优化:首先,PRNG速度很慢,选择“ first”,“ last”和“ middle”可能更快,并且冒泡的速度可能更快。排序并选择中位数枢轴。 其次,“三位数中位数”实现不会像随机选择的枢轴一样遇到效率极低的情况。
尽管在某些情况下“快速排序”确实会衰减为O(n ^ 2) ,但它具有就位排序的能力,这与“合并排序”不同,这意味着由于不必处理所有分配问题,它可能比“合并排序”运行得更快并释放内存中的工作空间。 分配,释放,写入内存以及从内存中读取数据需要花费时间,并且通过就地排序将读写操作减至最少,这使Quick Sort优于Merge Sort。
Python范例
本示例使用Python,并将使用三位数中位数选择方案进行适当排序。 三位数中位数方案永远不会传递3个数字,因此可以使用冒泡排序轻松实现将3个数字排序并选择中间一个数字的方法。 我们首先创建一个函数,该函数将传递3个值的数组,然后将它们返回排序。
def bubble (array) :
swapped = False
for i in range( 2 ):
if (array[i] > array[i+ 1 ]):
array[i],array[i+ 1 ] = array[i+ 1 ],array[i]
swapped = True
if not swapped:
return array
else :
return bubble(array)
有了这个,我们可以启动quicksort()
函数。 它将传递3个参数:正在排序的数组,正在排序的分区的左边界和正在排序的分区的右边界。
def quicksort (arr, left, right) :
if (len(arr[left:right+ 1 ])> 2 ):
middle = (left + right)// 2
three = bubble([arr[left], arr[middle], arr[right]])
if (three[ 1 ] == arr[left]):
pivot = left
elif (three[ 1 ] == arr[right]):
pivot = right
else :
pivot = middle
else :
pivot = right
left_index = left
right_index = right
如果要排序的分区的长度超过2个项目,我们将选择第一个,最后一个和中间项目,并选择中间值作为我们的枢纽。 如果只有2,我们将选择正确的项目作为支点。 将left_index
和right_index
设置为left
和right
变量,以跟踪实际比较的项目,而left
和right
自身将跟踪正在处理的数组的边界。
现在,进入主事件循环:
while (left_index<=right_index):
if (left_index == pivot):
left_index += 1
if (right_index == pivot):
right_index -= 1
if (arr[left_index] > arr[pivot]):
if (arr[right_index] < arr[pivot]):
arr[left_index], arr[right_index] = arr[right_index],arr[left_index]
left_index += 1
right_index -= 1
else :
right_index -= 1
else :
left_index += 1
只要左索引未通过右索引,就将while
循环条件设置为继续运行。 如果left_index
使其到达分区的右边界,但未找到属于数组右侧的任何东西,则循环停止。 在这种情况下, left_index
和right_index
都将在分区的右端停止。
首先通过检查left_index
或right_index
是否在pivot
。 如果是,它将沿适当的方向移动它们以跳过枢轴。 保证索引在正确的位置后,他们可以开始将项目与pivot
进行比较。 将left_index
处的项目与数据left_index
进行比较。 如果较小,则left_index
会增加并再次与枢轴进行比较。 如果较大,则我们开始寻找right_index
所指向的项目,该项目小于数据透视表。 如果不是,它将递减并继续比较。 标识2个可交换项目后,它们将被交换,并且left_index
和right_index
都将更改,因为这两个项目现在都已就位,无需再次与数据透视表进行比较。
一旦left_index
通过right_index
或运行到分区的末尾,就该将pivot
放到位:
if (left_index < pivot):
arr[left_index], arr[pivot] = arr[pivot],arr[left_index]
pivot = left_index
elif (right_index > pivot):
arr[right_index], arr[pivot] = arr[pivot],arr[right_index]
pivot = right_index
一切都在左边 left_index
所属离开的pivot
。 同样, right_index
右边的所有内容都属于它的右边。 现在唯一的例外是pivot
本身。 因为left_index
比大pivot
时right_index
传递给它的pivot
应该只对换left_index
如果left_index
仍然在左侧pivot
,因为该项目将需要在右侧pivot
。 如果left_index
已通过pivot
,现在位于右侧,则将left_index
与pivot
交换将产生比其左侧的pivot
更大的项目。 取而代之的是,将使用right_index
切换pivot
right_index
,这是应该位于pivot
左侧的最后一项。 执行交换后,它会更新pivot
的索引。
最后,我们总结一下:
if (len(arr[left:pivot]) > 1 ):
quicksort(arr, left, pivot -1 )
if (len(arr[pivot+ 1 :right]) > 1 ):
quicksort(arr, pivot+ 1 ,right)
return arr
新的左分区是arr[left:pivot]
,新的右分区是arr[pivot+1:right]
。 如果其中只有一项,那么我们知道一项在适当的位置。 但是,如果有2个或更多,则需要评估这些项目并将其分类到适当的位置。 然后可以再次调用quicksort()
函数,对分区使用不同的左右边界,然后递归调用,直到对整个列表进行排序为止。
我们的整个quicksort.py
文件如下所示:
def bubble (array) :
swapped = False
for i in range( 2 ):
if (array[i] > array[i+ 1 ]):
array[i],array[i+ 1 ] = array[i+ 1 ],array[i]
swapped = True
if not swapped:
return array
else :
return bubble(array)
def quicksort (arr, left, right) :
if (len(arr[left:right+ 1 ])> 2 ):
middle = (left + right)// 2
three = bubble([arr[left], arr[middle], arr[right]])
if (three[ 1 ] == arr[left]):
pivot = left
elif (three[ 1 ] == arr[right]):
pivot = right
else :
pivot = middle
else :
pivot = right
left_index = left
right_index = right
while (left_index<=right_index):
if (left_index == pivot):
left_index += 1
if (right_index == pivot):
right_index -= 1
if (arr[left_index] > arr[pivot]):
if (arr[right_index] < arr[pivot]):
arr[left_index], arr[right_index] = arr[right_index],arr[left_index]
left_index += 1
right_index -= 1
else :
right_index -= 1
else :
left_index += 1
if (left_index < pivot):
arr[left_index], arr[pivot] = arr[pivot],arr[left_index]
pivot = left_index
elif (right_index > pivot):
arr[right_index], arr[pivot] = arr[pivot],arr[right_index]
pivot = right_index
if (len(arr[left:pivot]) > 1 ):
quicksort(arr, left, pivot -1 )
if (len(arr[pivot+ 1 :right]) > 1 ):
quicksort(arr, pivot+ 1 ,right)
return arr
测试速度
现在我们已经完成了快速排序和合并排序这两种通常在O(n * log(n))时间内执行的不同算法,接下来,我们可以编写一个单元测试,使我们可以直接比较两者的性能并进行分析为我们自己。 在此示例中,我将导入timeit
模块以跟踪性能。
测试将很简单:我想为每个测试创建一个充满随机数的测试数组。 数组准备好后,我将使用timeit
捕获当前时间,然后运行排序算法。 完成后,我将再次使用timeit
捕获结束时间,并计算运行时间。 这些运行时将保存在自己的数组中,并且可以执行一千次测试。 利用所有这些数据,我们可以找到最高的运行时间,最低的运行时间并计算平均值。 如果我们使用“快速排序”和“合并排序”以相同的方式进行操作,则可以建立性能之间的比较。
def time_test () :
times = []
for i in range( 1000 ):
test_arr = []
for j in range( 1000 ):
test_arr.append(random.randint( 1 , 15000 ))
start = timeit.default_timer()
quicksort(test_arr, 0 , (len(test_arr) -1 ))
stop = timeit.default_timer()
exec_time = stop-start
times.append(exec_time)
quicksort(times, 0 ,len(times) -1 )
average = sum(times)/len(times)
print "Lowest exec time: %s" % min(times)
print "Highest exec time: %s" % max(times)
print "Mean exec time: %s" % average
这是我为快速排序编写的计时测试。 我基本上也为Merge Sort写了同样的东西,只是做了一些调整。 运行两个测试后,这些是我的结果:
在这里,我们可以看到“快速排序”比“合并排序”更快,执行速度几乎是“合并排序”的两倍。
使用此功能测试性能,我们还可以探索不同的枢轴选择方法如何影响性能。 首先,我们将看到3中位数,随机数和右侧枢轴选择如何在完全随机的数组上堆叠:
显然,计算3的中位数并不是枢轴选择的最有效方法。 它的最快运行时间非常接近随机变量的最快运行时间,但是它的执行时间最高,并且平均执行时间是3种中最高的。随机选择的枢轴具有最快的最坏情况性能。 总是选择分区的最右边部分作为支点,这导致最坏情况下的性能要比3分位数的效率高得多,但是却不如随机选择的那样快。 但是,它的最低运行时间是迄今为止最快的,仅占其他两个时间的2/3。 右侧枢轴选择的平均运行时间也快得多。
显然,如果要完全不对数组进行排序,那么选择右侧数组将是赢家。 它不必费心随机生成数字或排序项目来选择3的中值,因此会更快。 数组的完全随机性质意味着函数将倾向于创建相当平衡的数组。 但是,如果数组已经大部分排序或完全排序怎么办?
为了对此进行测试,将再次稍微修改一下测试函数以随机生成一个数组,但先对其进行排序,然后将排序后的数组传递给该函数。 性能变化非常明显:
右侧枢轴选择的平均时间比中位数3的时间长30倍 ! 右侧枢轴选择的工作速度远快于其他方法,但是仅在传递了真正随机的,未排序的数组时才进行。 如果该优点通过了大部分或完全已经排序的数组,则该优点会很快消失并严重损害性能。
尽管在完全随机和完全排序的列表中,随机选择的枢轴的行为几乎完全相同,但在处理已经可以进行某种排序的数组时,中位数3枢轴选择无疑是赢家。 实际上,在所有类别中,“中位数3”选择方案花费了大约2/3的时间作为随机选择方案。 这是因为在这种情况下,中位数3 始终会创建完美平衡的数组,而随机选择的3将生成合理平衡但仍不平衡的数组。
有了这些,我们不难理解为什么Quick Sort是软件工程师的最爱。 它可靠,快速,高效,并且可以在最小的空间要求下实现。 如果您喜欢此算法,或者对本算法的检查很有用,则可以通过分享或购买咖啡来表示赞赏(尽管现在,我可能会用厕纸代替咖啡)。 如果您真的已经读了这么多,感谢您的阅读,我希望您喜欢它。
翻译自: https://hackernoon.com/essential-algorithms-the-quick-sort-mr1q32wr
坐在马桶上看算法:快速排序