Initial commit: C++ courseware

This commit is contained in:
xiaolinquan committed 2026-11-05 22:51:02 +08:00
commit 4a4ee7c287
1789 files changed
+373489

No files matched your search

@@ -0,0 +1,139 @@
## C++ 算法讲义:栈 (Stack)
### 1. 引言
* **问题引入**:
想象你在餐厅洗盘子。客人们不断把用过的盘子堆在桌上,你每次洗的时候,是从这叠盘子的**最底端**抽,还是从**最顶端**拿?显然,你只能拿走最后放上去的那个。
* **价值**:
这种“后来者居上”的逻辑在计算机世界无处不在:浏览器的“后退”按钮、编辑器的“撤销”功能 (Ctrl+Z)、甚至复杂的表达式求值和函数调用,背后都是栈在支撑。它是掌握高级数据结构(如树、图)的敲门砖。
* **学习目标**:
理解栈的 **LIFO (后进先出)** 原理,学会手动模拟栈以及使用 C++ STL 标准库中的 `stack`。
---
### 2. 核心思想
* **栈 (Stack)** 是一种遵循“后进先出”(Last In, First Out,简称 **LIFO**)原则的线性数据结构。
* **入栈 (Push)**:把新元素放到最顶端。
* **出栈 (Pop)**:把最顶端的元素移走。
* **栈顶 (Top)**:当前最后进入、最上面的那个元素。
---
### 3. 代码实现
#### 方法一:手动数组模拟
使用数组 `S[]` 存数据,用变量 `top_ptr` 指向栈顶下标(初始为 -1)。
```cpp
int S[100];
int top_ptr = -1; // 初始为空
void push(int x) {
top_ptr++; // 腾出地方
S[top_ptr] = x; // 放下盘子
}
void pop() {
if (top_ptr != -1) top_ptr--; // 逻辑删除:指针下移,下次push会覆盖旧数据
}
int top() {
return S[top_ptr]; // 返回当前的“顶端”
}
```
#### 方法二:使用 C++ STL 标准库 (推荐用法)
实际开发中,我们直接调用 `#include <stack>`。
![image-20260305170759709](C:\Users\xiaolinquan\AppData\Roaming\Typora\typora-user-images\image-20260305170759709.png)
```cpp
#include <iostream>
#include <stack> //栈的头文件
using namespace std;
int main() {
stack<int> s; // 定义一个整数栈
s.push(10); // 入栈
s.push(20);
if (!s.empty()) { // 访问前务必检查是否为空!
cout << "栈顶元素: " << s.top() << endl; // 输出 20
}
s.pop(); // 弹出 20
return 0;
}
```
---
### 4. 经典应用
#### A. 括号匹配 (例 15-5)
括号匹配的本质是**最近匹配原则**:最后打开的左括号,必须最先被闭合。
#### 核心逻辑拆解
我们可以把栈想象成一个“待完成任务清单”:
1. **遇到左括号 `(` `[` `{`**:
- **操作**:`s.push(ch)`。
- **含义**:你许下一个承诺,后面必须有一个对应的“盖子”来找它。
2. **遇到右括号 `)` `]` `}`**:
- **第一步:检查是否有盒子。** 如果此时栈是空的(`s.empty()`),说明你手里拿着盖子却没盒子,直接判定 **非法**。
- **第二步:检查类型。** 看一眼栈顶 `s.top()`。如果我手里是 `]`,栈顶必须是 `[`。
- **匹配**:`s.pop()`。说明这个盒子成功盖好了,从清单中划掉。
- **不匹配**:比如 `( ]`,直接判定 **非法**。
当整个字符串读完后,**必须检查栈是否为空**:
- 如果 `s.empty()` 为真,说明所有承诺都兑现了 $\rightarrow$ **成功!**
- 如果栈里还剩东西,说明有的括号“开了口却没盖上” $\rightarrow$ **非法**。
------
#### B. 后缀表达式 (例 15-6)
后缀表达式(又称逆波兰表达式)最大的魅力在于:**它不需要括号,也不需要考虑加减乘除的优先级**,因为位置已经决定了一切。
#### 步骤:
假设我们要计算:`3 4 2 * + 5 -`
1. **扫描到数字**:
- 直接 `s.push(num)`。
- *注意*:在代码实现时,如果输入是 `123.` 这种带点的格式,记得用循环把 `'1'`,`'2'`,`'3'` 拼接成整数 $123$ 再入栈。
2. **扫描到运算符(如 `+`, `-`, `\*`, `/`)**:
- **关键动作**:从栈中弹出两个数。
- **注意顺序**:
- 第一个弹出的是**右操作数** $b = s.top()$; `s.pop()`;
- 第二个弹出的是**左操作数** $a = s.top()$; `s.pop()`;
- **计算并将结果压回**:计算 $a \text{ 运算符 } b$ 的结果,然后 `s.push(result)`。
3. **扫描到终止符 `@`**:
- 此时栈顶剩下的唯一一个数字,就是最终答案。
#### 为什么第一个弹出的是右操作数?
看这个例子:`10 2 /` (即 $10 \div 2$)
- 栈里先入 $10$,后入 $2$。
- 弹出第一个数是 $2$(右边),第二个数是 $10$(左边)。
- 计算 $10 / 2 = 5$。如果顺序反了,变成 $2 / 10$ 就全错了!
---
### 5. 总结
* **要点回顾**:
* 栈是 **LIFO**(后进先出)。
* `stack` 的常用操作:`push` (入), `pop` (出), `top` (看栈顶), `empty` (判空)。
* **注意**:`pop()` 和 `top()` 前一定要判断栈是否为空,否则程序会崩溃。
@@ -0,0 +1,275 @@
### **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)的前驱查找能力**,使得插入和删除操作在各种情况下都极为高效,是实际应用中更常用、更强大的链表形态。
@@ -0,0 +1,186 @@
### **C++算法将以 链表(二)**
#### **一、 引言**
##### **1.1 问题引入:静态链表的局限性**
前面我们学习了用数组来模拟链表(静态链表),比如开一个 `Next[1005]` 和 `Prev[1005]` 数组。这种方法在算法竞赛中很常见,因为它写起来快。但是它有两个缺点:
1. **空间浪费或溢出**:你必须提前估计好元素的最大数量(比如开 1000 万个大小的数组)。如果最终只有 10 个人排队,剩下的 999 万多个位置就白白浪费了;如果来了 1001 万个人,程序就会因为数组越界而崩溃。
2. **值与下标的绑定**:如果我们要存的数据本身非常大(比如学生的学号是 `202300001`),我们显然不能开一个大小为 2亿 的数组来把学号当作下标。
##### **1.2 价值阐述**
有没有一种办法:来一个人,我就找计算机要一个刚好的位置;走一个人,我就把这个位置还给计算机?完全**按需分配,一点都不浪费**?
这就是我们今天要学习的——**动态内存分配**与**指针**。基于此构建的**动态链表**,突破了数组容量的限制,是计算机底层系统中极为常见的数据结构。同时,我们还将学习 C++ 已经为我们造好的双向链表工具——**`list`**。
---
#### **二、 动态链表的核心:指针与 `new/delete`**
##### **2.1 定义节点(`struct Node`)**
在静态链表中,用数组的**下标**来表示位置(`Next[i] = j`)。
在动态链表中,直接用内存的**真实地址**来表示位置。存放地址的变量,叫做**指针**。
```cpp
struct Node {
int value; // 数据域:节点存储的值
Node* prev; // 指针域:指向前驱节点的地址
Node* next; // 指针域:指向后继节点的地址
};
```
**注意**:`Node*` 中的星号 `*`,代表这是一个**指针类型**。`prev` 和 `next` 里存的不是一个数字,而是一个内存地址。如果一个节点没有后继(比如它是尾节点),我们让指针指向一个特殊的空地址:**`nullptr`**。
##### **2.2 向操作系统 要 和 还 内存**
* **要内存 (`new`)**:
当一个新元素加入时,我们需要在内存中为他申请一块空间。
```cpp
Node* p = new Node;
```
这句代码做了三件事:
1. `new Node`:计算机在堆区找到一块刚好能放下 `Node` 结构体的内存空间。
2. 返回这块内存空间的**内存地址**。
3. `Node* p = ...`:把这个地址保存在指针变量 `p` 中。以后我们就可以通过 `p` 找到这个新元素了。
* **访问指针指向的内容 (`->`)**:
因为 `p` 存的是地址,我们不能用普通的 `.` 来访问数据,必须用**箭头符号 `->`**(代表顺着地址走过去看)。
```cpp
p->value = 10; // 把这块内存里的 value 设置为 10
p->next = nullptr; // 把这块内存里的 next 设置为空
```
* **还内存 (`delete`)**:
当再也不需要使用该元素时,就必须把这块空间还给操作系统,否则会导致**内存泄漏**。
```cpp
delete p; // 释放 p 指向的那块内存空间
```
---
#### **三、 动态双向链表的实战操作**
掌握了 `new` 和 `->`,那么动态链表的操作逻辑和静态链表**完全一模一样**,只是写法换成了指针!
##### **3.1 查找节点 (O(n))**
在动态链表中,我们只有头节点 `head` 的地址,要找某个值,必须从头挨个顺着 `next` 指针往下问。
```cpp
Node* findNode(Node* head, int val) {
Node* current = head; // 从起点出发
while (current != nullptr) { // 只要还没走到空(尽头)
if (current->value == val) {
return current; // 找到了,返回这个节点的地址
}
current = current->next; // 没找到,顺着指针去下一个节点
}
return nullptr; // 找遍了也没找到,返回空地址
}
```
##### **3.2 插入节点:`ins_back(x, p)`**
场景:找到节点 `x`,把新创建的节点 `p` 插在 `x` 和它的后继 `z` 之间。
```cpp
void ins_back(Node* x, Node* p) {
Node* z = x->next; // 提前找到 x 的后继 z
// 设置新节点 p 的前后驱
p->next = z; // p 的后继连向 z
p->prev = x; // p 的前驱连向 x
// 更新原有的 x 和 z
x->next = p; // x 的后继改为 p
if (z != nullptr) { // 如果 z 存在(x 不是尾节点)
z->prev = p; // z 的前驱改为 p
}
}
```
##### **3.3 删除节点:`del(x)`**
场景:将节点 `x` 从链表中删除,并释放内存。
```cpp
// 全局变量有 Node* head 指向开始元素的头指针; 和指向结束位置的尾指针 Node* tail;
void del(Node* x) {
Node* L = x->prev; // x 的前驱
Node* R = x->next; // x 的后继
// 处理前驱的连接
if (L != nullptr) {
L->next = R; // 前驱直接连向后继
} else {
head = R; // 如果没有前驱,说明 x 是头节点,删除后头节点变更为 R
}
// 处理后继的连接
if (R != nullptr) {
R->prev = L; // 后继直接连向前驱
} else {
tail = L; // 如果没有后继,说明 x 是尾节点,更新尾节点为 L
}
delete x; // 手动释放 x 占用的内存空间
}
```
---
#### **四、 C++ 标准库:`list`**
在日常开发和算法竞赛中,C++ 标准库(STL)已经为我们写好了一个方便、极其强大的双向链表工具:**`list`**。而使用前需要包含头文件:`#include <list>`
##### **4.1 创建与初始化**
```cpp
#include <list>
using namespace std;
list<int> a; // 创建一个空的存放 int 的双向链表
list<int> l = {1, 2, 3}; // 创建并初始化为 1, 2, 3
// 获取元素数量
long long count = l.size();
```
##### **4.2 特定增删操作 (O(1))**
* **首尾操作**:
* **在头部/尾部插入元素**:`l.push_front(x);` / `l.push_back(x);`
* **删除头部/尾部元素**:`l.pop_front();` / `l.pop_back();`
* **如何表示每个元素的位置?**:
STL 提供了**迭代器**,用于表示链表中的位置。
* **指向第一个有效元素:**`auto b_it = l.begin();`
* **指向最后一个元素的**下一个**位置(没有实际元素,仅作为结束标志)**:`auto e_it = l.end();`
* **移动**:`it++` (向后移),`it--` (向前移)。
* **访问迭代器指向的元素(解引用):** `cout << *b_it;` ,其中`b_it` 是指向第一个有效元素的迭代器,那么本代码会输出第一个元素的值。
* **指定位置增删 (O(1))**:
* **在迭代器 `it` 指向的位置前插入 `x`:**`l.insert(it, x);`
* **删除 `it` 指向的元素:**`l.erase(it);`
##### **4.3 遍历链表**
遍历链表更加推荐使用基于范围的 `for` 循环:
```cpp
list<int> a = {10, 20, 30};
// val 依次代表链表 a 中的每一个元素
for (int val : a) {
cout << val << " ";
}
// 输出: 10 20 30
// 也可以使用传统的 for 循环方式
for (auto it = a.begin(); it != a.end(); it++) {
// 使用 *it 解引用来获取迭代器指向的元素值
cout << *it << " ";
}
```
@@ -0,0 +1,119 @@
## C++ 算法讲义:队列
### 1. 引言
* **问题引入**:
例 15-7:超市排队。这回小止作为一个收银员在超市打工。收银员会给排在队伍最前面的顾客结账,然后再服务队伍中的下一个顾客。同时,队伍的末尾一直会有新的顾客加入。
**设问**:如果我们在超市排队,收银员像处理栈一样,总是先给**最后面**来的人结账(后进先出),会发生什么?
前面排队的人绝对会掀桌子!这太不公平了!
* **价值阐述**:
为了维持秩序,我们需要一种公平的规则——**谁先排队,谁先结账**。这就是**队列(Queue)**的核心思想。在计算机世界里,操作系统的任务调度、网络数据的传输等,都需要这种公平的机制来维持运转。
* **学习目标**:
掌握队列先进先出的运作机制,学会用数组模拟队列,并熟练使用 C++ STL 中的 `queue` 模板解决环形逻辑问题。
---
### 2. 核心思想
队列(queue)是一种遵循**先进先出(First In, First Out,简称 FIFO)**规则的线性数据结构。顾名思义,它模拟了现实中的排队现象。
我们将队列的核心操作与超市排队完美对应:
* **入队 `push(x)` —— 排队入列**
* **比喻**:新顾客 $x$ 推着购物车,站到了队伍的最末尾。
* **意义**:将元素 $x$ 压入队列的尾部。
* **看队首 `front()` —— 抬头看一眼**
* **比喻**:收银员小止喊:“下一位!” 然后抬头看一眼现在站在最前面的人是谁。注意,**只是看一眼**,这个人还没结账走掉。
* **意义**:查询(获取)队首的元素,但不删除它。
* **出队 `pop()` —— 结账走人**
* **比喻**:最前面的人结完账,离开了队伍。排在第二位的人顺理成章地成为了新的队首。
* **意义**:弹出队首的元素(删除最前面的数据)。
---
### 3. 代码实现
#### 方法一:使用数组模拟队列
在程序里,我们可以用一个固定大小的数组和两个变量来管理队伍。
* **初始化**:
定义数组 `int q[100];`。
初始化两个指针索引:`front = 0` 和 `rear = 0`。
* `front`:指向队列的第一个人(队首)。
* `rear`:指向队伍末尾**下一个可插入的空位**。
* **入队 (`push`)**:
将新元素放入 `rear` 指向的位置:`q[rear] = x;`。
队伍变长了,尾巴往后挪一格:`rear++;`。
* **出队 (`pop`)**:
队首的人走了,前面空出来了,头指针往后挪一格:`front++;`。(我们不需要真的抹除数组里的数据,只要 `front` 越过它,它就相当于出队了)。
#### 方法二:使用 C++ 现成的模板 —— `queue`
实际编程中,我们不需要每次都手写数组,C++ 标准库已经为我们准备好了。
```cpp
#include <iostream>
#include <queue> // 队列头文件
using namespace std;
int main() {
// 声明语法:queue<数据类型> 队列名;
queue<int> q; // 存顾客编号
// queue<string> qs; 存顾客姓名
q.push(1); // 1号顾客排队
q.push(2); // 2号顾客排队
cout << "当前队首是: " << q.front() << endl; // 看一眼,输出 1
q.pop(); // 1号顾客结账走人
cout << "新的队首是: " << q.front() << endl; // 看一眼,输出 2
return 0;
}
```
---
### 4. 实例
**例 15-9 约瑟夫问题 (B0221)**
**题目**:$n$ 个人围成一圈,从第一个人开始报数,数到 $k$ 的人出列。再由下一个人重新从 1 开始报数,数到 $k$ 的人再出圈... 依次类推,直到淘汰 $n-1$ 名小朋友。请按顺序输出每个出圈人的编号。
* **思考**:
题目说小朋友坐成一个圈,我们的直觉是画一个圆去模拟。但在计算机内存里,数据通常是**排成一排**的(线性)。怎么让这一排数据能转起来呢?
* **算法**:
想象一个超市收银台的回转传送带。传送带本身是直的,但在尽头会把没被拿走的数据**送回起始点**!
对于这个传送带而言,先放进去的数据,会先传到尽头,这不就是队列吗!我们只需要加上一个特殊操作:**队首回填队尾**。
* **步骤拆解(数到 $k$ 淘汰)**:
假设队列里是 1 到 $n$。我们要数 $k$ 个人:
1. **对于前 $k-1$ 个人(不用出局)**:
操作:先获取队首元素 `x = q.front()`,把他踢出队首 `q.pop()`,紧接着让他重新在队尾排队 `q.push(x)`。
2. **对于第 $k$ 个人(必须出局)**:
操作:看一眼队首并输出他的编号,然后直接踢出去 `q.pop()`,**不再**让他回队尾。
队列 + 队首回填队尾 叫做**逻辑环形队列**,在很多需要周期性、环形逻辑的地方都有效!
---
### 5. 总结
* **要点回顾**:
* **队列 (Queue)** 是一种遵循**先进先出 (FIFO)** 原则的数据结构。
* **入队 (`push`)**:新元素从队尾进入。
* **出队 (`pop`)**:老元素从队首离开。
* **看队首 (`front`)**:获取准备第一个离开的元素。
* **看队尾 (`back`)**:获取最后进入的元素。
* **适用场景**:
只要问题符合**先产生、先处理**,需要公平排队或**逐层扩散**的逻辑,就适合用队列。例如:数据流动模型(滑动窗口)、资源调度模型(打印机排队)等。