C语言硬啃LeetCode HOT 100链表题:从模板到心法
2026/9/7 18:56:17 网站建设 项目流程

刷LeetCode HOT 100的链表题,很多人当初劝我别用C语言:没有现成的容器、没有标准库帮你处理节点、连初始化一个链表都要手写一二十行。但刷完这二十几道题之后我想说——恰恰因为C语言什么都不给你,你才被逼着把链表的每一个指针都看得明明白白。这篇博客是“C's Log”系列的第一篇,记录我用C语言硬啃HOT 100链表题的完整过程:怎么搭基础设施、核心题目怎么拆解、哪些坑我踩得最惨,以及最后沉淀下来的模板和心法。

如果你正准备刷LeetCode,或者正在学C语言链表部分不知道怎么练手,这篇文章都值得花十分钟看完。我不讲虚的,全部是实际敲过、跑过、提交报过错之后的真实经验。HOT 100里的链表题看起来多,其实归类之后根本不需要焦虑,跟着我的分组和复盘节奏来,你也能把这块硬骨头啃下来。

1. 为什么选C语言刷链表题:动机、准备与基础设施

1.1 刷题语言选型背后的真实考量

先交代一下背景。我当时的处境是:C语言语法刚学完,指针和链表这部分上课听得云里雾里,结构体套结构体、指向指针的指针,每次看都头大。想通过刷题巩固,但栈和队列的题用C写起来太痛苦,反而是链表——翻来覆去就是节点和指针,特别适合用来把C语言的指针功底打扎实。

很多人喜欢用Python、Java刷题,因为它们有现成的链表实现和GC(垃圾回收),你根本不用关心内存释放。但这也带来了一个隐蔽的问题:你会不知不觉忽略链表最核心的东西——指针的指向变化。用C语言刷链表题,等于把“思考”和“落实”之间所有的缓冲都去掉了。你脑子里必须非常清楚当前指针指向哪个节点、操作完之后哪个节点的next被修改了。这个能力一旦练出来,看很多C工程的源码都会觉得轻松很多。

HOT 100里的链表题大概是这么个量级:纯链表题大约有二十来道,分布在反转、合并、相交、环形、删除、排序、复制等几个大类。数量不算多,但覆盖了链表几乎全部的核心操作。用C语言把这些题刷完,链表基本就算真正掌握了。

1.2 一套趁手的C语言链表基础设施模板

用C刷题有个现实问题:LeetCode里人家已经把结构体给你定义好了,比如struct ListNode,但本地调试时你得自己写。为了避免每次开新题都重复造轮子,我在“C's Log”仓库里放了一个linked_list_base.h,里面固定有这几样东西:

struct ListNode { int val; struct ListNode *next; }; // 根据数组创建链表(带哨兵节点版本) struct ListNode* createList(int* arr, int size) { struct ListNode dummy = {0, NULL}; struct ListNode* tail = &dummy; for (int i = 0; i < size; i++) { tail->next = (struct ListNode*)malloc(sizeof(struct ListNode)); tail->next->val = arr[i]; tail->next->next = NULL; tail = tail->next; } return dummy.next; } // 打印链表 void printList(struct ListNode* head) { int count = 0; while (head && count < 20) { // 防止环形链表导致死循环 printf("%d -> ", head->val); head = head->next; count++; } printf("NULL\n"); } // 释放链表 void freeList(struct ListNode* head) { while (head) { struct ListNode* tmp = head; head = head->next; free(tmp); } }

这套模板我用了整个刷题周期。注意几个设计细节:createList里用了一个栈上哨兵节点dummy,这样就不用为头节点的特殊情况单独写分支;printList加了循环次数限制,防止把环形链表打印到天荒地老;freeList是先存tmp再移head再释放,顺序不能反。这些细节看起来不起眼,实际刷题调试时每一个都能救命。

1.3 环境准备:VS Code与编译调试

本地调试我用的VS Code配C/C++插件,配合Code Runner一键编译运行。这里有个坑要单独说一下:如果用Code Runner默认配置跑C程序,它会用gcc file.c -o file这种方式编译,如果你的代码里有两个.c文件(比如主文件和链表基础模板),需要手动配置args参数,否则会报“undefined reference”。

我的建议是直接用CMake或者一个简单的Makefile。刷题本地验证的场景不需要复杂构建系统,一个build.sh脚本足够:先编译基础工具文件,再编译当前题目文件,链接生成可执行文件。VS Code里配置好调试器之后,断点打在指针操作那一行,配合“监视”窗口看headprevtmp几个变量的地址和值的变化,链路很快就清晰了。这一步非常值得做——很多人刷链表题只看代码想逻辑,这很容易漏掉指针指向的细节问题。

2. 链表题的核心套路:指针操作、哨兵节点与快慢指针

2.1 链表题的本质:遍历、插入、删除三件套

把HOT 100里所有链表题做完,你会发现一个事实:无论题目包装成什么样,链表题的核心操作永远只有三个——遍历、插入、删除。反转链表本质是遍历时不断在头部插入;合并有序链表本质是归并遍历加尾插;删除倒数第N个节点本质是找到节点再删除;两两交换节点本质是三步交换指针。

想明白了这一点,就抓住了链表题的“题眼”。做题时我习惯先问自己三个问题:这道题要遍历吗?要改指针吗?是改多少个指针?以删除倒数第N个节点为例,它既需要找到目标节点(遍历),又需要把前一个节点的next指向后一个节点(改指针)。确定这两个动作之后,再设计链表访问的顺序,代码就不会乱。

另一个常常被忽略的点是指针的“数量”。链表操作中,改几个节点往往就需要几个指针变量来暂存。比如删除一个中间节点,至少需要两个指针:一个指向当前节点,一个指向当前节点的前驱。而“两两交换”这种题,需要三个甚至四个指针才能清晰完成。指针变量要敢声明,别怕多,怕的是想不清楚每个指针什么时候扮演什么角色。

我自己的习惯是:动手写代码之前,先在草稿纸上把链表画出来,用箭头标出每一步指针的变化。这个习惯帮我避免了大量无意义的debug时间。当你发现一次提交报错,与其盯着代码猜,不如在纸上把链路走一遍,往往几秒钟就能看出问题所在。

2.2 哨兵节点:让头疼的边界条件直接消失

边界条件永远是链表题的第一大失分点:要删除的正好是头节点怎么办?链表为空怎么办?链表只有一个节点怎么办?这些判断写起来又臭又长,还容易漏。我的解决办法是引入哨兵节点(dummy node)。

struct ListNode* removeNthFromEnd(struct ListNode* head, int n) { struct ListNode dummy = {0, head}; struct ListNode* slow = &dummy; struct ListNode* fast = &dummy; // fast先走n+1步 for (int i = 0; i <= n; i++) { fast = fast->next; } while (fast) { slow = slow->next; fast = fast->next; } struct ListNode* target = slow->next; slow->next = slow->next->next; free(target); return dummy.next; }

上面是删除倒数第N个节点的标准解法,核心就是用哨兵节点统一处理“删除头节点”这种特殊情况。如果没有dummy,当链表只有一个节点且要删除倒数第1个节点时,slow->next会变成野指针,代码得多写好几行判断。加了dummy之后,头节点也变成了一个普通节点,所有删除逻辑统一走同一条分支。这个模式在反转链表(有时用)、删除排序链表中的重复元素、两两交换节点等题里都能复用。

2.3 快慢指针:链表题的“双人舞”

快慢指针是链表题里最优雅的一个套路。环形链表检测(141)、返回环形链表入口(142)、找链表中点、找倒数第N个节点,全部可以用快慢指针解决。它的思想很简单:两个指针从同一起点出发,一个每次走两步,一个每次走一步,如果链表有环,它们一定会在某个地方相遇。

为什么快指针每次走两步而不是三步?因为两步保证慢指针进环后的第一圈内就能被追上,不会出现刚好跳过相遇点的情况。这个细节如果自己推导一遍会记得很牢:设环外长度是L,环的长度是C,慢指针进环时快指针已经在环里走了若干距离,快指针相对慢指针每次多走一步,最多走C-1步就能追上,根本不需要走好几圈。

环形链表II那道题还有一个数学结论:相遇点到环入口的距离,等于链表头到环入口的距离。很多题解直接甩结论让大家背,我建议自己多画几次图推导,理解了之后做题就不容易忘。

3. HOT 100经典链表题逐题拆解

3.1 反转链表(206):迭代与递归的AB面

反转链表是HOT 100里被点名的“经典中的经典”,也是我C's Log里记录最详细的一道题。迭代写法核心是三个指针:prevcurrnext,每次循环里先把curr->next存到next,然后把curr->next指向prev,再整体右移。这个“先保存再断链”的顺序是灵魂,很多人写错是因为直接让curr->next = prev,导致后面的节点找不到了。

递归写法更短,但理解成本更高:

struct ListNode* reverseList(struct ListNode* head) { if (!head || !head->next) return head; struct ListNode* newHead = reverseList(head->next); head->next->next = head; head->next = NULL; return newHead; }

递归的代码只有四行,但你必须想清楚:递归返回的是反转后的新头;head->next->next = head是把当前节点拼到已经反转好的链表尾部;head->next = NULL是让原来的头变成新的尾巴。两种写法的取舍可以参考下面这个表格:

写法时间复杂度空间复杂度适用场景
迭代O(n)O(1)追求效率、代码可控时优先
递归O(n)O(n)(递归栈)训练递归思维、代码简洁优先

我建议两种写法都熟练掌握:迭代用于追求效率和可控性,递归用于训练递归思维。HOT 100里很多题都会衍生出反转链表的变体,比如反转链表II、K个一组翻转链表,掌握基础版是前提。

3.2 合并两个有序链表(21)与两数相加(2):递归的妙用

合并两个有序链表按迭代写法是典型的归并:两个指针分别指向两个链表头,谁小取谁,然后移动指针。但我想重点说递归写法,因为它是理解链表递归的绝佳例子:

struct ListNode* mergeTwoLists(struct ListNode* l1, struct ListNode* l2) { if (!l1) return l2; if (!l2) return l1; if (l1->val < l2->val) { l1->next = mergeTwoLists(l1->next, l2); return l1; } else { l2->next = mergeTwoLists(l1, l2->next); return l2; } }

递归的终止条件是某一方为空,直接返回另一方。这背后的逻辑是:一旦有一方链表已经为空,剩余的节点全部来自另一方,不需要再比较了。每次递归选取较小的节点作为当前结果的头,然后递归合并剩下的部分。这个写法写出来非常简洁,跑测试也没有栈溢出风险(链表长度有限),值得反复品味。合并K个升序链表(23)虽然也是HOT 100里的重量级选手,但它的核心还是复用mergeTwoLists,用分治或者优先队列把多条链表两两合并,思路是相通的。

两数相加也是链表题里的高频题,它的核心是模拟“竖式加法”。两个链表从左到右刚好对应数字的低位到高位,我们只需要维护一个carry进位变量,同步遍历两个链表,取当前节点的值加起来再加进位,val = sum % 10carry = sum / 10,直到两个链表都为空且进位为0。这里有个容易漏掉的点:当两个链表都遍历完了,但进位还等于1时,需要额外malloc一个新节点存放最高位的1。这道题很好地训练了“同时遍历两个链表”和“处理最后残留状态”的能力。

3.3 环形链表(141/142)与相交链表(160):双指针的进阶玩法

141题用快慢指针基本是标准答案。慢指针每次走一步,快指针每次走两步,如果相遇就说明有环。这里有个边界情况要小心:快指针在走第二步之前必须先判空,否则对空指针解引用直接内存错误。

142题在141基础上多问了一个问题:环的入口在哪?解法是:快慢指针第一次相遇后,把一个指针移回链表头,然后两个指针都以每次一步的速度前进,再次相遇的位置就是环的入口。这个结论看起来很神奇,其实推导一下就是数学题。我自己在日志里画了不下十张图才彻底理解,建议你也画一画,别死记结论。

相交链表(160)更是把“双指针”发挥到了极致:用两个指针PA、PB分别从两个链表的头出发,PA走完A链表后跳到B链表头继续走,PB走完B链表后跳到A链表头继续走,如果两个链表相交,它们一定会在第一个相交节点相遇。这个解法的精妙之处在于:通过“互相换路”消除了两个链表长度差的影响。写完代码后我为这个解法拍了半天大腿,忍不住在日志里写了一句“这才是优雅”。

3.4 删除链表的倒数第N个节点(19):一次遍历就够了

这道题前面展示过代码,这里再说一下它的关键设计:快指针先走n+1步,然后快慢指针同步走,当快指针走到链表尾时,慢指针正好停在倒数第N+1个节点,也就是待删除节点的前驱。先走n+1步的原因是:我们希望慢指针指到“待删节点的前一个节点”,这样删除操作直接slow->next = slow->next->next就可以。如果只先走n步,慢指针会停在待删节点上,删除时还得再维护一个前驱指针。

这道题用C语言实现时,一个容易出错的地方是:for (int i = 0; i <= n; i++)循环里没有判空,如果n等于链表长度,快指针正好移动到NULL,此时第二个while循环一次都不会执行,删除的就是头节点,而dummy.next保证了头节点被删后仍能正确返回新的头。没有dummy的话,这里就是一个极易触发崩溃的场景。我从这道题开始真正建立了“凡涉及头节点可能变化的操作,一律先加dummy”的习惯。

4. 刷题日志里踩过的C语言链表坑

4.1 free之后不置空:悬空指针的教训

C语言刷链表题,内存管理绕不开。我第一次用C提交环形链表题时,本地运行一切正常,但提交到LeetCode却报错。排查了半天,发现是本地调试时我在函数末尾释放了链表的全部节点,但释放完之后没有把链表头置NULL。虽然LeetCode会自己管理测试用例的内存,但我的本地测试代码里,释放完节点后如果再次访问head->next,就是一个典型的悬空指针——指针指向的地址已经被释放,再访问就是未定义行为。

这个坑的教训是:每次free(ptr)之后,马上补一句ptr = NULL。从编译器到静态分析工具,几乎所有的C语言规范都会强调这一点,但实际写起来太容易忽略了。刷完链表题后,我始终保持着对悬空指针和重复释放的警惕,这些从竞争中练出来的习惯,后来成了我写C工程代码时的基本功。

4.2 头节点更新:为什么必须用二级指针

C语言刷链表题,还有一个必须当面锣对面鼓说清楚的点:什么时候要用二级指针struct ListNode**

举例:你要写一个“在链表头部插入一个节点”的函数。如果函数签名是void insertAtHead(struct ListNode* head, int val),那么函数里给head重新赋值,不会影响外部调用者的head,因为C语言是值传递,函数内部改的只是形参的副本。头节点变化没法传出去,这个函数就是错的。正确的做法是传指向头节点指针的指针:void insertAtHead(struct ListNode** head, int val),函数内部用*head = newNode来更新外部指针。

LeetCode的题目为什么不用你传二级指针?因为每道题的函数签名都设计好了,比如返回struct ListNode*,意思是“新链表的头节点由返回值带回”。但你自己设计辅助函数时,一定要搞清楚“这个函数是否会修改头节点”。我早期写过一个反转链表的本地版本,把辅助函数签名写错了,跑了半天发现链表“原地没动”,最后排查出是形参副本问题。这个经历让我彻底记住了指针传递的本质。下面这张表是我后来总结的,从这里也一眼能看出最典型的几种内存问题:

问题类型典型场景规避方法
悬空指针free之后又访问已释放内存free后立即置NULL
形参副本辅助函数里要修改头节点传二级指针ListNode**
内存泄漏本地循环跑测试用例用Valgrind检查lost情况

4.3 LeetCode环境下的内存泄漏:自己造的问题自己清理

很多刚用C刷LeetCode的人会有一个疑问:题解里经常看到malloc新节点,到底要不要free?答案是:在LeetCode的评测环境里,你的函数返回后,整个进程就结束了,操作系统会回收所有内存,所以不free也能通过判题。但本地一遍又一遍地跑测试时,内存泄漏会积累得肉眼可见——跑几十个用例后程序内存蹭蹭涨。

我当时的习惯是:写题解代码时专注算法逻辑,不加free(因为返回值里包含新链表,free了反而没法返回);但本地测试脚本里,每次跑完一个用例,会把测试链表和新生成的链表全部free掉。用Valgrind检查一遍没有内存泄漏,才算这道题真正写完。Valgrind的输出里,definitely lostindirectly lostpossibly lost几类错误含义不同,第一次看到时建议认真查一下,比盲目改代码高效得多。

4.4 边界条件漏判:空链表与单节点链表

链表题的边界条件真的是一门玄学。我第一次写“两两交换链表中的节点”时,自信满满地写完,空链表、单节点、双节点、三节点、四节点的本地用例全部通过,结果一提交,照样挂在一个很隐蔽的边界场景上。排查到最后发现,我的循环条件里只判断了headhead->next,但交换操作里有一处中间状态会访问cur->next->next,当cur->next为NULL时就直接触发了段错误。这类错误靠肉眼很难看出来,必须对每个可能为NULL的指针做一次“空指针审计”。

我的经验是:写完后先跑最小规模的边界用例——空链表、单个节点、两个节点。这三个用例能在5秒内暴露80%的指针错误。然后跑正常规模用例验证逻辑,再跑大规模用例确认性能。这个顺序不要反,否则排查起来非常痛苦。

5. 从刷题到沉淀:模板库与链表题的实战价值

5.1 我整理的链表刷题自检清单

C's Log刷到最后,我沉淀了一套“链表题自检清单”,每次写完代码按清单逐项检查,通过率明显提升:

  1. 检查所有能解引用的指针,操作前是否保证它非NULL
  2. 涉及头节点可能变化的操作,是否优先用哨兵节点
  3. 修改多个节点的next时,是否用临时变量保存了会被覆盖的指针
  4. 循环终止条件是否正确,是否会死循环(环形链表场景尤其注意)
  5. 链表的最后一个节点next是否置为NULL
  6. 本地测试是否覆盖了空链表、单节点、两个节点的边界情况
  7. 是否需要释放不再使用的节点,是否在free后置空

这个清单我现在还在用,不只是刷题——日常写C语言相关代码时,凡操作链表都会过一遍。它等于把C语言链表编程中的经典陷阱沉淀成了肌肉记忆,这也算是我刷题过程中最值回票价的一部分。

5.2 链表在实际工程中的用武之地

有人会问:刷完链表题,除了面试还能干啥?其实链表在真实工程里应用非常广。操作系统里的进程控制块队列、任务调度器、内存管理中的空闲块链表,底层几乎都是用链表实现的。Linux内核代码里的list_head双向循环链表,你在HOT 100的双链表题里学到的prev、next指针操作,内核里都有对应。嵌入式领域的环形缓冲、最近最少使用(LRU)缓存淘汰策略(LeetCode 146题),本质也是哈希表+双向链表的结构——146题正是HOT 100里最经典的链表综合应用题。

刷完链表题后再去看这些工程场景,你会发现书上的知识和真实世界之间从不缺联系,缺的是那种“原来如此”的豁然开朗。这也是我坚持用C语言刷题的一个重要原因:它逼着我在抽象算法与真实内存之间来回穿越,建立的联系比用高级语言刷题要牢固得多。

5.3 刷HOT 100的节奏建议

最后分享一点刷题节奏的心得。HOT 100链表题看起来密集,但不用怕。我按知识点分组刷:先把反转链表三件套(206、92、25)放一起,再把双指针题(141、142、160、19、876)放一组,再把构建类题目(21、2、23合并K个升序链表)放一组。同一组的题解法套路相似,刷起来有乘数效应,第一道题花一小时,第二道半小时,第三道可能二十分钟就搞定。

每次刷完一组,我会把题号、考点、自己能想到的最优解思路、踩坑记录写进C's Log。一周后再回头重新做一遍做错或卡壳的题,检验是否真正掌握。这个“分组+复盘”的节奏,我觉得比每天随机刷两三道更高效,也更容易坚持。

用C语言刷链表题这条路,走到最后你会发现:最大的收获不是会了那二十几道题的解法,而是终于能坦然面对指针和内存——很多C程序员工作好几年都没解决的心理阴影,在这几十个小时的硬磕里被彻底治愈了。如果你也在刷题,或者在学C语言的链表部分,希望这篇日志能帮你少踩几个坑,把链表这块硬骨头啃得更轻松一些。

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

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

立即咨询