为什么我的MergeSort在Python中这么慢?

时间:2011-08-15 09:59:19

标签: python algorithm sorting mergesort

我在理解这种行为方面遇到了一些麻烦。 我正在使用timeit-module测量执行时间,并获得 10000 周期的以下结果:

  • 合并: 1.22722930395
  • 泡泡:0.810706578175
  • 选择:0.469924766812

这是我的MergeSort代码:

def mergeSort(array):
    if len(array) <= 1:
        return array
    else:
        left = array[:len(array)/2]
        right = array[len(array)/2:]
        return merge(mergeSort(left),mergeSort(right))

def merge(array1,array2):
    merged_array=[]
    while len(array1) > 0 or len(array2) > 0:

        if array2 and not array1:
            merged_array.append(array2.pop(0))

        elif (array1 and not array2) or array1[0] < array2[0]:
            merged_array.append(array1.pop(0))

        else:
            merged_array.append(array2.pop(0))
    return merged_array

修改

我已经将列表操作更改为使用指针,我的测试现在使用0-1000的1000个随机数列表。 (顺便说一句:我在这里改为10个周期)

结果:

  • 合并: 0.0574434420723
  • 泡泡:1.74780097558
  • 选择:0.362952293025

这是我重写的合并定义:

def merge(array1, array2):
    merged_array = []
    pointer1, pointer2 = 0, 0
    while pointer1 < len(array1) and pointer2 < len(array2):
        if array1[pointer1] < array2[pointer2]:
            merged_array.append(array1[pointer1])
            pointer1 += 1
        else:
            merged_array.append(array2[pointer2])
            pointer2 += 1
    while pointer1 < len(array1):
        merged_array.append(array1[pointer1])
        pointer1 += 1

    while pointer2 < len(array2):
        merged_array.append(array2[pointer2])
        pointer2 += 1

    return merged_array

现在看起来效果很好:)

4 个答案:

答案 0 :(得分:10)

list.pop(0)弹出第一个元素并且必须移动所有剩余的元素,这是一个必须不会发生的额外的O(n)操作。

此外,切片list对象会创建一个副本:

left = array[:len(array)/2]
right = array[len(array)/2:]

这意味着您还使用O(n * log(n))内存而不是O(n)。

我看不到BubbleSort,但我敢打赌它可以就地运行,难怪它更快。

您需要将其重写为就地工作。而不是复制原始列表的一部分,传递起始和结束索引。

答案 1 :(得分:6)

对于初学者:我无法在100个周期和大小为10000的列表中重现您的计时结果。本回答中讨论的所有实现的timeit的详尽基准(包括bubblesort 您的原始代码段)作为要点发布here。我发现一次运行的平均持续时间的结果如下:

  • Python的本地(Tim)排序:0.0144600081444
  • Bubblesort:26.9620819092
  • (您的)原始Mergesort:0.224888720512

现在,为了让你的功能更快,你可以做一些事情。

  • 编辑:显然,我错了(感谢cwillu)。 Length computation takes O(1) in python。但是在任何地方删除无用的计算仍然会改善一些事情(Original Mergesort:0.224888720512,no-length Mergesort:0.195795390606):

    def nolenmerge(array1,array2):
        merged_array=[]
        while array1 or array2:
            if not array1:
                merged_array.append(array2.pop(0))
            elif (not array2) or array1[0] < array2[0]:
                merged_array.append(array1.pop(0))
            else:
                merged_array.append(array2.pop(0))
        return merged_array
    
    def nolenmergeSort(array):
        n  = len(array)
        if n <= 1:
            return array
        left = array[:n/2]
        right = array[n/2:]
        return nolenmerge(nolenmergeSort(left),nolenmergeSort(right))
    
  • 其次,正如this answer中所述, pop(0)是线性的。最后将合并重写为pop()

    def fastmerge(array1,array2):
        merged_array=[]
        while array1 or array2:
            if not array1:
                merged_array.append(array2.pop())
            elif (not array2) or array1[-1] > array2[-1]:
                merged_array.append(array1.pop())
            else:
                merged_array.append(array2.pop())
        merged_array.reverse()
        return merged_array
    

    这又快了:no-len Mergesort:0.195795390606,no-len Mergesort + fastmerge:0.126505711079

  • 第三个 - 如果您使用的语言可以进行尾调用优化,那么这只会是有用的,没有它,它是a bad idea - 您的合并调用到merge不是tail-recursive;在呼叫(mergeSort left)中有剩余工作时,它会递归调用(mergeSort right)(merge)

    但是你可以使用CPS使合并尾递归(如果你不做tco,这将用尽即使是适度列表的堆栈大小):

    def cps_merge_sort(array):
        return cpsmergeSort(array,lambda x:x)
    
    def cpsmergeSort(array,continuation):
        n  = len(array)
        if n <= 1:
            return continuation(array)
        left = array[:n/2]
        right = array[n/2:]
        return cpsmergeSort (left, lambda leftR:
                             cpsmergeSort(right, lambda rightR:
                                          continuation(fastmerge(leftR,rightR))))
    

    完成此操作后,您可以手动执行TCO ,以通过递归到正常函数(trampolining的while循环来推迟调用堆栈管理,例如{{3}最初由Guy Guy Steele制作的技巧)。 Trampolining和CPS一起工作

    你编写了一个thunking函数,它“记录”并延迟应用程序:它接受一个函数及其参数,并返回一个返回的函数(原始函数应用于这些参数)。

    thunk = lambda name, *args: lambda: name(*args)
    

    然后编写一个管理thunks调用的trampoline:它应用thunk直到thunk返回结果(而不是另一个thunk)

    def trampoline(bouncer):
        while callable(bouncer):
            bouncer = bouncer()
        return bouncer
    

    然后剩下的就是“冻结”(thunk)来自原始CPS函数的所有递归调用,让蹦床以适当的顺序打开它们。您的函数现在返回一个thunk,在每次调用时都不会递归(并丢弃自己的帧):

    def tco_cpsmergeSort(array,continuation):
        n  = len(array)
        if n <= 1:
            return continuation(array)
        left = array[:n/2]
        right = array[n/2:]
        return thunk (tco_cpsmergeSort, left, lambda leftR:
                      thunk (tco_cpsmergeSort, right, lambda rightR:
                             (continuation(fastmerge(leftR,rightR)))))
    
    mycpomergesort = lambda l: trampoline(tco_cpsmergeSort(l,lambda x:x))
    

可悲的是,这不会那么快(递归mergesort:0.126505711079,这个蹦床版本:0.170638551712)。好吧,我想递归合并排序算法的堆栈爆炸实际上是适度的:一旦你走出阵列切片递归模式中最左边的路径,算法就会开始返回(&amp;删除框架)。因此,对于10K大小的列表,您获得的函数堆栈最多为log_2(10 000)= 14 ...非常适中。

你可以用here的幌子来做更多涉及基于堆栈的TCO消除:

    def leftcomb(l):
        maxn,leftcomb = len(l),[]
        n = maxn/2
        while maxn > 1:
            leftcomb.append((l[n:maxn],False))
            maxn,n = n,n/2
        return l[:maxn],leftcomb

    def tcomergesort(l):
        l,stack = leftcomb(l)
        while stack: # l sorted, stack contains tagged slices
            i,ordered = stack.pop()
            if ordered:
                l = fastmerge(l,i)
            else:
                stack.append((l,True)) # store return call
                rsub,ssub = leftcomb(i)
                stack.extend(ssub) #recurse
                l = rsub
        return l

但是这只会更快一点(trampolined mergesort:0.170638551712,这个基于堆栈的版本:0.144994809628)。显然,堆栈构建python在我们原始合并排序的递归调用中做得相当便宜。

最终结果?在我的机器上(Ubuntu natty的股票Python 2.7.1+),平均运行时间(100次运行中除外 - Bubblesort-,大小为10000的列表,包含大小为0-10000000的随机整数):

  • Python的本地(Tim)排序:0.0144600081444
  • Bubblesort:26.9620819092
  • Original Mergesort:0.224888720512
  • no-len Mergesort:0.195795390606
  • no-len Mergesort + fastmerge:0.126505711079
  • 蹦床CPS Mergesort + fastmerge:0.170638551712
  • 基于堆栈的mergesort + fastmerge:0.144994809628

答案 2 :(得分:1)

您的merge-sort有一个很大的常数因素,您必须在 large 列表上运行它才能看到渐近复杂性的好处。

答案 3 :(得分:0)

嗯...... 1000条记录?你仍然在这里的多项式系数优势范围内。如果我有 selection-sort:15 * n ^ 2(读取)+ 5 * n ^ 2(交换) inserted-sort:5 * n ^ 2(读取)+ 15 * n ^ 2(交换) merge-sort:200 * n * log(n)(读取)1000 * n * log(n)(合并)

你将在一场激烈的比赛中进行一场激烈的比赛。顺便说一句,排序速度提高2倍就没有了。尝试慢100倍。这就是感受真正差异的地方。尝试“不会在我的终生中完成”算法(有一些已知的正则表达式需要这么长时间来匹配简单的字符串)。

因此,请尝试使用1M或1G记录,并告诉我们您是否仍然合并 - 排序效果不佳。

那就是说..

导致这种合并排序的成本很高很多。首先,没有人在小规模数据结构上快速运行或合并排序。如果你有if(len <= 1),人们通常会: if(len&lt; = 16):(使用内联插入排序) else:merge-sort 在每个传播级别。

由于插入排序在n的较小尺寸下具有较小的系数成本。请注意,您的工作中有50%是在最后一英里完成的。

接下来,您将不必要地运行array1.pop(0)而不是维护索引计数器。如果你很幸运,python可以有效地管理数组起始偏移,但是在其他条件相同的情况下,你正在改变输入参数

另外,你知道合并期间目标数组的大小,为什么重复复制merged_array为什么..在函数的开头预先分配目标数组的大小..这至少可以节省每个合并级别有十几个'克隆'。

通常,merge-sort使用RAM大小的2倍。由于所有临时合并缓冲区(希望python可以在递归之前释放结构),您的算法可能使用20x。它打破了优雅,但通常最好的合并排序算法立即分配一个等于源数组大小的合并缓冲区,并执行复杂的地址算术(或数组索引+跨度长度)以保持合并数据 - 来回结构。它不会像这样简单的递归问题那么强烈,但它有点接近。

在C-sorting中,缓存一致性是你最大的敌人。您需要热数据结构,以便最大化缓存。通过分配瞬态临时缓冲区(即使内存管理器返回指向热存储器的指针),您也有可能进行缓慢的DRAM调用(为即将覆盖的数据预填充缓存行)。这是插入排序,选择排序和快速排序有一个优点,即合并排序(如上所述)

说到这一点,像快速排序这样的东西既是自然优雅的代码,也是自然高效的代码,并且不会浪费任何内存(在维基百科上谷歌它们 - 他们有一个javascript实现,从中建立你的代码)。从快速排序中挤出最后一盎司的性能很难(特别是在脚本语言中,这就是为什么他们通常只使用C-api来完成这一部分),并且你有最坏情况的O(n ^ 2) 。你可以通过组合冒泡 - 排序/快速排序来尝试和聪明,以减轻最坏情况。

快乐的编码。