链表排序用归并排序:递归实现、快慢指针与代码详解

发布时间:2026/10/16 15:04:10
链表排序用归并排序:递归实现、快慢指针与代码详解 链表排序这个话题很多人一开始都会觉得“不就是排个序嘛”但真上了手才发现数组里玩得挺溜的那套排序算法搬到链表上全变味了。用递归实现归并排序是目前处理链表排序最干净、最不容易出错的方案之一。我在实际项目里用它处理过上千节点的链表数据也踩过递归边界、快慢指针初始化的坑这篇就把整个思路从零拆开配合完整代码和调试经验给需要的人一条能直接走通的路。1. 为什么链表排序首选归并排序1.1 数组排序的思维惯性在链表上行不通先说我见过最多的错误拿快速排序的思路直接套链表。快排的核心是随机选一个基准值然后把数组拆成两部分左边比基准小、右边比基准大。这个操作在数组里依托下标随机访问代价是O(1)。链表就不行了你要访问中间某个节点只能从头一个个next过去找基准的过程本身就变成O(n)更别提分区时还要维护三个链表的头尾指针代码写出来又长又容易弄丢节点。插入排序呢链表插入确实方便只要改指针就行但插入排序的时间复杂度是O(n²)数据一多就肉眼可见地变慢尤其是链表这种只支持顺序访问的结构每次查找插入位置都要重新走一遍情况比数组更糟。那堆排序呢链表没法像数组那样从下标直接算父节点和子节点的位置你得额外维护一个数组来模拟堆数据量小还行大了就没意义了。排除下来你会发现归并排序几乎是链表排序的最优解。它的核心操作是“分割”和“合并”这两个操作恰好都只依赖顺序访问——分割链表只需要快慢指针找中点合并链表只需要不断比较两个链表的头节点完全不需要随机访问下标。1.2 归并排序的天然适配性我在给某跨平台系统的数据处理模块选排序方案时专门对比过几类算法在链表上的表现。归并排序的最坏时间复杂度是O(n log n)这是快排都比不了的快排最坏能退化到O(n²)。虽然归并有额外的空间开销但对链表来说这个开销不是O(n)的辅助数组而是递归带来的栈空间也就是O(log n)。这一点和数组归并完全不同数组归并必须要一个O(n)的临时数组链表归并只需要改指针就能在原链表上完成操作。还有稳定性。归并排序天然稳定值相等的节点在排序后保持原有相对顺序。我之前在做某图像处理Demo时需要按优先级排序一批处理任务同时又要保留任务的提交顺序稳定排序这个特性直接省掉了额外加时间戳字段的麻烦。所以结论很简单如果你要在链表上实现排序优先考虑递归归并不要被数组时代的排序习惯带偏。2. 递归思路拆解三个关键步骤2.1 终止条件什么时候递归该停了递归归并排序的整体框架是分割、递归排序、合并三步。但很多人第一次写归并排序的递归第一个问号就是递归的终点到底在哪答案很朴素当链表为空或者只剩下一个节点时它天然就是有序的不需要再排序。所以终止条件就两句话if (head NULL || head-next NULL) { return head; }这里要特别注意判断条件是head NULL或者head-next NULL两者缺一不可。只判断head NULL遇到单节点链表时还会继续递归永远拆不到头栈直接爆掉。只判断head-next NULL空链表进来就没法处理。我见过不少新手只写前半句然后对着段错误发愁。有人还会问为什么递归到单个节点就结束了而不是用迭代的方式自底向上归并。这个问题的答案是递归版本更直观代码量也更少。自底向上的归并要控制步长一轮一轮合并相邻的链表片段代码逻辑绕还得小心维护每轮的边界调试起来很累。递归版本从完整的链表开始往上往下拆思路非常线性。2.2 找中点快慢指针的细节把链表从中间拆成两半是递归归并最核心的操作之一。常规做法是快慢指针慢指针每次走一步快指针每次走两步快指针走到末尾时慢指针刚好落在链表的中间位置。说出来很简单写起来有一个非常关键的细节快指针的初始值到底是从head出发还是从head-next出发。// 写法一 struct ListNode* slow head; struct ListNode* fast head; // 写法二 struct ListNode* slow head; struct ListNode* fast head-next;这两种写法的区别在于最终分割点的位置不同。写法一在遇到偶数长度链表时slow会落在中间的偏右位置也就是中间两个节点的后一个写法二会让slow落在中间两个节点的前一个。对于归并排序来说只要能把链表拆成左右两部分差一个节点没有本质影响两种都能跑通。但写法二在处理“链表只有两个节点”时有一个实际好处。如果链表只有两个节点head-next-next是NULL循环可以直接跳过slow还是指向第一个节点mid指向第二个节点分割干净利落。你要是用写法一两个节点时fast先走两步直接到末尾循环结束后slow其实已经走到了第二个节点mid变成NULL左半部分是原链表右半部分是空链表虽然也能排序但递归调用会有一次无效的深度不划算。我实际测试下来的心得是用写法二也就是fast head-next整体代码更稳递归深度更小推荐直接照这个写。循环条件也要配套写完整while (fast ! NULL fast-next ! NULL) { slow slow-next; fast fast-next-next; }这里不能只写fast ! NULL因为fast每轮跳两步如果当前节点是最后一个节点fast-next已经是NULL再去访问fast-next-next就会解引用空指针直接崩溃。分割之后要记得把链表真正断开struct ListNode* mid slow-next; slow-next NULL;这一步很多人会忽略。如果你只算出mid不断开slow-next那么左右两个子链表其实还是共享节点的后面的递归排序就会互相干扰你怎么排都排不对。断开之后左半边是从head开始到slow结束的链表右半边是从mid开始的链表两者互不相干可以放心递归。2.3 拆分与合并的递归框架分割完成后剩下的事情就是递归排序左右两半然后合并。这段代码是整个算法的骨架struct ListNode* left sortList(head); struct ListNode* right sortList(mid); return merge(left, right);这里要理解一个关键点递归调用sortList(head)时head已经指向左半边的头部但左半边现在的尾节点是slowslow-next已经被设置为NULL所以递归时不会越界跑到右半边去。同理mid是右半边的头部它后面的链表是完整的。递归框架理解透了你就会发现链表归并排序和数组归并排序的结构一模一样数组用下标分成左右两段链表用指针分割数组merge的时候创建新数组链表merge的时候改next指针。剩下的差别全在合并函数的实现上。3. 合并两个有序链表核心代码与原理3.1 合并逻辑的两种实现方式合并函数的任务非常明确给定两个已经有序的单链表把它们合并成一个新的有序单链表并返回新链表的头节点。两种常见思路一种是递归合并一种是迭代合并。递归合并代码短但每层递归都要压栈合并的节点数多时栈深度可能和节点数成正比存在栈溢出的风险。迭代合并只用一层循环空间复杂度是O(1)更推荐在生产环境使用。迭代合并的核心逻辑是维护一个移动的尾指针不断把值更小的链表的头节点接上去。我写一个可以直接用的版本struct ListNode* merge(struct ListNode* l1, struct ListNode* l2) { struct ListNode dummy; struct ListNode* tail dummy; dummy.next NULL; while (l1 ! NULL l2 ! NULL) { if (l1-val l2-val) { tail-next l1; l1 l1-next; } else { tail-next l2; l2 l2-next; } tail tail-next; } tail-next (l1 ! NULL) ? l1 : l2; return dummy.next; }这段代码的运行逻辑是每次比较l1和l2的当前节点值谁小就把谁接到tail后面同时l1或l2前进一步tail也跟着前进一步。循环结束的条件是其中一个链表走到了NULL此时另一个链表剩下的节点全部接到tail后面因为剩下的节点本身已经有序直接整段拼接即可。3.2 哨兵节点的使用你可能注意到了代码里我定义了一个局部变量dummy而不是直接定义一个struct ListNode* head NULL。这个dummy就是哨兵节点它的next指向合并后链表的头节点。为什么需要它如果没有dummy合并的第一个节点需要特判如果l1的值更小头节点就是l1否则头节点就是l2。每合并一个节点都要手动更新头指针代码会多出一堆if-else而且容易出错。有了dummy所有节点都统一通过tail-next接入最后直接返回dummy.next省心又不容易出bug。这里要特别提醒一个内存管理的问题dummy是栈上的局部变量不是malloc出来的所以函数结束时不需要free也不会造成内存泄漏。有些人看到dummy就不放心想着是不是应该释放它其实完全没有必要它只是一个占位节点整个函数生命周期里都在栈上自动销毁。合并的时候还有一个细节就是比较符号用还是。用会优先取l1的节点这对稳定性很重要。节点值相等时保持左半部分的节点在前右半部分的节点在后归并排序的稳定性就体现在这里。如果你用相等的节点就全跑到右半边去了相对顺序被破坏稳定性就丢了。合并函数的时间复杂度是O(n)它只需要遍历一遍两个链表的所有节点不管链表多长都是线性扫描不存在回溯或重扫。空间复杂度是O(1)除了几个指针变量没有额外分配任何节点。4. 完整代码与复杂度分析4.1 完整可运行代码把上面的分割和合并串起来就是一个完整的链表递归归并排序实现。我尽量把注释写清楚方便直接抄。#include stdio.h #include stdlib.h struct ListNode { int val; struct ListNode *next; }; // 合并两个有序链表 struct ListNode* merge(struct ListNode* l1, struct ListNode* l2) { struct ListNode dummy; struct ListNode* tail dummy; dummy.next NULL; while (l1 ! NULL l2 ! NULL) { if (l1-val l2-val) { tail-next l1; l1 l1-next; } else { tail-next l2; l2 l2-next; } tail tail-next; } tail-next (l1 ! NULL) ? l1 : l2; return dummy.next; } // 递归归并排序主函数 struct ListNode* sortList(struct ListNode* head) { // 终止条件空链表或单节点链表已有序 if (head NULL || head-next NULL) { return head; } // 快慢指针找中点 struct ListNode* slow head; struct ListNode* fast head-next; while (fast ! NULL fast-next ! NULL) { slow slow-next; fast fast-next-next; } // 断开链表分成左右两半 struct ListNode* mid slow-next; slow-next NULL; // 递归排序左右两半 struct ListNode* left sortList(head); struct ListNode* right sortList(mid); // 合并排序后的两半 return merge(left, right); } // 辅助函数创建新节点 struct ListNode* createNode(int val) { struct ListNode* node (struct ListNode*)malloc(sizeof(struct ListNode)); node-val val; node-next NULL; return node; } // 辅助函数打印链表 void printList(struct ListNode* head) { while (head ! NULL) { printf(%d , head-val); head head-next; } printf(\n); } // 测试主函数 int main() { struct ListNode* head createNode(4); head-next createNode(2); head-next-next createNode(1); head-next-next-next createNode(3); head-next-next-next-next createNode(5); printf(排序前: ); printList(head); head sortList(head); printf(排序后: ); printList(head); return 0; }这套代码直接编译就能跑输出结果应该是排序前: 4 2 1 3 5 排序后: 1 2 3 4 54.2 时间复杂度与空间复杂度归并排序的时间复杂度是稳定的O(n log n)不区分最好、最坏、平均情况因为不管输入数据长什么样分割的递归树结构都是一样的每层都要遍历所有节点做合并操作。递归层数是log2(n)每层的合并操作累加起来是O(n)所以总时间复杂度是O(n log n)。这就是为什么我在前面说归并排序是链表排序里最稳妥的选择——它不会像快排那样遇到逆序数据就退化到O(n²)。空间复杂度上链表归并排序比起数组归并排序有一个显著优势不需要O(n)的辅助数组只需要递归调用栈的空间。递归深度是log2(n)所以空间复杂度是O(log n)。需要注意这是不考虑递归栈帧本身常数开销的理论值实际上每个栈帧会存一些局部变量但量级就是O(log n)。4.3 稳定性分析归并排序的稳定性主要靠合并函数保证。我的merge里用了当l1和l2的当前节点值相等时l1的节点先被接入这样就保持了左半部分节点相对右半部分节点的顺序。整个排序过程中相同值的节点不会被交换到对方的区域所以稳定性成立。这一点在实际业务里非常实用。比如你有一个按时间排序的日志链表现在要按日志级别再排一次序如果排序算法不稳定同级别日志的内部时间顺序就乱了。用稳定的归并排序两次排序就能叠加出“先按级别、再按时间”的复合排序效果不用再做额外处理。5. 常见问题与排查技巧实录5.1 快慢指针边界问题快慢指针是链表归并里最容易写崩的地方。最常见的错误是把循环条件写成while (fast ! NULL fast-next ! NULL)漏掉fast-next ! NULL然后访问fast-next-next时报段错误。还有一种错法是初始化时写成struct ListNode* slow head; struct ListNode* fast head;然后循环条件还是fast ! NULL fast-next ! NULL这样在偶数长度的链表上会得到偏右的中点分割后左右长度不均衡但整体算法依然能工作。不过正如前面说的用fast head-next这种写法能得到偏左的中点左右长度更均衡递归深度更小尽量使用这种写法。我的排查经验是在分割前后打印slow和mid节点的值检查分割点是否正确。如果分割点明显偏右、左半边比右半边多出两个节点以上就说明循环条件或指针移动有问题。5.2 递归栈溢出风险链表长度特别大时递归深度是log2(n)1万节点的链表递归深度也就14层左右完全不用担心。但如果你把终止条件写错了比如漏了head-next NULL的判断递归就会在单节点链表上无限调用下去栈很快爆掉。我之前在某公司开发一个数据同步工具时就遇到过单链表节点十几万个递归深度约17层完全没问题。真正危险的是自底向上实现时忘了更新步长或者递归函数里不小心把head传成了head-next导致递归深度变成n。遇到栈溢出优先检查递归的终止条件和参数传递。5.3 调试方法如何用打印定位问题链表调试比数组麻烦不能直接打印下标。我的习惯是写一个简单的打印函数每次操作后把当前链表的状态打出来void printList(struct ListNode* head) { while (head ! NULL) { printf(%d - , head-val); head head-next; } printf(NULL\n); }在sortList函数的入口处打印传入的链表在merge函数入口打印两个待合并链表在merge返回后打印合并结果。这样能非常直观地看出递归拆分是否正确、左右链表是否被正确排序、合并时是否丢失节点。配合一个小数据集比如4个节点手动走一遍递归流程比盯着代码干想效率高得多。我在帮一位同事排查他的链表归并排序问题时就是通过在sortList入口打印参数发现分割后右半部分总是空链表最后定位到是分割前忘了断开slow-next。打印一遍问题立刻现出原形。6. 边界条件与性能实测6.1 各种输入情况的测试用例写归并排序不能只测一个正常序列就完事。边界情况才是最容易出bug的地方。我常用的测试用例有这几种空链表head为NULLsortList应该直接返回NULL不能崩溃。单节点链表head-next为NULL应该原样返回节点本身已序。两个节点的链表验证快慢指针在最短链表中能正确分割。逆序链表验证排序结果和递归性能。全部相等的链表验证稳定性以及快慢指针在大量相等元素中也能正常工作。非常长的链表比如10万节点验证递归深度是否符合预期时间是否在可接受范围内。每个用例跑一遍观察输出结果和耗时基本就能确认实现是否健壮。6.2 性能实测结论我拿一个5万节点的随机链表实测过递归归并排序在普通笔记本上的耗时大概在几十毫秒到一两百毫秒之间具体取决于节点的内存分布情况。链表节点如果是malloc分散分配的缓存命中率低耗时会比连续分配的内存高不少。这是链表本身的特性和排序算法无关但如果你对性能有极端要求可以考虑先把链表转成数组排序再转回链表虽然多了一次O(n)的拷贝但排序速度会快很多。内存使用方面5万节点递归深度约16层栈空间消耗微乎其微。即便到100万节点递归深度也才20层左右不需要担心函数调用开销。真正值得担心的是合并函数的循环体执行次数也就是n log n次比较和指针调整这个量级无论如何也绕不开。7. 延伸与扩展思路7.1 把递归改为自底向上的非递归版有些场景要求绝对不能使用递归比如嵌入式中栈空间极度有限或者链表长度巨大。这时可以改成自底向上的归并排序思路先把链表拆成若干个长度为1的有序段然后相邻两段两两合并合并后的段长度翻倍直到整个链表有序。实现核心是用一个步长变量来控制合并段的大小。每轮开始从链表头开始每两个步长节点取一段调用merge合并然后更新步长。代码比递归版复杂一些但可以做到完全不用递归空间复杂度降为O(1)。7.2 链表归并排序在更广泛场景中的应用归并排序的思想其实不只在排序链表时有用。我后来在做某跨平台系统的数据合并模块时经常需要把多个有序数据源合并成一个有序结果这时直接复用merge函数比每次调用完整排序高效得多。那一套代码后来被多个项目复用核心逻辑一直没变过。还有一点体会链表归并排序的实现是我推荐给初学者的递归练习题目之一。它不强求递归但用了递归会让代码结构变得非常清晰。把递归拆清楚、把指针边界处理好递归能力会有实打实的提升。最后再分享一个我自己的经验调试递归算法千万不要只靠人脑跟踪函数调用。打印当前处理的数据范围和返回值能省下大量时间。我第一次写完这个链表的归并排序就是靠这种打印定位到快慢指针初始化的问题。你现在看这篇文章学会了等你真正写一遍、调试一遍这个算法就长在你身上了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询