[学习笔记] 二分答案&三分答案(写了我就能拿3分?)
二分法
定义
二分查找(英语:binary search),也称折半搜索(英语:half-interval search)、对数搜索(英语:logarithmic search),是用来在一个有序数组中查找某一元素的算法。
过程
以在一个升序数组中查找一个数为例。
它每次考察数组当前部分的中间元素,如果中间元素刚好要找的,就结束搜索过程;如果中间元素小于查找的值,那么左侧的只会更小,不会有所查找的元素,只需到右侧查找;如果中间元素大于所查找的值同理,只需到左侧查找。
性质
时间复杂度
二分查找的最优时间复杂度为 \(O(1)\)。
二分查找的平均时间复杂度和最坏时间复杂度均为 O(\log n)。因为在二分搜索过程中,算法每次都把查询的区间减半,所以对于一个长度为 \(n\) 的数组,至多会进行 \(O(\log n)\) 次查找。
空间复杂度
迭代版本的二分查找的空间复杂度为 \(O(1)\)。
递归(无尾调用消除)版本的二分查找的空间复杂度为 \(O(\log n)\)。
实现
int binary_search(int start, int end, int key) {
int ret = -1; // 未搜索到数据返回-1下标
int mid;
while (start <= end) {
mid = start + ((end - start) >> 1); // 直接平均可能会溢出,所以用这个算法
if (arr[mid] < key)
start = mid + 1;
else if (arr[mid] > key)
end = mid - 1;
else { // 最后检测相等是因为多数搜索情况不是大于就是小于
ret = mid;
break;
}
}
return ret; // 单一出口
}
最大值最小化
注意,这里的有序是广义的有序,如果一个数组中的左侧或者右侧都满足某一种条件,而另一侧都不满足这种条件,也可以看做是一种有序(如果把满足天剑看做 \(1\),不满足看做 \(0\),至少对于这个条件的这一维度是有序的)。换言之,二分搜索法可以用来满足某种条件的最大(最小)的值。
要求满足某种条件的最大值的最小可能情况(最大值最小化),首先的想法是从小到大枚举这个作为答案的 \(\lceil\) 最大值 \(\rfloor\),然后去判断是否合法。若答案单调,就可以使用二分搜索法来更快地找到答案。因此,要想使用二分搜索法来解这种 \(\lceil\) 最大值最小化 \(\rfloor\) 的题目,需要满足以下 \(3\) 个条件:
- 答案在一个固定区间内;
- 可能查找一个符合条件的值不是很容易,但是要求能比较容易得判断某个值是否是符合条件的;
- 可行解对于区间满足一定的单调性。换而言之,如果 \(x\) 是符合条件的,那么有 \(x+1\) 或者 \(x-1\) 也符合条件。(这样下来就满足了上面提到的单调性)
当然,最小值最大化也是同理的。
STL的二分查找
c++ 标准库中实现了查找首个不小于(大于等于)给定值的元素的函数 std::lower_bound 和查找首个大于给定值的元素的函数 std::upper_bound,二者均定义于头文件 <algorithm>
二者均采用二分实现,所以调用前必须保证元素有序。
二分答案
解题的时候往往会考虑枚举答案然后检验枚举的值是否正确。若满足单调性,则满足使用二分法的条件。把这里的枚举换成二分,就变成了 \(\lceil\) 二分答案 \(\rfloor\)。
例题:洛谷P1873
解题思路:
我们可以在 \(1\sim10^9\) 中枚举答案,但是这种朴素写法坑定拿不到满分,因为从 \(1\) 枚举到 \(10^9\) 太耗时间。我们可以再 \(1\sim10^9]\) 的区间上进行二分作为答案,然后检查各个答案的可行性(一半使用贪心法)。这就是二分答案。
参考解法:
#include<bits/stdc++.h>
using namespace std;
long long n, m, a[1000005], ma = -1;
bool check(int x) {
long long sum = 0;
for (int i = 1; i <= n; i++) {
if (a[i] >= x) sum += a[i] - x;
}
return sum < m;
}
int main() {
scanf("%lld%lld", &n, &m);
for (int i = 1; i <= n; i++) {
scanf("%lld", &a[i]);
}
int i = -1, j = 1e9+1;
while (i + 1 < j) {
int mid = (i + j) / 2;
if (check(mid)) j = mid;
else i = mid;
}
printf("%lld", i);
return 0;
}
整体二分
引入
在信息学竞赛中,有一部分题目可以使用二分的办法来解决。但是当这种题目有多次询问且我们每次查询都直接二分可能导致TLE是,就会用到整体二分。整体二分的主题思路就是把多个查询一起解决。(所以这是一个离线算法)
可以使用整体二分解决的题目需要满足以下性质:
- 询问的答案具有可二分性
- 修改对判定答案的贡献互相独立,修改之间互补影响效果
- 修改如果对判定答案有贡献,则贡献为一确定的与判定标准无关的值
- 贡献满足交换律、结合律、具有可加性
- 题目允许使用离线算法
——许昊然《浅谈数据结构题几个非经典解法》
解释
记 \([l,r]\) 为答案的值域,\([L,R]\) 为答案的定义域。(也就是说求答案是仅考虑下标在区间 \([L,R]\) 内大操作和询问,这其中询问的答案在 \([l,r]\) 内)
- 我们首先把所有操作按时间顺序存入数组中,然后开始分治,
- 在每一层分治中,利用数据结构(常见的是树状数组)统计当前查询的答案和 \(mid\) 之间的关系。
- 根据查询出来的答案和 \(mid\) 间的关系(小于等于 \(mid\) 和大于 \(mid\))将当前处理的操作序列氛围 \(q1\) 和 \(q2\) 两份,并分别递归处理。
- 当 \(l=r\) 是,找到答案,记录答案并返回即可。
需要注意的是,在整体二分中,若当前处理的值域为 \([l,r]\),则此时最终答案不在 \([l,r]\) 的询问会在其他时候处理。
过程
从普通二分说起:
查询全局第 \(k\) 小
题1 在一个数列中查询第 \(k\) 小的数。
当然可以直接排序。如果用二分,可以用数据结构记录每个大小范围内有多少个数,然后用二分猜测,利用数据结构检验。
题2 在一个数列中多次查询第 \(k\) 小的数。
可以对于每个询问进行一次二分;但是,也可以把所有的询问放在一起二分。
先考虑二分的本质:假设要猜一个 \([l,r]\) 之间的数,猜测之后会知道是猜大了,猜小了还是刚好。当然可以从 \(l\) 枚举到 \(r\),但更优秀的方法是二分:猜测答案是
$ m = \lfloor\frac{l + r}{2}\rfloor$,然后去验证 m 的正确性,再调整边界。这样做每次询问的复杂度为 \(O(\log n)\),若询问次数为 \(q\),则时间复杂度为 \(O(q\log n)\)。
回过头来,对于当前的所有询问,可以去猜测所有询问的答案都是 \(mid\),然后去依次验证每个询问的答案应该是小于等于 \(mid\) 的还是大于 \(mid\) 的,并将询问分为两个部分(不大于/大于),对于每个部分继续二分。注意:如果一个询问的答案是大于 \(mid\) 的,则在将其划至右侧前需更新它的 \(k\),即,如果当前数列中小于等于 \(mid\) 的数有 \(t\) 个,则将询问划分后实际是在右区间询问第 \(k-t\) 小数。如果一个部分的 \(l=r\) 了,则结束这个部分的二分。利用线段树的相关知识,我们每次将整个答案可能在的区间 \([1,maxans]\) 划分成了若干个部分,这样的划分共进行了 \(O(\log maxans)\) 次,一次划分会将整个操作序列操作一次。若对整个序列进行操作,并支持对应的查询的时间复杂度为 \(O(T)\),则整体二分的时间复杂度为 \(O(T\log n)\)。
参考代码:
struct Query {
int id, k; // 这个询问的编号, 这个询问的k
};
int ans[N], a[N]; // ans[i] 表示编号为i的询问的答案,a 为原数列
int check(int l, int r) {// 返回原数列中值域在 [l,r] 中的数的个数
int res = 0;
for (int i = 1; i <= n; i++) {
if (l <= a[i] && a[i] <= r) res++;
}
return res;
}
void solve(int l, int r, vector<Query> q)
{
int m = (l + r) / 2;
vector<Query> q1, q2; // 将被划到左侧的询问和右侧的询问
if (l == r) {
for (unsigned i = 0; i < q.size(); i++) ans[q[i].id] = l;
return;
return;
}
int t = check(l, m);
for (unsigned i = 0; i < q.size(); i++) {
if (q[i].k <= t){
q1.push_back(q[i]);
}else{
q[i].k -= t, q2.push_back(q[i]);
}
}
solve(l, m, q1), solve(m + 1, r, q2);
return;
}
查询区间第 \(k\) 小
题3 在一个数列中多次查询区间第 \(k\) 小的数。
涉及到给定区间的查询,再按之前的方法进行二分就会导致 \(check\) 函数的时间复杂度爆炸。仍然考虑询问与值域中点 \(m\) 的关系:若询问区间内小于等于 \(m\) 的数有 \(t\) 个,询问的是区间内的 \(k\) 小数,则当 \(k\leq t\) 时,答案应小于等于 \(m\);否则,答案应大于 \(m\)。(注意边界问题)此处需记录一个区间小于等于指定数的数的数量,即单点加,求区间和,可用树状数组快速处理。为提高效率,只对数列中值在值域区间 \([l,r]\) 的数进行统计,即,在进一步递归之前,不仅将询问划分,将当前处理的数按值域范围划为两半。
参考代码(关键部分):
struct Num {
int p, x;
}; // 位于数列中第 p 项的数的值为 x
struct Query {
int l, r, k, id;
}; // 一个编号为 id, 询问 [l,r] 中第 k 小数的询问
int ans[N];
void add(int p, int x); // 树状数组, 在 p 位置加上 x
int query(int p); // 树状数组, 求 [1,p] 的和
void clear(); // 树状数组, 清空
void solve(int l, int r, vector<Num> a, vector<Query> q)
// a中为给定数列中值在值域区间 [l,r] 中的数
{
int m = (l + r) / 2;
if (l == r) {
for (unsigned i = 0; i < q.size(); i++) ans[q[i].id] = l;
return;
}
vector<Num> a1, a2;
vector<Query> q1, q2;
for (unsigned i = 0; i < a.size(); i++)
if (a[i].x <= m)
a1.push_back(a[i]), add(a[i].p, 1);
else
a2.push_back(a[i]);
for (unsigned i = 0; i < q.size(); i++) {
int t = query(q[i].r) - query(q[i].l - 1);
if (q[i].k <= t)
q1.push_back(q[i]);
else
q[i].k -= t, q2.push_back(q[i]);
}
clear();
solve(l, m, a1, q1), solve(m + 1, r, a2, q2);
return;
}
下面提供洛谷P3834【模板】可持久化线段树 2 一题使用整体二分,偏向竞赛风格的写法。
#include<bits/stdc++.h>
using namespace std;
constexpr int N = 2e5 + 10;
int n, m, ans[N], t[N], a[N],toRaw[N],tot;
pair<int, int> b[N];
int sum(int p) {
int ans = 0;
while (p) {
ans += t[p];
p -= p & (-p);
}
return ans;
}
void add(int p, int x) {
while (p <= n) {
t[p] += x;
p += p & (-p);
}
return ;
}
struct node {
int l, r, k, id, type;
} q[N * 2], q1[N * 2], q2[N * 2];
void solve(int l, int r, int ql, int qr) {
if (ql > qr) return ;
if (l == r) {
for (int i = ql; i <= qr; i++){
if (q[i].type == 2) ans[q[i].id] = l;
}
return ;
}
int mid = (l + r) / 2, cnt1 = 0, cnt2 = 0;
for (int i = ql; i <= qr; i++) {
if (q[i].type == 1) {
if (q[i].l <= mid) {
add(q[i].id, 1);
q1[++cnt1] = q[i];
} else{
q2[++cnt2] = q[i];
}
} else {
int x = sum(q[i].r) - sum(q[i].l - 1);
if (q[i].k <= x)
q1[++cnt1] = q[i];
else {
q[i].k -= x;
q2[++cnt2] = q[i];
}
}
}
for (int i = 1; i <= cnt1; i++) {
if (q1[i].type == 1) {
add(q1[i].id, -1);
}
}
for (int i = 1; i <= cnt1; i++) {
q[i + ql - 1] = q1[i];
}
for (int i = 1; i <= cnt2; i++) {
q[i + cnt1 + ql - 1] = q2[i];
}
solve(l, mid, ql, cnt1 + ql - 1);
solve(mid + 1, r, cnt1 + ql, qr);
return ;
}
int main() {
cin >> n >> m;
for (int i = 1,x; i <= n; i++) {
cin >> x;
b[i].first = x;
b[i].second = i;
}
sort(b + 1, b + n + 1);
int cnt = 0;
for (int i = 1; i <= n; i++) {
if (b[i].first != b[i - 1].first) cnt++;
a[b[i].second] = cnt;
toRaw[cnt] = b[i].first;
}
for (int i = 1; i <= n; i++) {
q[++tot] = {a[i], -1, -1, i, 1};
}
for (int i = 1; i <= m; i++) {
int l, r, k;
cin >> l >> r >> k;
q[++tot] = {l, r, k, i, 2};
}
solve(0, cnt + 1, 1, tot);
for (int i = 1; i <= m; i++) {
cout << toRaw[ans[i]] << '\n';
}
return 0;
}
带修改区间第 \(k\) 小
题4 给定一个数列,要支持单点修改,区间查第 \(k\) 小。
修改操作可以直接理解为从原数列中删去一个数再添加一个数,为方便起见,将询问和修改统称为 \(\lceil\) 操作 \(\rfloor\)。因后面的操作会依附于之前的操作,不能如题 3 一样将统计和处理询问分开,故可将所有操作存于一个数组,用标识区分类型,依次处理每个操作。为便于处理树状数组,修改操作可分拆为擦除操作和插入操作。
优化
- 注意到每次对于操作进行分类是,只会更爱操作顺序,故可直接在原数组上操作。具体实现,在二分是将记录操作的 \(q,a\) 数组换位一个大的全局数组,二分是记录信息变为 \(L,R\),记当前处理的操作是全局数组上的哪个区间。利用临时数组记录当前的分类情况,进一步递归前将临时数组信息协会原数组。
- 树状数组每次清空都会复杂度爆炸,可采用每次使用树状数组是记录当前修改位置(这已由1中提到的临时数组实现),本次操作结束后在原数组加 \(-1\) 的方法快速清零。
- 一开始对于数列的初始化操作可简化插入操作。
三分法
引入
如果需要求出单峰函数的极值点,通常使用二分法衍生出的三分法求单峰函数的极值点。
三分法与二分法的基本思想类似,但每次操作需在当前区间 \([l,r]\)(下图中除去虚线范围内的部分)内任取两点 \(lmid,rmid(lmid<rmid)\)(下图中的两个蓝点)。如下图,如果 \(f(lmid)<f(rmid)\),则在 \([rmid,r]\)(下图中的红色部分)中函数必然单调递增,最小值所在点(下图中的绿点)必然不在这一区间内,可舍去这一区间。反之亦然。
三分法每次操作会舍去两侧区间中的其中一个。为减少三分法的操作次数,应使两侧区间尽可能大。因此,每一次操作时的 \(lmid\) 和 \(rmid\) 分别取 \(mid-\varepsilon\) 和 \(mid-\varepsilon\) 是一个不错的选择。
实现
while (r - l > eps) {
mid = (l + r) / 2;
lmid = mid - eps;
rmid = mid + eps;
if (f(lmid) < f(rmid))
r = mid;
else
l = mid;
}
例题
例题:洛谷P3382
解题思路
本题要求求 \(N\) 次函数在 \([l,r]\) 取最大值时自变量的值,显然可以使用三分法。
参考代码
#include<bits/stdc++.h>
using namespace std;
const double eps = 1e-7;
int n;
double l, r, a[20], mid, lmid, rmid;
double f(double x) {
double res = 0;
for (int i = n; i >= 0; i--) {
res += a[i] * pow(x, i);
}
return res;
}
int main() {
cin >> n >> l >> r;
for (int i = n; i >= 0; i--){
cin >> a[i];
}
while (r - l > eps) {
mid = (l + r) / 2;
lmid = mid - eps;
rmid = mid + eps;
if (f(lmid) > f(rmid)){
r = mid;
}else{
l = mid;
}
}
// cout << fixed << setprecision(6) << l;
printf("%.6lf",l);
return 0;
}

浙公网安备 33010602011771号