算法基础—二分算法
目录
一、⼆分查找例题
1 牛可乐和魔法封印
2 A-B 数对
3 烦恼的高考志愿
二、 ⼆分答案
1 木材加⼯
2 砍树
3 跳石头
⼆分算法的原理以及模板其实是很简单的,主要的难点在于问题中的各种各样的细节问题。因此,⼤多数情况下,只是背会⼆分模板并不能解决题目,还要去处理各种乱七⼋糟的边界问题。
【案例】
题⽬来源: ⼒扣
题⽬链接: 34. 在排序数组中查找元素的第⼀个和最后⼀个位置
难度系数: ★★
【题⽬描述】
给你⼀个按照⾮递减顺序排列的整数数组 nums ,和⼀个⽬标值 target 。请你找出给定⽬标值
在数组中的开始位置和结束位置。
如果数组中不存在⽬标值 target ,返回 [-1, -1] 。
你必须设计并实现时间复杂度为 O(log n) 的算法解决此问题
【解法】
解法一:暴力解法->从前往后扫描数组(慢在没有利用数组有序的特征)
解法二:二分算法
解决时的细节问题(因题目而异):
1.while循环里面的判断如何写:while(left <right)/while(left <=right);
2.求中点的方法:(left + right)/2 / (left + right+1)/2;
3.二分结束后,相遇点的情况:需要判断一下,循环结束后,是否是我们想要的结果;
class Solution { public: vector<int> searchRange(vector<int>& nums, int target) { int n = nums.size(); //处理边界情况 if(n == 0) return {-1,-1}; //1.求起始位置 int left = 0,right = n - 1; while(left < right) { int mid = (left + right) / 2; if(nums[mid] >= target) right = mid; else left = mid + 1; } if(nums[left] != target) return{-1,-1}; int retleft = left; //2.求终止位置 left = 0,right = n-1; while(left < right) { int mid = (left + right + 1) / 2; if(nums[mid] <= target) left = mid; else right = mid - 1; } return {retleft,left}; } };
【算法原理】
当我们的解具有⼆段性时,就可以使⽤⼆分算法找出答案:
- 根据待查找区间的中点位置,分析答案会出现在哪⼀侧;
- 接下来舍弃⼀半的待查找区间,转⽽在有答案的区间内继续使⽤⼆分算法查找结果
【模板】
⼆分的模板在⽹上⾄少能搜出来三个以上。但是,我们仅需掌握⼀个,并且⼀直使⽤下去即可。
// ⼆分查找区间左端点
int l = 1, r = n;
while(l < r)
{
int mid = (l + r) / 2;
if(check(mid)) r = mid;
else l = mid + 1;
}
// ⼆分结束之后可能需要判断是否存在结果
/ ⼆分查找区间右端点
int l = 1, r = n;
while(l < r)
{
int mid = (l + r + 1) / 2;
if(check(mid)) l = mid;
else r = mid - 1;
}
// ⼆分结束之后可能需要判断是否存在结果
为了防止溢出,求中点时可以使用:mid = left + (right - left) / 2
【⼆分问题解决流程】
1. 先画图分析,确定使⽤左端点模板还是右端点模板,还是两者配合⼀起使⽤;
2. ⼆分出结果之后,不要忘记判断结果是否存在,⼆分问题细节众多,⼀定要分析全⾯。
【STL 中的⼆分查找】
<algorithm>
1. lower_bound :⼤于等于 x 的最⼩元素,返回的是迭代器;时间复杂度: O(log N) 。
2. upper_bound :⼤于 x 的最⼩元素,返回的是迭代器。时间复杂度: O(log N) 。
⼆者均采⽤⼆分实现。但是 STL 中的⼆分查找只能适⽤于"在有序的数组中查找",如果是⼆分答案就不能使⽤。因此还是需要记忆⼆分模板
一、⼆分查找例题
1 牛可乐和魔法封印
题⽬来源: ⽜客⽹
题⽬链接: ⽜可乐和魔法封印
难度系数: ★★
【解法】
解法一:暴力解法->从前往后扫描一遍(O(n*q))
解法二:二分算法
1.找到大于等于x的起始位置
2.找到小于等于y的终止位置
【参考代码】
#include<iostream>
using namespace std;
const int N = 1e5;
int a[N];
int n,q;
int main()
{
cin >> n;
for(int i = 1;i <= n;i++) cin >> a[i];
cin >> q;
while(q--)
{
int x,y;cin >> x >> y;
//大于等于x的最小元素
int left = 1,right = n;
while(left < right)
{
int mid = (left + right) / 2;
if(a[mid] >= x) right = mid;
else left = mid + 1;
}
if(a[left] < x){
cout << 0 << endl;
continue;
}
int retleft = left;
//小于等于y的最大元素
left = 1,right = n;
while(left < right)
{
int mid = (left + right + 1) / 2;
if(a[mid] <= y) left = mid;
else right = mid - 1;
}
if(a[left] > y)
{
cout << 0 << endl;
continue;
}
cout << left - retleft + 1 << endl;
}
return 0;
}
2 A-B 数对
题⽬来源: 洛⾕
题⽬链接:A-B 数对
难度系数: ★
【解法】
分析可得到:
性质一:元素的顺序是不影响最终结果的
- 可以先把整个数组排序
- 把A-B=C变形成B=A-C通过枚举A,然后查找有多少个B
【参考代码】
#include<iostream>
#include<algorithm>
using namespace std;
typedef long long LL;
const int N = 2e5 + 10;
LL a[N];
LL n,c;
int main()
{
cin >> n >> c;
for(int i = 1;i <= n;i++)cin >> a[i];
sort(a+1,a+n+1);
LL ret = 0;
for(int i = 2;i <=n;i++)
{
LL b = a[i] - c;
ret += upper_bound(a+1,a+i,b) - lower_bound(a+1,a+i,b);
}
cout << ret << endl;
return 0;
}
//其实此题的最优解是哈希表 题解如下:
// #include<iostream>
// #include<unordered_map>
// using namespace std;
// const int N = 2e5 + 10;
// typedef long long LL;
// LL a[N];
// unordered_map<LL,LL> mp;
// LL sum;
// int main(){
// LL n,c;cin >> n >> c;
// for(LL i = 1;i <= n;i++){
// cin >> a[i];
// mp[a[i]]++;
// }
// for(int i = 1;i <= n;i++){
// sum += mp[a[i] + c];
// }
// cout << sum << endl;
// return 0;
// }
3 烦恼的高考志愿
题⽬来源: 洛⾕
题⽬链接: P1678 烦恼的⾼考志愿
难度系数: ★★
【解法】
分析题意:给点一个数b,然后在数组中找出离b最近的那个数
解法一:利用set来解决
解法二:排序+二分(找出大于等于B的最小的元素的位置)
【参考代码】
#include<iostream>
#include<algorithm>
using namespace std;
typedef long long LL;
const int N = 1e5 + 10;
LL a[N];
int m,n;
LL ret;
int main()
{
cin >> m >> n;
for(int i = 1;i <= m;i++) cin >> a[i];
sort(a,a+m+1);
a[0] = -1e7 + 10;
for(int i = 1;i <= n;i++)
{
LL x;cin >> x;
int left = 1,right = m;
while(left < right)
{
int mid = (left + right) / 2;
if(a[mid] >= x) right = mid;
else left = mid + 1;
}
ret += min(abs(a[left]-x),abs(a[left-1]-x));
}
cout << ret << endl;
return 0;
}
二、 ⼆分答案
准确来说,应该叫做「⼆分答案 + 判断」。
⼆分答案可以处理⼤部分「最⼤值最⼩」以及「最⼩值最⼤」的问题。如果「解空间」在从⼩到⼤的「变化」过程中,「判断」答案的结果出现「⼆段性」,此时我们就可以「⼆分」这个「解空间」,通过「判断」,找出最优解。
刚接触的时候,可能觉得这个「算法原理」很抽象。没关系, 道题的练习过后,你会发现这个「⼆分答案」的原理其实很容易理解,重点是如何去「判断」答案的可⾏性
1 木材加⼯
题⽬来源: 洛⾕
题⽬链接: P2440 ⽊材加⼯
难度系数: ★★
【解法】
解法一:暴力解法(这个解法的时间复杂度O(n*L)->1e13,会超时)
- 枚举所有的切割长度x
- 求出在x的情况下,能切出来多少段->C
- 找出C>=k的情况下,最大的x
解法二:利用二分来优化
x表示:切割出来的小段长度
c表示:在x的基础下,最多能切出来多少段
k表示:最终要切出来的段数
根据题意,可以发现如下性质,:
- 当 x 增⼤的时候, c 在减⼩。也就是最终要切成的⻓度越⼤,能切的段数越少;
- 当 x 减⼩的时候, c 在增⼤。也就是最终要切成的⻓度越⼩,能切的段数越多。
那么在整个「解空间」里面,设最终的结果是 ret ,于是有:
- 当x ≤ ret 时,c ≥ k 。也就是「要切的⻓度」⼩于等于「最优⻓度」的时候,最终切出来的段数「⼤于等于」k;
- 当 x > ret时,c < k 。也就是「要切的⻓度」⼤于「最优⻓度」的时候,最终切出来的段数「⼩于」k ;
在解空间中,根据 的位置,可以将解集分成两部分,具有「⼆段性」,那么我们就可以「⼆分答案」。
当我们每次⼆分⼀个切成的⻓度 x 的时候,如何算出能切的段数 c ?
- 很简单,遍历整个数组,针对每⼀根⽊头,能切成的段数就是 a[i] / x 。
【参考代码】
#include<iostream>
using namespace std;
typedef long long LL;
const int N = 1e5 + 10;
LL a[N];
int n,k;
//当切割长度为mid的时候,最多能切出来多少段
LL calc(int mid)
{
LL c = 0;
for(int i = 1;i <= n;i++)
{
c += a[i] / mid;
}
return c;
}
int main()
{
cin >> n >> k;
LL m = 0;
for(int i = 1;i <= n;i++)
{
cin >> a[i];
m = max(m,a[i]);
}
LL left = 0,right = m;
while(left < right)
{
LL mid = (left + right + 1) / 2;
if(calc(mid) >= k) left = mid;
else right = mid - 1;
}
cout << left << endl;
return 0;
}
2 砍树
题⽬来源: 洛⾕
题⽬链接:P1873 [COCI 2011/2012 #5] EKO / 砍树
难度系数: ★★
【解法】
H表示:伐木机的高度
C表示:当伐木机的高度为h的时候,所能获得的木材
- 当 H 增⼤的时候, C 在减⼩;
- 当 H 减⼩的时候, C 在增⼤。
那么在整个「解空间」⾥⾯,设最终的结果是 ret ,于是有:
当H<=ret 时 ,C>=M 。也就是「伐⽊机的⾼度」⼤于等于「最优⾼度」时,能得到的⽊材「⼩于等于」M ;
当H>ret 时,C < M 。也就是「伐⽊机的⾼度」⼩于「最优⾼度」时,能得到的⽊材「⼤于」M 。
在解空间中,根据 的位置,可以将解集分成两部分,具有「⼆段性」,那么我们就可以「⼆分答案」。
当我们每次⼆分⼀个伐⽊机的⾼度 H 的时候,如何算出得到的⽊材 C ?
- 很简单,遍历整个数组,针对每⼀根⽊头,能切成的⽊材就是 a[i] - H
【参考代码】
#include<iostream>
using namespace std;
typedef long long LL;
const int N = 1e6 + 10;
LL a[N];
int n,m;
// 当伐木机的高度为 x 时,所能获得的木材
LL calc(int x)
{
LL c = 0;
for(int i = 1;i <= n;i++)
{
if(a[i] > x) c += a[i] - x;
}
return c;
}
int main()
{
cin >> n >> m;
LL maxh = 0;
for(int i = 1;i <= n;i++){
cin >> a[i];
maxh = max(maxh,a[i]);
}
LL left = 1,right = maxh;
while(left < right)
{
LL mid = (left + right + 1) / 2;
if(calc(mid) >= m) left = mid;
else right = mid - 1;
}
cout << left << endl;
return 0;
}
3 跳石头
题⽬来源: 洛⾕
题⽬链接: P2678 [NOIP2015 提⾼组] 跳⽯头
难度系数: ★★
【解法】
x表示:最短的跳跃距离
c表示:在跳跃距离为x的情况下,移走的岩石
根据题意,我们可以发现如下性质:
- 当 x 增⼤的时候, c 也在增⼤;
- 当 x 减⼩的时候, c 也在减⼩。
那么在整个「解空间」⾥⾯,设最终的结果是 ret ,于是有:
- 当 时x ≤ ret,c ≤ M。也就是「每次跳的最短距离」⼩于等于「最优距离」时,移⾛的⽯头块数「⼩于等于」M ;
- 当 时x > ret,c > M。也就是「每次跳的最短距离」⼤于「最优距离」时,移⾛的⽯头块数「⼤于」 M。
在解空间中,根据ret 的位置,可以将解集分成两部分,具有「⼆段性」,那么我们就可以「⼆分答案」。
当我们每次⼆分⼀个最短距离 x 时,如何算出移⾛的⽯头块数 c ?
- 定义前后两个指针 i, j 遍历整个数组,设 i ≤ j ,每次 j 从 i 的位置开始向后移动;
- 当第⼀次发现 a[j] - a[i] ≥ x 时,说明 [i + 1, j - 1] 之间的⽯头都可以移⾛;
- 然后将 i 更新到 j 的位置,继续重复上⾯两步。
【参考代码】
#include<iostream>
using namespace std;
const int N = 5e4 + 10;
int a[N];
int l,n,m;
//当最短跳跃距离为x时,移走的岩石数目
int calc(int x)
{
int ret = 0;
for(int i = 0;i <= n;i++)
{
int j = i + 1;
while(j <= n && a[j] - a[i] < x) j++;
ret += j - i -1;
i = j - 1;
}
return ret;
}
int main()
{
cin >> l >> n >> m;
for(int i = 1;i <= n;i++) cin >> a[i];
a[n+1] = l;
n++;
int left = 1,right = l;
while(left < right)
{
int mid = (left + right + 1) / 2;
if(calc(mid) <= m) left = mid;
else right = mid - 1;
}
cout << left << endl;
return 0;
}