Files
T

186 lines
8.9 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. 经典最优化问题:砍树问题 (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` 贪心向左探索。