这组题我最近又跟着“代码随想录”的Day3计划重新刷了一遍,清理掉了很多以前“会写但说不清”的模糊地带。Day3选的这三道题很经典:203移除链表元素、707设计链表、206反转链表,算是把链表从概念到实操全串起来了。如果你正好也在刷链表题,或者刚学到C++结构体链表、Python单链表,又觉得边界条件、空指针这些老是处理不明白,那这份笔记应该能帮你少走几条弯路。
先说一下这三道题的定位。203是“单链表删除操作”的最小样本,练的是遍历和找前驱;707是“手动实现一个链表类”,练的是对下标、虚拟头节点和哨兵位这些细节的掌控;206则是“反转链表”,练的是指针方向变换。三题连在一起,几乎覆盖了链表题里最高频的基本功:遍历、删除、插入、结构调整。即使以后碰到LRU缓存、LFU缓存、浏览器历史回退之类的工程场景,底层那套指针操作和边界意识,还是这几道题里反复磕的那几样东西。
1. 先从链表基础说起:你踩的坑多半不是题难,而是物理结构没想透
1.1 链表节点的定义方式:C++和Python都得会写
算法题里的链表,本质上只是一堆节点对象通过指针串在一起。每个节点一般存两个信息:当前节点的值val,以及指向下一个节点的指针next。
C++里最常见的定义是结构体:
cpp复制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) {}
};
这个结构体定义了三种构造函数,方便刷题时直接用new ListNode(5)或new ListNode(5, head)创建节点。如果不写构造函数,C++不会自动帮你把next置空,就容易出现野指针问题。很多新手在本地IDE写链表时,忘了给next初始化,直到程序崩溃才反应过来。
Python这边更简洁,本质上就是类加属性:
python复制class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
Python不需要手动管理内存,也不用担心释放节点的问题,但对“引用”的理解要求其实更高。你写cur = cur.next时,改的是局部变量cur指向谁,而不是改链表结构;只有写cur.next = xxx才是真正在改动链表。这个区别是很多Python选手写链表时迷糊的根源。
1.2 链表和数组的思维差异:为什么你会觉得“链条老断”
数组在内存里是一块连续空间,你在C++里arr + i就是第i个元素,天然支持随机访问。链表则完全不同,每个节点散落在内存各处,节点之间靠指针联系。用一个粗暴的类比:数组是电影院连排座位,按座位号入座,你看8排5座在哪直接走就行;链表是一群人排着队,每个人只记住“我后面那个人是谁”,你想找队伍里第8个人,只能从第一个人开始一个一个问过去。
这个差异直接导致两类错误。第一类,把“遍历节点”误当成“修改链表”。比如有人想删除某个节点,直接写一句cur = cur->next,结果只是自己跳到下一个位置去了,链表本身根本没动。第二类,改了一个节点的next之后,忘了先保存后继节点。想想排队场景,如果第3个人转身去牵第1个人的手,原本排在第4的人就跟丢了,你再想找他就找不到了。链表代码里那些“先保存temp、再改next”的操作,本质都是为了不让队伍断掉。
数组里我们经常说“第i个元素”,这个思维放到链表里会吃大亏。链表的“第i个节点”是有代价的,必须从头开始走i步才能拿到。更关键的是,链表里有大量操作需要站在“目标节点的前一个位置”去做,因为单链表的指针只能从前往后走,你只有拿到前驱节点,才能让它指向新的节点。这个“前驱”意识,就是三道题里最核心的基本功。
1.3 单链表的基本操作:增、删、查每件事都做给谁看
链表的增删查其实只有几条规则。查,就是从头遍历,走到目标节点,取值。增,分头插、尾插和中间插。中间插入的关键是让前驱的next先指向新节点,再让新节点的next指向原后继,顺序不能反,否则会先把后续链条弄丢。
删除更特殊。你要删目标节点,必须找到它的前驱。前驱的next改成目标节点的后继,然后把目标节点安全的释放掉。很多人第一反应是“遍历时发现cur->val == val我就把cur删了”,可一旦走到cur,你根本拿不到它前一个节点的指针。所以几乎所有链表的删除题,遍历时都不是看cur自己,而是看cur的next。
用文字表示删除的通用动作就是:
- 找到待删节点的前驱prev;
- 记录待删节点
delNode = prev->next; - 让
prev->next = delNode->next; - 释放delNode占用的内存;
- 更新链表长度或头指针等附属信息。
这三道题里,203和707都是这套动作的变体,206则是把每个节点的next方向整体掉了个头。想清楚链表的“前驱驱动”特性,后面就不会觉得代码是在硬背。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 203移除链表元素:用一个虚拟头节点,把特判全部干掉
2.1 先别急着写代码,想想如果不用虚拟头节点会多难受
题目本身不难理解:给一个链表头节点head和一个整数val,删除链表里所有值等于val的节点。比如1 -> 2 -> 6 -> 3 -> 4 -> 5 -> 6,要把所有6都删掉。
最自然的第一版写法,往往是这样:先处理头节点是val的情况,用一个while循环不停地把head往后挪,直到head的值不等于val;然后再从头开始遍历剩余部分,遇到要删的就改next。也就是说,头节点要去单独写一段逻辑,中间节点又单独写一段逻辑。
问题在哪?头节点没有前驱,你想删头节点,只能让head指针自己后移;而中间节点有前驱,可以靠prev指针操作。这两种场景的处理方式不一样,代码里就必须有一堆if和while分支去处理“我到底在删头部还是删中部”。一旦链表经过若干次删除后原来的头节点没了,新头节点又可能是待删节点,你还要回过去再检查一轮,逻辑很容易漏。
这道题真正想教你的,就是“虚拟头节点”这个思路。我不直接用一个指向第一个有效节点的head指针,而是先new一个节点dummy,让dummy->next = head,然后从dummy开始遍历。这样一来,原来的头节点也变成了“中间节点”,它也有了一个虚拟的前驱。所有删除操作都可以用同一套逻辑处理,不再需要专门的头部特判。
2.2 虚拟头为什么能把代码变干净
在链表中引入一个额外节点,作用是垫在前头,让每一个真实节点都有了一个“前驱”。你遍历时的指针cur站在dummy上,检查的是cur->next的值,删除时也改cur->next。
举个例子说明。链是head -> A -> B,如果A的值等于val,你想删A,标准动作是让head的next指向B。在没有虚拟头时,你要先判断A是不是head,然后再操作。有了虚拟头之后,代码只看dummy->next,删起来永远是一个套路:
cpp复制if (cur->next->val == val) {
ListNode* toDelete = cur->next;
cur->next = cur->next->next;
delete toDelete;
} else {
cur = cur->next;
}
这里要注意一个细节:删除成功后,cur不要移动到下一个节点。因为有可能cur的新后继也等于val,比如链表1 -> 2 -> 2 -> 3,删了第一个2以后,cur的next变成了第二个2,如果不重新检查就漏删了。只有遇到不需要删除的节点时,cur才能向后走一步。
如果只是做“删除节点值等于val的节点”这样的事,何时移动cur是很关键的判断逻辑。不移动的版本能确保当前cur后面不留任何值为val的节点。
