这组题我最近又跟着“代码随想录”的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的节点。
2.3 C++和Python参考实现
C++版本需要手动释放被删除的节点:
cpp复制class Solution {
public:
ListNode* removeElements(ListNode* head, int val) {
ListNode* dummy = new ListNode(0, head);
ListNode* cur = dummy;
while (cur->next != nullptr) {
if (cur->next->val == val) {
ListNode* toDelete = cur->next;
cur->next = cur->next->next;
delete toDelete;
} else {
cur = cur->next;
}
}
ListNode* result = dummy->next;
delete dummy;
return result;
}
};
Python版本逻辑完全一样,只是不用管内存释放:
python复制class Solution:
def removeElements(self, head: Optional[ListNode], val: int) -> Optional[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
两个版本都遵守同一条原则:最终返回的是dummy->next,而不是一开始传入的head。因为head可能已经被删掉了,也可能链表经过若干次操作后头节点变了。网上很多人写这道题报错,十有八九是最后return了head,导致删了头节点后返回的还是一个残留的旧指针。
2.4 这道题刷完,你需要记住的四条删除铁律
第一,链表删除,本质上删的是“某个节点的后继”。想删谁,就得站在谁的前面。第二,遍历时不要用while (cur != nullptr)再去判断cur的值,而要站在前驱位置判断,写成while (cur->next != nullptr),否则你拿不到前驱,无法完成删除。第三,C++里改完链接关系后,要用临时变量保存待删节点,再调用delete释放,不要改完指针就拍屁股走人,那样会内存泄漏。第四,返回新链表的头时,优先返回dummy->next,不要迷信一开始传进来的head。
最后给你提个醒。LeetCode这类平台不会因为你忘了delete而判错,因为它内部有回收机制,但到了真实C++工程里,你写的每个new都得有对应的delete。我自己以前刷题时为了省事不释放dummy,后来在内存池相关的代码里也随手写出了类似问题,排查到深夜才想起来是少了一个释放。所以刷题时养成释放的习惯,不是多此一举。
3. 707设计链表:一次体力活,把所有操作统一成“找前驱”
3.1 需求拆解:五个接口、两个关键成员
这道题像是一道“模拟卷”,要求实现一个MyLinkedList类,支持以下操作:
get(index):获取链表中下标为index的节点的值。如果下标无效,返回-1;addAtHead(val):在链表头部插入一个值为val的节点;addAtTail(val):在链表尾部追加一个节点;addAtIndex(index, val):在下标为index的节点前插入值为val的节点。如果index等于链表长度,则在链表末尾插入;如果index大于链表长度,则不插入;deleteAtIndex(index):删除下标为index的节点。如果下标无效,则不删除。
做设计题时,第一件事是确定数据结构。这里维护两个成员就够了:一个记录链表长度的int size,一个虚拟头节点dummy。为什么不用普通的ListNode* head呢?因为头插和头部删除都需要修改“头指针本身”。如果成员变量是head,addAtHead就必须写head = new ListNode(val, head),删除头节点又得写head = head->next,每次都要小心处理head被改动的情况。用dummy之后,头节点也有了一个稳定的前驱,所有插入、删除都统一变成了“在某个节点的后面插入”或“删除某个节点的后继”。
dummy虚拟头还有一个好处:即使链表为空,dummy也始终存在。你不需要在代码里到处判断head == nullptr,因为空链表只是dummy->next == nullptr。这套用法在工程里也非常普遍,哨兵位能让边界逻辑大幅简化。
3.2 index边界条件:这道题的生死线
不管实现方式怎么变,边界条件永远是第一优先级。做题前先把合法范围钉死:
| 操作 | 合法条件 | 非法处理 |
|---|---|---|
| get(index) | 0 <= index < size | 返回 -1 |
| addAtIndex(index,val) | 0 <= index <= size | index > size 时不操作 |
| deleteAtIndex(index) | 0 <= index < size | index越界时不操作 |
这里最容易被忽略的是addAtIndex中index == size的情况。题目专门说了,如果index等于链表长度,就在链表末尾插入。实现时千万别随手写个index >= size return,那会把尾插功能废掉。
addAtTail其实不用单独找尾节点。直接用addAtIndex(size, val)就行,因为index等于当前链表的长度时,表示要在末尾插入。这个复用让代码的错误面小很多,你不需要再单独写一遍“跑到最后一个节点再插入”的逻辑。
get(index)也要注意,下标从0开始。比如链表里只有一个节点,index=0时返回那个节点的值,不能返回dummy的值。很多人把dummy当成第0个节点,结果所有下标都差了一位。
3.3 找前驱为什么是“走index步”而不是index-1步
很多人在707里卡住,不是因为代码量大,而是搞不明白addAtIndex到底要让cur走到哪。这里的关键是理解虚拟头dummy的逻辑位置。
如果把dummy看成位于“下标-1”的位置,那么:
addAtIndex(0, val):要插在下标0的节点前面,也就是dummy的后面。cur从dummy开始走0步,就已经停在了正确的前驱位置。addAtIndex(2, val):链表是A B C,下标0是A,下标2是C。要在C之前插入,应该让cur停在B这个前驱上。从dummy到B需要走2步。addAtIndex(size, val):要插在末尾。假如size=3,链表是A B C,cur从dummy走3步,正好停在C上,在C后面插入就完成了尾插。
所以找前驱的循环统一是:
cpp复制ListNode* cur = dummy;
for (int i = 0; i < index; i++) {
cur = cur->next;
}
这里的index走几遍,取决于你要的操作是get还是增删。get要拿第index个节点,可以让cur从dummy往前走index+1步,但我更推荐先封装一个findPrev(int index)函数,专门返回“第index个节点”的前驱。这样get操作就变成先找前驱,再取prev->next->val,增删操作也都在同一个函数上建立。整份代码只维护一套边界逻辑,后面不容易改乱。
3.4 C++完整参考实现
我习惯用C++把完整实现写出来,先看代码:
cpp复制class MyLinkedList {
private:
struct Node {
int val;
Node* next;
Node(int v) : val(v), next(nullptr) {}
};
Node* dummy;
int size;
Node* findPrev(int index) {
Node* cur = dummy;
for (int i = 0; i < index; i++) {
cur = cur->next;
}
return cur;
}
public:
MyLinkedList() {
dummy = new Node(0);
size = 0;
}
int get(int index) {
if (index < 0 || index >= size) return -1;
Node* prev = findPrev(index);
return prev->next->val;
}
void addAtHead(int val) {
Node* newNode = new Node(val);
newNode->next = dummy->next;
dummy->next = newNode;
size++;
}
void addAtTail(int val) {
addAtIndex(size, val);
}
void addAtIndex(int index, int val) {
if (index < 0 || index > size) return;
Node* prev = findPrev(index);
Node* newNode = new Node(val);
newNode->next = prev->next;
prev->next = newNode;
size++;
}
void deleteAtIndex(int index) {
if (index < 0 || index >= size) return;
Node* prev = findPrev(index);
Node* toDelete = prev->next;
prev->next = toDelete->next;
delete toDelete;
size--;
}
};
有几个点专门解释下。构造函数里给dummy分配了内存,析构函数里要记得把整个链表都释放干净,否则C++测试跑多了会内存泄漏。这里的findPrev每次都是从dummy开始走index步,虽然看起来重复劳动,但把问题收敛了。addAtIndex里new出来的节点如果最后没插入成功,应当delete掉,不过刷题场景通常不会过于纠结,工程上要注意。
3.5 Python精简实现
Python版本和C++的核心逻辑完全一样,不涉及内存释放,结构看起来更清爽:
python复制class MyLinkedList:
def __init__(self):
self.dummy = ListNode()
self.size = 0
def _get_prev(self, index: int) -> ListNode:
cur = self.dummy
for _ in range(index):
cur = cur.next
return cur
def get(self, index: int) -> int:
if index < 0 or index >= self.size:
return -1
return self._get_prev(index).next.val
def addAtHead(self, val: int) -> None:
node = ListNode(val)
node.next = self.dummy.next
self.dummy.next = node
self.size += 1
def addAtTail(self, val: int) -> None:
self.addAtIndex(self.size, val)
def addAtIndex(self, index: int, val: int) -> None:
if index < 0 or index > self.size:
return
prev = self._get_prev(index)
node = ListNode(val)
node.next = prev.next
prev.next = node
self.size += 1
def deleteAtIndex(self, index: int) -> None:
if index < 0 or index >= self.size:
return
prev = self._get_prev(index)
prev.next = prev.next.next
self.size -= 1
写Python时最容易犯的错是把self.size当成普通变量,直接在里面size += 1,结果Python报错说找不到局部变量。这看起来是Python语法问题,本质还是对类的属性作用域不熟。动手前先把成员变量想明白,比写完再调半天强。
3.6 这道题真正练的是什么
707不考什么高深算法,考的是你对索引、边界、指针变化的管理。你自己实现链表时,感觉最繁琐的不是某个方法单独看有多难,而是它们共享一套数据后,你很容易在A方法里改了结构,忘了同步size,或者在B方法里用错index,导致越界访问。
我自己的体会是,必须有几个“统一约定”:统一用dummy作为起点,统一用findPrev来定位,统一在增删成功后调整size。任何打破约定、单独优化的写法,都会增加后续维护的心理负担。这套思维不光用于刷题,写真实的LRU缓存、双向队列、内存池里的FreeList时,哨兵位和统一边界处理都是同一套底层逻辑。
4. 206反转链表:让每个节点都掉头,把单向箭头全拧回去
4.1 反转的难点在哪里
题目给一个单链表头节点head,要求反转链表,并返回反转后的头节点。比如1 -> 2 -> 3 -> 4 -> 5要变成5 -> 4 -> 3 -> 2 -> 1。
我第一次做的时候天真地想,直接遍历一遍,把每个节点的next指向它前一个节点不就行了?结果第一步就翻车。我想让1的next指向空,这样链表从1这里直接断了,后面的2、3、4、5全找不到了。问题就出在:单链表每个节点只知道自己的后继,当你把当前节点的next改成前驱后,它的原后继就丢了。所以必须有一个临时指针,在改变方向之前先把后继保存下来。
你在任何时候听到“反转链表”,脑海里要立刻映射出一句话:把当前节点的next指向prev,再带着prev和cur一起向前移动。这本质上是三个引用在做接力,顺序一错就崩。
4.2 迭代法:prev、cur、temp三个角色怎么分工
迭代法用一个循环完成整个反转。先定下三个角色:
- prev:已经反转好的那部分链表的头,初始为空;
- cur:当前要处理反转的节点,初始为head;
- nextTemp:cur原来的后继,防止修改next之后丢链。
每轮循环做四件事:
- 用
ListNode* nextTemp = cur->next暂存后继; - 把
cur->next改成prev,让当前节点指向前一个节点; - 把prev移动成cur;
- 把cur移动成nextTemp。
写成C++是这样:
cpp复制class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode* prev = nullptr;
ListNode* cur = head;
while (cur != nullptr) {
ListNode* nextTemp = cur->next;
cur->next = prev;
prev = cur;
cur = nextTemp;
}
return prev;
}
};
配合一个具体例子演示循环过程。初始prev是null,cur是1。第一轮,先用nextTemp存下2;让1的next指向null;prev变成1;cur变成2。此时链表已经被拆成两段,1指向null,而2带着后续节点还在待处理状态。第二轮,nextTemp存下3;让2的next指向1;prev变成2;cur变成3。以此类推,最后cur变成null,循环结束,prev停留在5的位置。5就是反转后的链表头。
这个过程中有两个非常容易出错的点。一是忘了写nextTemp,导致cur->next被覆盖后找不到原链表剩余部分。二是在循环末尾把cur = nextTemp和prev = cur的顺序写反。一旦先把cur赋给了下一个节点,prev再想去指向cur,就已经拿不到刚处理完的节点了。我建议你在草稿纸上模拟两轮,把每个变量指向的节点标出来,天然就明白为什么必须先移动prev再移动cur。
Python版本只是变量的写法不同:
python复制class Solution:
def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]:
prev = None
cur = head
while cur:
next_temp = cur.next
cur.next = prev
prev = cur
cur = next_temp
return prev
4.3 递归法:代码只有几行,但理解门槛更高
递归法也是高频考点,因为它能帮你深化“函数调用栈”的理解。先看代码:
cpp复制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)解决的是“把以head为头的链表反转,并且返回新链表的头节点”。当处理到当前节点head时,我们先不去想整个链表,而是相信递归调用已经把head->next之后的所有节点反转好了。递归调用返回的newHead,是整个新链表的头。
以1 -> 2 -> 3为例。reverseList(1)会先调用reverseList(2),reverseList(2)会先调用reverseList(3)。3的next是空,所以reverseList(3)直接返回3。回到reverseList(2),此时head是2,head->next是3。head->next->next = head的意思就是让3的next指向2,完成一次掉头。然后head->next = nullptr把2原来的后继断开。这样以2为头的那部分已经变成了3 -> 2。再回到reverseList(1),此时head->next还是2,head->next->next = head让2的next指向1,再把1的next置空,得到3 -> 2 -> 1。
理解这段代码的关键难点在于:head->next在递归返回之后,其实已经被改成了“反转后子链表的尾节点”。因为对于1 -> 2 -> 3,递归调用reverseList(2)之后,2->3的方向被改成了3->2,此时2仍然是1的后继,但2的next已经指向了它原本后面的3。无论子链表怎么反转,1的原始后继节点2依然还是1的next。所以可以通过head->next->next = head让原本在我后面的节点反过来指向我。最后再把head的next置空,消除旧方向的残余。
递归法写起来简洁,但有几件事必须注意。第一,递归函数的退出条件不能漏,如果head为空或head只有单个节点,直接返回head,否则会产生空指针访问。第二,递归调用会占用调用栈,链表特别长时可能栈溢出。生产环境里我一般更偏向迭代写法,但面试官让你写递归时,你也要能对“为什么head->next->next = head”解释得清清楚楚,而不是靠背。
4.4 反转链表和其他题的关联
很多人问,反转链表到底在工程里有什么用?直接反转一个单链表的业务场景确实不多,但它是很多困难题的“局部零件”。如果你后面刷到92题反转链表II,或者25题K个一组翻转链表,会发现核心函数就是这里206的迭代逻辑,只不过需要额外记录区间的前驱和后继。链表里很多结构变化题,本质都是“在正确的位置断开,在正确的位置重连”,206练的就是这个最小可用单元。
再进一步说,双向链表虽然没有这类“反转需求”,但双链表里频繁出现的“摘节点”和“插入节点”操作,和你在单链表里练的prev、next指针腾挪是一模一样的直觉。很多人在LeetCode上看到LRU缓存,第一反应是哈希表好写,但实际上被卡住的部分正是双向链表节点的摘除和插入。206帮我们把“指针交接”变成条件反射后,那类题目会更顺手。
5. 刷完三题再回头看:链表题背后的通用心法
5.1 边界四问:动手前先问完再写
链表题写错,十有八九错在边界。我给自己定了一个“边界四问”清单,每次写完链表代码都会快速过一遍:
| 问题 | 检查方式 |
|---|---|
| 链表为空时,代码会不会访问空指针的next? | 看循环条件是否判空 |
| 链表只有一个节点时,逻辑会不会自相矛盾? | 手动模拟一轮 |
| 操作位置是头节点时,返回的头是否还是原来那个head? | 确认dummy->next |
| 操作位置是尾节点或越过末尾时,循环会不会多走一步? | 对比index与size |
比如在707里,get(0)在链表为空时不合法,要提前返回-1;而addAtIndex(0, val)在链表为空时是合法的,因为index不能大于size。这两条看似简单,但如果你没做边界四问,很容易在实现某个方法时漏判。
5.2 链表遍历的两种站位:什么时候看自己,什么时候看后继
链表题里经常出现两个长得差不多的遍历条件:while (cur != nullptr)和while (cur->next != nullptr)。它们的用途完全不同。
while (cur != nullptr)适合“只是查看当前节点”的场景。比如统计节点个数、打印链表内容、查找某个值是否存在。你想访问当前节点,那就让cur从头走到尾,直到cur为空。
while (cur->next != nullptr)适合“需要站在前驱位置修改后继”的场景。比如删除某个节点、在指定位置插入节点。这时候如果让cur走到空,你会错过最后一个真正需要判断的节点,因为删除循环里你判断的是cur->next,当cur已经到最后一个节点时,它的next是空,条件直接退出,最后一个节点根本没检查过。所以删除节点时,循环终止条件应当是cur->next不为空,而内部判断目标值的时候,看的也是cur->next的值。
做203时很容易写错成while (cur != nullptr)然后判断cur->val,结果发现找不着怎么删。这不是代码不熟练,而是“删除操作天然依赖前驱”的这个认知没建立牢固。707里的findPrev函数也同理,它返回的是前驱而不是目标节点,因为你后续的插入和删除都改的是前驱的next。
5.3 链表在实际系统里的常见落脚点
热搜里很多人问,队列、栈、链表这些数据结构到底应用在什么场景。其实链表在真实系统里从来不“单独出现”,它总是作为一块积木嵌在更大的结构里。
栈可以用链表实现,叫链栈。入栈时在头部插入节点,出栈时删除头部节点,都是O(1)操作。如果栈的最大深度不确定,用链表比用固定数组更灵活,因为数组扩容涉及搬运数据,链栈只需要new节点。
队列用链表实现也很自然。入队时在尾部追加节点,出队时删除头部节点。如果你给链表加虚拟头和尾指针,队首队尾都能做到O(1)操作,而数组队列还需要考虑循环数组的容量问题。当然数组队列在缓存局部性上通常更好,实际取舍要看场景。
缓存系统里最经典的LRU算法,用的是“哈希表+双向链表”。双向链表的作用是维护每个缓存项的访问顺序,每次访问一个节点,就把它摘下来放到链表头部;容量满了就删除链表尾部的节点。这里的摘节点和插头部,正是707里的deleteAtIndex和addAtHead,只是双向链表的摘节点比单链表简单些,因为每个节点都知道自己的前驱。
内存分配器里还有一种常见的FreeList,将被释放的内存块串成一个单链表,分配时从链表头取一个块,释放时把块插回链表头部。很多嵌入式系统、操作系统内核里都在用这套结构。那种“在头部快速插入删除”的需求,就是链表最擅长的事。
图论里的邻接表,也是用链表或动态数组存储一个顶点的所有邻居。这些问题表面上看都不像“反转链表”,但它们的调试体验极其相似:空指针、忘更新末尾、尺寸不同步。所以练好链表基础,受益的不只是应付面试题,更是为后面看底层代码做铺垫。
6. 调试链表代码的排查技巧:我在现场踩过的坑
6.1 编译能过,运行却直接崩溃
链表的崩溃原因往往集中在空指针访问。C++里最常见的报错是Segmentation fault,Python里则是AttributeError: 'NoneType' object has no attribute 'next'。出现这种问题,我一般按下面几步快速定位:
- 检查循环条件里有没有判空。比如遍历时写了
while (cur->next),却没有保证cur本身不为空,一旦cur已经走到最后一个节点,cur->next就是空指针访问吗?其实cur->next在cur为空时才会崩,所以要确认cur不会变成空。 - 检查对head的处理。很多崩溃发生在
head本身是空指针时,比如题目给了一个空链表,然后你直接写head->next,必然崩。 - 最实用的一招:在关键步骤打印当前节点指针和它的next。链表问题靠肉眼在代码里找很难,因为你脑子里模拟的状态往往和程序不一致,print出来一看就知道循环在哪一步发生了异常跳转。
我曾经在写206反转链表时,把cur = cur->next和cur = nextTemp写混了,结果链表越指越乱,看起来是死循环,其实是临时指针没有正确接管后继。当时用了最笨的办法,在循环里每隔一轮打印prev、cur、nextTemp三个指针的值,立刻看到第二轮就丢失了2节点。
6.2 输出结果少一个节点或多一个节点
链表题里还有一种典型现象:程序不崩,但结果长度不对。这多半是删除或插入时没有处理好链表长度标记。
707里最常见的坑就是size更新不一致。addAtIndex成功插入后忘了给size加一,或者在deleteAtIndex时减了两次,后面get就全错位。如果你发现get(index)的结果和真正链表位置对不上,先别怀疑代码逻辑,直接打印size和链表长度。用错误size去套正确逻辑,结果永远不对。
还有一种情况是头节点更新错误。比如203中,如果你最后返回的是传入的head而不是dummy->next,当原head被删除后,返回的head仍然指向一个已经被释放的节点。C++里这种问题不一定马上崩,因为内存可能还没被重新使用,但结果肯定是错的。在本地调试时,我给自己写了一个辅助函数printList(ListNode* head),每步操作后都调用一遍,能快速看到链表从头到尾的节点序列。确认结果序列和预期差了哪些节点,再去反推是哪一步连错了。
6.3 内存泄漏和悬垂指针:C++特有的坑
C++选手做链表题,除了逻辑问题,还要关注内存的释放。203中删除节点后应该delete,707中deleteAtIndex后也要delete。如果只改指针不释放,测试循环几千次后内存会涨得很厉害。反过来,如果你delete了一个节点,但还有指针指向它,这个指针就成了悬垂指针,后续再使用会引发未定义行为。
我在刷707时写过一段代码,先delete了toDelete,然后又想打印toDelete->val做验证。这一步在本地居然没崩,但这是典型悬垂指针,值已经不可信了。后来在调试信息里删掉了这行,才算真正把逻辑理清。工程经验告诉我,内存操作宁可多写几行保护,也不要依赖“这玩意看起来还能跑”的侥幸。
LeetCode平台一般不会因为你忘记delete而判WA或TLE,但如果你在准备面试,面试官很有可能会追问:这里new出来的节点什么时候释放?如果连基本的RAII意识都没有,会显得C++基本功不扎实。我的建议是:平时刷题就把C++内存习惯培养起来,每个new都对应一个delete,每个被摘下来的节点都明确释放。这样面试时聊到资源管理,你能直接给出实践过的答案。
6.4 一个百试百灵的本地调试小工具
最后分享一个我自己一直在用的习惯。不管是刷题还是本地测试,我都会在链表类里加一个打印函数:
cpp复制void printList(ListNode* head) {
ListNode* cur = head;
while (cur != nullptr) {
cout << cur->val << " -> ";
cur = cur->next;
}
cout << "null" << endl;
}
Python版本就把cout换成print。看起来平平无奇,但配合用例来跑非常有用。707这样的模拟题,我最常做的事情就是每执行一个add或delete操作后打印一次链表,肉眼对比size是否符合预期。链表跟数组最大的不同是,数组打印出来总是直观的序列,链表一旦断链或成环,打印你也能看出来,比如出现循环重复的节点,或者只输出了半截。
这个习惯我从刷LeetCode用到了写嵌入式驱动里的链表组件,本质上都是同一个逻辑:先确认结构对不对,再去抠每一步的指针变换。如果你现在刷链表题总觉得自己debug很慢,强烈建议也写一个printList工具函数。你会发现很多问题不是“不会写”,而是“看不见错在哪一步”。有了打印输出,每一步的走向清清楚楚,你的链表代码会写得越来越有底气。
