如果让我用一句话形容链表这个知识点,我会说:它本身难度不大,但它是对“操作细节”最具考验的一个数据结构。这两天跟着一套算法刷题训练计划走到day3,主题恰好是链表。我的做法是先从最基础的节点定义开始,再把增删改查、反转链表、两两交换、删除倒数节点、环形链表这些高频题目全部手推了一遍,每道题都在纸上画过完整的指针指向图。
这篇内容相当于我自己的学习复盘:链表到底怎么理解,操作时候哪些细节千万不能搞错,经典题目有哪些固定套路,以及我踩过的那些坑。不管你是刚接触链表的新手,还是已经会写但总在边界条件上栽跟头的朋友,这篇文章应该都能帮你把这块知识彻底理顺。
1. 先弄清链表的底层逻辑:数组与链表的本质区别
很多人在初学阶段对链表的理解停留在“有几个节点、用箭头串起来”这个层面。这个直觉基本正确,但一旦涉及代码实现,就会暴露出问题。因为链表和数组的底层存储方式完全不同,理解了这个差异,后面所有操作你才能推导出来,而不是死记代码。
1.1 数组的连续房间 vs 链表的指针绳
数组在内存里是一段连续的空间,就像一栋楼的连续房间,房间号是固定的。你只要知道第一个房间的门牌号,就能通过下标计算出任意房间的位置,所以数组的随机访问是 O(1) 的。
但连续空间也带来一个麻烦:如果你想在某两个房间中间插入一个新的房间,就必须把后面所有房间都往后挪一格,否则没有位置放。这就导致数组的插入和删除,平均时间复杂度是 O(n)。
链表则完全不同。链表的每个节点像是一个独立的集装箱,散落在内存各个角落,集装箱之间靠一根绳子(指针)串起来。每个集装箱里装着两部分东西:实际的数据,以及一根指向下一个集装箱的绳子。
所以链表天生就不要求内存连续,它的插入和删除只需要“解开老绳子、绑上新绳子”,时间复杂度是 O(1)。代价是:访问某个位置的节点,你只能从第一个节点开始,顺着绳子一个个找,平均 O(n)。
理解了这一点,你就知道为什么很多算法题会同时涉及数组和链表——它们是互补关系,没有绝对的优劣。数组擅长快速查找,链表擅长快速增删。面试里问到“什么时候用链表”,本质上就是在考察你对内存模型的理解,而不只是背结论。
1.2 单链表、双链表、循环链表,怎么选
链表有三种常见形态,虽然题目里大多数是单链表,但也有不少题会考双链表和循环链表。
单链表是最基础的形态。每个节点只有一个 next 指针,只能从前往后走。它的优点是省空间、结构简单,缺点是没法回头。很多反转、合并类题目,都是基于单链表出现的。
双链表在每个节点里多了一个 prev 指针,既能往前也能往后。删除节点时,单链表需要找到待删节点的前驱,而双链表自己就能拿到前驱节点,所以某些场景下双链表会更方便。代价是每个节点多维护一个指针,写入操作复杂度更高。
循环链表则是把链表的尾节点 next 指向头节点,形成一个环。题目里最常见的考察方式是判断链表是否有环、以及找到环的入口。这个我后面会展开讲。
实操建议:刷题阶段优先吃透单链表,因为大部分题目最后都会转换成单链表的操作。Python 刷题时我习惯直接写 ListNode 类,C++ 则用 struct 定义节点,两个都要会写,因为不同题目环境可能要求你用指定语言写完整代码。
2. 链表操作核心技术拆解:从定义到实战套路
你有没有发现一个现象:很多人能看懂链表的原理,但一写代码就乱。原因往往出在“指针顺序”上——链表操作的本质是修改指针指向,而修改的顺序稍有不慎,就会导致节点丢失或死循环。
2.1 节点的定义与构造
不管用哪种语言,链表节点的定义都非常固定。以 Python 为例:
class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = nextC++ 则更简洁:
struct ListNode { int val; ListNode *next; ListNode(int x) : val(x), next(nullptr) {} };注意 C++ 版本里我建议在构造函数里把 next 初始化为 nullptr。很多初学者不初始化头节点的 next,导致后续遍历时悬空指针访问,这是 C++ 链表里最典型的崩溃原因。
Python 里则要养成的习惯是:在创建一个节点时显式指定 next 参数,哪怕默认是 None。这样能避免后面忘记设置 next 而出现隐晦问题。
提示:面试手写链表中,节点定义通常需要你现场写出来。不要图省事省略构造函数,更不要在成员变量里只写 val 不写 next。阅卷人第一眼就会看这里。
2.2 虚拟头节点的魔法
链表操作中有一类最容易出错的场景,就是“操作头节点”。比如删除头节点、在头节点前插入节点,都需要单独写一堆 if 判断。虚拟头节点(dummy node)就是专门来解决这个问题的。
做法很简单:创建一个不存储有效数据的临时节点,让它的 next 指向真正的头节点。之后无论你怎么操作,新链表的头节点都是 dummy.next,而你只需要关注 dummy 后面的第一个有效节点即可。
以删除元素为例,如果没有 dummy,你需要判断删除的是不是头节点,如果是,head 需要往后移一位。代码里就会出现两套分支。有了 dummy 后,你只需要统一找到待删节点的前驱,代码一个分支都不需要。
ListNode* removeElements(ListNode* head, int val) { ListNode* dummy = new ListNode(0); dummy->next = head; ListNode* cur = dummy; while (cur->next) { if (cur->next->val == val) { cur->next = cur->next->next; } else { cur = cur->next; } } return dummy->next; }这段代码对头节点和中段节点的处理方式完全一致,关键就在于 dummy 充当了统一的前驱。这也是为什么我强烈建议:凡是涉及“可能改变头节点”的链表操作,先创建一个 dummy。
经验之谈:反转链表、两两交换节点、删除倒数节点这些题里用 dummy,不只是少写几个 if,更关键的是它让我在推导指针顺序时少犯晕。因为 dummy 的引入让链表的边界变得整齐划一,而整齐划一是减少错误的最大武器。
2.3 双指针套路汇总
链表题里有一大批题目属于“双指针”范畴。这里要特别注意,链表双指针和数组双指针的思路不同,数组通常是左右夹逼,链表里更常见的是“快慢指针”。
最经典的快慢指针应用是判断链表是否有环:慢指针每次走一步,快指针每次走两步。如果链表中存在环,两者必然会在某个节点相遇;如果无环,快指针会先到达末尾的空指针。
为什么快指针走两步而不是三步?因为两步可以保证慢指针每走一步,快指针相对它前进一步,两者之间的距离变化是可控的,数学上更容易分析相遇条件。
另一个高频用法是删除倒数第 N 个节点:让快指针先走 N 步,然后快慢指针一起走,当快指针到达末尾时,慢指针正好停留在待删节点的前驱位置。这一招能实现一次遍历完成删除,比先遍历统计长度再走一遍要更贴近面试官的期望。
快慢指针还有一个变体是寻找链表中间节点:快指针走两步,慢指针走一步,当快指针走到末尾时,慢指针刚好到达中间位置。这个技巧在链表排序、回文判断里非常有用。
注意:写快慢指针时,最常犯的错误是在循环内部对快指针的 next 直接取 next,忘了先判断快指针本身是否为空。每次移动前都必须先判空。
3. 高频题目实操:四道经典题一步步拆解
这一部分我把训练计划 day3 里最核心的几道题拿出来,给出完整思路和代码。你不需要死背代码,而是要掌握推导的过程。每道题我都建议你拿出一张草稿纸,画 5 个节点,然后逐步移动指针,感受每一步的指向变化。
3.1 反转链表:迭代与递归两种写法
反转链表是链表题的门面担当,几乎每次面试都会碰到。题目要求把链表从 1->2->3->4->5 变成 5->4->3->2->1。
最直观的思路是迭代:准备三个变量 prev、cur、next。cur 是当前节点,prev 是它前一个节点,next 是它后一个节点。每一步把 cur.next 指向前面的 prev 就行,但注意在修改指针之前,必须先把 next 保存下来,否则 cur 后面的节点就找不到了。
ListNode* reverseList(ListNode* head) { ListNode* prev = nullptr; ListNode* cur = head; while (cur) { ListNode* next = cur->next; // 先保存后继 cur->next = prev; // 反转指针 prev = cur; // 前驱后移 cur = next; // 当前后移 } return prev; // prev 最终指向新链表头 }这个算法的时间复杂度是 O(n),空间复杂度 O(1),是面试首选。很多人写反转链表时出错,就是忘了先保存 next,或最后返回的是 cur 而不是 prev。我建议你在纸上把 cur、prev、next 三个变量用一个表格记录下来,每一轮循环都更新一行,走完一遍你就能彻底理解。
递归写法也值得掌握,虽然空间复杂度是 O(n),但它体现的是另一种思维:假设后面的链表已经反转好了,我只需要把当前节点的 next 节点的 next 指向当前节点。
ListNode* reverseList(ListNode* head) { if (!head || !head->next) return head; ListNode* newHead = reverseList(head->next); head->next->next = head; head->next = nullptr; return newHead; }3.2 两两交换链表中的节点:画图定指针
题目要求把 1->2->3->4 变成 2->1->4->3。这道题考察的重点是多个指针的重新连接顺序。
我的建议是画图。画完图你会看到,对任意一组节点 a、b,以及 a 前面那个节点 pre 和 b 后面那个节点 next,一共需要三次指针修改。
用 dummy 辅助时,核心代码是这样:
ListNode* swapPairs(ListNode* head) { ListNode* dummy = new ListNode(0); dummy->next = head; ListNode* cur = dummy; while (cur->next && cur->next->next) { ListNode* node1 = cur->next; ListNode* node2 = cur->next->next; node1->next = node2->next; // 1 指向 3 cur->next = node2; // pre 指向 2 node2->next = node1; // 2 指向 1 cur = node1; // 移动 pre 到下一组的前驱 } return dummy->next; }这道题最容易犯的错是调整完 node1->next 之后,还想利用它拿到 node2,却发现已经改过了。所以我的习惯是:先把 node1、node2 这两个节点指针固定下来,再做修改。固定在前,修改在后,这个顺序非常重要。
我自己的体验是:两两交换这题如果不画图,纯靠脑补,大概有五成概率写错。画完图后,每一步都非常机械,基本零失误。链表题的核心方法论,其实就是“画图 + 固定节点”。
3.3 删除链表的倒数第N个节点:一次遍历方案
这道题的经典解法有两种。第一种是先统计链表长度,再走 size - n 步找到待删节点的前驱;第二种就是我前面提到的快慢指针。
快慢指针做法:
ListNode* removeNthFromEnd(ListNode* head, int n) { ListNode* dummy = new ListNode(0); dummy->next = head; ListNode* fast = dummy; ListNode* slow = dummy; // 快指针先走 n+1 步 for (int i = 0; i <= n; i++) { fast = fast->next; } // 快慢一起走 while (fast) { fast = fast->next; slow = slow->next; } // slow 此时指向待删节点的前驱 ListNode* del = slow->next; slow->next = slow->next->next; delete del; return dummy->next; }关键在于 fast 先走的是 n+1 步,而不是 n 步。因为我们要让 slow 指向待删节点的前一个节点,这样才能执行删除动作。如果只走 n 步,slow 会指向待删节点本身,虽然也能删,但你需要额外保存前驱,不如直接走 n+1 步来得干净。
边界情况思考:如果链表只有一个节点,要删除倒数第一个节点,这时 dummy 的作用就体现出来了。如果没有 dummy,你还需要判断 head 是否要更新,代码会多一截。
3.4 环形链表找入口:数学推导不迷路
判断链表有没有环,用快慢指针即可。但面试官往往不满足于此,还会让你找环的入口。这需要对相遇点做一次数学推导。
假设从头节点到环入口的距离是 a,环入口到快慢指针相遇点的距离是 b,相遇点再回环入口的距离是 c。那么环的周长 L = b + c。
慢指针相遇时走过的距离是 a + b,快指针是慢指针的两倍,所以快指针走了 2(a + b)。同时快指针多走的距离一定是环周长的整数倍 kL,于是:
2(a + b) = a + b + kL,即 a + b = kL = (k-1)(b+c) + (b+c),整理后 a = (k-1)(b+c) + c。
这意味着:从相遇点到环入口的距离 c,加上若干圈环长,恰好等于从头节点到环入口的距离 a。所以只要让一个指针从 head 重新出发,另一个指针从相遇点出发,都每次走一步,两者必定会在环入口相遇。
ListNode *detectCycle(ListNode *head) { ListNode* fast = head; ListNode* slow = head; while (fast && fast->next) { slow = slow->next; fast = fast->next->next; if (slow == fast) { ListNode* index1 = head; ListNode* index2 = fast; while (index1 != index2) { index1 = index1->next; index2 = index2->next; } return index1; } } return nullptr; }这里我不会死记硬背结论,而是每次做题时在草稿纸上重新推导一遍。推导三次之后,这个过程就变成了一种“肌肉记忆”。当你把推导过程内化后,即使面试官临时问你“如果快指针走三步会怎样”,你也能立刻分析出相遇条件的变化。
4. 链表老手的避坑清单:常见问题与排查记录
链表题刷得越多,你越会发现:出错的不是解题思路,而是实现过程中几个固定的坑。这一部分我整理了一份问题排查清单,都是实际练习里反复出现过的高频问题。
4.1 空指针的三大高发场景
空指针是链表实现里的第一杀手。
第一个高发场景是循环条件判断错误。比如 while (cur->next) 写成 while (cur),这会导致在 cur 为空时访问 cur->next,程序直接崩溃。正确写法是:当你需要访问 cur->next 的属性时,必须确保 cur 本身不为空。
第二个高发场景是快慢指针中直接取 fast->next->next。如果 fast 本身已经是最后一个节点,这一步就是非法访问。我习惯先判断 fast && fast->next,再进入循环体内移动。
第三个高发场景是删除节点时,删掉了指针却没有更新前驱。在单链表中删除某个节点,必须有前驱指针;没有前驱你就没法把前驱的 next 接到后继上。每次删除前,先确认自己手里有没有前驱。
排查套路:如果你的代码出现段错误或空指针异常,先检查所有
->next或.next的访问,向上回溯两层,确认上一层节点是否可能为空。90% 的链表崩溃都能通过这个动作定位。
4.2 指针丢失与备用指针
指针丢失是另一类高频错误。最典型的就是在修改链表指针之前,没有保存原来的后继节点。
比如反转链表时,如果你先执行了 cur->next = prev,然后又想通过 cur->next 获取原来的下一个节点,那拿到的是 prev,而不是你要找的节点。解决办法很朴素:每次要修改一个节点的 next 之前,先用一个临时变量把它保存下来。
我自己的编码习惯是:涉及指针重连的代码块,临时变量的命名尽量表达清楚年,比如 nextNode、node1、node2,而不是千篇一律的 tmp。因为复杂的链表题通常需要保存多个节点,如果全部叫 tmp,代码写到最后你自己都会迷糊。
画图同样能避免指针丢失。你只需要关心当前步骤要求我们连接哪两个节点,并且确保每一步的修改都不会切断后路。
4.3 边界条件的兜底策略
链表题最容易挂在边界条件上。常见的边界有三类:空链表、只有一个节点、操作头节点。
空链表很好处理,提前判断 if (!head) return nullptr 或 return head。但有些题要你把空链表和单节点统一用同一个逻辑处理,这时 dummy 的价值就非常明显,能让代码更简洁。
只有一个节点时,很多指针操作都会失效。比如 swapPairs 的循环条件 cur->next && cur->next->next,就是专门应对单节点和空集的情况。写代码前先想一想你的循环条件会不会在一开始就不满足,如果会,你的答案是否有兜底逻辑。
操作头节点这一类,核心建议就是使用 dummy。我翻看了很多链表题的高赞题解,几乎无一例外都会在涉及可能改变头节点的操作中使用虚拟头节点。这不是巧合,而是这类代码确实更容易维护。
最后一个技巧:每次写完链表操作,在 return 前问自己:“如果链表只有 0 个节点、1 个节点、2 个节点,我的代码分别会输出什么?”在脑子里快速跑一遍这三个最小用例,能拦截绝大多数边界错误。我刷链表题时每次都会严格执行这一条,它让我在面试中避免了很多尴尬。
链表这个专题很像打游戏里的基础装备:单独看并不华丽,但后续的复杂数据结构、面试中的高频考题,很多都是从链表的指针操作迁移过去的。day3 能把这个基础打扎实,后面学二叉树、图的时候会轻松不少。
我再分享一个个人习惯:学链表千万不要只对着屏幕看代码。我几乎每道题都用草稿纸画一遍指针指向,再配合代码逐行对照。通过三到五道题的训练,你就能把链表操作的几个核心模式内化成直觉。等你把反转链表、两两交换、环形链表这几道题画熟,你会发现后续绝大多数链表题都只是这些基本操作的排列组合。