Files
cpp-courseware/2.Course/1.深入浅出程序设计竞赛(基础篇)/2.初涉算法/6.二分查找/讲义资料/浮点二分答案 讲义.md
T

171 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
### 算法: 浮点数二分 讲义
#### 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` 的依据都是函数值的正负号。二分思想的应用远不止于此。任何一个参数,只要它能导致结果呈现出某种“单调性”(越大越如何,或越小越如何),我们都可以对这个参数进行二分,在整数和浮点数里是完全通用的。