1. 链表习题到底在考什么:从一道遍历题说起
我几乎每年都会看到同一类问题:学生或者刚转行的朋友,数组题写得很顺,一到链表就卡壳。明明逻辑看起来很简单,代码一跑就崩,或者陷入死循环。说真的,链表这种数据结构本身并不难,难的是你脑子里有没有“结点”“指针”“引用”这三样东西的清晰画面。
链表习题在考试和面试里的地位很特殊。它不像动态规划那样考验数学建模能力,也不像图论那样需要大量算法储备,它考的就是一件事:你对“内存中数据到底怎么连起来的”有没有直观感受。我常跟人打比方,数组就像一栋楼的连续房间,门牌号固定,你要找哪一间直接走过去;链表则像一队人牵手站成一排,你只知道队首是谁,想找第5个人,必须从第1个人开始,一个一个数过去。
这个差异直接决定了链表题的核心考点。拿最基础的“链表遍历”来说,很多人觉得这有什么好考的,一个while循环从头走到尾就完事了。但当你真正动手写,问题就来了:循环条件用什么?p != NULL还是p->next != NULL?如果遍历过程中要改链表结构怎么办?如果遍历到一半需要记住前一个结点怎么办?这三个问题,实际上包含了链表题里80%的考点。
一句话总结:链表习题的本质,是考察你能否在“只能看到当前结点和它的下一个结点”这种限制条件下,通过调整指针或引用的指向,完成特定的数据结构操作。所有看似花哨的题目——反转、合并、找环、相交——拆到最底层,都是指针操作的组合。
所以这篇文章我不打算按知识点平铺直叙地讲链表是什么、怎么定义,而是用“习题总结”的视角,把链表题目里最能打、最常考、最容易踩坑的几类题拆开揉碎,每一类都告诉你为什么要这么解、边界条件在哪、代码怎么写才不容易翻车。C++和Python两个版本我都会给,因为语言差异在这类题里体现得特别明显。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 基础操作习题:插入、删除与边界条件的“陷阱”
2.1 单链表插入:先接新结点,再断旧链接
插入操作是链表题的第一道坎。很多人第一次写链表插入,代码长这样:
cpp复制// C++ 版本 —— 错误示例
void insertAfter(Node* prev, int newValue) {
Node* newNode = new Node(newValue);
newNode->next = prev->next;
prev->next = newNode;
}
如果只看这段代码,逻辑是对的。但如果你问自己一句“如果prev是链表最后一个结点呢?”——prev->next是NULL,newNode->next = NULL,没问题,插入到尾部,正确。那如果要在头部插入呢?
cpp复制// C++ 版本 —— 头插法的正确姿势
void insertAtHead(Node*& head, int newValue) {
Node* newNode = new Node(newValue);
newNode->next = head;
head = newNode;
}
注意Node*& head,这里的引用符号很重要。如果写成Node* head,你修改的只是形参副本,函数结束之后head还是原来的值,新结点就“丢了”。这种内存泄漏在C++链表题里非常经典,我见过无数次。
Python版本写起来更直观,因为一切皆对象引用:
python复制# Python 版本 —— 在指定结点后插入
def insert_after(prev, new_value):
new_node = ListNode(new_value)
new_node.next = prev.next
prev.next = new_node
核心口诀就一句:先让新结点指向后一个结点,再让前一个结点指向新结点。顺序绝对不能反。如果先执行了prev->next = newNode,那么原来prev后面的那一整段链表就找不到了,真·丢链。这是链表题里最基础也最容易犯的顺序错误,做题的时候哪怕再熟练,这一步也必须画图确认。
2.2 删除结点:知道前驱,才能“绕过”目标
删除结点的习题比插入稍微绕一点,因为单链表只能往后走,删除结点必须知道“它前面是谁”。这就是为什么删除时如果你的指针还停在当前结点上,是删不掉它的——你永远找不到它前一个结点。
最普通的删除逻辑:
cpp复制// C++ 版本 —— 删除指定值结点(非头结点情况)
void deleteNode(Node* head, int target) {
Node* cur = head;
while (cur->next != NULL) {
if (cur->next->val == target) {
Node* toDelete = cur->next;
cur->next = cur->next->next;
delete toDelete;
return;
}
cur = cur->next;
}
}
注意这里是拿cur->next->val做判断,而不是cur->val。为什么要这样?因为你要删的是cur->next,你得让cur停在目标结点的前一个位置。这是我总结链表题时最想强调的一个思维转变:很多操作不能“看着目标做”,得“看着目标的前一个做”。
Python版本同理:
python复制# Python 版本 —— 删除指定值结点
def delete_node(head, target):
dummy = ListNode(0)
dummy.next = head
cur = dummy
while cur.next:
if cur.next.val == target:
cur.next = cur.next.next
return dummy.next
cur = cur.next
return dummy.next
这里我引入了一个哑结点dummy。为什么要这个?因为如果删除的是头结点,你直接修改head参数在Python里也不会生效——函数内的head只是局部变量。哑结点的作用就是让头结点也“有前驱”,这样整条链的删除逻辑就统一了,不用为头结点单独写分支。
2.3 头结点、尾结点、空链表:三个最容易翻车的位置
链表实操里,边界条件的本质是:“链”在两端会断。我总结过一套自检清单,每次写完链表代码都过一遍:
- 空链表:head是NULL或None,你的循环条件会不会崩?你的函数返回值是什么?
- 单结点链表:删除/反转/插入后,链会不会断?
- 操作头结点:头结点特殊处理了吗?还是用了哑结点统一逻辑?
- 操作尾结点:尾结点的next是NULL,你的判断条件会不会把NULL当成一个正常结点?
我举一个特别容易犯的错:
cpp复制// 错误示范:遍历链表并统计长度
int getLength(Node* head) {
int count = 0;
while (head->next != NULL) { // 如果head本身就是空链表,这里直接崩
count++;
head = head->next;
}
return count;
}
这段代码在空链表上会直接解引用NULL指针。正确写法是while (head != NULL),然后把count++放在循环体里。就这么一个小小的条件差异,很多人在笔试里翻车,不是不懂,是写快了没检查。
我个人的做题习惯是:拿到任何一道链表题,先把边界情况的代码路径在脑子里走一遍,再开始写正文逻辑。这样做虽然刚开始会慢一点,但习惯之后正确率提升非常明显,而且面试官看到你主动处理边界情况,观感会好很多。
3. 反转链表:迭代、递归与高频变体
3.1 迭代反转:三指针法的实现与原理
反转链表几乎是链表习题里的“必修课”,几乎所有面试题库都会收录这道题。它考察的不只是指针操作,还考察你能不能处理好“断链”前后结点的保存问题。
思路是这样的:要把链表方向反过来,你需要三个指针,pre、cur、next。每走一步,先把cur的后一个结点保存到next里,然后让cur指向pre,最后pre和cur都往右移动一步。这个“先保存,再改指,后移动”的顺序,是整道题不出错的关键。
cpp复制// C++ 版本 —— 迭代反转
Node* reverseList(Node* head) {
Node* pre = NULL;
Node* cur = head;
while (cur != NULL) {
Node* next = cur->next; // 先保存下一个结点
cur->next = pre; // 反转指针方向
pre = cur; // pre 右移
cur = next; // cur 右移
}
return pre; // 循环结束时,pre 指向新头结点
}
Python版本几乎一样:
python复制# Python 版本 —— 迭代反转
def reverse_list(head):
pre = None
cur = head
while cur:
next_node = cur.next
cur.next = pre
pre = cur
cur = next_node
return pre
为什么这三步的顺序这么重要?因为单链表是单向的,你一改cur->next,原来的下一个结点就找不到了。所以必须先把它存到临时变量里,这就是next存在的意义。我在讲这道题的时候经常说一句话:“改指向之前,先留后路。”
3.2 递归反转:理解子问题的划分
递归反转链表,很多初学者觉得特别难。但如果你换一种角度思考,它其实很优雅:假设链表有n个结点,调用reverseList(head)。递归的终止条件是head为空或head->next为空。递归进入reverseList(head->next),假设它返回的已经是“后半段反转后的头结点”,那么当前要做的,就是把head接到反转后的链表尾部。
cpp复制// C++ 版本 —— 递归反转
Node* reverseListRecursive(Node* head) {
if (head == NULL || head->next == NULL) {
return head;
}
Node* newHead = reverseListRecursive(head->next);
head->next->next = head; // 让原来的下一个结点指向当前结点
head->next = NULL; // 断开原来的指向
return newHead;
}
关键就两行:head->next->next = head 和 head->next = NULL。前者把当前结点接到链表尾部后面,后者断开当前结点原来的指向。
我在实际讲解时喜欢把递归反转和数学归纳法类比:你先假设“小一号的问题已经解决了”,然后只考虑当前层怎么把局部接上,剩下的交给递归。这也是为什么递归题你不需要在脑子里完整模拟每一层——那样会爆炸,只需要相信子问题已经被解决,然后专心写当前层逻辑。
3.3 高频变体:区间反转与K个一组翻转
反转链表学会了,如果只是默写代码,还远远不够。面试题和笔试题更喜欢出变体。最常见的有两个:
区间反转:反转从位置m到n之间的结点,其余保持不变。这道题的关键是先走m-1步找到反转区域的前驱,然后在这个区域里做反转,最后把三部分接起来。最容易出错的地方是:如果m=1,即从头结点开始反转,那么“前驱”不存在。解决办法又是哑结点——用一个dummy指向head,让dummy充当虚拟前驱,这样处理逻辑就统一了。
K个一组反转:每K个结点反转一次,不足K个保持不变。这道题是区间反转的叠加版。需要一个辅助函数reverseBetween或者直接写一个区间反转函数,然后以K为步长遍历链表,对每个区间调用反转逻辑。实现细节里非常容易漏掉的是:反转完一个区间之后,pre要移动到区间的末尾,cur要移动到下一个区间的开头。如果你直接把pre指向反转后的头结点,链接就会出错。
这两道变体题,我在博客里通常建议读者画图推导,不要直接写代码。画图的规则是:标出pre、start、end、next四个关键位置,然后用箭头表示反转后的关系。等画明白了,代码自然就出来了。
4. 快慢指针与环检测:一类题型的通用解法
4.1 判断链表是否有环:Floyd判圈法的证明与实现
链表是否有环,是链表题里又一个大热门。判断有环的算法叫Floyd判圈法,也叫快慢指针法,有很多听起来很玄的名字,但原理用大白话说就一句话:两个人绕操场跑步,一个快一个慢,如果跑道是环形的,快的人迟早会追上慢的人。如果跑道是直道的,快的人直接跑到终点,永远遇不到慢的。
cpp复制// C++ 版本 —— 判断链表是否有环
bool hasCycle(Node* head) {
Node* slow = head;
Node* fast = head;
while (fast != NULL && fast->next != NULL) {
slow = slow->next; // 慢指针走一步
fast = fast->next->next; // 快指针走两步
if (slow == fast) {
return true;
}
}
return false;
}
这里有一个细节,为什么更新时要用fast->next != NULL做判断?因为快指针每次走两步,如果链表没有环,快指针可能直接走到NULL,此时fast->next就会解引用NULL,直接崩溃。所以条件必须同时检查fast和fast->next都不为NULL。
有些变体题问“如果要求环的入口结点怎么找”。经典结论是先让快慢指针相遇,然后让其中一个指针重新指向头结点,两个指针都每次走一步,再次相遇的位置就是环的入口。这个结论的数学证明需要一点代数,我在这不展开推,但实际应用中非常管用。遇到这类题,先写hasCycle,再在这个基础上加两步,不用慌。
4.2 找链表中间结点与倒数第K个结点:一次遍历的套路
快慢指针不仅能判断环,还能在“一次遍历”的限制下解决两类经典习题。
找中间结点:仍然是快慢指针,快指针走两步,慢指针走一步。当快指针到达链表末尾时,慢指针恰好停在中间。如果链表长度是偶数呢?快指针到NULL时,慢指针停在第n/2个结点上;如果长度是奇数,快指针停在最后一个结点,慢指针停在正中间。这个细节在题目里通常会有明确要求“如果是偶数个结点,返回两个中间结点的前一个/后一个”,做题时需要留意题目表述。
python复制# Python 版本 —— 找链表中间结点
def find_middle(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
return slow
找倒数第K个结点:这个用前后双指针做。第一个指针先走K步,然后第二个指针从头开始,两个指针一起走。当第一个指针到达NULL时,第二个指针正好指向倒数第K个结点。这个套路简直万能——很多“倒数”类的链表题,本质上都是靠这种“让指针错开固定步数”的方法解决。
4.3 两个链表相交问题:长度差转化为步数差
除了环和单链表内部的位置查找,快慢/双指针还有一种应用:判断两个链表是否相交,并找到相交的起始结点。
最朴素的思路是:先分别遍历两个链表,求出长度。然后让长的链表先走长度差步,两个链表再一起走,第一个相同的结点就是交点。
cpp复制// C++ 版本 —— 找两链表交点
Node* getIntersectionNode(Node* headA, Node* headB) {
int lenA = getLength(headA);
int lenB = getLength(headB);
Node* curA = headA;
Node* curB = headB;
if (lenA > lenB) {
for (int i = 0; i < lenA - lenB; i++) curA = curA->next;
} else {
for (int i = 0; i < lenB - lenA; i++) curB = curB->next;
}
while (curA != curB) {
curA = curA->next;
curB = curB->next;
}
return curA; // 无交点时最终为 NULL
}
这道题有一个更简洁的写法:两个指针同时遍历,把自己那条链走完后跳到另一条链的头部继续走。只要两条链相交,它们一定会在某个位置相等。这个解法的好处是不需要先求长度,但理解起来略微烧脑。我通常会建议初学者先用求长度的方法写,运行通过之后再去理解“双指针互相交换跑道”的解法。
5. C++与Python的链表实现差异
5.1 C++结构体链表基本语法与内存管理
C++里链表的标准定义方式是用结构体:
cpp复制#include <iostream>
struct Node {
int val;
Node* next;
Node(int x) : val(x), next(NULL) {}
};
构造函数Node(int x)在创建结点时自动把val初始化为x,next初始化为NULL,这是一个非常好的实践。如果你不在构造函数里初始化,next就是一个未定义值的野指针,后面任何next判断都可能出问题。
C++链表习题最让人头疼的不是逻辑,而是内存管理。你手动new出来的结点,必须手动delete,否则就会内存泄漏。在练习/笔试场景下,内存泄漏可能看不出来什么影响,但在实际工程项目或大工作量级题目里,泄漏多了程序会越来越慢甚至崩溃。
一个常见的C++链表删除技巧:
cpp复制// 删除整个链表
void deleteList(Node*& head) {
Node* cur = head;
while (cur != NULL) {
Node* next = cur->next;
delete cur;
cur = next;
}
head = NULL;
}
注意要在delete之前先保存next,否则删除当前结点之后你就找不到下一个结点在哪里了。这个思路和反转链表里“改指向前先保存下一个”一模一样,链表题里这种“先保存再操作”的模式出现频率非常高,值得形成肌肉记忆。
5.2 Python的类实现与引用语义
Python里链表的定义用类:
python复制class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
对于从C++转Python的人来说,最大的不适应是Python的引用语义。在Python里,head这个变量本身是一个指向对象的引用,如果你在函数内重新给head赋值,函数外不受影响。但如果你修改的是head.next、head.val,函数外会看到变化。
这个特性在实际刷题时会产生很多细微的坑。比如上面提到的插入头结点:
python复制# 错误写法:函数外 head 不变
def insert_at_head(head, val):
new_node = ListNode(val)
new_node.next = head
head = new_node # 只是改变了局部变量的指向
# 正确写法:返回新的头结点
def insert_at_head(head, val):
new_node = ListNode(val)
new_node.next = head
return new_node
所以Python链表题里,凡是可能改动头结点的函数,几乎都需要返回新的头结点,或者用哑结点把“头结点的变化”吸收掉。做题时先想清楚“这个操作会不会改变head指向的对象”,如果会,要么返回值,要么用dummy。
Python还有一个优势:没有内存释放的负担。你不需要delete一个结点,Python的垃圾回收会自动处理没有任何引用的对象。这也意味着Python链表题写起来比C++省心不少,但相应地,你对“谁在引用这个对象”要有更清晰的意识,否则改着改着链表就串了。
5.3 语言差异在习题中的体现
以我个人的刷题经验来说,同样的链表算法题,C++和Python的代码长度能差30%到50%。C++的代码更啰嗦,但每一步都更显式;Python的代码更短,但对“引用传递”的理解要求更高。
举个例子,合并两个有序链表这道题,C++版本:
cpp复制Node* mergeTwoLists(Node* l1, Node* l2) {
if (l1 == NULL) return l2;
if (l2 == NULL) return l1;
if (l1->val < l2->val) {
l1->next = mergeTwoLists(l1->next, l2);
return l1;
} else {
l2->next = mergeTwoLists(l1, l2->next);
return l2;
}
}
Python版本:
python复制def merge_two_lists(l1, l2):
if not l1:
return l2
if not l2:
return l1
if l1.val < l2.val:
l1.next = merge_two_lists(l1.next, l2)
return l1
else:
l2.next = merge_two_lists(l1, l2.next)
return l2
逻辑完全一样,对比着看非常清晰。递归在链表合并里特别合适,因为“合并两个链表”天然可以拆成“比较当前头结点 + 合并剩余部分”两个子问题。这道题也是递归思想从理论走到实操的样板题,强烈建议两种语言都写一遍,体会一下差异。
6. 刷题踩坑实录:实战中的细节教训
6.1 死循环与链表乱串:两个经典失败案例
链表题的bug,最常见的有两类:死循环和链表乱串。
死循环的经典场景是反转链表里忘记移动某个指针。比如有人写出如下代码:
cpp复制// 错误示例:忘记移动pre指针
Node* reverseList(Node* head) {
Node* pre = NULL;
Node* cur = head;
while (cur != NULL) {
Node* next = cur->next;
cur->next = pre;
// 漏了 pre = cur;
cur = next;
}
return pre;
}
这段代码跑起来,pre永远是NULL,cur走完整个链表返回NULL,结果就是一个空链表,但代码本身不会崩。这类bug的隐蔽之处在于:它不报错,只是结果错,而且逻辑看起来“很像对的”。
链表乱串的经典场景是:修改了某个结点的next指向,但这个结点同时被另一个变量引用,导致两个链表共享了一段结点。比如合并两个有序链表时,如果你不小心让某个结点的next同时接入了两条链,后续遍历就会无限循环或者访问到错误数据。遇到这种问题,调试时最好的办法就是打印:每走一步,把当前结点的地址和值打出来,对照着看不同分支的链接关系。
6.2 先画图再写代码:复杂指针操作的通用方法论
我教了很多初学者链表题之后,发现一个问题:大家不是不懂算法思路,而是写代码的时候,脑中的指针关系是模糊的。一模糊,代码就容易放飞。
所以我给所有人的建议都是:先在纸上画图。
画图的方法论其实很简单。比如区间反转这道题,你画一个完整的链表,标出m位置和n位置,然后用不同颜色的笔标出需要改动的4条边。画完了你会发现,“先让前驱指向反转区间的头”“让反转区间的尾指向后驱”这些操作描述得非常清楚,代码写起来就是照着图翻译。
画图还有一个好处:可以快速验证边界情况。把m=1和m=n这两种情况画出来,你会发现dummy结点能解决前一个情况,后一种情况根本没有实际反转发生。有了图,你的自检效率会高很多。
6.3 链表题自测用例清单
做题别只顾着提交,交之前先跑一遍自己的测试用例。这是我个人百试百灵的一套链表题自测清单:
- 空链表:head = NULL / None
- 单结点链表:链表只有一个结点时,操作后是否还正确
- 双结点链表:涉及pre/cur/next三个指针的操作,双结点才能完整跑通
- 头结点操作:删除头、反转头、插入头,有没有额外处理
- 尾结点操作:尾结点的next是否正确置为NULL/None
- 普通中间情况:3~5个结点的链表,保证逻辑正确
- 特殊值:结点值重复时,删除/查找逻辑是否受值的影响
这套清单我几乎每道链表题都会过一遍,虽然看起来繁琐,但省下来的调试时间远大于执行时间。链表题最容易出现的错误往往不是“复杂逻辑没搞懂”,而是“基础位置没处理好”。用一套固定清单去扫边界条件,相当于给代码做了一遍快速体检,非常值得养成习惯。
链表这类题的练习,最终会让你形成一种很值钱的直觉:拿到题目先想清楚“它的边界在哪”,再动手写代码。等你练到能在一分钟内判断一道链表题需要哑结点还是不需要、需要双指针还是单指针,你就真正过关了。
