每个程序员都需要了解他们的算法和数据结构。 在研究它们时,您需要确保确切了解它的功能,时间和空间的复杂性以及采用这种方法的原因,并且不仅能够对其进行编码,而且能够手动执行。 这就是基本算法的全部意义。
欢迎来到基本算法的第一次安装,今天在这里,我们将介绍合并排序。 合并排序是用于对大型数据集进行排序的最可靠的一致且性能最佳的排序算法之一,与快速排序相比, 最糟糕的情况是平均执行的比较少了近40%。 因此,让我们深入研究一下此算法是什么。
合并排序算法
首先,让我们看一个简单的数字数组,这些数字是乱序的,我们要对其进行排序:
var arr = [ 27 , 369 , 63 , 126 , 252 , 990 , 54 , 18 ]
合并排序算法可以采用这些数字,并以一致且有效的方式将它们按大小从小到大或从小到大的顺序排列。 合并排序采用递归的分治方法。 也就是说,它通过调用自身将数组分解为越来越小的数组,直到每个数组中只有一个元素为止。 然后,它一次比较两个数字,将它们排序并返回它们本身,因为它从递归调用函数中退出。 因此,我们上面的数组变成了以下子数组:
[[ 27 , 369 , 63 , 126 ],
[ 252 , 990 , 54 , 18 ]]
并再次放入:
[[[ 27 , 369 ], [ 63 , 126 ]], [[ 252 , 990 ], [ 54 , 18 ]]]
最后一次:
[[[[ 27 ], [ 369 ]], [[ 63 ], [ 126 ]]], [[[ 252 ], [ 990 ]], [[ 54 ], [ 18 ]]]]
将元素分类到子数组中后,需要对每对元素进行求值,以便在合并在一起时,左值小于右值。 在此示例中,仅最后一对单元素数组在左侧具有较大的值,因此[54], [18]
将合并为[18,54]
:
[[[ 27 , 369 ], [ 63 , 126 ]], [[ 252 , 990 ], [ 18 , 54 ]]]
通过将最小的元素保留在嵌套最多的子数组的index [0]上,可以有效地按顺序合并子数组。 为此,比较两个数组的第一个元素。 然后,从子数组中取出较小的元素,然后将它们放入要返回的数组中,同时将它们从要比较的数组中取出。
因此,比较数组[27, 369]
和[63, 126]
,我们只需要查看数组的第一项即可。 由于27 <63,因此27将被从第一个数组中取出,并将其添加到我们将返回的数组中。 这使我们可以比较[369], [63,126]
。 我们只需要再次查看第一个元素,然后看下一个63,因此我们返回的数组为[27,63]
,我们仍然需要比较[369], [126]
。 然后,我们添加126,只剩下369,因此我们将其添加。 总而言之,步骤如下所示:
[ 27 , 369 ] [ 63 , 126 ]
[]
[ 369 ] [ 63 , 126 ]
[ 27 ]
[ 369 ] [ 126 ]
[ 27 , 63 ]
[ 369 ]
[ 27 , 63 , 126 ]
[ 27 , 63 , 126 , 369 ]
在第二次迭代之后,数组如下所示:
[[ 27 , 63 , 126 , 369 ], [ 18 , 54 , 252 , 990 ]]
然后以完全相同的方式再次合并数组,将27与18进行比较,然后将27与54进行比较,再将63与54进行比较,依此类推,直到对整个数组进行排序并返回为止。
当我前面提到要对每个对进行评估,并且第一步只需要交换[54],[18]
,这两个对都保存在自己的数组中。 这意味着,可以通过比较两个数组的索引0处的元素,并在将较小的项添加到返回数组中时将其删除,从而创建一个函数来比较所有实例中的数字。 这将一直运行,直到其中一个数组为空,另一个数组的其余元素将附加到返回元素的末尾。
数组将以这种方式递归合并在一起,直到只剩下一个数组,然后将其完全排序。 在某种程度上,您可以将其分为两个基本步骤:
- 合并两个数组:合并两个数组。 比较其中两个的第一个元素。 应从该数组中删除较小的元素,并将其添加到我们要返回的数组末尾。 当从一个数组中删除所有元素时,只需将其他元素的其余部分添加到已排序的数组中并返回它。
- 分而治之:取一个数组。 找到它的中间。 将其左侧的所有内容传递到divide and conquer函数中,并将其右侧的所有内容传递给divide and conquer函数(因此该函数已被同时馈入了一半),直到只有一个。 然后,开始合并两个数组。
时间复杂度和效率
与堆排序类似,合并排序在最佳和最差情况下的时间复杂度为O(n * log(n)) 。 之所以不会改变,是因为它会在调用列表时执行对列表进行完全排序所需的所有操作。 合并排序获取列表时,对列表进行排序并不重要。 它可以已经排序,也可以尽可能不排序,但是合并排序仍然必须执行列表的O(log(n))划分和O(n)比较。
这与诸如冒泡排序(Bubble Sort)之类的其他算法形成对比,后者会递归遍历列表直到对其进行排序。 如果给出理想的列表或已排序列表,则在O(n * log(n))处的合并排序的性能显着低于Bubble Sort的O(n) 。 但是,列表排序越多,Bubble Sort必须在整个数组上进行的传递越多,因此Bubble Sort的平均和最差情况是O(n ^ 2) ,这使其在平均和最差情况下的效率极低。
但是, 为什么合并排序的复杂度是准线性的,复杂度为O(n * log(n))? 这是因为列表越长,您必须执行的操作越多。 也就是说,该算法具有O(n) ,需要更多时间才能与数组长度成比例。 O(log(n))来自发生的数组的分割数,就像二叉树一样。 在我们的示例中,我们有8个元素,我们必须将它们从长度为8 => 4 => 2 => 1的数组中分解出来才能开始比较它们。 那是3个除法,2 ^ 3是8。相比之下,如果我们使用16个元素,则需要除以16 => 8 => 4 => 2 => 1,即4个除法,而2 ^ 4是16! 因此,在这里我们可以看到拆分数组的操作数相对于输入长度以对数方式增长。 因此,它都在O(log(n))和O(n)处增长,给我们O(n * log(n))时间复杂度。
合并排序(Merge Sort)在没有进行适当排序的情况下,在考虑到空间的情况下有效实现时,其空间复杂度为O(n) ,这意味着执行所需的内存与输入的大小成比例地增长。 这意味着,对于输入的每个字节,必须在内存中分配一个字节以保存输出。
程式码范例
足够的概述,让我们看几个例子。 两个示例将使用与上面相同的数组。 阅读以上概述,我们可以将我们需要做的事情分解为两个基本步骤:
- 我们需要将数组递归分解为更小的子数组
- 我们需要将子数组合并在一起,并在填充时对其进行排序。
通过这两个主要步骤,我们现在可以编写2个不同的函数来实现合并排序算法。 请记住,除了代码长度和可读性之外,这些示例都不会针对任何其他内容进行优化。 在我初次学习时,我发现了许多“合并排序”的示例,其中包含其他几个参数和变量,尽管它们有效地工作和使用了空间,但在初次使用时却很难阅读。
示例1:Python
我们将编写的第一个函数是将提供的数组分解为子数组,并调用我们的另一个函数的函数。 由于这将是算法的父函数,因此我将其命名为merge_sort()
,它将作为唯一参数传递给未排序列表。 它将处理递归破坏数组,并将其传递给处理实际合并和排序的函数merge()
。
由于我们要将未排序的列表分为2个单独的列表,因此我们需要计算列表的中点。 将列表长度除以一半是可行的,但是如果您使用的是Python 3,则/
执行浮点运算,因此请使用//
来指定整数除法。 我们可以声明mid = len(sorted)//2
。 通过计算出的中点,我们可以将数组的两半传递回merge_sort()
。
def merge_sort (unsorted) :
mid = len(unsorted)// 2
left = merge_sort(unsorted[:mid])
right = merge_sort(unsorted[mid:])
现在,如果数组的长度超过1个元素,则可以正常工作,但是一旦我们减少到一个,我们将开始出现错误和无限循环。 因此,我们需要告诉程序仅在传递给函数的每个未排序列表中有多个元素时执行此操作。
当数组或包含2个或更多元素的列表传递给函数时,我们希望它返回给我们排序后的列表,这将是我们的下一个函数。 如果仅传递一个元素,我们希望它仅返回那个元素。 因此,我们可以将所有这些包装在if / else语句中,并添加return语句,此功能将是完整的:
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
在调用我们的merge()
merge_sort()
函数之前先调用merge_sort()
函数可确保在开始合并之前完成所有划分。 有了这个,我们可以开始构建merge()
函数。
merge()
函数将使用2个参数,即左侧和右侧数组。 我们将创建一个空的sorted_list
来保存返回的结果,并使用左右列表中的pop(0)
填充该结果。 我们将使用一些while循环来执行此任务。
虽然左侧和右侧都有未排序的列表,但我们需要比较第一个元素。 两者中较小的一个将从列表的前面弹出到我们将返回的sorted_list
。
def merge (left, right) :
sorted_list = []
while len(left) > 0 and len(right) > 0 :
if left[ 0 ] <= right[ 0 ]:
sorted_list.append(left.pop( 0 ))
else :
sorted_list.append(right.pop( 0 ))
那么,当我们留下一个空列表而不是空列表时会发生什么呢? 好吧,什么都没有。 由于我们不知道哪个列表将首先用完,或者列表将持续多久,因此在完成此循环后,我们需要再添加两个以检查左侧和右侧是否仍在填充,然后将其添加到sorted_list
之前的sorted_list
。 这为我们提供了以下merge()
函数:
def merge (left, right) :
sorted_list = []
while len(left) > 0 and len(right) > 0 :
if left[ 0 ] <= right[ 0 ]:
sorted_list.append(left.pop( 0 ))
else :
sorted_list.append(right.pop( 0 ))
while len(left) > 0 :
sorted_list.append(left.pop( 0 ))
while len(right) > 0 :
sorted_list.append(right.pop( 0 ))
return sorted_list
因此,现在,我们的整个mergesort.py
文件如下所示:
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
def merge (left, right) :
sorted_list = []
while len(left) > 0 and len(right) > 0 :
if left[ 0 ] <= right[ 0 ]:
sorted_list.append(left.pop( 0 ))
else :
sorted_list.append(right.pop( 0 ))
while len(left) > 0 :
sorted_list.append(left.pop( 0 ))
while len(right) > 0 :
sorted_list.append(right.pop( 0 ))
return sorted_list
现在,我们可以对其进行测试,看看它是否有效:
然后你走了! 易于阅读,简短的Python实现。
示例2:Javascript
再次,在此示例中,我们编写了第一个merge_sort()
函数,以将未排序的数组拆分为较小的子数组,在调用merge()
函数之前,递归调用自身,直到完成所有划分为止。
由于我们需要将其分成2个单独的数组,因此我们再次需要计算中点。 Javascript将对奇数执行浮点计算,因此我们将使用Math.floor()
进行四舍五入,就像Python对//
除数所做的那样。 因此,我们可以为中点设置一个变量,创建左右数组,然后在调用merge()
之前将它们传递回merge_sort()
merge()
。
const merge_sort = ( function ( unsorted ) {
var mid = Math .floor(unsorted.length/ 2 )
var left = unsorted.slice( 0 ,mid)
var right = unsorted.slice(mid,)
})
这对于包含多个元素的数组很好用,但是当您只有一项时,会导致无限循环和有关未定义变量的错误。 我们必须将其包装在if语句中,仅在unsorted
元素有多个的情况下才执行此代码块,而在只有一个元素的情况下仅返回该元素。 我们还需要为左右数组调用我们的merge()
函数,如果unsorted
数组中有多个元素,则返回该函数。
const merge_sort = ( function ( unsorted ) {
if (unsorted.length > 1 ){
var mid = Math .floor(unsorted.length/ 2 )
var left = merge_sort(unsorted.slice( 0 ,mid))
var right = merge_sort(unsorted.slice(mid,))
return merge(left, right)
} else {
return unsorted
}
})
现在我们可以开始我们的merge()
函数。 我们将初始化一个数组以保存排序后的结果,而left和right数组都包含元素,我们将比较两者的第一个元素。 无论哪个元素较小,都会从该数组中移出并推入结果中。 为了使代码简短而紧凑,并使用简单的if/else
语句,我选择使用单行三元运算。 如果您不熟悉三元运算,则它们是单行if语句,其结构如下:
(condition to evaluate) ? ( do this if true ) : ( do this if false )
首先写出将在if语句中保留的表达式,然后是?
。 下一个表达式是如果被评估的语句为true时该怎么办,然后是:
,然后是该操作为false时该怎么办。 我更喜欢JavaScript中的这些,而不喜欢Python中的它们,因为我发现可读性更容易。 在Python中,语法为(do this if true) if (statement to evaluate) else (do this if false)
,将要评估的语句放在中间,并在每一侧进行所需的操作。 就个人而言,我觉得它不那么容易阅读。 这是我不喜欢的Python怪癖之一,非常类似于split()
vs join()
语法。
因此,我们的第一个while
循环将包含三元函数,以在数组之间适当地移动元素,我们需要再添加2个while
循环,以便在第一个循环之后检查左右数组中是否有剩余元素,并添加它们到排序的数组。
const merge = ( function ( left, right ) {
var sorted = []
while (left.length > 0 && right.length > 0 ){
left[ 0 ] < right [ 0 ] ? sorted.push(left.shift()) : sorted.push(right.shift())
}
while (left.length> 0 ){
sorted.push(left.shift())
}
while (right.length> 0 ){
sorted.push(right.shift())
}
return sorted
})
现在,我们完整的mergesort.js
文件如下所示:
const merge_sort = ( function ( unsorted ) {
if (unsorted.length > 1 ){
var mid = Math .floor(unsorted.length/ 2 )
var left = merge_sort(unsorted.slice( 0 ,mid))
var right = merge_sort(unsorted.slice(mid,))
return merge(left, right)
} else {
return unsorted
}
})
const merge = ( function ( left, right ) {
var sorted = []
while (left.length > 0 && right.length > 0 ){
left[ 0 ] < right [ 0 ] ? sorted.push(left.shift()) : sorted.push(right.shift())
}
while (left.length> 0 ){
sorted.push(left.shift())
}
while (right.length> 0 ){
sorted.push(right.shift())
}
return sorted
})
现在,我们可以在浏览器控制台中对其进行测试,以确保其正常工作:
再谈时间复杂度
现在,我们有两种使用两种不同语言实施的算法的不同示例,我们可以返回并再次对其进行分析,以了解合并排序的复杂度为何以及为什么是O(n * log(n)) 。 上面的两个示例都创建了两个函数来执行算法的2个主要部分。 两种实现都使用父函数merge_sort()
递归地将它传递的列表分成两半。 因此,实际的merge_sort()
函数将执行几次,并且该次数随着传递给它的列表大小的对数而增加。 因此,可以说merge_sort()
函数本身的复杂度为O(log(n)) 。
另一方面,由merge_sort()
调用的merge()
函数的任务是获取两个输入并将它们组合为一个输出。 这将花费的时间完全取决于传递给它的输入的大小。 因此,可以说merge()
函数的复杂度为O(n) 。 该merge()
函数,在O(N)被调用一次 ,每次merge_sort()
被调用,并且次数merge_sort()
被称为是log(n)的时间,给我们提供了最后的复杂度为O(N *日志(n)) 。
合并排序算法始终有效,这就是为什么它被用作Java和Perl等语言的默认排序算法以及Python使用的混合Timsort的一部分的原因。 时间复杂度保持恒定,这在考虑性能和优化时是一件好事,并且由于它具有将输入递归地分成两半的性质,因此如果一个人一次可以访问多个CPU,则可以高度优化该算法。
这样就可以了,合并排序。 希望在此之后,您对如何做到有扎实的了解,甚至可以在直观的水平上真正理解O(n * log(n))的实际含义。 如果您喜欢这个或学到了东西,请分享!
From: https://hackernoon.com/essential-algorithms-the-merge-sort-8n2y3yju