
前两天帮一个应届学弟做模拟面试他把剑指offer刷了两遍结果第一道反转链表就卡住了——不是不会写是边边角角的空指针处理让他心里没底。这个场景我见过太多次了。链表在技术面试里的地位很特殊它不像动态规划那样依赖数学直觉也不像系统设计那样需要工程经验它考的就是最基础的指针操作、边界意识和对数据结构本身的理解。可恰恰是这种看起来简单的题目最能暴露一个人基本功扎不扎实。这篇文章挑选了链表考法里最核心的7道必刷题覆盖反转、回文、环检测、双指针、合并、相交、排序七类最常见的形态。每道题我都会讲清楚三件事为什么这么解、代码怎么落地、面试官会在哪里追问。不管你是刚开始准备面试还是已经刷过一轮想查漏补缺都可以按这篇文章的节奏再过一遍。先给一张速览表方便安排刷题节奏题目核心考点难度反转链表指针换向 / 递归简单回文链表快慢指针 反转后半段中等环形链表快慢指针判圈简单删除倒数第N个节点间隔双指针 哑节点中等合并两个有序链表哨兵节点 / 递归简单相交链表消除长度差简单排序链表归并排序改造中等1. 面试官考链表到底在考什么三层底层认知决定解题速度1.1 链表和数组的本质差异数组是一块连续内存下标访问 O(1)但插入删除需要搬移数据。链表用指针把分散的节点串起来插入删除只需要改指针代价是访问只能从头遍历。这个差异直接决定了凡是涉及在中间插入/删除的场景链表有天然优势凡是涉及随机访问的场景链表就是劣势。面试里链表题让你做的本质上就是两件事怎么在只给定指针的情况下高效地完成某种遍历怎么在修改指针的过程中不弄丢节点、不形成环、不产生空指针。看清楚这一点很多题目其实是在考同一个能力——对指针状态的精确控制。1.2 三种语言下的节点定义速查面试中很可能需要现场手写节点定义不同语言要烂熟于心。C 结构体struct ListNode { int val; ListNode *next; ListNode(int x) : val(x), next(nullptr) {} };Java 类定义public class ListNode { int val; ListNode next; ListNode() {} ListNode(int val) { this.val val; } ListNode(int val, ListNode next) { this.val val; this.next next; } }Python 类定义class ListNode: def __init__(self, val0, nextNone): self.val val self.next nextC 语言没有构造函数一般手动 malloc 加初始化struct ListNode { int val; struct ListNode *next; }; struct ListNode* createNode(int val) { struct ListNode* node (struct ListNode*)malloc(sizeof(struct ListNode)); node-val val; node-next NULL; return node; }一个小建议刷题时固定用一种语言把节点定义和基础操作练到肌肉记忆的程度。我见过太多人现场写节点时忘了初始化 next或者 Java 里访问公共字段还要纠结 getter/setter这些都是可以提前规避的失分点。顺带说一句嵌入式开发里还有一种侵入式链表节点不单独存在而是把 next/prev 指针直接嵌进业务结构体里通过指针偏移找到宿主结构。Linux 内核里的链表就是这么写的和算法题里节点即数据的设计完全不同但底层一样是指针操作。嵌入式岗位面试如果考链表经常把这两种风格放在一起聊提前了解没坏处。1.3 带头结点、循环链表这些概念别再搞混很多教材会区分带头结点和不带头结点的单链表。带头结点指的是链表头部有一个不存数据的哑节点好处是插入删除时不需要特判是不是头节点操作逻辑统一。算法题里常用的 dummy node哑节点思路其实就是这一概念的产物。不带头结点的链表头指针直接指向第一个数据节点边界处理要更小心。循环单链表是尾节点指向头节点的链表经典问题像约瑟夫环就用它。双向链表每个节点有 prev 和 next 两个指针操作对称但指针更多写起来更容易乱。说这些不是为了堆概念而是为了让你听得懂面试官在描述什么场景。链表的基本操作——遍历、插入、删除——是更底层的东西。遍历就是从 head 开始不断 next 直到 nullptr插入分头插、尾插、中间插关键是先接新节点再改原指针删除的关键则是找到前驱节点。如果这些还在靠背别急着刷题先拿笔在纸上把每个操作画一遍代码写一遍。热身完毕接下来进入正文。2. 题1、题2反转链表与回文链表吃透逆序这一条主线2.1 反转链表迭代三指针是最底层的肌肉记忆反转链表是链表题的hello world但它的指针控制方式贯穿了后面至少三道题。迭代写法非常固定class Solution { public: ListNode* reverseList(ListNode* head) { ListNode *prev nullptr; ListNode *curr head; while (curr ! nullptr) { ListNode *next curr-next; // 先存不然一掉头就找不到了 curr-next prev; // 当前节点指向前一个 prev curr; // prev 前移 curr next; // curr 前移 } return prev; } };关键就一句话每次循环先记住 curr-next否则把 curr-next 改成 prev 之后原来的下一个节点就丢了。这就是我前面说的不弄丢节点。很多人第一次写会漏掉 next 这个临时变量结果断链或者死循环原因就是没想清楚指针变化的时序。用具体例子走一遍1-2-3-null。初始 prevnullcurr1。第一步next2让 1-nullprev1curr2第二步next3让 2-1prev2curr3第三步nextnull让 3-2prev3currnull循环结束返回 3。没有任何跳步每一步都在做同一件事把当前节点的 next 掉头。Python 版本也放出来字节跳动、美团这类面试经常会让你现场换语言写def reverse_list(head: ListNode) - ListNode: prev, curr None, head while curr: nxt curr.next curr.next prev prev curr curr nxt return prev2.2 反转链表的递归写法与理解方式递归写法短但理解门槛明显更高class Solution { public: 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; } };我建议这样理解reverseList(head-next)返回的是从 head-next 开始的那一段链表反转后的新头。这个新头在整个反转完成后就是整条链表的新头。递归返回后要做的只是把 head 接到这段新链表的尾部——而 head-next 在反转后恰好是这段链表的尾节点所以head-next-next head完成拼接head-next nullptr把原来的正向连接断开否则会成环。递归的空间复杂度是 O(n)因为有递归栈。代码虽然优雅但面试时如果没特别要求我建议优先写迭代少一个解释不清的风险点。2.3 回文链表快慢指针找中点反转后半段判断一个链表是不是回文比如 1-2-3-2-1 是回文1-2-2-1 也是。最直接的办法是遍历一次放进数组再判断O(n) 空间但面试官很可能追问能不能 O(1) 空间。O(1) 思路分三步快慢指针找中点反转后半段从两端同时遍历比较。class Solution { public: bool isPalindrome(ListNode* head) { if (head nullptr || head-next nullptr) return true; // 1. 快慢指针找中点slow 最终指向后半段起点 ListNode *slow head, *fast head; while (fast fast-next) { slow slow-next; fast fast-next-next; } // 2. 反转后半段 ListNode *prev nullptr, *curr slow; while (curr) { ListNode *next curr-next; curr-next prev; prev curr; curr next; } // 3. 两头同时比较 ListNode *left head, *right prev; while (right) { if (left-val ! right-val) return false; left left-next; right right-next; } return true; } };细节在奇数长度和偶数长度的差异上。奇数长度时slow 会停在中点反转后半段包含中点本身比较时中点和自己比不影响结果。偶数长度时slow 正好停在第二个半段的起点。这两种情况建议分别在纸上画一遍比背结论可靠得多。面试官常追问为什么不直接反转整条链表再比较因为反转整条后原链表结构被改掉了你没法同时从两端头开始做正反向遍历。反转后半段的巧妙之处在于前半段的正向遍历完全不受影响。还有追问反转完要不要恢复原结构LeetCode 默认不要求但你可以主动提一句如果要恢复再反一次就行这是加分细节。3. 题3、题4环形链表与删除倒数第N个节点双指针的两种经典形态3.1 环形链表快慢指针为什么一定能相遇判断链表有没有环经典解法是快慢指针class Solution { public: bool hasCycle(ListNode *head) { ListNode *slow head, *fast head; while (fast fast-next) { slow slow-next; fast fast-next-next; if (slow fast) return true; } return false; } };原理可以这样理解slow 每次走 1 步fast 每次走 2 步。如果链表有环slow 进入环之后fast 已经在环里了。想象两个人绕圈跑一个快一个慢只要一直跑下去快的迟早追上慢的。相对速度是 1 步/轮意味着每一轮 fast 相对 slow 靠近 1 个节点而环的长度是有限的所以一定能相遇。这里有个高频追问为什么 fast 不走 3 步fast 走 3 步时相对速度是 2如果环的长度是偶数且初始时 fast 和 slow 之间的间隙是奇数那么每一轮间隙减 2永远减不到 0两人可能永远错过。步长选 2 是因为相对速度为 1能保证覆盖所有可能的间隙位置。能答出这一层面试官对你代码的理解深度是认可的。3.2 进阶环形链表II的环入口数学推导找到环的入口是高频进阶题。在相遇之后把一个指针拉回 head两个指针都每次走一步再次相遇的地方就是环的入口。class Solution { public: 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; } };推导过程我写一遍设 head 到环入口的距离是 a环入口到相遇点的距离是 b环的长度是 L。slow 走的总步数是 abslow 在环内走不到一圈就会碰上 fast。fast 走的总步数是 2(ab)。同时 fast 的路径也可以写成 a b nL其中 n 是 fast 在环里绕的圈数n ≥ 1。于是2(ab) a b nL得到a b nL也就是a nL - b。这个式子的意思是从相遇点继续走nL - b步会回到环入口从 head 走 a 步也到环入口。两者步数一样所以让 head 和 slow 同步走第一次相遇点就是入口。这个证明建议自己推一遍面试时能顺畅讲出来这道题基本就稳了。3.3 删除倒数第N个节点哑节点让边界变简单删除链表倒数第 n 个节点。核心思路快指针先走 n 步然后快慢一起走快指针到末尾时慢指针正好停在倒数第 n 个节点的前一个位置。删除慢指针的下一个节点即可。但有个坑如果删的是头节点怎么办单链表删除节点必须知道它的前驱头节点没有前驱。更优雅的做法是加一个哑节点class Solution { public: ListNode* removeNthFromEnd(ListNode* head, int n) { ListNode *dummy new ListNode(0); dummy-next head; ListNode *fast dummy, *slow dummy; for (int i 0; i n; i) { fast fast-next; } while (fast-next) { fast fast-next; slow slow-next; } slow-next slow-next-next; return dummy-next; } };dummy-next 始终指向删除后的新头直接返回它就行。这就是带头结点思想在算法题里的应用把头部边界统一化少写一堆 if。注意这里的快慢指针和环检测里的快慢指针不一样。环检测是速度不同的快慢指针这里是间隔固定步数的快慢指针。两者都在利用双指针的空间差但形态完全不同。面试时讲清楚你用的是哪一种不要混着说。4. 题5、题6合并有序链表与相交链表拼接类题目的通用套路4.1 合并两个有序链表哨兵节点的工程价值合并两个升序链表是拼接类题目的地基class Solution { public: ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) { ListNode *dummy new ListNode(0); 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; } };几个要点dummy 节点作为最终结果的占位头tail 始终指向已合并链表的末尾每次选择两个链表中值较小的节点接到 tail 后面同时移动对应链表的指针循环结束后把剩余链表直接接上。这个模板在后面排序链表里还要用必须练到闭眼能写。递归版本更短但空间复杂度差一些class Solution { public: ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) { if (!list1) return list2; if (!list2) return list1; if (list1-val list2-val) { list1-next mergeTwoLists(list1-next, list2); return list1; } else { list2-next mergeTwoLists(list1, list2-next); return list2; } } };递归版本空间复杂度 O(nm)递归栈迭代版本 O(1)。面试默认写迭代。追问变体是合并 K 个有序链表用优先队列每次取 k 个候选头里最小的或者两两归并。思路相通都是从多个有序序列里反复取最小头节点。4.2 相交链表走完自己的路再去走别人的路找到两个链表的第一个相交节点。常规思路是先算两个链表长度让长的先走差值步再一起走这要遍历两遍。更巧妙的双指针解法class Solution { public: ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) { if (headA nullptr || headB nullptr) return nullptr; ListNode *p headA, *q headB; while (p ! q) { p p ? p-next : headB; q q ? q-next : headA; } return p; } };理解方式p 从 A 出发走到末尾后切到 B 的头部继续走q 从 B 出发走到末尾后切到 A 的头部继续走。设 A 的独有部分长度是 aB 的独有部分是 b公共部分是 c。p 走完 A 的 ac 步后切到 B再走 b 步到达交点总共 acb 步。q 走完 B 的 bc 步后切到 A再走 a 步到达交点总共 bca 步。两者步数相同所以一定会同时到达交点。如果不相交p 走完 A 再走完 Bq 走完 B 再走完 A总步数都是 ab最后同时到达 nullptr循环退出返回空。这两个长度相等的路径天然消除了链表长度差异这是整道题的精妙所在。面试时如果能画出 p 和 q 的走位图面试官通常会很满意。5. 题7排序链表——把归并排序搬到链表上5.1 为什么快排和堆排在链表上不香在 O(n log n) 时间内排序链表。先说结论链表排序的默认答案是归并排序。快排需要随机访问和从尾部往前的指针移动链表做不到高效的 partition。堆排需要数组式的索引来维护堆结构链表也不行。归并排序的核心操作是拆分和合并只需要顺序访问天然适合链表。数组归并需要额外 O(n) 辅助数组而链表归并的合并过程只需要改指针不需要额外数组空间——这是链表在排序场景里少有的优势。5.2 自顶向下归并拆、排、合三步走归并排序的思路一句话把链表从中间切开分别排序再合并。class Solution { public: ListNode* sortList(ListNode* head) { if (head nullptr || head-next nullptr) return head; // 1. 快慢指针找中点并断开 ListNode *slow head, *fast head, *prev nullptr; while (fast fast-next) { prev slow; slow slow-next; fast fast-next-next; } prev-next nullptr; // 2. 递归排序两半 ListNode *left sortList(head); ListNode *right sortList(slow); // 3. 合并复用 4.1 节的迭代实现 return mergeTwoLists(left, right); } };找中点并断开的操作需要三个指针slow 是快慢指针里慢的那个prev 记录 slow 的前一个节点。找到中点后执行prev-next nullptr把链表从中间一分为二slow 就是第二段的头。递归版本时间复杂度 O(n log n)空间复杂度 O(log n)来自递归栈。面试时如果能说出递归栈深度等于归并树的高度是 log n说明你真的理解了归并过程。如果要严格 O(1) 空间需要自底向上归并从 1 个节点一组开始两两合并然后 2 个一组、4 个一组……实现细节更多。面试中先掌握自顶向下版本被追问时再提自底向上的思路。5.3 复杂度分析与面试追问链表归并和数组归并的关键差异列个表对比一下维度数组归并排序链表归并排序拆分直接二分快慢指针找中点 O(n)合并需要 O(n) 辅助数组只改指针O(1) 辅助空间总空间O(n)O(log n)递归栈总时间O(n log n)O(n log n)常被追问的还有链表能不能用插入排序可以LeetCode 147 就是链表插入排序但时间复杂度 O(n^2)只适合小数据量或者作为为什么不用插入排序的对比题。面试时间有限时优先讲归并。6. 刷完这7道题之后高频坑位自查表与下一阶段路线6.1 高频坑位自查表刷链表题最常见的错误我整理了一张自查表考前对照着检查坑表现规避方法空指针解引用对 nullptr 调用 -next先判空循环条件带上 fast fast-next断链修改 next 前没保存原值变动指针时先想清楚原来的 next 去哪了成环反转/拼接后节点互相指换向一定记得把尾部 next 置空死循环快慢指针或合并跑不完检查循环条件是否每一步都推进返回错误头用了 dummy 节点却返回 dummy返回 dummy-next 而不是 dummy删除没找前驱单链表删不掉目标节点删除操作统一先找前驱配合哑节点这些坑没有技巧就是练。每道题至少完整写 3 遍第一遍可能要靠回忆第二遍理解每一步第三遍闭眼默写。我的经验是如果第三遍仍然卡壳说明有一个环节没真正理解这时候不要硬背回到纸上画图。6.2 下一阶段练什么7 道题覆盖的是最高频的题型链表的考法还有很多变体建议按这个顺序补K 个一组翻转链表综合反转、区间操作、边界处理进阶必刷复制带随机指针的链表哈希表和原地复制两种解法考察对引用关系的理解LRU 缓存哈希表 双向链表把链表用到系统设计场景重排链表快慢指针 反转 合并的综合题两数相加链表作为大数存储结构考察遍历同步。我个人觉得链表刷得好不好不看你会不会背题而看拿到一道没见过的新题时能不能在五分钟内说清楚这题需要哪几个指针、往哪个方向走、有哪些边界。7 道题练完你应该能明显感受到自己对指针操作的掌控感上了一个台阶。最后分享一个我在实际面试中最常用的检查方法代码写完后不要急着提交拿一个长度为 2 的链表和一个长度为 3 的链表人肉跑一遍。这两个长度能覆盖大多数边界组合。养成这个习惯白板环节的通过率会明显提升。