1. 链表这个考点到底在考什么
1.1 为什么链表能成为必刷清单里的常客
如果你刷过几套算法题,大概会发现一个规律:数组和链表是所有数据结构里的底子。数组还算好理解,连续内存、靠下标访问,逻辑直白;但链表这玩意儿,很多人在学校学过之后,除了考试画图,几乎没在实际代码里用过。到了刷题阶段,链表题却频繁出现,尤其是“代码随想录”这种以面试为导向的刷题路线,day4就把它单独拎出来集中练习,背后是有原因的。
链表题考察的其实不是“你会不会用链表”,而是三个更深层的能力:第一,你有没有搞清楚指针和引用到底在操作什么;第二,你能否在循环里正确处理“边界条件”,比如空链表、单节点链表、删除尾节点;第三,你愿不愿意用“虚拟头节点”这类小技巧来统一逻辑,而不是每个特殊情况都单独写一段if。说白了,链表题是一块试金石,能反映一个人写代码时是不是够谨慎、够系统。
以我自己的面试经验来说,链表题目往往出现在第一轮或者第二轮,题目本身不难,但写起来很容易漏细节。尤其是用C/C++或者Rust这类需要自己管理指针的语言,一个空指针解引用就能让整道题前功尽弃;换到Java或Python这类语言,虽然取消了指针的显式操作,但引用传递的语义依然存在,如果理解不到位,照样会踩坑。
1.2 先理清链表的底子:节点、指针和内存布局
讲链表之前,我建议你先把“节点”这个概念彻底想明白。一个标准的单链表节点,至少包含两部分:一个存储数据的字段,一个指向下一个节点的引用。在C语言里,它长这样:
struct ListNode { int val; struct ListNode *next; };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, val=0, next=None): self.val = val self.next = next很多新手最大的困惑是:next到底是节点本身,还是一个地址?严格来说,它是一个引用(在C里是指针变量),保存的是下一个节点的内存地址。但你在代码里写cur.next的时候,语言会自动帮你解引用,所以你感觉到“cur.next就是下一个节点”。这种便利其实掩盖了底层的内存关系,一旦涉及“改指针指向”,就必须回到“操作地址”这个层面去思考。
想明白这一点之后,链表的学习就有了抓手。链表题几乎所有的操作,都能归结为对“指针指向”的调整——改一个或多个next,让节点的连接顺序按你的意图重组。理解了这层逻辑,后面看各种反转、交换、删除的题,就不会觉得是一堆孤立套路了。
2. 核心操作拆解:从结构到代码的思路转化
2.1 一定要掌握的虚拟头节点技巧
链表题里最高频的技巧,我觉得非“虚拟头节点”莫属。它解决的核心痛点是:当需要删除或修改的是首节点时,头节点本身就要变化,写起来逻辑容易分叉。比如你要删除链表中所有值等于目标值的节点,如果第一个节点正好命中,那你得把头指针后移;如果中间某个节点命中,你又要借助前驱节点来操作。两种情况的代码写法不一致,bug率就上去了。
虚拟头节点的思路,是在真正的头节点前面额外加一个节点,它不存储数据,只是为了让“每个节点的操作逻辑都统一”。加了它之后,原链表的所有节点都变成了“中间节点”,处理方式完全一样:通过前驱来删除,通过前驱的next来改变指向。最后返回结果时,直接返回虚拟头节点的next即可。
我用Python写过一段很典型的删除逻辑,可以对照看一下:
def removeElements(head: ListNode, val: int) -> ListNode: dummy = ListNode(next=head) cur = dummy while cur.next: if cur.next.val == val: cur.next = cur.next.next else: cur = cur.next return dummy.next这段代码的精髓在于,cur永远指向当前节点的前驱,判断和删除都基于cur.next完成,完全没有单独处理头节点的分支。你不需要问“如果删的是头节点怎么办”,因为在虚拟头节点的视角下,头节点也只是“某个前驱的后继”而已。
注意:虚拟头节点不要直接参与数据操作,它只是占位,所以它的
val随意设置即可,不需要赋真实数据值。
类似的技巧,在“两两交换链表中的节点”、“删除倒数第N个节点”这些题目里也会反复用到。我自己的习惯是:凡是要考虑“链表第一位可能被改动”的题,先写一个dummy节点再说,这不仅让逻辑统一,也让代码更容易读。
2.2 遍历链表时的常见误区与习惯养成
链表遍历是最基本的操作,但即使这么基础的东西,我也见过不少同学写错。最常见的两个坑:一个是循环条件应该用while cur:还是while cur.next:分不清,另一个是遍历时不小心移动了头指针导致返回结果错误。
写循环之前,先想清楚你要处理的是“当前节点”还是“当前节点的下一个节点”。如果你要对当前节点做操作,那循环条件就应该是while cur:;如果你要通过前驱来操作后继,那条件是while cur.next:。两者的区别,等你被空指针异常折磨一次之后就能彻底记住了。
还有一个小习惯,我建议你从一开始就养成:凡是会改变链表结构的操作,永远保留一个指向头部的变量,比如head或result,不要为了遍历让头指针“跑丢”。如果你需要遍历,请新建一个临时指针变量,类似cur = head。
很多语言里,你用一个变量接收原来的头指针,然后在循环里移动它,最后想返回这个头指针,结果发现它已经跑到链表尾部了。这种错误,原因就是对“引用变量”的理解不够。记住,头指针在你遍历结束之后,仍然指向原来的内存地址——前提是你在操作之前就把它存下来了,或者没有把它本身当作游标去移动。
2.3 反转链表:迭代和递归都要会
反转链表是链表题里最经典的一道,也是很多后续题目(比如回文链表、K个一组翻转)的基础。这里我想重点讲一下迭代思路,因为递归虽然代码短,但初学阶段很多人会绕晕。
迭代反转的核心,是维护三个指针:前驱指针pre、当前指针cur、后继指针tmp。每一轮先保存cur的后继,然后把cur的next指向前驱,再把两个指针各自后移。循环结束后,pre就是新的头节点。
def reverseList(head: ListNode) -> ListNode: pre = None cur = head while cur: tmp = cur.next # 先保存后继 cur.next = pre # 反转当前节点的指向 pre = cur # 前驱后移 cur = tmp # 当前节点后移 return pre很多人的问题在于:为什么要先保存tmp?因为一旦执行了cur.next = pre,原来的后继信息就被覆盖了,不保存的话,下一轮就不知道该往哪走。这就像你在单行道上倒车,先把退路记下来再调头。
递归版本也不难,但你需要理解一个关键点:递归函数返回的,始终是“新链表最末尾的那个节点”,也就是原链表的最后一个节点。每次递归都在处理当前节点的反转逻辑,最终把整个链表倒序。
3. day4核心题目解析:四道必刷题串联起来的体系
3.1 移除链表元素
这道题看起来最基础,但它把虚拟头节点、遍历、删除三个核心点全串起来了。题面很简单:给定一个链表头节点head和一个整数val,删除链表中所有满足Node.val == val的节点,返回新的头节点。
解题思路我在前面已经演示过,用虚拟头节点后,整个过程就是一个简单的遍历。这里我额外想提醒一个细节:删除节点之后,cur不要急着向后移动。因为在删除操作里,cur.next已经变成了原后继的后继,新的cur.next可能是另一个待删除节点,所以处理完之后,继续循环判断就对了,不能无脑cur = cur.next。
这个细节特别容易踩坑。你可以对比一下下面这份“错误示范”:
def removeElements_wrong(head: ListNode, val: int) -> ListNode: dummy = ListNode(next=head) cur = dummy while cur.next: if cur.next.val == val: cur.next = cur.next.next # 少了else cur = cur.next # 错误:删除之后仍然移动cur return dummy.next代码看着差不多,实际在连续命中目标节点时就会跳过检查,导致漏删。我之前辅导过一个学弟,他纠结了很久都没发现自己错在这一行。所以,写链表遍历删除的循环时,记住一个原则:只有当前节点“不需要删除”时,游标才能往前走。
3.2 两两交换链表中的节点
题目要求:给定一个链表,两两交换其中相邻的节点,并返回交换后的链表。你不能只是改节点的值,而是真的要交换节点本身。这道题是为了训练对指针操作的精细控制,同时它也很适合拿来理解“交换指针指向”和“交换值”的区别。
我先给一段能直接运行的Python解法:
def swapPairs(head: ListNode) -> ListNode: dummy = ListNode(next=head) cur = dummy while cur.next and cur.next.next: first = cur.next second = cur.next.next # 核心交换三步 cur.next = second first.next = second.next second.next = first # 移动游标,下一次要处理的区域起点是first cur = first return dummy.next画个图你就能看明白:假设有节点a -> b -> c -> d,现在要交换a和b,交换完成后是b -> a -> c -> d。那么需要调整三处指向:cur(虚拟头)本来指向a,改成指向b;a.next本来指向b,改成指向c;b.next本来指向c,改成指向a。代码里每一步都对应这三处指针调整。
做完这一步之后,游标应该移到a的位置,因为c和d的交换需要a作为前驱。这个“移动游标”的步骤,很多人会忘掉或者移错,建议多画几遍图加深记忆。
3.3 删除倒数第N个节点
这道题有两种主流解法:一是两次遍历,先算出链表长度,再删除正数第len - n + 1个节点;二是用双指针,一次遍历搞定。既然你已经到了day4,我建议你直接掌握双指针方案,因为它对后续“环形链表”等题也有帮助。
双指针的步骤是:先让快指针领先慢指针n步,然后两个指针一起前进,当快指针指向链表末尾时,慢指针恰好指向倒数第n个节点的前驱节点。此时直接把慢指针的next跳过去即可。
def removeNthFromEnd(head: ListNode, n: int) -> ListNode: dummy = ListNode(next=head) fast = dummy slow = dummy # 快指针先走n步 for _ in range(n): fast = fast.next # 两个指针一起走 while fast.next: fast = fast.next slow = slow.next # 此时slow是倒数第n个节点的前驱 slow.next = slow.next.next return dummy.next这里有一个新手容易搞混的点:快指针究竟要先走n步还是n+1步?答案取决于你想要慢指针停在目标节点的位置,还是目标节点前驱的位置。如果你想让慢指针停在目标节点“前驱”,则快指针先走n步;如果你想实现“找到倒数第n个节点”然后自己删除,快指针先走n+1步。我建议专门记一下“前驱派”的写法,因为链表删除本来就依赖前驱,这个思路能直接推广。
提醒:链表中“跳过第N个节点”的本质,是把第N个节点的前驱
next指向第N个节点的后继。这需要你拿到的是前驱节点,而不是目标节点本身。
3.4 链表相交与环形链表二
链表相交这道题,核心是判断两个链表是否在某个节点之后合并,并返回第一个相交节点。常规做法有两种:一是用哈希集合存下链表A的所有节点,再遍历链表B逐个检查;二是双指针同时遍历两个链表,一个遍历完就跳到另一个链表继续,最终相遇点就是相交点。
双指针做法很巧妙:链表A长度为a,链表B长度为b,假设公共部分长度为c,那么a + (b - c)恰好等于b - c + a,也就是两个指针分别走完自己链表再接上对方链表之后,会在相交点相遇。这个数学推理初看有点绕,但你用具体数字代一下就清楚了。
环形链表二这道题,则是双指针快慢跑的经典应用。快指针每次走两步,慢指针每次走一步,如果存在环,它们必定在环内相遇。关键难点在于:如何从相遇点找到环的入口。这里有一个结论:从头节点出发的指针,和从相遇点出发的指针,每次都走一步,它们最终会在环的入口相遇。原因有两段相等的距离,推导过程在随想录里有详细说明,建议自己推一遍。
我个人认为,环形链表二的难点不在于写代码,而在于“理解为什么”。你不需要把数学推导背下来,但至少要能在面试时用两三句话把它讲清楚——面试官往往更在意你清不清楚原理,而不是能不能默写。
4. 双指针技巧与链表题目的边界处理
4.1 快慢指针在链表题中的多种用法
链表的很多难题,最后都会落到“双指针”这个工具箱里。除了上面说的快慢指针找环,双指针还能用来找链表中点、找倒数第K个节点、判断回文链表等。
找中点的思路是:快指针每次走两步,慢指针每次走一步,当快指针到达末尾时,慢指针就在中点。这个技巧在后面“排序链表”、“链表二叉树转换”等题目中也会用到。找倒数第K个节点和删除倒数第N个节点是同一个思路家族,只是最后操作不同。
值得注意的一点是,快慢指针并不总是“快指针走得快”,有时候两个指针的移动速度相同,步调不同,比如链表相交问题里的两个指针,其实是“各自走完自己链表后绕到对方链表”的模式。理解双指针的本质,是理解两个指针之间的“路程关系”,而不是机械地记忆快慢。
4.2 边界条件清单:每次提交前过一遍
链表题的边界条件其实是有限的几类,你可以整理成清单,每次写完代码之后自查一遍:
- 空链表:
head为None,你的代码能不能直接返回?循环会不会进入? - 单节点链表:只有一个节点时,删除、交换、反转的结果是否符合预期?
- 两个节点链表:交换、反转操作是否引入了环?
- 删除头节点:虚拟头节点有没有正确解决这个问题?
- 删除尾节点:能否通过
cur.next.next访问到尾节点而不空指针?
我自己写链表题,最后几分钟基本不是在看功能逻辑,而是在脑子里跑这些边界条件。有一个办法特别有效:把链表画在小本子上,用箭头标注所有指针变化,逐行对照代码走一遍。这比在IDE里调试快得多,而且能锻炼脑子里的“指针推演”能力。
5. 实操心得与常见问题排查
5.1 不同语言写链表的差异与坑
我用过C、C++、Java、Go、Python和Rust写链表题,体验差异很大,这里挑几个重点说一下,帮你避坑。
- C/C++:最接近底层,需要手动
new和delete节点。面试或实际项目里,内存泄漏是隐患,但刷算法题一般都忽略。不过千万不要野指针操作,解引用空指针在C++里是未定义行为,表现可能是段错误,也可能是莫名其妙的数据错乱。 - Java/Go:引用类型天然安全一点,但也要注意,如果你把某个节点赋值给两个引用,修改其中一个的
next,另一个引用也会“看到变化”,因为两者指向的是同一个对象。这其实是优点,但也是链表题里出错的一个源头——你以为你保存了旧状态,实际上保存的引用已经被改了。 - Python:同理,对象赋值是引用传递。写
tmp = cur.next保存后继时,tmp是一个节点对象引用,之后cur.next再怎么改,tmp仍然指向原来的对象。这和“深拷贝”完全是两回事,别混淆。 - Rust:所有权系统和借用检查器让写链表变得极为痛苦,老手都会绕道。刷题阶段建议先用简单语言理解算法,Rust的链表操作适合后面单独补。
如果面试允许选择语言,我建议选你最熟练的,但一定要清楚“引用/指针”的本质。我在实际面试中见过很多候选人用Java写的链表题,最后出问题往往不是语法,而是引用指向被意外改动。
5.2 我摸爬滚打总结出的五条避坑建议
链表题写多了,我把自己的踩坑经验总结成几条,新手可以直接拿来当检查标准:
第一,循环条件里写cur.next之前,先确保cur本身不为None。很多空指针异常都源于“内层循环条件默认外层非空”。
第二,删除节点后,如果需要继续遍历,记得“停留原地”;只有节点没被删除时才移动游标。这一点前面提到过,但值得再强调一遍,因为它每天都能难倒一批人。
第三,交换节点时,如果要操作的是“后面的节点”,一定先保存所有相关引用,再统一修改指向。顺序一旦不对,链表就断了,而且这种断链很隐蔽,只有跑到后面才会报错。
第四,返回结果前,想一想是不是应该返回dummy.next而不是head。当头节点可能被改变时,head可能已经指向旧节点,用dummy.next最保险。
第五,能用while不用for,能用局部变量就别依赖全局状态。链表题的运行环境往往需要快速修改,代码越简单直接,越不容易出错。
5.3 一个完整的综合练习建议
如果你已经刷到day4,接下来几天我建议你做一次“整合练习”:不看解析,手写链表题的五件套——移除指定元素、反转链表、两两交换、删除倒数第N个节点、环形链表二。写完统一对照随想录的解析,把自己卡住的地方重点标记,过两天再重写一遍。
我自己的经验是:链表题的“手感”比“理解”更重要。理解了思路,如果手写不出来,面试时照样会卡壳。所以我会专门找一个晚上,把这五道题各写三遍:第一遍看着笔记写,第二遍关上笔记写,第三遍限时20分钟写。三遍下来,虚拟头节点和双指针基本就变成肌肉记忆了。
6. 最后再分享一个我自己的小经验
链表题刷到现在,我最大的体会是:不要怕画图,更不要嫌麻烦。你花五分钟在纸上画出指针变化,可能帮你省下半小时的调试时间。很多看似奇怪的bug,一画图就明白了,无非是哪个指针的指向被覆盖了、哪一步操作顺序反了。
另一个心得是,链表题的代码风格都很接近,核心就是“通过前驱操作后继”。如果你能在脑子里把每道链表题都翻译成“当前节点的前驱是谁、后继是谁、我该怎么改变它们的关系”,那不管题目怎么变,底层逻辑都是通的。
最后补一句,随想录day4这一天的链表专题,和后续的哈希表、二叉树、回溯算法都有隐性关联。链表让你建立起“指针操作”的直觉,哈希表让你理解引用和值的映射,二叉树则进一步加深对递归和引用传递的理解。把今天的内容吃透,后面每一天刷题都会轻松很多。