【问题标题】:complexity of mergesort with linked list链表合并排序的复杂性
【发布时间】:2012-03-26 08:06:30
【问题描述】:

我有使用链表进行合并排序的代码,它工作正常,我的问题是这个算法的复杂性是多少?它是 O(nlog(n)) 吗?它也稳定吗?我很感兴趣,因为我知道合并排序是稳定的,使用链表怎么样?如果我们有一些彼此相等的元素,这段代码是否保留元素的顺序?非常感谢

#include<stdio.h>
#include <stdlib.h>
struct node
{
    int number;
    struct node *next;

};
struct node *addnode(int number,struct node *next);
struct node*mergesort(struct node *head);
struct node *merge(struct node *one,struct node *two);

int main(void){
    struct node *head;
    struct node *current;
    struct node *next;
    int test[]={8,3,1,4,2,5,7,0,11,14,6};
    int n=sizeof(test)/sizeof(test[0]);
    int i;
    head=NULL;
     for (i=0;i<n;i++)
         head=addnode(test[i],head);
     i=0;
     head=mergesort(head);
    printf("before----after sort \n");
    for (current=head;current!=NULL;current=current->next)
        printf("%4d\t%4d\n",test[i++],current->number);

    /* free list */
    for (current=head;current!=NULL;current=current->next)
        next=current->next;free(current);
return 0;
}

struct node *addnode(int number,struct node* next){
    struct node *tnode;
    tnode=(struct node*)malloc(sizeof(*tnode));
    if(tnode!=NULL){
        tnode->number=number;
        tnode->next=next;
            }

     return tnode;
     }
struct node *mergesort(struct node *head){

    struct node *head_one;
    struct node *head_two;
    if((head==NULL) ||(head->next==NULL))
         return head;
    head_one=head;
    head_two=head->next;
    while( (head_two!=NULL) &&(head_two->next!=NULL)){
        head=head->next;
        head_two=head->next->next;
        }
    head_two=head->next;
    head->next=NULL;
    return merge(mergesort(head_one),mergesort(head_two));
    }
struct node *merge(struct node*head_one,struct node*head_two){

    struct node *head_three;
    if(head_one==NULL)
         return head_two;
    if(head_two==NULL)
         return head_one;
    if(head_one->number<head_two->number){

head_three=head_one;
head_three->next=merge(head_one->next,head_two);
    }
    else
    {

        head_three=head_two;
        head_three->next=merge(head_one,head_two->next);


    }

    return head_three;
    }

【问题讨论】:

  • 到目前为止你做了哪些分析?您可以通过在不同的输入上运行算法并绘制运行时间以及检查它是否稳定来回答您自己的问题。
  • 诸如“合并排序的复杂性是什么?”之类的问题。并且“合并排序是否稳定”通过网络搜索很容易回答。
  • @DavidHeffernan:OP 似乎总体上意识到 Mergesort 的复杂性和稳定性,但想知道这个具体的实现。
  • @templatetypedef :这只会给出平均时间复杂度。

标签: c linked-list mergesort


【解决方案1】:

来自Will Nessanswer

一般来说,这就是所谓的自上而下的归并排序。

您也可以采用自下而上的方式,首先对两个元素的连续块进行排序,然后将它们合并为(因此,现在已排序)4 个元素的块,然后将这些成对合并为8 个元素等,直到只剩下一个块 - 排序列表。

作为这种算法的一个例子,考虑 Git 在mergesort.c 中使用的算法:

它来自 Git 2.34(2021 年第四季度),它优化了用于对链表进行排序的合并排序实现


请参阅commit c90cfc2(2021 年 10 月 8 日)和 commit afc72b5commit 40bc872commit 84edc40commit f1ed4cecommit 1aa5899commit 0cecb75commit 0cecb75commit e031e97commit d536a71、@987 2021 年 10 月 1 日)René Scharfe (rscharfe)
(由 Junio C Hamano -- gitster -- 合并到 commit 0ef0809,2021 年 10 月 18 日)

mergesort: 使用排名栈

签字人:René Scharfe

自下而上的归并排序实现需要大量跳过子列表。

递归版本可以避免这种情况,但需要log2(n) 堆栈帧。

解决方案:显式管理一堆不同长度的排序子列表,以避免快速转发,同时限制内存使用。

虽然此补丁是独立开发的,但 Mono projectmono/mono/mono/eglib/sort.frag.h 中也使用了排名堆栈。

这个想法是为 log2(n_max) 排序的子列表保留插槽,每个 2 的幂都有一个。
这样的构造可以容纳不超过n_max的任何长度的列表。
由于已知最大项目数(实际上是SIZE_MAX),我们可以预先分配整个排名堆栈。

我们一个一个地添加项目,这类似于增加一个二进制数。
通过跟踪项目的数量并检查其中的位来利用它,而不是在检查是否存在某个等级的子列表时检查等级堆栈中的NULL,以避免内存访问。

  • 第一项可以作为长度为 2^0 的子列表进入空的第一个插槽。
  • 第二个需要与前一个子列表合并,结果作为长度为 2^1 的子列表进入第二个空槽。
  • 第三个进入空出的第一个插槽,依此类推。
    最后我们合并所有子列表得到结果。

新版本仍然执行稳定的排序,确保在比较函数指示相等时将之前看到的项目放在最前面。
这是通过优先选择具有更高排名的子列表中的项目来完成的。

新的合并功能也尽量减少操作次数。
blame.c::blame_merge() 一样,如果函数已经指向正确的项,则该函数不会设置下一个指针,并在到达给定的两个子列表之一的末尾时退出。
旧代码无法执行后者,因为它将所有项目保存在一个列表中。

不过,比较次数保持不变。
以下是与排名堆栈比较次数最多的 rand 分布的“test-tool mergesort test”示例输出:

$ t/helper/test-tool mergesort test | awk '
    NR > 1 && $1 != "rand" {next}
    $7 > max[$3] {max[$3] = $7; line[$3] = $0}
    END {for (n in line) print line[n]}
'
distribut mode                    n        m get_next set_next  compare verdict
rand      copy                  100       32      669      420      569 OK
rand      dither               1023       64     9997     5396     8974 OK
rand      dither               1024      512    10007     6159     8983 OK
rand      dither               1025      256    10993     5988     9968 OK

这里与之前的区别:

distribut mode                    n        m get_next set_next  compare
rand      copy                  100       32     -515     -280        0
rand      dither               1023       64    -6376    -4834        0
rand      dither               1024      512    -6377    -4081        0
rand      dither               1025      256    -7461    -5287        0

get_nextset_next 的调用次数显着减少。

注意:这些获胜者与引入 unriffle 模式的补丁中显示的不同,因为在两者之间添加 unriffle_skewed 模式改变了 rand() 值的消耗。

【讨论】:

    【解决方案2】:

    您的代码中有错字。修正后,它确实很稳定,并且具有O(n log n) 的复杂性。尽管可以肯定的是,您确实应该将merge 重新实现循环而不是递归。 C 没有尾调用优化(对吗?),所以这会搞砸:

    struct node *mergesort(struct node *head){
    
        struct node *head_one;
        struct node *head_two;
        if((head==NULL) ||(head->next==NULL))
             return head;
        head_one=head;
        head_two=head->next;
        while( (head_two!=NULL) &&(head_two->next!=NULL)){
            head=head->next;
            // head_two=head->next->next;      // -- the typo, corrected:
            head_two=head_two->next->next;
            }
        head_two=head->next;
        head->next=NULL;
        return merge(mergesort(head_one),mergesort(head_two));
        }
    

    当我们这样做的时候,改变您的工作流程

        return merge(mergesort(head_one),mergesort(head_two));
    

        struct node *p1, *p2; 
        // ......
        p1 = mergesort(head_one);
        p2 = mergesort(head_two);
        return merge(p1,p2);
    

    以这种方式在堆栈上会更容易(将使用更少)。

    一般来说,这就是所谓的自上而下合并排序。您也可以以 自下而上 的方式进行操作,首先对每个两个元素的连续块进行排序,然后将它们合并为(因此,现在是排序的)4 个元素的块,然后合并 那些成对的,分成8个元素的块,等等,直到只剩下一个块——排序列表。

    为了获得更多花哨(和高效),而不是从 2 块开始,首先将列表拆分为单调 运行,即增加序列和减少序列 - 重新链接后者当你去的时候是相反的——因此根据它的固有顺序分割原始列表,所以很可能会有更少的初始块合并;然后像以前一样重复成对合并这些,直到最后只剩下一个。

    【讨论】:

    • 我一定错过了什么,但“推进 head_one 和 head_two”部分O(n) 不是时间本身吗?也就是说,整体时间复杂度如何仍然O(nlogn)?因为在数组中,您可以使用O(1) 时间获得中点,而无需增加“快”和“慢”指针
    • @benjaminz 是的,指针的前进是~3n/2;它实现了将列表分成两半;因此有 O(log n) 步骤(重复减半是重复加倍的对偶: x = 2^n === log2(x) = n )。合并也是 O(n)。 -- 在数组中,拆分是 O(1),但合并仍然是 O(n),因此,对于 log n 步,总体上仍然是 n log n。 -- 我已经编辑过,提到自下而上的方案:那时没有递归拆分,只是重复合并。
    • 谢谢@WillNess,我再想一想,意识到“合并”步骤是O(n),所以“推进”,即O(n / 2)是不是 比“合并”步骤更糟糕,因此它对整体 O(nlogn) 运行时间没有太大影响 =)
    • @benjaminz 前进约 1.5n,是的,复杂性无关紧要。请参阅 Christoph 的答案及其 cmets 中的讨论;也是我的编辑;对于首选方法,即自下而上的归并排序。
    【解决方案3】:

    Mergesort 表示拆分和合并。下面片段中的拆分并不完美(它会导致在等分布的游程上出现二次行为,请参阅 Christoph 的评论),但它会解决问题:

    #include <stdio.h>
    #include <string.h>
    
    struct llist {
            struct llist *next;
            char *payload;
            };
    
    int llist_cmp(struct llist *l, struct llist *r);
    struct llist * llist_split(struct llist **hnd
                            , int (*cmp)(struct llist *l, struct llist *r) );
    struct llist * llist_merge(struct llist *one, struct llist *two
                            , int (*cmp)(struct llist *l, struct llist *r) );
    struct llist * llist_sort(struct llist *ptr
                            , int (*cmp)(struct llist *l, struct llist *r) );
    
    struct llist * llist_split(struct llist **hnd, int (*cmp)(struct llist *l, struct llist *r) )
    {
    struct llist *this, *save, **tail;
    
    for (save=NULL, tail = &save; this = *hnd; ) {
            if (! this->next) break;
            if ( cmp( this, this->next) <= 0) { hnd = &this->next; continue; }
            *tail = this->next;
            this->next = this->next->next;
            tail = &(*tail)->next;
            *tail = NULL;
            }
    return save;
    }
    
    struct llist * llist_merge(struct llist *one, struct llist *two, int (*cmp)(struct llist *l, struct llist *r) )
    {
    struct llist *result, **tail;
    
    for (result=NULL, tail = &result; one && two; tail = &(*tail)->next ) {
            if (cmp(one,two) <=0) { *tail = one; one=one->next; }
            else { *tail = two; two=two->next; }
            }
    *tail = one ? one: two;
    return result;
    }
    struct llist * llist_sort(struct llist *ptr, int (*cmp)(struct llist *l, struct llist *r) )
    {
    struct llist *save;
    
    save=llist_split(&ptr, cmp);
    if (!save) return ptr;
    
    save = llist_sort(save, cmp);
    
    return llist_merge(ptr, save, cmp);
    }
    
    int llist_cmp(struct llist *l, struct llist *r)
    
    {
    if (!l) return 1;
    if (!r) return -1;
    return strcmp(l->payload,r->payload);
    }
    
    
    struct llist lists[] =
    {{ lists+1, "one" }
    ,{ lists+2, "two" }
    ,{ lists+3, "three" }
    ,{ lists+4, "four" }
    ,{ lists+5, "five" }
    ,{ lists+6, "six" }
    ,{ lists+7, "seven" }
    ,{ lists+8, "eight" }
    ,{ lists+9, "nine" }
    ,{ NULL, "ten" }
            };
    
    int main()
    {
    struct llist *root,*tmp;
    
    root = lists;
    
    fprintf(stdout, "## %s\n", "initial:" );
    for (tmp=root; tmp; tmp=tmp->next) {
            fprintf(stdout, "%s\n", tmp->payload);
            }
    
    fprintf(stdout, "## %s\n", "sorting..." );
    root = llist_sort(root, llist_cmp);
    for (tmp=root; tmp; tmp=tmp->next) {
            fprintf(stdout, "%s\n", tmp->payload);
            }
    fprintf(stdout, "## %s\n", "done." );
    
    return 0;
    }
    

    【讨论】:

      【解决方案4】:

      如何为链表实现归并排序

      • 不要递归地平分列表 - 随机访问不是免费的
      • 不要分成大小为1n - 1的子列表,如explained by ruakh

      如何为链表实现归并排序

      不使用二分法,而是通过维护一堆已排序的子列表来构建列表。也就是说,首先将大小为1 的列表推入堆栈并向下合并,直到达到更大的列表;如果你能弄清楚背后的数学原理,你实际上不需要存储列表大小。

      如果合并函数是稳定的,排序算法将是稳定的。稳定的版本会从头开始构建合并列表,方法是始终从列表中获取单个元素,并在相等的情况下使用第一个列表。不稳定但性能更好的版本会分块添加到合并列表中,避免在每个元素之后进行不必要的重新链接。

      【讨论】:

      • 递归地将单链表一分为二增加了O(n log n)算法的复杂性。即,这里的一个常数因素(在错字被修复后;见我的回答)。如果您比编译器编写者做得更好,那么显式构建自己的堆栈就可以了。但是您不需要它,以自下而上的方式构建排序列表 - 您只需要 log n 使用 2,4,8, ... 的大小参数 n 传递列表,每次重新链接合并 in -放置 大小为n 的块,假设n/2 大小的块已经排序。参看。 Richard O'Keefe 这样的代码在 Prolog 和 Scheme 中。
      • ...所以这确实是个好主意!这样就没有分裂,只有合并。 :)
      • @WillNess:当然,实际的性能提升将取决于具体的用例,但我已经看到将递归版本替换为基于堆栈的版本将运行时间缩短了一半;此外,基于堆栈的版本是在线的,即在从磁盘(或其他有延迟的来源)读取数据时非常好......
      • aha,on-line - 所以你的意思是尽快以“倾斜”的方式合并块。这样它也更加“增量”,具有最小元素到目前为止,而输入正在被消耗。好的。也可以根据“运行”顺序进行初始分区,长度不同,即使对于下降的也可以在发现时重新链接。但是在内存输入中,所有这些都是不必要的,不需要堆栈,您只需使用 2,4,8... 方案完成列表的完整扫描,成对合并块 up,直到只剩下一个人。当然,一切都在一个循环中,没有递归。
      猜你喜欢
      • 2013-10-21
      • 2020-05-24
      • 1970-01-01
      • 1970-01-01
      • 2012-04-18
      • 2020-09-25
      相关资源
      最近更新 更多