二分查找与二分答案
“猜数字游戏”:在心里想一个不超过
这里的猜法就是“二分”。首先猜
二分查找
例题:P2249 [深基13.例1] 查找
输入
个不超过 的单调不减的非负整数 ,然后进行 次询问。对于每次询问,给出一个整数 ,要求输出这个数字在序列中第一次出现的编号,如果没有找到,则输出 。
分析:对于每次询问,直接从头到尾找一遍数字是不可行的,这样时间复杂度就是
利用二分的思想就可以加速查找过程,每次取中间元素和待查找数据进行比较,如果正好相等那就找到了,如果待查找元素更小,则在左半部分继续用这个方式查找,更大则在右半部分查找。
#include <cstdio> const int N = 1e6 + 5; int a[N]; int main() { int n, m; scanf("%d%d", &n, &m); for (int i = 1; i <= n; i++) { scanf("%d", &a[i]); } for (int i = 1; i <= m; i++) { int q; scanf("%d", &q); int l = 1, r = n; bool ok = false; while (l <= r) { int mid = (l + r) / 2; // 取中点 if (q == a[mid]) { // 刚好找到要找的数字 printf("%d ", mid); ok = true; break; } else if (q < a[mid]) { // 取区间的前一半 r = mid - 1; } else { // 取区间的后一半 l = mid + 1; } } if (!ok) printf("-1 "); } return 0; }
但是这个程序并不能通过样例数据。例如对于序列
要正确地解决这个问题,首先需要将问题转化一下,如果要找某个数
#include <cstdio> const int N = 1e6 + 5; int a[N]; int main() { int n, m; scanf("%d%d", &n, &m); for (int i = 1; i <= n; i++) { scanf("%d", &a[i]); } for (int i = 1; i <= m; i++) { int q; scanf("%d", &q); int l = 1, r = n; while (l <= r) { int mid = (l + r) / 2; if (a[mid] < q) { l = mid + 1; } else { r = mid - 1; } } // 最后分界线两侧为 r | l // 左侧 <q,右侧 >=q // 因此要查找某个数在序列中第一次出现的位置,对最终的 a[l] 进行判断 if (l <= n && a[l] == q) printf("%d ", l); // 注意要判断l<=n,因为可能q比所有的元素都大 else printf("-1 "); } return 0; }
由于每轮二分区间长度都要衰减一半,因此二分查找的时间复杂度是
在 C++ 标准模板库(STL)中针对二分查找有两个相关函数 lower_bound()
和 upper_bound()
,用到的头文件是 algorithm
:
lower_bound(begin,end,val)
在值有序的数组连续地址[begin,end)
中找到第一个大于等于 的位置并返回其地址upper_bound(begin,end,val)
在值有序的数组连续地址[begin,end)
中找到第一个大于 的位置并返回其地址
如果对“地址”的概念不理解,可以先认为这个返回值减去数组名刚好等于对应的数组下标。如果不存在大于等于/大于 val
的元素,则返回尾指针或尾迭代器。如果要找最后一个小于或小于等于 val
的,可以在 lower_bound
或 upper_bound
的结果上自减,但要注意自减前先判断是否已经是开头位置。
lower_bound
能找到某数第一次出现的位置,upper_bound
能找到某数最后一次出现的位置的下一个位置,那么某个数出现的次数可以表示为 upper_bound(...)-lower_bound(...)
。
利用标准库,之前的例题可以写得更加精简:
#include <cstdio> #include <algorithm> using std::lower_bound; const int N = 1e6 + 5; int a[N]; int main() { int n, m; scanf("%d%d", &n, &m); for (int i = 1; i <= n; i++) { scanf("%d", &a[i]); } for (int i = 1; i <= m; i++) { int q; scanf("%d", &q); // 返回 a[1]~a[n] 中 >=q 的第一个位置的地址,减去数组名 a 后相当于对应下标 int idx = lower_bound(a + 1, a + n + 1, q) - a; if (idx <= n && a[idx] == q) printf("%d ", idx); else printf("-1 "); } return 0; }
另外,如果只需要知道有序序列中是否包含某个元素,可以使用 binary_search
这个函数,用法与 lower_bound
和 upper_bound
类似,只不过返回值是布尔型,找得到返回 true
,找不到返回 false
。
习题:P1678 烦恼的高考志愿
解题思路
将每个学校的分数线排序,此时对于每位同学的估分
#include <cstdio> #include <algorithm> using ll = long long; using std::sort; using std::lower_bound; using std::min; const int N = 1e5 + 5; const int INF = 1e7; int a[N]; int main() { int m, n; scanf("%d%d", &m, &n); for (int i = 1; i <= m; i++) scanf("%d", &a[i]); sort(a + 1, a + m + 1); ll ans = 0; for (int i = 1; i <= n; i++) { int b; scanf("%d", &b); int diff = INF; int idx = lower_bound(a + 1, a + m + 1, b) - a; // 第一个>=b的位置 if (idx <= m) diff = min(diff, a[idx] - b); idx--; // 最后一个<b的位置 if (idx >= 1) diff = min(diff, b - a[idx]); // 取分差小的加到答案中 ans += diff; } printf("%lld\n", ans); return 0; }
习题:P1978 集合
解题思路
题意概括一下就是如果
比如当 long long
的范围。不妨反过来考虑,从小到大依次考虑每个数是否选入集合,如果这个数不是
#include <cstdio> #include <algorithm> using std::sort; using std::lower_bound; using ll = long long; const int N = 100005; ll a[N], chosen[N]; // chosen记录已被选入集合的数 int main() { int n, k; scanf("%d%d", &n, &k); for (int i = 1; i <= n; i++) scanf("%lld", &a[i]); sort(a + 1, a + n + 1); int cnt = 0; // 已被选入集合的数的个数 for (int i = 1; i <= n; i++) { if (a[i] % k != 0) { cnt++; chosen[cnt] = a[i]; } else { int idx = lower_bound(chosen + 1, chosen + cnt + 1, a[i] / k) - chosen; if (idx > cnt || chosen[idx] != a[i] / k) { // 在已选入集合的数字中没有找到a[i]/k cnt++; chosen[cnt] = a[i]; } } } printf("%d\n", cnt); return 0; }
二分答案
二分思想不仅可以在有序序列中快速查询元素,还能高效率地解决一些具有单调性判定的问题。
回顾一下前面的二分查找:给定一个升序数组
这实际上是单调性判定问题。
例题:P1824 进击的奶牛
一个牛棚有
个隔间,它们分布在一条直线上,坐标是 。现在需要把 头牛安置在指定的隔间,使得所有牛中相邻两头的最近距离越大越好,求这个最大的最近距离。例如,有 个隔间, 头牛,隔间的坐标是 。可以将牛关在 这些隔间中,最近的距离是 。如果要求所有牛之间的距离都大于 ,则不存在这样的方案,因此最大的最近距离就是 。
分析:如果这个最近间隔距离很小(考虑最近距离是
可以从
令“条件”表示“当间隔距离至少为
那么问题变为如何高效地检验“条件”的可行性。也就是限制任意两头安排的牛的距离不能小于
#include <cstdio> #include <algorithm> using std::sort; const int N = 1e5 + 5; int x[N], n, c; bool check(int d) { // 先在x[1]处安排一头牛 int cnt = 1, pre = x[1]; for (int i = 2; i <= n; i++) { if (x[i] - pre >= d) { // 与上一头牛拉开足够间距,可以安排 cnt++; pre = x[i]; } } return cnt >= c; // 验证当前距离限制下是否可行(够安排c头牛) } int main() { scanf("%d%d", &n, &c); for (int i = 1; i <= n; i++) scanf("%d", &x[i]); sort(x + 1, x + n + 1); // 先将所有隔间位置排序 int l = 0, r = x[n] - x[1]; // 设置二分的起始区间 int ans; // 记录最后一次可行的最近距离 while (l <= r) { int mid = (l + r) / 2; if (check(mid)) { // 如果这个最近距离可行,尝试更大的情况 l = mid + 1; ans = mid; } else { // 如果这个最近距离不可行,尝试更小的情况 r = mid - 1; } } printf("%d\n", ans); return 0; }
例题:P1873 [COCI2011-2012#5] EKO / 砍树
有
棵树,每棵树的高度分别为 ,对于一个砍树高度 ,可以将每棵树上比 高的部分的木材锯下并收集起来(不高于 的部分保持不变),现在要求最大的整数高度 ,使得能够收集到长度至少为 的木材。
分析:如果锯子高度设得很低,可以收集到的木材会非常多,以至于超过需要的数量。随着砍树高度逐渐变大,获得的木材会逐渐减少。砍树高度增加到一定程度时,收集到的木材会开始变得不够。因此需要找到最大的
令“条件”表示“当砍树高度为
证明了“条件”的单调性以后,问题就转化成了:如何判断“条件”是否成立,即当砍树高度为
#include <cstdio> const int N = 1e6 + 5; int a[N], n, m; bool check(int h) { // 检验当砍树高度为h时,能否收集到至少m的木材 int s = 0; for (int i = 1; i <= n; i++) { if (a[i] > h) { s += a[i] - h; // 按照题意模拟 if (s >= m) return true; // 收集够了则表示可行 } } return false; } int main() { scanf("%d%d", &n, &m); int r = 0; for (int i = 1; i <= n; i++) { scanf("%d", &a[i]); if (a[i] > r) r = a[i]; } int l = 0; // 二分的左端点是0,右端点是最大的树高(答案不可能比最高的树还高) int ans = 0; while (l <= r) { int mid = (l + r) / 2; if (check(mid)) { // 如果check(mid)为真,说明mid是一个可行解,还可以尝试更高的砍树高度 l = mid + 1; ans = mid; } else { // 如果check(mid)为假,应该尝试更低的砍树高度 r = mid - 1; } } printf("%d\n", ans); return 0; }
这里每次 check
的时间复杂度为
使用二分答案的条件:
1.问题可以被归纳为找到使得某条件成立(或不成立)的最大(或最小)的 。
2.把看作一个值为真或假的函数,那么它一定在某个分界线的一侧全为真,另一侧全为假。
3.可以找到一个高效的算法来检验的真假。
通俗地说,二分答案可以用来处理“最大的最小”或“最小的最大”类问题。
例题:P1083 [NOIP2012 提高组] 借教室
分析:直接模拟题意很容易实现,从第一份订单开始处理每一份订单,针对每一个订单的时间区间,把每一天的剩余教室都减去相应的数量,如果某一次减完那一天剩余教室数变负数了,则可以提前结束并输出相应结果。这样做时间复杂度为
参考代码
#include <cstdio> const int N = 1e6 + 5; int r[N]; int main() { int n, m; scanf("%d%d", &n, &m); for (int i = 1; i <= n; i++) { scanf("%d", &r[i]); } int ans = 0; for (int i = 1; i <= m; i++) { int d, s, t; scanf("%d%d%d", &d, &s, &t); bool flag = false; for (int j = s; j <= t; j++) { r[j] -= d; if (r[j] < 0) { flag = true; break; } } if (flag) { printf("-1\n"); ans = i; break; } } printf("%d\n", ans); return 0; }
我们在前面学过,如果有多个区间操作进行叠加,可以用差分数组的思想来模拟。利用差分数组,我们可以把每一份订单的影响变成两次单点操作,但是如果我们要在订单完成后检验是否出现教室不够的现象依然需要对差分数组求一遍前缀和,这样总的时间复杂度还是
注意到题目让我们求的是第一个会导致教室不够的订单,也就是说订单处理得越多,教室越容易不够。这样一来就具备了一种单调性,我们可以二分要处理前
参考代码
#include <cstdio> using ll = long long; const int N = 1e6 + 5; int r[N], d[N], s[N], t[N], n, m; ll b[N]; // 差分数组 bool check(int x) { // 检验将前x份的订单完成后是否会出现教室不够的情况 for (int i = 1; i <= n + 1; i++) b[i] = 0; // 清空差分数组 for (int i = 1; i <= x; i++) { b[s[i]] += d[i]; b[t[i] + 1] -= d[i]; // 差分思想模拟区间更新 } for (int i = 1; i <= n; i++) { b[i] += b[i - 1]; // 对差分数组求前缀和还原出原数组 if (b[i] > r[i]) return true; } return false; } int main() { scanf("%d%d", &n, &m); for (int i = 1; i <= n; i++) { scanf("%d", &r[i]); } for (int i = 1; i <= m; i++) { scanf("%d%d%d", &d[i], &s[i], &t[i]); } int ans = 0; int l = 1, r = m; while (l <= r) { int mid = (l + r) / 2; if (check(mid)) { r = mid - 1; ans = mid; } else { l = mid + 1; } } if (ans != 0) printf("-1\n"); printf("%d\n", ans); return 0; }
习题:P2440 木材加工
解题思路
每根小段木头越短,能切出来的总段数就越长,越能够满足
特别地,如果每根原木的长度直接加起来都达不到
#include <cstdio> #include <algorithm> using std::max; const int N = 1e5 + 5; int a[N], n, k; bool check(int x) { int cnt = 0; for (int i = 1; i <= n; i++) { cnt += a[i] / x; if (cnt >= k) return true; } return false; } int main() { scanf("%d%d", &n, &k); int r = 1; for (int i = 1; i <= n; i++) { scanf("%d", &a[i]); r = max(r, a[i]); } int l = 1, ans = 0; while (l <= r) { int mid = (l + r) / 2; if (check(mid)) { l = mid + 1; ans = mid; } else { r = mid - 1; } } printf("%d\n", ans); return 0; }
习题:P2678 [NOIP2015 提高组] 跳石头
解题思路
非常类似于 P1824 进击的奶牛,只不过那题是算出某种间距下最多可以安置多少头牛,这题是算出最少需要移走多少块石头。但是要注意的是,终点是独立于石头的一个单独的位置,需要把最后一跳也考虑进来。
#include <cstdio> const int N = 5e4 + 5; int d[N], L, n, m; bool check(int dis) { int cnt = 0; // 需要移除多少块石头 int pre = 0; for (int i = 1; i <= n + 1; i++) { // 因为考虑终点,所以是n+1 if (d[i] - pre >= dis) { // 跳到下一块石头满足间距要求 pre = d[i]; } else { // 不满足间距要求,需要移除这块石头 cnt++; if (cnt > m) return false; // 需要移除的石头超限了 } } return true; } int main() { scanf("%d%d%d", &L, &n, &m); for (int i = 1; i <= n; i++) { scanf("%d", &d[i]); } d[n + 1] = L; // 注意最后还有一次跳到终点的过程 int l = 1, r = L, ans = 1; while (l <= r) { int mid = (l + r) / 2; if (check(mid)) { l = mid + 1; ans = mid; } else { r = mid - 1; } } printf("%d\n", ans); return 0; }
习题:P1182 数列分段 Section II
解题思路
考虑两个极端情况,如果每个数自成一段,此时每段和最大值就是最大的那个数,如果总共只有一段,此时每段和最大值就是所有数加起来。这里我们可以得到一个单调性:对每段和设一个上限
那么这个分段数的计算只需要从左往右依次处理即可,当累加和超出判断的上限时,做一次分段并从新的一段开始重新计算累加和,以此类推。
#include <cstdio> #include <algorithm> using std::max; const int N = 1e5 + 5; int a[N], n, m; bool check(int x) { // 检验在分段和上限为x的情况下分段数是否可以<=m int s = 0; int cnt = 0; // 至少需要的分段数 for (int i = 1; i <= n; i++) { if (s + a[i] <= x) { s += a[i]; } else { // 在a[i-1]和a[i]之间划一刀 cnt++; s = a[i]; } } cnt++; // 不要忘了最后一段 return cnt <= m; } int main() { scanf("%d%d", &n, &m); int l = 0, r = 1e9; for (int i = 1; i <= n; i++) { scanf("%d", &a[i]); l = max(l, a[i]); } int ans = r; while (l <= r) { int mid = (l + r) / 2; if (check(mid)) { r = mid - 1; ans = mid; } else { l = mid + 1; } } printf("%d\n", ans); return 0; }
习题:P8800 [蓝桥杯 2022 国 B] 卡牌
解题思路
显然要凑的套数越多越难凑,答案满足单调性性质,可以二分答案。
二分要凑的套数,如果某种牌的初始数量不够需要的套数,就用空白牌补充对应的数量,通过空白牌的消耗数量来判断能否凑出这么多套。注意二分答案的边界。
#include <cstdio> #include <algorithm> using ll = long long; using std::max; const int N = 2e5 + 5; int a[N], b[N], n; ll m; bool check(int x) { ll rest = m; for (int i = 1; i <= n; i++) { if (x - a[i] > b[i]) return false; int need = max(x - a[i], 0); rest -= need; if (rest < 0) return false; } return true; } int main() { scanf("%d%lld", &n, &m); for (int i = 1; i <= n; i++) scanf("%d", &a[i]); for (int i = 1; i <= n; i++) scanf("%d", &b[i]); int l = 0, r = n * 2, ans = 0; // 注意二分的边界,答案的最大情况是一开始已经有n套 // 额外的m张空白牌最多还能提供n套 while (l <= r) { int mid = (l + r) / 2; if (check(mid)) { l = mid + 1; ans = mid; } else { r = mid - 1; } } printf("%d\n", ans); return 0; }
例题:CF1486D Max Median
分析:二分答案
如果一个区间的中位数
将
对于每一个区间右端点,可以找到长度
参考代码
#include <cstdio> #include <algorithm> using std::sort; using std::min; const int N = 200005; int n, k, a[N], tmp[N], pre[N]; bool check(int mid) { for (int i = 1; i <= n; i++) { if (a[i] < mid) { tmp[i] = tmp[i - 1] - 1; } else { tmp[i] = tmp[i - 1] + 1; } pre[i] = min(pre[i - 1], tmp[i]); } for (int i = k; i <= n; i++) if (tmp[i] - pre[i - k] > 0) return true; return false; } int main() { scanf("%d%d", &n, &k); for (int i = 1; i <= n; i++) { scanf("%d", &a[i]); } int l = 1, r = n, ans = n; while (l <= r) { int mid = (l + r) / 2; if (check(mid)) { l = mid + 1; ans = mid; } else { r = mid - 1; } } printf("%d\n", ans); return 0; }
习题:P1314 [NOIP2011 提高组] 聪明的质监员
解题思路
当
于是问题转化为当固定
#include <cstdio> #include <algorithm> using std::max; using std::min; using ll = long long; const int N = 2e5 + 5; int sw[N], l[N], r[N], n, m, w[N], v[N]; ll sv[N]; ll calc(int W) { // 计算当设置参数为W时y的结果 for (int i = 1; i <= n; i++) { // 根据本次的W预处理两个前缀和 sw[i] = sw[i - 1] + (w[i] >= W); sv[i] = sv[i - 1] + (w[i] >= W) * v[i]; } ll res = 0; for (int i = 1; i <= m; i++) { // 利用前缀和快速求出每个检验区间的检验值 res += (sw[r[i]] - sw[l[i] - 1]) * (sv[r[i]] - sv[l[i] - 1]); } return res; } int main() { ll s; scanf("%d%d%lld", &n, &m, &s); int L = 0, R = 0; for (int i = 1; i <= n; i++) { scanf("%d%d", &w[i], &v[i]); R = max(R, w[i]); } R++; // 理论上当W等于最大的w+1时,检验值之和为0,因为此时一定选不出有效的矿石 for (int i = 1; i <= m; i++) { scanf("%d%d", &l[i], &r[i]); } ll ans = s; while (L <= R) { int mid = (L + R) / 2; ll tmp = calc(mid); if (tmp >= s) { L = mid + 1; ans = min(ans, tmp - s); } else { R = mid - 1; ans = min(ans, s - tmp); } } printf("%lld\n", ans); return 0; }
例题:P3743 小鸟的设备
分析:对于这个问题而言,首先需要想到一个贪心策略:因为充电宝可以无缝切换地给任意一个设备充电,因此当某个设备电还没用完的时候是可以不管它的。于是就发现了问题的一个单调性:时间越短,需要充电的设备就越少,时间越长则需要充电的设备就越多。而充电能力是有限的,因此要找的是这样一个时间,在它之前充电宝足够让每个设备都有电,超过这个时间则会有某个设备开始没电。有这个单调性的存在,我们就可以对答案(使用时间)进行二分,那么问题就转化成了在指定时间内判定是否能让每个设备都有电。
根据每个设备的耗电速度
特别地,当充电宝的充电能力足够抵消每一个设备的耗电时,可以无限使用这些设备,输出
注意这是一个实数域上的二分答案问题,因此二分部分的代码框架与整数域略有不同。
#include <cstdio> const int N = 1e5 + 5; const double EPS = 1e-6; int a[N], b[N], n, p; bool check(double t) { double rest = p; for (int i = 1; i <= n; i++) { double ti = 1.0 * b[i] / a[i]; // 注意a[i]和b[i]都是int类型 if (ti < t) { rest -= (a[i] - b[i] / t); // 注意double类型如何判断<0 if (rest < -EPS) return false; // 充电宝的充电能力不够分了 } } return true; } int main() { scanf("%d%d", &n, &p); bool ok = true; // 假设能给所有设备都充电 int rest = p; for (int i = 1; i <= n; i++) { scanf("%d%d", &a[i], &b[i]); if (ok && rest >= a[i]) { rest -= a[i]; } else { ok = false; // 说明不可能无限充电 } } if (ok) { printf("-1\n"); } else { // 注意右端点并不是1e5,而是要比1e10大一点 // 因为考虑 a[i]-b[i]/t 这个式子,如果a[i]很小,b[i]很大,则t可以达到1e10级别 double l = 0, r = 1e10 + 1; while (r - l > EPS) { // 控制答案精度 double mid = (l + r) / 2; if (check(mid)) { l = mid; } else { r = mid; } } printf("%.6f\n", l); } return 0; }
二分的次数和精度有关,但是考虑每次二分的区间都可以缩小一半,缩减的速度还是很快的,因此也是对数级别。实数二分与整数二分有一些微妙的区别,需要确认好精度。比如本题答案的判定是基于误差不超过万分之一,因此我们可以将控制精度的量设到 1e-6
以确保精度足够但又不会计算过多。实际上,如果精度误差控制得过分小,反而可能会导致超时。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 终于写完轮子一部分:tcp代理 了,记录一下
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理