链表
C++链表GESP 5级“深度精通”指南:从原理到实战的完全掌握
第一部分:链表的“基因级”理解(内存与指针的舞蹈)
1.1 链表的物理本质:为什么它“非连续”?
核心洞察:链表不是“存储数据的结构”,而是“组织指针关系的结构”。每个节点都是一块独立申请的内存,节点间的联系完全靠指针维系。
// 每一个new都在堆内存的不同位置创建节点
ListNode* node1 = new ListNode(10); // 假设地址 0x1000
ListNode* node2 = new ListNode(20); // 可能地址 0x3040(不连续!)
node1->next = node2; // 这才是建立联系的关键!
GESP高频考点:选择题常考“链表节点在内存中是否连续?”→ 一定选“不连续”。
1.2 指针操作的“原子性思维”
链表所有操作都可归结为指针的重新指向。你必须像操作原子一样精确:
// 在节点p后插入q的“原子操作分解”
ListNode* temp = p->next; // 步骤1:保存原关系(原子读取)
q->next = temp; // 步骤2:建立新关系1(原子写入)
p->next = q; // 步骤3:建立新关系2(原子写入)
关键理解:p->next = q不是“移动”节点,而是修改p节点中next成员存储的地址值。节点本身在内存中不动。
第二部分:单链表的“全生命周期”管理(代码级深度剖析)
2.1 节点定义:不止一种写法
// 版本1:GESP考试最常用(清晰直接)
struct ListNode {
int val;
ListNode* next;
// 构造函数:确保新建节点时next自动置空
ListNode(int x) : val(x), next(nullptr) {}
};
// 版本2:带初始化列表的构造函数
struct ListNode {
int val;
ListNode* next;
ListNode(int x) {
val = x;
next = nullptr; // 必须显式初始化!
}
};
// 易错点:忘记初始化next
struct ListNode {
int val;
ListNode* next; // 危险!未初始化,可能是野指针
ListNode(int x) : val(x) {} // 错误:next没有初始化!
};
2.2 插入操作:边界情况的全覆盖
头插法的“隐藏细节”:
void insertHead(ListNode*& head, int val) {
ListNode* newNode = new ListNode(val);
// 下面两行代码顺序可互换吗?分析:
// head = newNode; newNode->next = head;
// 错误!这样newNode->next指向了自己
newNode->next = head; // 正确:新节点指向原头
head = newNode; // 头指针更新为新节点
}
尾插法的“遍历终止条件”深度分析:
void insertTail(ListNode*& head, int val) {
ListNode* newNode = new ListNode(val);
// 情况1:链表为空
if (head == nullptr) {
head = newNode;
return;
}
// 情况2:链表非空
ListNode* curr = head;
// 为什么是curr->next != nullptr而不是curr != nullptr?
while (curr->next != nullptr) { // 我们要找最后一个“节点”
curr = curr->next;
}
// 循环结束时,curr指向最后一个节点(不是nullptr!)
curr->next = newNode;
}
思维训练:如果循环条件改为while (curr != nullptr),会怎样?
- 最终
curr会是nullptr,无法执行curr->next = newNode - 需要一个额外指针
prev记录前一个节点
2.3 删除操作:内存管理的铁律
删除节点的“四步法则”:
void deleteNode(ListNode*& head, int val) {
// 第0步:防御性检查
if (head == nullptr) return;
// 第1步:处理头节点特殊情况
if (head->val == val) {
ListNode* temp = head;
head = head->next;
delete temp; // 铁律1:有new必须有对应的delete
return;
}
// 第2步:查找要删除节点的前驱
ListNode* curr = head;
while (curr->next != nullptr && curr->next->val != val) {
curr = curr->next;
}
// 第3步:如果找到,执行删除
if (curr->next != nullptr) { // 确保curr->next有效
ListNode* temp = curr->next; // 保存要删除的节点地址
curr->next = curr->next->next; // 从链表中“摘除”
delete temp; // 释放内存
}
// 第4步:如果没找到,什么也不做
}
关键理解:delete操作释放的是该节点占用的内存,但不会自动将指向它的指针设为nullptr。这就是“悬空指针”问题的来源。
第三部分:高级技巧的“原理级”掌握
3.1 哑节点(Dummy Node):为什么它能统一操作?
哑节点的本质:创建一个临时节点作为“操作基准点”,让原链表的头节点也能像中间节点一样被处理。
ListNode* dummy = new ListNode(0); // 值任意,不会使用
dummy->next = head; // 关键:哑节点“粘”到链表前
// 现在,即使要删除原head,也有前驱节点(dummy)了
ListNode* curr = dummy;
while (curr->next != nullptr) {
// curr始终指向当前处理节点的前驱
// 这使得删除操作统一:curr->next = curr->next->next
}
重要细节:使用哑节点后,真正的头节点是dummy->next,最后要记得delete dummy释放哑节点本身。
3.2 快慢指针:数学原理与实现细节
找中点:循环条件的微妙差异
// 版本A:偶数个节点时返回上中点
ListNode* findMiddleUp(ListNode* head) {
if (!head) return nullptr;
ListNode *slow = head, *fast = head;
while (fast->next && fast->next->next) {
slow = slow->next;
fast = fast->next->next;
}
return slow; // 链表1->2->3->4,返回2
}
// 版本B:偶数个节点时返回下中点
ListNode* findMiddleDown(ListNode* head) {
if (!head) return nullptr;
ListNode *slow = head, *fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
}
return slow; // 链表1->2->3->4,返回3
}
数学原理:快指针速度是慢指针2倍。设链表长n:
- 当fast到达末尾时,slow走了n/2步
- 循环条件决定了fast的停止位置,从而影响slow的最终位置
检测环:为什么快慢指针一定会相遇?
证明:设环外长度a,环长度b
- 第一次进入环时,slow走了a步,fast走了2a步(已在环内走了a步)
- 设此时fast落后slow
(b - a % b)步 - 每走一步,fast追近1步,因此必然在环内相遇
第四部分:GESP 5级真题“解题思维框架”
4.1 选择题:三秒判断法
| 题型 | 关键词 | 正确选项特征 | 错误选项特征 |
|---|---|---|---|
| 链表vs数组 | “更适合链表” | 提到“频繁插入删除” | 提到“随机访问”、“连续存储” |
| 时间复杂度 | “单链表某操作复杂度” | 头插/头删:O(1) | 其他操作:基本是O(n) |
| 指针操作 | “插入顺序” | 先q->next = p->next |
先p->next = q |
| 内存/结构 | “正确的是” | “动态增长”、“非连续” | “连续存储”、“随机访问快” |
4.2 编程题:五步解题法
以链表反转为例展示完整思维过程:
ListNode* reverseList(ListNode* head) {
// ✅ 第1步:边界检查(1分钟思考)
if (!head || !head->next) return head;
// ✅ 第2步:定义指针(命名清晰)
ListNode* prev = nullptr; // 前驱,初始无
ListNode* curr = head; // 当前,从头开始
ListNode* next = nullptr; // 后继,临时保存
// ✅ 第3步:循环反转(画图理解)
while (curr) {
next = curr->next; // 保存退路
curr->next = prev; // 反转箭头
prev = curr; // prev前移
curr = next; // curr前移
}
// ✅ 第4步:返回结果
return prev; // 注意:prev是新的头
// ✅ 第5步:检查边界
// - 空链表:第1行已处理
// - 单节点:第1行已处理
// - 双节点:可在脑中模拟
}
4.3 真题深度分析:2024年9月题
题目:以下哪种情况使用链表比数组更合适?
A. 数据量固定且读多写少
B. 需要频繁在中间或开头插入、删除元素
C. 需要高效随机访问元素
D. 存储空间必须连续
解题思维过程:
- 提取关键词:“链表比数组更合适”→问链表优势
- 回忆核心区别:链表优势=动态增删;数组优势=随机访问、连续存储
- 逐项分析:
- A:数据固定+读多→数组缓存友好,不合适
- B:频繁插入删除→链表O(1)插入优势,✓
- C:随机访问→数组O(1),链表O(n),不合适
- D:连续存储→数组特性,不合适
- 确定答案:B
第五部分:从“知道”到“精通”的刻意练习
5.1 练习等级体系
| 等级 | 目标 | 练习内容 | 时间投入 |
|---|---|---|---|
| 入门级 | 理解基本操作 | 画图模拟插入删除 | 2-3小时 |
| 基础级 | 能写正确代码 | 实现所有基本操作 | 4-5小时 |
| 熟练级 | 熟练解决典型问题 | 反转、合并、找中点等 | 6-8小时 |
| 精通级 | 解决复杂问题 | 环检测、排序、复杂操作 | 10+小时 |
5.2 高效练习方法
方法1:可视化练习(必做)
在纸上画出:
- 初始链表状态
- 每个操作后的指针变化
- 最终状态
示例练习:反转链表1->2->3->null
初始: null [1] -> [2] -> [3] -> null
第1步: null <- [1] [2] -> [3] -> null
第2步: null <- [1] <- [2] [3] -> null
第3步: null <- [1] <- [2] <- [3] null
结果: [3] -> [2] -> [1] -> null
方法2:边界测试法
对每个函数,测试:
// 1. 空链表
head = nullptr;
result = yourFunction(head);
// 2. 单节点链表
head = new ListNode(1);
result = yourFunction(head);
// 3. 双节点链表
head = new ListNode(1);
head->next = new ListNode(2);
result = yourFunction(head);
// 4. 多节点链表
// ...(正常情况)
方法3:复杂度分析法
对每个操作,问自己:
- 需要遍历吗?遍历多少次?
- 有嵌套循环吗?
- 时间复杂度:O(1)、O(n)、O(n²)?
- 空间复杂度:使用了额外空间吗?
第六部分:考前冲刺与实战策略
6.1 最后一周复习计划
| 天数 | 上午 (1.5h) | 下午 (1.5h) | 晚上 (1h) |
|---|---|---|---|
| Day 1 | 基本操作:插入删除 | 遍历与查找 | 画图练习 |
| Day 2 | 高级操作:反转 | 快慢指针应用 | 复杂度分析 |
| Day 3 | 哑节点技巧 | 合并两个链表 | 边界测试 |
| Day 4 | 真题选择题 | 真题编程题 | 错题分析 |
| Day 5 | 模拟测试1 | 模拟测试2 | 弱点突破 |
| Day 6 | 总复习 | 概念梳理 | 放松休息 |
| Day 7 | 考试 | - | - |
6.2 考场时间分配建议
选择题:每题1-2分钟
- 30秒读题
- 30秒分析
- 30秒确认
- 不会的先标记,最后回来看
编程题:15-20分钟
- 3分钟:读题,画图理解
- 2分钟:设计算法,考虑边界
- 8分钟:编写代码
- 2分钟:测试简单用例
- 2分钟:检查内存管理
6.3 常见失分点与规避策略
- 内存泄漏:每个
new都要想好对应的delete - 悬空指针:
delete后最好置nullptr - 边界错误:总是考虑空链表、单节点情况
- 指针顺序错误:画图!画图!画图!
- 复杂度判断错误:记住“单链表大部分操作是O(n)”
第七部分:链表思想的拓展与应用
7.1 链表在GESP 5级外的应用
实现栈(后进先出):
class ListStack {
private:
ListNode* top; // 只需要头指针
public:
void push(int val) { // 头插法
ListNode* newNode = new ListNode(val);
newNode->next = top;
top = newNode;
}
int pop() { // 删除头节点
if (!top) throw "Stack empty";
int val = top->val;
ListNode* temp = top;
top = top->next;
delete temp;
return val;
}
};
实现队列(先进先出):
class ListQueue {
private:
ListNode* front; // 队头
ListNode* rear; // 队尾
public:
void enqueue(int val) { // 尾插法
ListNode* newNode = new ListNode(val);
if (!rear) { // 空队列
front = rear = newNode;
} else {
rear->next = newNode;
rear = newNode;
}
}
int dequeue() { // 删除头节点
if (!front) throw "Queue empty";
int val = front->val;
ListNode* temp = front;
front = front->next;
if (!front) rear = nullptr; // 队列变空
delete temp;
return val;
}
};
7.2 链表学习的“元认知”
学习链表不仅是为了通过GESP,更是培养指针思维和动态内存管理能力。这些能力是C++编程的基石,对未来学习更复杂的数据结构(树、图)至关重要。
最后的叮嘱
链表学习的三个阶段:
- 困惑期:指针绕晕,操作混乱 → 坚持画图,手动模拟
- 理解期:能看懂代码,但自己写易错 → 反复练习,形成肌肉记忆
- 精通期:看到问题就能想到解法 → 大量实战,总结模式
记住:链表是少数几个通过刻意练习一定能掌握的计算机科学概念。你的每一分努力都会体现在代码的正确性上。
这份指南结合了两份讲义的全部精华,并增加了深度分析和实战策略。按此系统学习,你不仅能通过GESP 5级,更能真正掌握链表这一核心数据结构。
现在,拿起笔,打开编译器,开始练习吧。从画出一个简单的三个节点的链表开始,一步一步,走向精通。
C++链表详解(结合GESP 5级考点)
一、链表基础与GESP考点
1.1 链表核心概念
链表是一种动态数据结构,通过指针将零散的内存块串联起来。每个节点包含:
- 数据域:存储实际值
- 指针域:存储下一个节点的地址
GESP 5级考点提醒:链表在GESP 5级考试中常考指针操作、内存管理和算法应用。
二、链表的内存布局(图示)
2.1 单链表内存结构
栈内存 堆内存
┌─────────┐ ┌─────────┐ ┌─────────┐
│ head───────→│ 节点1 │ │ 节点2 │
│ 0x1000 │ │ data: 10│ │ data: 20│
└─────────┘ │ next:──────→│ next:──────→ NULL
│ 0x2000│ │ 0x3000│ │
└─────────┘ └─────────┘
地址:0x1000 地址:0x2000
2.2 代码实现
struct ListNode {
int val;
ListNode* next; // 指向下一个节点的指针
ListNode(int x) : val(x), next(nullptr) {}
};
三、链表基本操作详解(配图)
3.1 插入操作
3.1.1 头部插入
初始: head → [A] → [B] → NULL
步骤: 1. 创建新节点 [N]
2. N.next = head
3. head = N
结果: head → [N] → [A] → [B] → NULL
void insertHead(ListNode*& head, int val) {
ListNode* newNode = new ListNode(val);
newNode->next = head; // 新节点指向原头节点
head = newNode; // 头指针指向新节点
}
3.1.2 尾部插入
初始: head → [A] → [B] → NULL
步骤: 1. 遍历到最后一个节点 [B]
2. 创建新节点 [N]
3. B.next = N
结果: head → [A] → [B] → [N] → NULL
void insertTail(ListNode*& head, int val) {
ListNode* newNode = new ListNode(val);
if (head == nullptr) {
head = newNode;
return;
}
ListNode* curr = head;
while (curr->next != nullptr) {
curr = curr->next;
}
curr->next = newNode;
}
3.1.3 中间插入(在指定节点后)
初始: head → [A] → [B] → [C] → NULL
插入: 在节点A后插入N
步骤: 1. N.next = A.next
2. A.next = N
结果: head → [A] → [N] → [B] → [C] → NULL
void insertAfter(ListNode* prevNode, int val) {
if (prevNode == nullptr) return;
ListNode* newNode = new ListNode(val);
newNode->next = prevNode->next;
prevNode->next = newNode;
}
3.2 删除操作
3.2.1 删除头节点
初始: head → [A] → [B] → [C] → NULL
步骤: 1. temp = head
2. head = head.next
3. delete temp
结果: head → [B] → [C] → NULL
void deleteHead(ListNode*& head) {
if (head == nullptr) return;
ListNode* temp = head;
head = head->next;
delete temp; // 重要!释放内存
}
3.2.2 删除指定值节点
删除节点B:
初始: [A] → [B] → [C] → NULL
步骤: 1. 找到B的前驱节点A
2. A.next = B.next
3. delete B
结果: [A] → [C] → NULL
void deleteNode(ListNode*& head, int val) {
if (head == nullptr) return;
// 如果要删除的是头节点
if (head->val == val) {
deleteHead(head);
return;
}
// 找到要删除节点的前一个节点
ListNode* curr = head;
while (curr->next != nullptr && curr->next->val != val) {
curr = curr->next;
}
if (curr->next != nullptr) {
ListNode* temp = curr->next;
curr->next = curr->next->next;
delete temp;
}
}
四、GESP 5级链表选择题示例
4.1 题目示例1
题目: 在单链表中,如果要在节点p后插入一个新节点q,正确的操作顺序是?
A. q->next = p->next; p->next = q;
B. p->next = q; q->next = p->next;
C. q->next = p; p->next = q;
D. p->next = q->next; q->next = p;
解析:
正确答案:A
图示解释:
初始: p → [X] → ...
步骤1: q->next = p->next (q指向p原来的下一个节点)
p → [X] → ...
↑
q
步骤2: p->next = q (p指向q)
p → q → [X] → ...
4.2 题目示例2
题目: 下面哪个操作在单链表中的时间复杂度是O(1)?
A. 访问第i个节点
B. 在链表尾部插入节点
C. 删除链表头节点
D. 在链表中查找指定值
解析:
正确答案:C
时间复杂度分析:
A. O(n) - 需要从头遍历
B. O(n) - 需要找到尾部
C. O(1) - 直接修改头指针
D. O(n) - 需要遍历查找
五、GESP 5级链表编程题训练
5.1 基础编程题:链表反转
题目要求: 编写函数反转单链表
ListNode* reverseList(ListNode* head) {
ListNode* prev = nullptr;
ListNode* curr = head;
ListNode* next = nullptr;
while (curr != nullptr) {
next = curr->next; // 保存下一个节点
curr->next = prev; // 反转指针方向
prev = curr; // prev向前移动
curr = next; // curr向前移动
}
return prev; // prev成为新的头节点
}
图解反转过程:
初始: NULL ← prev curr → [1] → [2] → [3] → NULL
第1轮: NULL ← [1] [2] → [3] → NULL
第2轮: NULL ← [1] ← [2] [3] → NULL
第3轮: NULL ← [1] ← [2] ← [3] NULL
结果: [3] → [2] → [1] → NULL
5.2 进阶编程题:检测链表环
题目要求: 判断链表是否有环(Floyd判圈算法)
bool hasCycle(ListNode* head) {
if (head == nullptr || head->next == nullptr) {
return false;
}
ListNode* slow = head; // 慢指针,每次走1步
ListNode* fast = head; // 快指针,每次走2步
while (fast != nullptr && fast->next != nullptr) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) { // 快慢指针相遇,说明有环
return true;
}
}
return false; // 快指针到达末尾,说明无环
}
图解快慢指针:
有环情况:
head → [A] → [B] → [C] → [D]
↑ ↓
[F] ← [E] ←
slow路径: A → B → C → D → E → F → C ...
fast路径: A → C → E → C → E → C ...
最终在C或E处相遇
六、链表常用技巧总结
6.1 哑节点(Dummy Node)技巧
作用: 简化边界条件处理
ListNode* removeElements(ListNode* head, int val) {
ListNode* dummy = new ListNode(0); // 创建哑节点
dummy->next = head;
ListNode* curr = dummy;
while (curr->next != nullptr) {
if (curr->next->val == val) {
ListNode* temp = curr->next;
curr->next = curr->next->next;
delete temp;
} else {
curr = curr->next;
}
}
ListNode* newHead = dummy->next;
delete dummy; // 释放哑节点
return newHead;
}
6.2 快慢指针技巧
应用场景:
- 找链表中点
- 检测环
- 找环的入口
- 找倒数第k个节点
// 找链表中点
ListNode* findMiddle(ListNode* head) {
if (head == nullptr) return nullptr;
ListNode* slow = head;
ListNode* fast = head;
while (fast->next != nullptr && fast->next->next != nullptr) {
slow = slow->next;
fast = fast->next->next;
}
return slow; // 奇数返回正中,偶数返回前一个
}
七、双向链表详解
7.1 双向链表结构
节点结构:
┌─────────────┐
│ prev | data | next │
└─────────────┘
struct DoublyListNode {
int val;
DoublyListNode* prev;
DoublyListNode* next;
DoublyListNode(int x) : val(x), prev(nullptr), next(nullptr) {}
};
7.2 双向链表插入
// 在节点后插入新节点
void insertAfter(DoublyListNode* node, int val) {
if (node == nullptr) return;
DoublyListNode* newNode = new DoublyListNode(val);
newNode->next = node->next;
newNode->prev = node;
if (node->next != nullptr) {
node->next->prev = newNode;
}
node->next = newNode;
}
八、实战训练题
8.1 GESP风格选择题
题目1: 以下代码执行后,链表的状态是什么?
ListNode* head = new ListNode(1);
head->next = new ListNode(2);
head->next->next = new ListNode(3);
ListNode* p = head->next;
p->next = new ListNode(4);
p->next->next = head;
题目2: 删除链表中所有重复元素,只保留第一次出现的元素
ListNode* deleteDuplicates(ListNode* head) {
if (head == nullptr) return nullptr;
ListNode* curr = head;
while (curr != nullptr && curr->next != nullptr) {
if (curr->val == curr->next->val) {
ListNode* temp = curr->next;
curr->next = curr->next->next;
delete temp;
} else {
curr = curr->next;
}
}
return head;
}
九、内存管理与注意事项
9.1 常见错误
// 错误示例1:内存泄漏
void badExample() {
ListNode* head = new ListNode(1);
head->next = new ListNode(2);
// 忘记delete,内存泄漏!
}
// 错误示例2:悬空指针
void badExample2() {
ListNode* head = new ListNode(1);
ListNode* p = head;
delete head;
// 此时p是悬空指针!
cout << p->val; // 未定义行为
}
9.2 正确实践
// 使用辅助函数释放链表
void deleteList(ListNode*& head) {
while (head != nullptr) {
ListNode* temp = head;
head = head->next;
delete temp;
}
head = nullptr; // 防止成为悬空指针
}
十、综合练习题
10.1 合并两个有序链表
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
ListNode dummy(0); // 哑节点
ListNode* tail = &dummy;
while (l1 != nullptr && l2 != nullptr) {
if (l1->val <= l2->val) {
tail->next = l1;
l1 = l1->next;
} else {
tail->next = l2;
l2 = l2->next;
}
tail = tail->next;
}
// 连接剩余部分
tail->next = (l1 != nullptr) ? l1 : l2;
return dummy.next;
}
10.2 链表排序(归并排序)
ListNode* sortList(ListNode* head) {
if (head == nullptr || head->next == nullptr) {
return head;
}
// 找到中点
ListNode* slow = head;
ListNode* fast = head->next;
while (fast != nullptr && fast->next != nullptr) {
slow = slow->next;
fast = fast->next->next;
}
// 分割链表
ListNode* mid = slow->next;
slow->next = nullptr;
// 递归排序
ListNode* left = sortList(head);
ListNode* right = sortList(mid);
// 合并
return mergeTwoLists(left, right);
}
总结要点
- 链表优势:动态大小,插入删除高效
- 链表劣势:随机访问慢,内存开销大
- 核心操作:插入、删除、遍历、反转
- 常用技巧:哑节点、快慢指针、递归
- GESP重点:指针操作、边界条件、时间复杂度
- 内存管理:及时释放,避免泄漏
通过以上详细讲解和练习,你应该能够掌握链表的各个方面,为GESP 5级考试做好充分准备。记得多练习画图理解指针的变化过程!
GESP 5级C++链表专题讲义与真题解析
一、GESP 5级链表考点全景图
1.1 考试大纲中的链表要求
GESP 5级考试对链表的考查主要围绕以下几个方面:
- 单链表的基本操作:创建、插入、删除、遍历
- 链表与数组的对比:时间复杂度、空间复杂度、适用场景
- 链表的应用:栈、队列的实现
- 指针操作与内存管理:
new/delete的正确使用
1.2 近年来真题趋势分析
根据2023-2024年GESP 5级考试题目分析,链表相关题目呈现以下特点:
- 选择题:侧重概念理解和特性对比(占比约60%)
- 编程题:侧重基本操作实现(占比约40%)
- 难度分布:基础题70%,中等难度题20%,较难题10%
二、链表基础概念精讲(附真题解析)
2.1 链表的核心结构
节点定义的标准写法:
// GESP考试推荐的标准写法
struct ListNode {
int data; // 数据域
ListNode* next; // 指针域
// 构造函数
ListNode(int val) {
data = val;
next = nullptr;
}
};
GESP 5级真题(2024年6月):
下列关于链表节点的描述,错误的是:
A. 每个节点至少包含一个数据域和一个指针域
B. 链表的最后一个节点的指针域通常指向NULL
C. 链表节点在内存中一定是连续存储的
D. 链表的插入和删除操作不需要移动大量元素
解析:
- 正确答案:C
- 关键点:链表节点在内存中不一定连续,这是链表与数组的核心区别之一
- 记忆技巧:链表像"火车",车厢(节点)通过挂钩(指针)连接,车厢位置不固定
2.2 链表的创建与初始化
创建链表的三种常见方式:
// 方式1:逐个节点创建(GESP常考)
ListNode* createList1() {
ListNode* head = new ListNode(1);
head->next = new ListNode(2);
head->next->next = new ListNode(3);
return head;
}
// 方式2:循环创建(更实用)
ListNode* createList2(int arr[], int n) {
if (n == 0) return nullptr;
ListNode* head = new ListNode(arr[0]);
ListNode* curr = head;
for (int i = 1; i < n; i++) {
curr->next = new ListNode(arr[i]);
curr = curr->next;
}
return head;
}
// 方式3:使用头插法(逆序创建)
ListNode* createList3(int arr[], int n) {
ListNode* head = nullptr;
for (int i = 0; i < n; i++) {
ListNode* newNode = new ListNode(arr[i]);
newNode->next = head;
head = newNode;
}
return head;
}
GESP 5级真题(2023年12月):
以下代码创建了一个什么样的链表?
ListNode* head = nullptr; for (int i = 1; i <= 3; i++) { ListNode* node = new ListNode(i); node->next = head; head = node; }
解析:
- 链表顺序:3 → 2 → 1 → NULL
- 这是头插法,创建的是逆序链表
- 重要考点:头插法的时间复杂度为O(1),尾插法为O(n)
三、链表基本操作详解(附真题演练)
3.1 插入操作全解析
3.1.1 各种插入情况的代码实现
class LinkedList {
private:
ListNode* head;
public:
// 1. 在链表头部插入
void insertAtHead(int val) {
ListNode* newNode = new ListNode(val);
newNode->next = head; // 步骤1:新节点指向原头节点
head = newNode; // 步骤2:头指针指向新节点
}
// 2. 在链表尾部插入
void insertAtTail(int val) {
ListNode* newNode = new ListNode(val);
// 情况1:链表为空
if (head == nullptr) {
head = newNode;
return;
}
// 情况2:链表非空,找到最后一个节点
ListNode* curr = head;
while (curr->next != nullptr) {
curr = curr->next;
}
curr->next = newNode;
}
// 3. 在指定位置插入(0-based index)
void insertAtIndex(int index, int val) {
if (index < 0) return;
// 插入在头部的情况
if (index == 0) {
insertAtHead(val);
return;
}
// 找到插入位置的前一个节点
ListNode* curr = head;
for (int i = 0; i < index - 1 && curr != nullptr; i++) {
curr = curr->next;
}
// 如果位置有效
if (curr != nullptr) {
ListNode* newNode = new ListNode(val);
newNode->next = curr->next;
curr->next = newNode;
}
}
// 4. 在指定值后面插入
void insertAfterValue(int target, int val) {
ListNode* curr = head;
while (curr != nullptr && curr->data != target) {
curr = curr->next;
}
if (curr != nullptr) {
ListNode* newNode = new ListNode(val);
newNode->next = curr->next;
curr->next = newNode;
}
}
};
3.1.2 GESP真题:插入操作顺序
GESP 5级真题(2024年3月):
在单链表中,要在节点p后面插入一个新节点q,正确的操作顺序是:
A. p->next = q; q->next = p->next; B. q->next = p->next; p->next = q; C. q->next = p; p->next = q; D. p->next = q->next; q->next = p;
详细解析:
正确答案:B
错误分析:
A: p->next = q; q->next = p->next;
- 第一步p->next = q后,p原来的下一个节点就丢失了
- 第二步q->next = p->next实际上等于q->next = q,形成自环
B: q->next = p->next; p->next = q; ✓
- 第一步:q指向p原来的下一个节点(保存了后续链表)
- 第二步:p指向q,完成插入
C: q->next = p; p->next = q;
- 第一步:q指向p,断开了p与后续节点的联系
- 这样会使链表从p处断开
D: p->next = q->next; q->next = p;
- q->next初始为nullptr,所以p->next = nullptr
- 这样会丢失p后面的所有节点
记忆口诀:先连后断,新节点先接后续,原节点再接新节点
3.2 删除操作全解析
3.2.1 各种删除情况的代码实现
class LinkedList {
public:
// 1. 删除头节点
void deleteHead() {
if (head == nullptr) return;
ListNode* temp = head; // 保存原头节点
head = head->next; // 头指针后移
delete temp; // 释放内存
}
// 2. 删除尾节点
void deleteTail() {
if (head == nullptr) return;
// 只有一个节点的情况
if (head->next == nullptr) {
delete head;
head = nullptr;
return;
}
// 找到倒数第二个节点
ListNode* curr = head;
while (curr->next->next != nullptr) {
curr = curr->next;
}
delete curr->next; // 释放尾节点
curr->next = nullptr; // 新的尾节点next置空
}
// 3. 删除指定值的节点(删除第一个匹配的)
void deleteByValue(int val) {
// 空链表情况
if (head == nullptr) return;
// 删除头节点的情况
if (head->data == val) {
deleteHead();
return;
}
// 查找要删除节点的前驱节点
ListNode* curr = head;
while (curr->next != nullptr && curr->next->data != val) {
curr = curr->next;
}
// 如果找到要删除的节点
if (curr->next != nullptr) {
ListNode* temp = curr->next; // 保存要删除的节点
curr->next = curr->next->next; // 跳过要删除的节点
delete temp; // 释放内存
}
}
// 4. 删除指定位置的节点(0-based index)
void deleteByIndex(int index) {
if (head == nullptr || index < 0) return;
// 删除头节点的情况
if (index == 0) {
deleteHead();
return;
}
// 找到要删除节点的前一个节点
ListNode* curr = head;
for (int i = 0; i < index - 1 && curr != nullptr; i++) {
curr = curr->next;
}
// 如果位置有效且要删除的节点存在
if (curr != nullptr && curr->next != nullptr) {
ListNode* temp = curr->next;
curr->next = curr->next->next;
delete temp;
}
}
};
3.2.2 GESP真题:删除操作的时间复杂度
GESP 5级真题(2024年9月):
下列关于单链表删除操作的说法,正确的是:
A. 删除头节点的时间复杂度是O(n)
B. 删除尾节点的时间复杂度是O(1)
C. 删除指定值的节点需要遍历链表
D. 删除操作不需要释放内存
解析:
正确答案:C
详细分析:
A: 错误。删除头节点只需要修改头指针,时间复杂度O(1)
B: 错误。删除尾节点需要找到倒数第二个节点,需要遍历,时间复杂度O(n)
C: 正确。删除指定值的节点需要遍历链表找到该节点
D: 错误。C++中动态分配的内存必须手动释放
总结:
- 删除头节点:O(1)
- 删除尾节点:O(n)
- 删除中间节点:O(n)(需要找到前驱节点)
四、链表高级操作与GESP编程题
4.1 链表反转(重点掌握)
4.1.1 迭代法反转链表
ListNode* reverseList(ListNode* head) {
ListNode* prev = nullptr; // 前驱节点
ListNode* curr = head; // 当前节点
ListNode* next = nullptr; // 后继节点
while (curr != nullptr) {
// 1. 保存下一个节点
next = curr->next;
// 2. 反转指针方向
curr->next = prev;
// 3. 指针前进
prev = curr;
curr = next;
}
// prev现在是新的头节点
return prev;
}
4.1.2 递归法反转链表
ListNode* reverseListRecursive(ListNode* head) {
// 递归终止条件
if (head == nullptr || head->next == nullptr) {
return head;
}
// 递归反转后续链表
ListNode* newHead = reverseListRecursive(head->next);
// 反转当前节点
head->next->next = head;
head->next = nullptr;
return newHead;
}
4.1.3 GESP编程题真题(2023年12月)
题目要求:
编写函数 reverseLinkedList,接收一个链表的头指针,返回反转后的链表头指针。
评分标准:
- 正确反转链表(3分)
- 正确处理空链表和单节点链表(2分)
- 代码清晰,有适当注释(1分)
参考答案:
ListNode* reverseLinkedList(ListNode* head) {
// 处理边界情况
if (head == nullptr || head->next == nullptr) {
return head;
}
ListNode* prev = nullptr;
ListNode* curr = head;
while (curr != nullptr) {
ListNode* nextTemp = curr->next; // 保存下一个节点
curr->next = prev; // 反转当前节点的指针
prev = curr; // prev前进
curr = nextTemp; // curr前进
}
return prev; // prev是新的头节点
}
4.2 链表中点查找(快慢指针法)
4.2.1 找中间节点
ListNode* findMiddle(ListNode* head) {
if (head == nullptr) return nullptr;
ListNode* slow = head; // 慢指针,每次走1步
ListNode* fast = head; // 快指针,每次走2步
while (fast != nullptr && fast->next != nullptr) {
slow = slow->next; // 慢指针走1步
fast = fast->next->next; // 快指针走2步
}
return slow; // 慢指针所在位置就是中间节点
}
4.2.2 GESP选择题真题(2024年6月)
题目:
使用快慢指针法查找单链表的中间节点,当快指针到达链表末尾时,慢指针的位置是:
A. 链表的第一个节点
B. 链表的中间节点(奇数个)或上中节点(偶数个)
C. 链表的最后一个节点
D. 链表的中间节点(奇数个)或下中节点(偶数个)
解析:
正确答案:B
详细说明:
- 奇数个节点:1→2→3→4→5
慢指针:1→2→3,停在正中间节点3
- 偶数个节点:1→2→3→4
慢指针:1→2→3,停在上中节点2(不是下中节点3)
规律:当快指针无法再前进两步时,循环结束
五、链表与数组对比(GESP高频考点)
5.1 性能对比表
| 特性 | 数组 | 单链表 | 双向链表 |
|---|---|---|---|
| 内存分配 | 连续内存 | 分散内存 | 分散内存 |
| 随机访问 | O(1) | O(n) | O(n) |
| 头部插入 | O(n) | O(1) | O(1) |
| 尾部插入 | O(1)(已知大小) | O(n) | O(1)(有tail指针) |
| 中间插入 | O(n) | O(1)(已知位置) | O(1)(已知位置) |
| 内存开销 | 小(只存数据) | 大(数据+指针) | 更大(数据+2指针) |
| 缓存友好性 | 高 | 低 | 低 |
5.2 GESP选择题真题集锦
真题1(2024年9月)
以下哪种情况使用链表比数组更合适?
A. 数据量固定且读多写少
B. 需要频繁在中间或开头插入、删除元素
C. 需要高效随机访问元素
D. 存储空间必须连续
解析:正确答案B。链表在插入删除方面有优势,数组在随机访问方面有优势。
真题2(2024年3月)
下列关于链表的描述,正确的是:
A. 链表支持随机访问
B. 链表节点在内存中连续存储
C. 链表插入节点时需要移动其他节点
D. 链表可以动态增长
解析:正确答案D。链表通过动态分配节点实现动态增长。
真题3(2023年12月)
在一个包含n个节点的单链表中,删除已知节点p(p不是尾节点)的时间复杂度是:
A. O(1) B. O(log n) C. O(n) D. O(n²)
解析:正确答案A。如果知道要删除的节点p,且p不是尾节点,可以将p下一个节点的值复制到p,然后删除p的下一个节点,时间复杂度O(1)。
六、双向链表专题
6.1 双向链表的基本操作
struct DListNode {
int data;
DListNode* prev;
DListNode* next;
DListNode(int val) : data(val), prev(nullptr), next(nullptr) {}
};
class DoublyLinkedList {
private:
DListNode* head;
DListNode* tail;
public:
// 在节点后插入
void insertAfter(DListNode* node, int val) {
if (node == nullptr) return;
DListNode* newNode = new DListNode(val);
newNode->prev = node;
newNode->next = node->next;
if (node->next != nullptr) {
node->next->prev = newNode;
}
node->next = newNode;
// 如果插入在尾节点后面,更新tail
if (node == tail) {
tail = newNode;
}
}
// 删除节点
void deleteNode(DListNode* node) {
if (node == nullptr) return;
// 调整前驱节点的next指针
if (node->prev != nullptr) {
node->prev->next = node->next;
} else {
// 删除的是头节点
head = node->next;
}
// 调整后继节点的prev指针
if (node->next != nullptr) {
node->next->prev = node->prev;
} else {
// 删除的是尾节点
tail = node->prev;
}
delete node;
}
};
6.2 GESP真题:双向链表指针操作
GESP 5级真题(2024年6月):
在双向链表中,要在节点p和节点q之间插入新节点r(p在q之前),需要修改几个指针?
A. 2 B. 3 C. 4 D. 5
解析:
正确答案:C(需要修改4个指针)
修改的指针:
1. r->prev = p
2. r->next = q
3. p->next = r
4. q->prev = r
图解:
原:p ⇄ q
目标:p ⇄ r ⇄ q
七、链表内存管理注意事项
7.1 常见错误及避免方法
// 错误1:内存泄漏
void memoryLeak() {
ListNode* head = new ListNode(1);
head->next = new ListNode(2);
// 忘记delete!内存泄漏!
}
// 正确做法:使用析构函数或辅助函数
void deleteList(ListNode*& head) {
while (head != nullptr) {
ListNode* temp = head;
head = head->next;
delete temp;
}
}
// 错误2:悬空指针
void danglingPointer() {
ListNode* head = new ListNode(1);
ListNode* p = head;
delete head;
// 此时p是悬空指针!
// cout << p->data; // 未定义行为
}
// 正确做法:delete后立即置空
void safeDelete(ListNode*& ptr) {
delete ptr;
ptr = nullptr; // 避免悬空指针
}
7.2 GESP编程题评分要点
在GESP编程题中,链表相关的题目评分通常关注:
- 正确性(4分):算法逻辑正确
- 完整性(3分):处理所有边界情况(空链表、单节点等)
- 内存管理(2分):正确使用new/delete,无内存泄漏
- 代码风格(1分):命名规范,有适当注释
八、综合训练与模拟题
8.1 综合编程题:合并两个有序链表
题目要求:
编写函数 mergeTwoLists,合并两个升序排列的链表,返回合并后的链表头指针。
参考答案:
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
// 创建哑节点简化操作
ListNode dummy(0);
ListNode* tail = &dummy;
// 双指针遍历两个链表
while (l1 != nullptr && l2 != nullptr) {
if (l1->data <= l2->data) {
tail->next = l1;
l1 = l1->next;
} else {
tail->next = l2;
l2 = l2->next;
}
tail = tail->next;
}
// 连接剩余部分
if (l1 != nullptr) {
tail->next = l1;
} else {
tail->next = l2;
}
return dummy.next;
}
8.2 模拟考试题
一、选择题(每题2分)
-
单链表中,删除节点p(p不是尾节点)的最优时间复杂度是?
A. O(1) B. O(log n) C. O(n) D. O(n²) -
双向链表相比单链表的优势是?
A. 节省内存 B. 支持双向遍历 C. 随机访问更快 D. 插入更快 -
在单链表中查找第k个节点,时间复杂度是?
A. O(1) B. O(log n) C. O(n) D. O(n²)
二、编程题(10分)
编写函数 removeNthFromEnd,删除链表的倒数第n个节点。
九、备考建议与总结
9.1 GESP 5级链表备考策略
- 掌握基础操作:插入、删除、遍历必须熟练
- 理解时间复杂度:能够分析各种操作的时间复杂度
- 画图理解指针:复杂操作先画图,再写代码
- 多做真题:熟悉考试题型和难度
- 注意边界条件:空链表、单节点、头尾节点等特殊情况
9.2 链表学习路线图
第一周:掌握单链表基本操作
1. 节点定义与创建
2. 插入操作(头插、尾插、中间插)
3. 删除操作
4. 遍历与查找
第二周:学习高级操作
1. 链表反转
2. 快慢指针应用
3. 环检测
4. 合并链表
第三周:对比与拓展
1. 链表vs数组对比
2. 双向链表
3. 循环链表
4. 链表应用(栈、队列)
第四周:真题训练与模拟
1. 完成近2年真题
2. 模拟考试
3. 查漏补缺
9.3 关键知识点总结
- 链表核心:通过指针连接的非连续存储结构
- 插入要点:先连后断,注意顺序
- 删除要点:找到前驱节点,记得释放内存
- 时间复杂度:
- 访问:O(n)
- 头插/删:O(1)
- 尾插:O(n)(单链表),O(1)(有tail指针)
- 尾删:O(n)
希望这份详细的讲义能帮助你全面掌握C++链表,顺利通过GESP 5级考试!记住:多画图、多练习、多思考是学习链表的最佳方法。放一起,且详细

浙公网安备 33010602011771号