Initial commit: C++ courseware
This commit is contained in:
commit
4a4ee7c287
1789 files changed
+373489
No files matched your search
@@ -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.
Reference in new issue
Block a user