Answers:
可以合理地预期,在运行时您不能做得比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表示法可能会隐藏一些恒定因素,这些因素可能会导致一种算法由于内存局部性,项目数量少等原因而表现更好。
listsort
,就会看到可以使用参数进行切换int is_double
。
listsort
仅支持单链接列表
根据许多因素,将列表复制到数组然后使用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,因此缓存非常重要。
这是一篇有关该主题的不错的小论文。他的经验结论是Treesort最好,其次是Quicksort和Mergesort。泥沙分类,气泡分类,选择分类表现非常差。
Ching-Kuang Shene对链表排序算法的比较研究
http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.31.9981
如许多次所述,一般数据的基于比较的排序的下限将为O(n log n)。为了简要概括这些参数,有n!列表的不同排序方式。任何具有n的比较树!(位于O(n ^ n)中)可能的最终排序至少需要log(n!)作为其高度:这为您提供了O(log(n ^ n))的下限,即O(n登录n)。
因此,对于链表上的常规数据,将对任何可以比较两个对象的数据进行处理的最佳排序将是O(n log n)。但是,如果您要处理的工作域更有限,则可以缩短所需时间(至少与n成正比)。例如,如果您使用的整数不大于某个值,则可以使用Counting Sort或Radix Sort,因为它们使用要排序的特定对象来降低与n成比例的复杂度。但是请注意,这些会增加您可能不会考虑的复杂性(例如,Counting Sort和Radix sort都添加基于您要排序的数字的大小的因子O(n + k ),其中k是例如Counting Sort的最大数的大小)。
此外,如果碰巧具有完美散列的对象(或至少具有不同映射所有值的散列),则可以尝试在其散列函数上使用计数或基数排序。
合并排序不需要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 );
}
O(n lg n)
不基于比较的排序算法快(例如,基数排序)。根据定义,比较排序适用于具有总顺序(即可以比较)的任何域。
据我所知,最好的排序算法是O(n * log n),无论使用哪种容器-事实证明,广义上的排序(mergesort / quicksort等样式)排序不会降低。使用链接列表不会给您更好的运行时间。
在O(n)中运行的唯一一种算法是“ hack”算法,该算法依赖于对值计数而不是实际排序。
O(n lg c)
。如果您所有的元素都是唯一的,则c >= n
,因此所需时间比更长O(n lg n)
。
这是一个仅遍历列表,收集运行,然后以与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)。
(嗯...第二次更新。)
O(log m)
不需要额外的内存-只需将运行交替添加到两个列表,直到一个为空。
您可以将其复制到数组中,然后对其进行排序。
复制到数组O(n)中,
排序O(nlgn)(如果您使用诸如merge sort的快速算法),
如有必要,复制回链表O(n),
所以它将是O(nlgn)。
请注意,如果您不知道链表中元素的数量,就不会知道数组的大小。如果您使用Java进行编码,则可以使用Arraylist为例。