Initial commit: C++ courseware
This commit is contained in:
commit
4a4ee7c287
1789 files changed
+373489
No files matched your search
@@ -0,0 +1,157 @@
|
||||
### **C++ 算法讲义:贪心算法(上)**
|
||||
|
||||
### **1. 引言**
|
||||
|
||||
#### **1.1 问题引入:如何使得资源分配最优化**
|
||||
|
||||
在很多领域,我们频繁面对资源受限下的最优化问题。一个经典的抽象模型是:给定一组物品,每种物品都具有特定的价值和成本(例如重量、体积或时间消耗),以及一个总的成本预算(例如背包容量)。目标是在不超过预算的前提下,选取一部分或全部物品,使得总价值最大化。
|
||||
|
||||
这个问题,被称为**背包问题**,是运筹学和算法理论中的一个核心问题。根据物品是否可分割,它又分为两种主要变体。我们从其中更适合贪心策略的**部分背包问题**入手。
|
||||
|
||||
#### **1.2 贪心算法的地位与应用**
|
||||
|
||||
贪心算法是一种基础而强大的算法。它代表了一种简洁、高效的解决问题的世界观。其核心在于:
|
||||
|
||||
* **高效性**:相较于需要穷举所有可能性的暴力搜索算法,贪心算法通常具有多项式 $O(n+m)$ 时间复杂度,实现高效。在处理大规模数据时,这种效率优势尤为关键。
|
||||
* **基础性**:许多图论和组合优化领域的基础算法都根植于贪心思想。例如,计算图的最小生成树的 Prim 算法和 Kruskal 算法,以及计算单源最短路径的 Dijkstra 算法,其核心都包含了一个贪心选择的过程,我们以后就将会学习二叉树与图论。
|
||||
* **启发性算法**:即便在贪心策略无法保证得到全局最优解的复杂问题(NP-hard 问题)中,贪心算法也常被用作一种有效的近似算法或启发式方法,就算得不到最优解,也能够快速给出一个质量较高的可行解。
|
||||
|
||||
---
|
||||
|
||||
### **2. 核心思想**
|
||||
|
||||
#### **2.1 问题引入:部分背包问题(例12-1 OJ:B0174)**
|
||||
|
||||
**题目描述:**
|
||||
|
||||
阿里巴巴走进了装满宝藏的藏宝洞。藏宝洞里面有 $N(N \le 100)$ 堆金币,第 $i$ 堆金币的总重量和总价值分别是 $m_i,v_i(1\le m_i,v_i \le 100)$。阿里巴巴有一个承重量为 $T(T \le 1000)$ 的背包,但并不一定有办法将全部的金币都装进去。他想装走尽可能多价值的金币。所有金币都可以随意分割,分割完的金币重量价值比(也就是单位价格)不变。请问阿里巴巴最多可以拿走多少价值的金币?
|
||||
|
||||
面对部分背包问题,即物品可被分割,我们的目标是最大化总价值。直觉上,存在多种可能的“贪心”标准:
|
||||
* **价值最高优先**:优先装入总价值最高的物品。此策略的缺陷是显而易见的,一个价值极高但重量巨大的物品可能会占据所有容量,使得总价值远低于组合多个小物品的情况。
|
||||
* **重量最轻优先**:优先装入重量最轻的物品,以期装入更多数量的物品。此策略同样存在问题,因为它完全忽略了物品的价值,可能会导致背包被大量低价值物品填满。
|
||||
|
||||
上述直觉性策略都是不成立了,于是我们应该**寻找一个正确的贪心标准。**
|
||||
|
||||
对于部分背包问题,一个更合理的标准应该是衡量单位重量所能提供的价值,即**价值密度(性价比)**。
|
||||
|
||||
#### **2.2 贪心算法的性质**
|
||||
|
||||
**定义**:贪心算法在求解问题的每一步中,都做出一个局部最优的选择。它寄希望于一系列的局部最优选择能够产生一个全局最优解。
|
||||
|
||||
一个问题能够使用贪心算法求解,通常需要满足以下两个关键性质:
|
||||
|
||||
1. **无后效性**
|
||||
即某个状态以前的过程**不会影响以后的状态,只与当前状态有关**。换句话说,我们做出的一个贪心选择,只会将原问题简化为一个规模更小的、同性质的子问题,而我们此前的选择并不会影响这个子问题的最优解。也就是**一个全局最优解可以通过一系列局部最优的选择来达到**。
|
||||
|
||||
2. **最优子结构**
|
||||
一个问题的最优解包含了其子问题的最优解。当我们做出一个贪心选择后,原问题就简化为了一个规模更小的、性质相同的子问题。例如,当我们将一个物品装入背包后,剩下的问题就变成了:使用剩余的背包容量,在剩余的物品中获取最大价值。这个子问题的解,结合我们已做出的选择,将构成原问题的解。
|
||||
|
||||
一个贪心策略的正确性必须经过严格的数学证明,通常使用“交换论证法”(也叫反证法)来证明。即假设存在一个不采用贪心选择的最优解,通过一系列交换操作,可以将其转化为一个采用贪心选择且结果不劣于原解的方案,从而证明贪心选择的正确性。
|
||||
|
||||
---
|
||||
|
||||
### **3. 实例:部分背包问题**
|
||||
|
||||
#### **3.1 案例描述**
|
||||
给定一个容量为 `W` 的背包和 `n` 个物品。第 `i` 个物品的重量为 `weight[i]`,价值为 `value[i]`。物品可以被分割。求解如何装包使得总价值最大。
|
||||
|
||||
**样例输入:**
|
||||
* 背包容量 `W = 50`
|
||||
* 物品 (weight, value):
|
||||
* Item 1: (10, 60)
|
||||
* Item 2: (20, 100)
|
||||
* Item 3: (30, 120)
|
||||
|
||||
#### **3.2 思路展示与推演**
|
||||
|
||||
1. **贪心标准**:选择价值密度 `value/weight` 最高的物品。
|
||||
2. **计算并排序**:
|
||||
* Item 1: 密度 = 60 / 10 = 6.0
|
||||
* Item 2: 密度 = 100 / 20 = 5.0
|
||||
* Item 3: 密度 = 120 / 30 = 4.0
|
||||
排序后的顺序为:Item 1 > Item 2 > Item 3。
|
||||
3. **决策过程**:
|
||||
* **处理 Item 1**:背包剩余容量为50。Item 1重量为10,可完全装入。
|
||||
* 装入10kg的Item 1。
|
||||
* 背包更新:容量 `50 - 10 = 40`。总价值 `+ 60`。
|
||||
* **处理 Item 2**:背包剩余容量为40。Item 2重量为20,可完全装入。
|
||||
* 装入20kg的Item 2。
|
||||
* 背包更新:容量 `40 - 20 = 20`。总价值 `+ 100`。
|
||||
* **处理 Item 3**:背包剩余容量为20。Item 3重量为30,无法完全装入。
|
||||
* 只能装入剩余容量允许的部分,即20kg的Item 3。
|
||||
* 背包更新:容量 `20 - 20 = 0`。总价值 `+ (20/30) * 120 = 80`。
|
||||
4. **最终结果**:
|
||||
* 背包已满。总价值为 `60 + 100 + 80 = 240`。
|
||||
|
||||
---
|
||||
|
||||
### **4. 代码实现**
|
||||
|
||||
为了实现上述逻辑,我们通常定义一个结构体来表示物品,然后使用C++标准库的 `sort` 配合自定义比较函数来完成排序。
|
||||
|
||||
#### **4.1 关键代码**
|
||||
|
||||
```cpp
|
||||
#include <bits/stdc++.h>
|
||||
using namespace std;
|
||||
|
||||
// 定义物品结构体
|
||||
struct Item {
|
||||
int m; // 重量
|
||||
int v; // 价值
|
||||
double ratio; // 价值密度 (性价比)
|
||||
};
|
||||
|
||||
// 自定义比较函数,用于sort排序,按价值密度降序
|
||||
bool cmp(const Item& a, const Item& b) {
|
||||
return a.ratio > b.ratio;
|
||||
}
|
||||
|
||||
int main() {
|
||||
int n;
|
||||
double t; // 背包容量可能需要参与浮点数运算,用double更方便
|
||||
cin >> n >> t;
|
||||
|
||||
vector<Item> items(n);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
cin >> items[i].m >> items[i].v;
|
||||
// 计算价值密度,注意要进行浮点数除法
|
||||
items[i].ratio = (double)items[i].v / items[i].m;
|
||||
}
|
||||
|
||||
// 按价值密度从高到低排序
|
||||
sort(items.begin(), items.end(), cmp);
|
||||
|
||||
double total_value = 0.0;
|
||||
|
||||
// 遍历排序后的物品
|
||||
for (int i = 0; i < n; ++i) {
|
||||
// 如果背包容量为0,提前结束
|
||||
if (t == 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 如果能装下整个物品
|
||||
if (t >= items[i].m) {
|
||||
total_value += items[i].v;
|
||||
t -= items[i].m;
|
||||
} else { // 如果只能装下部分物品
|
||||
// 装入部分物品,价值 = 剩余容量 * 价值密度
|
||||
total_value += t * items[i].ratio;
|
||||
t = 0; // 背包已满
|
||||
}
|
||||
}
|
||||
|
||||
// 按格式输出,保留两位小数
|
||||
cout << fixed << setprecision(2) << total_value << endl;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
#### **4.2 代码解读**
|
||||
* **`struct Item`**:封装了物品的重量,价值等属性。
|
||||
* **`cmp` / `sort`**:这是贪心策略的实现。我们通过提供一个自定义比较函数,让 `sort` 算法按照我们定义的贪心标准(价值密度降序)来排列物品。
|
||||
* **循环**:循环体内的`if-else`结构实现了贪心决策。首先尝试完整地装入一个物品;如果容量不足,则计算能装入的部分,并将其价值计入总和,随后由于背包已满,循环提前终止。
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,260 @@
|
||||
### **C++ 算法:贪心算法(下)讲义**
|
||||
|
||||
##### **1.1 问题引入:分卷子**
|
||||
现在有一大摞混在一起的试卷,已知 A, B, C, D 四个等级的卷子数量。智能分卷机每次只能执行一次二分操作,即将一摞卷子分成两堆,且同等级的卷子必须在同一堆。
|
||||
|
||||
每次操作的**成本**,等于当前被分的那一摞卷子的**总数量**。请设计一个最优的分类方案,使得总成本最小。
|
||||
|
||||
例如,A, B, C, D 的数量分别为 10, 20, 30, 40。
|
||||
* **方案一 (1+3拆分):**
|
||||
1. `{A,B,C,D}` -> `{A}` + `{B,C,D}`,成本 = 10+20+30+40 = 100
|
||||
2. `{B,C,D}` -> `{B}` + `{C,D}`,成本 = 20+30+40 = 90
|
||||
3. `{C,D}` -> `{C}` + `{D}`,成本 = 30+40 = 70
|
||||
4. 总成本 = 100 + 90 + 70 = **260**
|
||||
* **方案二 (2+2拆分):**
|
||||
1. `{A,B,C,D}` -> `{A,B}` + `{C,D}`,成本 = 10+20+30+40 = 100
|
||||
2. `{A,B}` -> `{A}` + `{B}`,成本 = 10+20 = 30
|
||||
3. `{C,D}` -> `{C}` + `{D}`,成本 = 30+40 = 70
|
||||
4. 总成本 = 100 + 30 + 70 = **200**
|
||||
|
||||
不同的拆分结构,导致了完全不同的成本。这引发了我们的核心问题:最优的拆分结构是什么?
|
||||
|
||||
##### **1.2 学习目标**
|
||||
1. 掌握贪心模型——**霍夫曼模型**。
|
||||
2. 使用**双队列法**高效实现霍夫曼模型。
|
||||
3. 识别并将“合并果子”、“哈夫曼编码”等问题归纳到霍夫曼模型中。
|
||||
|
||||
---
|
||||
|
||||
### **2. 分卷子**
|
||||
|
||||
#### **2.1 正向思考**
|
||||
一个自然的猜想是:为了让总成本小,应该尽早地把数量多的卷子分离出去,这样它就不用参与后续的拆分,似乎能降低成本。
|
||||
例如,对于 {10, 20, 30, 40},我们每次都分离出当前最大的一堆:
|
||||
1. `{A,B,C,D}` -> `{D}` + `{A,B,C}`,成本 100
|
||||
2. `{A,B,C}` -> `{C}` + `{A,B}`,成本 60
|
||||
3. `{A,B}` -> `{B}` + `{A}`,成本 30
|
||||
4. 总成本 = 100 + 60 + 30 = **190**。这确实比之前的200和260都小!
|
||||
|
||||
但这个策略是普适的吗?考虑 {30, 30, 30, 30}:
|
||||
* **按“最大”分离:** 成本 = 120 + 90 + 60 = **270**。
|
||||
* **按“对半分”:** 成本 = 120 + 60 + 60 = **240**。
|
||||
|
||||
**结论:** “每次分离出最大的一堆”这个贪心策略是**错误**的。问题的关键不仅在于分离哪个,还在于**拆分的结构**。正向思考(从上往下拆分)让我们陷入了困境。
|
||||
|
||||
---
|
||||
|
||||
### **3. 逆向思维**
|
||||
|
||||
当一条路走不通时,不妨倒过来走。**“从上往下”拆分的最优解,就是等价于“从下往上”合并的最小成本解!**
|
||||
|
||||
每一次拆分操作,成本是被拆分堆的总量。反过来看,每一次合并操作,成本是被合并堆的总量。这两个过程是等价的。
|
||||
|
||||
于是可以将“分卷子”问题,转化为一个更经典的逆向问题——**合并果子**。
|
||||
|
||||
> **问题描述:** 有 `n` 堆果子,每次可以合并任意两堆,合并的体力消耗等于两堆果子的重量之和。求将所有果子合并成一堆的最小总体力消耗。
|
||||
|
||||
##### **3.1 贪心策略的确立**
|
||||
假设有三堆果子 {1, 2, 9}:
|
||||
* 先合并 1 和 9 (成本10),再与 2 合并 (成本12),总成本 22。
|
||||
* 先合并 2 和 9 (成本11),再与 1 合并 (成本12),总成本 23。
|
||||
* 先合并 1 和 2 (成本3),再与 9 合并 (成本12),总成本 **15**。
|
||||
|
||||
我们发现,一个较早合并产生的大重量堆(如10或11),会像滚雪球一样,在后续的合并中**反复贡献**它的重量,从而累加出巨大的总成本。
|
||||
|
||||
为了让这个雪球滚得尽可能慢、尽可能小,我们的贪心策略必须是:**每一次合并,都选择当前果园里重量最小的两堆果子进行合并。**
|
||||
|
||||
---
|
||||
|
||||
### **4. 霍夫曼模型与实现**
|
||||
|
||||
我们将“合并果子”这种问题抽象出来,称之为**霍夫曼模型**。
|
||||
|
||||
#### **4.1 模型定义**
|
||||
当一个问题符合以下特征时,它就属于霍夫曼模型:
|
||||
1. **初始元素:** 给定 `n` 个带权值的独立元素。
|
||||
2. **操作规则:** 存在一种“二合一”的合并操作。
|
||||
3. **成本计算:** 每次合并的成本是被合并两元素的权值之和,新产生的元素权值也等于这个和。
|
||||
4. **目标:** 经过 `n-1` 次合并后,使总成本最小。
|
||||
|
||||
**对于所有符合霍夫曼模型的问题,最优贪心策略都是:永远优先合并当前权值最小的两个元素。**
|
||||
|
||||
#### **4.2 高效实现:双队列法**
|
||||
如何高效地“永远找到并合并最小的两个”?
|
||||
一个想法是:将所有数字放入一个数组,排序,取出最小的两个合并,把新数字放回数组,再排序... 这样做效率比较低。
|
||||
|
||||
这里我们介绍一种巧妙的**双队列法**:
|
||||
|
||||
**核心思想:**
|
||||
1. 初始的 `n` 堆果子,我们先将其**从小到大排序**,放入第一个队列 `Q1`。
|
||||
2. 所有通过合并**新产生**的果子堆,我们放入第二个队列 `Q2`。
|
||||
|
||||
**关键洞察:**
|
||||
* `Q1` 内部本身是有序的。
|
||||
* 我们总是合并最小的两个,所以后合并产生的新堆,重量一定不小于先合并产生的新堆。因此,我们每次将新堆放入 `Q2` 的**队尾**,`Q2` 内部也自然地**保持了有序**!
|
||||
* 基于此,在任何时刻,要寻找全局的最小值,它只可能出现在哪里?**`Q1` 的队头** 或 **`Q2` 的队头**。
|
||||
|
||||
**实现步骤:**
|
||||
|
||||
1. 将所有 `n` 个初始值读入数组,并进行一次**升序排序**。
|
||||
2. 创建两个队列(或用 `vector` 模拟),`Q1` 存储排序后的初始权值,`Q2` 为空。
|
||||
3. 循环 `n-1` 次,执行合并操作:
|
||||
a. 定义一个 `get_min()` 函数,它只比较 `Q1` 和 `Q2` 的队头,返回并弹出较小的那个。(需处理其中一个队列为空的情况)。
|
||||
b. 调用两次 `get_min()`,得到全局最小的两个元素 `min1`, `min2`。
|
||||
c. 计算成本 `cost = min1 + min2`,并累加到总成本中。
|
||||
d. 将新产生的权值 `cost` **放入 `Q2` 的队尾**。
|
||||
4. 循环结束后,累加的总成本即为最小总成本。
|
||||
|
||||
这个方法的总时间复杂度为 `O(N log N)`,主要来自最初的一次排序,非常高效。
|
||||
|
||||
**关键代码(哈夫曼编码和合并果子都是本代码):**
|
||||
|
||||
```cpp
|
||||
// get_min 函数从两个有序队列的队头取出一个全局最小值
|
||||
// 实现非常简单故省略,只需要比较两个队列的队头元素谁更小返回即可(注意判空!)
|
||||
int get_min(queue<int>& q1, queue<int>& q2);
|
||||
|
||||
// 以下为 main 函数中内容
|
||||
// 先对进行一次升序排序
|
||||
sort(a.begin(), a.end());
|
||||
|
||||
// 从两个有序队列的队头取出一个全局最小值
|
||||
for (int i = 0; i < n - 1; ++i) { // 进行 n-1 次合并
|
||||
// 取出全局最小的两个数
|
||||
int first_min = get_min(Q1, Q2);
|
||||
int second_min = get_min(Q1, Q2);
|
||||
int current_cost = first_min + second_min;
|
||||
total_cost += current_cost;
|
||||
Q2.push(current_cost); // 将新堆放入 Q2
|
||||
}
|
||||
// 最终输出 total_cost 即可
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **5. 哈夫曼编码**
|
||||
|
||||
至此,我们已经掌握了通过逆向思维,用“合并”来解决“拆分”问题的思想。现在,我们将这个思想应用到一另一个问题上——**数据压缩**。而这个问题的最优解,正是以其发明者命名的**哈夫曼编码**。
|
||||
|
||||
**问题:** 为一组字符设计一种二进制**前缀码**(即任何字符的编码不能是另一字符编码的开头部分),使得出现**频率高的字符编码尽可能短**,**频率低的字符编码可以长一些**。最终目标是使编码后的信息流**总长度最小**。
|
||||
|
||||
#### **5.1 核心目标**
|
||||
|
||||
要解决这个问题,我们首先要理解一串信息的总长度是怎么计算的。
|
||||
`总长度 = Σ (每个字符的频率 × 其编码的长度)`
|
||||
|
||||
这个公式有点抽象。让我们把它转化成一个更直观的猜谜问题:
|
||||
* **编码:** 计算机用 `0` 和 `1` 进行编码。
|
||||
* **猜谜:** 我们通过问一系列“是/否”问题来确定一个字符。
|
||||
|
||||
可以建立一个对应关系:**一个字符的“编码长度”,就等于在这场猜谜游戏中,唯一确定它所需要问的“问题个数”。**
|
||||
|
||||
例如,设计了这样一套问题流程,在集合 {A, B, C, D} 中选择一个字符让你猜测:
|
||||
1. 第一个问题:“它在A/B组里吗?” -> 是(0) / 否(1)
|
||||
2. 第二个问题:“它是A吗?” -> 是(0) / 否(1)
|
||||
如果一个字符的“答案记录”是 `00`,那么它的编码就是 `01`,编码长度为2,因为我们问了2个问题才找到它。
|
||||
3. 因为这套答案记录具有唯一性,所以答案的记录就可以直接构成该字符的二进制编码(例如 00 唯一指代着A, 01 就唯一指代着 B)。
|
||||
|
||||
在这个模型下,==**一个字符的“编码长度”,就严格等于找到它所需要问的“问题个数”**==。所以,我们的目标可以转化为:
|
||||
**==最小化总编码长度 = 最小化总提问次数==**
|
||||
也就是:**总提问次数 = Σ (每个字符的频率 × 找到它所需的问题数)**
|
||||
|
||||
#### **5.2 正向思考的难点:第一步应该问什么?**
|
||||
|
||||
有了这个清晰的目标,我们该如何设计提问顺序呢,使得我们**能以更短的问题区分出频率最大的字符**?
|
||||
比如,对于频率为 `{5, 10, 13, 14, 20}` 的五个字符,我们的第一个问题该怎么问?
|
||||
|
||||
* 是问:“它在 `{5, 10}` 这组里吗?”
|
||||
* 还是问:“它在 `{20}` 这组里吗?”
|
||||
* 或者问:“它在 `{13, 14}` 这组里吗?”
|
||||
|
||||
这是一个非常难的开端。第一个问题有无数种问法,不同的问法会产生完全不同的后续问题。无法判断哪一个开局能导向全局最优。
|
||||
|
||||
#### **5.3 逆向思维:最后一个问题问什么?**
|
||||
|
||||
当正向思考走不通时,再次使用逆向思维。不去想**第一个**问题,我们去想**最后一个**问题。
|
||||
|
||||
在一个完整的提问流程中,“最后一个问题”是用来区分哪两个选项的?
|
||||
根据我们的贪心原则“**频率低的,问的问题也可以越多**”,那些被藏在问题链条最深处、需要最多问题才能找到的,必然是**频率最低**的两个字符!
|
||||
|
||||
这给了我们一个出发点:
|
||||
1. **第一步:** 在 `{5, 10, 13, 14, 20}` 中,找到两个频率最低的元素:**5** 和 **10**。
|
||||
2. **构建过程:** 我们可以把这两个字符看作一个小团体。为了区分它俩,我们会设计一个专门的问题。这个专门的问题,在整个提问结构中,将处于最末端。
|
||||
3. **逆向合并:** 我们把这个小团体“合并”成一个整体,并思考这个合并操作的“代价”是什么。
|
||||
|
||||
#### **5.4 “合并代价”的含义**
|
||||
|
||||
“合并 5 和 10” 产生的和 `15`,它的物理意义是什么?
|
||||
|
||||
让我们把那个专门用于区分“5”和“10”的问题称为 `Q_5_10`。
|
||||
* **`Q_5_10` 何时被问到?** 只有当我们已经确定目标字符就是“5”或“10”两者之一时,才会问这个问题。
|
||||
* **`Q_5_10` 总共会被问到多少次?** 在解码整个信息流的过程中,**每当出现一次频率为5的字符,或频率为10的字符**,我们最终都需要通过 `Q_5_10` 来做出最后区分。因此,这个问题总共会被问到 `5 + 10 = 15` 次。
|
||||
|
||||
**结论:**
|
||||
**每一次合并操作的代价(两个频率之和),就等于区分这两个子集的问题,在整个信息中==被提出的总次数==。**
|
||||
|
||||
`Q_5_10`这个问题一共会被询问15次,这个询问次数就是代价,也是**==这次合并对“总提问次数”(也就是总编码长度)的贡献值==**。
|
||||
|
||||
#### **5.5 算法流程**
|
||||
|
||||
现在,问题豁然开朗。因为前面已经论述了:在这个模型下,==**一个字符的“编码长度”,就严格等于找到它所需要问的“问题个数”**==。所以,我们的目标可以转化为:**要最小化总编码长度 就是要最小化总提问次数**。
|
||||
|
||||
而最小的总提问次数的求取就像是上面的 `Q_5_10` 一样,`Q_5_10`这个问题一共会被询问15次,这个询问次数就是代价,也是**这次合并对总的提问次数的贡献值**。
|
||||
|
||||
故我们可以得到公式:**最小总编码长度 = Σ (每次合并操作的代价)**
|
||||
|
||||
这和“合并果子”问题中,“最小总体力消耗 = Σ (每次合并的体力)”是**完全等价**的!
|
||||
|
||||
#### **5.6 举例实验**
|
||||
|
||||
* **初始频率集合:{5, 10, 13, 14, 20}**
|
||||
|
||||
* **第一次合并:**
|
||||
* **操作:** 找到并合并两个频率最低的元素 `5` 和 `10`,形成一个代表 `{5, 10}` 子问题的新节点,其频率为 `5 + 10 = 15`。
|
||||
* **意义:** **此次合并的代价为 15**。这表示:在整个信息流中,那个用来区分“频率为5的字符”和“频率为10的字符”的最终问题,总共会被问到 **15** 次。
|
||||
* **剩余集合:** `{13, 14, 15, 20}`
|
||||
|
||||
* **第二次合并:**
|
||||
* **操作:** 在新集合中,合并最小的 `13` 和 `14`,新频率为 `13 + 14 = 27`。
|
||||
* **意义:** **此次合并的代价为 27**。这代表区分“频率为13的字符”和“频率为14的字符”的问题,总共会被问到 **27** 次。
|
||||
* **剩余集合:** `{15, 20, 27}`
|
||||
|
||||
* **第三次合并:**
|
||||
* **操作:** 合并最小的 `15` 和 `20`,新频率为 `15 + 20 = 35`。
|
||||
* **意义:** **此次合并的代价为 35**。这里的 `15` 代表了 `{5, 10}` 组合。因此,这个代价的含义是:那个用来区分“`{5, 10}` 组”和“频率为20的字符”的问题,总共会被问到 **35** 次。
|
||||
* **剩余集合:** `{27, 35}`
|
||||
|
||||
* **最后一次合并:**
|
||||
* **操作:** 合并最后的 `27` 和 `35`,得到根节点,频率为 `27 + 35 = 62`。
|
||||
* **意义:** **此次合并的代价为 62**。这代表整个提问流程中的第一个问题,即用来区分“`{13, 14}` 组”和“`{5, 10, 20}` 组”的顶层问题,总共会被问到 **62** 次(也就是每次都需要问)。
|
||||
* **剩余集合:** `{62}`,合并完成。
|
||||
|
||||
#### **计算总成本:**
|
||||
|
||||
我们将每一次合并产生的“代价”——即每个中间节点的频率——相加:
|
||||
|
||||
`总成本 = 15 (区分5和10) + 27 (区分13和14) + 35 (区分{5,10}和20) + 62 (区分{13,14}和{5,10,20}) = 139`
|
||||
|
||||
这就是最终的最小总编码长度。每一次合并的代价,都代表了一个“区分问题”在整个信息流中被提出的总次数。将所有这些“区分问题”的提问次数加起来,自然就等于所有字符在被唯一确定前,需要被提问的总次数。
|
||||
|
||||
通过这种方式,将一个看似复杂的编码问题,转化为了一个直观的、可累加的“合并代价”问题,并用贪心策略找到了最优解。
|
||||
|
||||
**也可以通过类比的方式发现哈夫曼编码的构建过程,与合并果子是等价的!**
|
||||
|
||||
* 字符频率 <==> 果子重量
|
||||
* 合并频率 <==> 合并果子
|
||||
* 最小总编码长度 <==> 最小总体力消耗
|
||||
|
||||
---
|
||||
|
||||
### **6. 总结与思考**
|
||||
|
||||
#### **6.1 核心要点**
|
||||
* **霍夫曼模型**:**目前可以暂时不理解哈夫曼编码**,但是要理解这个霍夫曼模型是“n元素带权,二合一合并求最小总成本”的问题。
|
||||
* **核心策略**:并且也要知道**霍夫曼模型的解决策略永远优先合并当前权值最小的两个元素**。
|
||||
* **逆向思维**:当正向的“拆分”或“构建”问题无从下手时,尝试逆向的合并过程。
|
||||
* **高效实现**:**双队列法**是一种不依赖特殊数据结构,仅通过排序和队列就能高效解决霍夫曼模型的巧妙方法。
|
||||
|
||||
#### **6.2 思考**
|
||||
1. **文件压缩**:思考一下,为什么哈夫曼编码能实现文件压缩?什么类型的文件压缩率会比较高?
|
||||
2. **模型变种**:如果每次可以合并 `k` 堆果子,而不是2堆,贪心策略应该如何调整?
|
||||
Binary file not shown.
@@ -0,0 +1,144 @@
|
||||
### **C++ 算法讲义:贪心算法(中)**
|
||||
|
||||
#### **1. 引言**
|
||||
|
||||
在上节课,我们解决了一个资源分配问题(部分背包)。今天,我们从资源调度、任务处理等领域来看贪心策略的应用。我们将通过“排队接水”这个经典问题,来进行探讨。
|
||||
|
||||
##### **1.1 问题引入:排队接水**
|
||||
有 $n$ 个人在一个水龙头前排队接水,每个人接水的时间为 $T_i$。请设计一种排队顺序,使得所有人的**平均等待时间**最小。注意:一个人的等待时间,是指排在他前面所有人接水时间的总和,不包括他自己的接水时间。
|
||||
|
||||
---
|
||||
|
||||
### **2. 贪心模型一:排序贡献问题**
|
||||
|
||||
#### **2.1 问题分析:从“平均”到“总和”**
|
||||
|
||||
对于“排队接水”问题,我们的目标是“平均等待时间最小”。根据平均值的定义:
|
||||
$平均等待时间 = (所有人的等待时间之和) / n$
|
||||
|
||||
由于人数 $n$ 是固定的,要让平均值最小,就等价于让分子 **所有人的等待时间之和** 最小。问题被简化了。
|
||||
|
||||
现在,我们来分析“等待时间之和”是如何构成的。假设排队顺序为 $t_1, t_2, ..., t_n$:
|
||||
* 第1个人等待时间:$0$
|
||||
* 第2个人等待时间:$t_1$
|
||||
* 第3个人等待时间:$t_1 + t_2$
|
||||
* ...
|
||||
* 第$n$个人等待时间:$t_1 + t_2 + ... + t_{n-1}$
|
||||
|
||||
将它们全部相加,得到总等待时间 $Total$。如果我们换个角度看,每个人的接水时间 $t_i$ 对总时间的“贡献”是多少?
|
||||
* $t_1$ 的时间,被后面的 $n-1$ 个人都等了一遍,所以它在总和中被加了 $n-1$ 次。
|
||||
* $t_2$ 的时间,被后面的 $n-2$ 个人都等了一遍,所以它被加了 $n-2$ 次。
|
||||
* ...
|
||||
* $t_i$ 的时间,被它后面的 $n-i$ 个人等待,所以被加了 $n-i$ 次。
|
||||
|
||||
于是,我们得到了总等待时间的数学表达式:
|
||||
$Total = (n-1)*t_1 + (n-2)*t_2 + ... + 1*t_{n-1} + 0*t_n$
|
||||
|
||||
#### **2.2 贪心策略与证明**
|
||||
|
||||
观察这个公式,我们发现,排在越前面的人,其接水时间 $t_i$ 所乘的**权重 $(n-i)$ 越大**。为了让这个加权和最小,一个非常自然的贪心策略应运而生:**让值小的数,乘以大的权重;值大的数,乘以小的权重。**
|
||||
|
||||
换句话说,**接水时间最短的人应该排在最前面**。
|
||||
|
||||
这个策略的正确性,可以用我们在上一讲提到的**交换论证法(反证法)**来严格证明:
|
||||
1. **假设**存在一个最优解,但它不完全是按接水时间升序排列的。
|
||||
2. 那么,这个队列中必然存在一对**相邻**的人,比如第 $i$ 个人和第 $i+1$ 个人,他们的接水时间是逆序的,即 $t_i > t_{i+1}$。
|
||||
3. 我们保持其他人不动,只**交换** $i$ 和 $i+1$ 的位置。
|
||||
4. 分析总等待时间的变化。交换前,这两项对总和的贡献是 $... + (n-i)*t_i + (n-i-1)*t_{i+1} + ...$。
|
||||
5. 交换后,贡献变为 $... + (n-i)*t_{i+1} + (n-i-1)*t_i + ...$。
|
||||
6. 两者之差为:$[(n-i)t_i + (n-i-1)t_{i+1}] - [(n-i)t_{i+1} + (n-i-1)t_i] = (t_i - t_{i+1})$。
|
||||
7. 因为我们设 $t_i > t_{i+1}$,所以差值大于0。这意味着,交换后的总等待时间**更小**了。
|
||||
8. 这与我们“假设存在一个非升序的最优解”相**矛盾**。因此,任何非升序的排列都不是最优的。最优解必须是按接水时间从小到大排列。
|
||||
|
||||
#### **2.3 关键代码实现**
|
||||
|
||||
有了被证明的策略,代码实现就非常直接了。
|
||||
|
||||
```cpp
|
||||
struct Person {
|
||||
int id; // 原始编号
|
||||
int time; // 接水时间
|
||||
};
|
||||
|
||||
// 自定义比较函数
|
||||
bool cmp(const Person& a, const Person& b) {
|
||||
if (a.time != b.time) {
|
||||
return a.time < b.time; // 主要按接水时间升序
|
||||
}
|
||||
return a.id < b.id; // 时间相同,按编号升序
|
||||
}
|
||||
|
||||
int main() {
|
||||
// total_wait_time 为总等待时间
|
||||
// current_wait_time 为前面人的接水总时长
|
||||
for (int i = 1; i < n; ++i) {
|
||||
current_wait_time += people[i - 1].time;
|
||||
total_wait_time += current_wait_time;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **3. 贪心模型二:区间选择问题**
|
||||
|
||||
现在我们来看另一大类问题:从一堆带有起止时间(或左右端点)的事件(或区间)中,按某种规则进行选择。
|
||||
|
||||
#### **3.1 问题引入:活动安排 (凌乱的yyy)**
|
||||
|
||||
这个问题其目标是在大量有时间冲突的活动中,选择出**数量最多**的、互不冲突的活动。
|
||||
|
||||
我们已经知道,正确的贪心策略是:**将所有活动按结束时间升序排序,然后依次选择与已选活动不冲突的活动。**
|
||||
|
||||
这个策略的直觉是:**尽快结束当前活动,为未来留下最大的选择空间。** 它的正确性,也可以用 反证法/交换论证法 严格证明,篇幅限制这里就不证明了。
|
||||
|
||||
#### **3.2 模型抽象:最多不相交区间问题**
|
||||
|
||||
“活动安排”问题可以被抽象成一个数学模型:**最多不相交区间问题**。
|
||||
|
||||
给定 $n$ 个闭区间 $[l_i, r_i]$,选择最多的区间,使得它们两两不相交。
|
||||
|
||||
这里的映射关系是:
|
||||
* 一场活动 <==> 一个区间 $[l, r]$
|
||||
* 开始时间 <==> 左端点 $l$
|
||||
* 结束时间 <==> 右端点 $r$
|
||||
* 活动不冲突 <==> 区间不相交
|
||||
|
||||
只要能抽象出问题的核心,无论它被包装成什么样子,解法都是一样的:**按右端点排序,然后贪心选取。**
|
||||
|
||||
#### **3.3 模型应用与变种**
|
||||
|
||||
掌握了这个模型,我们就能解决一系列看似不同的问题。
|
||||
|
||||
##### **变种1:最少资源覆盖所有目标 (射气球)**
|
||||
|
||||
**问题**:在 X 轴上,有一些水平方向的气球。每个气球可以由一个开始和结束坐标去表示,要求用最少的箭射爆所有气球。一支箭可以射爆所有穿过其发射位置的气球。
|
||||
**分析**:这个问题要求“最少”的资源(箭)来“覆盖”所有目标(气球)。我们转换一下思路:每当我们**不得不**使用一支新箭时,才用。什么时候是“不得不”呢?就是当出现一个新气球,它无法被上一支箭覆盖时。
|
||||
**贪心策略**:
|
||||
|
||||
1. 将所有气球(区间)按**右端点**从小到大排序。
|
||||
2. 初始化箭数 $arrows = 1$。取第一个气球,并假想在它的右端点 $r_1$ 处射出第一支箭。这个位置是在能射爆第一个气球的前提下,能“照顾”到后续气球范围最广的位置。
|
||||
3. 遍历剩下的气球。如果某个气球 $[l_i, r_i]$ 的左端点 $l_i r_1$,说明它已经逃出了第一支箭的范围,我们**不得不**发射一支新箭。
|
||||
4. 此时,增加箭数 $arrows++$,并更新我们的“箭靶”位置为这个新气球的右端点 $r_i$。
|
||||
5. 重复此过程,直到所有气球都被检查完毕。
|
||||
|
||||
##### **变种2:最少点覆盖所有区间 (区间选点)**
|
||||
|
||||
**问题**:假设有 n 个区间,分别是` [l₁, r₁], [l₂, r₂], ..., [lₙ, rₙ]`。 我们需要从这 n 个区间中选择出若干个区间,要求选出的这些区间**两两不相交**(端点相交的情况,如 [1,3] 和 [3,5],不算相交)。最多能选出多少个这样的区间呢?
|
||||
|
||||
**分析**:这和射气球问题几乎完全一样!因为选一个点 就等价于 射一支箭。
|
||||
**贪心策略**:
|
||||
|
||||
1. 将所有区间按**右端点**从小到大排序。
|
||||
2. 选择第一个区间的**右端点**作为第一个点。
|
||||
3. 遍历后续区间,跳过所有已经被这个点覆盖的区间。
|
||||
4. 找到第一个未被覆盖的区间,选择它的右端点作为第二个点。
|
||||
5. 重复此过程。
|
||||
|
||||
---
|
||||
|
||||
### **4. 总结**
|
||||
|
||||
* **贪心模型一 (加权和贪心排序)**:当总成本是每个元素按其排序位置的加权和时,通常的策略是将“最优”的元素放在“贡献最大”的位置。
|
||||
* **贪心模型二 (区间选择)**:处理区间冲突、覆盖、选择问题时,**按右端点排序**是常见的贪心标准。因为它总是为**未来保留最大的可能性**。
|
||||
* **识别模型**:算法学习的关键在于识别问题的数学模型。学会将具体问题抽象化,这样就能通过我们学习的算法去解决各种实际问题。
|
||||
Binary file not shown.
Reference in new issue
Block a user