Files
cpp-courseware/2.Course/1.深入浅出程序设计竞赛(基础篇)/3.简单数据结构/1.线性表/链表(一) 讲义.md
T

276 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
### **C++ 链表(一):讲义**
#### **一、 引言**
##### **1.1 问题引入**
我们来回顾一下最熟悉的数据结构——数组。如果想记录一排学生的队伍,用数组 `string names[50];` 来存,非常方便。数组的特点是什么?—— 内存中,所有元素都是“肩并着肩”挨在一起的,位置**连续且固定**。
这带来了好处:可以通过下标 `names[i]` 瞬间访问到任何一个学生,这叫**随机访问**。
但也带来了麻烦:
* **插入**:如果一个新同学想插到队伍中间,那么他之后的所有同学都必须**依次往后挪一个位置**,工程浩大。
* **删除**:如果一个同学离队,他之后的所有同学又得**依次往前填补空位**。
对于一个需要频繁增删成员的动态队伍来说,数组这种结构,显然不够灵活。
##### **1.2 价值阐述**
有没有一种数据结构,能像一个灵活的队伍,轻松地让成员加入或离开,而不需要大规模地挪动呢?这就是今天要学习的——**链表**。
链表是计算机科学中最基础也最重要的数据结构之一。它在内存中的存储方式,解决了数组在插入和删除操作上的低效问题。掌握链表,将能够:
* 理解一种全新的、非连续的数据组织方式。
* 高效地处理动态变化的数据集合。
* 为学习更高级的数据结构(如哈希表、树、图等)打下理论基础。
---
#### **二、 核心思想**
##### **2.1 思路铺垫**
想象一下,排好队的学生解散后,在大厅里随意站着,他们在大厅中的位置是**不连续**的。我们还能还原出原来的队伍顺序吗?
如果只知道他们的名字,显然不能。但如果在解散前,我们让每个同学都记住一件事——**我后面站的是谁**,情况就完全不同了。
只要我们还知道**谁是第一个人(起点)**,我们就能:
1. 找到第一个人。
2. 问他:“你后面是谁?” -> 从而找到第二个人。
3. 再问第二个人:“你后面是谁?” -> 从而找到第三个人。
...
就这样,像一条线索,一个接一个,把整个队伍重新“串”起来。
这种数据在内存中可以分散存储,仅依靠一个“指向下一个元素”的信息来维持逻辑顺序的数据结构,就是**链表**。
##### **2.2 链表的组成**
一个链表由若干个**节点(Node)**串联而成。每个节点,就像队伍里的一个同学,包含两个核心信息:
1. **数据域 (Data)**:节点本身存储的数据。例如,同学的编号、姓名等。
2. **指针域 (Next)**:一个“指针”或“线索信息”,用于存储**下一个节点的位置信息**。
此外,我们还需要一个单独的变量 **`head`**,来记录整个链表的**头节点(起点)**。
##### **2.3 步骤拆解:用 `Next` 数组模拟链表**
在程序中,如何表示“某人后面是谁”这个信息呢?一种非常直观的方式是使用一个**`Next`数组**,这种方法也被称为**静态链表**。
* **规则**:`Next[i] = j`,其含义是:编号为 `i` 的节点,它的下一个节点是编号为 `j` 的节点。
* **结束标记**:队伍的最后一个人后面没人了。我们需要一个特殊值来表示“链表的末尾”,通常使用 `0` 或 `-1`(因为节点编号通常从1开始)。
**案例【例15-10】**:
有4名同学,原始队列顺序是 `1 -> 4 -> 3 -> 2`。已知1号是头节点。
根据“我后面是谁”的规则,我们可以构建出 `Next` 数组:
* 1号后面是4号 => `Next[1] = 4;`
* 4号后面是3号 => `Next[4] = 3;`
* 3号后面是2号 => `Next[3] = 2;`
* 2号后面没人了 => `Next[2] = 0;` (使用0作为结束标记)
这样,我们就用一个简单的数组,成功地在计算机中描述了这个手牵手的链式关系。
---
#### **三、 实例:遍历链表**
遍历是指从头节点开始,沿着“Next”指针,依次访问链表中的每一个节点,直到末尾。
##### **3.1 例15-10 还原排队顺序**
* **已知信息**:
* 头节点 `head = 1`。
* `Next` 数组:`Next[1]=4`, `Next[4]=3`, `Next[3]=2`, `Next[2]=0`。
##### **3.2 分步演示**
1. **【起点】**:创建一个“当前位置”变量 `current`,让它从头节点出发。`current = head;` (即 `current = 1`)。
2. **【第1步】**:
* **判断**:`current` 不是终点 ( `1 != 0` )。
* **访问**:输出当前位置的编号 `1`。
* **前进**:查询 `Next[1]`,得到 `4`。更新 `current` 的值为 `4`。
3. **【第2步】**:查询 `Next[4]`,得到 `3`。更新 `current` 的值为 `3`。(其他略)
4. **【第3步】**:查询 `Next[3]`,得到 `2`。更新 `current` 的值为 `2`。(其他略)
5. **【第4步】**:
* **判断**:`current` 不是终点 ( `2 != 0` )。
* **访问**:输出当前位置的编号 `2`。
* **前进**:查询 `Next[2]`,得到 `0`。更新 `current` 的值为 `0`。
6. **【终点】**:
* **判断**:`current` 是终点 ( `0 == 0` )。循环条件不满足,**循环结束**。
最终,我们按 `1 4 3 2` 的正确顺序输出了整个队伍。
---
#### **四、 链表的遍历代码实现**
##### **4.1 `while` 循环实现**
`while` 循环非常直观地体现了遍历的逻辑。
```cpp
// 假设Next数组已构建好, head = 1
int current = head; // 1. 从头节点出发
while (current != 0) { // 2. 只要还没到终点
cout << current << " "; // 3. 访问当前节点
current = Next[current]; // 4. 移动到下一个节点
}
```
##### **4.2 `for` 循环的实现**
我们也可以用 `for` 循环来表达同样的过程,这种写法更紧凑,并将控制逻辑集中在一起。
```cpp
// for (初始化; 循环条件; 循环后操作)
for (int current = head; current != 0; current = Next[current]) {
cout << current << " ";
}
```
* **初始化**:`int current = head` (从起点开始)
* **循环条件**:`current != 0` (何时停止)
* **循环后操作**:`current = Next[current]` (如何去下一个节点)
这两种写法本质上完全一样,都实现了链表的遍历。
---
#### 五、 核心操作一:插入
##### **5.1 先连接新节点的后继,再修改前驱的后继**
**实例场景**:
假设我们有一条 `A -> B` 的链路,现在新节点 `X` 想要插入在 `A` 和 `B` 之间,形成 `A -> X -> B` 的新逻辑顺序。
<img src="C:\Users\xiaolinquan\AppData\Roaming\Typora\typora-user-images\image-20260705163944820.png" alt="image-20260705163944820" style="zoom:33%;" />
这个过程需要修改节点的后继关系。正确的步骤分为两步,且**顺序很重要**:
1. **第一步:设置新节点X的后继**
* **动作**:将新节点 `X` 的后继指针,指向节点 `A` 原本的后继 `B`。
* **原因**:节点 `A` 的后继信息 `Next[A]` 是我们找到节点 `B` 的唯一信息。如果先修改 `Next[A]`,`B` 的位置信息就会丢失,导致链表后半部分失联,所以要先设置新节点X的后继。
* **代码实现**:
```cpp
Next[X] = Next[A];
```
2. **第二步:修改前驱节点A的后继**
* **动作**:现在 `X` 已经连接到了链表的后半部分,可以放心地修改 `A` 的后继指针,使其指向新插入的节点 `X`。
* **代码实现**:
```cpp
Next[A] = X;
```
**总结**:`Next[X] = Next[A];` 和 `Next[A] = X;` 这两行代码,必须严格按照此顺序执行,以确保链表的完整性。
因为如果先把节点A的后继更新B。那么节点A原本的后继是B的这一关键信息就彻底丢失了。接下来需要设置新节点 X 的后继为 B,此时 B 节点就无法再找到了,因为如果节点 B 去查询节点 A 的后继,得到的结果变成了 B 自己,所以会导致无法执行 `Next[X] = Next[A];`这一步。
---
#### **六、 删除 **
##### **6.1 核心思想:修改前驱节点的后继,直接指向被删节点的后继**
**实例**:
假设我们有一条 `A -> B -> C` 的链路,现在需要从删除节点 `B`,形成 `A -> C` 的新顺序。
<img src="C:\Users\xiaolinquan\AppData\Roaming\Typora\typora-user-images\image-20260705162706312.png" alt="image-20260705162706312" style="zoom:33%;" />
这个过程只需要一步关键操作:
1. **修改A的后继**
* **动作**:将节点 `A` 的后继指针,直接指向 `B` 的后继节点 `C`。
* **分析**:节点 `A` 如何知道 `C` 的位置?通过 `B` 的后继信息 `Next[B]` 即可获得。
* **代码实现**:
```cpp
Next[A] = Next[B];
```
* 执行完这行代码后,节点 `A` 绕过了 `B` 直接与 `C` 连接,节点 `B` 从逻辑上被成功删除,因为再也没有可能访问到节点 `B` 了。
##### **6.2 如何找到前驱节点?**
上述删除操作看似简单,但它有一个前提:我们必须知道待删除节点 `B` 的**前驱节点**是 `A`。
在单向链表中,每个节点只存储了其后继节点的信息,并不知道其前驱是谁。这意味着,如果只给定删除节点B,我们无法直接执行 `Next[A] = Next[B]`,因为我们不知道 `A` 在什么地方。
为了找到 `A`,我们只能从链表的**头节点 `head`** 开始,沿着后继指针逐个遍历,直到找到一个节点 `p`,满足 `Next[p] == B`。这个节点 `p` 就是我们需要的 `A`。这个查找过程,在最坏情况下需要遍历整个链表,时间复杂度为 O(N)。
这使得单向链表的删除操作,在其核心逻辑之外,增加了一个效率不高的查找步骤。
---
#### **七、 双向链表**
为了解决单向链表寻找前驱节点难的问题,我们对链表结构进行了一次升级。
##### **7.1 记录前驱与后继的双向连接**
**定义**:双向链表的每个节点,不再只有一个后继指针,而是拥有**两个指针**:
1. **`next` 指针**:指向下一个节点。
2. **`prev` 指针**:指向**前一个**节点。
<img src="C:\Users\xiaolinquan\AppData\Roaming\Typora\typora-user-images\image-20260705162740579.png" alt="image-20260705162740579" style="zoom:33%;" />
通过这种方式,每个节点都同时掌握了其前驱和后继的信息,数据在逻辑上形成了双向的链接。
##### **7.2 双向链表的优势**
* **高效查找前驱**:对于任意一个节点 `B`,找到其前驱 `A` 不再需要遍历,只需一步 `A = B->prev` 即可,时间复杂度为 **O(1)**。
* **实现了双向遍历**:我们既可以从头节点 `head` 开始,通过 `next` 指针正向遍历链表;因为是双向的,我们自然还会定义并储存一个尾节点 `tail` ,那么就可以通过尾节点 `tail` 的 `prev` 指针反向遍历。
* **高效的删除**:由于可以瞬间找到前驱和后继,双向链表的删除操作也变得极其高效。
**双向链表删除节点B的逻辑**:
1. 让B的前驱A,跳过B,直接指向B的后继C:`A->next = C` 即 `B->prev->next = B->next`。
2. 让B的后继C,回头跳过B,直接指向B的前驱A:`C->prev = A` 即 `B->next->prev = B->prev`。
完成这两步,节点 `B` 就从双向连接中删除了,整个过程的时间复杂度为 **O(1)**。
---
##### **7.3 双向链表的插入逻辑**
双向链表的插入操作同样高效,其核心在于要同时维护 `next` 和 `prev` 两个方向的链接。假设我们需要在节点 `A` 和其后继节点 `B` 之间,插入一个新节点 `X`,目标是形成 `A <--> X <--> B` 的新结构。
<img src="C:\Users\xiaolinquan\AppData\Roaming\Typora\typora-user-images\image-20260705163927459.png" alt="image-20260705163927459" style="zoom:33%;" />
**步骤一:将新节点 `X` 链接到其前驱和后继**
首先设置新节点 `X` 自己的 `prev` 和 `next` 指针,让它正确地指向其前驱和后继。
1. **设置 `X` 的 `next` 指针**:
* **动作**:将 `X` 的 `next` 指针指向 `A` 原本的后继节点 `B`。
* **代码实现**:`X->next = A->next;`
2. **设置 `X` 的 `prev` 指针**:
* **动作**:将 `X` 的 `prev` 指针指向它的前驱节点 `A`。
* **代码实现**:`X->prev = A;`
完成这两步后,新节点 `X` 已经正确地连接到了链表中,但原有的 `A` 和 `B` 之间的链接还没有更新到 `x`。
**步骤二:更新原有节点,使其指向新节点 `X`**
接下来修改 `A` 和 `B` 的指针,让它们转而指向新节点 `X`。
3. **更新后继节点 `B` 的 `prev` 指针**:
* **动作**:将 `B` 的 `prev` 指针从指向 `A` 改为指向 `X`。
* **代码实现**:`A->next->prev = X;` (即 `B->prev = X`)
* **注意**:这一步必须在第4步之前执行,因为第4步会修改 `A->next` 的指向。
4. **更新前驱节点 `A` 的 `next` 指针**:
* **动作**:将 `A` 的 `next` 指针从指向 `B` 改为指向 `X`。
* **代码实现**:`A->next = X;`
整个过程因为不涉及数据移动,时间复杂度同样为 **O(1)**。
---
#### **八、 总结**
* 链表的核心是用一个指针(`Next`信息)将物理上不连续的数据,在逻辑上串联起来。
* 遍历链表需要两个关键信息:**头节点 `head`** 和 **`Next` 关系网**。
* 遍历的三要素是:**起点(`current=head`)**、**终点判断(`current!=0`)** 和 **前进方式(`current=Next[current]`)**。
* 链表的核心优势在于其**插入**和**删除**操作的高效性,这源于它通过修改指针(后继/前驱关系)而非移动数据来改变逻辑顺序。
* **插入操作**的关键在于**“先连接新节点的后继,再修改前驱节点的后继”**,以确保链表的连续性。
* **单向链表**的删除操作存在一个瓶颈:**寻找待删节点的前驱节点是低效的 (O(N))**。
* **双向链表**通过增加一个 `prev` 指针,以微小的空间开销,换来了**O(1)的前驱查找能力**,使得插入和删除操作在各种情况下都极为高效,是实际应用中更常用、更强大的链表形态。