news 2026/9/26 12:21:40

链表刷题Day3:移除元素、设计链表、反转链表的指针技巧详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
链表刷题Day3:移除元素、设计链表、反转链表的指针技巧详解

今天聊链表刷题计划里的Day3。这三道题——203.移除链表元素、707.设计链表、206.反转链表——名字看着简单,但刷过的人都知道,它们是链表模块里最要命的“试金石”。很多人数组题做得飞起,一到链表就卡壳,原因很简单:数组的操作有下标兜底,链表全靠指针指来指去,一个不小心就断链、丢节点、死循环。

这篇文章不是贴三道题的答案,而是把这三道题背后的东西彻底讲透:为什么虚拟头节点能统一删除逻辑,为什么707题看似简单却藏着大量边界,为什么反转链表的迭代法三指针这么经典。无论是刚学数据结构的学生,还是准备面试的求职者,把这三道题吃透,链表这块地基就稳了。

1. 为什么Day3要同时刷这三道链表题

1.1 链表学习的“最小闭环”

链表这个数据结构的核心是两个词:节点和指针。节点存数据,指针把节点串起来。操作无非增、删、改、查,但难就难在“改指针顺序这件事上不能有一点含糊”。我们常说要培养“指针感”,其实就是大脑里能模拟出指针变化的动画效果。

Day3这三道题刚好形成一个完整的能力闭环:203是“删”——只改一个节点的next指向,把目标节点从链条中摘除;707是“建”——要实现完整的增删改查,相当于把链表的每一种指针操作都过一遍;206是“翻”——把所有相邻节点的指向全部逆过来,是对指针操作的最高强度考验。

我见过很多人看答案能看懂,合上书自己写就崩,本质问题就出在:他只是在背代码,没有建立对指针操作的心理模型。而这三道题恰好是建立心理模型的三个不同维度,缺一个都不行。

1.2 三道题如何层层递进

从难度和思维量上看,这三道题的递进关系很明显。203是最基础的“在遍历中改next”,只需要维护一个prev指针,思维上是线性的;707开始涉及索引位置、边界判断、多种操作的统一处理,难度一下就上来了;206则完全转换视角,你要在遍历的过程中“边走边重构”链表结构,对空间想象能力的要求最高。

更妙的是,它们之间有天然的铺垫关系。203里你学会了虚拟头节点,到了707设计链表时,这个技巧能帮你省掉一大半头节点特判;203里你熟悉了prev和cur的移动节奏,到了206反转链表时,这个节奏会演变成pre、cur、nxt三指针的接力。所以这三道题放同一天不是巧合,而是刻意编排的递进训练。

1.3 在纸上画链表比写代码更重要

先告诉你一个我在实际刷题中最深刻的体会:不要在键盘上直接写链表代码,先在纸上画链表。

把节点画成方框,把next指针画成箭头,然后拿着笔把箭头重新连一遍。这个方法听起来笨,但极其有效。尤其是206反转链表,很多人在代码里绕来绕去写不对,但如果你在纸上画出1→2→3→4,然后用橡皮把箭头改成4→3→2→1,你会发现整个过程就是“逐个把箭头掉头”而已,代码只是把这个过程翻译成了循环。

我甚至建议,你写任何链表题之前都先画三步:第一步画原始链表,第二步画出你想得到的目标状态,第三步思考“每一步指针操作之后,哪个节点的信息会丢失,需要提前保存”。把这个习惯练成肌肉记忆,链表题基本就稳了。

2. 203题:移除链表元素的思路拆解与实现要点

2.1 先想清楚“删除节点”到底在做什么

203题的题目很直白:给定一个链表头节点head和一个整数val,删除链表中所有值等于val的节点,返回新的头节点。很多人一上来就写“如果当前节点值等于val,就把当前节点删掉”,然后用一个cur指针从头遍历。但这里有个隐蔽的问题:单链表只能从前往后走,你是无法知道“上一个节点是谁”的,而没有上一个节点,你就没法完成删除操作。

删除的本质是:把前一个节点的next,指向被删节点的next。也就是说,真正的操作对象是“被删节点的前驱”,而不是被删节点本身。这就是为什么这道题最简单的写法要维护一个prev指针,让prev始终指向当前遍历节点的前一个位置,检查prev->next是否需要删除。

如果你只用cur指针硬写,会遇到一个很麻烦的问题:头节点怎么删?头节点没有前驱,你必须单独写一段逻辑去更新head本身。这当然能做,但代码会变得支离破碎,而且很容易在边界处出错。

2.2 虚拟头节点:让头节点的处理不再特判

解决头节点特判问题的标准做法,就是引入虚拟头节点,也叫dummy node。具体做法是new一个值为0的节点,让它的next指向head,然后从dummy开始遍历。这样一来,原来的头节点也有了前驱,删除逻辑就完全统一了:不管删的是不是头节点,都走“prev->next = prev->next->next”这一条路。

为什么最后要返回dummy->next而不是head?因为head可能已经被删掉了,你无法确定它还是不是链表的头。而dummy节点永远存在,dummy->next才一定是当前链表真正的头节点。这是一个极其常用的技巧,后面203、19、82题都会用到,甚至双向链表、LRU缓存设计里也有它的变体。

注意:C++里使用new创建的dummy节点,函数结束前记得delete掉,避免内存泄漏。刷题平台一般不查这个,但面试官偶尔会追问。

2.3 完整实现与复杂度分析

先看C++版本的完整实现:

ListNode* removeElements(ListNode* head, int val) { ListNode* dummy = new ListNode(0, head); // 虚拟头节点,next指向head ListNode* prev = dummy; while (prev->next != nullptr) { if (prev->next->val == val) { ListNode* tmp = prev->next; // 先保存待删节点 prev->next = prev->next->next; // 跳过待删节点 delete tmp; // 释放内存 // 注意:这里prev不移动! } else { prev = prev->next; // 只有未删除时才移动prev } } ListNode* ans = dummy->next; delete dummy; return ans; }

这里有一个非常关键的细节:当prev->next的值等于val并完成删除后,prev一定不能移动。因为新的prev->next是原来被删节点的后继,它的值可能也等于val,需要继续检查。只有当前节点不需要删除时,prev才向后移动。这个细节我第一次写的时候就踩了坑,盯着屏幕看了半天循环为什么跳过了连续重复的val。

再给一个Python版本,Python没有指针,理解起来更直观:

def removeElements(self, head: Optional[ListNode], val: int) -> Optional[ListNode]: dummy = ListNode(next=head) prev = dummy while prev.next: if prev.next.val == val: prev.next = prev.next.next else: prev = prev.next return dummy.next

时间复杂度O(n),每个节点最多访问一次;空间复杂度O(1),只用到了常数级的额外指针。

2.4 不用虚拟头节点的写法:看差距在哪

为了让你更直观地理解虚拟头节点节省了什么,我贴一下不用的写法核心逻辑:

// 先单独处理头节点 while (head != nullptr && head->val == val) { ListNode* tmp = head; head = head->next; delete tmp; } // 再处理后续节点 ListNode* cur = head; while (cur != nullptr && cur->next != nullptr) { if (cur->next->val == val) { ListNode* tmp = cur->next; cur->next = cur->next->next; delete tmp; } else { cur = cur->next; } } return head;

你看,代码量差不多,但逻辑分了两个阶段,头节点的处理要单独写一个while循环。如果哪天题目改成“删除所有值等于val的节点并返回新的头节点”,这种写法的出错概率明显更高。虚拟头节点的价值不在于减少代码行数,而在于让逻辑变得统一、不容易遗漏边界。这也是为什么所有主流题解都会推荐dummy节点。

3. 707题:设计链表,一次吃透增删改查的所有边界

3.1 你要实现的不是“功能”,而是“指针纪律”

707题要求你设计一个链表类,实现get(int index)、addAtHead(int val)、addAtTail(int val)、addAtIndex(int index, int val)、deleteAtIndex(int index)这五个方法。这题在LeetCode上标着“中等”,但其实没有任何高深的算法,它就是把你对链表操作的理解拿出来逐项检查。

我常说这题考的不是智商,是纪律。什么叫纪律?就是每次操作前先想清楚三个问题:第一,index是否合法;第二,我怎么走到目标位置;第三,改指针之后,还有没有指针指着我下一步要访问的节点。任何一个问题没想清楚,代码就会出现段错误、死循环或者逻辑错误。

很多人在addAtIndex这里崩掉,就是因为没想明白“在第index个节点之前插入”到底要从哪个节点出发走几步。我们先统一约定:index从0开始,第0个节点就是头节点,addAtIndex(index, val)表示在第index个节点之前插入新节点。这一步理清了,后面才有得聊。

3.2 get与addAtIndex:索引边界的核心判断

get(index)的要求是:如果index无效(index < 0或index >= size),返回-1;否则返回第index个节点的值。实现很简单,从dummy->next出发,走index步。这里最容易犯的错误是循环条件写成index > 0还是index >= 0。记住:因为头节点是第0个节点,你要走到第index个节点,就是从当前头节点开始走index步。比如get(0)应该站在原地就返回头节点的值。

deleteAtIndex(index)的边界要更仔细:index < 0或index >= size时什么都不做。走到第index个节点的前驱,然后跳过第index个节点。注意,这里必须是从dummy出发走index步到达“前驱位置”,很多同学从head出发,结果删的永远是头节点的后继,全乱套了。

addAtIndex(index, val)是三种插入的统一入口。题目规定如果index == size,则插到链表尾部;如果index > size,则什么都不做;如果index == 0,则插到头部。你不用为这些情况写多个分支,统一做法是:从dummy出发,走index步,停在第index个节点的前驱位置,然后插入新节点。

提示:707题要求自己管理节点,C++在deleteAtIndex时一定要delete删除的节点。如果不delete,刷题平台内存不受影响,但面试官很可能追问内存管理问题。

3.3 加一个dummy哨兵节点,代码会简洁得多

设计链表的时候,很多人会纠结“要不要用dummy节点”。我的建议是:一定要用。原因很简单,有了dummy节点,addAtHead就变成了“在dummy后面插入”,addAtTail就是“从dummy出发走到末尾再插入”,deleteAtIndex永远不用考虑“删除头节点时head本身要更新”的问题。

你可能觉得头节点特判也没多麻烦,但你想想addAtHead和deleteAtIndex(0)同时存在的情况:如果不用dummy,addAtHead要更新head成员变量;deleteAtIndex(0)也要更新head成员变量。这两处逻辑散落在不同方法里,很容易改一处漏一处。有了dummy之后,head指针本身不参与任何操作,所有方法统一通过dummy操作,逻辑一致性大幅提升。

另外,类内部一定要维护一个size成员变量。add操作后size++,delete操作后size--。这样get和deleteAtIndex的合法性判断只需要跟size比较,不需要临时遍历链表统计长度,时间复杂度从O(n)降到O(1)。很多人忽略这个细节,每次get时都遍历一遍,结果deleteAtIndex里又遍历了一遍,整体效率差很多。

3.4 完整实现与易错点梳理

下面给出一个完整的C++实现,重点看addAtIndex的实现方式:

class MyLinkedList { private: struct ListNode { int val; ListNode* next; ListNode(int val) : val(val), next(nullptr) {} }; ListNode* dummy; int size; public: MyLinkedList() { dummy = new ListNode(0); size = 0; } int get(int index) { if (index < 0 || index >= size) return -1; ListNode* cur = dummy->next; while (index--) { cur = cur->next; } return cur->val; } void addAtHead(int val) { ListNode* node = new ListNode(val); node->next = dummy->next; dummy->next = node; size++; } void addAtTail(int val) { ListNode* cur = dummy; while (cur->next != nullptr) { cur = cur->next; } cur->next = new ListNode(val); size++; } void addAtIndex(int index, int val) { if (index < 0 || index > size) return; ListNode* cur = dummy; while (index--) { cur = cur->next; } ListNode* node = new ListNode(val); node->next = cur->next; cur->next = node; size++; } void deleteAtIndex(int index) { if (index < 0 || index >= size) return; ListNode* cur = dummy; while (index--) { cur = cur->next; } ListNode* tmp = cur->next; cur->next = cur->next->next; delete tmp; size--; } };

几个容易出错的点我单独拎出来说。第一,addAtIndex里循环是while(index--),从dummy开始走index步,这样循环结束后cur正好是第index个节点的前驱——这个细节是整道题的核心,我用一个具体例子验证:假设链表是5->6->7,size=3,在index=1处插入4,预期结果是5->4->6->7。从dummy出发,走1步到5这个节点,这时在5后面插入4,结果就对了。

第二,deleteAtIndex删除后要根据删掉的节点位置判断是否需要移动cur。这里因为我们已经通过循环到达了目标位置的前驱,删除后不需要再移动,直接break结束即可。

第三,addAtIndex中当index等于size时,从dummy出发走size步会走到原链表最后一个节点,在它后面插入就是尾插,天然满足条件。当index等于0时,走0步就是dummy本身,在其后面插入就是头插。这也验证了统一逻辑的好处——不需要写任何分支。

我把五个操作的边界情况整理成了一个速查表,刷题时对着看就不会懵:

操作index条件从哪个节点出发循环步数最终效果
get0 ≤ index < sizedummy->nextindex返回目标节点值
addAtHead无条件dummy0在头节点前插入
addAtTail无条件dummy遍历到末尾在末尾插入
addAtIndex0 ≤ index ≤ sizedummyindex在第index个节点前插入
deleteAtIndex0 ≤ index < sizedummyindex删除第index个节点

4. 206题:反转链表的两种核心思路与多种写法

4.1 迭代法:三指针是怎么想出来的

206题要求反转一个单链表。有的人觉得这题很难,有的人觉得很简单,差距就在于有没有理解三指针迭代法的“接力”逻辑。想象你手里拿着一串链条,你要把它掉个头,最自然的做法是一个环节一个环节地松挂钩、掉头、再挂上。代码里就是这个过程。

初始时,pre指向null,cur指向head。每轮循环做三件事:先用nxt保存cur->next,因为马上要改cur->next,不保存就找不到了;然后把cur->next指回pre;最后pre和cur分别向后移动——pre移到cur的位置,cur移到nxt的位置。当cur走到null时,pre就是反转后链表的头节点。

核心的“为什么要先保存nxt”这个问题,是我觉得整道题最关键的一问。我们来看一个具体例子:链表1->2->3,cur在1,pre是null。执行cur->next = pre,1->next变成了null,链表从1这里断成两截,后面的2->3彻底丢失。所以必须在修改cur->next之前,先让nxt = cur->next把2保存下来。这个“先保存后继再改指向”是所有链表重排操作的通用法则。

4.2 递归法:把问题交给更短的链表

递归法理解起来稍微抽象一点,但代码极其优雅。核心思路是:假设链表是1->2->3->4,如果你已经成功把2->3->4这部分反转成了4->3->2,那么现在只需要让2->next指向1,再让1->next指向null,整个链表就反转完成了。

写成代码就是:

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; }

这里最绕的是head->next->next = head这一句。它表达的是“让head的下一个节点的next反过来指向head”,也就是让原本的2->next = 1。很多视频和文章都会画图解释,但我个人的经验是:如果递归版你实在绕不清楚,不要死磕,迭代法完全够用且面试更稳妥。递归版的隐患也很明显:链表很长时会栈溢出。

还有一种头插法值得一提。它创建一个新的dummy节点,遍历原链表,每取一个节点就插入到dummy后面。代码稍微多一点,但思路非常直观:把原链表的节点一个一个摘下来,头插到新链表里,最后新链表的节点顺序自然就反了。

4.3 复杂度对比:为什么反转链表常考

反转链表的三种写法,时间上都是O(n),因为每个节点都要处理一次。空间上有区别:迭代法是O(1),只用pre、cur、nxt三个指针;递归法要O(n)的栈空间,链表长时可能爆栈;头插法也是O(1),但需要额外创建一个dummy节点。

面试官爱考这题,是因为它可以在很短的时间内考察出一个人对指针操作的掌握程度。而且反转链表是所有链表进阶题的地基,比如“反转链表的第m到n个节点”、“K个一组翻转链表”、“判断回文链表”,全部以它为基础。如果你能闭着眼睛写出迭代版并流畅解释每一行代码,说明链表基本功已经过关了。

4.4 完整实现与变体延伸

迭代版完整代码:

ListNode* reverseList(ListNode* head) { ListNode* pre = nullptr; ListNode* cur = head; while (cur != nullptr) { ListNode* nxt = cur->next; // 第一步:保存后继 cur->next = pre; // 第二步:反转指向 pre = cur; // 第三步:pre前移 cur = nxt; // 第四步:cur前移 } return pre; }

头插法版本:

ListNode* reverseList(ListNode* head) { ListNode* dummy = new ListNode(0); ListNode* cur = head; while (cur != nullptr) { ListNode* nxt = cur->next; // 保存后继 cur->next = dummy->next; // 头插 dummy->next = cur; cur = nxt; } ListNode* ans = dummy->next; delete dummy; return ans; }

Python迭代版:

def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]: pre, cur = None, head while cur: nxt = cur.next cur.next = pre pre = cur cur = nxt return pre

要验证代码的正确性,拿1->2->3手动跑一遍:初始pre=null,cur=1。第一轮,nxt=2,1->next=null,pre=1,cur=2。第二轮,nxt=3,2->next=1,pre=2,cur=3。第三轮,nxt=null,3->next=2,pre=3,cur=null。循环结束,返回pre也就是3,链表变成了3->2->1,完美。

5. 刷题中的常见问题与调试技巧实录

5.1 最容易踩的五个坑

我把这三道题做题过程中最容易踩的坑整理成一个表格,每条背后都是我自己或身边朋友真实出过的bug:

坑出现场景原因解决方案
未保存后继直接改next206反转、707插入改掉cur->next后原链表后半段丢失改next前先nxt = cur->next
删除后prev不移动203删除连续重复节点只删了一个,新的节点还是目标值删除时prev原地不动继续检查
从head出发而非dummy出发707的addAtIndex/deleteAtIndex走了size步或index步后位置偏移统一从dummy出发走index步
index边界判断错误707的get/addAtIndex没有理清0 ≤ index ≤ size的差异对照上文的边界速查表检查
递归反转导致栈溢出206递归版处理超长链表递归深度等于链表长度改用迭代法,O(1)空间

5.2 常用调试手段:画图、打印、小规模测试

链表题出bug后,靠眼睛在代码里找通常效率很低,因为问题往往发生在“指针状态的某一个中间时刻”。我的调试流程分三步。第一步,把链表画在纸上,用小方框代表节点,用箭头代表next,然后手动执行一遍代码,看每一步箭头的变化是否符合预期。这个过程相当于在用最朴素的方式模拟CPU。

第二步,写一个printList辅助函数,在每个循环的关键位置打印当前链表状态。这个方法治标很实用,尤其是707题,你可以在addAtIndex和deleteAtIndex前后各打印一次,立刻就能看出“指针是否走到了正确的位置”。我实际刷题时,遇到超过三分钟还定位不出来的bug,就会打印,基本立刻破案。

第三步,准备一组边界测试用例:空链表、只有一个节点的链表、删除头节点、删除连续重复值、在index=size处插入、删除最后一个节点。把这些用例跑一遍,所有边界问题基本都能暴露出来。我见过太多人只拿题目给的示例测一遍就提交,结果$错在边界上,很可惜。

5.3 三道题联动:Day3之后你该练什么

把这三道题吃透之后,你已经掌握了链表题最核心的三个能力:用dummy统一头节点操作、用prev指针处理删除、用多指针完成重排。接下来建议趁热打铁练这些进阶题:19题“删除链表的倒数第N个节点”是203的变体,需要快慢指针和dummy配合;24题“两两交换链表中的节点”是206的变体,本质上是在局部做两次反转;82题“删除排序链表中的重复元素II”是203的加强版,不仅要删重复节点,还要考虑删除后是否需要继续检查;142题“环形链表II”则需要用到双指针技巧。

我个人实际刷题中的体会是:链表题不能贪多,一天吃透三道经典题比泛泛刷十道效果强得多。这三道题做完,如果你能在手边没有参考答案的情况下,把三个题的核心解法各写一遍,并能说出每一步为什么要这么做,就说明你已经形成了自己的链表解题框架。后面再遇到链表题,你大概率会条件反射式地先问自己一句:dummy要不要加?指针在改之前要不要先保存后继?走多少步才能到达目标位置?这三问,一问一个准。

最后分享一个我坚持了很久的小习惯:每天睡前在纸上画一个1->2->3->4的链表,然后默写一遍反转链表迭代版的五行核心逻辑。坚持一周之后,后面再碰到复杂的链表题,你会发现大脑里自动就有了一幅指针接力的动图,写代码的手感完全不一样。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 12:20:26

异步RL架构全拆解:三台可扩容机器如何重构Agent训练循环

最近这套“小米 MiMo-V2.6 全异步 RL”架构在 Agent 训练相关的讨论里反复出现&#xff0c;标题里的“每小时 3 万美元”先抓眼球&#xff0c;但真正值得研究的&#xff0c;是它把传统 RL 里那种“跑一步等一步”的 Agent 训练循环&#xff0c;改成了三组可以单独水平扩容的机器…

作者头像 李华
网站建设 2026/9/26 12:18:54

ADrive开放API拆解:从网盘到文档中台的集成实践

今天朋友圈被字节的 ADrive 刷屏&#xff0c;说实话&#xff0c;网盘产品隔三差五就有动静&#xff0c;但这次我第一反应不是去下载客户端&#xff0c;而是去翻它的开放 API 文档。原因很简单&#xff0c;刷屏的关键词是“文档能力”和“开放 API”——这说明 ADrive 不再只是一…

作者头像 李华
网站建设 2026/9/26 12:18:37

H5 Canvas地图绘制:GeoJSON数据与交互避坑指南

简介&#xff1a;基于HTML5 Canvas与ECharts构建的交互式地图绘制项目&#xff0c;面向具备一定前端基础、希望深入学习地图可视化与GeoJSON数据应用的开发者。项目利用Canvas的路径、填充等API绘制地图基础轮廓&#xff0c;再通过ECharts地图系列加载GeoJSON地理数据&#xff…

作者头像 李华
网站建设 2026/9/26 12:16:30

D30 | 数据标注平台:从 0 搭一个企业级 AI 训练数据生产系统

文章目录 D30 | 数据标注平台:从 0 搭一个企业级 AI 训练数据生产系统 写在前面 一、为什么需要数据标注平台 1.1 数据标注的 3 大痛点 1.2 标注平台的 5 大核心模块 1.3 选型参考 二、标注任务管理:分配 / 进度 / 状态 2.1 任务生命周期 2.2 任务管理实现 2.3 任务分配策略 …

作者头像 李华