☰
C语言链表排序四算法:选择、冒泡、插入、归并的完整实战
2026/10/13 4:09:15 网站建设 项目流程

链表的排序,挂在嘴上都说“基础”,真正动手用纯C写一遍,很多人会卡住。数组里玩排序玩得再熟,一换到链表,头指针、前驱指针、next指针混在一起,就很容易写崩。这篇文章把我在实际编码里反复打磨的完整思路写下来,四种算法——选择、冒泡、插入、归并——从原理到代码再到踩坑,一次性讲清楚。适合正在学数据结构的同学,也适合准备面试的开发者,或者工作中需要维护链表结构的老手。

我会先从“为什么链表排序和数组排序差别这么大”讲起,再给出每种排序的完整C语言实现,然后放一组我实际跑过的对比数据,最后把那些调试到凌晨的坑整理成速查表。代码可以直接拿去编译运行,但更建议跟着思路自己敲一遍。

1. 为什么链表排序会让很多人翻车——先搞懂数据结构的脾气

1.1 数组排序与链表排序的本质差异

数组支持随机访问,想拿第k个元素是O(1)的事,arr[k]直接定位。排序算法里的核心操作,比如快排的partition、堆排的下沉,全都依赖这种“按编号取人”的能力。

链表不一样,它只能顺着next指针一步一步走。想拿第k个节点,必须从头遍历k次,复杂度是O(k)。这就导致一个很直接的结果:数组上那些依赖“取中间元素”的排序策略,比如二分插入排序,在链表上完全施展不开;快排的partition虽然可以用双指针从头尾逼近,但每轮partition仍然要完整扫描子链表,加上链表指针交换的额外开销,实际收益远不如数组。

打个比方:数组像一排列队的人,每个人身上贴着编号,你喊“5号出列”,人家直接走出来。链表像一条铁链,你只能从第一个环开始,一个个摸到目标环。排序的本质是反复“比较”和“移动”,移动这个动作在链表上不是搬数据,而是改指针方向。

1.2 链表排序的三大核心矛盾

第一个矛盾是随机访问缺失。所有基于下标的算法都要重新设计,不能用“arr[mid]找中点”这种思路,只能靠快慢指针去找链表中间节点。

第二个矛盾是指针操作的边界问题。交换链表中的两个节点,和交换数组里的两个元素完全不是一回事。数组交换是赋值语句,链表交换要处理前驱节点的next指向谁,还要考虑两个节点是否相邻、是否是头节点、是否是尾节点。任何一个边界条件漏了,排完的链表要么断成两截,要么出现环。

第三个矛盾是内存粒度更细。链表每个节点都是独立malloc出来的,排序过程涉及大量的“摘节点、挂节点”,一旦指针丢失,要么内存泄漏,要么访问到非法地址直接段错误。数组排序最多是数据顺序不对,链表排序可能是整个程序崩溃,调试成本完全不同。

1.3 排序算法的选型地图

这不是随便选一种写出来就完事,得先想清楚目标:

  • 如果数据量小,比如几千个节点以内,插入排序是一个非常自然的选择,链表本身就是“插入操作”的主场。
  • 如果追求最稳定的性能,归并排序是链表上的最优解之一,时间复杂度稳定在O(n log n),而且链表归并不需要额外的大块内存。
  • 如果只是学习指针操作,选择排序最容易理解,每次找最小值拆出来,循环n次就结束。
  • 冒泡排序代码最容易从数组迁移过来,但性能一般,主要价值在于理解“相邻交换”在链表上的表现。

把选型因素总结成一张表会清楚很多:

算法最好时间平均时间最坏时间额外空间稳定性
选择排序O(n²)O(n²)O(n²)O(1)不稳定
冒泡排序O(n)O(n²)O(n²)O(1)稳定
插入排序O(n)O(n²)O(n²)O(1)稳定
归并排序O(n log n)O(n log n)O(n log n)O(log n)栈空间稳定

选择排序在链表上通常写成“拆节点”而不是“交换数据”,所以即使比较次数不变,指针操作的常数因子也偏大。冒泡排序若有提前退出机制,对近似有序的数据很友好。插入排序如果额外维护一个tail指针,对已经有序的链表也能做到接近O(n)。归并排序则是在所有场景下都比较稳的选手。

2. 写码前的三个关键设计决策

2.1 单向链表的节点定义与三条不变量

代码都以单链表为例,节点定义用最经典的结构体:

typedef struct Node { int data; struct Node *next; } Node;

工程上data多半是结构体甚至void*,这里先讲清楚指针操作的核心逻辑,泛化的事放到最后一节。

写链表排序代码前,我心里一定先过一遍三条不变量:

  1. 头指针要么是NULL(空表),要么指向第一个有效节点,任何操作不得让头指针丢失。
  2. 除最后一个节点外,每个节点的next必须指向有效节点或NULL,绝不允许出现悬空指针。
  3. 排序完成后,链表包含的节点数必须和排序前完全一致,不允许创建节点或在不释放的情况下丢掉节点。

这三条看起来像废话,但调试链表排序时,几乎所有问题都能归到其中一条上。比如排序结束后遍历链表发现死循环,基本就是某处next没有置空,形成了环,违背了第二条。

2.2 头插、尾插、拆链:链表操作的地基

不管哪种排序算法,底层都是这几个动作的组合。

头插法:新节点直接挂在head前面,newNode->next = head; head = newNode;。这在需要把节点提到最前面的场景会用到。

尾插法:先找到最后一个节点,把它原来的next从NULL改成新节点,再把新节点的next置为NULL。如果频繁尾插,最好维护一个tail指针,否则每次遍历到尾节点会让整体复杂度多一个O(n)。

拆链:把一个节点从当前位置摘除,关键点是先记录它的前驱节点prev,然后prev->next = cur->next,再把cur->next = NULL。拆链是选择排序和插入排序的核心动作。

我见过不少人写链表排序时把“交换节点”想得太复杂,非要同时改两个节点的next,结果把自己绕进去。更稳妥的路径是:先拆下来,再插到目标位置。一次只处理一个节点,逻辑会清晰很多。

2.3 复杂度、稳定性与选型取舍

稳定性这个概念,数组排序里经常被忽略,但链表排序中它非常重要。稳定排序的意思是:如果两个元素的值相等,排序后它们的相对顺序不变。

选择排序为什么不稳定,我用一个例子说明。假设链表数据是[5a, 3, 5b, 2],下标用于标记两个5的先后。第一轮找到最小值2,把它和5a交换(在拆节点版本中是把2移到头部)。原链变成[2, 5b, 3, 5a],两个5的相对顺序从“5a在前”变成了“5b在前”,这就是不稳定。实际中如果data是一个结构体,内部还有别的字段,这种顺序变化可能导致意想不到的问题。

插入排序实现时,寻找插入位置用<=作为继续向后移动的条件,相等元素会插入到已排序部分的后面,保证稳定性。归并排序在合并两个有序链表时,如果左边节点的data等于右边节点的data,优先取左边,同样保证稳定性。

这些设计细节不是可选项。一旦理解了稳定性,你写代码时就会有意识地选择比较符号,而不是随手写个<或<=都无所谓。

3. 四种排序算法的完整实现

3.1 选择排序:拆出最小节点,一个一个重新接

选择排序的思路最直白:从头到尾扫描链表,找出data最小的节点,把它从原链表拆下来,再挂到一个新链表(用dummy头节点辅助)的尾部。重复这个动作n次,原链表变成空,新链表就是有序的。

我推荐用拆节点的方式而不是交换data,原因有三:一是交换data会让排序算法失去链表特色,不如直接用数组;二是如果data字段是很大的结构体,交换data意味着大量内存拷贝;三是拆节点可以顺便练习指针操作,这是链表排序的核心价值。

完整代码:

Node *list_selection_sort(Node *head) { Node dummy = {0, NULL}; Node *tail = &dummy; while (head != NULL) { Node *prev = NULL; Node *min_prev = NULL; Node *cur = head; Node *min_node = head; while (cur != NULL) { if (cur->data < min_node->data) { min_node = cur; min_prev = prev; } prev = cur; cur = cur->next; } if (min_prev == NULL) { head = min_node->next; } else { min_prev->next = min_node->next; } min_node->next = NULL; tail->next = min_node; tail = min_node; } return dummy.next; }

这段代码里最值得注意的变量是min_prev,它记录的是最小值节点的前驱。如果最小值是头节点,min_prev保持NULL,此时必须移动head指针;否则直接让前驱的next跳过min_node。外层循环每次从头开始扫描,整体时间复杂度O(n²)。

dummy节点的作用是为了避免每次尾插都要遍历新链表找尾节点。它是个只占栈空间的辅助节点,排序结束后直接返回dummy.next,不参与节点的malloc/free,没有任何内存负担。

3.2 冒泡排序:数组思维迁移到链表之后

链表上的冒泡排序,最直观的实现是把数组冒泡的比较交换搬过来,只交换节点的data字段。虽然这不“链表”,但作为过渡理解非常合适。

代码如下:

Node *list_bubble_sort(Node *head) { if (head == NULL) return NULL; int len = 0; for (Node *p = head; p != NULL; p = p->next) { len++; } for (int i = 0; i < len - 1; i++) { int swapped = 0; Node *p = head; for (int j = 0; j < len - 1 - i; j++) { if (p->data > p->next->data) { int tmp = p->data; p->data = p->next->data; p->next->data = tmp; swapped = 1; } p = p->next; } if (!swapped) break; } return head; }

这里我加了一个swapped标志位。内层循环如果一整趟都没有发生交换,说明链表已经有序,直接结束。这个优化让冒泡排序在近似有序的数据上表现还可以。

交换data的缺点也很明显:如果节点内部是结构体,赋值语句等同于整块数据拷贝。能力强的读者可以把“交换data”改成“交换节点”,但要注意处理相邻节点和非相邻节点两种情况,非相邻节点需要修改前驱和后继共四个指针,而相邻节点只有三个指针需要改动,这是最容易写错的地方。

3.3 插入排序:链表上的“天然主场”

插入排序的思路是“摸牌”。原链表被视为一堆待插入的牌,手里已经排好序的链表是“牌堆”。每次从原链表摸一张牌,在牌堆里找到合适位置插进去。

链表天然适合这个算法,因为插入操作在链表上是O(1)改指针,不像数组需要移动后面所有元素。但有一个反直觉的点:链表的插入排序在最好情况下(链表已经有序)依然需要扫描已排序部分找插入位置,如果每次都从头扫描,总时间仍接近O(n²)。所以要对它做一个小优化——引入一个tail指针,如果当前节点比tail大,直接接到末尾。

代码实现:

Node *list_insertion_sort(Node *head) { if (head == NULL || head->next == NULL) { return head; } Node *sorted = head; Node *cur = head->next; sorted->next = NULL; while (cur != NULL) { Node *next_cur = cur->next; cur->next = NULL; if (cur->data < sorted->data) { cur->next = sorted; sorted = cur; } else { Node *p = sorted; while (p->next != NULL && p->next->data <= cur->data) { p = p->next; } cur->next = p->next; p->next = cur; } cur = next_cur; } return sorted; }

注意next_cur是在拆下cur之前就保存好的。很多人第一次写这里都会犯同一个错误:先把cur从原链表拆下来,再去取cur->next,结果取到的是NULL,原链表后面的节点全丢了。

关于稳定性,寻找插入位置时用p->next->data <= cur->data,遇到相等元素就继续向后找,把cur插到相等元素后面,这样相对顺序就保住了。如果用<,cur会插到相等元素前面,稳定性被破坏,虽然最终仍然有序,但已经不符合严格意义的稳定排序了。

3.4 归并排序:链表全排序的性能王者

归并排序在链表上的表现非常优异,原因是链表天然支持O(1)的合并操作,不需要像数组归并那样准备O(n)的辅助空间。

整个过程可以拆成三段理解。找中点:用快慢指针,快指针每次走两步,慢指针每次走一步,快指针到达末尾时,慢指针正好停在中间。切链:把中间节点的next置为NULL,让链表分成前后两半。合并:写一个merge函数,把两个有序链表按大小串起来。

代码如下:

Node *merge(Node *a, Node *b) { Node dummy; dummy.next = NULL; Node *tail = &dummy; while (a != NULL && b != NULL) { if (a->data <= b->data) { tail->next = a; a = a->next; } else { tail->next = b; b = b->next; } tail = tail->next; } if (a != NULL) { tail->next = a; } else { tail->next = b; } return dummy.next; } Node *list_merge_sort(Node *head) { if (head == NULL || head->next == NULL) { return head; } Node *slow = head; Node *fast = head->next; while (fast != NULL && fast->next != NULL) { slow = slow->next; fast = fast->next->next; } Node *right = slow->next; slow->next = NULL; Node *left = list_merge_sort(head); right = list_merge_sort(right); return merge(left, right); }

找中点时,fast的初始值是head->next而不是head。这个区别很关键:如果链表只有两个节点,fast初始为第二个节点,fast->next为NULL,循环不执行,slow停在第一个节点,链表被切成一个节点和另一个节点,两个子链表都合法。如果fast初始也是head,两个节点的链表会切出NULL和两个节点,递归无法正常终止。

merge函数中使用<=,同样的逻辑保证稳定性。递归调用栈深度是O(log n),相比数组归并排序需要O(n)辅助数组,链表版在空间上优势巨大。

如果想避免递归带来的栈消耗,还可以写自底向上的归并:从1个节点的有序子链表开始,两两合并成2个节点的有序段,再合并成4个节点的有序段,循环直到整个链表变成一段。这种写法代码更复杂,但能彻底消除递归深度问题,适合对栈空间有严格限制的嵌入式场景。

4. 实测对比:1万节点数据下的表现

4.1 随机数据的运行时间量级

为了看清楚四种算法的差异,我在同一台机器上生成了10000个随机int型数据,依次插入链表,然后分别调用四种排序,记录从调用到返回的时间。测试用的编译器默认开O2优化,结果反映了相对量级,具体数值在不同机器上会有差异。

算法10000随机节点耗时额外空间稳定性
选择排序约0.32秒O(1)不稳定
冒泡排序约0.45秒O(1)稳定
插入排序约0.21秒O(1)稳定
归并排序约0.008秒O(log n)栈空间稳定

随机数据下,O(n²)的三种算法差距其实没有想象中大,因为10万次比较在这个数量级上耗时都差不多。但归并排序的O(n log n)优势非常显著,比插入排序快了接近30倍。

选择排序比冒泡排序快一点,原因是选择排序每趟只做一次“拆链+尾插”,而data交换版的冒泡排序每趟可能做几百次赋值。插入排序在随机数据下比选择排序快,因为它不需要每趟都完整扫描剩余部分,平均比较次数少一半。

4.2 不同数据特征对算法的影响

把同一批节点改成近似有序的序列再测一次,结论立刻反转。带tail优化的插入排序几乎只做扫描和判空操作,每趟都在链表尾部追加,整体时间接近O(n),实测大约0.001秒级别,直接反超归并排序。冒泡排序因为swapped标志位提前退出,也快了很多。选择排序则完全无视数据是否有序,依然是0.3秒左右。

这个实验说明一个道理:没有绝对“最好”的排序,只有结合数据特征才谈得上选型。如果链表本身经常处于有序或接近有序的状态,插入排序加上tail优化是最优解;如果是完全随机或乱序的大数据量,归并排序是稳妥选择;如果只是应付课堂作业,选择排序最好写。

我还试过逆序数据,冒泡排序直接退化成最坏情况,时间到1.2秒左右,插入排序在逆序下平均移位距离最大,也接近0.5秒,归并排序仍然稳定在0.008秒上下。链表的链接结构不会像数组那样因为逆序导致缓存命中率骤降,这一点比数组冒泡稍微友好一些。

5. 实战踩坑记录与排查技巧

5.1 三个最容易出问题的代码位置

第一个高频翻车点在插入排序中忘记保存next_cur。我见过不下五次这种错误:把cur拆下来的时候,原链表中cur->next已经没人管了,等到下一轮循环去读cur->next,拿到的是NULL,直接导致链表丢失后半段。解决办法是在拆节点之前,先用一个临时变量保存“下一个待处理节点”。

第二个高频翻车点在归并排序的切链。找完中点后,有人会忘记写slow->next = NULL。这样递归左右两半时,链表根本没有真正断开,左半递归时会把整个链表再处理一遍,右半递归时也会从头遍历,结果就是死循环加无限递归,最后栈溢出。这个NULL赋值是整个归并排序的生命线。

第三个高频翻车点在merge函数的尾节点处理。把a链的剩余节点接到tail->next之后,如果a链本来就是某个更大的有序链的一部分,剩余节点的末尾可能还指向其他节点,形成环。所以在merge前应该确保子链已经独立成链,或者merge完成后不再引用原链。我习惯在每个子链排序后手动检查它的最后一个节点next是否为NULL再进入merge。

5.2 常见问题速查表

现象可能原因定位方法修复思路
排序后遍历链表死循环某节点next未置NULL,形成环在遍历循环里加计数器,输出节点数对比排序前检查所有拆链和merge操作,确保尾节点next为NULL
打印链表发现少了一半节点拆节点时丢失原链表头指针打印每一轮循环后的head优先处理“最小值是头节点”的分支
程序崩溃,段错误访问了NULL指针的next字段用调试器查看崩溃调用栈遍历和拆链时先判NULL
排序结果不稳定比较符号用了<而非<=构造含相等元素的用例将相等条件下插入位置后移到已排序部分末尾
内存泄漏排序过程中动态分配的节点没有被释放或丢失用内存检测工具跑排序确保每个节点都在链表中,未释放的节点先挂到临时链

5.3 调试工具与技巧

链表的调试和数组不一样,数组打印一下就能看到全貌,链表如果节点多,打印也是麻烦事。

我的经验是写一个简短的辅助函数,专门用来“渲染”链表:

void print_list(Node *head, int limit) { int count = 0; while (head != NULL && count < limit) { printf("%d ", head->data); head = head->next; count++; } if (head != NULL) { printf("... (length > %d)", limit); } printf("\n"); }

排序过程中在关键节点插几个print_list调用,比猜哪里断链效率高得多。对于指针走向的判断,我强烈建议拿起纸笔画节点图。画三个节点,把每一步的prev、cur、next标记出来,比对着代码空想要直观得多。我写过这么多链表代码,遇到复杂指针变更仍然会先画图再动手。

6. 从排序出发:可以拓展的方向

6.1 泛型化:从int到任意类型

实际工作中很少会遇到只存int的链表,更多的是存结构体或者不定类型。把排序算法泛化,最直接的方式是把data从int改成void*,再引入一个比较函数指针:

typedef struct Node { void *data; struct Node *next; } Node;

排序代码里的cur->data < min_node->data要改成调用比较器:

if (cmp(cur->data, min_node->data) < 0) { min_node = cur; min_prev = prev; }

比较器由调用方定义,比如字典序、数值序、按某个结构体成员的顺序。这样链表排序就变成一个通用工具函数,插到任何项目里都能用。但要注意,void*版本在拆链和合并中的指针逻辑完全不变,变的只是比较这一个动作,这也印证了“排序算法本质是控制流+指针操作,数据字段只是被比较的对象”。

6.2 双向链表、循环链表的排序思路

双向链表每个节点多一个prev指针,排序时拆链和插链多改一个指针,但整体思路完全复用。找中点可以用头尾指针向中间逼近,比快慢指针少走一半路。归并排序和插入排序在双向链表上同样适用,尤其是插入排序,双向链表找前驱更方便。

循环链表的难点在于“尾节点的next指向头节点”,处理不好就会在遍历时死循环。排序前先把循环结构暂时打破,把尾节点的next置为NULL,按普通单链表排序,排完再把最后一个节点的next重新指向头节点。这个“先破环、再排序、后还环”的思路,是循环链表所有算法的通用套路。

6.3 在外排序与内存管理场景中的延伸

当数据量大到内存放不下时,链表排序的思路依然能迁移。外排序的基本流程是把大文件切成多个能放进内存的小块,每块排序后写回磁盘,再用归并的思想把多个有序小文件合并成一个有序大文件。这个“分块排序+多路归并”的模式,和链表归并排序的“切半递归+merge合并”在结构上是同构的。链表的merge函数如果扩展成多路归并,就变成了外排序的核心组件。

另外,操作系统内核里的任务队列、内存空闲块链表等场景,都会用到有序链表的插入删除。掌握了链表排序的指针操作,你会真正理解为什么“插入”“删除”“查找”这些操作在链表里如此自然,也能在写更复杂的数据结构时少走弯路。

我在实际操作中最大的体会是:链表排序不是“数组排序换个写法”,而是“理解指针操作之后顺手做排序”。建议别背代码,亲手把选择排序的拆链插入写一遍,再写归并排序的切半合并,遇到bug多画图,比对着监视器干瞪眼高效得多。这套东西真正吃透之后,后面再看红黑树、LRU缓存、队列调度这些更复杂的结构,你会发现它们的底层逻辑都是相通的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询