排序链表最快的算法是什么?


95

我很好奇O(n log n)是否是链表可以做的最好的事情。


31
众所周知,O(nlogn)是基于比较的排序的界限。有一些基于非比较的排序可以提供O(n)性能(例如,计数排序),但是它们需要对数据进行额外的约束。
MAK

Answers:


100

可以合理地预期,在运行时您不能做得比O(N log N)好。

但是,有趣的部分是研究您是否可以就地稳定地对其进行最坏情况的行为排序,等等。

Putty名望的Simon Tatham解释了如何使用merge sort对链表进行排序。他总结了以下评论:

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

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

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

就像@JørgenFogh在下面提到的那样,big-O表示法可能会隐藏一些恒定因素,这些因素可能会导致一种算法由于内存局部性,项目数量少等原因而表现更好。


3
这不是单个链表。他的C代码使用* prev和* next。
2013年

3
@LE 两者都适用。如果看到的签名listsort,就会看到可以使用参数进行切换int is_double
csl

1
@LE:这是C代码的Python版本,listsort支持单链接列表
jfs

O(kn)理论上是线性的,可以通过存储桶排序来实现。假设一个合理的k(您要排序的对象的位数/大小),它可能会更快一些
亚当(Adam)

74

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

之所以会更快,是因为数组的缓存性能要比链表好得多。如果列表中的节点分散在内存中,则可能会在整个位置生成高速缓存未命中。再说一次,如果数组很大,无论如何都会遇到缓存未命中的情况。

Mergesort可以更好地并行化,因此如果您要这样做,可能是更好的选择。如果直接在链接列表上执行它,则速度也更快。

由于这两种算法都在O(n * log n)中运行,因此要做出明智的决定,就需要在要运行它们的计算机上对这两种算法进行性能分析。

-编辑

我决定检验我的假设,并编写了一个C程序,该程序测量(使用clock())对一个int链接列表进行排序所花费的时间。我尝试了一个分配给每个节点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 RAM,因此缓存非常重要。


2
好的评论,但是您应该考虑将数据从列表复制到数组的非固定成本(必须遍历列表),以及快速排序的最坏情况运行时间。
csl

1
O(n * log n)理论上与O(n * log n + n)相同,这将包括复制成本。对于任何足够大的n,副本的成本确实不重要;一次遍历列表到末尾应该是n次。
院长J,2009年

1
@DeanJ:从理论上讲,是的,但是请记住,最初的海报提出了微观优化很重要的情况。在这种情况下,必须考虑将链接列表转换为数组所花费的时间。这些评论很有见地,但是我并不完全相信它会在现实中带来性能提升。可能适用于很小的N。
csl

1
@csl:实际上,我希望本地化对N的影响会更大。假设高速缓存未命中是主要的性能影响,那么copy-qsort-copy方法将导致大约2 * N高速缓存未命中用于复制,加上qsort的未命中数,这将是N log(N)的一小部分(因为qsort中的大多数访问都接近于最近访问的元素)。由于较高比例的比较会导致缓存未命中,因此合并排序的未命中数占N log(N)的比例较大。因此,对于较大的N,此术语起主导作用,并会降低mergesort的速度。
史蒂夫·杰索普

2
@Steve:对,qsort不是直接替代,但我的意思不是关于qsort与mergesort。当qsort可用时,我只是不想编写另一个版本的mergesort。标准库的方式比滚动自己更方便。
约根·福(JørgenFogh),2009年

8

比较排序(即基于比较元素的排序)不可能比快n log n。底层数据结构是什么都没有关系。参见维基百科

利用列表中有很多相同元素(例如计数排序)或列表中元素的某些预期分布的其他种类的排序速度更快,尽管我想不到任何一种效果特别好在链表上。



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成比例的复杂度。但是请注意,这些会增加您可能不会考虑的复杂性(例如,Counting Sort和Radix sort都添加基于您要排序的数字的大小的因子O(n + k ),其中k是例如Counting Sort的最大数的大小)。

此外,如果碰巧具有完美散列的对象(或至少具有不同映射所有值的散列),则可以尝试在其散列函数上使用计数或基数排序。


3

一个基数排序特别适合于一个链表,因为它很容易使对应于数字的每个可能值头指针表。


1
您可以在这个主题上解释更多吗,或者在链接列表中提供任何用于基数排序的资源链接。
LoveToCode

2

合并排序不需要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 );
}

5
已经证明,不存在比n log n更快的基于比较的排序算法。
Artelius

9
不,已经证明,对一般数据进行基于比较的排序算法没有比n log n快的方法
Pete Kirkham

不,任何排序算法都比O(n lg n)不基于比较的排序算法快(例如,基数排序)。根据定义,比较排序适用于具有总顺序(即可以比较)的任何域。
bdonlan

3
@bdonlan“通用数据”的要点是,有一些算法对于约束输入比随机输入更快。在极限情况下,您可以编写平凡的O(1)算法,在输入数据被约束为已经排序的情况下,该算法对列表进行排序
Pete Kirkham

那将不是基于比较的排序。修饰语“基于常规数据”是多余的,因为比较排序已经可以处理常规数据(big-O表示进行的比较次数)。
史蒂夫·杰索普

1

这不是您问题的直接答案,但是如果您使用“ 跳过列表”,则该列表已经排序,并且搜索时间为O(log N)。


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

1

据我所知,最好的排序算法是O(n * log n),无论使用哪种容器-事实证明,广义上的排序(mergesort / quicksort等样式)排序不会降低。使用链接列表不会给您更好的运行时间。

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


3
这不是黑客算法,也不在O(n)中运行。它以O(cn)运行,其中c是您要排序的最大值(实际上,这是最大值和最小值之间的差),并且仅适用于整数值。O(n)和O(cn)之间是有区别的,因为除非您可以为要排序的值提供确定的上限(并因此将其限制为常数),否则有两个因素会使复杂性变得复杂。
DivineWolfwood

严格来说,它在中运行O(n lg c)。如果您所有的元素都是唯一的,则c >= n,因此所需时间比更长O(n lg n)
bdonlan

1

这是一个仅遍历列表,收集运行,然后以与mergesort相同的方式调度合并的实现。

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

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

(在下面更新。评论者1很好,我应该在这里进行描述)

该算法的要点是:

    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);
    }

考虑对列表排序(dagibecfjh)(忽略运行)。堆栈状态如下:

    [ ]
    [ (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使用斐波那契数列之类的东西使用2的幂来维护其堆栈,但这里与Timsort有一个相似之处。

累计运行利用了任何已经排序的数据,因此对于一个已经排序的列表(一次运行),最佳情况下的复杂度为O(n)。由于我们同时累积了上升和下降行程,因此行程将始终至少为2。如所期望的,对于高度随机化的数据为O(n log n)。

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

或只见自下而上的mergesort上的Wikipedia


运行创建的“反向输入”效果很好,这是一个不错的选择。O(log m)不需要额外的内存-只需将运行交替添加到两个列表,直到一个为空。
灰胡子

1

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

  • 复制到数组O(n)中,

  • 排序O(nlgn)(如果您使用诸如merge sort的快速算法),

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

所以它将是O(nlgn)。

请注意,如果您不知道链表中元素的数量,就不会知道数组的大小。如果您使用Java进行编码,则可以使用Arraylist为例。


这对JørgenFogh的答案有什么帮助?
灰胡子

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.