「学习笔记」二分

引入

二分是一种十分重要的算法。srds,我不得不举一个被用烂了的例子。

JQ 心里想了一个数 \(x\)(\(1 \leq x \leq 10\)),ZWB 来猜,如果在 \(4\) 次内猜中了,ZWB 就和 JQ [数据删除]。假设 \(x = 7\)。

第 \(1\) 次,范围为 \([1, 10]\),ZWB 猜 \(\lfloor \frac{1 + 10}{2} \rfloor = 5\)。JQ 甜蜜地说小了。

第 \(2\) 次,范围为 \([6, 10]\),ZWB 猜 \(\lfloor \frac{6 + 10}{2} \rfloor = 8\)。JQ 甜蜜地说大了。

第 \(3\) 次,范围为 \([6, 7]\),ZWB 猜 \(\lfloor \frac{6 + 7}{2} \rfloor = 6\)。JQ 甜蜜地说小了。

第 \(4\) 次,范围为 \([7, 7]\),ZWB 猜 \(\lfloor \frac{7 + 7}{2} \rfloor = 7\)。JQ 甜蜜地说猜中了!于是,ZWB 和 JQ 就开心地 [数据删除] 了。

事实上,我们可以发现,每一次询问,我们都把区间缩短了一半,从而只需要 \(\lfloor \log_2^{10} + 1 \rfloor = 4\) 次即可猜中。假如 ZWB 直接暴力询问 \(1 \sim 10\),那么他最坏需要猜 \(10\) 次,就有可能不能和 JQ [数据删除] 了。

当然,最重要的二分的前提:有单调性

二分查找

二分查找跟二分答案有一些差别,所以还是拿出来单独讲一下。

「例题」猜数字

题意简述:给出一个序列 \(a\),共有 \(n\) 个元素,且 \(a_i < a_{i + 1}\)。请你输出元素 \(x\) 的位置,保证 \(x\) 一定在 \(a\) 中。

数据范围:\(1 \leq n \leq 10 ^ 4\)。

这种题比较 simple 啊。类似于上面 ZWB & JQ 的猜数字,我们可以定义 \(l = 1, r = n\),然后每次都取 \(mid = \lfloor \frac{l + r}{2} \rfloor\),将 \(a_{mid}\) 与 \(x\) 比较。如果 \(a_{mid} < x\),我们就令 \(l = mid + 1\);如果 \(a_{mid} > x\),我们就令 \(r = mid - 1\);否则 \(a_{mid} = x\),直接输出即可。

怎么分好像很简单,那么我们的边界是什么呢?如果 \(l \leq r\)(有哪怕一个值没被我们排除掉),我们就一直猜。或者你直接 while (true) 也不是不行,毕竟一定找得到嘛。这样,我们查找的时间复杂度就由 \(O(n)\) 变为了 \(O(\log n)\)。

#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1e4 + 5;

int a[MAXN], n, x;
int main() {
	scanf("%d", &n);
	for (int i = 1; i <= n; i++)
		scanf("%d", &a[i]);
	scanf("%d", &x);
	int l = 1, r = n;
	while (true) {
		int mid = l + r >> 1;
		if (a[mid] < x) l = mid + 1;
		else if (a[mid] > x) r = mid - 1;
		else return printf("%d\n", mid), 0;
	}
	return 0;
}

那假如 \(x\) 不一定存在呢?我们就必须要写 while (l <= r),并且在循环结束后输出 \(-1\)。

#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1e4 + 5;

int a[MAXN], n, x;
int main() {
	scanf("%d", &n);
	for (int i = 1; i <= n; i++)
		scanf("%d", &a[i]);
	scanf("%d", &x);
	int l = 1, r = n;
	while (l <= r) {
		int mid = l + r >> 1;
		if (a[mid] < x) l = mid + 1;
		else if (a[mid] > x) r = mid - 1;
		else return printf("%d\n", mid), 0;
	}
	puts("-1");
	return 0;
}

「例题」二分查找

题意简述:给出一个序列 \(a\),共有 \(n\) 个元素,且 \(a_i \leq a_{i + 1}\)。请你输出元素 \(x\) 在 \(a\) 中第一次出现的位置。如果不存在输出 \(-1\)。

数据范围:\(1 \leq n \leq 10 ^ 6\)。

这里我们不但要找 \(x\),还要找到其第一次出现的位置。

  • 当 \(a_{mid} > x\) 时,\(mid\) 一定不符合,令 \(r = mid - 1\)。
  • 当 \(a_{mid} = x\) 时,\(mid\) 是符合的,但不一定是第一次出现的位置,所以可以令 \(r = mid\)。
  • 当 \(a_{mid} < x\) 时,\(mid\) 一定不符合,令 \(l = mid + 1\)。

那什么时候停止呢?由于我们只查找一个元素,所以只要 \(l < r\) 就可以一直循环(\(l = r\) 了就直接跳出)。

#include <bits/stdc++.h>
using namespace std;

int a[1000005], n, x;
int main() {
	scanf("%d", &n);
	for (int i = 1; i <= n; i++)
		scanf("%d", &a[i]);
	scanf("%d", &x);
	int l = 1, r = n;
	while (l < r) {
		int mid = l + r >> 1;
		if (a[mid] > x) r = mid - 1;
		else if (a[mid] < x) l = mid + 1;
		else r = mid;
	}
	if (a[l] != x) puts("-1"); // 最后判断一下是否相等
	else printf("%d\n", l);
	return 0;
}

「例题」二分查找上界

原题目链接:Link

因为上界和下界非常经典,所以先直接给出代码:

#include <cstdio>

int n, a[105], x;
int main() {
	scanf("%d", &n);
	for (int i = 1; i <= n; i++)
		scanf("%d", &a[i]);
	scanf("%d", &x);
	int l = 0, r = n;
	while (l < r) {
		int mid = l + r + 1 >> 1;
		if (a[mid] <= x) l = mid;
		else r = mid - 1;
	}
	printf("%d\n", l);
	return 0;
}
  • 当 \(a_{mid} > x\) 时,显然区间为 \([l, mid)\),所以令 \(r = mid - 1\)。
  • 当 \(a_{mid} = x\) 时,是可行的,但是不一定是最后一个位置,区间为 \([mid, r]\),所以令 \(l = mid\)。
  • 当 \(a_{mid} < x\) 时,与等于的情况类似,可行但不一定是最后一个位置,所以也是 \(l = mid\)。

最后,等于和小于的情况合并,就得到了上面的代码。

那这里 \(mid\) 为什么取的是 \(\lfloor \frac{l + r + 1}{2} \rfloor\) 呢?如果我们写 mid = l + r >> 1,那么当 \(r = l + 1\) 时,\(mid = l\),如果进入 a[mid] <= x 分支,那么又令 \(l = mid\),没有变化,从而死循环。而这里加了 \(1\) 就可以很好的避免这个问题。

而且,我们还可以发现,这里的 \(mid = \lfloor \frac{l + r + 1}{2} \rfloor\) 是不可能等于 \(l\) 的,所以我们可以在开始时把 \(l\) 赋值为 \(0\),如果结束的时候 \(l\) 还是等于 \(0\),说明全部进的是 r = mid - 1 这个分支,即无解的情况,刚好又符合题目的要求(无解输出 \(0\)),所以最终直接输出 \(l\) 即可。

短短的十几行代码,真的十分精妙。

「习题」二分查找下界

原题目链接:Link

试着模仿二分查找上界写出代码。

对了,STL 中有两个可爱的函数,lower_bound 和 upper_bound,实现了上界和下界的查找。用法见 Link

二分答案

终于可以开始讲二分答案啦!!!感动!!!

一般来讲,如果一个问题的答案具有单调性,我们就可以二分这个答案,判断这个答案是否可行,然后进行相应的二分。

「例题」数列分段 Section II

原题目链接:Link

题目要求每段和的最大值最小为多少。看到这种「最大的最小」「最小的最大」,一般都是二分。

先来看有没有单调性。题目相当于找到一个最大值 \(x\),使得按这个值分出来的段数 \(\leq m\),而我们要找到 \(x\) 的最小值 \(ans\)。也就是说,当 \(x < ans\) 时,条件一定不成立;而 \(x \leq ans\) 时,条件一定成立,这就有了单调性

按照上面分析的,我们直接二分这个 \(x\) 即可。

#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1e5 + 5;

int n, a[MAXN], m;
int l, r;

bool check(int x) {
	int tot = 1, sum = 0;
	for (int i = 1; i <= n; i++)
		if (sum + a[i] > x) tot++, sum = a[i];
		else sum += a[i];
	return tot <= m;
} // 分出的段数不大于 m 就是可行的
int main() {
	cin >> n >> m;
	for (int i = 1; i <= n; i++) {
		cin >> a[i];
		l = max(l, a[i]);
		r += a[i]; // 注意 l 和 r 的范围
	}
	while (l < r) {
		int mid = l + r >> 1;
		if (check(mid)) r = mid; // 可行,缩小范围,因为可能刚好 mid 就是答案
		else l = mid + 1;
		// 不可行,即 tot > m,说明 mid 太小了,令 l = mid + 1
	}
	cout << l << endl;
	return 0;
}

「例题」砍树

原题目链接:Link

显然,我们可以二分这个锯片的高度 \(mid\),然后算出可以砍出的木材的数量,如果 \(\geq m\) 就满足条件,\(l = mid\);否则不满足 \(r = mid - 1\)。发现了吗?一般来讲,只可能有 l = mid 或者 l = mid + 1 这种写法,只可能有 r = mid 或者 r = mid - 1 这种写法;如果 \(mid\) 可能是答案就不会写加一减一,如果 \(mid\) 不可能是答案就一定会加一减一,把 \(mid\) 给排除掉。

Come back!这里我们写的是 l = midr = mid - 1,发现如果写 mid = l + r >> 1 的话,如果 \(r = l + 1\) 且进入 l = mid 分支就会死循环,所以我们要写 mid = l + r + 1 >> 1。代码如下:

#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1e6 + 5;

int a[MAXN], n, m;
int l, r;

bool check(int x) {
	long long sum = 0; // 十年 OI 一场空,不开 long long 70pts
	for (int i = 1; i <= n; i++)
		if (a[i] > x) sum += a[i] - x;
	return sum >= m;
}
int main() {
	scanf("%d %d", &n, &m);
	for (int i = 1; i <= n; i++) {
		scanf("%d", &a[i]);
		r = max(r, a[i]); // 注意划分边界
		// 此处 l 无明显边界,而 r 显然不能超过 a[i] 的最大值(不然你切个屁)
	}
	while (l < r) {
		int mid = l + r + 1 >> 1;
		if (check(mid)) l = mid;
		else r = mid - 1;
	}
	printf("%d\n", l);
	return 0;
}

「例题」一元三次方程求解

原题目链接:Link

思路一:暴力。由于根的范围在 \(-100 \sim 100\),所以我们可以直接暴力枚举,复杂度 \(O(20000)\)(大雾)。

思路二:实数二分。

题目中有一句关键的话:根与根之差的绝对值 \(\geq 1\)。

也就是说,在 \(x \sim x + 1\) 的范围内,最多只有一个满足条件的根(\(x\) 为整数)。我们就可以枚举这个 \(x\),然后 \(l = x, r = x + 1\) 进行二分。

由于这里不是整数,所以我们要进行实数二分。其实,实数二分比整数二分更简单,因为没有各种奇怪的加一减一,但是要限定精度。具体地说,实数二分模版如下:

while (r - l > eps) {
    double mid = (l + r) / 2;
    if (check(mid) l = mid; // 或者 r = mid
    else r = mid; // 或者 l = mid
}

其中,\(eps\) 是限定的精度。如果题目要求输出 \(k\) 位小数,我们一般可以取 \(eps = 10 ^ {-(k + 2)}\)。那我们来看看这题的代码:

#include <bits/stdc++.h>
using namespace std;
const double eps = 1e-4;

double a, b, c, d;

double f(double x) {
	return a * x * x * x + b * x * x + c * x + d;
}
int main() {
	scanf("%lf %lf %lf %lf", &a, &b, &c, &d);
	for (double i = -100; i < 100; i++) {
		if (f(i) == 0) {
			printf("%.2lf ", i);
			continue;
		} // 如果自身就是根,直接 continue
		// 如果你不放心实数直接比相等,也可以写 fabs(f(i)) < 1e-16
		// 是的必须要到 1e-16,否则就 WA
		if (f(i) * f(i + 1) < 0) { // 如果之间有根,进行二分
			double l = i, r = i + 1, mid;
			while (r - l > eps) {
				mid = (l + r) / 2;
				if (f(mid) * f(l) < 0) r = mid; // 根在 [l, mid] 上
				else l = mid; // 根在 [mid, r] 上
			}
			printf("%.2lf ", l);
		}
	}
	return 0;
}

「例题」kotori的设备

原题目链接:Link

我们先来骗下分。

什么时候我们可以无限使用这些设备呢?自然就是 \(\sum_{i=1}^{n} a_i \leq p\)​ (所有设备每秒消耗的电量小于等于充电器每秒可以充的电量)的时候,因为这时一定可以让每个设备都充到需要的电。

这样你应该可以骗到 \(15\) 分(大雾)。那么接下来,因为题目标签有二分,所以我们只能二分了。那么我们肯定是二分答案(kotori 在其中一个设备能量降为 0 之前最多能使用多久的时间)。

设这个答案为 \(x\)​。那么第 \(i\) 个设备在 \(x\) 秒后,消耗的电量就是 \(x \times a_i\)​​​。如果原始电量 \(b_i\) 足够消耗(即 \(b_i \geq x \times a_i\)​),我们就不需要给第 \(i\) 个设备充电。

那如果不够消耗(即 \(b_i < x \times a_i\))呢?此时电量为 \(b_i - x \times a_i\)(负数)。我们就只能给它充上 \(0 - (b_i - x \times a_i)\)​ ,即 \(x \times a_i - b_i\) 的电量。

求出当时间等于 \(x\) 时,需要给所有设备充的电量和 \(s\),如果 \(s \leq x \times p\)(\(x \times p\)​​ 就是充电器最多可以充的电量),就可行;否则不可行。(PS:果然实数二分就是 easy & simple。)

代码如下:

#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
const double eps = 1e-5; // 题目要求 < 1e-4,我们设为 1e-5
#define ll long long

int n, p, a[MAXN], b[MAXN];
ll sum; // 最多达 1e10,int 会炸

bool check(double x) {
	double tot = 0;
	for (int i = 1; i <= n; i++)
		if (a[i] * x > b[i])
			tot += a[i] * x - b[i];
	return tot <= x * p; // 二分
}
int main() {
	scanf("%d %d", &n, &p);
	for (int i = 1; i <= n; i++) {
		scanf("%d %d", &a[i], &b[i]);
		sum += a[i];
	}
	if (sum <= p) puts("-1");
	else {
		double l = 0, r = 1e10, mid;
		while (r - l > eps) {
			if (check(mid = (l + r) / 2)) l = mid; // 压行技巧
			else r = mid;
		} // 实数二分模版
		printf("%.10lf\n", l);
	}
	return 0;
}

「例题」防线

原题目链接:Link

题目很容易理解,就是找那唯一的有奇数个防具的位置嘛。但是这跟二分有什么关系?事实上,因为只有唯一的一个点有奇数个防具,我们就可以前缀和!那个点之前的点的前缀和都是偶数,而它本身以及后面的点的前缀和都是奇数,这样就满足了二分条件!

(此处应有蛋黄派的妙啊表情包,但是为了加载快一点就没弄了。)

相信大家根据之前的分析,已经熟练掌握了恶心的加一减一以及 \(mid\) 以及循环条件,所以我们直接上代码:

#include <bits/stdc++.h>
using namespace std;
const int MAXN = 2e5 + 5;

int t, n, s[MAXN], e[MAXN], d[MAXN];

int getv(int x) {
	int tot = 0;
	for (int i = 1; i <= n; i++)
		if (x >= s[i])
			tot += (min(x, e[i]) - s[i]) / d[i] + 1;
	return tot;
} // 得到在 x 前的所有防具的数量
// 这里 + 1 是因为除出来的是段数,数量还要加(植树问题)
// 这里取 min 是因为防具可能到 e[i] 就没有了,自然不能算进去
int main() {
	scanf("%d", &t);
	while (t--) {
		scanf("%d", &n);
		int l = 0, r = 0;
		for (int i = 1; i <= n; i++) {
			scanf("%d %d %d", &s[i], &e[i], &d[i]);
			r = max(r, e[i]); // r 的边界
		}
		while (l < r) {
			int mid = l + r >> 1;
			if (getv(mid) & 1) r = mid;
			else l = mid + 1;
		}
		int ans = getv(l) - getv(l - 1); // 前缀和,得到 l 点的防具数量
		if (ans & 1) printf("%d %d\n", l, ans);
		else puts("There's no weakness."); // 再判个无解
	}
	return 0;
}

三分

为什么把三分提到这里来讲,我也不知道。反正三分跟二分挺像的,你懂吧?不过,三分适用于单峰函数。

这里有一个三分理解:Link

「例题」曲线

原题目链接:Link

首先,你得证明这个函数可以三分。

……

证毕。那么我们现在就开始三分吧!

三分每次取的是 \(l, r\) 的三等分点,即 \(mid_1 = l + \frac{r - l}{3}\) 和 \(mid_2 = r - \frac{r - l}{3}\)。在这道题中,如果 \(mid_1 > mid_2\),则可以发现 \(mid_1\) 一定在最小值左侧;否则 \(mid_2\) 就在右侧(自己用两个手指模拟一下)。代码如下:

#include <bits/stdc++.h>
using namespace std;
const double eps = 1e-9;
const int MAXN = 1e5 + 5;

int t, n;
int a[MAXN], b[MAXN], c[MAXN];

double f(double x) {
	double res;
	for (int i = 1; i <= n; i++)
		res = i == 1 ? a[i] * x * x + b[i] * x + c[i] : max(res, a[i] * x * x + b[i] * x + c[i]);
	return res;
} // 题意中的 f 函数
int main() {
	scanf("%d", &t);
	while (t--) {
		scanf("%d", &n);
		for (int i = 1; i <= n; i++)
			scanf("%d %d %d", &a[i], &b[i], &c[i]);
		double l = 0, r = 1000;
		while (r - l > eps) {
			double mid1 = l + (r - l) / 3, mid2 = r - (r - l) / 3;
			if (f(mid1) > f(mid2)) l = mid1;
			else r = mid2;
		}
		printf("%.4lf\n", f(l));
	}
	return 0;
}

By the way,三分的精度是很高的,可能动不动就要 \(10\) 的负十几次方。

「习题」灯泡

原题目链接:Link

这是清华集训的题目,所以可能稍微需要一点数学知识。比如相似(或者说三角函数),而且精度非常恶心,我的代码 loj 上能 A,洛谷上会被卡。

#include <bits/stdc++.h>
using namespace std;
const double eps = 1e-12;

int t;
double H, h, D;

double f(double d) {
	double v = (D - d) / (H - h) * h, res = d * (H - h) / (D - d);
	return d < v ? d + h - res : (D - d) / (H - h) * h;
}
int main() {
	cin >> t;
	while (t--) {
		cin >> H >> h >> D;
		double l = 0, r = D;
		while (r - l > eps) {
			double mid1 = l + (r - l) / 3, mid2 = r - (r - l) / 3;
			if (f(mid1) > f(mid2)) r = mid2;
			else l = mid1;
		}
		printf("%.3lf\n", f(l));
	}
	return 0;
}
上一篇:使用PHP Simple HTML DOM Parser坚持选择类或id


下一篇:[SDOI2012]吊灯(结论)