链表(Linked List)是数据结构中最基础、最高频,也是最考验指针操作功底的题型。它不像数组那样在内存中连续存储,而是通过指针将零散的内存块串联起来。
链表的本质与优缺点
1. 物理结构:
链表由一个个"节点(Node)"组成,每个节点包含两部分:
-
数据域(val):存储数据。
-
指针域(next) :存储下一个节点的内存地址。
在 C++ 中通常定义为:
cppstruct ListNode { int val; ListNode *next; ListNode() : val(0), next(nullptr) {} ListNode(int x) : val(x), next(nullptr) {} };2. 与数组(Array)的核心对比:
-
优点 :增删极快。只要知道前驱节点,插入或删除节点只需要改变指针指向,时间复杂度 O(1)。且不需要连续内存,内存利用率高。
-
缺点 :查询极慢。不能像数组那样通过下标 O(1) 随机访问。要访问第 K 个节点,必须从头遍历,时间复杂度 O(K)。
-
算法题的启示 :正因为链表"查慢增删快",链表题的考点几乎全部集中在**"如何在不破坏链表结构的前提下,高效地遍历和重新连接指针"**。
题目一:两数相加
cpp
/**
* Definition for singly-linked list.
* 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) {}
* };
*/
class Solution {
public:
ListNode* addTwoNumbers(ListNode* l1, ListNode* l2) {
ListNode* cur1 = l1;
ListNode* cur2 = l2;
ListNode* newhead = new ListNode(0);
ListNode* prev = newhead;
int t = 0;
while (cur1 || cur2 || t) {
if (cur1 != nullptr) {
t += cur1->val;
cur1 = cur1->next;
}
if (cur2 != nullptr) {
t += cur2->val;
cur2 = cur2->next;
}
prev->next = new ListNode(t % 10);
prev = prev->next;
t /= 10;
}
prev = newhead->next;
delete newhead;
return prev;
}
};
算法思想:模拟竖式加法 + 遍历链表
这道题本质上是小学数学的加法:从最低位开始相加,逢十进一。因为链表是逆序存储的(个位在头节点),正好符合我们从低位到高位的计算顺序。我们只需遍历两个链表,逐位相加并处理进位即可。
逐行代码逻辑
-
ListNode* cur1 = l1; ListNode* cur2 = l2;:分别指向两个链表的头部,用于遍历。 -
ListNode* newhead = new ListNode(0);:创建一个虚拟头节点,值为0。 -
ListNode* prev = newhead;:prev作为尾指针,始终指向新链表的最后一个节点。 -
int t = 0;:t代表当前位的和以及进位。 -
while(cur1 || cur2 || t):循环条件很关键,只要两个链表还有节点,或者进位t不为0,就继续循环。 -
t += cur1->val;:累加l1当前节点的值。 -
t += cur2->val;:累加l2当前节点的值。 -
prev->next = new ListNode(t % 10);:取t的个位数,创建新节点,并接到结果链表末尾。 -
prev = prev->next;:尾指针后移。 -
t /= 10;:t除以10,得到进位,用于下一轮循环。 -
prev = newhead->next; delete newhead; return prev;:获取真正的头节点,释放虚拟头节点,返回结果。
题目二:两两交换链表中的节点
cpp
/**
* Definition for singly-linked list.
* 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) {}
* };
*/
class Solution {
public:
ListNode* swapPairs(ListNode* head)
{
if(head == nullptr || head->next == nullptr) return head;
ListNode* cur1 = head;
ListNode* cur2 = head->next;
ListNode* newHead = new ListNode(0);
newHead->next = head;
ListNode* prev = newHead;
while(cur1&&cur2)
{
prev->next=cur2;
cur1->next=cur2->next;
cur2->next=cur1;
prev=cur1;
cur1=cur1->next;
if(cur1)
{
cur2 = cur1->next;
} else
{
cur2 = nullptr;
}
}
ListNode* res = newHead->next;
delete newHead;
return res;
}
};
算法思想:指针交换 + 虚拟头节点
我们需要遍历链表,每次处理两个相邻节点。核心操作是改变三个指针的指向:上一组的尾节点、当前组的第一个节点、当前组的第二个节点。由于头节点也可能被交换,使用虚拟头节点可以统一处理。
逐行代码逻辑
-
if(head == nullptr || head->next == nullptr) return head;:处理边界情况。空链表或只有一个节点,无需交换,直接返回。 -
ListNode* cur1 = head; ListNode* cur2 = head->next;:初始化当前要处理的两个节点。 -
ListNode* newHead = new ListNode(0); newHead->next = head;:创建虚拟头节点,并指向原头节点。 -
while(cur2):循环条件。当cur2存在时,说明至少有两个节点可以交换。 -
cur1->next = cur2->next;:第1步 ,将cur1指向cur2的下一个节点(即让cur1指向下一组的开头)。 -
cur2->next = cur1;:第2步 ,将cur2指向cur1,完成交换。 -
cur1 = cur1->next; cur2 = cur1->next;:第3步,更新指针,准备处理下一组。
题目三:重排链表
cpp
/**
* Definition for singly-linked list.
* 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) {}
* };
*/
class Solution {
public:
void reorderList(ListNode* head)
{
if (head == nullptr || head->next == nullptr ||
head->next->next == nullptr)
return;
}
ListNode *slow = head, *fast = head;
while(fast && fast->next)
{
slow = slow->next;
fast = fast->next->next;
}
ListNode* head2 = new ListNode(0);
ListNode* cur=slow->next;
slow->next=nullptr;
while(cur)
{
ListNode* next = cur->next;
cur->next=head2->next;
head2->next=cur;
cur=next;
}
ListNode* ret = new ListNode(0);
ListNode* prev = ret;
ListNode* cur1 = head, *cur2 = head2->next;
while(cur1)
{
prev->next=cur1;
cur1=cur1->next;
prev=prev->next;
if(cur2)
{
prev->next=cur2;
prev=prev->next;
cur2=cur2->next;
}
}
delete head2;
return ret;
};
算法思想:快慢指针找中点 + 头插法反转后半段 + 合并两个链表
这是一道综合性很强的题目,分为三个清晰的步骤:
-
用快慢指针找到链表中点。
-
将中点后的后半部分链表用头插法进行反转。
-
将前半部分与反转后的后半部分交替合并。
逐行代码逻辑
-
if (head == nullptr || head->next == nullptr || head->next->next == nullptr) return;:处理边界。节点数少于3时,重排后顺序不变。 -
ListNode *slow = head, *fast = head;:初始化快慢指针。 -
while (fast && fast->next):快指针每次走两步,慢指针每次走一步。循环结束时,slow位于中间节点。 -
ListNode* head2 = new ListNode(0);:为反转后半段创建虚拟头节点。 -
ListNode* cur = slow->next;:cur指向后半段的第一个节点。 -
slow->next = nullptr;:关键步骤!断开前后两半,使前一半成为独立链表。 -
while(cur) { ... }:头插法反转链表 。next = cur->next;(保存下一个);cur->next = head2->next;(指向新头部);head2->next = cur;(新头接管);cur = next;(后移)。 -
ListNode* ret = new ListNode(0); ListNode* prev = ret;:创建最终结果链表的虚拟头节点和尾指针。 -
ListNode* cur1 = head, *cur2 = head2->next;:分别指向前半段和反转后的后半段头部。 -
while(cur1):循环合并。因为前半段长度一定大于等于后半段,所以以cur1为主循环条件。 -
prev->next = cur1; cur1 = cur1->next; prev = prev->next;:先放第一个链表的节点。 -
if(cur2) { prev->next = cur2; cur2 = cur2->next; prev = prev->next; }:再放第二个链表的节点(如果有的话)。 -
结尾:释放虚拟头节点
delete ret;,返回ret->next。
题目四:合并K个升序链表
cpp
/**
* Definition for singly-linked list.
* 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) {}
* };
*/
class Solution {
struct cmp {
bool operator()(const ListNode* l1, const ListNode* l2) {
return l1->val > l2->val;
}
};
public:
ListNode* mergeKLists(vector<ListNode*>& lists) {
priority_queue<ListNode*, vector<ListNode*>, cmp> heap;
for (auto l : lists) {
if (l)
heap.push(l);
}
ListNode* ret = new ListNode(0);
ListNode* prev = ret;
while (!heap.empty()) {
ListNode* t = heap.top();
heap.pop();
prev->next = t;
prev = t;
if (t->next)
heap.push(t->next);
}
prev = ret->next;
delete ret;
return prev;
}
};
算法思想:优先队列(最小堆)
面对 K 个有序链表,每次只需要从 K 个链表的"当前头部"中选出最小的那一个,接到结果链表上。这个过程正好可以用"最小堆"来实现,堆顶永远是当前的最小值。
逐行代码逻辑
-
struct cmp { bool operator()(const ListNode* l1, const ListNode* l2) { return l1->val > l2->val; } };:自定义比较器。在优先队列中,返回true表示l1优先级低于l2。这里用>使得值小的优先级高,从而构造小根堆。 -
priority_queue<ListNode*, vector<ListNode*>, cmp> heap;:声明一个存放ListNode*的小根堆。 -
for(auto l : lists) { if(l) heap.push(l); }:将所有链表的头节点放入堆中。注意 :必须判断if(l),因为lists中可能包含空链表。 -
ListNode* ret = new ListNode(0); ListNode* prev = ret;:创建虚拟头节点和尾指针。 -
while(!heap.empty()):只要堆非空,就继续合并。 -
ListNode* t = heap.top(); heap.pop();:取出堆顶节点(全局最小值),并从堆中移除。 -
prev->next = t; prev = t;:将取出的节点连接到结果链表末尾。 -
if(t->next) heap.push(t->next);:关键步骤!如果取出的节点后面还有节点,必须将它的下一个节点入堆,参与后续的比较。 -
结尾:释放虚拟头节点
delete ret;,返回ret->next。
题目五:K个一组反转链表
cpp
/**
* Definition for singly-linked list.
* 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) {}
* };
*/
class Solution {
public:
ListNode* reverseKGroup(ListNode* head, int k)
{
// 1. 计算链表的总长度
int n = 0;
ListNode* cur = head;
while(cur)
{
cur = cur->next;
n++;
}
n /= k; // 计算出一共需要反转多少组
// 2. 初始化指针
ListNode* newHead = new ListNode(0); // 总虚拟头节点,用于统一处理头部
ListNode* prev = newHead; // 核心指针 始终指向上一组反转后的尾节点
cur = head; // 重新指向原链表头部,准备开始反转
// 3. 外层循环:固定循环 n 次(n 是总组数)
for(int i = 0; i < n; i++)
{
ListNode* tmp = cur; // 【极其关键】记录这一组反转前的第一个节点
// 4. 内层循环:对每一组进行长度为 k 的头插法反转
for(int j = 0; j < k; j++)
{
ListNode* next = cur->next; // 保命步:先存下一个节点,防断链
cur->next = prev->next; // 断旧连新:当前节点指向 prev 后面的节点
prev->next = cur; // 新头接管:prev 接管当前节点
cur = next; // 指针推进:cur 后移
}
// 5. 一组反转完毕后的收尾工作
prev = tmp; // 精髓所在 将 prev 更新为这一组原来的头节点(即现在的尾节点)
}
// 6. 拼接不需要反转的剩余部分
prev->next = cur;
// 7. 返回最终结果并释放内存
cur = newHead->next;
delete newHead;
return cur;
}
};
算法思想:分组头插法 + 宏观指针拼接
这道题的核心难点在于:如何把反转好的局部链表,重新天衣无缝地拼回原链表中?
-
先遍历一遍 ,算出链表总长度
n。 -
计算需要反转的组数 :
n / k。这就意味着剩下的n % k个节点完全不需要处理,直接留在原地即可。外层for循环次数固定,不需要在循环内部去判断"剩余节点够不够 K 个"。 -
外层循环控制组数,内层循环执行"头插法反转" 。每次反转 K 个节点,并且利用一个
prev指针记录上一组的尾部,用于承接下一组。
这五道题几乎覆盖了链表操作的所有核心场景:遍历、增删、反转、合并、分组。
核心重难点(算法思想层面)
1. 虚拟头节点------ 链表的"万能钥匙"
在这五道题中,几乎每一题都用到了虚拟头节点(两数相加、两两交换、重排链表、合并K个、K个一组翻转)。
-
作用:将"头节点"的特殊情况普通化。如果没有它,你需要在每个操作前判断"我是不是在操作第一个节点",代码会变得极其臃肿。
-
重难点 :虚拟头节点不仅用于"构建结果链表",还用于"作为前置节点辅助翻转"(如K个一组中的
prev)。 -
口诀:只要涉及头节点可能改变的链表操作,先建 虚拟头节点。
2. 指针的"保存与推进" ------ 链表的"命脉"
链表不像数组可以通过下标随机访问,一旦指针断开,后续节点就永远丢失了。
-
重难点 :在改变任何一个节点的
next指向之前,必须先保存它的原始后继节点。 -
实战体现:
-
反转链表中的
next = cur->next;(先存后断)。 -
K个一组翻转中的
tmp = cur;(保存本组开头,反转后会变成尾部)。 -
合并链表中的
cur1 = cur1->next;(推进指针)。
-
3. 快慢指针 ------ 找中点/判环的神器
-
重难点 :
slow走一步,fast走两步。循环条件while(fast && fast->next)决定了slow最终停在哪里。 -
实战体现 :在"重排链表"中,正是利用快慢指针找到中点,才得以将链表一分为二。要注意区分节点总数的奇偶性对
slow位置的影响。
深度易错点(代码实现层面)
1. 空指针解引用
这是链表题最致命的错误,通常发生在以下三个场景:
-
遍历时越界 :
while(cur->next)没判断cur是否为空。正确写法:while(cur && cur->next)。 -
累加值时越界 :如"两数相加"中
t += cur1->val;,如果cur1已经是nullptr,直接崩溃。必须在前面加if(cur1)。 -
推进指针时越界 :如"两两交换"中,如果
cur1已经变成nullptr,再去cur2 = cur1->next;就会崩溃。先更新cur2,再更新cur1是安全的顺序。
2. 指针丢失与断链(逻辑错误)
-
反转时的断链 :执行
cur->next = prev;之前,如果没有保存cur->next,后面的链表就全丢了。 -
合并时的断链 :合并两个链表时,如果先移动了
cur1,再想用cur1去接后面的节点,就会发现cur1已经指向别处了。必须先用prev->next把节点接上,再移动cur1。
3. 循环终止条件写错(死循环或漏节点)
-
合并K个(优先队列版) :
while(!heap.empty())没问题,但循环内部如果忘记if(t->next) heap.push(t->next);,那么取出的节点后面的所有节点都会丢失。 -
重排链表 :合并时如果用
while(cur1 && cur2),奇数个节点时,前半段最后一个节点会被遗漏。以较长的链表作为循环条件 (while(cur1))才是安全的。 -
K个一组翻转 :如果内层循环写成
while(cur)而不是for(int j = 0; j < k; j++),那么当剩余节点不足 K 个时,也会被错误地翻转。
4. 内存泄漏
-
在 C++ 中,
new出来的虚拟头节点,在返回结果前必须delete。 -
如"重排链表"中的
delete ret;,"两数相加"中的delete newhead;。 -
进阶技巧 :在"合并K个(分治版)"中,使用栈上局部变量
ListNode head;替代new ListNode(0),既不需要delete,效率也更高。
5. 虚拟头节点忘记断开原连接
- 在"重排链表"中,找到中点后,必须执行
slow->next = nullptr;。如果不切断,前半段的尾节点仍然指向后半段,合并时就会形成环,导致程序死循环。
思维框架
面对任何链表题:
-
画图:不要空想,在纸上画出指针的初始状态和目标状态。
-
设虚拟头节点 :除非明确知道头节点不会改变(如纯粹的遍历),否则一律先建
dummy。 -
定循环条件 :明确循环结束的边界是
nullptr还是某个特定节点,注意&&的短路特性。 -
先存后改 :在修改任何
next指针前,先确认自己已经保存了所有必要的后继节点。 -
收尾检查:
-
最后返回的是不是
dummy->next? -
有没有节点被遗漏(尤其是尾部)?
-
有没有动态内存没有释放?
-