Kevinrzy103874的博客

Kevinrzy103874的博客

动态线条
动态线条end
code: {

专注于分享信息学竞赛技巧、知识点、模拟赛及一些题目题解,又有着当码农的乐趣,有时还会写写比赛游记等等。

链表

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

  1. 第一次进入环时,slow走了a步,fast走了2a步(已在环内走了a步)
  2. 设此时fast落后slow (b - a % b)
  3. 每走一步,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. 存储空间必须连续

解题思维过程

  1. 提取关键词:“链表比数组更合适”→问链表优势
  2. 回忆核心区别:链表优势=动态增删;数组优势=随机访问、连续存储
  3. 逐项分析
    • A:数据固定+读多→数组缓存友好,不合适
    • B:频繁插入删除→链表O(1)插入优势,✓
    • C:随机访问→数组O(1),链表O(n),不合适
    • D:连续存储→数组特性,不合适
  4. 确定答案:B

第五部分:从“知道”到“精通”的刻意练习

5.1 练习等级体系

等级 目标 练习内容 时间投入
入门级 理解基本操作 画图模拟插入删除 2-3小时
基础级 能写正确代码 实现所有基本操作 4-5小时
熟练级 熟练解决典型问题 反转、合并、找中点等 6-8小时
精通级 解决复杂问题 环检测、排序、复杂操作 10+小时

5.2 高效练习方法

方法1:可视化练习(必做)

在纸上画出:

  1. 初始链表状态
  2. 每个操作后的指针变化
  3. 最终状态

示例练习:反转链表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:复杂度分析法

对每个操作,问自己:

  1. 需要遍历吗?遍历多少次?
  2. 有嵌套循环吗?
  3. 时间复杂度:O(1)、O(n)、O(n²)?
  4. 空间复杂度:使用了额外空间吗?

第六部分:考前冲刺与实战策略

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 常见失分点与规避策略

  1. 内存泄漏:每个new都要想好对应的delete
  2. 悬空指针delete后最好置nullptr
  3. 边界错误:总是考虑空链表、单节点情况
  4. 指针顺序错误:画图!画图!画图!
  5. 复杂度判断错误:记住“单链表大部分操作是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++编程的基石,对未来学习更复杂的数据结构(树、图)至关重要。


最后的叮嘱

链表学习的三个阶段

  1. 困惑期:指针绕晕,操作混乱 → 坚持画图,手动模拟
  2. 理解期:能看懂代码,但自己写易错 → 反复练习,形成肌肉记忆
  3. 精通期:看到问题就能想到解法 → 大量实战,总结模式

记住:链表是少数几个通过刻意练习一定能掌握的计算机科学概念。你的每一分努力都会体现在代码的正确性上。

这份指南结合了两份讲义的全部精华,并增加了深度分析和实战策略。按此系统学习,你不仅能通过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 快慢指针技巧

应用场景:

  1. 找链表中点
  2. 检测环
  3. 找环的入口
  4. 找倒数第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);
}

总结要点

  1. 链表优势:动态大小,插入删除高效
  2. 链表劣势:随机访问慢,内存开销大
  3. 核心操作:插入、删除、遍历、反转
  4. 常用技巧:哑节点、快慢指针、递归
  5. GESP重点:指针操作、边界条件、时间复杂度
  6. 内存管理:及时释放,避免泄漏

通过以上详细讲解和练习,你应该能够掌握链表的各个方面,为GESP 5级考试做好充分准备。记得多练习画图理解指针的变化过程!

GESP 5级C++链表专题讲义与真题解析

一、GESP 5级链表考点全景图

1.1 考试大纲中的链表要求

GESP 5级考试对链表的考查主要围绕以下几个方面:

  • 单链表的基本操作:创建、插入、删除、遍历
  • 链表与数组的对比:时间复杂度、空间复杂度、适用场景
  • 链表的应用:栈、队列的实现
  • 指针操作与内存管理new/delete的正确使用

1.2 近年来真题趋势分析

根据2023-2024年GESP 5级考试题目分析,链表相关题目呈现以下特点:

  1. 选择题:侧重概念理解和特性对比(占比约60%)
  2. 编程题:侧重基本操作实现(占比约40%)
  3. 难度分布:基础题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编程题中,链表相关的题目评分通常关注:

  1. 正确性(4分):算法逻辑正确
  2. 完整性(3分):处理所有边界情况(空链表、单节点等)
  3. 内存管理(2分):正确使用new/delete,无内存泄漏
  4. 代码风格(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分)

  1. 单链表中,删除节点p(p不是尾节点)的最优时间复杂度是?
    A. O(1) B. O(log n) C. O(n) D. O(n²)

  2. 双向链表相比单链表的优势是?
    A. 节省内存 B. 支持双向遍历 C. 随机访问更快 D. 插入更快

  3. 在单链表中查找第k个节点,时间复杂度是?
    A. O(1) B. O(log n) C. O(n) D. O(n²)

二、编程题(10分)
编写函数 removeNthFromEnd,删除链表的倒数第n个节点。


九、备考建议与总结

9.1 GESP 5级链表备考策略

  1. 掌握基础操作:插入、删除、遍历必须熟练
  2. 理解时间复杂度:能够分析各种操作的时间复杂度
  3. 画图理解指针:复杂操作先画图,再写代码
  4. 多做真题:熟悉考试题型和难度
  5. 注意边界条件:空链表、单节点、头尾节点等特殊情况

9.2 链表学习路线图

第一周:掌握单链表基本操作
    1. 节点定义与创建
    2. 插入操作(头插、尾插、中间插)
    3. 删除操作
    4. 遍历与查找

第二周:学习高级操作
    1. 链表反转
    2. 快慢指针应用
    3. 环检测
    4. 合并链表

第三周:对比与拓展
    1. 链表vs数组对比
    2. 双向链表
    3. 循环链表
    4. 链表应用(栈、队列)

第四周:真题训练与模拟
    1. 完成近2年真题
    2. 模拟考试
    3. 查漏补缺

9.3 关键知识点总结

  1. 链表核心:通过指针连接的非连续存储结构
  2. 插入要点:先连后断,注意顺序
  3. 删除要点:找到前驱节点,记得释放内存
  4. 时间复杂度
    • 访问:O(n)
    • 头插/删:O(1)
    • 尾插:O(n)(单链表),O(1)(有tail指针)
    • 尾删:O(n)

希望这份详细的讲义能帮助你全面掌握C++链表,顺利通过GESP 5级考试!记住:多画图、多练习、多思考是学习链表的最佳方法。放一起,且详细

posted @ 2025-12-27 09:55  Kevinrzy103874  阅读(8)  评论(0)    收藏  举报