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,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`结构实现了贪心决策。首先尝试完整地装入一个物品;如果容量不足,则计算能装入的部分,并将其价值计入总和,随后由于背包已满,循环提前终止。
@@ -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堆,贪心策略应该如何调整?
@@ -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. 总结**
* **贪心模型一 (加权和贪心排序)**:当总成本是每个元素按其排序位置的加权和时,通常的策略是将“最优”的元素放在“贡献最大”的位置。
* **贪心模型二 (区间选择)**:处理区间冲突、覆盖、选择问题时,**按右端点排序**是常见的贪心标准。因为它总是为**未来保留最大的可能性**。
* **识别模型**:算法学习的关键在于识别问题的数学模型。学会将具体问题抽象化,这样就能通过我们学习的算法去解决各种实际问题。
@@ -0,0 +1,33 @@
### **参考答案**
#### **一、 填空题**
1. 局部最优,全局最优解
2. 无后效性
3. 价值密度(或 性价比)
4. 比较函数
5. 最优子结构
#### **二、 单项选择题**
1. B
2. C
3. C
4. B
5. B
6. D
7. C
8. C
9. C
10. A
#### **三、 判断题**
1. ×
2. √
3. √
4. ×
5. √
6. √
7. √
8. × (对于部分背包问题,该物品可以装入一部分)
9. × (通常时间复杂度更低)
10. √
@@ -0,0 +1,70 @@
### C++ 贪心算法(上) 课后习题
姓名: ____________________________________________
#### **一、 填空题**
1. 贪心算法在求解问题的每一步中,都做出一个 **\_\_\_\_\_\_\_\_\_\_** 的选择,寄希望于一系列的局部最优选择能够产生一个 **\_\_\_\_\_\_\_\_\_\_**。
2. 一个问题能用贪心算法求解,通常需要满足 **\_\_\_\_\_\_\_\_\_\_** 和最优子结构两个关键性质。
3. 在部分背包问题中,正确的贪心标准是优先选择 **\_\_\_\_\_\_\_\_\_\_** 最高的物品。
4. 在C++代码实现中,为了对物品按价值密度进行排序,我们可以使用`sort`函数,并为其提供一个自定义的 **\_\_\_\_\_\_\_\_\_\_**。
5. 一个问题的最优解包含了其子问题的最优解,这个性质被称为 **\_\_\_\_\_\_\_\_\_\_**。
#### **二、 单项选择题**
( ) 1. 贪心算法的核心思想是?
A. 穷举所有可能 B. 每步都做出局部最优选择 C. 将问题分解为不相关的子问题 D. 从后向前推导最终解
( ) 2. 根据讲义,以下哪个问题是适合用贪心算法解决的典型例子?
A. 0-1背包问题 B. 排序问题 C. 部分背包问题 D. 二分问题
( ) 3. 对于部分背包问题,最佳的贪心策略是?
A. 优先选择价值最高的物品 B. 优先选择重量最轻的物品 C. 优先选择价值密度最高的物品 D. 随机选择物品
( ) 4. “某个状态以前的过程不会影响以后的状态,只与当前状态有关”描述的是贪心算法的哪个性质?
A. 最优子结构 B. 无后效性 C. 高效性 D. 启发性
( ) 5. 假设背包容量为20,有A、B两个物品。A重10价值60,B重20价值100。按照部分背包问题(可拆分物品)的贪心策略,最终得到的最大价值是?
A. 120 B. 110 C. 100 D. 60
( ) 6. 在部分背包问题的C++代码实现中,`Item`结构体中的`ratio`(价值密度/性价比)成员最好使用什么类型?
A. `int` B. `char` C. `bool` D. `double`
( ) 7. 已知`Item`结构体内有三个成员变量分别为:`v`: 物品价值, `m`: 物品重量, `ratio`: 物品价值密度, 在对物品按价值密度进行降序排序时,自定义比较函数`bool cmp(const Item& a, const Item& b)`的正确写法是?
A. `return a.v > b.v;` B. `return a.m < b.m;` C. `return a.ratio > b.ratio;` D. `return a.ratio < b.ratio;`
( ) 8. 一个贪心策略通常可以使用什么办法来证明?
A. 公式法 B. 暴力枚举法 C. 反证法 D. 数学归纳法
( ) 9. 在处理最后一个无法完全装入的物品时,应该如何计算其贡献的价值?
A. 计为0 B. 计为其全部价值 C. (剩余容量 / 物品重量) \* 物品价值 D. 无法计算
( ) 10. 为什么“优先选择价值最高的物品”的策略对于部分背包问题是错误的?
A. 因为它忽略了物品的重量 B. 因为它忽略了背包的容量 C. 因为价值最高的物品总是最重 D. 因为该策略计算过于复杂
#### **三、 判断题 (对的打“√”,错的打“×”)**
( ) 1. 贪心算法适用于所有最优化问题,并且总能找到最优解。
( ) 2. 对于部分背包问题,我们可以将物品分割成任意大小的块装入背包。
( ) 3. 在贪心算法中,一旦一个选择被做出,它就不会被撤销或更改。
( ) 4. “重量最轻优先”是解决部分背包问题的一个有效贪心策略。
( ) 5. 贪心算法就算得不到最优解,也能够快速给出一个质量较高的可行解。
( ) 6. 贪心算法的正确性通常需要严格的数学证明,不能仅凭直觉。
( ) 7. 在C++代码中,`sort(items.begin(), items.end(), cmp);` 这行代码实现了贪心策略的核心部分,即对物品进行排序。
( ) 8. 如果一个物品的重量大于当前背包的剩余容量,那么该物品以及之后的所有物品都不能再装入背包。
( ) 9. 贪心算法相较于暴力搜索算法,通常具有更高的时间复杂度。
( ) 10. 在计算价值密度时,`ratio = (double)v / m;` 是为了防止整数除法导致精度丢失。
@@ -0,0 +1,31 @@
### **参考答案**
#### **一、 填空题**
1. 合并
2. 权值最小
3. Q1的队头,Q2的队头
4. 频率
5. 两堆果子的重量(或权值)之和
#### **二、 单项选择题**
1. B
2. B
3. C (先合并10和20,成本30,新堆为30。再合并两个30,成本60。总成本30+60=90)
4. B (因为后合并的权值更大,放队尾可维持Q2有序)
5. B
7. B
8. D (活动安排属于区间选择贪心模型)
9. C
10. C
#### **三、 判断题**
1. × (讲义中{30,30,30,30}的例子说明对半分更好,但{10,20,30,40}的例子说明分离最大更好,证明单一策略不是普适的)
2. √
3. × (应该尽可能晚地参与合并)
4. √
5. × (不一定,全局最小值在两个队头之一,但无法保证Q1队头一定小于Q2队头)
6. × (可以调整,变为每次合并权值最小的`k`个)
7. √ (这是前缀码的定义,也是哈夫曼编码的性质)
8. √
9. √ (每次操作的成本是被分的那一摞卷子的总数量,总成本是所有操作成本之和)
@@ -0,0 +1,61 @@
### C++ 贪心算法(下) 课后习题
姓名: ____________________________________________
#### **一、 填空题**
1. 通过逆向思维,分卷子的拆分问题可以转化为转化为等价的\_\_\_\_\_\_\_\_\_\_问题来求解。
2. 有 n 个元素带权,二合一合并求最小总成本的问题叫做霍夫曼模型,其最优贪心策略是:每一次操作,都选择当前所有元素中 **\_\_\_\_\_\_\_\_\_\_** 的两个进行合并。
3. 在使用双队列法(两个队列的变量名为`Q1` 和 `Q2`)实现霍夫曼模型时,全局最小的元素只可能出现在 **\_\_\_\_\_\_\_\_\_\_** 或 **\_\_\_\_\_\_\_\_\_\_**。
#### **二、 单项选择题**
( ) 1. “分卷子”的例子告诉我们,正向思考“每次分离出最大的一堆”这个贪心策略是?
A. 正确的 B. 错误的 C. 最低效的 D. 最高效的
( ) 2. 对于“合并果子”问题,正确的贪心策略是?
A. 优先合并重量最大的两堆 B. 优先合并重量最轻的两堆 C. 优先合并重量最接近的两堆 D. 随机合并
( ) 3. 有三堆果子,重量分别为 10, 20, 30。将它们合并成一堆的最小总成本是?
A. 60 B. 80 C. 90 D. 110
( ) 4. 在双队列法中,我们将合并后新产生的元素放入第二个队列(Q2)的哪个位置?
A. 队头 B. 队尾 C. 任意位置 D. 替换队头
( ) 5. 双队列法能够高效实现霍夫曼模型,其关键是?
A. 两个队列的长度永远相等 B. 新产生的元素组成的队列Q2自然有序
​ C. Q1队列的元素会越来越少 D. 只需要一个队列就够了
( ) 6. 哈夫曼编码的目标是使得编码后的信息总长度最小,它通过什么方式实现?
A. 让所有字符编码等长 B. 出现频率高的字符编码短,频率低的编码长
​ C. 出现频率低的字符编码短,频率高的编码长 D. 使用尽可能多的0
( ) 7. 下列哪个问题不适合直接用霍夫曼模型解决?
A. 合并N堆石子,成本为合并后石子总数 B. 构建哈夫曼编码
​ C. 分离试卷,成本为分离前试卷总数 D. 活动安排问题
( ) 8. 在双队列法的`get_min`函数中,如果Q1为空,应该从哪里取元素?
A. 无法继续 B. 从Q1队尾 C. 从Q2队头 D. 随机选择
( ) 9. “分卷子”问题和“哈夫曼编码”的构建过程,都体现了哪种重要的算法思想?
A. 分治思想 B. 动态规划 C. 逆向思维与贪心 D. 暴力搜索
#### **三、 判断题 (对的打“√”,错的打“×”)**
( ) 1. 对于任意输入的果子重量,“每次对半分”的合并策略总能得到最小成本。
( ) 2. “从上往下”拆分的最小成本,等于“从下往上”合并的最小成本。
( ) 3. 在“合并果子”问题中,一个重量较大的果子堆,应该尽早参与合并,以减小总成本。
( ) 4. 在双队列法中,因为新合并的果子堆是放入Q2的,所以Q1中的元素数量只减不增。
( ) 5. 在任意时刻,Q1队头的元素一定小于等于Q2队头的元素。
( ) 6. 霍夫曼模型的核心是“二合一”操作,如果每次可以合并三堆,则不能使用类似的贪心思想。
( ) 7. 哈夫曼编码是一种前缀码,意味着任何一个字符的编码都不是另一个字符编码的前缀。
( ) 8. 双队列法的核心是避免了重复排序,从而提高了算法效率。
( ) 9. 在“分卷子”问题中,总成本等于所有中间步骤产生的卷子堆的数量之和。
@@ -0,0 +1,28 @@
### C++ 贪心算法(中) 课后习题 - **答案**
#### **一、 填空题**
1. 总等待时间
2. 权重,短
3. 结束时间
4. 不相交 (或 不重叠)
5. 右端点
6. 价值密度 (或 性价比)
7. 反证法
#### **二、 单项选择题**
1. B 2. A 3. C 4. C 5. B 6. B 7. C 8. D 9. C 10. A
#### **三、 判断题**
1. √
2. × (这是一个常见的错误贪心策略,反例:活动(1,10), (2,3), (4,5)。选(1,10)后其他都不能选,但选(2,3)和(4,5)更优。)
3. √
4. √
5. × (“0-1背包”问题不能用简单的贪心策略解决,是动态规划的经典问题。)
6. √
7. √
8. × (目标是区间的**数量**最多,而不是总长度最长。)
9. × (贪心算法只对具有特定性质的问题能找到最优解,对很多问题只能找到近似解或不可行。)
10. √
@@ -0,0 +1,70 @@
### C++ 贪心算法(中) 课后习题
姓名: \_\_\_\_\_\_\_\_\_\_\_\_\_
#### **一、 填空题**
1. 在“排队接水”问题中,我们的目标是让所有人的平均等待时间最小,这等价于让所有人的\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_之和最小。
2. 在“排队接水”问题总等待时间的计算公式中,排在越前面的人,其接水时间所乘的\_\_\_\_\_\_\_\_\_\_\_\_\_越大。因此,我们的贪心策略是让接水时间\_\_\_\_\_\_\_\_\_\_\_\_\_的人排在最前面。
3. “活动安排”问题的贪心策略是:将所有活动按\_\_\_\_\_\_\_\_\_\_\_\_\_从小到大排序,然后依次选择与已选活动不冲突的活动。
4. “活动安排”问题可以被抽象为更普适的数学模型,叫做“最多\_\_\_\_\_\_\_\_\_\_\_\_\_区间问题”。
5. 在“射气球”或“区间选点”问题中,我们通常将所有区间按\_\_\_\_\_\_\_\_\_\_\_\_\_从小到大排序。
6. 对于部分背包问题(有n个不同价值,不同重量物品可供选择,每个物品可分割,求在一定背包容量下能获取的物品的最大价值),它的贪心标准是优先选择\_\_\_\_\_\_\_\_\_\_\_\_\_最高的物品。
7. 我们常用\_\_\_\_\_\_\_\_\_\_\_\_\_来证明一个贪心策略的正确性。
#### **二、 单项选择题**
( ) 1. 在“排队接水”问题中,正确的排队策略是什么?
A. 接水时间长的人排在前面
B. 接水时间短的人排在前面
C. 随机排队
D. 按到达顺序排队
( ) 2. 假设有4个人排队接水,他们的接水时间分别是 $t_1, t_2, t_3, t_4$。在总等待时间的计算公式中,哪个人的接水时间被计算的次数最多?
A. 第1个人($t_1$) B. 第2个人($t_2$) C. 第3个人($t_3$) D. 第4个人($t_4$)
( ) 3. 有3个人排队接水,时间分别是 2分钟、5分钟、8分钟。要使总等待时间最短,他们的排队顺序应该是?
A. `8, 5, 2` B. `5, 8, 2` C. `2, 5, 8` D. 任意顺序都可以
( ) 4. 在一个大量有时间冲突的活动中,要求选择出**数量最多**的、互不冲突的活动问题中,正确的贪心策略是?
A. 优先选择开始时间最早的活动
B. 优先选择持续时间最短的活动
C. 优先选择结束时间最早的活动
D. 优先选择开始时间最晚的活动
( ) 5. 在一个大量有时间冲突的活动中,要求选择出**数量最多**的、互不冲突的活动问题中,采取“结束时间最早”策略的主要理由是?
A. 让每个活动尽快开始
B. 尽快结束当前活动,为未来留下最大的选择空间
C. 确保持续时间长的活动能被安排
D. 避免活动之间有任何空闲时间
( ) 6. 考虑以下活动(开始时间, 结束时间):A(1, 4), B(3, 5), C(0, 6), D(5, 7)。按照“活动安排”的贪心策略,最多能选出几个活动?
A. 1 B. 2 C. 3 D. 4
( ) 7. 对于射气球问题:在 X 轴上,有一些水平方向的气球。每个气球可以由一个开始和结束坐标去表示,要求用最少的箭射爆所有气球,它的贪心策略是?
A. 按气球宽度从大到小排序
B. 按气球左端点从小到大排序
C. 按气球右端点从小到大排序
D. 随机选择一个气球射击
( ) 8. 现有A, B, C, D四个活动,它们的起止时间分别是A(8, 11), B(9, 12), C(10, 13), D(8, 10)。为了能安排下尽可能多的活动,根据贪心策略,我们应该首先选择哪个活动?
A. 活动A B. 活动B C. 活动C D. 活动D
( ) 9. 部分背包问题,其贪心选择能够成立的关键前提是?
A. 物品价值都很高 B. 物品重量都很轻
C. 物品可以被任意分割 D. 背包容量非常大
( ) 10. 一个问题能用贪心算法求解,通常需要满足最优子结构和什么性质?
A. 无后效性 B. 先进先出 C. 后进先出 D. 运行速度快
#### **三、 判断题 (对的打“√”,错的打“×”)**
( ) 1. 在“排队接水”问题中,让接水时间最长的人排在队尾,总等待时间会最短。
( ) 2. 在“活动安排”问题中,总是优先选择开始时间最早的活动,一定能得到最优解。
( ) 3. 贪心算法在每一步都做出一个当前看起来最好的选择。
( ) 4. 在“射气球”问题中,按右端点排序后,第一支箭应该射在第一个气球的右端点位置。
( ) 5. “部分背包”问题和“0-1背包”问题(物品不能分割)都可以用相同的贪心策略解决。
( ) 6. 学习贪心算法的关键在于识别问题的内在数学模型,并找到正确的贪心标准。
( ) 7. 在“排队接水”问题中,即使所有人的接水时间都一样,交换任意两个人的位置,总等待时间也不会改变。
( ) 8. 在“最多不相交区间问题”中,我们的目标是让选出的所有区间的总长度最长。
( ) 9. 贪心算法总能为所有问题找到全局最优解。
( ) 10. “排队接水”和“活动安排”两个问题的贪心策略,第一步都是对输入数据进行排序。