Initial commit: C++ courseware
This commit is contained in:
commit
4a4ee7c287
1789 files changed
+373489
No files matched your search
@@ -0,0 +1,224 @@
|
||||
## **C++算法讲义:高精度乘法**
|
||||
### **第一部分:大数 × 小数**
|
||||
|
||||
在学习复杂的“大数 × 大数”之前,我们先解决一个简化版的问题:一个很大的数乘以一个小数(`int` 范围内)。这是我们后续大数 × 大数方法一的基础。
|
||||
|
||||
#### **核心思想 (大数 × 小数)**
|
||||
|
||||
这个过程和高精度加法极其相似,都是逐位处理。不同之处在于,我们不再是 `A[i] + B[i]`,而是用大数的每一位 `A[i]` 去乘以那个小数 `b`,并且进位不再是 `0` 或 `1`,可以是任意大小的数字。
|
||||
|
||||
**算法步骤**:
|
||||
1. 将大数 `A`(字符串)倒序,方便从个位开始处理。
|
||||
2. 设置一个进位 `carry = 0`。
|
||||
3. 遍历倒序后的大数 `A` 的每一位 `A[i]`。
|
||||
4. 计算 `(A[i] - '0') * b + carry`,得到当前位的乘积结果 `res`。
|
||||
5. 新结果的当前位是 `res % 10`。
|
||||
6. 新的进位是 `res / 10`。
|
||||
7. 循环结束后,如果 `carry` 还有剩余,需要继续处理完。
|
||||
8. 最后将得到的结果字符串再次反转,即为最终答案。
|
||||
|
||||
#### **代码实现 (大数 × 小数)**
|
||||
|
||||
```cpp
|
||||
string mul(string a, int b) {
|
||||
// 特殊情况:如果乘数是 0 或大数是 "0",结果直接是 "0"
|
||||
if (b == 0 || a == "0") return "0";
|
||||
|
||||
// 反转大数 a,方便从个位开始处理
|
||||
reverse(a.begin(), a.end());
|
||||
|
||||
string ans = "";
|
||||
int carry = 0; // 进位
|
||||
|
||||
// 逐位与 b 相乘
|
||||
for (int i = 0; i < a.size(); i++) {
|
||||
// 1. 计算当前位的乘积,并加上上一位的进位
|
||||
int res = (a[i] - '0') * b + carry;
|
||||
|
||||
// 2. 将乘积的个位数拼接到 ans 末尾
|
||||
ans += to_string(res % 10);
|
||||
|
||||
// 3. 更新进位(进位是乘积整除10)
|
||||
carry = res / 10;
|
||||
}
|
||||
|
||||
// 循环结束后,如果还有进位,需要全部追加到 ans 末尾
|
||||
while (carry) {
|
||||
ans += to_string(carry % 10);
|
||||
carry /= 10;
|
||||
}
|
||||
|
||||
// 反转得到最终结果
|
||||
reverse(ans.begin(), ans.end());
|
||||
return ans;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **第二部分:大数 × 大数**
|
||||
|
||||
有了大数 × 小数的解决办法,我们现在可以开始着手去完成大数 × 大数的问题了。
|
||||
|
||||
### **方法一:分解相加法 **
|
||||
|
||||
#### **核心思想**
|
||||
|
||||
还记得乘法分配律吗?`a × (b + c) = a × b + a × c`。我们可以把一个大数拆开来看!
|
||||
|
||||
`321 × 45`
|
||||
`= 321 × (5 + 40)`
|
||||
`= 321 × 5 + 321 × 40`
|
||||
|
||||
我们把一个“大数 × 大数”问题,巧妙地转化成了:
|
||||
1. 一个“**大数 × 小数**” (`321 × 5`)
|
||||
2. 另一个“**大数 × 小数**” (`321 × 4`),再在其结果末尾**补0**
|
||||
3. 最后把这两个结果用“**大数 + 大数**”加起来!
|
||||
|
||||
这个思路的精髓在于**复用**。我们只需要实现 `mul(string, int)` 和 `add(string, string)` 这两个函数,就可以组合出 `mul(string, string)` 的功能。
|
||||
|
||||
#### **代码实现 (分解相加法)**
|
||||
|
||||
```cpp
|
||||
// 注意:此函数依赖我们之前已经实现好的高精度加法函数, 以及我们上面刚实现的 mul(string, int) 函数
|
||||
string mul_v1(string a, string b) {
|
||||
if (a == "0" || b == "0") return "0";
|
||||
reverse(b.begin(), b.end()); // 反转乘数b,从个位开始处理
|
||||
string total_ans = "0";
|
||||
|
||||
// 遍历 b 的每一位
|
||||
for (int i = 0; i < b.size(); i++) {
|
||||
int digit_b = b[i] - '0'; // 1. 取出 b 的当前位数字
|
||||
string part_ans = mul(a, digit_b); // 2. 调用大数×小数函数,计算部分积
|
||||
// 3. 补0(移位)。个位(i=0)补0个0,十位(i=1)补1个0...
|
||||
for (int j = 0; j < i; j++) {
|
||||
part_ans += '0';
|
||||
}
|
||||
total_ans = add(total_ans, part_ans); // 4. 调用“大数+大数”函数,累加到总结果上
|
||||
}
|
||||
|
||||
return total_ans;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **方法二:竖式模拟法**
|
||||
|
||||
这种方法更高效,避免了方法一中频繁的字符串操作和多次相加。
|
||||
|
||||
#### **8. 核心思想**
|
||||
|
||||
我们回顾一下手算 `123 × 45` 的过程。本质上,我们是在计算所有数位的两两乘积,然后把它们放到正确的位置上。
|
||||
|
||||
* `3 × 5` 的结果在**个位**
|
||||
* `2 × 5` 和 `3 × 4` 的结果都在**十位**
|
||||
* `1 × 5` 和 `2 × 4` 的结果都在**百位**
|
||||
* ...
|
||||
|
||||
**规律发现**:设被乘数 `A` 的第 `i` 位和乘数 `B` 的第 `j` 位相乘(`i`, `j` 从0开始,代表个位、十位...),其结果 `A[i] × B[j]` 应该累加到最终结果 `C` 的第 `i+j` 位上!
|
||||
|
||||
**优化策略**:为了避免复杂的实时进位,我们采用**先累加,后统一进位**的策略。
|
||||
1. 创建一个足够长的结果数组 `res` (长度为 `n+m`)。
|
||||
2. 用两层循环,计算所有 `A[i] × B[j]`,并直接累加到 `res[i+j]` 上,此时 `res` 里可能存着大于9的数。
|
||||
3. 最后,从头到尾遍历 `res` 数组,一次性处理完所有进位。
|
||||
|
||||
#### **9. 实例 (123 × 45)**
|
||||
|
||||
**存储与初始化**
|
||||
|
||||
* `A = {3, 2, 1}` (123倒序)
|
||||
* `B = {5, 4}` (45倒序)
|
||||
* `res = {0, 0, 0, 0, 0}` (长度为 3+2=5)
|
||||
|
||||
**逐位乘积累加 (不进位)**
|
||||
|
||||
| 操作 | 累加位置 | 计算 | res 数组状态 |
|
||||
| :-------- | :------- | :------- | :------------------- |
|
||||
| A[0]×B[0] | `res[0]` | `+= 3×5` | `{15, 0, 0, 0, 0}` |
|
||||
| A[1]×B[0] | `res[1]` | `+= 2×5` | `{15, 10, 0, 0, 0}` |
|
||||
| A[2]×B[0] | `res[2]` | `+= 1×5` | `{15, 10, 5, 0, 0}` |
|
||||
| A[0]×B[1] | `res[1]` | `+= 3×4` | `{15, 22, 5, 0, 0}` |
|
||||
| A[1]×B[1] | `res[2]` | `+= 2×4` | `{15, 22, 13, 0, 0}` |
|
||||
| A[2]×B[1] | `res[3]` | `+= 1×4` | `{15, 22, 13, 4, 0}` |
|
||||
|
||||
**统一处理进位**
|
||||
|
||||
| 位置 `i` | `res[i]`(处理前) | `res[i] + carry` | `ans += ...` | 新 `carry` |
|
||||
| :------- | :--------------- | :--------------- | :----------- | :--------- |
|
||||
| 0 | 15 | `15 + 0 = 15` | `ans += "5"` | `1` |
|
||||
| 1 | 22 | `22 + 1 = 23` | `ans += "3"` | `2` |
|
||||
| 2 | 13 | `13 + 2 = 15` | `ans += "5"` | `1` |
|
||||
| 3 | 4 | `4 + 1 = 5` | `ans += "5"` | `0` |
|
||||
| 4 | 0 | `0 + 0 = 0` | `ans += "0"` | `0` |
|
||||
|
||||
**清理与输出**
|
||||
|
||||
* 处理后得到的 `ans` 是 `"53550"`。
|
||||
* 去除末尾多余的 '0' -> `"5355"`。
|
||||
* 反转 -> `"5535"`。正确!
|
||||
|
||||
#### **10. 代码实现 (竖式模拟法)**
|
||||
|
||||
```cpp
|
||||
string mul_v2(string a, string b) {
|
||||
// 特殊情况:如果乘数是 0,结果直接是 0
|
||||
if (b == "0" || a == "0" ) return "0";
|
||||
// 反转, 让下标 0 对应个位,1 对应十位...
|
||||
reverse(b.begin(), b.end());
|
||||
reverse(a.begin(), a.end());
|
||||
|
||||
// 创建结果数组(不处理进位)
|
||||
// n位乘以m位,积最多是 n+m 位
|
||||
// 初始全部填充为 0
|
||||
int n = a.size(), m = b.size();
|
||||
vector res(n + m, 0);
|
||||
// 逐位相乘并累加
|
||||
for (int i = 0; i < n; i++) {
|
||||
for (int j = 0; j < m; j++) {
|
||||
int digitA = a[i] - '0'; // 将字符转换为数字进行乘法
|
||||
int digitB = b[j] - '0';
|
||||
// a的第i位与b的第j位相乘,放在结果数组的第 i+j 位
|
||||
res[i + j] += digitA * digitB;
|
||||
}
|
||||
}
|
||||
|
||||
string ans = ""; // 最终的结果
|
||||
int carry = 0; // 储存进位,初始化为 0
|
||||
for (int i = 0; i < res.size(); i++) {
|
||||
// 计算当前位的数值,开始处理进位
|
||||
int num = res[i] + carry;
|
||||
// 计算下一位的进位
|
||||
carry = num / 10;
|
||||
// 当前位只保留个位数添加到结果
|
||||
ans += to_string(num % 10);
|
||||
}
|
||||
while(carry){ // 处理最后的进位
|
||||
ans += to_string(carry % 10);
|
||||
carry /= 10;
|
||||
}
|
||||
// 去除末尾多余的 '0'(即原数的前导零)
|
||||
while (ans.size() > 1 && ans.back() == '0') {
|
||||
ans.pop_back();
|
||||
}
|
||||
|
||||
// 翻转回来
|
||||
reverse(ans.begin(), ans.end());
|
||||
return ans;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **方法对比**
|
||||
|
||||
**结论**:在算法竞赛中,**方法二(竖式模拟法)是主要的选择**。方法一则胜在思路巧妙,体现了优秀的模块化和问题分解思想。
|
||||
|
||||
---
|
||||
|
||||
### 总结
|
||||
|
||||
1. 高精度乘法有两种主流思路:**分解相加**和**竖式模拟**。
|
||||
2. **分解相加**:将 `大数×大数` 转化为多次 `大数×小数` 和 `大数+大数`,核心是**复用**。
|
||||
3. **竖式模拟**:用 `vector` 存储中间结果,核心是 `C[i+j] += A[i] × B[j]` 公式和**先累加后进位**的思想。
|
||||
4. 在性能上,**竖式模拟法**优于分解相加法。
|
||||
Binary file not shown.
@@ -0,0 +1,120 @@
|
||||
# **C++ 高精度减法讲义**
|
||||
|
||||
#### 1. 思想
|
||||
|
||||
既然高精度加法可以通过“反转 -> 补零 -> 逐位相加”的流程简化计算,减法是否也可以呢?
|
||||
|
||||
答案是肯定的!通过**完全相同的预处理步骤**,我们同样可以将减法问题转化为一个从左到右的、下标对齐的简单循环。
|
||||
|
||||
**产生了新的问题:**
|
||||
|
||||
我们已经知道如何用字符串来模拟大数的加法。减法同样可以模拟,但比加法多了三个问题:
|
||||
|
||||
* **预处理**:减法必须先判断大小,确定结果的符号。
|
||||
* **计算**:将进位(`carry`) 逻辑替换为借位(`borrow`) 逻辑。
|
||||
* **后处理**:减法的结果可能产生前导零 (例如 `123-120=003`),需要特殊处理。
|
||||
|
||||
#### 2. 算法步骤详解
|
||||
|
||||
**判断符号 (这是减法独有的)**
|
||||
|
||||
* 在计算前,必须比较 `a` 和 `b` 的大小。
|
||||
* 创建一个 `bool negative = false;` 标志位。
|
||||
* 如果 `b` 比 `a` 大,说明结果是负数。我们记下 `negative = true;`,然后交换 `a` 和 `b`。
|
||||
* **目的**:通过这一步,我们保证了接下来的计算永远是 `大数 - 小数`,极大地简化了借位逻辑。
|
||||
|
||||
**反转与补零 (与加法完全一致)**
|
||||
|
||||
* 将 `a` 和 `b` (已经是大数和小数了) 同时反转。
|
||||
* 在较短的字符串**末尾**补 `0`,使其与较长的等长。
|
||||
* **目的**:让原本的“个位、十位、百位...”对齐到字符串的索引 `0, 1, 2...`,从而可以使用一个简单的 `for` 循环从左到右处理。
|
||||
|
||||
**核心循环——逐位相减**
|
||||
这是算法的核心,我们将在这里实现借位逻辑。
|
||||
|
||||
* 创建一个 `int borrow = 0;` 用于记录借位。
|
||||
* 循环从 `i = 0` 开始:
|
||||
1. **取数计算**:`diff = (a[i]-'0') - (b[i]-'0') - borrow;`
|
||||
* `a[i]` 是被减数,`b[i]` 是减数。
|
||||
* `borrow` 是来自**更低位**的借位请求。如果上一轮(`i-1`)发生了借位,`borrow` 就是 `1`,意味着当前位的 `a[i]` 已经被借走了一个 `1`。
|
||||
2. **判断并处理借位**:
|
||||
* 如果 `diff < 0`,说明当前位不够减。
|
||||
* `diff += 10;` (向更高位借来的 `1` 在本位体现为 `10`)。
|
||||
* `borrow = 1;` (设置借位标记,告诉**下一轮循环**,它的 `a[i+1]` 被借走了)。
|
||||
* 如果 `diff >= 0`,说明够减。
|
||||
* `borrow = 0;` (重置借位标记)。
|
||||
3. **拼接结果**:`ans += to_string(diff);`
|
||||
|
||||
**去除前导零 (在反转前操作)**
|
||||
|
||||
* 我们得到的 `ans` 是一个倒序的结果。例如,`123 - 120` 的计算结果 `3`,在 `ans` 中会是 `"300"`。
|
||||
* 如果直接反转,会得到 `"003"`。
|
||||
* 所以,我们必须在**最终反转之前**,去除 `ans` **末尾**多余的 `0`。
|
||||
* **注意**:要保留最后一位 `0` (例如 `10 - 10` 的结果是 `0`),所以循环条件是 `ans.size() > 1`。
|
||||
|
||||
**最终反转与添加符号**
|
||||
|
||||
* 将清理好的 `ans` 字符串反转回来,得到正确的顺序。
|
||||
* 检查设置的 `negative` 标志位。如果为 `true`,在结果的最前面加上 `"-"`。
|
||||
|
||||
#### 3. 完整代码实现
|
||||
|
||||
```cpp
|
||||
// 比较函数,判断 a 是否大于 b
|
||||
bool isGreater(string a, string b) {
|
||||
if (a.length() != b.length()) {
|
||||
return a.length() > b.length();
|
||||
}
|
||||
return a > b; // 长度相同时,直接按字典序比较
|
||||
}
|
||||
|
||||
string sub(string a, string b) {
|
||||
// 判断符号,并确保 a >= b
|
||||
bool negative = false;
|
||||
if (isGreater(b, a)) {
|
||||
negative = true;
|
||||
swap(a, b);
|
||||
}
|
||||
|
||||
// 将两个输入字符串都反转
|
||||
reverse(a.begin(), a.end());
|
||||
reverse(b.begin(), a.end());
|
||||
|
||||
// 在较短的字符串末尾补 '0',使其与较长的等长
|
||||
while (b.size() < a.size()) b += '0';
|
||||
|
||||
string ans = "";
|
||||
int borrow = 0;
|
||||
|
||||
// 核心循环,逐位相减
|
||||
for (int i = 0; i < a.size(); i++) {
|
||||
// 计算当前位的差值(考虑上一位的借位)
|
||||
int diff = (a[i] - '0') - (b[i] - '0') - borrow;
|
||||
|
||||
// 判断是否需要向更高位借位
|
||||
if (diff < 0) {
|
||||
// 当前位不够减,需要借位
|
||||
diff += 10; // 借来的 1 在本位变成了 10
|
||||
borrow = 1; // 产生了借位,标记下一轮计算要被借走
|
||||
} else {
|
||||
// 当前位够减,无需借位
|
||||
borrow = 0; // 重置借位标记
|
||||
}
|
||||
// 将当前位的结果拼接到 ans 末尾
|
||||
ans += to_string(diff);
|
||||
}
|
||||
|
||||
// 在反转前,把倒序结果末尾的 0 去掉 (处理前导零)
|
||||
while (ans.size() > 1 && ans.back() == '0') {
|
||||
ans.pop_back();
|
||||
}
|
||||
|
||||
// 将最终结果反转回来
|
||||
reverse(ans.begin(), ans.end());
|
||||
|
||||
// 如果结果为负,添加负号
|
||||
if (negative) ans = "-" + ans;
|
||||
|
||||
return ans;
|
||||
}
|
||||
```
|
||||
Binary file not shown.
@@ -0,0 +1,122 @@
|
||||
# **C++ 高精度加法**
|
||||
|
||||
#### **一:为什么需要高精度计算?**
|
||||
|
||||
在编程中,我们常用的 `int`、`long long` 等数据类型都有其表示范围的上限。例如,一个 64 位的 `long long` 最大也只能存储约 `9 x 10^18` 的数字。当我们面临的问题需要处理的数值远超这个范围时——比如计算 100 的阶乘(一个有 158 位的天文数字),或者在密码学中处理超长大整数——标准数据类型便无能为力了。
|
||||
|
||||
**高精度计算**,就是为了解决这个问题而生。它利用字符串或数组来模拟我们人类进行笔算的过程,从而处理任意大(只要内存允许)的整数运算。而接下来就会讲解最基础也最重要的运算:**高精度加法**。
|
||||
|
||||
#### **二:算法思路**
|
||||
|
||||
**1. 方法一:双指针法**
|
||||
|
||||
最自然的思路是**模拟小学的竖式计算**:末位对齐,从右到左逐位相加,并处理进位。如果用代码直接实现,通常是这样的:
|
||||
|
||||
* 使用两个指针 `i` 和 `j`,分别指向两个字符串 `a` 和 `b` 的末尾。
|
||||
* 在一个 `while (i >= 0 || j >= 0)` 的循环中进行计算。
|
||||
* 循环内部需要写复杂的 `if-else` 来处理:`i` 和 `j` 都有效、只有 `i` 有效、只有 `j` 有效这三种情况。
|
||||
|
||||
这种方法虽然直观,但**代码实现相对复杂,边界条件多,容易出错**。
|
||||
|
||||
**2. 反转对齐法**
|
||||
|
||||
为了规避双指针的复杂性,我们引入一种实现更简洁的思路:
|
||||
|
||||
**“与其费力地从后往前处理复杂的边界,不如我们先把它变成一个简单的、从头走到尾的问题!”**
|
||||
|
||||
这就是“反转对齐法”的核心思想。它通过预处理,将所有情况(如两个数长度不同)统一为一种标准模型,使得最核心的“逐位相加”过程可以用一个极其简单的单循环完成。
|
||||
|
||||
#### **三:核心流程**
|
||||
|
||||
让我们通过计算 `987 + 56` 来感受这个这个过程:
|
||||
|
||||
**原始数字**
|
||||
* `a = "987"`
|
||||
* `b = "56"`
|
||||
|
||||
**第一步:全部反转**
|
||||
我们将两个字符串都进行反转,这样它们的个位、十位、百位...就分别对齐到了字符串的索引 `0, 1, 2...`。
|
||||
|
||||
* `a` 变为 `"789"`
|
||||
* `b` 变为 `"65"`
|
||||
|
||||
**第二步:补零对齐
|
||||
为了让两个字符串的每一位都能一一对应相加,我们将较短的字符串在**末尾补 `0`,直到它们等长。
|
||||
|
||||
* `a` 保持 `"789"`
|
||||
* `b` 变为 `"650"`
|
||||
> 在反转后的字符串**末尾**补 `0`,等价于在原始数字的**开头**补 `0` (例如,`56` 变成 `056`),这是在模拟竖式计算的末位对齐。
|
||||
|
||||
**第三步:从左到右逐位相加 **
|
||||
由于已经反转对齐,我们现在可以从索引 `0` 开始,用一个简单的 `for` 循环来完成所有位的相加。
|
||||
|
||||
```
|
||||
(carry) 0 -> 1 -> 1 -> 1 (进位变化过程)
|
||||
7 8 9 (反转后的 a)
|
||||
+ 6 5 0 (反转后的 b)
|
||||
-----------------
|
||||
(ans) 3 -> 4 -> 0 (倒序的结果 ans)
|
||||
```
|
||||
* **i = 0 (个位):** `carry` 是 `0`。`7 + 6 + 0 = 13`。结果存 `3`,新 `carry` 为 `1`。`ans = "3"`。
|
||||
* **i = 1 (十位):** `carry` 是 `1`。`8 + 5 + 1 = 14`。结果存 `4`,新 `carry` 为 `1`。`ans = "34"`。
|
||||
* **i = 2 (百位):** `carry` 是 `1`。`9 + 0 + 1 = 10`。结果存 `0`,新 `carry` 为 `1`。`ans = "340"`。
|
||||
|
||||
**第四步:处理最终进位**
|
||||
循环结束后,检查是否还有剩余的进位。在这里,`carry` 等于 `1`,需要追加到结果的末尾。
|
||||
|
||||
* `ans` 变为 `"3401"`。
|
||||
|
||||
**第五步:再次反转**
|
||||
我们得到的 `ans` 是最终结果的倒序。现在,我们把它反转回来,得到正确的答案。
|
||||
|
||||
* `"3401"` 反转后得到 `"1043"`。
|
||||
|
||||
至此,计算完成!这个过程将一个复杂的、需要处理多种情况的双指针问题,转化成了一个简单的、逻辑统一的单循环问题。
|
||||
|
||||
#### **四:代码实现**
|
||||
|
||||
```cpp
|
||||
string add(string a, string b) {
|
||||
// 将两个输入字符串都反转
|
||||
// 目的:让个位在 [0] 位置,方便从左到右处理
|
||||
reverse(a.begin(), a.end());
|
||||
reverse(b.begin(), a.end());
|
||||
|
||||
// 在较短的字符串末尾补 0,使其与较长的等长
|
||||
// 目的:统一循环次数,避免复杂的边界判断
|
||||
while (a.size() < b.size()) a += '0';
|
||||
while (b.size() < a.size()) b += '0';
|
||||
|
||||
string ans = ""; // 存放倒序的结果
|
||||
int carry = 0; // 初始化进位
|
||||
|
||||
// 从左到右逐位相加 (i=0 是个位,i=1 是十位...)
|
||||
for (int i = 0; i < a.size(); i++) {
|
||||
// 计算当前第 i 位的总和
|
||||
int sum = (a[i] - '0') + (b[i] - '0') + carry;
|
||||
|
||||
// 将结果的个位数(字符形式)拼接到 ans 的末尾
|
||||
ans += to_string(sum % 10);
|
||||
|
||||
// 更新进位,为下一轮做准备
|
||||
carry = sum / 10;
|
||||
}
|
||||
|
||||
// 如果循环结束后仍有进位 (如 99+1=100), 则追加到 ans 末尾
|
||||
if (carry > 0) {
|
||||
ans += to_string(carry);
|
||||
}
|
||||
|
||||
// 将倒序的结果 ans 再次反转,得到最终答案
|
||||
reverse(ans.begin(), ans.end());
|
||||
|
||||
return ans;
|
||||
}
|
||||
```
|
||||
|
||||
#### **第五部分:总结与要点**
|
||||
|
||||
* **核心思路**:通过 `反转 -> 补零 -> 相加 -> 再反转` 的流程,将问题简化。
|
||||
* **设计思想**:**将复杂性从核心循环中剥离,移入预处理和后处理阶段**。
|
||||
* 通过预处理将所有输入(无论长度如何)都统一为相同长度的字符串,避免了在核心循环中进行繁琐的长度判断,大大减少了出错的可能性。
|
||||
* **技巧**:在**反转后**的字符串**末尾**补零,是实现**原始数字**在**开头**对齐的关键。
|
||||
Binary file not shown.
@@ -0,0 +1,185 @@
|
||||
### **C++ 算法讲义:高精度除法**
|
||||
|
||||
#### **一、 引言**
|
||||
|
||||
##### **1.1 问题引入**
|
||||
|
||||
我们已经掌握了如何让计算机处理大整数的加、减、乘法。现在迎来了高精度运算中逻辑最复杂的一环:**高精度除法**。
|
||||
|
||||
思考一个场景:处理宏观经济数据时,计算一个国家千万亿级别的 GDP 除以数亿人口的人均产值;或是密码学中,对两个极长的大素数进行除法和取模运算。
|
||||
|
||||
例如:`1,000,000,000,000,000,000 ÷ 123,456,789 = ? ... ?`
|
||||
|
||||
C++ 内置的 `/` 和 `%` 运算符在面对这种规模的数字时会因溢出而失效。我们必须设计一种算法,教会计算机像人类一样进行“大数 ÷ 大数”的精确计算。
|
||||
|
||||
##### **1.2 价值阐述**
|
||||
|
||||
高精度除法是高精度运算系列中综合性最强的算法。因为它的实现不仅需要严密的逻辑,还深度依赖之前学过的高精度减法和比较算法。并且它是实现更高级数学算法(如大数求最大公约数、快速幂取模、大数开方等)的基础工具。
|
||||
|
||||
|
||||
#### **二、 核心思想:模拟竖式除法**
|
||||
|
||||
为了让计算机“学会”除法,我们回顾人类是如何手工计算的。以 `859 ÷ 21` 为例:
|
||||
|
||||
```text
|
||||
4 0 <-- 商 (Quotient)
|
||||
______
|
||||
21 ) 8 5 9 <-- 被除数 (Dividend)
|
||||
8 4 <-- 21 * 4
|
||||
---
|
||||
1 9 <-- 85 - 84 = 1,然后落下 '9',组成新余数 19
|
||||
0 0 <-- 19 不够除 21 (商 0)
|
||||
---
|
||||
1 9 <-- 最终余数 (Remainder)
|
||||
```
|
||||
|
||||
**竖式除法的动作分解:**
|
||||
|
||||
1. **取位**:从被除数的最高位开始,依次向右提取一个“片段”。
|
||||
2. **试商**:判断当前“片段”里最多包含几个除数。得出的数字即为商的一位。
|
||||
3. **相减**:从当前片段中减去 `除数 × 商`。
|
||||
4. **落位 **:将被除数的下一位“落”下,拼接到相减后的余数末尾,形成下一轮的新片段。
|
||||
5. **循环往复**:重复上述过程,直到被除数的所有位都被处理完毕。
|
||||
|
||||
**如何让计算机进行“试商”?**
|
||||
|
||||
人类可以通过直觉快速估算出 `85` 里面有 `4` 个 `21`。但计算机需要精确的指令告诉它怎么做。
|
||||
最直接的“试商”方法是**连减法**:
|
||||
在一个内部循环中,不断地从 `当前片段` 中减去 `除数`,并用一个计数器记录减成功的次数。直到 `当前片段` 小于 `除数` 为止。**这个计数器的值,就是当前这一位的商。**
|
||||
|
||||
**算法核心逻辑总结:**
|
||||
从被除数 `A` 的最高位起,逐位构建 `当前余数 R`。每一步,将 `A` 的新一位字符拼到 `R` 的末尾,然后用一个内部 `while` 循环,统计 `R` 能够减去多少次除数 `B`。减去的次数存入最终的商,减完后剩下的值作为下一轮的初始余数。
|
||||
|
||||
---
|
||||
|
||||
#### **三、 实例与图解演示**
|
||||
|
||||
**具体案例**:用算法逻辑推演 `859 ÷ 21`。
|
||||
|
||||
**前置要求**:我们假定已经拥有了两个辅助函数:`isGreaterOrEqual(A, B)`(判断大数A是否大于等于B)和 `sub(A, B)`(执行大数减法),其中 `R` 代表余数部分。
|
||||
|
||||
**分步演示表**:
|
||||
|
||||
| 步骤 | 落位 | 拼接前 `R` | 拼接后 `R` | 试商过程 (连减法寻找商 `d`) | 记录商 | 更新后的 `R` |
|
||||
| :--- | :----------- | :--------- | :--------- | :--------------------------------------- | :------ | :----------- |
|
||||
| 1 | `A[0] = '8'` | `""` | `"8"` | `while("8" >= "21")` 假,`d=0` | `"0"` | `"8"` |
|
||||
| 2 | `A[1] = '5'` | `"8"` | `"85"` | `while("85" >= "21")` 真,共减4次,`d=4` | `"04"` | `"1"` |
|
||||
| 3 | `A[2] = '9'` | `"1"` | `"19"` | `while("19" >= "21")` 假,`d=0` | `"040"` | `"19"` |
|
||||
|
||||
**最终结果**:
|
||||
|
||||
* 得到原始商 `"040"`,我们需要去除前导零,最终商为 `"40"`。
|
||||
* 最终 `R` 的值为 `"19"`,这就是余数。
|
||||
|
||||
---
|
||||
|
||||
#### **四、 代码实现**
|
||||
|
||||
高精度除法它实现严重大数比较和减法。
|
||||
|
||||
```cpp
|
||||
// 判断大数 a 是否大于或等于 b (a >= b 返回 true)
|
||||
bool isGreaterOrEqual(string a, string b) {
|
||||
if (a.length() != b.length()) {
|
||||
return a.length() > b.length();
|
||||
}
|
||||
return a >= b; // 长度相同时,按字典序比较即可等效于数值比较
|
||||
}
|
||||
|
||||
// 高精度减法 (前提是 a >= b,且均为正数)
|
||||
string sub(string a, string b) {
|
||||
reverse(a.begin(), a.end());
|
||||
reverse(b.begin(), b.end());
|
||||
while (b.size() < a.size()) b += '0';
|
||||
|
||||
string ans = "";
|
||||
int borrow = 0;
|
||||
|
||||
for (int i = 0; i < a.size(); i++) {
|
||||
int diff = (a[i] - '0') - (b[i] - '0') - borrow;
|
||||
if (diff < 0) {
|
||||
diff += 10;
|
||||
borrow = 1;
|
||||
} else {
|
||||
borrow = 0;
|
||||
}
|
||||
ans += to_string(diff);
|
||||
}
|
||||
|
||||
// 去除减法结果末尾多余的 '0' (反转后就是前导零)
|
||||
while (ans.size() > 1 && ans.back() == '0') {
|
||||
ans.pop_back();
|
||||
}
|
||||
reverse(ans.begin(), ans.end());
|
||||
return ans;
|
||||
}
|
||||
|
||||
// 高精度除法
|
||||
// 返回 pair<string, string>,分别代表 {商, 余数}
|
||||
pair<string, string> divide(string a, string b) {
|
||||
// 特判处理
|
||||
if (b == "0") return {"除0错误", ""};
|
||||
if (a == b) return {"1", "0"};
|
||||
if (!isGreaterOrEqual(a, b)) return {"0", a}; // 被除数小于除数
|
||||
|
||||
string quotient = ""; // 用于累加商
|
||||
string remainder = ""; // 用于维护当前片段的余数
|
||||
|
||||
// 模拟竖式除法:从最高位向低位遍历被除数 a
|
||||
for (int i = 0; i < a.length(); i++) {
|
||||
// 落位:将当前位拼接到余数末尾
|
||||
remainder += a[i];
|
||||
|
||||
// 清理余数可能产生的前导零 (例如 05 -> 5)
|
||||
if (remainder.size() > 1 && remainder[0] == '0') {
|
||||
remainder.erase(remainder.begin());
|
||||
}
|
||||
|
||||
// 试商 (连减法)
|
||||
int count = 0;
|
||||
// 只要当前余数大于等于除数,就继续减
|
||||
while (isGreaterOrEqual(remainder, b)) {
|
||||
remainder = sub(remainder, b); // 执行大数减法
|
||||
count++;
|
||||
}
|
||||
|
||||
// 记录本位计算出的商
|
||||
quotient += to_string(count);
|
||||
}
|
||||
|
||||
// 清理商的前导零 (例如 040 -> 40)
|
||||
while (quotient.size() > 1 && quotient[0] == '0') {
|
||||
quotient.erase(quotient.begin());
|
||||
}
|
||||
|
||||
// 处理恰好整除的情况
|
||||
if (remainder.empty()) remainder = "0";
|
||||
|
||||
return {quotient, remainder};
|
||||
}
|
||||
```
|
||||
|
||||
#### **五、 算法瓶颈与优化方向**
|
||||
|
||||
**思考:连减法试商的效率问题**
|
||||
|
||||
上述代码中,我们采用了“连减法”进行试商:`while (isGreaterOrEqual(remainder, b))`。
|
||||
这是一种直观但效率不高的策略。最坏的情况下(例如某一位的商是 `9`),内部的 `while` 循环需要执行 `9` 次高精度减法。如果被除数极长,这将极大地拖慢程序运行速度。
|
||||
|
||||
**二分法试商**
|
||||
|
||||
为了提高试商效率,我们可以将其转化为一个**查找问题**。每一位的商必然是一个在 `0` 到 `9` 之间的整数。
|
||||
我们可以利用**二分查找法**在这个小区间内快速定位商的值。
|
||||
|
||||
对于一个猜测的商 `mid`,我们只需计算 `B × mid`(大数乘以单精度数),然后将其与 `remainder` 比较:
|
||||
* 如果 `B × mid > remainder`:说明猜测的商太大了,正确答案在左半区间 `[0, mid - 1]`。
|
||||
* 如果 `B × mid <= remainder`:说明猜测的商可能是对的,但也可能还能更大,我们记录下当前的 `mid`,并继续在右半区间 `[mid, 9]` 尝试。
|
||||
|
||||
通过二分查找,每次试商的次数从最多 9 次降低到大约 3-4 次。这在处理超大规模数字时,可以带来显著的性能提升。
|
||||
|
||||
### 六. 总结
|
||||
|
||||
1. **核心思想**:高精度除法是对手动**竖式除法**的模拟。
|
||||
2. **关键步骤**:“落位” -> “试商” -> “相减” -> “记录商” 的循环。
|
||||
3. **实现难点**:“试商”过程。简单实现是**循环减法**,高效实现是**二分查找+乘法**。
|
||||
4. **基础依赖**:高精度除法建立在**比较和高精度**减法之上。
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,201 @@
|
||||
# C++ STL排序 `sort()` 知识点总结
|
||||
|
||||
### 1. 头文件
|
||||
`sort` 函数包含在 `<algorithm>` 头文件中。
|
||||
|
||||
```cpp
|
||||
#include <algorithm>
|
||||
```
|
||||
|
||||
### 2. 函数原型与基本语法
|
||||
`sort` 对给定区间内的元素进行排序。
|
||||
|
||||
* **函数原型**:`void sort(RandomAccessIterator first, RandomAccessIterator last);`
|
||||
* **参数**:
|
||||
* `first`:指向要排序序列起始位置的迭代器(包含)。
|
||||
* `last`:指向要排序序列结束位置的下一位的迭代器(不包含)。
|
||||
* 即:排序区间为 **左闭右开 [first, last)**。
|
||||
|
||||
### 3. 默认行为
|
||||
* **排序规则**:默认按照 **升序**(从小到大)排列。
|
||||
* **比较依据**:使用元素类型自带的 `<` 运算符进行比较。
|
||||
* **适用类型**:
|
||||
* 基本数据类型(`int`, `double`, `char` 等)。
|
||||
* C++内置的扩展类型(`string`,按字典序(ASCII码表的顺序)排序)。
|
||||
* 自定义结构体类型(需要配合自定义比较函数)。
|
||||
|
||||
### 4. 算法复杂度
|
||||
* **时间复杂度**:
|
||||
* 平均情况:$O(N \log N)$
|
||||
* 最坏情况:$O(N \log N)$
|
||||
* **空间复杂度**:
|
||||
* $O(\log N)$
|
||||
|
||||
### 5. 底层实现原理 (了解)
|
||||
`sort` 并非简单的快速排序,而是一种混合排序算法:
|
||||
|
||||
1. **快速排序 **:作为主要排序手段,不断的将数据拆分分片。
|
||||
2. **堆排序 **:当快速排序的递归深度过深(超过 $\log N$ 级别)时,自动切换为堆排序。
|
||||
3. **插入排序**:当数据片段非常小(少于 16 个元素)时,切换为插入排序。因为对于极小数组,插入排序比快排和堆排更高效且常数更小。
|
||||
|
||||
### 6. 代码示例
|
||||
|
||||
**示例 1:数组排序**
|
||||
|
||||
```cpp
|
||||
int arr[] = {4, 2, 5, 1, 3};
|
||||
// 排序整个数组,arr 是首地址,arr+5 是尾地址 + 1
|
||||
sort(arr, arr + 5);
|
||||
// 结果: 1, 2, 3, 4, 5
|
||||
```
|
||||
|
||||
**示例 2:Vector 排序**
|
||||
```cpp
|
||||
vector<int> v = {10, 30, 20};
|
||||
sort(v.begin(), v.end());
|
||||
// 结果: 10, 20, 30
|
||||
```
|
||||
|
||||
**示例 3:局部排序**
|
||||
```cpp
|
||||
vector<int> v = {5, 1, 9, 3, 7};
|
||||
// 只排序前3个元素 (下标0到2)
|
||||
sort(v.begin(), v.begin() + 3);
|
||||
// 结果: 1, 5, 9, 3, 7 (后两个元素位置未动)
|
||||
```
|
||||
|
||||
### 7. 自定义比较函数
|
||||
`sort` 的第三个参数接收一个比较器,其中存在一个内置比较器`greater<T>()`用于降序排序。
|
||||
|
||||
```cpp
|
||||
sort(v.begin(), v.end(), greater<T>()); // T 为 vec 中储存的数据类型
|
||||
```
|
||||
|
||||
而在这里如果我们需要自定义比较的方法的话,我们就需要传递一个**比较函数**。
|
||||
|
||||
* **函数参数**:必须接受两个跟你要排序的同类型的参数(建议使用 `const&`以提高效率,不加也可以,但是因为加上`&`是传递原件,能节省复印所需要的时间),并返回 `bool` 类型。
|
||||
* **返回值含义**:
|
||||
* 返回 `true`:表示第一个参数应该排在第二个参数**前面**。
|
||||
* 返回 `false`:表示第一个参数应该排在第二个参数**后面**,或者两者相等。
|
||||
|
||||
### 8. 比较函数的写法模板
|
||||
|
||||
假设有一个结构体 `Student`:
|
||||
```cpp
|
||||
struct Student {
|
||||
string name;
|
||||
int score;
|
||||
int id;
|
||||
};
|
||||
```
|
||||
|
||||
我们需要定义一个普通的函数(写在 `main` 函数外面):
|
||||
|
||||
```cpp
|
||||
// 比较函数:cmp
|
||||
bool cmp(const Student& a, const Student& b) {
|
||||
// 排序逻辑写在这里
|
||||
return a.score < b.score; // 当 a 应该排在前面时,需要返回 true,我们这里按照分数的升序排序
|
||||
}
|
||||
```
|
||||
|
||||
### 9. 常用排序逻辑
|
||||
|
||||
#### A. 升序排列 (从小到大)
|
||||
逻辑:当 `a` 的值 **小于** `b` 的值时,`a` 在前,返回 `true`。
|
||||
```cpp
|
||||
bool cmp(const Student& a, const Student& b) {
|
||||
return a.score < b.score;
|
||||
}
|
||||
```
|
||||
|
||||
#### B. 降序排列 (从大到小)
|
||||
逻辑:当 `a` 的值 **大于** `b` 的值时,`a` 在前,返回 `true`。
|
||||
```cpp
|
||||
bool cmp(const Student& a, const Student& b) {
|
||||
return a.score > b.score;
|
||||
}
|
||||
```
|
||||
|
||||
#### C. 多条件排序 (重点)
|
||||
这是结构体排序最常见的场景。例如:**先按分数从高到低排序;如果分数相同,则按学号从小到大排序。**
|
||||
|
||||
```cpp
|
||||
bool cmp(const Student& a, const Student& b) {
|
||||
if (a.score > b.score) {
|
||||
// 第一关键字:分数不同,按分数大的在前排序
|
||||
return true;
|
||||
} else if(a.score < b.score){
|
||||
// 第一关键字:分数不同,按分数大的在前排序,a 更小所以应该排在后面,返回false
|
||||
return false;
|
||||
}else{
|
||||
// 第二关键字:分数相同,按学号升序(小的在前)
|
||||
return a.id < b.id;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10. 完整代码示例
|
||||
|
||||
```cpp
|
||||
#include <bits/stdc++.h>
|
||||
using namespace std;
|
||||
|
||||
struct Student {
|
||||
string name;
|
||||
int score;
|
||||
int id;
|
||||
};
|
||||
|
||||
// 自定义比较函数:先按分数降序,分数相同按ID升序
|
||||
bool cmp(const Student& a, const Student& b) {
|
||||
if (a.score > b.score) {
|
||||
// 第一关键字:分数不同,按分数大的在前排序
|
||||
return true;
|
||||
} else if(a.score < b.score){
|
||||
// 第一关键字:分数不同,按分数大的在前排序,a 更小所以应该排在后面,返回false
|
||||
return false;
|
||||
}else{ // 分数相同
|
||||
// 第二关键字:分数相同,按学号升序(小的在前)
|
||||
return a.id < b.id;
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
vector<Student> students; // 为了方便,没有输入测试数据,你可以自行添加
|
||||
|
||||
// 调用 sort,传入函数名 cmp 作为第三个参数
|
||||
sort(students.begin(), students.end(), cmp);
|
||||
|
||||
// 输出结果
|
||||
for (const auto& s : students) {
|
||||
cout << s.name; // 输出的顺序已经按分数从高到低排序;如果分数相同,则按学号从小到大排序
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
### 11. 注意事项
|
||||
|
||||
在编写比较函数时,必须满足严格弱序原则。最常见的错误是使用 `<=` 或 `>=`。
|
||||
|
||||
* **错误写法**:
|
||||
|
||||
```cpp
|
||||
bool cmp(const Student& a, const Student& b) {
|
||||
return a.score >= b.score; // 错误!
|
||||
}
|
||||
```
|
||||
**为什么?**:因为如果 `a.score` 等于 `b.score`,`cmp(a, b)` 返回 `true`,且 `cmp(b, a)` 也返回 `true`。这会让 `sort` 认为 a **必须**在 b 前面,同时 b **也必须**在 a 前面,导致逻辑矛盾,可能引发**死循环**或**程序崩溃**。
|
||||
|
||||
* **正确的方法**:
|
||||
当两个元素相等时,比较函数**必须**返回 `false`。两个都返回 `false` 函数就会认为 **a 等于 b** (谁在前无所谓,视为相等)。
|
||||
|
||||
* **正确写法**:
|
||||
|
||||
```cpp
|
||||
bool cmp(const Student& a, const Student& b) {
|
||||
return a.score > b.score; // 正确
|
||||
}
|
||||
```
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,183 @@
|
||||
## **C++算法 暴力枚举 讲义**
|
||||
|
||||
#### **1. 引言**
|
||||
|
||||
* **问题引入**
|
||||
|
||||
设想一下,你觉得家门口的山非常碍事,决心发扬“愚公移山”的精神,一镐一担地要把山移走。这个故事告诉我们,只要目标明确、方法单一,并且有无限的时间,理论上任何有限的困难都可以被克服。这就是“暴力”思想的雏形:**不计代价,穷举所有操作**。
|
||||
|
||||
然而,现实中时间是有限的。但如果问题规模变了呢?如果你要移走的不是一座山,而是一个不到半人高的小沙堆,那一镐一担的笨办法虽然累,但很快就能完成。再切换一个场景:你忘记了行李箱的三位数字密码,从 `000` 试到 `999`,这1000次尝试对人来说可能很繁琐,但对每秒能进行上亿次计算的计算机来说,简直就是一眨眼的功夫。
|
||||
|
||||
这堂课,我们就是要学习如何让计算机来做这个“不知疲倦的愚公”,用最直接、最朴素的方式,在有限且可控的“解空间”内,系统性地找出我们想要的答案。
|
||||
|
||||
* **价值阐述**
|
||||
|
||||
很多初学者看不起“暴力”,觉得它“没技术含量”。这种想法是极其危险的。一个成熟的程序员必须深刻理解并掌握暴力枚举,因为它:
|
||||
|
||||
1. **兜底与抢分**:在算法竞赛中,你可能无法立刻想出最优解。但只要问题的数据规模不大,编写一个简单的暴力程序往往能让你稳稳拿到30%甚至70%的分数,这远比因为想不出低复杂度解法而交白卷要强得多。
|
||||
|
||||
2. **高级算法的基石**:未来要学习的**深度优先搜索(DFS)、回溯法**,**动态规划(DP)**,其本质都是在暴力枚举的基础上,增加了“**剪枝**”(砍掉无效的搜索分支)或“**记忆化**”(记住已经算过的结果,避免重复计算)等优化手段。
|
||||
|
||||
3. **是检验正确性的标准**:当你实现了一个复杂的、高度优化的算法后,你如何100%确定它是对的?一个常见且可靠的方法就是,用一个逻辑简单、绝对正确的暴力算法,在小数据规模下和你的最优解进行“对拍”(比对最终输出结果是否一致),来验证你复杂算法的正确性。
|
||||
|
||||
---
|
||||
### **3. 实例**
|
||||
|
||||
#### **案例一:统计棋盘中的矩形和正方形 (例10-1)**
|
||||
|
||||
这个问题是理解“暴力”如何演进为“优雅”的最佳范例。
|
||||
|
||||
* **问题**: 在一个 `n x m` 的棋盘上,有多少个矩形?多少个正方形?
|
||||
* **示例**: 一个 `2 x 3` 的棋盘。
|
||||
|
||||
##### **解法A:暴力四重循环枚举“两个角点”**
|
||||
|
||||
* **核心思路**: 一个矩形可以被它左上角顶点 `(x1, y1)` 和右下角顶点 `(x2, y2)` 唯一确定。那么,我们只要能枚举出所有合法的 `(x1, y1, x2, y2)` 组合,就等同于找到了所有矩形。
|
||||
|
||||
* **分步演示 (思维过程)**:
|
||||
1. **枚举左上角行 `x1`**: 它可以从第1行到第n行。`for (int x1 = 1; x1 <= n; ++x1)`。
|
||||
2. **枚举左上角列 `y1`**: 它可以从第1列到第m列。`for (int y1 = 1; y1 <= m; ++y1)`。
|
||||
3. **枚举右下角行 `x2`**: `x2` 必须在 `x1` 的下方或同一行,所以它从 `x1` 开始,到第n行结束。`for (int x2 = x1; x2 <= n; ++x2)`。
|
||||
4. **枚举右下角列 `y2`**: `y2` 必须在 `y1` 的右方或同一列,所以它从 `y1` 开始,到第m列结束。`for (int y2 = y1; y2 <= m; ++y2)`。
|
||||
5. **检验逻辑 (`check`)**: 在四重循环的内部,我们得到了一个矩形。
|
||||
* 计算宽度 `width = y2 - y1 + 1`。
|
||||
* 计算高度 `height = x2 - x1 + 1`。
|
||||
* 如果 `width == height`,那么正方形数量 `square_count++`。
|
||||
* 否则,它是一个非正方形的矩形,`rectangle_count++`。
|
||||
|
||||
* **代码实现**
|
||||
|
||||
```cpp
|
||||
long long square_count = 0;
|
||||
long long rectangle_count = 0;
|
||||
|
||||
// 枚举左上角 (x1, y1)
|
||||
for (int x1 = 1; x1 <= n; ++x1) {
|
||||
for (int y1 = 1; y1 <= m; ++y1) {
|
||||
// 枚举右下角 (x2, y2)
|
||||
for (int x2 = x1; x2 <= n; ++x2) {
|
||||
for (int y2 = y1; y2 <= m; ++y2) {
|
||||
// 这是一个合法的矩形,进行判断
|
||||
if ((x2 - x1) == (y2 - y1)) {
|
||||
square_count++;
|
||||
} else {
|
||||
rectangle_count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
* **复杂度分析**: 四层循环,每一层循环的次数都与 `n` 或 `m` 相关。总的时间复杂度为 $O(N^2 \times M^2)$。如果 `N` 和 `M` 是1000,计算次数将是 $1000^4 = 10^{12}$(万亿)级别。**这在任何比赛中都是不可接受的,必定超时!**
|
||||
|
||||
##### **解法B:枚举“两条边”和“边长”**
|
||||
|
||||
暴力法的低效根源在于枚举的对象(点)和问题(矩形)的关联太间接。我们换一个视角。
|
||||
|
||||
* **核心思路 (矩形)**: 一个矩形是由“两条不平行的水平线”和“两条不平行的垂直线”框出来的。
|
||||
* 在一个 `n` 行的棋盘上,有多少条水平线?答案是 `n+1` 条。
|
||||
* 在一个 `m` 列的棋盘上,有多少条垂直线?答案是 `m+1` 条。
|
||||
* **问题转化**: 找矩形 = 从 `n+1` 条水平线中选2条 $\times$ 从 `m+1` 条垂直线中选2条。
|
||||
* **数学公式**: 组合学告诉我们,从 `k` 个物品中选2个的组合数是 $C(k, 2) = \frac{k \times (k-1)}{2}$。
|
||||
* **总矩形数量** = $(\frac{(n+1) \times n}{2}) \times (\frac{(m+1) \times m}{2})$。
|
||||
* 这个公式可以在 $O(1)$ 时间内计算出所有矩形(包括正方形)的总数。
|
||||
|
||||
* **核心思路 (正方形)**: 改变枚举对象,我们不枚举角点,而是枚举**正方形的边长**。
|
||||
* **边长为 1** 的正方形有多少个?显然是 `n * m` 个。
|
||||
* **边长为 2** 的正方形有多少个?想象一个 `2x2` 的正方形在棋盘上滑动。它的右下角可以在 `(n-1) * (m-1)` 个位置上。所以有 `(n-1) * (m-1)` 个。
|
||||
* **边长为 k** 的正方形有多少个?它的右下角可以在 `(n-k+1) * (m-k+1)` 个位置上。
|
||||
* **总正方形数量**: 我们只需写一个循环,让边长 `k` 从1遍历到 `min(n, m)` 即可。
|
||||
`for (int k = 1; k <= min(n, m); ++k) { square_count += (n-k+1) * (m-k+1); }`
|
||||
这个循环的复杂度是 $O(\min(N, M))$,与 $O(N^2 M^2)$ 相比,是天壤之别。
|
||||
|
||||
* **最终答案**:
|
||||
1. 用 $O(\min(N,M))$ 的循环计算出 `square_count`。
|
||||
2. 用 $O(1)$ 的公式计算出 `total_rectangle_count`。
|
||||
3. 非正方形的矩形数量 = `total_rectangle_count - square_count`。
|
||||
|
||||
---
|
||||
#### **案例二:位运算枚举子集 (选数问题)**
|
||||
|
||||
* **问题**: $n=4$ 个数 `x = {3, 7, 12, 19}`,选出 `k=3` 个,求和为素数的方案数。
|
||||
|
||||
* **核心思路**: 计算机世界里,万物皆比特。我们可以用一个整数的二进制形态,来“压缩”一个集合的选取状态(有些像之前学习的桶数组思想)。
|
||||
|
||||
* **分步演示 (深度解析)**
|
||||
1. **建立映射**: 我们有4个数,需要4个“开关”。一个4位的二进制数正好可以表示。`b₃b₂b₁b₀`。
|
||||
* `b₀` 对应 `x[0]` (3) 的状态。
|
||||
* `b₁` 对应 `x[1]` (7) 的状态。
|
||||
* `b₂` 对应 `x[2]` (12) 的状态。
|
||||
* `b₃` 对应 `x[3]` (19) 的状态。
|
||||
|
||||
2. **遍历所有可能性**: 所有的选取方案(子集)共有 $2^4=16$ 种。这16种方案,正好对应从 `0000` 到 `1111` 这16个二进制数,也就是十进制的 `0` 到 `15`。
|
||||
* `for (int i = 0; i < 16; ++i)` 这个循环,就是在不重不漏地遍历所有子集!`i` 在这里被称为“**掩码 (mask)**”。
|
||||
|
||||
3. **筛选符合条件的子集 (剪枝)**: 题目要求只选 `k=3` 个数。在我们的模型里,这就等价于:**掩码 `i` 的二进制表示中,必须恰好有3个`1`**。
|
||||
* 例如,`i=13 (1101)`,有3个`1`,是我们要考虑的。
|
||||
* 例如,`i=9 (1001)`,只有2个`1`,不符合`k=3`的要求,直接跳过。
|
||||
* `__builtin_popcount(i)` 这个函数就是用来干这个的,它能快速统计`1`的个数。
|
||||
|
||||
4. **解码掩码,计算和**: 对于一个通过了筛选的掩码 `i` (比如`13`),我们如何知道它代表了哪几个数呢?这就需要用到 `(i >> j) & 1` 这个核心公式。
|
||||
* **目标**: 检查掩码 `i` 的每一位(从第0位到第3位)。
|
||||
* `j=0` (检查第0位):
|
||||
* `i >> 0`: `1101` 右移0位,还是 `1101`。
|
||||
* `(1101) & 1`: `1101` 按位与 `0001`,结果是 `0001` (即1)。
|
||||
* `if (1)` 为真,说明第0位是1,所以把 `x[0]` (即3) 加入 `current_sum`。
|
||||
* `j=1` (检查第1位):
|
||||
* `i >> 1`: `1101` 右移1位,变成 `0110`。
|
||||
* `(0110) & 1`: `0110` 按位与 `0001`,结果是 `0000` (即0)。
|
||||
* `if (0)` 为假,说明第1位是0,跳过 `x[1]`。
|
||||
* `j=2` (检查第2位):
|
||||
* `i >> 2`: `1101` 右移2位,变成 `0011`。
|
||||
* `(0011) & 1`: `0011` 按位与 `0001`,结果是 `0001` (即1)。
|
||||
* `if (1)` 为真,说明第2位是1,把 `x[2]` (即12) 加入 `current_sum`。
|
||||
* `j=3` (检查第3位):
|
||||
* `i >> 3`: `1101` 右移3位,变成 `0001`。
|
||||
* `(0001) & 1`: `0001` 按位与 `0001`,结果是 `0001` (即1)。
|
||||
* `if (1)` 为真,说明第3位是1,把 `x[3]` (即19) 加入 `current_sum`。
|
||||
* 至此,我们成功通过掩码 `13` 解码出子集 `{3, 12, 19}`,并计算出和 `3+12+19 = 34`。
|
||||
|
||||
5. **检验 (`check`)**: 将 `current_sum` (34) 传递给 `isPrime` 函数判断。`isPrime(34)` 返回`false`。所以这个方案不计数。
|
||||
|
||||
程序会继续遍历其他 `popcount` 为3的掩码,如 `i=11 (1011)` -> `{3,7,19}` -> sum=29 -> `isPrime(29)`为`true`,`total++`。
|
||||
|
||||
---
|
||||
#### **案例三:全排列函数 `next_permutation`**
|
||||
|
||||
* **问题**: 给定集合 `{1, 2, 3}`,输出它的所有排列。
|
||||
* **排列 vs. 组合**:
|
||||
* **组合**: `{1, 2}` 和 `{2, 1}` 是同一个组合。
|
||||
* **排列**: `{1, 2}` 和 `{2, 1}` 是两个不同的排列。顺序很重要!
|
||||
* 位运算枚举的是组合(子集),它无法表达顺序信息。
|
||||
|
||||
* **核心思路**: C++ STL 提供了一个函数 `next_permutation`,它可以将一个序列转换成它的**下一个字典序排列**。
|
||||
|
||||
* **分步演示 (代码执行流)**
|
||||
1. **初始化**: 创建一个 `vector<int> v = {1, 2, 3};`。**至关重要的一步**:在使用 `next_permutation` 前,必须保证你的序列是**字典序最小**的,也就是排好序的。否则将错过在它之前的所有排列。
|
||||
2. **使用 `do-while` 模板**:
|
||||
```cpp
|
||||
do {
|
||||
// ... 处理当前排列 v ...
|
||||
} while (next_permutation(v.begin(), v.end()));
|
||||
```
|
||||
3. **执行过程**:
|
||||
* **第一次进入循环**: `do` 会无条件执行一次。此时 `v` 是 `{1, 2, 3}`。程序处理(比如打印)这个初始排列。
|
||||
* **第一次 `while` 判断**: `next_permutation` 被调用。它发现 `{1, 2, 3}` 的下一个排列是 `{1, 3, 2}`。于是,它将 `v` **原地修改**为 `{1, 3, 2}`,并返回 `true`。循环继续。
|
||||
* **第二次进入循环**: `do` 块执行。此时 `v` 是 `{1, 3, 2}`。程序处理这个排列。
|
||||
* **第二次 `while` 判断**: `next_permutation` 被调用。它将 `v` 修改为 `{2, 1, 3}`,并返回 `true`。循环继续。
|
||||
* ...这个过程不断重复...
|
||||
* **最后一次有效循环**: 假设当前 `v` 是 `{3, 2, 1}`,这是字典序最大的排列。`do` 块处理它。
|
||||
* **最后的 `while` 判断**: `next_permutation` 被调用。它发现 `{3, 2, 1}` 已经没有下一个更大的排列了。于是,它会做两件事:
|
||||
1. 将 `v` 变回**字典序最小**的排列,即 `{1, 2, 3}`。
|
||||
2. 返回 `false`。
|
||||
* 因为返回 `false`,`while` 循环终止。整个全排列过程结束。
|
||||
|
||||
### **6. 总结与巩固**
|
||||
|
||||
1. 暴力枚举是解决问题的基本盘,是学习更高级搜索算法(如DFS)的必经之路。不要轻视它,要熟练掌握它。
|
||||
2. **不重不漏**:设计枚举方案时,首要确保的是覆盖所有可能解,既不能重复计算,也不能遗漏任何一种情况。
|
||||
3. **优化**:纯粹的暴力是愚公,而如果带着智慧的暴力是巧匠:
|
||||
* **数学思维**:尽可能能推导出公式解决的,绝不动循环。
|
||||
* **剪枝意识**:在枚举过程中,尽早判断并排除那些明显不可能是答案的分支。
|
||||
4. **子集问题 -> 位运算**:当N较小(一般<20)时,使用 `for (int i = 0; i < (1 << n); ++i)` 遍历掩码,并用 `(i >> j) & 1` 解析每一位,是比较方便的解法。
|
||||
5. **排列问题 -> `next_permutation`**:熟练掌握 `do-while` + `next_permutation` 的模板,可以轻松解决全排列相关问题。
|
||||
Binary file not shown.
Binary file not shown.
@@ -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.
@@ -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;` 是为了防止整数除法导致精度丢失。
|
||||
|
||||
Binary file not shown.
@@ -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. 在“分卷子”问题中,总成本等于所有中间步骤产生的卷子堆的数量之和。
|
||||
Binary file not shown.
@@ -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. √
|
||||
Binary file not shown.
@@ -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. “排队接水”和“活动安排”两个问题的贪心策略,第一步都是对输入数据进行排序。
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,250 @@
|
||||
## **C++算法讲义: 二分查找**
|
||||
|
||||
#### **1. 引言 **
|
||||
|
||||
* **问题引入:猜数字游戏**
|
||||
从 1 到 1000 猜一个数字,你会怎么猜?
|
||||
* **线性查找 (老实人)**:从 1 开始逐个猜。最坏情况猜 1000 次,$O(N)$。
|
||||
* **二分查找 (聪明人)**:每次猜中间,根据反馈排除一半区间。最坏情况只需 10 次 ($2^{10} > 1000$),$O(\log N)$。
|
||||
当数据量达到 42 亿时,线性查找需 42 亿次,而二分查找仅需 32 次,效率差距天壤之别!
|
||||
|
||||
|
||||
* **核心前提**
|
||||
二分查找之所以高效,是因为它有一个**前提**:**数据必须具有单调性(即有序)**。如果数据是乱序的,折半策略将失效。就像在乱七八糟的纸堆里找东西,你不可能“折半”查找。
|
||||
|
||||
* **学习价值**
|
||||
掌握二分查找是 C++ 算法学习者的必备技能:
|
||||
* **高效搜索**:在大量有序数据中快速定位,提升程序性能。
|
||||
* **解决变体问题**:很多复杂问题通过转化,也能应用二分查找思想。
|
||||
* **面试高频考点**:它不仅考察代码实现,更考察对边界和变体问题的处理能力。
|
||||
|
||||
* **学习目标**
|
||||
学完此部分,你应该能:
|
||||
1. 理解二分查找的**分治原理**和**对数时间复杂度**。
|
||||
2. 掌握数据**有序性**这一核心前提。
|
||||
3. 熟练编写**经典二分查找模板**,并理解其闭区间 `l, r`、`l <= r` 循环条件和 `mid` 计算方式。
|
||||
4. 掌握修改模板,解决**查找第一个/最后一个出现的目标值**等变体问题。
|
||||
5. 识别**死循环陷阱**,并正确处理 `mid` 计算与边界更新关系。
|
||||
|
||||
---
|
||||
|
||||
#### **2. 核心思想**
|
||||
|
||||
##### **2.1 二分查找的定义**
|
||||
|
||||
二分查找(Binary Search)是一种基于**分治策略**的高效搜索算法。它利用数据的**有序性**,每轮将搜索范围减半,直到找到目标元素或搜索区间为空。
|
||||
|
||||
类比词典查单词:打开中间页,如果单词靠前就往前翻,靠后就往后翻,每次都排除一半无关的页码。
|
||||
|
||||
##### **2.2 基本原理:三指针与区间缩减**
|
||||
|
||||
在有序数组 `arr` 中查找 `target`,我们使用三个“指针”:
|
||||
* `l` (左边界):搜索区间的头部索引。
|
||||
* `r` (右边界):搜索区间的尾部索引。
|
||||
* `mid` (中间点):`mid = l + (r - l) / 2`。
|
||||
|
||||
**核心步骤(循环执行):**
|
||||
|
||||
1. **计算 `mid`**:`mid = l + (r - l) / 2` (向下取整)。这种写法能**有效防止 `l + r` 溢出**。
|
||||
2. **比较 `arr[mid]` 与 `target`**:
|
||||
* `arr[mid] < target`:目标在 `mid` 的右边,更新 `l = mid + 1`。
|
||||
* `arr[mid] > target`:目标在 `mid` 的左边,更新 `r = mid - 1`。
|
||||
* `arr[mid] == target`:找到目标,返回 `mid`。
|
||||
|
||||
##### **2.3 经典二分查找模板 (闭区间 `[l, r]`)**
|
||||
|
||||
我们推荐使用**闭区间 `[l, r]`** 的写法,因为它清晰直观,不易出错。
|
||||
|
||||
```cpp
|
||||
// 在有序数组 nums 中查找 target,找到返回下标,没找到返回 -1
|
||||
int bs(const vector<int>& nums, int target) {
|
||||
int l = 0;
|
||||
int r = nums.size() - 1; // r 是最后一个元素的下标,[l, r] 闭区间
|
||||
|
||||
// 循环条件:当 l <= r 时,表示搜索区间 [l, r] 依然有效
|
||||
// 即使 l == r,区间中仍有一个元素,需要检查
|
||||
while (l <= r) {
|
||||
int mid = l + (r - l) / 2; // 防溢出写法,向下取整
|
||||
|
||||
if (nums[mid] == target) {
|
||||
return mid; // 找到了,直接返回
|
||||
} else if (nums[mid] < target) {
|
||||
l = mid + 1; // 目标在右半边,新的左边界变为 mid + 1
|
||||
} else { // nums[mid] > target
|
||||
r = mid - 1; // 目标在左半边,新的右边界变为 mid - 1
|
||||
}
|
||||
}
|
||||
// 循环结束,说明搜索区间为空(l > r),target 不存在于数组中
|
||||
return -1;
|
||||
}
|
||||
```
|
||||
|
||||
**代码解读要点:**
|
||||
1. `r = nums.size() - 1;`:`r` 初始化为最后一个元素的下标,定义 `[l, r]` 为闭区间。
|
||||
2. `while (l <= r)`:关键条件。当 `l == r` 时,区间中仍有 1 个元素,需要检查。
|
||||
3. `l = mid + 1;` 和 `r = mid - 1;`:当 `nums[mid]` 不等于 `target` 时,`mid` 肯定不是目标,所以可以直接排除 `mid`。
|
||||
|
||||
---
|
||||
|
||||
#### **3. 实例**
|
||||
|
||||
##### **3.1 查找第一个出现的元素 (OJ:B0189 )**
|
||||
|
||||
**题目:** 有序数组 `nums` 可能包含重复元素。查找 `target` 第一次出现的索引,未找到返回 -1。
|
||||
|
||||
**解决思路:找到了别停,继续向左收缩!**
|
||||
当 `nums[mid] == target` 时,先记录 `mid` 可能是答案,然后向左 (`r = mid - 1`) 继续搜索,看是否存在更靠前的 `target`。
|
||||
|
||||
```cpp
|
||||
// 查找第一次出现 target 的索引
|
||||
int bsFirst(const vector<int>& nums, int target) {
|
||||
int l = 0; // 左边界索引
|
||||
int r = nums.size() - 1; // 右边界索引
|
||||
int ans = -1; // 记录可能的结果
|
||||
|
||||
while (l <= r) {
|
||||
int mid = l + (r - l) / 2;
|
||||
|
||||
if (nums[mid] == target) {
|
||||
ans = mid; // 记录当前索引
|
||||
r = mid - 1; // 继续向左收缩,找更靠前的
|
||||
} else if (nums[mid] < target) {
|
||||
l = mid + 1; // 目标在右侧
|
||||
} else { // nums[mid] > target
|
||||
r = mid - 1; // 目标在左侧
|
||||
}
|
||||
}
|
||||
return ans; // 返回记录的第一个 target 的索引
|
||||
}
|
||||
```
|
||||
|
||||
**流程 (查找 `2` 在 `[1, 2, 2, 2, 3, 5, 7, 9]`):**
|
||||
|
||||
1. `l=0, r=7, mid=3` (指向第二个 `2`)。`nums[3]==2`。`ans=3`,`r=2`。
|
||||
2. `l=0, r=2, mid=1` (指向第一个 `2`)。`nums[1]==2`。`ans=1`,`r=0`。
|
||||
3. `l=0, r=0, mid=0` (指向 `1`)。`nums[0]==1 < 2`。`l=1`。
|
||||
4. `l=1, r=0`。`l > r`,循环结束。返回 `ans = 1`。
|
||||
|
||||
##### **3.2 查找最后一个出现的元素**
|
||||
|
||||
**题目:** 寻找这个数字在序列中**最后一次出现**的下标。
|
||||
|
||||
**思路**:与查找第一个出现的元素类似,但策略相反——**找到了别停,继续向右收缩!**
|
||||
当 `nums[mid] == target` 时,先记录 `mid` 可能是答案,然后向右 (`l = mid + 1`) 继续搜索,看是否存在更靠后的 `target`。
|
||||
|
||||
```cpp
|
||||
// 查找最后一个出现 target 的索引
|
||||
int bsLast(const vector<int>& nums, int target) {
|
||||
int l = 0;
|
||||
int r = nums.size() - 1;
|
||||
int ans = -1;
|
||||
|
||||
while (l <= r) {
|
||||
int mid = l + (r - l) / 2;
|
||||
|
||||
if (nums[mid] == target) {
|
||||
ans = mid; // 记录当前索引
|
||||
l = mid + 1; // 继续向右收缩,找更靠后的
|
||||
} else if (nums[mid] < target) {
|
||||
l = mid + 1; // 目标在右侧
|
||||
} else { // nums[mid] > target
|
||||
r = mid - 1; // 目标在左侧
|
||||
}
|
||||
}
|
||||
return ans;
|
||||
}
|
||||
```
|
||||
|
||||
##### **3.3 区间收缩排除法 (左闭右开 `[l, r)`)**
|
||||
|
||||
这是一种不同风格的二分查找模板,它不是在循环中“找到就返回”,而是不断缩小区间,直到区间只剩下一个元素,最后再检查这个元素是不是我们要找的。
|
||||
|
||||
```cpp
|
||||
// 查找第一个等于或大于 target 的元素
|
||||
int bsExclude(const vector<int>& nums, int target) {
|
||||
int l = 0;
|
||||
int r = nums.size(); // [l, r) 左闭右开,r 初始化为 size
|
||||
|
||||
// 循环条件:当 l < r 时,表示搜索区间 [l, r) 依然有效
|
||||
// 最终 l == r,指向第一个满足条件的元素或 size
|
||||
while (l < r) {
|
||||
int mid = l + (r - l) / 2; // 向下取整
|
||||
if (nums[mid] >= target) {
|
||||
r = mid; // mid 可能是答案,或者答案在 mid 左边,所以把 r 收缩到 mid (不排除 mid)
|
||||
} else { // nums[mid] < target
|
||||
l = mid + 1; // mid 太小,排除 mid,把 l 移到 mid + 1
|
||||
}
|
||||
}
|
||||
// 循环结束时,l == r。这个 l 就是第一个 >= target 的位置。
|
||||
// 需要后处理:检查 l 是否越界且 nums[l] 是否等于 target。
|
||||
if (l < nums.size() && nums[l] == target) {
|
||||
return l;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**代码解读要点:**
|
||||
1. **区间定义:`[l, r)` (左闭右开)**。`r` 初始化为 `nums.size()`。
|
||||
2. **`while (l < r)`**:当 `l == r` 时循环终止。
|
||||
3. **`if (nums[mid] >= target) r = mid;`**:`mid` 可能含有答案,因此 `mid` 不被排除。
|
||||
4. **`else l = mid + 1;`**:`mid` 肯定不是答案,因此 `mid` 被排除。
|
||||
5. **后处理**:循环结束时 `l == r`,这个 `l` 是第一个 `>= target` 的位置。需要额外检查。
|
||||
|
||||
##### **3.4 陷阱:二分查找的死循环**
|
||||
|
||||
死循环常发生在**区间只剩 2 个元素**时 (`l`, `l+1`)。
|
||||
**原因**:`mid` 的取整方向与边界更新方向不一致。
|
||||
* 如果策略是 `l = mid` (保留 `mid` 作为左边界),且 `mid` 是**向下取整**(偏向 `l`),那么当 `r = l+1` 时 `mid` 永远等于 `l`,导致 `l = mid` 陷入 `l=l` 的死循环。
|
||||
* **解决方案**:
|
||||
* **当 `l = mid` 更新时,`mid` 必须**向上取整:`mid = l + (r - l + 1) / 2`。
|
||||
* **当 `r = mid` 更新时,`mid` 必须**向下取整:`mid = l + (r - l) / 2`。
|
||||
|
||||
**逻辑本质:** 二分查找的死循环,永远发生在区间还剩 2 个元素的时候。`mid` 必须跳过那个被保留的边界,去测试另一个元素,这样区间才能缩小。
|
||||
|
||||
**示例:寻找最后一个 `target`,使用 `[l, r)` 和 `l = mid` 策略时,正确的 `mid` 计算**
|
||||
|
||||
```cpp
|
||||
// 查找最后一个等于 target 的元素
|
||||
int bsLastOpt(const vector<int>& nums, int target) {
|
||||
int l = 0;
|
||||
int r = nums.size(); // [l, r) 左闭右开
|
||||
|
||||
int lastIdx = -1; // 记录可能的最后一个 target 索引
|
||||
|
||||
while (l < r) {
|
||||
// mid 向上取整,以避免 l = mid 时死循环
|
||||
int mid = l + (r - l + 1) / 2; // 注意这里 +1,让 mid 偏向右边
|
||||
|
||||
if (nums[mid] <= target) { // mid 可能是答案,或答案在 mid 右边,包含 mid
|
||||
lastIdx = mid; // 记录当前可能的答案
|
||||
l = mid; // 保留 mid,向右搜索 [mid, r)
|
||||
} else { // nums[mid] > target // mid 太大了,答案在 mid 左边,不包含 mid
|
||||
r = mid - 1; // 排除 mid,向左搜索 [l, mid-1)
|
||||
}
|
||||
}
|
||||
// 循环结束,检查 lastIdx 是否有效且等于 target
|
||||
if (lastIdx != -1 && nums[lastIdx] == target) {
|
||||
return lastIdx;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **4. 总结**
|
||||
|
||||
* **要点回顾**
|
||||
1. **核心**:利用有序性,每次排除一半搜索空间,$O(\log N)$。
|
||||
2. **前提**:数据必须有序。
|
||||
3. **经典模板 (`[l, r]` 闭区间)**:`r = size - 1; while (l <= r); l = mid + 1; r = mid - 1;` 找到即可返回。
|
||||
4. **变体**:
|
||||
* **查找第一个**:`nums[mid] == target` 时,`ans = mid; r = mid - 1;` (向左收缩)。
|
||||
* **查找最后一个**:`nums[mid] == target` 时,`ans = mid; l = mid + 1;` (向右收缩)。
|
||||
5. **区间收缩排除法 (`[l, r)` 左闭右开)**:`r = size; while (l < r);` 最终 `l == r` 需后处理。
|
||||
6. **死循环**:`mid` 取整方向需与边界更新方向一致。
|
||||
* `l = mid` 时,`mid` 向上取整 (`mid = l + (r - l + 1) / 2`)。
|
||||
* `r = mid` 时,`mid` 向下取整 (`mid = l + (r - l) / 2`)。
|
||||
Binary file not shown.
@@ -0,0 +1,185 @@
|
||||
# 算法:二分答案 讲义
|
||||
|
||||
## 一、 引言
|
||||
|
||||
在基础二分查找中,我们的搜索域局限于已有的物理数组(如在一个有序序列中查找特定元素)。而二分答案是一种高阶的算法思维,它将二分的搜索域拓展到了抽象的**解空间** 中,用于锁定满足特定条件的最优解。
|
||||
|
||||
### 1. 经典最优化问题:砍树问题 (EKO)
|
||||
**问题描述**:
|
||||
给定 $N$ 棵树的高度集合,需要收集至少 $M$ 米的木材。设定一个全局锯片高度阈值 $H$,凡是高度大于 $H$ 的树,其超出 $H$ 的部分将被截取。
|
||||
**优化目标**:在保证收集木材总量 $\ge M$ 的前提下,求锯片高度 $H$ 的**最大可能值**。
|
||||
|
||||
### 2. 最优化问题
|
||||
日常算法中常见的“最大化最小值”、“最小化最大值”等最优化问题,直接正向求解往往极其困难,甚至缺乏解析解。
|
||||
二分答案的思想提供了一种思路:它将**“求出最优解是多少”**转化为了**“验证某一个给定的猜测解是否合法”**。这种视角的转换,能够将指数级或多项式级的求解时间,降维至对数时间复杂度 $O(\log N)$。
|
||||
|
||||
---
|
||||
|
||||
## 二、 理论基础:单调性与复杂度突破
|
||||
|
||||
### 1. 暴力穷举的性能瓶颈
|
||||
若采用暴力枚举,从高度 0 逐一递增尝试高度 $H$:
|
||||
* **状态空间**:最高树高若为 $10^9$ 米,则最坏需要尝试 $10^9$ 次。
|
||||
* **状态验证**:每次尝试需遍历 $N$ 棵树验证总木材量,耗时 $O(N)$。
|
||||
* **总时间复杂度**:$O(N \times \text{MaxHeight})$。当 $N \approx 10^6$ 且 $\text{MaxHeight} \approx 10^9$ 时,运算量达 $10^{15}$ 级别,必定导致 TLE。
|
||||
|
||||
### 2. 二分充要条件:单调性 (Monotonicity)
|
||||
|
||||
观察砍树问题的内在物理规律:
|
||||
* 阈值 $H$ **递增** $\rightarrow$ 被截取部分缩短 $\rightarrow$ 木材总量**单调递减**。
|
||||
这种严格的单调递减特性,构成了二分答案的充要条件。在解空间 $[0, \text{MaxHeight}]$ 中,必然存在一个明确的布尔值临界点(即 `true` 与 `false` 的分界线)。我们无需线性遍历,而是可以直接通过折半查找(二分法)快速逼近该临界点。
|
||||
优化后的总时间复杂度将骤降至:**$O(N \log (\text{MaxHeight}))$**。
|
||||
|
||||
---
|
||||
|
||||
## 三、 标准求解模板
|
||||
|
||||
解决此类问题的标准步骤如下:
|
||||
|
||||
### 1. 界定解空间 $[left, right]$
|
||||
明确答案的数学下界与上界。在砍树问题中:
|
||||
* `left` $= 0$(地平线)。
|
||||
* `right` $= \max(\text{trees})$(树的理论最高值)。
|
||||
|
||||
### 2. 构造验证函数 `check(mid)`
|
||||
设计一个返回布尔值的函数,负责回答判定性问题:“若猜测答案为 $mid$,当前方案是否合法?”
|
||||
在砍树问题中,仅需计算高度为 $mid$ 时的总木材截取量,与需求 $M$ 进行比较即可。
|
||||
|
||||
### 3. 在二分中收敛答案
|
||||
在 $[left, right]$ 区间内不断取中值 $mid$ 交由 `check` 验证,并基于验证结果收缩边界,最终逼近极限即为我们的答案。
|
||||
|
||||
---
|
||||
|
||||
## 四、 算法推演与代码实现
|
||||
|
||||
### 1. 状态转移演示
|
||||
假设 $N=4, M=7$,树高集合为 $\{20, 15, 10, 17\}$。
|
||||
初始状态:`left` $= 0$, `right` $= 20$, `ans` $= 0$。
|
||||
|
||||
| 迭代 | $left$ | $right$ | $mid$ | `check(mid)` 验证 | 判定结果 | 边界更新策略 |
|
||||
| :--- | :----- | :------ | :---- | :----------------------------- | :------- | :---------------------------------------- |
|
||||
| 1 | 0 | 20 | 10 | 截取量 $= 10+5+0+7 = 22 \ge 7$ | `true` | 木材充足,尝试抬高锯片:$ans=10, left=11$ |
|
||||
| 2 | 11 | 20 | 15 | 截取量 $= 5+0+0+2 = 7 \ge 7$ | `true` | 木材刚好,尝试极值:$ans=15, left=16$ |
|
||||
| 3 | 16 | 20 | 18 | 截取量 $= 2+0+0+0 = 2 < 7$ | `false` | 木材不足,必须降低锯片:$right=17$ |
|
||||
| 4 | 16 | 17 | 16 | 截取量 $= 4+0+0+1 = 5 < 7$ | `false` | 木材不足,必须降低锯片:$right=15$ |
|
||||
|
||||
**循环终止**:此时 $left (16) > right (15)$,退出循环。最优解 $ans = 15$。
|
||||
|
||||
### 2. C++ 实现
|
||||
|
||||
```cpp
|
||||
#include <iostream>
|
||||
#include <vector>
|
||||
#include <algorithm>
|
||||
|
||||
using namespace std;
|
||||
|
||||
// 验证器:检测在高度 h 时,木材总量是否达标
|
||||
bool check(long long h, long long m, const vector<int>& trees) {
|
||||
long long sum = 0; // 使用 long long 预防大数值累加溢出
|
||||
for (int tree_height : trees) {
|
||||
if (tree_height > h) {
|
||||
sum += (long long)(tree_height - h);
|
||||
if (sum >= m) return true; // 达标即刻返回
|
||||
}
|
||||
}
|
||||
return sum >= m;
|
||||
}
|
||||
|
||||
int main() {
|
||||
int n;
|
||||
long long m;
|
||||
cin >> n >> m;
|
||||
|
||||
vector<int> trees(n);
|
||||
int max_h = 0;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
cin >> trees[i];
|
||||
max_h = max(max_h, trees[i]);
|
||||
}
|
||||
|
||||
// 解空间初始化
|
||||
long long left = 0, right = max_h;
|
||||
long long ans = 0;
|
||||
|
||||
// 二分求解主逻辑
|
||||
while (left <= right) {
|
||||
// 防止 left+right 数值溢出的写法
|
||||
long long mid = left + (right - left) / 2;
|
||||
|
||||
if (check(mid, m, trees)) {
|
||||
ans = mid; // 记录合法解
|
||||
left = mid + 1; // 持续逼近上界 (求最大值)
|
||||
} else {
|
||||
right = mid - 1; // 方案非法,收缩上界
|
||||
}
|
||||
}
|
||||
|
||||
cout << ans << "\n";
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
## 五、进击的奶牛 (Aggressive Cows) —— 最大化最小值模型
|
||||
|
||||
**1. 数学模型**
|
||||
|
||||
* **物理场景**:给定一条直线上 `N` 个不重复的坐标点(牛舍),需要从中挑出 `M` 个坐标(安置奶牛)。
|
||||
* **最优化目标**:最大化这 `M` 个坐标中,任意相邻两个坐标之间距离的**最小值**。
|
||||
* **术语定义**:这类问题在算法竞赛中被称为**“最大化最小值”**问题。
|
||||
|
||||
**2. 单调性与解空间界定**
|
||||
要使用二分查找,必须先证明问题在解空间上存在单调性。
|
||||
|
||||
* **问题**:能否在给定的坐标中选出 `M` 个点,使得任意两点间距 $\ge d$。
|
||||
* **推导**:如果在间距要求极其苛刻(极大)的情况下 $P(d)$ 为 `true`,那么放宽要求,对于任意更小的间距 $d-1$,$P(d-1)$ 必然也为 `true`。
|
||||
* **解空间映射**:随着距离 $d$ 的不断递增,命题 $P(d)$ 的返回值序列必然呈现 `[true, true, ..., true, false, false, ...]` 的阶跃分布。
|
||||
* **边界界定**:
|
||||
* 下界 `left = 0`(理论上的最小距离)。
|
||||
* 上界 `right = max_pos - min_pos`(理论上的最大物理跨度)。
|
||||
* **目标**:寻找使 $P(d)$ 为 `true` 的**右侧极限边界**(即最后一个 `true`)。
|
||||
|
||||
**3. 贪心验证 (`check` 函数) 的正确性证明**
|
||||
在 `check(d)` 函数中,我们使用了“**排序 + 顺序贪心**”的策略。很多同学会产生疑问:为什么第一头牛必须强行放在第一个牛舍(最左侧)?
|
||||
* **贪心选择性质证明**:
|
||||
假设存在一种最优的安置方案,第一头牛没有放在第一个牛舍,而是放在了第 `k` 个牛舍($k > 1$)。此时,如果我们把这第一头牛强行向左移动,挪到第 1 个牛舍,它与第二头牛的距离只会**增加**,而绝不会减少。
|
||||
因此,将第一头牛紧贴最左侧放置,是为后续的牛预留最大生存空间的**绝对安全选择**(不劣于其他任何选择)。
|
||||
* **线性扫描**:在固定了第一头牛之后,我们只需从左向右进行一次 $O(N)$ 的线性扫描。只要当前牛舍与上一头牛的相对距离 $\ge d$,就立即安置下一头牛。这种“能放就立刻放”的局部最优策略,最终能推导出全局的最大放置数量。
|
||||
|
||||
**4. C++ 核心代码**
|
||||
|
||||
```cpp
|
||||
// 当规定的绝对安全距离为 d 时,能否成功安置 m 头牛?
|
||||
bool check(int d, int m, const vector<int>& stalls) {
|
||||
// 前提:stalls 数组在传入前必须已经过升序排序!
|
||||
int cows_placed = 1; // 直接在最左端安置第一头牛
|
||||
int last_pos = stalls[0]; // 记录上一头牛的坐标
|
||||
|
||||
for (int i = 1; i < stalls.size(); ++i) {
|
||||
// 只要物理距离达标,立即执行安置(局部最优解)
|
||||
if (stalls[i] - last_pos >= d) {
|
||||
cows_placed++;
|
||||
last_pos = stalls[i]; // 更新
|
||||
}
|
||||
}
|
||||
// 若实际安置总数大于等于目标需求 m,则当前距离 d 合法
|
||||
return cows_placed >= m;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、两类求解最值的模板
|
||||
|
||||
二分答案在算法竞赛中高度模式化,主要分化为两大结构。
|
||||
|
||||
### 1. 最大化合法解 (求满足条件的最大值)
|
||||
解空间的布尔分布呈:`[T, T, ..., T, F, F, ...]`
|
||||
目标:寻找最后一个 `true`。
|
||||
* **边界更新**:`check` 成功时记录 `ans`,并执行 `left = mid + 1` 贪心向右探索。
|
||||
|
||||
### 2. 最小化合法解 (求满足条件的最小值)
|
||||
解空间的布尔分布呈:`[F, F, ..., F, T, T, ...]`
|
||||
目标:寻找第一个 `true`。
|
||||
* **边界更新**:`check` 成功时记录 `ans`,并执行 `right = mid - 1` 贪心向左探索。
|
||||
Binary file not shown.
@@ -0,0 +1,170 @@
|
||||
### 算法: 浮点数二分 讲义
|
||||
|
||||
#### 1. 引言
|
||||
|
||||
* **问题引入**:我们都知道 √2 大约是 1.414,但这个结果是怎么算出来的?计算器按一个键就能得到答案,但它内部到底发生了什么?我们无法通过有限次的加减乘除直接得到这个无限不循环小数。这背后其实就用到了我们今天要学的思想——不断缩小范围,无限逼近。
|
||||
|
||||
* **价值阐述**:浮点数二分是解决**连续函数求根**、**实数域最优解**等问题的一大利器。它在科学与工程计算中扮演着至关重要的角色:
|
||||
* **方程求解**:求解 `sin(x) + x = 5` 这种无法直接解出的**超越方程**的根。
|
||||
* **物理模拟**:计算物体达到特定速度或位置所需的时间。
|
||||
* **几何计算**:在复杂图形中寻找满足特定几何属性(如面积、周长)的参数。
|
||||
它让我们能够用一种统一、简洁且高效的编程模型,来应对各种复杂的数学和工程问题。
|
||||
|
||||
* **学习目标**:
|
||||
1. 理解浮点数二分与整数二分的本质区别。
|
||||
2. 掌握基于**精度控制**的循环条件和边界更新方式。
|
||||
3. 学会使用“枚举+二分”的分治策略解决复杂的多根求解问题。
|
||||
4. 能够独立编写代码求解一元三次方程的根。
|
||||
|
||||
#### 2. 核心思想
|
||||
|
||||
* **思路铺垫:手动计算 √2**
|
||||
回到 √2 的问题。我们不求精确命中,只求去逼近它。
|
||||
|
||||
1. **确定范围**:我们知道 1²=1 (太小了),2²=4 (太大了)。所以,√2 一定在 `[1.0, 2.0]` 这个区间里。
|
||||
2. **第一次猜测**:取区间中点 `1.5`。计算 `1.5² = 2.25`。嗯,还是太大了。说明 √2 肯定比 `1.5` 小。于是,答案范围被我们缩小到了 `[1.0, 1.5]`。
|
||||
3. **第二次猜测**:取新区间的中点 `1.25`。计算 `1.25² = 1.5625`。这次又太小了。说明 √2 比 `1.25` 大。答案范围进一步缩小到 `[1.25, 1.5]`。
|
||||
4. **继续...**:我们不断重复“**取中点 -> 判断 -> 缩小范围**”这个过程,区间的长度会以指数级速度减小,`left` 和 `right` 将会无限逼近那个真正的 √2。
|
||||
|
||||
* **浮点数二分与整数二分的三个核心区别**
|
||||
|
||||
1. **目标不同**
|
||||
* **整数二分**:像是在一排有编号的格子里找东西,目标是**精确到**某个格子。
|
||||
* **浮点数二分**:像是在一条连续的线段上找一个点,由于浮点数无限可分,我们几乎不可能精确命中。我们的目标是**无限逼近**,直到我们的左右边界把这个点“夹”得足够紧,紧到我们无法分辨它们的区别为止。
|
||||
|
||||
2. **循环条件不同:`while (r - l > eps)` vs. `while (l <= r)`**
|
||||
* 在整数世界,`l` 和 `r` 最终会“撞上”或“越过”对方,循环自然停止。
|
||||
* 在浮点数世界,`l` 和 `r` 可能永远不会完全相等。因此,我们引入一个极小值 `eps` (Epsilon,精度),比如 `1e-8`。当区间的长度 `r - l` 已经比这个 `eps` 还要小的时候,我们就认为 `l` 和 `r` 已经“贴”在一起了,找到了答案。
|
||||
|
||||
3. **边界更新不同:`l = mid` vs. `l = mid + 1`**
|
||||
* 整数二分中,如果 `mid` 不是答案,那下一个可能的答案至少是 `mid + 1`,我们可以放心地跳一格。
|
||||
* 浮点数二分中,如果 `mid` 不是答案,真正的答案可能就藏在 `mid` 和 `mid + 0.000...01` 之间。我们绝不能跳过任何一点点空间!因此,新的边界必须是 `mid` 本身,即 `l = mid` 或 `r = mid`。
|
||||
|
||||
* **浮点数二分模板**
|
||||
|
||||
```cpp
|
||||
double l, r; // 答案所在的初始范围
|
||||
const double eps = 1e-8; // 精度,题目要求k位小数,eps可设为1e-(k+2)
|
||||
|
||||
while (r - l > eps) { // 当区间长度还不够小时,继续二分
|
||||
double mid = l + (r - l) / 2; // 取中点
|
||||
if (check(mid)) { // check函数判断mid是否满足某种偏大的条件
|
||||
r = mid; // 答案在左半边,或者mid就是答案
|
||||
} else {
|
||||
l = mid; // 答案在右半边,或者mid就是答案
|
||||
}
|
||||
}
|
||||
// 循环结束时,l和r已经非常接近,输出任意一个均可
|
||||
cout << l << endl;
|
||||
```
|
||||
|
||||
#### 3. 实例
|
||||
|
||||
* **具体案例:求解方程 `ax³+bx²+cx+d=0`**
|
||||
(B0193 / 书本例13-5)
|
||||
**问题分析**:
|
||||
这是一个一元三次方程。直接求解非常复杂。但题目给了我们几个关键的信息:
|
||||
|
||||
1. **根的范围已知**:`[-100, 100]`。这直接给了我们一个初始的搜索大区间。
|
||||
2. **根的间距 ≥ 1**:它意味着,如果我们把 `[-100, 100]` 这条长长的数轴,切成 200 个长度为 1 的小区间 `[-100, -99]`, `[-99, -98]`, ..., `[99, 100]`,那么**每一个小区间里,最多只可能有一个根**。
|
||||
3. **零点存在性定理**:`f(x₁)*f(x₂) < 0` 意味着 `f(x₁)` 和 `f(x₂)` 的函数值一个为正、一个为负。由于函数曲线是连续的,它必然会在这两者之间**穿过x轴**一次,也就是说,` (x₁, x₂) `之间必有一个根。
|
||||
|
||||
* **算法策略:分治法 (枚举粗筛 + 二分精搜)**
|
||||
我们不能直接在 `[-100, 100]` 上二分,因为有三个根,天知道二分会逼近哪一个。正确的做法是:
|
||||
|
||||
**第一步:枚举粗筛**
|
||||
我们用一个 `for` 循环,以 1 为步长,从 -100 扫描到 99。在每一步,我们检查 `[i, i+1]` 这个小区间。
|
||||
|
||||
* 如果 `f(i)` 和 `f(i+1)` 的符号不同 (即 `f(i) * f(i+1) < 0`),我们就锁定了一个藏有根的区间!
|
||||
|
||||
**第二步:二分精搜**
|
||||
一旦我们找到了一个区间,比如 `[i, i+1]`,我们就知道这里面有且仅有一个根。现在问题变得简单了:在一个小区间内找一个唯一的根。我们立刻在这个小区间上套用浮点数二分模板,把这个根揪出来。
|
||||
|
||||
* **流程示例**
|
||||
|
||||
1. **粗筛**:`for` 循环检查 `[-3,-2]`, `[-2,-1]`, `[-1,0]`...
|
||||
2. 发现 `f(-2)` 是正的,`f(-1)` 是负的。`f(-2)*f(-1) < 0`!锁定区间 `[-2, -1]`。
|
||||
3. **精搜**:在 `[-2, -1]` 内启动浮点二分,最终逼近根 `r1`。
|
||||
4. `for` 循环继续,又发现 `f(0)*f(1) < 0`,锁定 `[0, 1]`,二分出 `r2`。
|
||||
5. `for` 循环继续,发现 `f(2)*f(3) < 0`,锁定 `[2, 3]`,二分出 `r3`。
|
||||
|
||||
#### 4. 代码实现
|
||||
|
||||
* **完整代码**
|
||||
```cpp
|
||||
#include <bits/stdc++.h>
|
||||
using namespace std;
|
||||
|
||||
// 全局变量存储方程系数
|
||||
double a, b, c, d;
|
||||
|
||||
// 定义方程函数 f(x)
|
||||
double f(double x) {
|
||||
return a * x * x * x + b * x * x + c * x + d;
|
||||
}
|
||||
|
||||
int main() {
|
||||
cin >> a >> b >> c >> d;
|
||||
|
||||
// 设置输出格式为保留两位小数
|
||||
cout << fixed << setprecision(2);
|
||||
|
||||
// 第一步:枚举粗筛
|
||||
for (int i = -100; i < 100; ++i) {
|
||||
double x1 = i;
|
||||
double x2 = i + 1;
|
||||
double y1 = f(x1);
|
||||
double y2 = f(x2);
|
||||
|
||||
if (y1 == 0) {
|
||||
// 如果左端点恰好是根,直接输出
|
||||
cout << x1 << " ";
|
||||
}
|
||||
else if (y1 * y2 < 0) {
|
||||
// 如果区间内有根,启动第二步:二分精搜
|
||||
double l = x1, r = x2;
|
||||
const double eps = 1e-4; // 题目要求2位小数,1e-4的精度足够
|
||||
|
||||
while (r - l > eps) {
|
||||
double mid = l + (r - l) / 2;
|
||||
// 判断根在左半边还是右半边
|
||||
// 如果 f(mid) 和 f(l) 同号,说明根在[mid, r]
|
||||
// 否则根在 [l, mid]
|
||||
if (f(mid) * f(l) > 0) {
|
||||
l = mid;
|
||||
} else {
|
||||
r = mid;
|
||||
}
|
||||
}
|
||||
cout << l << " ";
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
* **代码解读**:
|
||||
|
||||
* `for (int i = -100; i < 100; ++i)`: 实现了从 -100 到 99 的步长为 1 的扫描。
|
||||
* `if (f(mid) * f(l) > 0)`: 这是判断根在哪一半区间的核心。如果 `f(mid)` 和 `f(l)` 同号(乘积为正),说明 `[l, mid]` 这个区间两端点函数值同号,根不在这里,所以根在另一半 `[mid, r]`,因此更新 `l = mid`。反之 `r = mid`。
|
||||
|
||||
#### 5. 验证与深化
|
||||
|
||||
* **常见问题**
|
||||
1. **为什么 `eps` 要设为 `1e-(k+2)`?**
|
||||
这是一种经验法则。二分法在最后几次迭代时,精度提升会变慢。为了确保第 `k` 位小数的正确性,我们需要计算到比它更精确几位的程度,再进行四舍五入。多算两位通常是比较保险的选择。
|
||||
2. **二分判断条件 `if (f(mid) * f(l) > 0)` 可以换成 `if (f(mid) * f(r) > 0)` 吗?**
|
||||
可以,但逻辑要相应改变。如果 `f(mid)` 和 `f(r)` 同号,说明根在 `[l, mid]`,应更新 `r = mid`。只要保证逻辑自洽即可。
|
||||
3. **如果题目没有“根的间距>=1”这个条件怎么办?**
|
||||
问题会变得非常复杂。一个单位区间内可能有多个根,`f(i)*f(i+1) < 0` 这个条件就可能失效(比如有两个根,穿过去又穿回来,导致两端点同号)。这时需要借助微积分中的导数来判断函数的单调区间,在每个单调区间内再进行二分。
|
||||
|
||||
#### 6. 总结
|
||||
|
||||
* **要点回顾**:
|
||||
1. **浮点数二分**的核心是**逼近**,而非查找。
|
||||
2. **循环条件**由精度 `eps` 控制:`while (r - l > eps)`。
|
||||
3. **边界更新**不加减1:`l = mid` 或 `r = mid`。
|
||||
4. 面对复杂问题,**分治思想**是关键。通过**枚举粗筛**将大问题分解为多个简单的、适合二分的小问题。
|
||||
* **思考**:
|
||||
我们今天所有 `check` 的依据都是函数值的正负号。二分思想的应用远不止于此。任何一个参数,只要它能导致结果呈现出某种“单调性”(越大越如何,或越小越如何),我们都可以对这个参数进行二分,在整数和浮点数里是完全通用的。
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,558 @@
|
||||
### **C++算法讲义:搜索算法 - 深度优先搜索 (DFS) 与回溯**
|
||||
|
||||
#### **1. 引言**
|
||||
|
||||
* **问题引入:四阶数独(OJ:B0201)**
|
||||
数独是一种经典的逻辑游戏。我们这里要解决的是一个简化版的 4x4 数独。
|
||||
**要求:** 给定一个完全空白的 4×4 网格,要求填写数字 1 到 4,使得以下条件同时满足:
|
||||
|
||||
1. 每一行必须包含 1, 2, 3, 4 各一次(无重复)。
|
||||
2. 每一列必须包含 1, 2, 3, 4 各一次(无重复)。
|
||||
3. 每个 2×2 子区域(共 4 个)必须包含 1, 2, 3, 4 各一次(无重复)。
|
||||
(子区域划分:将 4×4 网格均分为左上、右上、左下、右下四个 2×2 的小方块)
|
||||
**任务:** 求所有可能的合法填写方法的总数。
|
||||
|
||||
* **价值阐述**
|
||||
暴力枚举的策略,会将所有可能的情况都枚举一遍。但其效率如同愚公移山,无法应付数据范围稍大的情形。
|
||||
搜索算法(包括 DFS 和 BFS)是在暴力枚举的基础上,引入了更聪明的策略,从起点开始,逐渐扩大寻找范围,直到找到需要的答案为止。严格来说,搜索算法也算是一种暴力枚举策略,但是它的**算法特性决定了效率比直接的枚举所有答案要高**,因为它可以通过**剪枝**跳过一些无效状态,降低问题规模。
|
||||
|
||||
DFS 及其背后的**回溯思想**,是解决很多复杂问题(如全排列、组合、路径寻找、NP 问题)的基石。掌握后我们可以:
|
||||
* **应对复杂问题**:解决那些需要穷举所有可能性,但又可以通过“提前发现错误”来优化的问题。
|
||||
* **理解递归深层逻辑**:深入理解函数调用栈在算法中的关键作用。
|
||||
* **培养抽象思维**:将现实问题抽象为搜索路径,并设计剪枝策略。
|
||||
|
||||
---
|
||||
|
||||
#### **2. 核心思想 **
|
||||
|
||||
##### **2.1 从暴力到智能:数独问题的思考**
|
||||
|
||||
我们以 4x4 数独为例,看看为什么我们需要 DFS。
|
||||
|
||||
* **解法一:纯暴力枚举**
|
||||
* **思路**:4×4 共 16 个格子。第 1 个格子试 1、2、3、4;第 2 个格子试 1、2、3、4……直到第 16 个格子。全填满之后,再去检查行、列、小方块有没有重复。
|
||||
* **计算代价**:每个格子 4 种选择,16 个格子总共是 $4^{16}$ 种情况。$4^{16}$ 大约等于 42 亿!计算机为了算这一个迷你数独,要足足判断 42 亿次。这种方式非常低效,不可行。
|
||||
|
||||
* **解法二:按行生成全排列**
|
||||
* **思路**:既然每一行都必须是由 1、2、3、4 组成,且不能重复,那我们干嘛要让第一行出现 `1, 1, 1, 1` 这种明显违规的排列呢?我们可以把“按格子填”改成“按行填”。1 到 4 的全排列(比如 1234, 1243, 1324...)一共有 $4! = 24$ 种。既然有 4 行,我们只需要写 4 层 for 循环,去组合这 4 行的排列。
|
||||
* **计算代价**:总情况数降到了 $24 \times 24 \times 24 \times 24 = 331,776$ 种。33 万次计算,计算机大概零点几秒就能跑完。
|
||||
|
||||
**这种解法完美了吗?**
|
||||
如果我们把问题放大到 9 阶数独,每行是 9 的全排列(36 万种),9 行组合起来就是 $362,880^9$,依然是个天文数字!解法 2 依然存在巨大的资源浪费。
|
||||
|
||||
**解法二的愚蠢之处**
|
||||
模拟解法二的运行:假设程序正在运行解法二,此时:
|
||||
* 第 1 行填了:`[1, 2, 3, 4]`
|
||||
* 第 2 行准备填:`[1, 3, 4, 2]`
|
||||
看一眼这两行,发现了什么问题?**第一列出现了两个 1,已经违规了!**
|
||||
但是,解法 2 并不会发现违规了,它依然会傻乎乎地去把第 3 行的 24 种可能、第 4 行的 24 种可能全部生成出来,拼成完整的 4x4 矩阵,然后才去检查。仅仅因为前两行这个明显的错误,程序在后面做了 $24 \times 24 = 576$ 次毫无意义的拼凑和检查。
|
||||
|
||||
**思考:聪明人会怎么做?**
|
||||
一旦发现某一步已经错了,立刻停手,绝不继续往下走,赶紧换一种填法!
|
||||
而在算法中,这种**提前发现错误并中断后续操作的思路,就叫做剪枝。我们现在有了要**随时检查,及时止损**的策略。但问题来了:`for` 循环非常死板,写了 16 层就是 16 层,很难做到进退自如。我们需要一种更灵活的框架。
|
||||
|
||||
##### **2.2 深度优先搜索 (DFS) 的核心逻辑**
|
||||
|
||||
DFS 的核心思想就是:**走一步看一步,不合法就回头,换条路再走。**
|
||||
|
||||
**递归枚举(DFS)的逻辑思想:**
|
||||
我们不用循环,而是写一个递归函数 `void dfs(当前填第几个空)`。流程可以想象成**走迷宫**:
|
||||
1. **走一步看一步**:站在当前需要填写的空格,从所有可能的数字(1~4)中逐个尝试。
|
||||
2. **立刻检查**:看看当前的行、列、小方块里有没有刚刚填写的数字。
|
||||
3. **合法就深入(深度优先)**:如果没有冲突,说明这个填的数字暂时没问题!我把它填进去(保存现场),然后进入下一个空格 `dfs(下一个空格)`。
|
||||
4. **死胡同就回头(回溯)**:假如我在某一个空格试了 1、2、3、4 发现全都冲突了,怎么办?这说明我在前面几步走错路了!此时,我不能强行往下走,而是要退回到上一个空格,把之前填的数字擦掉(恢复现场),换一个数字重新试。
|
||||
|
||||
通过这种**“填数字 $\rightarrow$ 检查 $\rightarrow$ 不行就擦掉重填”**的递归机制,我们就避开了所有的无效状态。这种一般化的枚举框架,就是深度优先搜索与回溯法。
|
||||
|
||||
##### **2.3 递归与回溯的底层机制:系统栈**
|
||||
|
||||
既然逻辑已经理顺了,现在就把这些想法变成一行行能跑的代码。
|
||||
首先要解决一个最基本的问题:为什么非得用函数,而且是自己调自己的递归函数?
|
||||
|
||||
**知识回顾:栈不仅仅是一个数据结构。它其实是一种暂时挂起、等待时机的逻辑模型。它让我们在面对复杂、嵌套、或者需要回头看的任务时,能够不慌不忙,一次只专注处理眼前最紧急的那件事。**
|
||||
|
||||
* **系统栈**:当函数调用另一个函数时,计算机会把当前的进度和相关信息**压栈 (push)**,等结束了再**弹栈 (pop)** 回来继续干活。在普通的 `for` 循环里,代码是一口气执行到底的,没有停留。
|
||||
但在递归中,当 `dfs(k)` 执行到 `dfs(k + 1)` 这一行时,`dfs(k)` 会暂时停工。
|
||||
|
||||
* **挂起**:假设你现在是 `dfs(1)`,你刚刚把数字 1 填入第一个格子。接下来你调用了 `dfs(2)`。此时,`dfs(1)` 的这个函数并没有结束,它的 `for` 循环停在了 `num = 1` 这一步,不再往下走了。
|
||||
**本质**:计算机把 `dfs(1)` 当前所有的数据(当前在第几格、填了什么数字、接下来还要试哪些数字)打包放进了**系统栈**里。任务干了一半,要准备填第二个格子了,那我就先搁置在这里等着你的结果。
|
||||
|
||||
* **等待时机**:被挂起的 `dfs(1)` 待在栈里干什么呢?它在等待。
|
||||
它在等什么?它在等 `dfs(2)` 给它一个明确的结局。
|
||||
因为 `dfs(1)` 填入数字 1 这个动作,到底是通向胜利的大道,还是通向死胡同?`dfs(1)` 自己是不知道的。它必须等待后续的格子尝试后返回到自己这里,才能得到答案。
|
||||
这种**“我先做一个初步决定,然后静静等待后续发展”**的模式,就是等待时机。
|
||||
|
||||
* **流程模拟**:
|
||||
* `dfs(k)` 调用 `dfs(k+1)`:代表基于当前成功的决策,向问题的下一个阶段前进。`dfs(k)` 的状态被压栈。
|
||||
* `dfs(k+1)` 返回 `return`:
|
||||
* 如果 `dfs(k+1)` 找到了解:`return` 后,`dfs(k)` 所在的那条路径可能是一条完整解的一部分。
|
||||
* 如果 `dfs(k+1)` 发现死胡同:`dfs(k+1)` 尝试完所有可能性后,没有为第 `k+1` 格找到任何一个合法的数字,它会执行 `return`,并将执行控制权交还给它的调用者——`dfs(k)`。
|
||||
此时,`dfs(k)` 从暂停处恢复执行。它收到 `dfs(k+1)` 的返回,意识到当初为 `k` 格填入的数字,导致了后续格子无解。它必须清除自己刚才做的决策,即把第 `k` 格的数字擦掉(**恢复现场**),以便 `for` 循环尝试下一个数字。
|
||||
然后,`dfs(k)` 的 `for` 循环继续,尝试为第 `k` 格填入下一个数字。
|
||||
|
||||
**总结**:
|
||||
递归将 **前进探索** 和 **失败回退** 这两个核心操作,映射到了 **函数调用** 和 **函数返回** 这两个编程构造上,使得复杂的搜索与回溯逻辑变得结构清晰且易于实现。递归函数调用栈是这个机制的底层实现。它自动保存了每个暂停的函数的状态,确保函数可以从正确的位置继续执行。
|
||||
|
||||
---
|
||||
|
||||
#### **3. 实例**
|
||||
|
||||
##### **3.1 数独问题:完整框架搭建 (例15-3)**
|
||||
|
||||
**准备工作:定义棋盘和递归函数框架**
|
||||
首先,我们需要一个 4x4 的棋盘、辅助变量和一个 `dfs` 函数的基本框架。
|
||||
|
||||
```cpp
|
||||
vector<vector<int>> grid(4, vector<int>(4, 0)); // 4x4棋盘,0代表空格
|
||||
int count = 0; // 用来统计解的总数
|
||||
|
||||
// 检查函数,判断在(x, y)位置填num是否合法(下面会详细实现)
|
||||
bool check(int x, int y, int num) {
|
||||
// 检查行
|
||||
for (int j = 0; j < 4; ++j) {
|
||||
if (grid[x][j] == num) return false;
|
||||
}
|
||||
// 检查列
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
if (grid[i][y] == num) return false;
|
||||
}
|
||||
// 检查2x2子区域
|
||||
int startX = (x / 2) * 2; // 计算2x2子区域的左上角行坐标
|
||||
int startY = (y / 2) * 2; // 计算2x2子区域的左上角列坐标
|
||||
for (int i = startX; i < startX + 2; ++i) {
|
||||
for (int j = startY; j < startY + 2; ++j) {
|
||||
if (grid[i][j] == num) return false;
|
||||
}
|
||||
}
|
||||
return true; // 所有检查都通过,合法
|
||||
}
|
||||
|
||||
// dfs函数,参数k代表当前要填的是第k个空格(从0到15)
|
||||
void dfs(int k) {
|
||||
// 递归的出口:如果k等于16,说明所有格子都填完了
|
||||
if (k == 16) {
|
||||
count++; // 找到了一个完整的合法解
|
||||
return; // 返回,结束这条探索路径
|
||||
}
|
||||
|
||||
// 将第k个空格(0-15)转换为二维的(x, y)坐标
|
||||
int x = k / 4; // 行号
|
||||
int y = k % 4; // 列号
|
||||
|
||||
// 步骤 1: 走一步看一步 —— 遍历所有可能的数字
|
||||
for (int num = 1; num <= 4; ++num) {
|
||||
// 步骤 2: 立刻检查 —— 判断填入num是否合法
|
||||
if (check(x, y, num)) {
|
||||
// 步骤 3: 合法就深入 —— 保存现场,递归调用
|
||||
grid[x][y] = num; // 把数字实际填入棋盘
|
||||
dfs(k + 1); // 深入,去处理下一个空格 (k+1)
|
||||
|
||||
// 步骤 4: 死胡同就回头(回溯)—— 恢复现场
|
||||
// 当dfs(k+1)返回,意味着从那一步开始的所有路径都探索完了。
|
||||
// 我们必须恢复现场,把格子变回空格,以便for循环下一次尝试新的num。
|
||||
grid[x][y] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
dfs(0); // 游戏开始:从第0个格子开始填写
|
||||
cout << count << endl;
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
**`check` 函数的实现细节:**
|
||||
`bool check(int x, int y, int num)`
|
||||
* **检查行**:`num` 不能与 `(x, y)` 所在行的任何其他数字相同。
|
||||
* 行号 `x` 是固定的。我们需要循环改变的是列号 `j`。
|
||||
* `for (int j = 0; j < 4; ++j) { if (grid[x][j] == num) return false; }`
|
||||
* **检查列**:`num` 不能与 `(x, y)` 所在列的任何其他数字相同。
|
||||
* 列号 `y` 是固定的。我们需要循环改变的是行号 `i`。
|
||||
* `for (int i = 0; i < 4; ++i) { if (grid[i][y] == num) return false; }`
|
||||
* **检查 2x2 子区域**:不能与当前坐标所在的 2x2 小方块内的任何其他数字相同。
|
||||
* **如何确定子区域的左上角 `(startX, startY)`?**
|
||||
我们观察到:
|
||||
* 对于第 0、1 行,左上角的行号总是 0。对于第 2、3 行,左上角的行号总是 2。
|
||||
* 这个规律可以用一个整数除法公式来表示:`int startX = (x / 2) * 2;`
|
||||
* 同理,`int startY = (y / 2) * 2;`
|
||||
* 然后遍历 `(startX, startY)` 到 `(startX+1, startY+1)` 这四个格子。
|
||||
|
||||
**优化 `check` 函数:使用桶数组**
|
||||
为了提高 `check` 函数的效率,可以引入三个**桶数组**(布尔数组)来实时记录每个区域的数字占用情况。这样,`check` 函数就不需要遍历,而是 O(1) 地查询。
|
||||
|
||||
```cpp
|
||||
vector<vector<int>> grid(4, vector<int>(4, 0)); // 4x4棋盘
|
||||
int count = 0;
|
||||
|
||||
// 三个桶数组,用来快速检查冲突
|
||||
bool row_used[4][5]; // row_used[i][num] 表示第i行num是否已被使用 (数字1-4)
|
||||
bool col_used[4][5]; // col_used[j][num] 表示第j列num是否已被使用
|
||||
bool block_used[4][5]; // block_used[k][num] 表示第k个2x2子区域num是否已被使用
|
||||
|
||||
// check函数变得非常简单,只是查询桶数组
|
||||
bool check(int x, int y, int num) {
|
||||
int block_idx = (x / 2) * 2 + (y / 2); // 计算当前格子所在的2x2子区域索引 (0-3)
|
||||
return !row_used[x][num] && !col_used[y][num] && !block_used[block_idx][num];
|
||||
}
|
||||
|
||||
void dfs(int k) {
|
||||
if (k == 16) {
|
||||
count++;
|
||||
return;
|
||||
}
|
||||
|
||||
int x = k / 4;
|
||||
int y = k % 4;
|
||||
int block_idx = (x / 2) * 2 + (y / 2);
|
||||
|
||||
for (int num = 1; num <= 4; ++num) {
|
||||
if (check(x, y, num)) {
|
||||
// 保存现场:更新棋盘和桶数组
|
||||
grid[x][y] = num;
|
||||
row_used[x][num] = true;
|
||||
col_used[y][num] = true;
|
||||
block_used[block_idx][num] = true;
|
||||
|
||||
dfs(k + 1); // 深入
|
||||
|
||||
// 恢复现场:撤销棋盘和桶数组的更改
|
||||
grid[x][y] = 0;
|
||||
row_used[x][num] = false;
|
||||
col_used[y][num] = false;
|
||||
block_used[block_idx][num] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
// 初始化桶数组
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
for (int j = 0; j < 5; ++j) { // 数字1-4,所以大小为5
|
||||
row_used[i][j] = false;
|
||||
col_used[i][j] = false;
|
||||
block_used[i][j] = false;
|
||||
}
|
||||
}
|
||||
|
||||
dfs(0);
|
||||
cout << count << endl;
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
至此,我们已经将所有逻辑思想转换为了代码,它们组合在一起,就构成了一个完整的、带有回溯功能的深度优先搜索。我们可以把这道题目总结为一套框架。
|
||||
|
||||
##### **3.2 DFS 回溯框架总结**
|
||||
|
||||
从暴力枚举到启发式剪枝:
|
||||
* **问题抽象**:在 N×N 矩阵中填充数字/放置棋子,寻找满足行、列、块约束的所有合法解。本质是在有限的状态空间中寻找目标状态。
|
||||
* **算法复杂度演变**:
|
||||
* 盲目搜索:每个格子独立尝试,时间复杂度高达 O($4^{16}$)。
|
||||
* 排列组合搜索:按行生成全排列,时间复杂度降至 O($24^4$),但存在大量无意义的后续匹配。
|
||||
* **深度优先搜索配合剪枝**:引入“及时止损”策略,一旦当前前缀状态不合法,立即截断后续搜索分支,极大收敛了搜索空间。
|
||||
|
||||
**深度优先搜索(DFS)流程可概括为四个步骤:**
|
||||
1. **状态扩展**:确定当前决策节点,枚举所有可能的候选动作(如尝试数字 1-4)。
|
||||
2. **约束满足性检查**:在执行动作前,验证动作的合法性(剪枝)。
|
||||
3. **状态转移与深入**:
|
||||
* **保存现场**:将当前合法的动作更新至全局状态(修改棋盘、更新标记)。
|
||||
* **递归调用**:将问题规模缩小,调用进入下一层计算。
|
||||
4. **状态回退**:
|
||||
* **恢复现场**:在子问题计算结束后,必须撤销当前动作对全局状态的修改 (如决策填写了 3,而决策为 3 的子问题计算结束了,那就需要将其撤销),以保证下一次同级枚举的初始状态纯净。
|
||||
|
||||
**DFS 回溯代码模板在实现上,会呈现其严格的对称性:**
|
||||
递归调用的上方是对状态的修改,下方必定是对状态的逆向撤销。以下是给出**==DFS的模板代码==**:
|
||||
|
||||
```cpp
|
||||
void dfs(int step) {
|
||||
// 递归出口:如果到达目标状态/所有决策都已完成
|
||||
if (到达目标状态) {
|
||||
记录解;
|
||||
return;
|
||||
}
|
||||
|
||||
// 状态扩展:遍历所有候选选项
|
||||
for (遍历所有候选选项) {
|
||||
// 约束满足性检查:剪枝
|
||||
if (当前选项满足约束条件) {
|
||||
// 1. 保存现场(对全局状态进行修改)
|
||||
update_state(选项);
|
||||
|
||||
// 2. 状态转移:进入子问题,递归调用
|
||||
dfs(step + 1);
|
||||
|
||||
// 3. 恢复现场(撤销对全局状态的修改)
|
||||
undo_state(选项);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
##### **3.3 经典案例:八皇后问题 (P190页 例14-2, OJ:B0202)**
|
||||
|
||||
**题目描述**:在一个 $N \times N$ 的国际象棋棋盘上,我们要放置 $N$ 个皇后。皇后的攻击范围呈米字型:同一行、同一列、以及两条斜线(左上到右下、右上到左下)上的所有格子。
|
||||
**任务要求**:放置 $N$ 个皇后,使得她们互不攻击。给定棋盘大小 $N$($N \le 13$),求出一共有多少种合法的放置方案,并按字典序输出前 3 种具体的放置方法。
|
||||
|
||||
**核心转化**:
|
||||
很多人一看到 $N \times N$ 的棋盘,第一反应是:建一个二维数组 `board[13][13]`,然后写两层 `for` 循环,每个格子去试放不放皇后。
|
||||
但是规则里有**同一行只能放一个皇后**。既然如此,我们根本不需要去遍历棋盘上的每一个格子!我们只需要给每一行分配一个皇后就行了。于是我们的任务从在棋盘上找空位放置皇后,变成了决定第 1 行的皇后坐在第几列、第 2 行的皇后坐在第几列……
|
||||
我们只需要一个一维桶数组 `a[row] = col`,它代表着 `row` 行的皇后坐在 `col` 列,就足以记录整个棋盘的状态!比如 `a[1]=3`,就代表第 1 行的皇后坐在了第 3 列。
|
||||
这就把一个复杂的二维平面搜索,变成了在一维上逐行推进搜索。
|
||||
我们的递归函数 `dfs(row)` 的含义也呼之欲出:当前正在为第 `row` 行的皇后挑选列号。
|
||||
|
||||
**如何 `check` 冲突?**
|
||||
在确定了逐行推进(`dfs(row)`)后,我们准备在第 `col` 列放下皇后。放下之前,必须立刻检查(回溯四步法之二)。
|
||||
|
||||
1. **检查同列冲突**:简单!搞一个布尔数组 `b[col]`,它的含义是如果 `b[col] = true`,那么代表着 `col` 这一列已经有皇后了,反之则没有皇后。
|
||||
2. **检查斜线冲突**:难道要写 `for` 循环,左上右下地去扫描棋盘吗?太慢了!
|
||||
* **右上 $\rightarrow$ 左下的斜线 (副对角线)**:你观察一下这条线上的坐标:
|
||||
`(0,0), (1,1), (2,2)`
|
||||
`(0,1), (1,2), (2,3)`
|
||||
`(1,0), (2,1), (3,2)`
|
||||
发现了什么?它们的 **行号 + 列号** 永远等于一个常数!
|
||||
解决方案:开一个数组 `c[row + col]`。只要在这个斜线上放了皇后,就把 `c[sum] = true`。下次谁想放在坐标和为 `sum` 的地方,就先查看一下 `c[sum]`,发现结果等于 `true`,也就是已经被占用了!
|
||||
* **左上 $\rightarrow$ 右下的斜线 (主对角线)**:你再观察这条线上的坐标:
|
||||
`(0,2), (1,1), (2,0)`
|
||||
`(0,0)`
|
||||
`(1,2), (2,3)`
|
||||
规律是:它们的 **行号 - 列号** 永远等于一个常数!
|
||||
但是,行号减列号可能是负数(比如 `1 - 2 = -1`),而数组下标不能是负数。怎么办?
|
||||
解决方案:那我们统一加上一个常数 `N`!不让他变为负数不就可以了,让下标变成 `row - col + N`。我们就开一个数组 `d[row - col + N]`。只要这条线上有皇后,对应的 `d[offset]` 就是 `true`。
|
||||
|
||||
```cpp
|
||||
int N; // 棋盘大小, 最多为 13 x 13
|
||||
int ans_count = 0; // 记录总方案数
|
||||
int a[20]; // 记录答案:a[行号] = 列号 (a[1]表示第一行皇后的列号)
|
||||
|
||||
// 三个桶数组,分别是列,副对角线,主对角线
|
||||
bool col_used[20]; // 监控列:col_used[列号]
|
||||
bool diag_sum_used[40]; // 监控副对角线:diag_sum_used[行 + 列] (取值最大为 12+12=24)
|
||||
bool diag_diff_used[40]; // 监控主对角线:diag_diff_used[行 - 列 + N] (取值最大为 12 - 1 + 12 = 23)
|
||||
|
||||
// 递归函数:为第 row 行的皇后选列的座位
|
||||
void dfs(int row) {
|
||||
// 递归出口:如果 1 到 N 行都安排满了,说明棋盘已经放完所有的皇后了!
|
||||
if (row > N) {
|
||||
ans_count++; // 找到一种合法方案,总数 +1
|
||||
if (ans_count <= 3) { // 题目要求:打印前 3 种方案
|
||||
for (int i = 1; i <= N; i++)
|
||||
cout << a[i] << " "; // a[i] 代表着 i 行的皇后在a[i]列
|
||||
cout << endl;
|
||||
}
|
||||
return; // 功成身退,弹出回溯,尝试其他可能
|
||||
}
|
||||
|
||||
// 从第一列开始,面对 1 到 N 列,挨个尝试放置皇后
|
||||
for (int col = 1; col <= N; col++) {
|
||||
// 检查:看三个标记数组是不是全是 false (即当前位置是否合法)
|
||||
// (row + col) 作为副对角线索引
|
||||
// (row - col + N) 作为主对角线索引 (加上N保证非负)
|
||||
if (!col_used[col] && !diag_sum_used[row + col] && !diag_diff_used[row - col + N]) {
|
||||
// 位置合法,那就深入
|
||||
a[row] = col; // 记录当前行的决定:第row行的皇后放在col列
|
||||
|
||||
// 保存现场:放了皇后,对应的标记数组标记为 true
|
||||
col_used[col] = true;
|
||||
diag_sum_used[row + col] = true;
|
||||
diag_diff_used[row - col + N] = true;
|
||||
|
||||
// 挂起当前状态,带着现有的布局,给下一行安排皇后
|
||||
dfs(row + 1);
|
||||
|
||||
// 恢复现场:当 dfs(row + 1) 返回时,说明后面的路走完了
|
||||
// 我们必须撤销刚才的决定,准备 for 循环尝试在换个位置在下一列放皇后
|
||||
col_used[col] = false;
|
||||
diag_sum_used[row + col] = false;
|
||||
diag_diff_used[row - col + N] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
cin >> N;
|
||||
dfs(1); // 从第 1 行的皇后开始安排
|
||||
cout << ans_count << endl; // 最后输出总方案数
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
**注意**:如果不写 `b[col] = c[row + col] = d[row - col + N] = false;` 这句会怎样?
|
||||
哪怕你已经退回到了上一行准备换个策略,标记数组依然以为那个位置有皇后。这就好比你搬出了酒店,却没有退房,后面的人永远住不进来。**恢复现场**是回溯算法一定要做的事情!
|
||||
|
||||
八皇后问题和四阶数独,本质上是在用同一个思想。数独是在空格子里填数字,八皇后是在行里填列号。它们的骨架全都是**试探 $\rightarrow$ 占座 $\rightarrow$ 递归 $\rightarrow$ 退座**。这告诉我们:只要能找出问题中需要尝试填写什么,并设计出检查函数(怎么判断冲突),然后在当前决策下,尝试递归去搜索解,无论是什么样的复杂的迷宫,DFS 都能用同一套逻辑帮你解决!
|
||||
|
||||
##### **3.4 剪枝优化:考前临时抱佛脚 (P191页 例14-3, OJ:B0203)**
|
||||
|
||||
**题目描述**:kkksc03 需要考 4 科。每科都有一个习题集,分别有 $s_1, s_2, s_3, s_4$ 道题目,完成每道题目需要一些时间。kkksc03 的左右两个大脑可以**同时计算 2 道不同的题目,但是仅限于同一科**。因此,kkksc03 必须**一科一科地复习**。他希望尽快把事情做完,所以他希望知道能够完成复习的**最短时间**。
|
||||
|
||||
**题意总结**:给定 4 个独立的数字集合(每科的题目耗时)。对于每个集合,要求将其分为两个子集(左右脑),使得这两个子集的“元素之和的最大值”尽可能小。求这 4 个集合的该最小值之和。
|
||||
|
||||
**算法拆解**:
|
||||
1. **每科独立计算**:
|
||||
* “必须一科一科复习”意味着 4 个科目是完全独立的。
|
||||
* 总最短时间 = 科目1最短时间 + 科目2最短时间 + 科目3最短时间 + 科目4最短时间。
|
||||
* 我们只需要专心写好“单科的最优解”函数,调用 4 次即可。
|
||||
2. **单科耗时取最大值**:
|
||||
* “左右脑同时计算同一科的不同题目”意味着面对单科的一堆题目,我们要把它们装进“左脑”和“右脑”两个桶里。
|
||||
* 单科耗时 = `max(左脑做题总耗时, 右脑做题总耗时)`。
|
||||
3. **均分逼近目标**:
|
||||
* 为了让 `max(左脑, 右脑)` 尽可能小,最好的策略就是让左右脑分配到的任务重量**尽可能接近**(即贴近该科总耗时的一半),谁也不能闲着。
|
||||
|
||||
**DFS 框架应用**:
|
||||
我们怎么把题目分给左右脑呢?这不就是我们在八皇后和数独里的“走一步看一步”吗!
|
||||
对于每一道题,我们只有 2 种选择:
|
||||
* **选择 A**:交给左脑。
|
||||
* **选择 B**:交给右脑。
|
||||
|
||||
我们可以构思一个递归函数 `dfs(index, sum_L, sum_R)`,参数传递:当前正准备分配第 `index` 题,以及此时左右脑的占用情况 `sum_L` 和 `sum_R`。
|
||||
|
||||
```cpp
|
||||
int s; // 当前科目题目总数
|
||||
vector<int> dfs_times; // 当前科目每道题所需时间
|
||||
int min_total_time; // 记录当前科目的最短完成时间
|
||||
|
||||
// dfs函数:为当前科目的第index道题分配给左脑或右脑
|
||||
// index: 当前处理的题目索引
|
||||
// sum_L: 左脑当前累计的完成时间
|
||||
// sum_R: 右脑当前累计的完成时间
|
||||
void dfs(int index, int sum_L, int sum_R) {
|
||||
// 递归出口:如果所有题目都已分配完毕
|
||||
if (index == s) {
|
||||
min_total_time = min(min_total_time, max(sum_L, sum_R));
|
||||
return;
|
||||
}
|
||||
|
||||
// 剪枝1:最优性剪枝
|
||||
// 如果当前的 sum_L 或 sum_R 已经比目前找到的最优解还要大,
|
||||
// 那么这条路径无论如何都无法得到更优的解,可以直接放弃。
|
||||
if (max(sum_L, sum_R) >= min_total_time) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 决策分支1:将第 index 道题分配给左脑
|
||||
// 深入:带着新的左脑时间进入下一层递归
|
||||
dfs(index + 1, sum_L + dfs_times[index], sum_R);
|
||||
|
||||
// 决策分支2:将第 index 道题分配给右脑
|
||||
// 深入:带着新的右脑时间进入下一层递归
|
||||
dfs(index + 1, sum_L, sum_R + dfs_times[index]);
|
||||
}
|
||||
|
||||
int main() {
|
||||
int total_min_time = 0; // 四科的总最短时间
|
||||
|
||||
for (int k = 0; k < 4; ++k) { // 处理4门科目
|
||||
cin >> s; // 读取当前科目的题目数量
|
||||
dfs_times.resize(s); // 调整 vector 大小
|
||||
for (int i = 0; i < s; ++i) {
|
||||
cin >> dfs_times[i]; // 读取每道题的时间
|
||||
}
|
||||
|
||||
// 剪枝2:优化搜索顺序 (可选)
|
||||
// 将题目按时间从大到小排序,可以更早触发最优性剪枝。
|
||||
sort(dfs_times.rbegin(), dfs_times.rend());
|
||||
|
||||
min_total_time = 1e9; // 初始化当前科目的最短时间为一个极大值
|
||||
dfs(0, 0, 0); // 从第0题开始分配,左右脑初始时间为0
|
||||
total_min_time += min_total_time; // 累加当前科目的最短时间
|
||||
}
|
||||
|
||||
cout << total_min_time << endl;
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
**剪枝优化:**
|
||||
1. **最优性剪枝**:
|
||||
* 在 `dfs` 函数开头添加判断:`if (max(sum_L, sum_R) >= min_total_time) return;`
|
||||
* 如果在搜索过程中,`sum_L` 或 `sum_R` 已经超过了当前已经找到的 `min_total_time`,那么无论后续题目如何分配,最终的 `max(sum_L, sum_R)` 肯定会比 `min_total_time` 更大。因此,这条路不可能通向更优的解,可以直接放弃。
|
||||
2. **优化搜索顺序**:
|
||||
* 为了让剪枝条件尽早触发,我们可以改变搜索的顺序。如果先处理时间较长的题目,`sum_L` 和 `sum_R` 会增长得更快,从而更快地达到剪枝的阈值。
|
||||
* 因此,一个非常有效的优化是在 DFS 开始前,将该科的所有题目按时间从大到小排序。
|
||||
|
||||
**书本上的做法:进一步优化**
|
||||
如果我们把任务分成两堆,必然是一堆 $\le \mathrm{sum}/2$,另一堆 $\ge \mathrm{sum}/2$。
|
||||
既然左右脑是对称的,我们完全可以规定:**左脑永远是拿得较少(或相等)的那一半。**也就是说,左脑的任务上限就是 `total_sum / 2`!
|
||||
目标变成了:在不超过 `total_sum / 2` 的前提下,让左脑拿到的题目总耗时**尽可能大**(越接近 `total_sum / 2` 越好)。
|
||||
当我们准备把某道题给左脑时,如果 `当前左脑时间 + 这道题的时间 > total_sum / 2`,这就意味着打破了我们的规定,直接判定为不合法分支,立刻剪枝!
|
||||
使用该做法就不再需要在参数里同时记录左脑和右脑的时间了。我们只需要关注左脑!因为对于每一道题,我们的选择变成了:
|
||||
* **装进左脑**(前提是装得下,不超过 `total_sum / 2`)。
|
||||
* **不装进左脑**(那就意味着丢给右脑了,不管它)。
|
||||
跑完这个 `dfs` 后,左脑尽了最大努力拿到了 `max_left_time`。
|
||||
那么这科的总耗时到底是多少呢?因为左脑拿的是较少的一半,所以右脑肯定拿了剩下较多的一半。最终这科的耗时就是右脑的时间:`total_sum - max_left_time`。
|
||||
|
||||
```cpp
|
||||
int s; // 当前科目题目总数
|
||||
vector<int> times; // 当前科目每道题所需时间
|
||||
int half_sum; // 当前科目总时间的一半
|
||||
int max_left; // 左脑能累积到的最大时间(不超过half_sum)
|
||||
|
||||
// 优化后的dfs函数:只关注左脑的累积时间
|
||||
// index: 当前处理的题目索引
|
||||
// current_L_time: 左脑当前累计的完成时间
|
||||
void dfs(int index, int current_L_time) {
|
||||
// 递归出口:如果所有题目都已分配完毕
|
||||
if (index == s) {
|
||||
max_left = max(max_left, current_L_time);
|
||||
return;
|
||||
}
|
||||
|
||||
// 将第 index 道题尝试分配给左脑
|
||||
// 条件:只有当加入这道题后不超过 half_sum,才可以分配给左脑!
|
||||
if (current_L_time + times[index] <= half_sum) {
|
||||
// 深入:把题装进去了,时间增加
|
||||
dfs(index + 1, current_L_time + times[index]);
|
||||
}
|
||||
|
||||
// 如果不分配给左脑的话,将第 index 道题尝试分配给右脑
|
||||
dfs(index + 1, current_L_time);
|
||||
}
|
||||
|
||||
int main() {
|
||||
int ans = 0; // 四科的总最短时间
|
||||
|
||||
for (int k = 0; k < 4; ++k) { // 处理4门科目
|
||||
cin >> s; // 读取当前科目的题目数量
|
||||
times.resize(s); // 调整 vector 大小
|
||||
int current_sum = 0; // 当前科目的总时间
|
||||
for (int i = 0; i < s; ++i) {
|
||||
cin >> times[i]; // 读取每道题的时间
|
||||
current_sum += times[i];
|
||||
}
|
||||
|
||||
// 剪枝:优化搜索顺序 (推荐)
|
||||
sort(times.rbegin(), times.rend());
|
||||
|
||||
half_sum = current_sum / 2; // 左脑的理论上限
|
||||
max_left = 0; // 初始化左脑能累积到的最大时间
|
||||
|
||||
dfs(0, 0); // 从第0题开始分配,左脑初始时间为0
|
||||
|
||||
// 当前科目的最短时间 = max(左脑时间, 右脑时间)
|
||||
// 因为左脑尽量接近 half_sum,所以右脑的时间 = current_sum - max_left
|
||||
// 最终最短时间 = max(max_left, current_sum - max_left)
|
||||
ans += max(max_left, current_sum - max_left);
|
||||
}
|
||||
|
||||
cout << ans << endl;
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **4. 总结与练习 **
|
||||
|
||||
* **要点回顾**
|
||||
1. **DFS 核心思想**:通过递归试探性地向前探索,遇到死胡同或不合法状态则回溯,撤销当前决策,尝试其他路径。
|
||||
2. **回溯本质**:利用系统栈保存函数上下文,实现状态的“前进探索”和“失败回退”。
|
||||
3. **DFS 基本框架**:
|
||||
* **递归函数** `dfs(step)`
|
||||
* **递归出口**:达到目标或所有决策完成。
|
||||
* **决策分支** (`for` 循环):遍历当前步骤的所有可能性。
|
||||
* **剪枝** (`if` 条件):提前判断当前决策是否合法,不合法则跳过,避免无效搜索。
|
||||
* **保存现场**:在递归调用前,修改全局状态。
|
||||
* **恢复现场**:在递归调用返回后,撤销对全局状态的修改,确保回溯到决策点时状态纯净。
|
||||
4. **剪枝策略**:
|
||||
* **可行性剪枝**:检查当前决策是否违反规则。
|
||||
* **最优性剪枝**:如果当前路径已不可能达到更优解,则剪掉。
|
||||
* **搜索顺序优化**:合理排序决策选项,有助于更早触发剪枝。
|
||||
Binary file not shown.
Reference in new issue
Block a user