☰
C语言链表核心操作:合并有序链表与快慢指针找中间结点
2026/10/19 9:48:28 网站建设 项目流程

开篇先聊几句

看到这个标题点进来的朋友,估计都是正在刷 LeetCode、被链表题折腾过或者正准备被折腾的人。链表这块内容,在面试里属于“看起来简单,写起来翻车”的典型代表,尤其是涉及合并、找中间结点这类操作时,指针一多就容易乱。这篇博文就围绕两道题展开:合并两个有序链表、找链表的中间结点,全部用 C 语言实现。别急着说“这题简单”,很多细节和坑,刷过的人心里都有数。

这篇内容适合谁?刚接触链表、想系统刷题巩固基础的同学;准备面试、需要快速过一遍链表经典题型的人;以及已经刷过但想看看有没有更稳的写法和更清晰的思路复盘的朋友。我会把核心思路、C 语言的写法细节、常见雷区、以及实际调试时遇到的情况都展开讲讲,保证你读完能把这两道题的变体也顺手拿下。

1. 链表题的整体设计与思路拆解

1.1 为什么链表题总在面试里阴魂不散

链表在数据结构里是个非常基础的存在,但它的魅力恰恰在于“基础却容易出错”。数组的随机访问是现成的,可链表得靠指针一步步走,稍微一个指针更新顺序不对,轻则结果错乱,重则直接崩溃。面试官喜欢考链表,不是因为链表本身多高级,而是它能把“是否真正理解内存中数据的组织方式”和“是否具备严谨的边界条件思维”一次测出来。

合并两个有序链表和寻找链表中间结点,算是链表操作里的基本功。前者考验“归并思想”用指针表达的能力,后者考验“快慢指针”这种经典技巧的灵活运用。很多更复杂的链表题,比如排序链表、重排链表,本质上都在使用这些底层技能。把这两道题吃透,相当于给自己铺好了后续刷题的路基,性价比很高。

1.2 两道题放在一起刷的原因

我刷题有个习惯,会把相关联的题放一起,做横向对比,这样记忆更深刻。合并链表和找中间结点有个共同的训练点:对指针的移动节奏和终止条件的敏感度。合并时你要盯着两个链表的当前结点,谁小就接谁,同时还要注意一个链表先走完的情况;找中间结点时,快指针走两步、慢指针走一步,当快指针撞到边界时,慢指针刚好落在中间。

放在一起刷的好处是,你可以用一个统一的思考框架去处理“遍历时多个指针如何协作”的问题,而不是每道题都从头开始构建思路。这样不仅省时间,还能把知识连成片。如果你正在按专题刷题,建议也把链表的题先归个类,再集中突破,效率会高很多。

2. 合并两个有序链表的全解析

2.1 题目要求与核心思路

题目要的是把两个升序链表合并成一个新的升序链表,并返回新链表的头结点。很多人一看到“合并”,第一反应是把两个链表拆开重排,或者再开个数组排序,那就绕远了。其实这题就是标准的“双指针归并”,思路一句话就能说清:两个指针分别指向两个链表当前结点,比较值的大小,把值小的结点接到结果链表尾部,然后对应的指针往后移一步;重复这个过程,直到某一个链表遍历完,剩下的另一个链表直接整体接上去。

这里有个关键认知:不需要真正“新建”节点,只需要调整指针的 next 指向。链表的结点还是那些结点,只是它们之间的连接关系变了。如果你每次创建一个新结点再复制值,不仅浪费空间,还容易出错,完全没必要。

2.2 迭代写法:哑结点是个好东西

迭代写法的核心是设置一个“虚拟头结点”(哑结点),让结果链表的头部维护变得统一。很多人刚开始觉得哑结点多余,觉得直接用一个指针指向第一个比较出来的较小结点不就行了吗?话是没错,但那样代码里就要单独判断“当前是不是第一个结点”,逻辑分支会变多。用一个哑结点之后,所有的结点都统一接到尾结点后面,最后再返回dummy->next,干净利落。

核心代码长这样:

struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2) { struct ListNode dummy; struct ListNode* tail = &dummy; dummy.next = NULL; while (list1 != NULL && list2 != NULL) { if (list1->val <= list2->val) { tail->next = list1; list1 = list1->next; } else { tail->next = list2; list2 = list2->next; } tail = tail->next; } if (list1 != NULL) { tail->next = list1; } if (list2 != NULL) { tail->next = list2; } return dummy.next; }

注意我这里的dummy是一个普通结构体变量,不是指针。这样在栈上分配,不用手动释放,也不会造成内存泄漏。如果你写成struct ListNode* dummy = malloc(...),那记得用完要 free,否则测试环境里容易出问题;虽然平时本地调试看不出啥,但拿到在线评测里跑多了就可能被内存检查程序抓到。

2.3 递归思路:写起来很短,但要真想明白

递归方法也很经典,核心思想是:比较两个头结点,谁的值小,谁就把剩下的合并工作“委托”出去。代码很短:

struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2) { if (list1 == NULL) return list2; if (list2 == NULL) return list1; if (list1->val <= list2->val) { list1->next = mergeTwoLists(list1->next, list2); return list1; } else { list2->next = mergeTwoLists(list1, list2->next); return list2; } }

这个写法最需要理清的是递归返回后,当前结点的 next 指向谁。比如list1->val <= list2->val时,说明list1应该站在结果链表的当前位置,它后面接什么?接“list1->next和list2合并后的结果”。这种“假设子问题已经解决”的思维,是理解递归的关键。递归写法的递归深度取决于链表长度,极端情况下可能栈溢出,但在 LeetCode 的测试数据量级里一般没大碍。刷题时我建议迭代为主、递归作辅助理解,两者都要能写出来。

2.4 合并操作的边界条件与易错点

合并这题有几个坑,我逐个说说。

第一,链表中可能存在相同值的结点。比较时用<=而不是<,原因在于这样能保证排序稳定性,对结果没实质影响,但能让你在处理两个链表值相等时,优先取list1的结点,逻辑上更顺。

第二,某个链表初始为空。这种情况在代码里已经被前两个if挡住,直接返回另一个链表,不需要多余判断。

第三,循环结束后,哪个链表还有剩余就接哪个。很多人漏掉这一步,结果合并后链表后半段突然断掉。

第四,如果题目要求“不能修改原链表”而必须在合并后保持原链表不变,那这种迭代+调整 next 的方法就不适用了,得额外复制结点。好在 LeetCode 这道题默认允许改链表结构,所以问题不大,但面试时最好和面试官确认一下。

3. 链表的中间结点全解析

3.1 题目要求与朴素思路

题目要求:给定一个带有头结点的非空单链表,返回链表的中间结点。如果有两个中间结点,则返回第二个中间结点。比如链表长度为奇数时,中间结点是唯一的;长度为偶数时,比如 1 2 3 4,那么中间结点应该是 3 还是 2 ?题目明说了“第二个中间结点”,所以是 3。

最朴素的思路是先遍历一遍,数出一共有多少个结点,然后再走一半的步数,到达中间位置。这个做法的复杂度是 O(n),但要遍历两遍,很多人觉得不够“优雅”。其实如果面试时间紧张,第一遍写出这种朴素解法,再把快慢指针写出来加分,也是很稳的策略。真正的优化是用快慢指针一遍解决问题。

3.2 快慢指针的直观解释

快慢指针的思路特别贴近生活:两个人赛跑,速度快的是另一个人的两倍,当快的人到达终点时,慢的人刚好在路程的一半。放到链表里就是让快指针每次走两步,慢指针每次走一步。快指针到达链表的尾部时,慢指针正好指向中间结点。

你可能想问:为什么快指针能“走两步”?因为链表不是数组,快指针说走两步,是指通过fast->next->next来移动,这要求当前结点以及它的 next 都不为空,否则就是非法访问。所以循环条件要写成while (fast != NULL && fast->next != NULL),两个条件都满足时才前进。

具体实现:

struct ListNode* middleNode(struct ListNode* head) { struct ListNode* slow = head; struct ListNode* fast = head; while (fast != NULL && fast->next != NULL) { slow = slow->next; fast = fast->next->next; } return slow; }

这段代码跑一遍,结果会自动满足“偶数长度返回第二个中间结点”的题目要求,不需要额外判断,这也是快慢指针在这个场景里最棒的地方。

3.3 循环条件的深度验证

很多人写这段代码时会在循环条件上犹豫:到底是fast->next != NULL还是fast->next->next != NULL?我们来仔细推理一下。

假设链表长度为 5(奇数),一开始fast和slow都在头结点。第一次循环后,slow到第 2 个结点,fast到第 3 个结点;第二次循环后,slow到第 3 个结点,fast到第 5 个结点。此时fast->next为 NULL,循环停止。返回的slow正好是第 3 个结点,中间结点没错。

再看链表长度为 4(偶数)。第一次循环后slow到第 2 个结点,fast到第 3 个结点;第二次循环时,当前fast在第 3 个结点,fast->next为第 4 个结点,非空,且fast->next->next为 NULL,条件fast->next != NULL为真,所以继续循环。第二次循环结束后,slow到第 3 个结点,fast到 NULL。循环停止,返回第 3 个结点。这就是题目要求的“第二个中间结点”。

这里正好看出为什么这个写法是符合题意的。如果把循环条件改成while (fast != NULL && fast->next->next != NULL),结果就会变成长度为 4 时返回第 2 个结点,和题目要求相反。所以刷题时,这类细节一定要拿具体例子在纸上画一画,别看代码短就觉得简单。

3.4 链表中点用途的进一步思考

求中间结点本身不是目的,它往往是更复杂操作的第一步。最常见的场景是:

  • 链表排序中的归并排序,需要先找到中点把链表拆成两半。
  • 判断回文链表,需要找到中点然后反转后半部分。
  • 重排链表,比如 L0 → Ln → L1 → Ln-1,也需要先找中点。

所以不要觉得这题刷完就完事了,后续很多题都能用上这个技能。建议你在刷完这题后,立刻试试“回文链表”和“排序链表”这两道题,把快慢指针用熟,再到综合体里用,体会一下“基础组件组装”的感觉。

4. 实操过程与核心环节实现

4.1 环境准备与测试用例设计

动手写代码之前,我建议你现在本地有一个趁手的 C 语言编译环境,CLion、VS Code 配好 gcc、或者直接用在线编译器都行。我个人习惯在 VS Code 里配一个简单的调试任务,这样能打断点看指针的变化,这对链表题来说特别有帮助。

设计测试用例时,有个让很多人忽略的点:不能只看普通情况。链表问题至少要测这些场景:

  • 空链表,这里题目保证非空,但合并题里两个链表都可以为空
  • 只有一个结点的链表
  • 长度为 2 的情况
  • 长度为奇数和偶数的不同情况
  • 两个有序链表长度差异很大的情况
  • 链表中有连续重复值的情况

我刷题时会把测试用例写成数组转换链表的小函数,直接批量生成测试数据,方便又高效。比如用下面这个小工具:

struct ListNode* createList(int arr[], int n) { if (n <= 0) return NULL; struct ListNode* head = malloc(sizeof(struct ListNode)); head->val = arr[0]; head->next = NULL; struct ListNode* cur = head; for (int i = 1; i < n; i++) { cur->next = malloc(sizeof(struct ListNode)); cur->next->val = arr[i]; cur->next->next = NULL; cur = cur->next; } return head; }

注意用完后要写一个freeList函数释放内存。很多新手在本地跑测试没释放内存,一时看不出问题,但一旦加了内存检测工具如 Valgrind,就可能提示内存泄漏,在面试或比赛环境里给面试官的印象也不好。所以代码虽小,规范不能丢。

4.2 完整可运行的合并链表 Demo

写一个可以直接编译运行的小程序,方便你观察输出。下面这段代码包含合并函数、打印函数和测试主函数:

#include <stdio.h> #include <stdlib.h> struct ListNode { int val; struct ListNode *next; }; struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2) { struct ListNode dummy; struct ListNode* tail = &dummy; dummy.next = NULL; while (list1 && list2) { if (list1->val <= list2->val) { tail->next = list1; list1 = list1->next; } else { tail->next = list2; list2 = list2->next; } tail = tail->next; } if (list1) tail->next = list1; if (list2) tail->next = list2; return dummy.next; } void printList(struct ListNode* head) { while (head) { printf("%d -> ", head->val); head = head->next; } printf("NULL\n"); } int main() { int a1[] = {1, 2, 4}; int a2[] = {1, 3, 4}; struct ListNode* l1 = createList(a1, 3); struct ListNode* l2 = createList(a2, 3); struct ListNode* merged = mergeTwoLists(l1, l2); printList(merged); return 0; }

这里我没有把createList写出来,主要是为了保持代码块简短,但你实际测试时需要把它补上。打印结果应该是1 -> 1 -> 2 -> 3 -> 4 -> 4 -> NULL。注意mergeTwoLists返回后,原来的l1和l2的很多节点已经被串到了新链表里,所以如果你还想分别打印原链表,就别指望了。

4.3 中间结点题目完整可运行 Demo

同样写个小测试程序:

int main() { int arr[] = {1, 2, 3, 4, 5}; struct ListNode* head = createList(arr, 5); struct ListNode* mid = middleNode(head); printf("中间结点是 %d\n", mid->val); printList(mid); // 从中间结点开始打印到链表尾部 return 0; }

运行结果会输出中间结点是 3,然后打印3 -> 4 -> 5 -> NULL。这里有个容易混淆的地方:返回的中间结点并不是把链表拆成两半并返回后半部分的头结点,从中间结点开始的整条链表其实还是原来的后半条链表。理解这一点很重要,因为很多利用中间结点的算法,是把mid作为后半段的头来用的。

4.4 调试技巧:如何用打印和断点观察指针变化

我见过太多人在链表题上卡了很久,最后发现是某个条件写反了。这里分享一个我用得比较顺的排查方法。

第一,递归和迭代代码里,如果逻辑理不清,就在关键步骤加printf打印当前结点的值。比如在合并循环里,每接一个结点就打印tail->next->val,就能看到接的顺序是否符合预期。虽然 LeetCode 提交时不会要求这些输出,但在本地调试时非常有帮助。

第二,遇到复杂场景可以在 IDE 里设条件断点。比如我只想在处理“第二个链表第一个值小于第一个链表第一个值”的时候停下来,就可以在 if 分支里加个条件断点,观察list1和list2当前的指针指向。这样做比反复看代码推理快得多。

第三,画出链表连接图。纸上画图永远是最直观的。每次写完一个涉及 next 修改的题目,我都会自己在草稿纸上画一遍链表指向的变化,养成习惯后,写代码会少很多低级错误。

5. 常见问题与排查技巧实录

5.1 内存访问错误与野指针

链表题最常见的崩溃原因就是访问了空指针或已经释放的节点。合并链表里,如果循环条件少写一个&& list2 != NULL,一旦某个链表先走到空,接着就会在循环体内访问list2->val,直接段错误。找中间结点时,如果循环条件里少判断fast != NULL,当链表长度为偶数时,最后一次fast已经在 NULL 上,再执行fast->next就会崩溃。

所以写链表代码,有一个习惯必须养成:每次访问一个指针的成员之前,先问自己“这个指针有没有可能为空?”尤其是->操作,一旦左边是 NULL,程序立马崩。这种错误有时本地能复现,有时时好时坏,非常恼人。

5.2 无限循环或链表成环

另一个经典问题是循环条件写反,导致死循环。比如合并题里,如果比较完list1->val <= list2->val后,忘记把list1移动到下一个节点,那下次循环还会继续处理同一个节点,链表不但没接上,还可能形成环。我复盘这种 bug 时发现,往往是在多个分支里复制粘贴了相同的指针更新,但漏改了其中一个变量名。建议你在每个分支结束后,都确认一下对应的指针确实后移了。

如果你怀疑链表成环,可以用一个临时节点,从头开始向后走若干步,打印每一步的地址和值,看是否出现重复地址。如果地址重现,那基本就是操作指针顺序错了。

5.3 本地跑的好好的,在线就是不通过

这种情况十有八九和内存管理有关。本地编译器可能没有开启严格的内存检查,在线评测系统则可能使用不同的内存检测机制。特别是你在本地用malloc创建了哑结点却忘记free,虽然程序能正常运行,但在某些平台上会判定为内存泄漏。另一种可能是编译器差异导致的未定义行为,比如对同一个变量多次使用->而没有加括号,或者把=写成了==但被警告信息掩盖了。

遇到这种问题,我的建议是把代码从头到尾按“指针状态”重新捋一遍,并用多个编译器开-Wall编译看看警告。LeetCode 的报错信息也很有帮助,如果报AddressSanitizer相关错误,那几乎可以确定是越界或野指针问题,顺着这个方向排查,通常能很快定位。

5.4 针对这两道题的避坑经验汇总

为了让你看得清楚,我把常见的出错点整理成一张表,方便你对照自查。

题目易错操作可能后果正确做法
合并链表循环条件遗漏非空判断访问空指针,崩溃循环内同时判断 list1 和 list2
合并链表指针更新漏写或写错变量死循环或输出错误每个分支更新对应链表的指针
合并链表忘记处理剩余链表结果链表丢失后半段循环外接上非空链表
找中间结点循环条件写成 fast->next->next偶数长度返回前一个用 fast != NULL && fast->next != NULL
找中间结点把 slow 和 fast 移动顺序写反结果偏移先移动 slow,再移动 fast
两种题目使用 malloc 创建哑结点忘记 free内存泄漏用栈变量,或及时 free
两种题目测试数组未转换为链表就直接用编译错误写 createList 辅助函数

这些坑我基本都是逐个踩过的。尤其是循环条件里fast->next->next那一次,当时我自信满满地提交,结果 runtime error,后来才发现自己把题目条件“偶数长度的第二个中间结点”理解反了。后来我学会了一个方法:遇到快慢指针,先拿长度为 2 和长度为 3 的链表在纸上手动模拟一遍,写完立刻验证,这样基本不会再错。

6. 从这两道题出发的扩展与思考

6.1 合并两个有序链表的变体

合并两个升序链表如果变成合并 K 个升序链表,思路就要升级。常见做法有两种:一是顺序合并,也就是两两合并,代码简单但效率稍差;二是用最小堆(优先队列)每次从 K 个链表的头结点里挑最小的,复杂度更优。如果你用 C 语言实现最小堆,正好能复习一下堆的写法。我个人在刷 K 个链表合并时,更推荐优先队列的思路,因为它更通用,面试时也更能体现数据结构功底。

另外还有一个变体是“合并两个降序链表”或“按降序输出合并结果”,这时你可以先把链表反转,再套用升序合并;也可以直接在比较时改变大小判断方向,但要注意结果链表是降序,指针连接的顺序也需要对应调整。

6.2 中间结点扩展:回文链表与重排链表

找中间结点是基本功,练完以后请立刻挑战下面两道题:

  • 回文链表:先找中点,再把后半部分反转,最后逐个比较前半部分和后半部分。这个题把找中点和反转链表结合在一起,非常经典。
  • 重排链表:L0 → Ln → L1 → Ln-1 → ...,同样是找到中点后拆成两个链表,再把第二个链表反转,然后合并。

这两道题做完,你对“多指针+链表操作”的掌握会上一个台阶。刷题嘛,最怕就是孤立地背答案,把所有题当作组合游戏来理解,效率会完全不同。

6.3 关于刷题节奏的一点个人经验

最后想和你分享一个我自己的体会。很多人刷题喜欢追求数量,一天十道、二十道地过,结果没过几天全忘了。我后来调整了策略,同一类的题集中刷,刷完写总结,过一两周再重新做一遍,效果比盲目刷题好很多。链表这块,今天这两道题,加上反转链表、删除倒数第 N 个结点、合并 K 个链表,就构成了一个很完整的小专题,建议你按这个顺序刷。

写代码时,一定不要只看不写。C 语言的指针操作,一定要亲手在编辑器里敲出来,跑起来,出错,再修,这个循环过程才是真正的成长。你可能觉得一开始写得很慢,但慢就是快,我在实际带项目的时候见过太多人,理论一套套的,一上手写链表就卡壳,这就是平时纸上谈兵太多造成的。

下次如果你再遇到链表题,可以先在草稿纸上画图,再动手写代码,相信我,这条路走多了会越来越顺。

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

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

立即咨询