不知道你有没有这种感觉:链表题做多了以后,会觉得这玩意儿其实不是考“数据结构”,是考“你手里有几根指针,心里有没有一张图”。我在刷力扣hot100的链表专题时最大的体会是,链表题的套路密度非常高,翻来覆去就那么几件事:反转、找中点、删节点、合并、判断环。真正难的其实不是某个算法,而是写代码的时候能不能保证“没丢节点、没指错边、边界没崩”。这篇刷题笔记我先聚焦最基础也最高频的一批:反转类、双指针类、哑节点类,以及一道把多个套路串起来的综合题。适合刚开始按专题刷hot100的读者,也适合刷过一遍但总觉得链表题不稳的人,专门用来把底层方法理清楚。
1. 链表题在hot100里的存在感,本质上是因为它考的是“操作感”
数组题考的是思维模型,动态规划考的是状态定义,链表题考的是操作。LeetCode上链表类题目的数量对比数组、哈希表、DP来说并不算特别多,但它出现在hot100里的密度相当高,而且很多题目是“看起来不难,一写就错”的类型。
1.1 链表结构给命题人提供了两个天然考点
第一个天然考点是指针/引用的操作准确度。单链表只能从头往后走,想回头就得靠额外指针或递归栈,想让一个节点从链表里摘出去,就得改前一个节点的next,而前一个节点怎么找到,本身就值得一考。
第二个天然考点是边界条件的敏感度。操作空链表、单节点链表、两个节点链表,循环条件写错一个符号就会产生空指针解引用,或者漏掉最后一个节点。这种边界错误恰恰是刷题时最能暴露代码功底的地方。
还有一个容易被忽略的点:链表可以在O(1)时间内完成插入和删除(前提是已经定位到前驱节点),这跟数组是相反的。很多实际场景里的“缓存淘汰”“任务队列”“撤销栈”都会用到这种结构特性,所以面试官天然喜欢拿链表来问“你会不会改结构”。
1.2 hot100里链表题的分布,其实非常集中
我把自己刷过的hot100链表相关题目整理了一下,发现可以按模式归类,而不是按难度归类:
| 常见链表题 | 核心考点 | 归类模式 |
|---|---|---|
| 206. 反转链表 | 迭代/递归反转 | 反转类 |
| 92. 反转链表 II | 区间内反转 + 边界接续 | 反转类 |
| 24. 两两交换链表中的节点 | 成对反转 + 递归/迭代 | 反转类 |
| 25. K 个一组翻转链表 | 分组反转 | 反转类(进阶) |
| 141. 环形链表 | 快慢指针判环 | 双指针类 |
| 142. 环形链表 II | 快慢指针找入环点 | 双指针类 |
| 19. 删除链表的倒数第 N 个结点 | 间隔指针/哑节点 | 双指针类 |
| 160. 相交链表 | 双指针走对方链 | 双指针类 |
| 21. 合并两个有序链表 | 哑节点 + 比较移动 | 哑节点类 |
| 23. 合并 K 个升序链表 | 多路归并 / 优先队列 | 哑节点类(进阶) |
| 2. 两数相加 | 模拟竖式加法 + 进位 | 哑节点类 |
| 234. 回文链表 | 快慢找中 + 反转后半 | 综合类 |
| 148. 排序链表 | 归并排序 + 找中点 | 综合类 |
| 138. 随机链表的复制 | 哈希表 / 原地复制 | 综合类 |
| 146. LRU 缓存 | 哈希表 + 双向链表 | 综合类(进阶) |
这张表基本就是我刷链表专题的路线图。本篇文章先吃透前三类,第四类综合题里选“回文链表”作为代表作拆解一遍,因为它的每一步都是前面基础套路的组合。排序链表、复制随机链表、LRU这类结构更重的题目,放到后面的笔记里展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 反转类题目的肌肉记忆:先写熟“三行核心代码”再谈递归
反转链表是hot100里最基础的一道题,同时又是无数复杂题的子过程。比如后面要说的回文链表、反转链表II、K个一组翻转,全都依赖单链表反转这个原子操作。这个操作必须写到不需要过脑子的程度。
2.1 迭代反转:为什么必须准备三个指针
单链表无法原地回头,所以反转过程必须用指针记录三样东西:前一个节点、当前节点、下一个节点。这就是pre/cur/nxt三件套。
cpp复制ListNode* reverseList(ListNode* head) {
ListNode* pre = nullptr;
ListNode* cur = head;
while (cur) {
ListNode* nxt = cur->next; // 先保住后路
cur->next = pre; // 把箭头掉头
pre = cur; // pre 前移
cur = nxt; // cur 前移
}
return pre;
}
这段代码里面有个顺序问题,我第一次刷的时候犯过混:cur->next = pre 这行一旦执行,cur后面的链表就暂时找不到了,所以必须提前把nxt存下来。循环最后返回的是pre而不是cur,因为循环结束时cur已经走到nullptr,pre才是原来的尾节点、反转后的新头。
从小白到熟练,我建议在纸上把1->2->3->null的每轮指针变化都画一遍。画三轮你就能理解为什么必须有三根指针,而不是去背代码。画完图再回来看代码,会觉得每行都有明确的存在理由。
2.2 递归反转:一句话解释head->next->next = head
递归版反转代码很短,但很多人看不懂:
cpp复制ListNode* reverseList(ListNode* head) {
if (!head || !head->next) return head;
ListNode* newHead = reverseList(head->next);
head->next->next = head;
head->next = nullptr;
return newHead;
}
理解它的关键是把目光放在“已经反转好的后半段”上。假设现在head指向节点1,head->next指向节点2,我们调用reverseList(head->next)后,节点2往后那一大段已经被反转完毕,并且返回了一个newHead,这个newHead就是原链表的尾节点。
此时节点2的next已经指向了它前面的节点了吗?还没有。递归只处理了“head->next为头的那段链表”,而节点1还单独挂在外面。所以我们需要让head->next(也就是节点2)的next指向head,这就是head->next->next = head的含义。最后把head->next置空,防止成环。
递归的缺点是链表过长时会占用系统栈,工程中一般用迭代。但递归思路对理解“把一个大问题拆成已经解决的小问题”非常有帮助,所以两种都值得会。
2.3 区间反转(反转链表II):比全反转多两个锚点
hot100里有一道92. 反转链表 II,要求反转从left到right的区间。它的做法是:先让指针走到left位置的前一个节点,记为pre,然后从pre->next开始做right-left次反转操作,操作完后把反转区间的头尾和前后节点接上。
这个题如果只会整链反转,写起来会卡在“区间头尾怎么接”。我的经验是不要临时想,而是固定使用两指针后插法:
cpp复制ListNode* reverseBetween(ListNode* head, int left, int right) {
ListNode* dummy = new ListNode(0);
dummy->next = head;
ListNode* pre = dummy;
for (int i = 0; i < left - 1; i++) {
pre = pre->next;
}
ListNode* cur = pre->next;
for (int i = 0; i < right - left; i++) {
ListNode* nxt = cur->next;
cur->next = nxt->next;
nxt->next = pre->next;
pre->next = nxt;
}
return dummy->next;
}
这里的核心思想是:每次都把cur后面的那个节点摘下来,移动到pre和pre->next之间。做right-left次,区间的顺序就彻底反过来了。这个写法比“先把区间拎出来反转再接回去”少很多边界判断,推荐直接记住。
3. 双指针的三组距离关系:快慢、间隔、前后
链表题里双指针不是一种套路,而是三种距离关系。很多人在一道题里看不出来该用哪种,是因为没有把问题归类到“位置关系”上。
3.1 快慢指针:判断环和寻找入环口的数学逻辑
141. 环形链表只需要判断有没有环。最经典的做法是快指针每次走两步,慢指针每次走一步,如果链表有环,那么两者一定会在环里相遇。
为什么快指针每次走两步,而不是走三步、四步?因为步长差为1时,快指针相对慢指针每一步追近1个节点,只要环存在,一定可以追上且不会跳过。如果步长差大于1,可能出现追不上或者跳过去的情况,分析起来就麻烦多了。所以工程实践中大家约定俗成用“快二慢一”。
142. 环形链表 II要求找到入环点。这个题的证明我看过很多次,自己推导一遍才真正记住:
设链表头到入环点的距离为D,入环点到快慢指针相遇点的距离为S,环长为C。快指针速度是慢指针的2倍,所以相遇时:
code复制slow 走了 D + S
fast 走了 D + S + n*C (n 是 fast 在环里绕的圈数)
fast 的路程是 slow 的 2 倍:
D + S + n*C = 2 * (D + S)
=> D = n*C - S
这个式子的意思是:从相遇点继续走n*C - S步会到达入环点,而从链表头走D步也会到达入环点。既然两者相等,那就让一个指针从链表头出发、另一个指针从相遇点出发,每次都走一步,它们第一次相遇的位置就是入环点。
代码实现:
cpp复制ListNode *detectCycle(ListNode *head) {
ListNode *slow = head, *fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) {
ListNode *p = head;
while (p != slow) {
p = p->next;
slow = slow->next;
}
return p;
}
}
return nullptr;
}
这里有个常见的循环条件问题:while (fast && fast->next)。必须先判断fast不为空,再判断fast->next不为空,因为下一步要访问fast->next->next。如果直接把条件写成while (fast->next),无环链表走到尾节点时会因为fast->next为空而退出,但如果链表只有一个节点且head为空,直接访问head->next就是空指针解引用。所以空链表和单节点链表这两个边界,在写循环条件的时候就要先在心里过一遍。
3.2 间隔指针:删除倒数第N个节点的前置定位
19. 删除链表的倒数第 N 个结点是一道很适合考察“能不能想到用两个固定间距的指针”的题。
常规思路是先遍历一遍得到链表长度,再走第二遍找到要删的位置。但用双指针可以只遍历一遍:让一个指针先走N步,然后第二个指针从头出发,两个指针保持N步的间距同步前进。当第一个指针走到nullptr时,第二个指针正好停在倒数第N个节点上。
不过,定位到倒数第N个节点本身还不够,删除它需要找到它的前驱。所以更稳的做法是让两个指针都从dummy节点出发,先让first走N步,再让两个指针同步走。这样first走到nullptr时,second指向的是倒数第N个节点的前驱,直接修改second->next即可。
cpp复制ListNode* removeNthFromEnd(ListNode* head, int n) {
ListNode* dummy = new ListNode(0);
dummy->next = head;
ListNode* first = dummy;
ListNode* second = dummy;
while (n-- > 0) first = first->next;
while (first->next) {
first = first->next;
second = second->next;
}
ListNode* del = second->next;
second->next = second->next->next;
delete del;
return dummy->next;
}
这里用dummy的意义在最后一句也能体现:如果要删除的节点正好是原链表头节点,返回dummy->next依然能拿到新链表的头,不需要额外判断。
3.3 前后相遇指针:相交链表为什么走“你走完了我走你”
160. 相交链表乍看是个几何问题,但其实是个“路程相等”问题。两个链表在某个节点相交后,后续节点是完全共享的。假设链表A在相交前的长度为a,链表B在相交前的长度为b,公共部分长度为c。
如果用一个指针遍历A结束后跳转到B的头,另一个指针遍历B结束后跳转到A的头,那么两个指针走的总路程始终是a + b + c。当它们走到这个路程终点时,都会同时到达那个相交节点(如果存在的话)。如果不存在相交,那么它们会同时走到nullptr。
这相当于把两条链表的长度差抹平了。我第一次看到这个解法时觉得很巧妙,后来想到一个生活化类比:两个人跑两条不同的跑道,跑道的前半段长度不一样,但后半段共用同一条路。只要让每个人都把两条跑道完整跑一遍,那么不管前面差多少,最终会走到同一个位置。
cpp复制ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
ListNode *p1 = headA, *p2 = headB;
while (p1 != p2) {
p1 = p1 ? p1->next : headB;
p2 = p2 ? p2->next : headA;
}
return p1;
}
这个写法里有个容易引起争论的点:当p1走完A变成nullptr时,到底该让它跳到headB,还是应该在nullptr处继续等待p2也走到nullptr?从上面的路程分析可以知道,跳转是必须的,不然永远抹不平长度差。而如果两条链表根本不相交,最终两个指针都会变成nullptr,循环退出。
4. 哑节点不是万能的,但这类“造新链表”的题没它很难写干净
链表的头节点很特殊。一旦头节点可能被删除、被替换,或者整个链表是“从零开始拼接”出来的,就必须考虑要不要引入哑节点(dummy node)。很多链表题写着写着开始堆if判断,本质上就是因为没有用哑节点把“空头”这种特殊情况统一掉。
4.1 哑节点的本质:把“头节点可能变化”变成普通情况
哑节点是一个额外创建的节点,它的next指向真正的链表头。在这个节点上,我们不需要它的值有意义,只为了让“当前节点的前一个节点”在循环里永远存在。
以21. 合并两个有序链表为例。合并的过程中,小节点不断被接在结果链表的尾部,结果链表的头一开始是未知的。如果不建哑节点,那么接第一个节点时要单独处理“当前结果链表为空”的情况,后续节点又要走另一套逻辑。用哑节点之后,所有节点都是统一地接在tail->next后面:
cpp复制ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) {
ListNode* dummy = new ListNode(-1);
ListNode* tail = dummy;
while (list1 && list2) {
if (list1->val < list2->val) {
tail->next = list1;
list1 = list1->next;
} else {
tail->next = list2;
list2 = list2->next;
}
tail = tail->next;
}
tail->next = list1 ? list1 : list2;
return dummy->next;
}
而23. 合并 K 个升序链表可以看作是这道题的扩展。最简单的实现是用一个优先队列,每次从K个链表的当前头里弹出最小值:
cpp复制struct Cmp {
bool operator()(ListNode* a, ListNode* b) {
return a->val > b->val;
}
};
ListNode* mergeKLists(vector<ListNode*>& lists) {
priority_queue<ListNode*, vector<ListNode*>, Cmp> pq;
for (ListNode* node : lists) {
if (node) pq.push(node);
}
ListNode* dummy = new ListNode(0);
ListNode* tail = dummy;
while (!pq.empty()) {
ListNode* node = pq.top();
pq.pop();
tail->next = node;
tail = tail->next;
if (node->next) pq.push(node->next);
}
return dummy->next;
}
优先队列方案的时间复杂度是O(N log K),其中N是所有节点的总数,K是链表数量。也可以用“两两合并”的方式,复杂度是O(N log K),但实现起来更绕。面试时优先队列方案思路清晰,不容易写错。
4.2 两数相加:链表题里最典型的“模拟加法器”
2. 两数相加这题很有意思。它给的链表是逆序存储的,最低位在链表头。这样的设计让“进位”变得非常自然:我们从两个链表的头部开始,正好对应个位相加,然后处理十位、百位。
这类题最容易犯的错是想把链表转成整数再相加。一旦测试数据里有超过long long范围的大数,这种做法直接爆掉。正确的思路是模拟竖式加法,每一位都处理三个输入:第一个链表当前节点的值、第二个链表当前节点的值、上一位产生的进位。
cpp复制ListNode* addTwoNumbers(ListNode* l1, ListNode* l2) {
ListNode* dummy = new ListNode(0);
ListNode* cur = dummy;
int carry = 0;
while (l1 || l2 || carry) {
int sum = carry;
if (l1) {
sum += l1->val;
l1 = l1->next;
}
if (l2) {
sum += l2->val;
l2 = l2->next;
}
carry = sum / 10;
cur->next = new ListNode(sum % 10);
cur = cur->next;
}
return dummy->next;
}
循环条件写成while (l1 || l2 || carry)是这个题的精髓。很多题解会先处理完两个链表,再单独检查最后的进位carry。但如果把carry也放进循环条件里,那么即便两个链表都遍历完了,只要还有进位没处理,循环就会继续,从而少写一段重复代码。
比如5 + 5 = 10,两个链表都是单节点,正常遍历完后carry = 1,如果不把carry纳入循环条件,最后会丢掉最高位的那个1。这个边界要特别注意。
5. 回文链表:一道题把快慢指针、反转、边界判断全串起来
234. 回文链表在我心里是hot100链表专题的“分水岭”:会做这道题,说明基础的双指针和反转已经掌握了。回文判断本身不难,难的是在O(n)时间、O(1)空间内完成,以及处理链表长度的奇偶性。
5.1 五种思路对比:为什么最终选择“找中点+反转后半段”
回文链表常见的解法大体有五类:
| 思路 | 时间 | 空间 | 特点 |
|---|---|---|---|
| 转成数组再左右指针 | O(n) | O(n) | 最简单,但空间不符合进阶要求 |
| 用栈存前半部分 | O(n) | O(n) | 代码直接,但额外空间明显 |
| 递归后序遍历 | O(n) | O(n) 栈空间 | 思路巧妙,面试不好解释 |
| 快慢指针找中点后反转后半段 | O(n) | O(1) | 推荐做法,综合度高 |
| 哈希值比较 | O(n) | O(1)(有碰撞风险) | 不推荐,碰撞导致不稳定 |
最终选择“快慢指针找中点+反转后半段”,因为它是纯链表操作,不依赖额外容器,而且每个步骤都能拆成前面已经写熟的子操作。
5.2 完整步骤和代码:这里有一版不需要单独判断奇偶性的写法
找中点有一个经典小细节:快指针每次走两步,慢指针每次走一步。当快指针到达末尾时,慢指针在中点。但中点位置的精确含义取决于链表长度是奇数还是偶数。
我的做法是不显式区分奇偶,直接把slow之后(包含slow)的链表反转,然后遍历对比。这版代码很短:
cpp复制bool isPalindrome(ListNode* head) {
ListNode* slow = head;
ListNode* fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
}
ListNode* tail = reverseList(slow);
while (tail) {
if (head->val != tail->val) {
return false;
}
head = head->next;
tail = tail->next;
}
return true;
}
为什么奇偶不需要单独判断?我用两个例子说明:
- 对于奇数长度的链表
1->2->3->2->1,快慢指针走完后slow停在正中间节点3上。反转从slow开始的链表,得到1->2->3。然后head从头开始和它逐位比较,比较到节点3时就和自己比较,长度为3,依次比对1、2、3,完全匹配。中间节点3正好被包含在反转链表的末尾,相当于自己和自己比了一次,不影响结果。 - 对于偶数长度的链表
1->2->2->1,快慢指针走完后slow停在第二个2上,也就是右半边第一个节点。反转从它开始的链表得到1->2,head从头开始比较两个节点,1和1比,2和2比,结束。
这种写法有一个副作用:它修改了原链表的结构(后半段被反转)。如果刷题环境不检查原链表,这没问题;但面试时最好跟面试官提一句“如果需要保持链表原状,可以在比较结束后再反转一次恢复”,显示你真的考虑过这个细节。
5.3 恢复链表结构的一种处理方式
某些情况下,比如平台会复用测试用例,或者面试官明确要求不能破坏输入链表,就需要在比较完后恢复。恢复的思路其实也很直接:因为我们已经反转了后半段,所以再次反转那一段就能还原。关键是要保留下“后半段反转后”的头指针。
cpp复制 ListNode* secondHead = reverseList(slow);
ListNode* p1 = head;
ListNode* p2 = secondHead;
bool ans = true;
while (p2) {
if (p1->val != p2->val) {
ans = false;
break;
}
p1 = p1->next;
p2 = p2->next;
}
reverseList(secondHead); // 恢复后半段
return ans;
这里恢复完以后,原链表会变成什么状态?因为我们把后半段反转了一次,比较完又反转回来,所以原链表后半段的大致结构会恢复。不过严格来说,前半段的尾节点到slow之间的连接还是要确认过的。如果之前在反转时把slow的next改了,可能在结构上并不能完美恢复成原链表。更稳妥的做法是保留原始链表头,通过拼接的方式恢复,但实现会复杂不少。所以如果你写的是“破坏链表版”,并且平台没要求恢复,直接用就好;如果面试官问起,你就说“再调用一次reverseList就可以把后半段转回来”,这一般已经能过关了。
5.4 从回文链表反推自己的薄弱环节
这道题我最推荐的用法是用来做自测。如果你能10分钟内无bug地写出这题,说明你对以下三件事已经比较熟:快慢指针的停止条件、reverseList的边界条件、双链表遍历的指针移动位置。如果哪个环节卡住了,就回到对应小节去补,而不是反复刷新题。链表专题的复习效率,很大程度取决于你能不能把“组合型题目”拆成“基础动作”。
6. 真正让我少踩坑的几个链表调试习惯
链表题的报错往往没有普通数组题那么好定位。数组访问越界会明确告诉你index,链表指针乱了指到哪里可能自己都不知道。所以我刷链表题的时候养成了一套固定习惯,分享出来供参考。
6.1 写一个打印函数,调试时不要靠猜
刷题时为了方便看中间过程,我经常在代码里临时加一个打印链表的函数:
cpp复制void printList(ListNode* head) {
int cnt = 0;
while (head && cnt < 20) {
cout << head->val << " -> ";
head = head->next;
cnt++;
}
cout << "null" << endl;
}
这里限制打印长度是为了防止有环链表中打印函数本身死循环。打印结果可以帮你直观看到“反转后链表长什么样”“删除节点后有没有断链”“合并后顺序对不对”。这比自己盯着代码脑补强太多。
6.2 在纸上走一遍“快慢指针”的循环条件
我见过很多人写快慢指针时,循环条件一会儿漏掉fast->next判断,一会儿又把两个条件顺序写反。这里有个通用口诀:只要你的循环体里访问了fast->next->next,循环条件就必须同时保证fast和fast->next都不为空,并且建议先判断fast再判断fast->next,因为如果fast本身已经是nullptr,再访问fast->next就是未定义行为。
我习惯在草稿纸上画一个三节点的链表,然后逐轮写出slow和fast的位置。这个过程看起来笨,但比在脑子里空转效率高得多。尤其当你遇到“奇数长度和偶数长度停的位置不一样”这类问题时,画一遍比看十遍题解都管用。
6.3 建立一套自己的链表用例清单
每次写完链表题,我都会用同一套用例去验证,而不是只在题目提供的一两个例子上跑:
- 空链表:
nullptr - 单节点链表:
1 -> null - 双节点链表:
1 -> 2 -> null,反转后应该是2 -> 1 -> null - 奇数长度回文链表:
1 -> 2 -> 3 -> 2 -> 1 - 偶数长度回文链表:
1 -> 2 -> 2 -> 1 - 非回文链表:
1 -> 2 -> 3 -> null - 带进位链表相加:
9 -> 9 -> 9加1
这套用例覆盖了绝大多数“循环跑不到最后一个节点”或“多走一步导致空指针”的问题。把这些场景在本地跑通了,再提交到力扣基本都是一次过。
还有一个很容易被忽略的点:在C++里,删除链表节点后要把已删除的指针置空,否则后续操作可能踩到野指针。题目本身不检查内存泄漏,但我们在平时写代码时养成好习惯,面试时问到“如果让你实现LRU,你会怎么保证内存安全”这类问题,也不至于手足无措。
链表这块内容,刷完基础套路后最大的变化是:拿到一道题先不看题解,而是问自己这是“反转、定位、合并、还是结构变换”,然后想清楚哪根指针该停在哪,再动手写代码。这个思维过程一旦形成,hot100里链表专题的多数中等题都能在几分钟内定位到解法框架。剩下的就是靠多写几遍,把边界条件练成本能。
