Initial commit: C++ courseware
This commit is contained in:
commit
4a4ee7c287
1789 files changed
+373489
No files matched your search
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>`。
|
||||
|
||||

|
||||
|
||||
```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()` 前一定要判断栈是否为空,否则程序会崩溃。
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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)的前驱查找能力**,使得插入和删除操作在各种情况下都极为高效,是实际应用中更常用、更强大的链表形态。
|
||||
Binary file not shown.
@@ -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 << " ";
|
||||
}
|
||||
```
|
||||
Binary file not shown.
@@ -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`)**:获取最后进入的元素。
|
||||
|
||||
|
||||
* **适用场景**:
|
||||
只要问题符合**先产生、先处理**,需要公平排队或**逐层扩散**的逻辑,就适合用队列。例如:数据流动模型(滑动窗口)、资源调度模型(打印机排队)等。
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,168 @@
|
||||
### **C++ 算法:哈希表**
|
||||
|
||||
#### **一、 引言**
|
||||
|
||||
##### **1.1 问题引入:字符串的查重**
|
||||
|
||||
【例17-3 字符串哈希】给定 $N$ 个字符串,求共有多少个**不同**的字符串。
|
||||
|
||||
**常规思路**:每拿到一个新字符串,都去已有的列表中从头到尾比对一遍。如果字符串数量达到十万、百万级别,这种时间复杂度为 $O(N^2)$ 的方法必将超时。
|
||||
|
||||
我们需要**看一眼字符串的名字,就能瞬间知道它应该放在哪里,以及它是否已经存在。**
|
||||
|
||||
##### **1.2 核心思想:图书管理员**
|
||||
|
||||
想象你是一个图书管理员,面前有海量的书(字符串)。你不想每次都跑遍整个图书馆找书。你希望有一个**规则**,让你一看到书名,就能立刻算出它应该放在编号为几的书架上。
|
||||
|
||||
* **字符串** -> *(通过某个神奇规则)* -> **唯一的书架编号**
|
||||
|
||||
这个神奇的规则,就是**哈希函数**。通过它,我们可以将查找的时间复杂度从线性的 $O(N)$ 降低到近乎 **$O(1)$**。
|
||||
|
||||
---
|
||||
|
||||
#### **二、 核心原理解析**
|
||||
|
||||
一个完整的哈希表系统,由两个核心部分组成:**哈希函数** 和 **桶数组(加冲突解决机制)**。
|
||||
|
||||
##### **2.1 哈希函数:将字符串变成数字**
|
||||
|
||||
我们要解决的第一个问题是:如何把一个字符串(如 `"abc"`)转换成一个唯一的数字索引(键值/哈希码)?
|
||||
|
||||
* **错误思路**:直接将字符的 ASCII 码相加。这样 `"abc"` 和 `"cba"` 的结果一样,会导致严重的混淆。
|
||||
* **正确思路:字符串哈希 (进制转换法)**。位置很重要!就像十进制的 `123` ($1\times10^2 + 2\times10^1 + 3\times10^0$)。我们把字符串看作一个 **P进制数**。
|
||||
|
||||
**哈希公式**:$hash("abc") = ('a' * P^2 + 'b' * P^1 + 'c' * P^0) % MOD$
|
||||
|
||||
* $P$ 通常取质数(如 131),$MOD$ 取一个很大的质数(如 $10^9 + 7$)以防止溢出并压缩数值范围。
|
||||
|
||||
**代码实现(迭代法)**:
|
||||
我们不需要真去算 $P$ 的次方,只需像从高位读数一样,每次将旧结果乘以 $P$,再加上新字符即可。
|
||||
|
||||
```cpp
|
||||
const int P = 131;
|
||||
const long long MOD = 1e9 + 7;
|
||||
|
||||
int hash_string(const string& str) {
|
||||
long long h = 0;
|
||||
for (char c : str) {
|
||||
h = (h * P + c) % MOD; // 迭代公式
|
||||
}
|
||||
return h;
|
||||
}
|
||||
```
|
||||
|
||||
##### **2.2 桶数组与哈希冲突**
|
||||
|
||||
算出哈希值后,我们需要把它放入实际的存储空间——**桶数组 **。
|
||||
为了把巨大的哈希值塞进有限的数组里,我们必须进行取模运算:`index = hash_val % SIZE`(`SIZE` 为桶数组的大小)。
|
||||
|
||||
**哈希冲突**:
|
||||
根据鸽巢原理,必定会有两个**不同**的字符串,计算出的 `index` **完全相同**。这就好比图书管理员发现,两本不同的书被分配到了同一个书架号。难道我们直接扔掉一本吗?
|
||||
|
||||
##### **2.3 解决冲突:链地址法**
|
||||
|
||||
遇到冲突时,最经典的解决方案是**链地址法 **。
|
||||
书架不能只放一本书了。我们在每个书架的位置放一个“箱子”或“链表”。所有被分配到这个书架号的书,统统扔进这个箱子里。
|
||||
|
||||
**数据结构设计**:
|
||||
|
||||
```cpp
|
||||
// SIZE 是桶的数量
|
||||
// vector<string> 就是那个"箱子",里面可以装多本哈希值冲突的书
|
||||
vector<vector<string>> hashTable(SIZE);
|
||||
```
|
||||
|
||||
**查找/插入流程**:
|
||||
1. 计算新字符串 `s` 的下标:`index = hash_string(s) % SIZE`。
|
||||
2. 直接走到第 `index` 个箱子前。
|
||||
3. **遍历这个箱子** `hashTable[index]`。
|
||||
* 如果找到了 `s`,说明重复了,什么都不做。
|
||||
* 如果找遍了箱子也没找到,说明是个全新的字符串,把它装进去:`hashTable[index].push_back(s)`。
|
||||
|
||||
只要哈希函数设计得好,冲突就会很少,每个箱子里的书极少,查找速度依然是 **$O(1)$**。但如果哈希函数很糟,所有书都挤在一个箱子里,哈希表就会退化成一个长数组,查找速度降回 **$O(N)$**。
|
||||
|
||||
---
|
||||
|
||||
#### **三、 STL: `unordered_map`**
|
||||
|
||||
手写哈希表能深刻理解原理,但在实战中,C++ 标准库(STL)已经为我们准备好了封装的哈希表——**`unordered_map`**。
|
||||
|
||||
它隐藏了复杂的哈希函数计算、内存分配和冲突处理,提供了一套极简、高效的操作接口。
|
||||
|
||||
##### **3.1 定义与核心特性**
|
||||
|
||||
* **头文件**:`#include <unordered_map>`
|
||||
* **特性**:键(Key)到值(Value)的映射,键是**唯一**的。内部元素是**无序**的。
|
||||
* **定义语法**:`unordered_map<KeyType, ValueType> umap;`
|
||||
* 例如:`unordered_map<string, int> count;` (通常用于统计字符串出现的次数)
|
||||
|
||||
##### **3.2 操作符:`[]`**
|
||||
|
||||
当我们写下 `umap[key]` 时,会发生:
|
||||
|
||||
1. **如果 `key` 已经存在**:
|
||||
它返回该 `key` 对应的值(Value)的引用。你可以直接读取或修改它。
|
||||
`cout << umap["apple"];` 对应输出 apple 的数量
|
||||
`umap["apple"]++;` 对应 apple 的数量加 1
|
||||
|
||||
2. **如果 `key` 不存在(重要)**:
|
||||
它会**自动**在哈希表中创建一个新的键值对 `(key, 默认值)`!如果是 `int`,默认值就是 0。
|
||||
`umap["banana"]++;` // 如果原本没有 banana,它会创建并设为 `int` 类型的初始值 0,然后执行++变成1。
|
||||
|
||||
##### **3.3 其他常用成员函数**
|
||||
|
||||
* **查找**:`umap.count(key)`。存在返回1,不存在返回0。比 `[]` 更安全,因为 `count` **不会**自动创建不存在的键。
|
||||
* **插入**:`umap.insert({"apple", 10})`。如果键已存在,插入会失败。
|
||||
* **删除**:`umap.erase(key)`。删除指定键的元素。
|
||||
* **状态**:`umap.size()`(元素个数),`umap.empty()`(是否为空)。
|
||||
|
||||
##### **3.4 遍历**
|
||||
|
||||
使用基于范围的 `for` 循环遍历,每次拿到的是一个 `pair`(包含 `.first` 和 `.second`)。
|
||||
|
||||
```cpp
|
||||
unordered_map<string, int> fruit = {{"apple", 5}, {"banana", 3}};
|
||||
|
||||
for (const auto& pair : fruit) {
|
||||
cout << "水果: " << pair.first << ", 数量: " << pair.second << endl;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **四、 案例:Cities and States (B0250)**
|
||||
|
||||
**题意简述**:寻找特殊的城市对。城市1的前两个字母 == 城市2的州代码,且 城市2的前两个字母 == 城市1的州代码,且两者不在同一个州。
|
||||
|
||||
```cpp
|
||||
int main() {
|
||||
int n;
|
||||
cin >> n;
|
||||
|
||||
// 键:组合字符串, 值:该组合出现的次数
|
||||
unordered_map<string, int> counts;
|
||||
long long ans = 0;
|
||||
|
||||
for (int i = 0; i < n; i++) {
|
||||
string city, state;
|
||||
cin >> city >> state;
|
||||
|
||||
string city_prefix = city.substr(0, 2); // 截取前两个字母
|
||||
|
||||
if (city_prefix != state) { // 必须来自不同的州
|
||||
string self_key = city_prefix + state; // 我自己的特征
|
||||
string partner_key = state + city_prefix; // 我需要的伴侣特征
|
||||
|
||||
// 查找伴侣:去哈希表里找,我需要的伴侣之前出现了几次?
|
||||
// 注意利用了 [] 运算符的自动创建特性,如果不存在就返回0,不会报错
|
||||
ans += counts[partner_key];
|
||||
|
||||
// 登记自己:把自己加入哈希表,供后面的城市查找
|
||||
counts[self_key]++;
|
||||
}
|
||||
}
|
||||
|
||||
cout << ans << endl;
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
Binary file not shown.
@@ -0,0 +1,163 @@
|
||||
### **C++ 算法:并查集**
|
||||
|
||||
#### **一、 引言**
|
||||
|
||||
##### **1.1 问题引入:朋友圈的合并与查询**
|
||||
|
||||
想象一下,我们班里有 $N$ 个同学,刚开学时大家互不相识,每个人都是一个独立的“小团体”。随着时间推移,不断有两人成为好朋友(例如“小明和小红成了好朋友”)。我们规定,**朋友的朋友也是朋友**,这样就会形成一个个越来越大的“朋友圈”。
|
||||
|
||||
现在,班主任需要随时进行两种操作:
|
||||
1. **合并 (Union)**:当两个人成为新朋友时,快速把他们各自的“朋友圈”合并成一个更大的圈子。
|
||||
2. **查询 (Find)**:随时提问:同学 A 和同学 B 现在属于同一个朋友圈吗?
|
||||
|
||||
##### **1.2 常规思路的困境**
|
||||
|
||||
我们尝试用已学的知识来解决这个问题:
|
||||
|
||||
* **思路一:用数组存圈子编号**
|
||||
* 定义 `group_id[i]` 表示 $i$ 号同学所在的圈子编号。
|
||||
* **查询 (A, B)**:只需比较 `group_id[A] == group_id[B]`。速度极快,时间复杂度 **O(1)**。
|
||||
* **合并 (A, B)**:假设A在1号圈,B在2号圈。为了合并,我们必须**遍历所有学生**,把所有组号为 2 的人都改成 1。速度慢,时间复杂度 **O(N)**。
|
||||
* 如果进行 $M$ 次合并,总耗时 $O(M \times N)$,数据量稍大就会超时。
|
||||
|
||||
* **思路二:用图的邻接表 + DFS/BFS**
|
||||
* 把同学看作顶点,朋友关系看作双向边。
|
||||
* **合并 (A, B)**:在图里加一条边 `adj[A].push_back(B)`。快,时间复杂度 **O(1)**。
|
||||
* **查询 (A, B)**:要判断A和B是否连通,必须从A开始做一次完整的 DFS 或 BFS 去找 B。慢,时间复杂度 **O(N+M)**。
|
||||
|
||||
**总结**:常规方法总是顾此失彼(查询快合并就慢,合并快查询就慢)。我们需要一种全新的数据结构,能让**合并和查询都非常快**。它就是我们今天要学习的——**并查集**。
|
||||
|
||||
---
|
||||
|
||||
#### **二、 核心思想**
|
||||
|
||||
并查集的核心思想,就像现实生活中的组织架构:
|
||||
每个圈子(集合)都推选出一个唯一的**“老大”(代表元)**,这个圈子里的所有事情,都由老大出面代表。
|
||||
|
||||
我们只需要一个非常简单的一维数组:
|
||||
`int parent[N];`
|
||||
* `parent[i]` 存储的是 $i$ 号同学的**直接上级**是谁。
|
||||
|
||||
##### **2.1 初始化**
|
||||
|
||||
一开始,所有人都不认识,每个人都是自己圈子的老大。
|
||||
```cpp
|
||||
void init(int n) {
|
||||
for (int i = 1; i <= n; i++) {
|
||||
parent[i] = i; // 初始时,每个人的上级就是他自己(自己是老大)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
##### **2.2 查询操作:`find(x)`**
|
||||
|
||||
**目标**:找出 $x$ 所在圈子的**最终老大**是谁。
|
||||
怎么找?顺藤摸瓜,逐级上报!
|
||||
|
||||
1. 问 $x$ 他的直接上级 `parent[x]` 是谁。
|
||||
2. 如果上级就是他自己 (`parent[x] == x`),说明 $x$ 就是最高级别的老大,寻找结束。
|
||||
3. 如果上级不是自己,就把寻找老大的任务抛给他的上级,去问 `find(parent[x])`。
|
||||
4. 这样一条链式地问上去,必定能找到最终老大。
|
||||
|
||||
如果只做到这一步,并查集还是慢的。试想一条长长的单线联系:1的上级是2,2的上级是3... 99的上级是100(老大)。1找老大要汇报99次!
|
||||
|
||||
##### **2.3 优化:路径压缩**
|
||||
|
||||
小兵 1 心想:既然我最终的老大是司令 100,那我干嘛每次都要通过班长、排长、连长逐级传话呢?我直接越级,**把司令设为我的直接上级**不行吗?
|
||||
|
||||
这就是并查集的优化——**路径压缩**。在 `find(x)` 函数**递归返回**的过程中,顺手把经过的所有节点,直接挂到最终老大的名下。
|
||||
|
||||
```cpp
|
||||
int find(int x) {
|
||||
// 递归边界:如果我自己就是老大,返回我自己
|
||||
if (parent[x] == x) {
|
||||
return x;
|
||||
}
|
||||
// 核心代码:
|
||||
// 1. find(parent[x]) 会递归找到最终老大。
|
||||
// 2. 将最终老大的编号直接赋值给 parent[x]。
|
||||
// 3. 返回这个最终老大。
|
||||
return parent[x] = find(parent[x]);
|
||||
}
|
||||
```
|
||||
经过路径压缩后,原本长长的一条链,就会被压缩到扁平。下次再执行 `find` 时,只需一步,时间复杂度接近 **O(1)**!
|
||||
|
||||
##### **2.4 合并操作:`unite(u, v)`**
|
||||
|
||||
有了 `find` 工具,合并操作实现起来就非常轻松。
|
||||
$u$ 和 $v$ 成为朋友,要求合并他们的朋友圈。怎么做?**让一个圈子的老大,去认另一个圈子的老大当新老大!**
|
||||
|
||||
```cpp
|
||||
void unite(int u, int v) {
|
||||
int rootU = find(u); // 找到 u 的老大
|
||||
int rootV = find(v); // 找到 v 的老大
|
||||
|
||||
// 如果老大不一样,说明本来不在一个圈子,需要合并
|
||||
if (rootU != rootV) {
|
||||
parent[rootU] = rootV; // 让 u 的老大 认 v 的老大 当上级
|
||||
}
|
||||
}
|
||||
```
|
||||
**思考**:只改了 `parent[rootU] = rootV` 这一行,整个圈子就合并了吗?
|
||||
**答案是肯定的!** 因为原来以 `rootU` 为老大的圈子里的小弟们,下次调用 `find` 找老大时,会先找到 `rootU`,然后发现 `rootU` 上面又有新老大了(`rootV`),于是大家都会顺藤摸瓜找到 `rootV`,并在路径压缩的帮助下,直接把自己挂到 `rootV` 名下。
|
||||
|
||||
---
|
||||
|
||||
#### **三、 实战演练**
|
||||
|
||||
##### **案例一:亲戚 (P238页 例17-1)**
|
||||
|
||||
**需求**:给出 $n$ 个人,$m$ 对亲戚关系(若 $x,y$ 是亲戚,$y,z$ 是亲戚,则 $x,z$ 也是亲戚)。随后有 $p$ 次询问,判断两个人是否是亲戚。
|
||||
|
||||
**分析**:
|
||||
|
||||
1. “亲戚关系传递” 就是把人合并到一个圈子。
|
||||
2. “判断是否是亲戚” 就是判断两人是不是在同一个圈子(老大是不是同一个人)。
|
||||
|
||||
**解题流程**:
|
||||
1. 读取 $n$,执行 `init(n)` 初始化并查集。
|
||||
2. 读取 $m$ 条关系,对每对关系执行 `unite(u, v)`。
|
||||
3. 读取 $p$ 次询问,对每对查询,判断 `find(u) == find(v)`。是则输出 Yes,否则输出 No。
|
||||
|
||||
##### **案例二:村村通 (B0248)**
|
||||
|
||||
**需求**:现有 $n$ 个城镇,$m$ 条已有道路。要求整个城镇网络全部连通(任意两城互达)。问:**最少还需要建设多少条道路?**
|
||||
|
||||
**分析**:
|
||||
城镇是节点,道路是边。这 $n$ 个节点初始时可能由几个互不连通的子图(岛屿)组成。
|
||||
**一个非常重要的图论结论**:要把 $K$ 个互不连通的岛屿连接成一个完整的连通图,最少需要 **$K - 1$** 条边。
|
||||
|
||||
现在的核心问题变成了:**如何计算当前图中,有多少个独立连通的“岛屿”(集合)?**
|
||||
|
||||
**并查集的妙用**:
|
||||
1. **初始状态**:$n$ 个城镇没有任何道路,就是 $n$ 个独立的集合,每个城镇自己是老大。
|
||||
2. **处理已有道路**:每输入一条道路 `(u, v)`,如果它们本来不连通(老大不同),我们就 `unite(u, v)` 将它们合并。
|
||||
3. **统计孤岛数量**:所有道路处理完后,我们如何知道还剩多少个集合?
|
||||
* 在并查集中,每个独立的集合**有且仅有一个最终老大**。
|
||||
* 老大的特征是什么?**`parent[i] == i`**!只有老大的上级是他自己。
|
||||
* 因此,我们只需遍历 $1$ 到 $n$,统计有多少个满足 `parent[i] == i` 的节点即可,假设数量为 $K$。
|
||||
4. **得出答案**:最少需要的道路数 = $K - 1$。
|
||||
|
||||
**核心代码片段**:
|
||||
```cpp
|
||||
int island_count = 0;
|
||||
for (int i = 1; i <= n; i++) {
|
||||
// 如果我的上级还是我自己,说明我是某个 孤岛集合 的老大
|
||||
if (parent[i] == i) {
|
||||
island_count++;
|
||||
}
|
||||
}
|
||||
cout << island_count - 1 << endl; // 需要的边数 = 孤岛数 - 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **四、 总结**
|
||||
|
||||
* **并查集 (DSU)** 是一种用于处理**不相交集合的合并及查询**问题的数据结构。
|
||||
* **核心操作**:
|
||||
* **`init`**:初始化 (`parent[i] = i`)。
|
||||
* **`find`**:找老大,并配合**路径压缩** (`parent[x] = find(parent[x])`),将长链压扁,保证效率。
|
||||
* **`unite`**:合并集合,老大认老大 (`parent[rootU] = rootV`)。
|
||||
* **应用场景**:朋友关系传递、连通性判断、图的连通块数量统计(如村村通问题)、以及未来将要学习的 Kruskal 最小生成树算法。
|
||||
* **复杂度**:在应用了路径压缩后,并查集的查询和合并操作的时间复杂度几乎可以看作是 **O(1)**(准确说复杂度应该是反阿克曼函数)。
|
||||
Binary file not shown.
@@ -0,0 +1,178 @@
|
||||
### **C++ STL 核心容器讲义:`set` 集合**
|
||||
|
||||
#### **一、 引言**
|
||||
|
||||
##### **1.1 问题引入:智能的木材仓库**
|
||||
|
||||
【例17-5 木材仓库】我们来管理一个特殊的木材仓库。这个仓库有以下几条严格的规则:
|
||||
1. **唯一性**:仓库里不会有两根长度完全相同的木材。
|
||||
2. **动态变化**:需要频繁地“进货”(加入新木材)和“出货”(取出木材)。
|
||||
3. **智能出货**:当需要取出一根长度为 `Length` 的木材时:
|
||||
* 如果有,就取出它。
|
||||
* 如果没有,不能随便拿,必须取出仓库中与之长度**最接近**的一根。
|
||||
* 如果最接近的有两根(一长一短),则**优先取短**的那一根。
|
||||
|
||||
##### **1.2 价值阐述:为什么需要 `set`?**
|
||||
|
||||
我们来分析一下这个仓库对数据容器的需求:
|
||||
1. **元素唯一**:这要求容器能自动处理重复元素。
|
||||
2. **动态增删**:容器必须支持高效的插入和删除。
|
||||
3. **快速查找“最接近”**:这是最核心、最难的需求。要找“最接近”,数据最好是**有序**的,这样我们才能快速定位到一个值和它的“左邻右舍”。
|
||||
|
||||
常规的 `vector` 或数组,虽然可以排序,但每次插入新元素都会破坏顺序,需要重新排序,效率极低。我们需要一个**“时刻保持有序”**且能**“自动去重”**的智能容器。
|
||||
|
||||
C++ STL (Standard Template Library) 中,就提供了这样一个完美的工具——`std::set`。
|
||||
|
||||
##### **1.3 学习目标**
|
||||
|
||||
学完本讲义,你将能够:
|
||||
1. 理解 `set` 的三大核心特性:有序性、唯一性、动态性。
|
||||
2. 熟练掌握 `set` 的创建、插入(`insert`)、查找(`find`)、删除(`erase`)等基本操作。
|
||||
3. 掌握使用 `lower_bound` 在 `set` 中高效查找“第一个大于或等于”目标值的元素。
|
||||
4. 运用 `set` 解决需要维护动态有序集合的复杂问题。
|
||||
|
||||
---
|
||||
|
||||
#### **二、 `set` 详解:一个自动排序与去重的集合**
|
||||
|
||||
##### **2.1 `set` 的核心特性**
|
||||
|
||||
`set` 是一个容器,你可以把它想象成一个“数学集合”。它最重要的三个特性是:
|
||||
|
||||
1. **元素唯一性 (Uniqueness)**:`set` 中不允许存在重复的元素。如果你尝试插入一个已经存在的元素,`set` 会自动忽略这次插入操作。
|
||||
2. **自动排序 (Ordering)**:无论你以何种顺序插入元素,`set` 内部都会**始终**按照元素的值(从小到大)来维护一个有序的序列。
|
||||
3. **动态增删 (Dynamic)**:可以高效地添加或删除元素,并且在操作后依然保持有序。
|
||||
|
||||
**内部原理揭秘**:`set` 之所以能拥有这些神奇的特性,是因为它的底层通常是由一种叫做**“红黑树”**(一种高度自平衡的二叉搜索树)的数据结构实现的。这保证了它的各项操作(插入、删除、查找)都具有非常高效的对数时间复杂度 $O(\log N)$。
|
||||
|
||||
##### **2.2 基本操作**
|
||||
|
||||
使用 `set` 前,必须包含头文件:`#include <set>`
|
||||
|
||||
1. **创建 `set`**:
|
||||
```cpp
|
||||
#include <set>
|
||||
using namespace std;
|
||||
|
||||
set<int> wood_set; // 创建一个名为 wood_set 的集合,专门存放 int 类型的木材长度
|
||||
```
|
||||
语法:`set<DataType> variable_name;`
|
||||
|
||||
2. **插入元素:`insert()`**
|
||||
* **作用**:将一个元素添加到集合中。
|
||||
* **用法**:`wood_set.insert(length);`
|
||||
* `set` 会自动完成排序和去重工作。
|
||||
|
||||
3. **查找元素:`find()`**
|
||||
* **作用**:在集合中查找指定的元素。
|
||||
* **返回值**:返回一个指向元素的**迭代器 (Iterator)**。迭代器可以理解为指向容器内部元素的一个“指针”或“书签”。
|
||||
* 如果**找到了**,返回指向该元素的迭代器。
|
||||
* 如果**没找到**,返回一个特殊的迭代器 `wood_set.end()`。`.end()` 本身不指向任何有效元素,它只是一个“集合末尾之后”的标志,专门用来表示“未找到”。
|
||||
* **用法**(判断元素是否存在):
|
||||
```cpp
|
||||
if (wood_set.find(length) != wood_set.end()) {
|
||||
cout << "Already Exist\n";
|
||||
} else {
|
||||
// 元素不存在,可以进行插入
|
||||
wood_set.insert(length);
|
||||
}
|
||||
```
|
||||
|
||||
4. **删除元素:`erase()`**
|
||||
* **作用**:从集合中移除指定的元素。
|
||||
* **用法**:`wood_set.erase(length);`
|
||||
|
||||
5. **检查是否为空:`empty()`**
|
||||
* **作用**:判断集合中是否有元素。
|
||||
* **用法**:`if (wood_set.empty()) { ... }`
|
||||
|
||||
---
|
||||
|
||||
#### **三、 实战演练:解决木材仓库问题**
|
||||
|
||||
##### **3.1 处理“进货”操作 (格式 `1 Length`)**
|
||||
|
||||
这个需求与 `set` 的 `find` 和 `insert` 操作契合。
|
||||
```cpp
|
||||
int type, length;
|
||||
cin >> type >> length;
|
||||
|
||||
if (type == 1) {
|
||||
if (wood_set.find(length) != wood_set.end()) {
|
||||
cout << "Already Exist\n";
|
||||
} else {
|
||||
wood_set.insert(length);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
##### **3.2 处理“出货”操作 (格式 `2 Length`)**
|
||||
|
||||
这是本题的核心难点,也是 `set` 有序性大放异彩的地方。
|
||||
|
||||
**第一步:处理空仓库的边界情况**
|
||||
```cpp
|
||||
if (wood_set.empty()) {
|
||||
cout << "Empty\n";
|
||||
continue; // 直接处理下一条操作
|
||||
}
|
||||
```
|
||||
|
||||
**第二步:寻找“最接近”的两个候选者**
|
||||
在一个有序的集合中,与目标 `Length` 最接近的数,只可能是:
|
||||
* **候选者 A (后继)**:在**大于或等于** `Length` 的数中,最小的那一个。
|
||||
* **候选者 B (前驱)**:在**严格小于** `Length` 的数中,最大的那一个。
|
||||
|
||||
如何高效地找到这两个候选者?答案是**二分查找**。`set` 的有序性让它天生支持二分。
|
||||
|
||||
**寻找候选者 A (后继):`lower_bound()`**
|
||||
`lower_bound` 是 C++ 算法库中的一个函数,它能在有序容器中进行二分查找,找到第一个**不小于**(即大于或等于)目标值的位置。
|
||||
|
||||
```cpp
|
||||
// 在 wood_set 中查找第一个不小于 length 的元素
|
||||
auto it_A = wood_set.lower_bound(length);
|
||||
```
|
||||
* `it_A` 是一个迭代器,指向候选者A。
|
||||
|
||||
**寻找候选者 B (前驱)**
|
||||
候选者 B 正是候选者 A 在有序序列中的**前一个元素**。
|
||||
|
||||
```cpp
|
||||
// 如果 it_A 不是集合的第一个元素,那么它一定有前驱
|
||||
if (it_A != wood_set.begin()) {
|
||||
auto it_B = prev(it_A); // C++11 之后,用 prev(it) 获取前一个迭代器
|
||||
// 或者 it_B = it_A; it_B--; (C++11 之前)
|
||||
// ... 此时 it_B 指向候选者 B ...
|
||||
}
|
||||
```
|
||||
|
||||
**第三步:比较候选者,做出最终选择**
|
||||
现在我们最多有两个候选者(由 `it_A` 和 `it_B` 指向),接下来就是简单的比较:
|
||||
1. 如果只有一个候选者(比如 `it_A` 是集合的第一个元素,没有前驱 `it_B`),那就直接选它。
|
||||
2. 如果两个都有,就分别计算它们与 `Length` 的差值。
|
||||
3. 如果差值不同,选差值小的那个。
|
||||
4. 如果差值相同,按题目要求,**优先选短的**,即选候选者B。
|
||||
|
||||
**第四步:执行出货**
|
||||
确定了最终要取出的木材 `best_choice` 之后,将其从集合中删除。
|
||||
```cpp
|
||||
cout << best_choice << "\n";
|
||||
wood_set.erase(best_choice);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **四、 总结**
|
||||
|
||||
`set` 是 C++ STL 中一个极其强大的容器,是解决**动态有序集合**相关问题。
|
||||
|
||||
* **核心原理**:底层由**平衡二叉搜索树**实现,保证了**有序性**和各项操作的**对数时间复杂度 ($O(\log N)$)**。
|
||||
* **三大特性**:**元素唯一**、**自动排序**、**高效增删**。
|
||||
* **核心操作回顾**:
|
||||
* **创建**: `set<T> s;`
|
||||
* **插入**: `s.insert(val);`
|
||||
* **判断存在**: `s.find(val) != s.end()`
|
||||
* **删除**: `s.erase(val);`
|
||||
* **查找下界**: `s.lower_bound(val);` (极其重要,用于在有序集合中定位)
|
||||
|
||||
当你遇到的问题需要维护一个不允许重复且时刻需要保持有序的数据集合时,第一时间就应该想到 `set`。
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,134 @@
|
||||
### **C++ 算法:图论(一)- 图的基本概念与模型 讲义**
|
||||
|
||||
#### **一、 引言**
|
||||
|
||||
##### **1.1 问题引入:从校园地图到数据结构**
|
||||
|
||||
我们已经学习了线性结构(如数组、链表)和树形结构(如二叉树)。前者模拟了“一对一”的关系,后者模拟了“一对多”的层次关系。但现实世界充满了更为复杂的联系。
|
||||
|
||||
例如校园地图:教学楼、食堂、图书馆、体育馆等建筑散布其间,复杂的道路网将它们连接起来。现在,我们面临一些实际问题:
|
||||
* 如何找到从大门口到图书馆的**最短路径**?
|
||||
* 如何规划一条路线,可以**不重复地走完**所有关键道路?
|
||||
* 如果某个路口正在施工,从A到B**是否还有其他路径**可以到达?
|
||||
|
||||
这些问题,都无法用简单的线性或树形结构来描述。我们需要一种新的数据结构,它能专门模拟这种“多对多”的复杂网络关系。这个结构,就是我们今天要学习的——**图(Graph)**。
|
||||
|
||||
##### **1.2 价值阐述:为什么图论很重要?**
|
||||
|
||||
图,是描述“多对多”关系的最强模型。它的应用无处不在:
|
||||
* **社交网络**:每个人是一个点,好友关系是一条线,构成一张巨大的社交图。
|
||||
* **交通网络**:城市是点,公路、铁路、航线是线,构成了交通图。导航软件的核心就是图算法。
|
||||
* **计算机网络**:路由器、服务器是点,网线是线,构成了计算机网络拓扑图。
|
||||
|
||||
---
|
||||
|
||||
#### **二、 核心思想:从具象到抽象**
|
||||
|
||||
##### **2.1 思路铺垫**
|
||||
|
||||
对于校园地图的例子。
|
||||
* 地图上的每一个关键**地点**(大门、教学楼、食堂...),我们把它抽象成一个**点**。
|
||||
* 连接两个地点之间的每一条**道路**,我们把它抽象成一条**线**。
|
||||
|
||||
通过这种方式,一张现实地图,就被简化成了一个由“点”和“线”组成的数学图形。这就是图的本质。
|
||||
|
||||
##### **2.2 图的数学定义**
|
||||
|
||||
在数学和计算机科学中,我们对图有更精确的定义:
|
||||
一个图 `G` 是一个二元组 `(V, E)`,记为 `G = (V, E)`。
|
||||
|
||||
* **`V` (Vertex Set)**:一个非空的**顶点**集合。每个顶点代表一个实体或对象。
|
||||
* **`E` (Edge Set)**:一个**边**的集合。每条边连接 `V` 中的两个顶点,代表它们之间存在某种连接关系。
|
||||
|
||||
**示例:**
|
||||
|
||||
<img src="C:\Users\xiaolinquan\AppData\Roaming\Typora\typora-user-images\image-20260529171113949.png" width="20%" alt="image-20260529171113949">
|
||||
|
||||
对于上图 `G`:
|
||||
* 顶点集合 V = {1, 2, 3, 5}
|
||||
* 边集合 E = {(1, 2), (1, 3), (2, 3), (2, 5)}
|
||||
|
||||
##### **2.3 图的核心组成元素**
|
||||
|
||||
1. **顶点 (Vertex)**
|
||||
* 也称为**节点**。在图中,它就是那些独立的“点”,用于代表现实世界中的事物。
|
||||
* **例如**:校园地图中的“建筑物”,社交网络中的“用户”,交通网络中的“城市”。
|
||||
|
||||
2. **边 (Edge)**
|
||||
* 连接两个顶点的“线”,用于表示两个顶点(事物)之间的特定关系。
|
||||
* **例如**:地图中的“道路”,社交网络中的“好友关系”,交通网络中的“航线”。
|
||||
|
||||
---
|
||||
|
||||
#### **三、 图的分类与属性**
|
||||
|
||||
现实世界的关系是多种多样的,因此图也有不同的类型和属性来描述它们。
|
||||
|
||||
##### **3.1 无向图**
|
||||
|
||||
* **定义**:图中的每条边都是**双向**的。如果从顶点u到顶点v有一条边,那么也必然可以从v回到u。边的表示用圆括号 `(u, v)`,且 `(u, v)` 与 `(v, u)` 代表同一条边。
|
||||
* **现实类比**:双向通行的马路、互为好友的社交关系。
|
||||
* **相关术语:度**
|
||||
* 一个顶点的**度**,是指与该顶点**直接相连**的边的数量。
|
||||
* **示例**:在 讲义 **2.2 节** 的图中,顶点2的度为3,因为它连接了1、3、5三个顶点。
|
||||
|
||||
##### **3.2 有向图**
|
||||
|
||||
* **定义**:图中的每条边都是**单向**的,具有明确的方向性。边的表示用括号来表示 `<u, v>`,它表示一条从u出发,指向v的边,这与 `<v, u>` 是两条完全不同的边。
|
||||
* **类比**:单行道、社交网络中的“关注”关系(A关注B,不代表B一定关注A)。
|
||||
* **相关术语:入度 与 出度**
|
||||
* 一个顶点的**入度**:指**指向**该顶点的边的数量。
|
||||
* 一个顶点的**出度**:指由该顶点**出发**指向其他顶点的边的数量。
|
||||
|
||||
##### **3.3 带权图**
|
||||
|
||||
* **定义**:图中的每条边都被赋予一个数值,这个数值称为**权重 或**成本。这个权重可以代表各种现实意义。
|
||||
* **边权**:边的权重。
|
||||
* **例如**:地图中道路的**长度**或通过所需**时间**;网络中传输数据的**带宽**或**延迟**;交通网络中的**票价**或**距离**。
|
||||
* **点权**:类似地,顶点也可以被赋予权重,代表该点自身的某种属性值(例如,一个城市的人口,一个景点的好评度)。
|
||||
|
||||
##### **3.4 简单图**
|
||||
|
||||
在大多数算法问题中,我们处理的图都是**简单图**,它满足两个条件:
|
||||
1. 不包含**自环**:即一条连接顶点和其自身的边。
|
||||
2. 不包含**重边**:即连接同一对顶点的多条边。
|
||||
|
||||
我们后续的讨论,如无特殊说明,都将基于简单图。
|
||||
|
||||
---
|
||||
|
||||
#### **四、 图的拓扑属性与概念**
|
||||
|
||||
除了基本的分类,图还具有一些描述其整体结构与连接特性的概念。
|
||||
|
||||
##### **4.1 连通性 **
|
||||
|
||||
* **连通**:若图中从顶点u到顶点v存在一条通路(可以经过若干中间顶点),则称 u 和 v 是**连通**的。
|
||||
* **连通图**:如果一个**无向图**中,**任意两个顶点**都是连通的,那么这个图被称为**连通图**。反之,如果图中存在至少一个“孤岛”,则为**非连通图**。
|
||||
* **强连通图**:这个概念专属于**有向图**。如果一个有向图中,对**任意两个顶点**u和v,都**同时存在**从u到v的路径 **和** 从v到u的路径,则称该图为**强连通图**。
|
||||
|
||||
##### **4.2 完全图**
|
||||
|
||||
* **定义**:图中**任意两个不同的顶点之间都存在一条边**的图,称为完全图。完全图是边数达到最大值的最“稠密”的图。
|
||||
* **无向完全图**:一个有n个顶点的无向完全图,其边数为 `e = n * (n - 1) / 2`。
|
||||
* **有向完全图**:一个有n个顶点的有向完全图,任意两点间都存在方向相反的两条边,其边数为 `e = n * (n - 1)`。
|
||||
|
||||
##### **4.3 路径**
|
||||
|
||||
* **路径**:在图中,从一个起点到一个终点所经过的**顶点序列**,就是一条路径。
|
||||
* **最短路径**:在一个带权图中,连接两个顶点的所有路径中,**路径上各边权重之和最小**的那一条路径,被称为最短路径。这是图论算法中一个非常核心、经典的问题。
|
||||
|
||||
---
|
||||
|
||||
#### **五、 总结**
|
||||
|
||||
* **图的本质**:图是用于描述事物间“**多对多**”关系的数学模型,由**顶点(Vertex)**和**边(Edge)**构成,即 `G = (V, E)`。
|
||||
* **核心分类**:
|
||||
* **无向图**:边是双向的,关系是相互的。顶点的核心属性是**度**。
|
||||
* **有向图**:边是单向的,关系有明确方向。顶点的核心属性是**入度**和**出度**。
|
||||
* **重要属性**:
|
||||
* **带权图**:边上附有数值(**权重**),代表成本、距离、时间等。
|
||||
* **拓扑概念**:
|
||||
* **连通性**:描述了图中顶点间路径的可达性,分为**连通图**、**强连通图**等。
|
||||
* **完全图**:一种边最稠密的特殊图,任意两点间都直接相连。
|
||||
* **路径**:是图的遍历基础,而**最短路径**是图算法的核心应用之一(之后的课程会详细讲解)。
|
||||
Binary file not shown.
@@ -0,0 +1,194 @@
|
||||
### **C++ 算法:图论(二)- 图的存储结构 讲义**
|
||||
|
||||
#### **一、 引言**
|
||||
|
||||
##### **1.1 问题引入**
|
||||
|
||||
我们已经知道,一张校园地图可以被抽象为一个由顶点(建筑)和边(道路)组成的图。但计算机不认识“点”和“边”,我们必须找到一种方式,用代码来描述“哪个顶点和哪个顶点相连?”以及“连接它们的道路有多长?”这个问题。
|
||||
|
||||
换言之,我们需要一个**数据结构**,来充当理论模型与算法实现之间的桥梁。
|
||||
|
||||
而存储图有多种办法,不同的方法会影响后续图算法的**时间复杂度**和**空间复杂度**。
|
||||
|
||||
---
|
||||
|
||||
#### **二、 存储方法一:邻接矩阵 (Adjacency Matrix)**
|
||||
|
||||
##### **2.1 核心思想:关系地图**
|
||||
|
||||
邻接矩阵:我们创建一个二维数组,用它来记录**每一对顶点之间**是否存在边。
|
||||
|
||||
例如我们有一张城市间的航班距离表:
|
||||
|
||||
| | 北京 | 上海 | 广州 |
|
||||
| :------- | :----- | :----- | :----- |
|
||||
| **北京** | 0 | 1200km | 2100km |
|
||||
| **上海** | 1200km | 0 | 1400km |
|
||||
| **广州** | 2100km | 1400km | 0 |
|
||||
|
||||
这个表格就是一个邻接矩阵。行和列都代表图的顶点,表格中的`[行, 列]`位置的数值,就代表了对应两个顶点间的关系(边的信息)。
|
||||
|
||||
##### **2.2 实现细节**
|
||||
|
||||
在C++中,我们通常使用一个二维数组 `G[N][N]` 来表示邻接矩阵,其中 `N` 是顶点的最大数量。`G[u][v]` 的值代表了从顶点 `u` 到顶点 `v` 的边的信息。
|
||||
|
||||
* **对于无权图**:
|
||||
* `G[u][v] = 1` 表示从 `u`到 `v` 存在一条边。
|
||||
* `G[u][v] = 0` 表示从 `u`到 `v` 不存在边。
|
||||
|
||||
* **对于带权图**:
|
||||
* `G[u][v] = w` 表示从 `u`到 `v` 存在一条边,且其权重为 `w`。
|
||||
* 如果边不存在,我们可以用一个特殊值来表示,通常是 **0, -1 或 无穷大**。如果用一个很大的整数(可以用 `0x3f3f3f3f`)来模拟无穷大,这样如果需要进行一些计算的时候的时候不易溢出。
|
||||
|
||||
* **对于无向图**:
|
||||
* 图中的边是双向的,所以矩阵是**对称**的。即 `G[u][v]` 和 `G[v][u]` 的值总是相等。我们在存边时,需要同时设置这两个值。
|
||||
|
||||
##### **2.3 代码实现**
|
||||
|
||||
我们实现储存一个顶点数量为 n , 边的数量为 m 的一个有向带权图(权重定义为顶点之间的距离)。
|
||||
|
||||
```cpp
|
||||
#include <bits/stdc++.h>
|
||||
using namespace std;
|
||||
|
||||
int main() {
|
||||
int n, m; // n个顶点, m条边
|
||||
cin >> n >> m;
|
||||
|
||||
// vector的大小为 n x n,初始全部设置为 0,代表不存在任何边
|
||||
vector<vector<int> > G(n, vecotr<int>(n, 0));
|
||||
|
||||
for (int i = 0; i < m; i++) {
|
||||
int u, v, w;
|
||||
cin >> u >> v >> w; // 读入一条从u到v,权重为w的边
|
||||
|
||||
// 如果是无向图,需要存两条边
|
||||
G[u][v] = w;
|
||||
G[v][u] = w; // 若是有向图,则没有这一行
|
||||
}
|
||||
|
||||
// 查询从顶点2到顶点3的距离(权值)
|
||||
if (G[2][3] == 0) {
|
||||
cout << "顶点2和3没有边相连" << endl;
|
||||
} else {
|
||||
cout << "顶点2到3的距离是: " << G[2][3] << endl;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
##### **2.4 优劣分析**
|
||||
|
||||
* **优点**:
|
||||
* **实现简单**:非常直观,易于理解和编写。
|
||||
* **查询效率高**:判断两个顶点 `u` 和 `v` 是否有边只需访问 `G[u][v]` 即可,时间复杂度为 **O(1)**。
|
||||
* **缺点**:
|
||||
* **空间复杂度高**:无论图中有多少条边,都需要一个 `N*N` 的空间,空间复杂度为 **O(N²)**。
|
||||
* **对于稀疏图(边数远小于顶点数的平方)来说,空间浪费巨大**。例如,1万个点但只有100条边的图,矩阵中绝大部分空间都是储存的 0,代表没有边相连,被浪费了。
|
||||
* **遍历邻居效率低**:要找到一个顶点的所有邻居,必须遍历一整行,时间复杂度为 **O(N)**。
|
||||
|
||||
---
|
||||
|
||||
#### **三、 存储方法二:邻接表 (Adjacency List)**
|
||||
|
||||
##### **3.1 核心思想:记录我的邻居是谁**
|
||||
|
||||
邻接矩阵像一张全国地图,记录了所有城市两两之间的关系,信息很全但很占地方。而邻接表则更像每个人的通讯录,只记录与自己有直接关系的人。
|
||||
|
||||
具体来说,我们为图中的**每一个顶点**,都维护一个**列表**,这个列表里只存放与该顶点**直接相连**的其他顶点(以及边的权重,如果是带权图)。
|
||||
|
||||
##### **3.2 通过`struct`封装实现邻接表**
|
||||
|
||||
为了更好地组织和管理图的数据,我们可以使用一个自定义的结构体 `Graph` 来表示和存储一个图。这种方式将图的所有关键信息打包在一起,让代码结构更清晰。
|
||||
|
||||
**1. 定义图的结构体**
|
||||
```cpp
|
||||
// 定义图的结构体
|
||||
struct Graph {
|
||||
int V = -1, E = -1; // V:顶点数量, E:边的数量
|
||||
vector<vector<int>> adjList; // 邻接表
|
||||
};
|
||||
```
|
||||
* `V` 和 `E` 分别存储图的顶点数和边数。
|
||||
* `adjList` 是一个 `vector` 的 `vector`。一个动态的二维数组。`adjList[u]` 本身就是一个 `vector`,它将作为顶点 `u` 的“邻居通讯录”,里面存放所有与 `u` 直接相连的顶点编号。
|
||||
|
||||
**2. 初始化图的结构**
|
||||
在使用这个图之前,我们需要一个初始化函数来设置其规模,并准备好存放邻居列表的空间。
|
||||
|
||||
```cpp
|
||||
struct Graph {
|
||||
int V = -1, E = -1; // V:顶点数量, E:边的数量
|
||||
vector<vector<int>> adjList; // 邻接表
|
||||
|
||||
// 初始化函数,设置顶点和边的数量
|
||||
void setCount(int new_V, int new_E) {
|
||||
V = new_V; E = new_E;
|
||||
// 一共有 V 个顶点,故需要 V 个对应顶点的通讯录来储存直接相连的顶点编号
|
||||
adjList.resize(new_V);
|
||||
}
|
||||
};
|
||||
```
|
||||
* `setCount` 函数接收顶点的数量 `new_V`,然后调用 `resize` 方法,将 `adjList` 变成一个包含 `new_V` 个空 `vector` 。
|
||||
|
||||
**3. 添加边**
|
||||
图的结构准备好后,我们需要一个方法来添加边,以描述顶点间的连通情况。
|
||||
|
||||
```cpp
|
||||
struct Graph {
|
||||
// 省略之前的结构体内容...以下为添加边的函数
|
||||
void addEdge(int src, int dest) {
|
||||
if (V == -1) return; // 确保图已被初始化
|
||||
|
||||
// 代表存在一条边src->sest, 在起点 src 的相连顶点数组中添加顶点 dest
|
||||
// 注意如果是无向图,还需要添加从 dest 到 src 的反向边
|
||||
adjList[src].push_back(dest);
|
||||
}
|
||||
};
|
||||
```
|
||||
* `addEdge` 函数就是在起点 `src` 的相连顶点列表 `adjList[src]` 的末尾,添加上终点 `dest`。
|
||||
* **注意**:对于**无向图**,由于边是双向的,添加一条 `(u, v)` 的边,意味着 `v` 是 `u` 的邻居,同时 `u` 也是 `v` 的邻居。因此需要**双向添加**。
|
||||
|
||||
##### **3.3 代码实现:使用`Graph`结构体**
|
||||
|
||||
下面我们通过一个完整的例子,演示如何使用这个 `Graph` 结构体来存储一个**有向无权图**。
|
||||
|
||||
```cpp
|
||||
int main() {
|
||||
// 创建一个图对象
|
||||
Graph G;
|
||||
int V, E;
|
||||
cin >> V >> E;
|
||||
|
||||
// 调用初始化函数,准备好邻接表的结构
|
||||
G.setCount(V, E);
|
||||
|
||||
// 读取 E 条边,并添加到图中
|
||||
for (int i = 0; i < E; ++i) {
|
||||
int src, dest;
|
||||
cin >> src >> dest;
|
||||
G.addEdge(src, dest);
|
||||
}
|
||||
|
||||
// 此时,图G中已经存好了所有的顶点和边信息,可以进行操作,例:打印顶点3的所有邻居
|
||||
for (int neighbor : G.adjList[3]) {
|
||||
cout << neighbor << " ";
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
##### **3.4 优劣分析**
|
||||
|
||||
* **优点**:
|
||||
* **空间效率高**:邻接表只存储实际存在的边,空间复杂度为 **O(N + M)**(N为顶点数,M为边数)。对于使用邻接矩阵的的 **O(N²)**,节省了大量空间。
|
||||
* **遍历邻居效率高**:邻接表要找到一个顶点的所有邻居,只需遍历其对应的 `vector` 即可,时间复杂度为 **O(出度(u))**,其中 `出度(u)` 是顶点u的度。优于邻接矩阵的遍历邻居时间复杂度**O(N)** 。
|
||||
* **缺点**:
|
||||
* **查询效率较低**:邻接表判断两个顶点 `u` 和 `v` 是否有边,需要遍历 `adj[u]` 这个列表,时间复杂度为 **O(出度(u))**,不如邻接矩阵直接查询的 O(1) 快。
|
||||
|
||||
#### **四、 总结**
|
||||
|
||||
* 我们学习了两种主流的图存储方法:**邻接矩阵**和**邻接表**。
|
||||
* **邻接矩阵**是一个二维数组,用 `G[u][v]` 存储边的信息,它像一张**完整的地图**,优点是查询快,缺点是浪费空间。
|
||||
* **邻接表**是一个 `vector` 数组,`adj[u]` 存储了 `u` 的所有邻居,它像一本**通讯录**,优点是节省空间,缺点是查询稍慢。选择主要在于分析图的**疏密程度**:**稠密用矩阵,稀疏用表**。
|
||||
* **在信息学竞赛和实际应用中,由于图的稀疏性,其实邻接表的使用频率会稍高于邻接矩阵。**
|
||||
Binary file not shown.
@@ -0,0 +1,185 @@
|
||||
### **C++ 算法:图论(三)- 图的遍历 讲义**
|
||||
|
||||
#### **一、 引言**
|
||||
|
||||
##### **1.1 问题引入**
|
||||
|
||||
我们已经熟悉了数组和树的遍历。数组的遍历是一条直线,从头到尾即可。树的遍历虽有岔路,但结构清晰,从父到子,不会回头。
|
||||
|
||||
但是,图的遍历要复杂得多。我们面临着两个新的问题:
|
||||
1. **连接的顶点多**:在树中,一个节点最多连接少数几个子节点。但在图中,一个顶点可能连接着任意数量的其他顶点,我们该如何选择前进的方向?
|
||||
2. **存在“环”**:在树中,我们绝不会走回祖先节点。但在图中,从A出发,经过B、C,完全可能再回到A,形成一个“环”。如果程序处理不当,就会陷入无限循环。
|
||||
|
||||
让我们通过书本中的**【例18-3 查找文献】**案例,来学习如何解决这些问题。
|
||||
|
||||
小K正在阅读一篇编号为1的博客文章。这篇文章引用了若干其他文章作为参考文献,而这些参考文献又各自引用了更多的文章,形成了一个庞杂的引用网络。小K求知欲旺盛,他决定:**只要读了一篇文章,就必须把它引用的所有没读过的文章都读一遍。**
|
||||
|
||||
现在,我们拥有这个引用网络的完整数据(`n`篇文章,`m`条引用关系)。问题是:我们如何设计一个策略,能帮助小K**不重复、不遗漏**地看完所有他从文章1出发能接触到的文章?
|
||||
|
||||
我们可以对问题进行**抽象**为图论问题:
|
||||
|
||||
- 每一篇**文章**,都可以看作一个**顶点**。
|
||||
- 每一条从文章X到文章Y的**引用关系**,都可以看作一条从X指向Y的**有向边**。
|
||||
|
||||
因此,这个问题被成功地转化为了一个图论问题:**从图的某个起始顶点出发,遍历所有可达的顶点。**
|
||||
|
||||
---
|
||||
|
||||
#### **二、 核心思想:如何做到“不重复、不遗漏”?**
|
||||
|
||||
##### **2.1 用`visited`数组避免重复**
|
||||
|
||||
为了解决图遍历中可能出现的“环”和“重复访问”问题,我们引入一个关键工具:**`visited`布尔数组**。
|
||||
|
||||
* **定义**:`bool visited[N];`
|
||||
* **作用**:用于记录对应的顶点我们是否已经访问过。`visited[i] = true` 表示顶点 `i` 我们已经访问过这个顶点了。
|
||||
* **规则**:
|
||||
1. 每当我们**首次**访问一个顶点时,立刻在“笔记”上记录下来:`visited[i] = true`。
|
||||
2. 在尝试访问任何一个顶点之前,检查**:`if (!visited[i])`**。我们只访问还没有到过的顶点。
|
||||
|
||||
这个`visited`数组是保证遍历**不重复**的关键,是DFS和BFS都必须使用的基础工具。
|
||||
|
||||
##### **2.2 DFS 与 BFS** 遍历图
|
||||
|
||||
解决了不重复的问题,我们如何保证不遗漏呢?有两种策略:
|
||||
|
||||
* **策略一:深度优先(DFS)- “一条路走到底”**
|
||||
* 当你读了文章A,发现它引用了B和C。你选择立刻深入进去,先把B以及B的所有参考文献、B的参考文献的参考文献……全部看完,直到这条链条的尽头。然后才“回溯”过来看文章C。
|
||||
* 一条路走到黑,不撞南墙不回头。
|
||||
|
||||
* **策略二:广度优先(BFS)- “逐层拓展”**
|
||||
* 当你读完文章A,发现它引用了B和C。你不会立刻深入,而是先把同一层级的B和C都看完。然后,再去看B和C共同引用的下一层级的所有文章。
|
||||
* 一层一层地看,像水波一样由近及远地扩散。
|
||||
|
||||
---
|
||||
|
||||
#### **三、 深度优先遍历 (DFS)**
|
||||
|
||||
##### **3.1 算法精讲**
|
||||
|
||||
DFS体现的是一条路走到底的策略。其过程符合**递归**的定义:要访问顶点`u`的所有可达点,我们先访问`u`,然后对`u`的每一个邻居`v`,再去递归地执行“访问`v`的所有可达点”这一相同的任务。它的两个重要动作是**深入探索**和**回溯**。
|
||||
|
||||
##### **3.2 步骤拆解(以查找文献的样例数据为例子)**
|
||||
|
||||
<img src="C:\Users\xiaolinquan\AppData\Roaming\Typora\typora-user-images\image-20260602155335576.png" alt="image-20260602155335576" style="zoom:33%;" />
|
||||
|
||||
DFS的遍历过程为:
|
||||
1. **从1出发**:访问**1**,标记`visited[1]=true`。1的邻居有2, 3, 4。
|
||||
2. **深入2**:选择邻居2,递归调用DFS(2)。访问**2**,标记`visited[2]=true`。2的邻居有5, 6。
|
||||
3. **深入5**:选择邻居5,递归调用DFS(5)。访问**5**,标记`visited[5]=true`。5没有邻居,已到达尽头。
|
||||
4. **回溯到2**:从5的调用中返回。检查2的下一个邻居6。
|
||||
5. **深入6**:选择邻居6,递归调用DFS(6)。访问**6**,标记`visited[6]=true`。6没有邻居。
|
||||
6. **回溯到2**:从6返回。2的所有邻居都已探索完毕。
|
||||
7. **回溯到1**:从2返回。检查1的下一个邻居3。
|
||||
8. **深入3 -> 7 -> 8**:重复上述过程,一路深入到8,再层层回溯到1。
|
||||
9. **探索4**:检查1的下一个邻居4,递归调用DFS(4)。访问**4**,标记`visited[4]=true`。
|
||||
10. **检查4的邻居**:尝试访问7和8,但检查发现`visited[7]`和`visited[8]`都已为`true`,故跳过。
|
||||
11. **回溯**:从4返回1。1的所有邻居探索完毕。遍历结束。
|
||||
|
||||
##### **3.3 代码实现**
|
||||
|
||||
```cpp
|
||||
// DFS函数,v是当前访问的顶点, adj: 邻接表, visited: 访问数组
|
||||
void DFS(int v) {
|
||||
visited[v] = true;
|
||||
cout << v << " "; // 访问当前节点
|
||||
|
||||
// 遍历v的所有邻居
|
||||
for (int neighbor : adj[v]) {
|
||||
// 如果邻居未被访问过,则递归深入
|
||||
if (!visited[neighbor]) {
|
||||
DFS(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 题目要求中邻居按小编号优先,所以在注意在主函数中对邻接表排序
|
||||
for (int i = 1; i <= n; i++) {
|
||||
sort(adj[i].begin(), adj[i].end());
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **四、 广度优先遍历 (BFS)**
|
||||
|
||||
##### **4.1 算法思路**
|
||||
|
||||
BFS是“逐层扩散”的思路。它必须先处理完最早发现的节点,再处理新发现的节点。这种“先进先出”的规则,契合了**队列(Queue)**的数据结构。
|
||||
|
||||
**算法流程**:
|
||||
1. 创建一个队列,并将起始顶点放入队列。标记起始顶点为已访问。
|
||||
2. 当队列不为空时,循环执行以下操作:
|
||||
a. 从**队头**取出一个顶点`u`。
|
||||
b. 访问`u`。
|
||||
c. 遍历`u`的所有邻居`v`,如果`v`未被访问过,则标记`v`并将其放入**队尾**。
|
||||
|
||||
##### **4.2 步骤拆解(以查找文献的样例数据为例子)**
|
||||
|
||||
<img src="C:\Users\xiaolinquan\AppData\Roaming\Typora\typora-user-images\image-20260602155335576.png" alt="image-20260602155335576" style="zoom:33%;" />
|
||||
|
||||
| 步骤 | 操作 | 队列状态 (Queue) | 已访问 (Visited) | 输出 |
|
||||
| :--- | :---------------------------- | :--------------- | :---------------- | :--- |
|
||||
| 1 | 初始状态,1入队 | `[1]` | {1} | |
|
||||
| 2 | 1出队,访问1,其邻居2,3,4入队 | `[2, 3, 4]` | {1,2,3,4} | 1 |
|
||||
| 3 | 2出队,访问2,其邻居5,6入队 | `[3, 4, 5, 6]` | {1,2,3,4,5,6} | 2 |
|
||||
| 4 | 3出队,访问3,其邻居7入队 | `[4, 5, 6, 7]` | {1,2,3,4,5,6,7} | 3 |
|
||||
| 5 | 4出队,访问4,其邻居8入队 | `[5, 6, 7, 8]` | {1,2,3,4,5,6,7,8} | 4 |
|
||||
| ... | ... | ... | ... | ... |
|
||||
| 10 | 队列为空,结束 | - | - | |
|
||||
|
||||
**最终访问顺序:1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8**
|
||||
|
||||
##### **4.3 代码实现**
|
||||
|
||||
```cpp
|
||||
// DFS函数,v是当前访问的顶点, adj: 邻接表, visited: 访问数组
|
||||
void BFS(int v) {
|
||||
queue<int> q;
|
||||
|
||||
q.push(v); // 起始点入队
|
||||
visited[v] = true;
|
||||
|
||||
while (!q.empty()) {
|
||||
int u = q.front(); q.pop(); // 取出队头
|
||||
|
||||
cout << u << " "; // 访问
|
||||
|
||||
// 根据邻接表遍历所有邻居
|
||||
for (int v : adj[u]) {
|
||||
if (!visited[v]) {
|
||||
visited[v] = true;
|
||||
q.push(v); // 新发现的顶点入队
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// main函数与DFS版本只是调用函数不同而已
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **五、 总结与应用**
|
||||
|
||||
##### **5.1 如何将现实问题抽象为图?**
|
||||
|
||||
图的表达能力极强,世界上绝大多数涉及“物体”和“连接”的问题,都可以抽象为图。只需问自己两个问题:
|
||||
1. **物体是什么?** -> 它们成为图的**顶点**。
|
||||
2. **连接是什么?** -> 它们成为图的**边**。
|
||||
|
||||
* **社交网络**:用户是顶点,好友关系是边。找“朋友的朋友”就是图的遍历。
|
||||
* **城市导航**:路口是顶点,道路是边。找“最短路线”就是图的最短路径搜索。
|
||||
|
||||
##### **5.2 拓展:非连通图的遍历**
|
||||
|
||||
在本题中,我们只需从顶点1开始遍历。但如果一个图由多个互不相连的部分组成(非连通图),我们该如何遍历所有顶点?
|
||||
|
||||
**解决办法**:在 `main` 函数中使用一个 `for` 循环,从 1 到 n 检查每一个顶点 `i`。如果发现 `!visited[i]`,就说明我们发现了一个新的、独立的连通块,此时再以 `i` 为起点,调用DFS或BFS来探索这个新部分。
|
||||
|
||||
```cpp
|
||||
for (int i = 1; i <= n; i++) {
|
||||
if (!visited[i]) { // 如果还没有被访问过,从点i开始遍历这个新的连通图
|
||||
DFS(i); // 或 BFS(i)
|
||||
}
|
||||
}
|
||||
```
|
||||
Binary file not shown.
@@ -0,0 +1,157 @@
|
||||
### **C++ 算法:图论(四)- 反向图**
|
||||
|
||||
#### **一、 引言**
|
||||
|
||||
##### **1.1 问题引入:寻找可达的最大顶点**
|
||||
|
||||
【例18-4】 图的遍历
|
||||
|
||||
给出一个 N 个点、M 条边的有向图。对于图中的每一个顶点 v,我们需要求出 A(v),它表示**从点 v 出发,沿着有向边能到达的所有顶点中,编号最大的那一个**。
|
||||
|
||||
例如,在一个图中,如果从顶点3开始出发只能到达 {3, 5, 8, 2},那么 A(3) 的值就是8。我们需要为图中所有的点(从1到N)都计算出这个值。
|
||||
|
||||
---
|
||||
|
||||
#### **二、 解题思路**
|
||||
|
||||
##### **2.1 思路一:暴力遍历**
|
||||
|
||||
面对这个问题,我们的第一反应通常是:对每一个点 `u`(从1到N),都从它出发做一次完整的 DFS 或 BFS,记录下所有能访问到的顶点,然后从中找出一个最大值。
|
||||
|
||||
* **复杂度分析**:
|
||||
* 一次完整的图遍历,复杂度为 O(N+M)。
|
||||
* 我们需要对 N 个点都执行一次。
|
||||
* 总时间复杂度 = N * O(N+M) = O(N² + N*M)。
|
||||
* **结论**:题目给出的数据范围 N, M 高达 10⁵。N² 级别的复杂度会达到 10¹⁰,程序必然超时。**暴力方法虽然思路正确,但效率过低,不是可行解。**
|
||||
|
||||
##### **2.2 思路二:记忆化搜索**
|
||||
|
||||
既然暴力不行,我们来优化。分析 A(u) 的答案构成:
|
||||
`A(u) = max(u, A(v₁), A(v₂), ...)`,其中 `v₁`, `v₂`... 是 `u` 的所有邻居。
|
||||
|
||||
这是一个递归的结构,但纯粹的递归会产生海量的**重复计算**。例如,计算A(0)时可能需要算A(2),而计算A(1)时可能也需要算A(2)。A(2)会被重复计算多次。
|
||||
|
||||
**优化方案:记忆化搜索**
|
||||
我们创建一个`Answer`数组,`Answer[i]` 用来存储 A(i) 的计算结果。
|
||||
```cpp
|
||||
int A(int u) {
|
||||
if (Answer[u] != 0) { // 如果算过了
|
||||
return Answer[u]; // 直接返回记好的答案
|
||||
}
|
||||
int ans = u;
|
||||
for (int v : adj[u]) {
|
||||
ans = max(ans, A(v));
|
||||
}
|
||||
Answer[u] = ans; // 算出来后,记在笔记本上
|
||||
return ans;
|
||||
}
|
||||
```
|
||||
|
||||
##### **2.3 缺陷:有环图中的循环依赖**
|
||||
|
||||
记忆化搜索看似能解决问题,但在有向有环图中,它会陷入死循环。
|
||||
考虑这样一个环:`2 -> 4 -> 3 -> 2`
|
||||
|
||||
* 为了计算 `A(2)`,程序需要先知道 `A(4)`。
|
||||
* 为了计算 `A(4)`,程序需要先知道 `A(3)`。
|
||||
* 为了计算 `A(3)`,程序需要先知道 `A(2)`。
|
||||
|
||||
这就形成了一个逻辑上的循环:一个尚未完成计算的函数,被它自己的调用链反向依赖。就像老板问经理要项目结果,经理问员工,员工却反过来问老板结果是什么。程序会因无限递归而崩溃。
|
||||
|
||||
---
|
||||
|
||||
#### **三、 解决方案:反向图**
|
||||
|
||||
既然正向求解(我能到哪?)的思路在有环时碰壁了,我们不妨**逆转思维**。
|
||||
|
||||
##### **3.1 从“我去哪”到“谁来找我”**
|
||||
|
||||
* **正向思考:我(顶点`u`)出发,能到达的最大编号是多少?
|
||||
* **反向思考:对于一个编号很大的顶点 `k`,有哪些点可以到达它?对于所有能到达`k`的点`u`来说,`A(u)`的候选答案里就必然包含了`k`。
|
||||
|
||||
##### **3.2 反向图的定义与构建**
|
||||
|
||||
为了实现“谁能到我”的查询,我们引入**反向图**。
|
||||
|
||||
* **定义**:对于一个有向图 G,其反向图 G_rev 拥有和 G 完全相同的顶点。对于 G 中的任意一条边 `u -> v`,在 G_rev 中都对应一条方向相反的边 `v -> u`。
|
||||
* **构建**:在读入原图的边 `u -> v` 时,我们不执行 `adj[u].push_back(v)`,而是执行 `rev_adj[v].push_back(u)`,即在**终点v**的邻接表中加入**起点u**。
|
||||
* **功能**:在**反向图**上从顶点 `k` 开始遍历,所有能被访问到的点,就是在**原图**中所有能到达 `k` 的点。
|
||||
|
||||
##### **3.3 结合贪心思想**
|
||||
|
||||
我们的目标是求最大值,一个很自然的**贪心**想法是:让编号越大的顶点,越有优先权去宣告自己是答案。
|
||||
|
||||
**算法流程**:
|
||||
1. **反向建图**:读入 `m` 条边 `u -> v`,构建反向图(添加边 `v -> u`)。
|
||||
2. **初始化**:创建一个 `ans` 数组,全部初始化为-1,表示所有点的答案待定。
|
||||
3. **倒序遍历与广播**:
|
||||
* 我们从大到小遍历所有顶点 `i` (从 `N` 到 `1`)。
|
||||
* 对于当前的顶点 `i`,检查 `ans[i]`。
|
||||
* **如果 `ans[i]` 仍为-1**:这说明 `i` 没有被任何比它更大的顶点“覆盖”过。它就是它所在“可达区域”的最大顶点。此时,我们从 `i` 出发,在**反向图**上进行一次遍历(DFS或BFS),将所有能访问到且`ans`值仍为-1的顶点`u`,都标记上答案:`ans[u] = i`。
|
||||
* **如果 `ans[i]` 不为-1**:这说明 `i` 已经被一个比它更大的顶点(比如 `j > i`)“覆盖”了,那么 `i` 就不可能是它自己的最终答案。我们直接跳过,什么也不做。
|
||||
4. **输出结果**:倒序循环结束后,`ans`数组中就存储了所有点的最终答案。
|
||||
|
||||
---
|
||||
|
||||
#### **四、 代码实现**
|
||||
|
||||
```cpp
|
||||
vector<vector<int> > rev_adj; // 存储反向图的邻接表
|
||||
vector<int> ans; // 存储最终答案
|
||||
|
||||
// 在反向图上进行DFS,将答案i“广播”出去
|
||||
void dfs(int u, int max_node_val) {
|
||||
ans[u] = max_node_val; // 标记当前点的答案
|
||||
|
||||
for (int v : rev_adj[u]) {
|
||||
if (ans[v] == 0) { // 如果邻居还未被广播过
|
||||
dfs(v, max_node_val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
int n, m;
|
||||
cin >> n >> m;
|
||||
rev_adj.resize(n + 1);
|
||||
ans.resize(n + 1);
|
||||
// 反向建图
|
||||
for (int i = 0; i < m; i++) {
|
||||
int u, v;
|
||||
cin >> u >> v;
|
||||
rev_adj[v].push_back(u); // 建立 v -> u 的反向边
|
||||
}
|
||||
|
||||
// 倒序遍历与广播
|
||||
for (int i = n; i >= 1; i--) {
|
||||
if (ans[i] == 0) { // ans[i]为0代表未被访问过
|
||||
// i没有被更大的节点覆盖,它就是自己区域的最大顶点
|
||||
// 开始以i为答案,在反向图上传播
|
||||
dfs(i, i);
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 1; i <= n; i++) {
|
||||
cout << ans[i] << " ";
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **五、 反向图的应用与总结**
|
||||
|
||||
##### **5.1 什么时候应该用反向图?**
|
||||
|
||||
反向图本质上是一种**视角转换工具**。当遇到以下情况时,可以考虑使用它:
|
||||
|
||||
1. **反向可达性查询**:当问题关心的不是“从A能到哪”,而是“谁能到A”时。
|
||||
2. **正向求解遇阻**:当正向的递归或递推因为“循环依赖”或需要“未来状态”而陷入困境时,可以尝试从确定的终点或最优解出发,反向地去影响和更新前端节点。
|
||||
|
||||
##### **5.2 总结**
|
||||
|
||||
* 面对一个复杂的图问题,直接的暴力解法往往因为效率问题而不可行。
|
||||
* **记忆化搜索**是优化递归、避免重复计算的强大技巧,但要警惕它在**有环图**中可能导致的**循环依赖**问题。
|
||||
* **反向图**提供了一种全新的解题视角,能将“A能到哪”的问题转化为“谁能到A”,从而将一个复杂的依赖问题转化为标准的图遍历问题。
|
||||
Binary file not shown.
Binary file not shown.
Reference in new issue
Block a user