【问题标题】:What's the fastest algorithm for sorting a linked list?排序链表最快的算法是什么?
【发布时间】:2010-12-04 06:01:45
【问题描述】:

我很好奇 O(n log n) 是否是链表所能做的最好的。

【问题讨论】:

  • 您知道,O(nlogn) 是基于比较的排序的界限。有一些基于非比较的排序可以提供 O(n) 的性能(例如计数排序),但它们需要对数据进行额外的限制。
  • 在那些日子里,问题不像“为什么这段代码不起作用??????”在 SO 上是可以接受的。

标签: algorithm sorting linked-list complexity-theory


【解决方案1】:

可以合理地预期在运行时间中你不能做得比 O(N log N) 更好。

然而,有趣的部分是调查你是否可以对它进行排序in-placestably、它的最坏情况行为等等。

以 Putty 闻名的 Simon Tatham 解释了如何使用 sort a linked list with merge sort。他以以下 cmets 结尾:

与任何自尊排序算法一样,它的运行时间为 O(N log N)。因为这是Mergesort,所以最坏情况的运行时间仍然是O(N log N);没有病理病例。

辅助存储需求小且恒定(即排序例程中的一些变量)。由于链表与数组本质上不同的行为,这种 Mergesort 实现避免了通常与算法相关的 O(N) 辅助存储成本。

C 中还有一个示例实现,适用于单链表和双链表。

正如@Jørgen Fogh 在下面提到的,big-O 表示法可能会隐藏一些常数因素,这些因素可能会导致一种算法由于内存局部性、项目数量少等原因而表现得更好。

【讨论】:

  • 这不适用于单链表。他的 C 代码使用 *prev 和 *next。
  • @L.E.它实际上适用于both。如果您看到listsort 的签名,您会看到可以使用参数int is_double 进行切换。
  • @L.E.:这里是a Python version of the listsort C code仅支持单链表
  • O(kn) 理论上是线性的,可以通过桶排序来实现。假设一个合理的 k(您正在排序的对象的位数/大小),它可能会快一点
【解决方案2】:

根据许多因素,将列表复制到数组然后使用Quicksort 实际上可能更快。

这可能更快的原因是数组有更好的 缓存性能优于链表。如果列表中的节点分散在内存中,你 可能会在各处产生缓存未命中。再说一次,如果数组很大,你无论如何都会得到缓存未命中。

Mergesort 可以更好地并行化,因此如果这是您想要的,它可能是一个更好的选择。如果直接在链表上执行也快很多。

由于这两种算法都在 O(n * log n) 中运行,因此做出明智的决定将涉及在您希望运行它们的机器上对它们进行分析。

--- 编辑

我决定检验我的假设并编写了一个 C 程序来测量对 int 链接列表进行排序所花费的时间(使用 clock())。我尝试了一个链表,其中每个节点都分配有malloc() 和一个链表,其中节点在数组中线性布局,因此缓存性能会更好。我将这些与内置的 qsort 进行了比较,其中包括将所有内容从碎片列表复制到数组并再次将结果复制回来。每个算法都在相同的 10 个数据集上运行,结果取平均值。

这些是结果:

N = 1000:

合并排序的碎片列表:0.000000 秒

带有 qsort 的数组:0.000000 秒

合并排序的打包列表:0.000000 秒

N = 100000:

合并排序的碎片列表:0.039000 秒

带有 qsort 的数组:0.025000 秒

合并排序的打包列表:0.009000 秒

N = 1000000:

合并排序的碎片列表:1.162000 秒

带有 qsort 的数组:0.420000 秒

合并排序的打包列表:0.112000 秒

N = 100000000:

合并排序的碎片列表:364.797000 秒

带有 qsort 的数组:61.166000 秒

合并排序的打包列表:16.525000 秒

结论:

至少在我的机器上,复制到数组中以提高缓存性能非常值得,因为在现实生活中很少有完全打包的链表。需要注意的是,我的机器是 2.8GHz Phenom II,但内存只有 0.6GHz,所以缓存很重要。

【讨论】:

  • 好的 cmets,但您应该考虑将数据从列表复制到数组的非恒定成本(您必须遍历列表),以及最坏情况下的运行时间快速排序。
  • O(n * log n) 理论上与 O(n * log n + n) 相同,其中包括复制成本。对于任何足够大的 n,副本的成本真的无关紧要。遍历一个列表一次到最后应该是 n 次。
  • @DeanJ:理论上,是的,但请记住,原始发帖人提出了微优化很重要的情况。在这种情况下,必须考虑将链表转换为数组所花费的时间。 cmets 很有见地,但我并不完全相信它会在现实中提供性能提升。也许它可能适用于非常小的 N。
  • @csl:实际上,我希望本地化的好处会在大 N 中发挥作用。假设缓存未命中是主要的性能影响,那么 copy-qsort-copy 方法会产生大约 2 *N 次缓存未命中的复制,加上 qsort 的未命中数,这将是 Nlog(N) 的一小部分(因为 qsort 中的大多数访问是对靠近最近访问的元素的元素) .合并排序的未命中数是 Nlog(N) 的较大部分,因为较高比例的比较会导致缓存未命中。所以对于大 N 来说,这个术语占主导地位并减慢归并排序。
  • @Steve:你说得对,qsort 不是一个替代品,但我的观点并不是关于 qsort 与 mergesort。当 qsort 随时可用时,我只是不想编写另一个版本的合并排序。标准库方式比滚动你自己的更方便。
【解决方案3】:

这是一篇关于这个主题的不错的小论文。他的经验结论是 Treesort 是最好的,其次是 Quicksort 和 Mergesort。沉积物排序、冒泡排序、选择排序的表现非常糟糕。

链表排序算法的比较研究 作者:沉景光

http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.31.9981

【讨论】:

    【解决方案4】:

    比较排序(即基于比较元素的排序)不可能比n log n 快。底层数据结构是什么并不重要。见Wikipedia

    利用列表中有许多相同元素的其他类型的排序(例如计数排序)或列表中某些预期的元素分布更快,尽管我想不出任何在链表上工作得特别好。

    【讨论】:

      【解决方案5】:

      如多次所述,基于比较的一般数据排序的下限将是 O(n log n)。为了简要总结这些论点,有 n!可以对列表进行排序的不同方式。任何有 n! 的比较树(在 O(n^n) 中)可能的最终排序将至少需要 log(n!) 作为其高度:这为您提供了 O(log(n^n)) 下限,即 O(n日志 n)。

      因此,对于链表上的一般数据,适用于任何可以比较两个对象的数据的最佳排序将是 O(n log n)。但是,如果您的工作领域更有限,您可以改进所需的时间(至少与 n 成正比)。例如,如果您使用不大于某个值的整数,则可以使用Counting SortRadix Sort,因为它们使用您正在排序的特定对象来降低与 n 成比例的复杂性。但是要小心,这些会增加一些您可能不会考虑的复杂性(例如,计数排序和基数排序都会添加基于您正在排序的数字大小的因素,O(n+k ) 其中 k 是计数排序的最大数的大小,例如)。

      此外,如果您碰巧拥有具有完美哈希的对象(或者至少是一个以不同方式映射所有值的哈希),您可以尝试对其哈希函数使用计数或基数排序。

      【讨论】:

        【解决方案6】:

        Radix sort 特别适用于链表,因为很容易制作一个与数字的每个可能值相对应的头指针表。

        【讨论】:

        • 能否请您详细解释一下这个主题或提供链表中基数排序的任何资源链接。
        【解决方案7】:

        不是对您问题的直接回答,但如果您使用 Skip List,则它已经排序并且具有 O(log N) 搜索时间。

        【讨论】:

        • 预期 O(lg N) 搜索时间 - 但不能保证,因为跳过列表依赖于随机性。如果您收到不受信任的输入,请确保输入的供应商无法预测您的 RNG,否则他们可能会向您发送触发其最坏情况性能的数据
        【解决方案8】:

        归并排序不需要 O(1) 访问并且是 O ( n ln n )。没有已知的排序一般数据的算法比 O ( n ln n ) 更好。

        基数排序(限制数据大小)或直方图排序(计算离散数据)等特殊数据算法可以对具有较低增长函数的链表进行排序,只要您使用具有 O(1) 访问权限的不同结构即可作为临时存储。

        另一类特殊数据是一个几乎排序的列表的比较排序,其中 k 个元素乱序。这可以在 O ( kn ) 操作中排序。

        将列表复制到数组并返回将是 O(N),因此如果空间不是问题,可以使用任何排序算法。

        例如,给定一个包含uint_8 的链表,此代码将使用直方图排序在 O(N) 时间内对其进行排序:

        #include <stdio.h>
        #include <stdint.h>
        #include <malloc.h>
        
        typedef struct _list list_t;
        struct _list {
            uint8_t value;
            list_t  *next;
        };
        
        
        list_t* sort_list ( list_t* list )
        {
            list_t* heads[257] = {0};
            list_t* tails[257] = {0};
        
            // O(N) loop
            for ( list_t* it = list; it != 0; it = it -> next ) {
                list_t* next = it -> next;
        
                if ( heads[ it -> value ] == 0 ) {
                    heads[ it -> value ] = it;
                } else {
                    tails[ it -> value ] -> next = it;
                }
        
                tails[ it -> value ] = it;
            }
        
            list_t* result = 0;
        
            // constant time loop
            for ( size_t i = 255; i-- > 0; ) {
                if ( tails[i] ) {
                    tails[i] -> next = result;
                    result = heads[i];
                }
            }
        
            return result;
        }
        
        list_t* make_list ( char* string )
        {
            list_t head;
        
            for ( list_t* it = &head; *string; it = it -> next, ++string ) {
                it -> next = malloc ( sizeof ( list_t ) );
                it -> next -> value = ( uint8_t ) * string;
                it -> next -> next = 0;
            }
        
            return head.next;
        }
        
        void free_list ( list_t* list )
        {
            for ( list_t* it = list; it != 0; ) {
                list_t* next = it -> next;
                free ( it );
                it = next;
            }
        }
        
        void print_list ( list_t* list )
        {
            printf ( "[ " );
        
            if ( list ) {
                printf ( "%c", list -> value );
        
                for ( list_t* it = list -> next; it != 0; it = it -> next )
                    printf ( ", %c", it -> value );
            }
        
            printf ( " ]\n" );
        }
        
        
        int main ( int nargs, char** args )
        {
            list_t* list = make_list ( nargs > 1 ? args[1] : "wibble" );
        
        
            print_list ( list );
        
            list_t* sorted = sort_list ( list );
        
        
            print_list ( sorted );
        
            free_list ( list );
        }
        

        【讨论】:

        • 证明没有比 n log n 更快的基于比较的排序算法。
        • 不,已经证明没有基于比较的排序算法对一般数据比n log n
        • 不,任何比O(n lg n) 更快的排序算法都不是基于比较的(例如,基数排序)。根据定义,比较排序适用于具有总顺序(即可以比较)的任何域。
        • @bdonlan “一般数据”的要点是,有些算法对于受约束的输入而不是随机输入来说更快。在极限情况下,您可以编写一个简单的 O(1) 算法,该算法对列表进行排序,因为输入数据被限制为已经排序
        • 这不是基于比较的排序。修饰符“on general data”是多余的,因为比较排序已经处理了一般数据(而 big-O 符号表示进行的比较次数)。
        【解决方案9】:

        据我所知,最好的排序算法是 O(n*log n),无论容器是什么——已经证明,广义上的排序(合并排序/快速排序等风格)不能再低了。使用链表不会给你更好的运行时间。

        在 O(n) 中运行的唯一一种算法是一种“hack”算法,它依赖于计数值而不是实际排序。

        【讨论】:

        • 这不是一个 hack 算法,它不会在 O(n) 中运行。它在 O(cn) 中运行,其中 c 是您要排序的最大值(嗯,实际上它是最高值和最低值之间的差异)并且仅适用于整数值。 O(n) 和 O(cn) 之间存在差异,因为除非您可以为要排序的值给出明确的上限(并因此将其限制为常数),否则您有两个因素使复杂性复杂化。跨度>
        • 严格来说,它运行在O(n lg c)。如果你的所有元素都是唯一的,那么c &gt;= n,因此它需要比O(n lg n)更长的时间。
        【解决方案10】:

        Here's an implementation 只遍历列表一次,收集运行,然后以与 mergesort 相同的方式安排合并。

        复杂度为 O(n log m),其中 n 是项目数,m 是运行次数。最好的情况是 O(n)(如果数据已经排序),最坏的情况是 O(n log n)。

        它需要 O(log m) 临时内存;排序是在列表中就地完成的。

        (在下面更新。评论者提出了一个很好的观点,我应该在这里描述它)

        算法的要点是:

            while list not empty
                accumulate a run from the start of the list
                merge the run with a stack of merges that simulate mergesort's recursion
            merge all remaining items on the stack
        

        累积跑数不需要太多解释,但趁机累积上升跑和下降跑(反向)是很好的。在这里,它预先添加小于运行头部的项目,并附加大于或等于运行结束的项目。 (请注意,前置应使用严格小于以保持排序稳定性。)

        将合并代码粘贴到这里是最简单的:

            int i = 0;
            for ( ; i < stack.size(); ++i) {
                if (!stack[i])
                    break;
                run = merge(run, stack[i], comp);
                stack[i] = nullptr;
            }
            if (i < stack.size()) {
                stack[i] = run;
            } else {
                stack.push_back(run);
            }
        

        考虑对列表进行排序 (d a g i b e c f j h)(忽略运行)。堆栈状态如下:

            [ ]
            [ (d) ]
            [ () (a d) ]
            [ (g), (a d) ]
            [ () () (a d g i) ]
            [ (b) () (a d g i) ]
            [ () (b e) (a d g i) ]
            [ (c) (b e) (a d g i ) ]
            [ () () () (a b c d e f g i) ]
            [ (j) () () (a b c d e f g i) ]
            [ () (h j) () (a b c d e f g i) ]
        

        然后,最后,合并所有这些列表。

        请注意,stack[i] 处的项目(运行)数为零或 2^i,并且堆栈大小以 1+log2(nruns) 为界。每个堆栈级别的每个元素合并一次,因此 O(n log m) 比较。这里与 Timsort 有一个相似之处,尽管 Timsort 使用类似于 Fibonacci 序列的东西来维护它的堆栈,其中它使用了 2 的幂。

        累积运行利用任何已排序的数据,因此对于已排序的列表(一次运行),最佳情况复杂度为 O(n)。由于我们同时累积升序和降序运行,因此运行的长度总是至少为 2。(这会将最大堆栈深度至少减少 1,首先要支付查找运行的成本。)最坏情况的复杂性是O(n log n),正如预期的那样,对于高度随机化的数据。

        (嗯...第二次更新。)

        或者只是在bottom-up mergesort 上查看维基百科。

        【讨论】:

        • 使用“反向输入”让运行创建表现良好是一个不错的选择。 O(log m) 不需要额外的内存 - 只需将运行交替添加到两个列表,直到一个为空。
        【解决方案11】:

        您可以将其复制到数组中,然后对其进行排序。

        • 复制到数组O(n),

        • 排序 O(nlgn)(如果您使用合并排序等快速算法),

        • 必要时复制回链表O(n),

        所以它会是 O(nlgn)。

        请注意,如果您不知道链表中元素的数量,您将无法知道数组的大小。例如,如果您使用 java 编码,则可以使用 Arraylist。

        【讨论】:

        【解决方案12】:

        Mergesort 是您可以在这里做的最好的事情。

        【讨论】:

        【解决方案13】:

        问题是LeetCode #148,所有主要语言都提供了大量解决方案。我的如下,但我想知道时间复杂度。为了找到中间元素,我们每次都遍历完整列表。第一次迭代 n 元素,第二次迭代 2 * n/2 元素,依此类推。好像是O(n^2) 时间。

        def sort(linked_list: LinkedList[int]) -> LinkedList[int]:
            # Return n // 2 element
            def middle(head: LinkedList[int]) -> LinkedList[int]:
                if not head or not head.next:
                    return head
                slow = head
                fast = head.next
        
                while fast and fast.next:
                    slow = slow.next
                    fast = fast.next.next
        
                return slow
        
            def merge(head1: LinkedList[int], head2: LinkedList[int]) -> LinkedList[int]:
                p1 = head1
                p2 = head2
                prev = head = None
        
                while p1 and p2:
                    smaller = p1 if p1.val < p2.val else p2
                    if not head:
                        head = smaller
                    if prev:
                        prev.next = smaller
                    prev = smaller
        
                    if smaller == p1:
                        p1 = p1.next
                    else:
                        p2 = p2.next
        
                if prev:
                    prev.next = p1 or p2
                else:
                    head = p1 or p2
        
                return head
        
            def merge_sort(head: LinkedList[int]) -> LinkedList[int]:
                if head and head.next:
                    mid = middle(head)
                    mid_next = mid.next
                    # Makes it easier to stop
                    mid.next = None
        
                    return merge(merge_sort(head), merge_sort(mid_next))
                else:
                    return head
        
            return merge_sort(linked_list)
        

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 2010-09-22
          • 1970-01-01
          • 1970-01-01
          • 2018-07-26
          • 1970-01-01
          • 1970-01-01
          相关资源
          最近更新 更多