1. 链表题到底在考什么
1.1 链表的本质与三种常见形态
如果不把数据结构当考试科目,而是当成现实里的东西来理解,链表其实非常朴素——它就是一串珠子,每颗珠子知道下一颗珠子在哪。数组是连续内存里排排坐,链表则是“你指我、我指他”的接力式存储。
刷题时见得最多的有三种形态。单链表最基础,每个节点只有一个 next 指针,走到尾部就没路了;双链表多一个 prev 指针,能倒着走;循环链表把尾部再接回头部,绕圈圈。LeetCode 上大部分题目默认给的是单链表,结构体长这样(以 C++ 为例):
struct ListNode { int val; ListNode *next; ListNode() : val(0), next(nullptr) {} ListNode(int x) : val(x), next(nullptr) {} ListNode(int x, ListNode *next) : val(x), next(next) {} };Python 版本更简洁,用类模拟节点的指向关系:
class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = next看到这里你会觉得,这不就是一个对象里挂着一个引用吗?对,链表题的本质就是“引用操作”。你手里拿的永远只有一个头节点引用,所有操作都是通过这个入口一路 next 下去的。理解这一点,后面所有题目的思路都会顺很多。
1.2 为什么面试官偏爱链表题
我刷了这么多题,发现一个规律:链表题看着简单,但很多人写出来是“能跑但很脏”,边界条件全是 if 硬凑。面试官爱考链表,恰恰是因为它能把一个人的代码习惯暴露得干干净净。
链表考的是三层能力:第一层是对指针/引用的掌控力,p = p->next 和 p->next = xxx 这两行代码语义完全不同,很多新手混着用;第二层是对边界条件的敏感性,空链表、单节点、双节点、删除头节点、处理尾部节点,每种情况都要心里有数;第三层是空间与时间取舍的判断力,像链表相交、找环入口这类题,不优化就是 O(n) 空间,优化双指针就是 O(1) 空间,刚好可以考察候选人愿不愿意多想一步。
另外,链表题天然的“指针 + 循环 + 递归”组合,是 C++ 面试的常客。Java、Go、Python 的开发者也会遇到,只是表现形态略有不同。所以这一专题值得沉下心来,把套路吃透。
1.3 我的刷题顺序建议
很多人拿到链表题就开始瞎刷,今天做个反转,明天做个合并,后天做个删除,题目之间没联系,刷完就忘。我个人的建议是分四个阶段推进:
- 基础操作阶段:遍历、插入、删除、反转、合并,先把“手的肌肉记忆”练出来;
- 经典套路阶段:虚拟头节点、快慢指针、链表相交、环检测,这些是高频考法;
- 综合应用阶段:排序链表、重排链表、K 个一组翻转,把前面的基础操作组合起来;
- 极限边界阶段:原地算法、递归替换迭代、O(1) 空间实现,用于提升区分度。
这四个阶段不一定完全线性,但每道题刷完要清楚它属于哪个阶段,考的是哪类能力。这样刷题才不是在感动自己。
2. 五个核心操作,吃透链表的底层逻辑
2.1 遍历与虚拟头节点:为什么一定要有 dummy
先别急着写代码,链表题里最基础也最容易被忽视的,就是遍历。
遍历单链表的模板极简:
ListNode* cur = head; while (cur != nullptr) { // 处理 cur->val cur = cur->next; }注意循环条件是 cur 而不是 cur->next。用 cur->next 会在最后一个节点上停下来,少处理一个节点,这是新手最容易踩的坑。
接下来是虚拟头节点(dummy node)。这个概念我第一次刷到的时候觉得多此一举,后来才明白它是“删头节点恐惧症”的终极解药。
考虑一个场景:删除链表中所有值为 val 的节点。如果不用虚拟头节点,头节点本身也可能被删,你得单独写一个 while 循环先处理头部;用了 dummy 之后,一切统一处理:
ListNode* dummy = new ListNode(0, head); ListNode* prev = dummy; ListNode* cur = head; while (cur != nullptr) { if (cur->val == val) { prev->next = cur->next; delete cur; // C++ 注意释放内存 } else { prev = cur; } cur = prev->next; } return dummy->next;dummy 的价值就是让“头节点”变成一个普通节点,代码不再需要为头部单独开分支。凡是要删除节点、头节点可能变化的题目,先加一个 dummy,代码立刻清爽一大截。
2.2 插入与删除:先接后断的指针顺序
链表操作里,最核心的纪律是“先接后断”。
拿在指定位置插入单链表来说,假设要在节点 a 的后面插入新节点 node,你手上拿着 a 的引用。经验不足的人常写成:
a->next = node; node->next = a->next;第二行等于 node->next = node,链表直接就断了,后面的节点全部丢失。正确的顺序应该把后面的线路先“接上”,再改前面的:
node->next = a->next; a->next = node;删除节点同理。删掉 a->next 时,得先把 a->next->next 接回到 a 上,再去释放被删的节点:
ListNode* target = a->next; a->next = target->next; delete target;这个顺序背后有个生活化的类比,像换水管:先松接头再拆旧管,水才不会喷一地。指针操作也是这样,先建立新连接,再打破旧连接,链子才一直不断。
双链表的插入稍微麻烦一点,因为绕圈的指针更多,但纪律一致:先把新节点和它的左右邻居互相挂好,再改动原来的链接。宁可多写两行,不要图省事把顺序搞反。
2.3 反转链表:迭代与递归两条路
反转链表是 LeetCode 206,也是链表题的分水岭。能独立把这道题写对的人,算是过了链表第一关。
迭代写法是维护三个引用,prev、cur、next,每次循环做一次方向翻转:
ListNode* reverseList(ListNode* head) { ListNode* prev = nullptr; ListNode* cur = head; while (cur != nullptr) { ListNode* next = cur->next; // 先存好下一个 cur->next = prev; // 反转指向 prev = cur; // prev 前进 cur = next; // cur 前进 } return prev; }几个要点:先存 next,否则 cur->next 一改,后面的节点就找不到了;最后返回的是 prev 而不是 cur,因为循环结束时 cur 已经是 nullptr,而 prev 指向原链表的尾部,也就是新链表的头部。
递归写法是另一种思考角度,很多人觉得不好想。其实递归的核心是:假设后面的链表已经反转好了,我只负责把当前节点的 next 指向自己,并让原来的下一个节点指向它的前一个:
ListNode* reverseList(ListNode* head) { if (head == nullptr || head->next == nullptr) return head; ListNode* newHead = reverseList(head->next); head->next->next = head; head->next = nullptr; return newHead; }递归看起来短,但调试起来不如迭代直观,而且压栈会占 O(n) 的递归栈空间。面试里我一般先写迭代,被追问再讲递归的思路,能体现出两种思维方式都掌握了。
2.4 快慢指针:一个模板走天下
快慢指针是链表题里的万能钥匙。找链表中点、判断是否有环、找环的入口、找倒数第 K 个节点,全都是同一个模板换参数:
ListNode* fast = head; ListNode* slow = head; while (fast != nullptr && fast->next != nullptr) { slow = slow->next; fast = fast->next->next; } // 循环结束时,slow 就是中点(奇数个节点是正中间,偶数个是偏右或偏左的那个,取决于具体写法)这个模板的“为什么能成立”其实特别朴素:fast 的速度是 slow 的两倍,同一个起点出发,fast 走到尽头时 slow 刚好走了一半。想象两个人跑步,一个人速度是另一个的两倍,同时出发,快的人到终点时慢的人刚好在半程。
判断链表是否有环只需要在循环里加一个判断,fast 和 slow 相遇就说明有环。找环入口需要再走一轮“数学推导”,这个我在后面的实战部分展开。
快慢指针有几个边界记忆点:while 条件里 fast 和 fast->next 都要判空,否则访问空指针会崩;链表只有一个节点时,循环直接不执行,slow 仍指向头节点,这个行为是对的;找倒数第 K 个节点时,可以让 fast 先走 K 步,然后两个指针同速走,fast 到尾部时 slow 就在倒数第 K 个位置。
3. 经典题目的完整推演
3.1 链表相交:从哈希到双指针的渐进思考
链表相交这道题(LeetCode 160 以及相关变种)给的是两个单链表的头节点,要求找出它们相交的起始节点,不相交则返回 null。
最朴素的想法是哈希表:遍历 A,把所有节点引用存进 HashSet,再遍历 B,第一个在集合里出现的节点就是交点。时间复杂度 O(m+n),空间 O(m)。
面试官一定会追问一句:“能不能 O(1) 空间?”答案是双指针,但理解起来稍微绕一点。
两个指针分别从 A 和 B 出发,走完自己的链表后,指向对方的头节点继续走。如果两个链表相交,它们一定会在交点相遇;如果不相交,它们会同时走到尾部 null。
为什么成立?假设 A 不相交部分的长度是 a,B 的是 b,公共部分是 c。指针 pa 走过的完整路程是 a+c+b,指针 pb 走过的完整路程是 b+c+a,二者相等,所以它们一定在交点相遇。这个推导其实是个“路程相等”问题,理解后很容易记住代码:
ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) { ListNode *pa = headA, *pb = headB; while (pa != pb) { pa = (pa == nullptr) ? headB : pa->next; pb = (pb == nullptr) ? headA : pb->next; } return pa; }这里有个细节:换链表用的是“走完再换”,不是“跳到对方头部”,代码里写成 pa == nullptr 时赋 headB,这样保证两个指针走的总路程一致。如果写成 pa->next == nullptr 就换,路程对不上,会死循环或者漏判。
3.2 环形链表:快慢指针的数学原理
环形链表有两问:第一问只判断有没有环(LeetCode 141),第二问找环的入口(LeetCode 142)。
判断有没有环,快慢指针就够了。但找环入口时,需要明白一个关键推导:当 slow 和 fast 第一次相遇时,把 fast 重新放回 head,两个指针都改成一次走一步,再次相遇的位置就是环入口。
这个结论一开始我觉得像魔法,后来看了推导才踏实。设从头节点到环入口的距离是 a,环入口到第一次相遇点的距离是 b,相遇点继续走到环入口的距离是 c(也就是环的剩余部分)。slow 走过的路程是 a+b,fast 走过的路程是 a+b + k*(b+c)+b,其中 k 是 fast 多绕的圈数。因为 fast 速度是 slow 的 2 倍,有:
2*(a+b) = a+b + k*(b+c)+b => 2a+2b = a+2b + k*(b+c) => a = k*(b+c) - c = (k-1)*(b+c) + c当 k=1 时,a=c。也就是说,从头节点到环入口的距离,等于相遇点继续走到环入口的距离。所以 fast 回到 head,两个指针一步一步走,必然在入口碰上。代码实现时:
ListNode *detectCycle(ListNode *head) { ListNode *fast = head, *slow = head; while (fast && fast->next) { slow = slow->next; fast = fast->next->next; if (slow == fast) { fast = head; while (slow != fast) { slow = slow->next; fast = fast->next; } return slow; } } return nullptr; }这个推导有个值得注意的小坑:很多人以为第一次相遇就在环入口,其实不是,相遇点通常离入口有一段距离。只有经过上述“路程换算”,才能从相遇点反推入口。
3.3 删除倒数第 N 个节点:一次遍历的精确控制
LeetCode 19 是高频题:删除链表倒数第 N 个节点,要求只遍历一次。
一看“一次遍历”,自然想到两个指针:fast 先走 N 步,然后 fast 和 slow 一起走,等 fast 走到 null 时,slow 正好在待删除节点的前一个位置。
实现时需要区分两种情况。如果 fast 先走 N 步后已经为 null,说明要删的是头节点,直接返回 head->next。否则用 slow 走,最后 slow->next = slow->next->next。但更优雅的做法是加一个 dummy 节点,让 slow 从 dummy 出发,这样“删头节点”的问题也被统一处理了:
ListNode* removeNthFromEnd(ListNode* head, int n) { ListNode* dummy = new ListNode(0, head); ListNode* fast = head; ListNode* slow = dummy; while (n-- > 0) fast = fast->next; while (fast != nullptr) { fast = fast->next; slow = slow->next; } slow->next = slow->next->next; return dummy->next; }这个版本把快指针走了 N 步、慢指针从 dummy 起步,两件事一组合,边界情况就全兜住了。我早期刷这道题喜欢在删头节点上单独写分支,后来改成 dummy 写法后,错误率明显降下来。记住:有 dummy 的地方,删头节点不再特殊。
3.4 链表排序:归并思想的链表落地
链表排序(LeetCode 148)要求 O(n log n) 时间、O(1) 额外空间。满足这个条件的是归并排序,因为链表的插入排序是 O(n²),快速排序对链表来说枢轴选择麻烦,递归深度也不好控。
自顶向下归并的思路是:用快慢指针找到中点,把链表切成两半,递归排序两半,然后合并两个有序链表。合并两个有序链表本身也是一道高频基础题(LeetCode 21),模板如下:
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) { ListNode* dummy = new ListNode(0); ListNode* cur = dummy; while (l1 && l2) { if (l1->val < l2->val) { cur->next = l1; l1 = l1->next; } else { cur->next = l2; l2 = l2->next; } cur = cur->next; } cur->next = l1 ? l1 : l2; return dummy->next; }归并排序主体:
ListNode* sortList(ListNode* head) { if (!head || !head->next) return head; ListNode* slow = head; ListNode* fast = head->next; // 注意这里 fast 先走一步,让中点偏前 while (fast && fast->next) { slow = slow->next; fast = fast->next->next; } ListNode* mid = slow->next; slow->next = nullptr; ListNode* left = sortList(head); ListNode* right = sortList(mid); return mergeTwoLists(left, right); }为什么 fast 要从 head->next 开始,而不是从 head?这是为了让 slow 停在中间偏左的位置,这样从 slow->next 切出来的右半部分不会为空。如果 fast 从 head 开始,偶数长度的链表 slow 会指向中间偏右,切分时左半部分可能为空,递归会出问题。这个细节是我实际调试时发现的,写出来给大家避坑。
如果被追问 O(1) 空间,就得用自底向上归并,每次按长度为 1、2、4……的块两两合并,纯迭代完成。思路不难但实现比较复杂,面试里能讲清楚原理就够了,现场写完整的自底向上归并不是必须的。
3.5 合并 K 个升序链表:多路归并的思路
把“合并两个有序链表”扩展成“合并 K 个”,是另一类高频综合题(LeetCode 23)。最简单的方法是每次取两个合并,复杂度 O(k²n),不够优。
推荐的思路是分治:把 K 个链表两两分组合并,一轮下来 K 变 K/2,重复到只剩一个。这样复杂度是 O(nk log k),也是归并思想在“多个输入”上的延伸。还可以用优先队列,把所有链表的头节点塞进一个小顶堆,每次弹出最小节点,再把它的下一个节点入堆,代码短但空间多 O(k)。
分治版本不额外引入复杂容器,写起来和上面的递归模式一脉相承。本质上就是把 sortList 里的“找中点切两半”换成了“对半切数组下标”,递归结构一模一样。
4. 刷题踩坑实录与排查技巧
4.1 空指针崩溃的五种场景
链表题里空指针是最常见的运行时错误,刷多了你会发现翻来覆去就这么几个触发点。
- 直接访问 nullptr 的 next,比如 while (head->next) 没先判 head 本身;
- 快慢指针里 fast->next->next 前,没保证 fast->next 不为空;
- 递归里 base case 覆盖不全,链表只剩一个节点时递归没到底;
- 用 cur->next 作为循环判断条件导致少处理最后一个节点,后面空指针访问;
- C++ 中 delete 节点后继续使用该节点,悬垂指针问题,比空指针更隐蔽。
应对方式就一句话:拿到链表的每一步操作前,先想清楚这条引用可能指向哪些状态。养成习惯后,空指针问题能少一半。
4.2 死循环:最隐蔽的 Bug
链表题的另一大杀手是死循环,表现为提交时超时(Time Limit Exceeded)。最常见的成因是链表中出现了“环”——不是题目给的环,而是你改指针时不小心把链表改成环了。
典型场景是在删除或反转操作里先改了 next 再存下一个节点,导致漏掉节点、循环回到先前节点。例如反转链表时如果忘了先存 next,cur->next 被改成 prev 后,cur 就再也找不到原来的后继了,链表会断,而如果循环条件写的是 while (cur->next),还会原地绕圈。
排查死循环我的方法是:在循环体里打印 cur 的地址,观察是否重复出现。本地调试时把链表长度设成 5 以内,用纸笔画一遍指针变化,比在脑内空转快得多。LeetCode 编辑器里也可以临时加 printf,提交前删掉即可。
4.3 边界条件自查清单
链表题的边界条件高度雷同,整理成清单后每次提交前可以逐项检查。
- 空链表 head == nullptr;
- 只有一个节点 head->next == nullptr;
- 删除的是头节点;
- 删除的是尾节点;
- 链表长度恰好为 2;
- 奇数 vs 偶数个节点(快慢指针题);
- 循环链表/环的入口节点是 head 本身;
- N 等于链表总长度(删除倒数第 N 题中)。
我刷题的习惯是:先在脑子里跑一遍清单再提交,一旦提交失败就按清单排查。很多次刷题记录里写到的“WA 了一次”,最后基本都能落在清单的某一项上。
4.4 C++、Python、Go 的实现差异
同样一份链表题,用不同语言写,细节差异挺大。
C++ 要手动管理内存,delete 被删除的节点,否则内存泄漏。但注意,LeetCode 判题时其实不检查内存泄漏,因此很多人就不写了。我建议练习时写规范一点,面试白板环节如果主动写出 delete,是加分项;但真实面试里有时候面试官更关注逻辑,delete 写不写要按对方风格来。
Python 刷链表题最舒服,引用即指针,不用管释放。但 Python 的默认递归深度有限(大约 1000 层),递归反转链表这种代码在链表很长时会栈溢出。LeetCode 默认样例通常没问题,但自己扩展测试时要小心。
Go 的链表操作介于两者之间,不需要手动 delete,但有 GC 开销,刷题性能上比 C++ 略慢。Go 没有指针算术,也没有继承,结构体的定义比 C++ 略啰嗦:
type ListNode struct { Val int Next *ListNode }语言差异不值得焦虑。链表考的是数据结构思维,语言只是表达方式。我自己在 C++ 里把思路理顺后,改用 Python 重新实现同一道题,往往只需要两分钟,因为操作逻辑完全是同一套。
5. 一点个人经验收尾
刷链表专题这段时间,我最大的体会是:链表题是“量变引起质变”最明显的题型。刚开始每道题都要想半天指针指向,刷到后面,看到题目基本能条件反射地意识到该用 dummy 还是快慢指针。这个专题特别适合用来练“思路先行”的习惯——摩尔定律不适用,但刷题定律适用:先想清楚“我要改变哪些引用,改变的顺序是什么”,再动手写代码。
最后分享一个刷题小技巧:每个核心操作(遍历、插入、删除、反转、合并)单独写成函数,然后复用这些函数去解综合题。比如 sortList 里用 mergeTwoLists,反转链表用 reverseList,这样每道题的解法都变成“拼积木”,思路清晰,代码也容易调试。后续再刷到重排链表、两两交换节点这类题,你会发现全都跑不出这个框架。
链表题刷到第五轮,已经能明显感觉到自己写代码的确定性变高了。这种确定性不来自天赋,纯粹是每一步指针操作都走脑子,每一个边界条件都读过清单。经验攒够了,手感自然出来。