深入浅出程序设计竞赛(洛谷基础篇) 第十三章 二分查找与二分答案
目录
- 前言
- 例13-1 有序序列中查找元素
- 二分查找有序数组中>=num的最左位置
- 二分查找有序数组中<=num的最右位置
- 例13-2 A-B数对
- 前置知识:
- 例13-3 砍树
- 前置知识
- 例13-4 进击的奶牛
- 前置知识
- 例13-5 一元三次方程求解
- 习题13-3 烦恼的高考志愿
- 习题13-4 木材加工
- 习题13-5 跳石头
- 习题13-6 路标设置
- 前置知识:
- 习题13-7 数列分段 Section II
- 习题13-8 银行贷款
- 总结
前言
本文介绍了二分查找的思想,并且给予二分的思想,我们对于单调可分的问题可以使用二分答案的方法解决,比如最小最大值,最大最小值等等问题。
电子版教材链接
:
我通过百度网盘分享的文件:深入浅出程序设计…pdf
链接:https://pan.baidu.com/s/1kmF8wZLnK3Zci7s1ffjRzw
提取码:Ra3Q
复制这段内容打开「百度网盘APP即可获取」
例13-1 有序序列中查找元素
#include <bits/stdc++.h>
using namespace std;
#define MAXN 10000005
int n,a[MAXN],q,m;
int find(int x)
{int l = 1,r = n;while(l<=r){int mid = (l+r)/2; // 求中点值,下取整if(a[mid] == x) return mid; else if(a[mid]>x) r = mid-1; // 取左区间else l = mid + 1; // 取右区间}return -1;
}int main()
{cin >> n >> m;for(int i = 1;i<=n;i++)cin >> a[i];while(m--){cin >> q;cout << find(q) << " ";}return 0;
}
二分查找有序数组中>=num的最左位置
接下来要优化算法,使得算法可以实现下面的内容
- 如果待查询的数字有多个,则需要输出最小的编号
- 如果不存在这个数字,则输出比它大的数字中最小的数字编号
- 如果没有比它大的数字,则输出n+1
- 如果遇到了重复的数字,这个算法需要直接输出最先找到的编号
int find_left(int x)
{int l = 1, r = n + 1; // r=n+1 以便当所有 a[i] < x 时返回 n+1while (l < r) {int mid = l + (r - l) / 2;if (a[mid] >= x)r = mid; // 缩小右边界,尝试找到更靠左的合法位置elsel = mid + 1; // 当前值太小,必须往右找}return l; // l 是第一个满足 a[l] >= x 的位置
}
如果数组的第一个元素索引是0的话可以使用下面的这个套路模板
int findleft(int arr[],int num)
{int l = 0,r = arr.length()-1,m = 0;int ans = -1;while(l<=r){//m = (l+r)/2;m = l + (r-l)/2; // 防溢出机制,也是拿出区间中点的值if(arr[m] >= num){ans = m; // 找到就更新到左区间继续查找r = m-1;}else{l = m+1; // 没找到就更新到右区间进行查找}}return flase;
}
二分查找有序数组中<=num的最右位置
int find_right(int x)
{int l = 1, r = n + 1; // r = n+1 是为了处理边界:如果所有值 <= x,返回 nwhile (l < r) {int mid = l + (r - l) / 2;if (a[mid] <= x)l = mid + 1; // 尝试向右找更大的 <= x 的值elser = mid; // a[mid] > x,不合法,缩小右边界}return l - 1; // 因为最终 l 是第一个 > x 的位置,所以 l-1 就是最后一个 <= x 的位置
}
如果数组的第一个元素索引是0的话可以使用下面的这个套路模板
int findRight(int arr[],int num)
{int l = 0,r = arr.length()-1,m = 0;int ans = -1;while(l<=r){//m = (l+r)/2;m = l + (r-l)/2; // 防溢出机制,也是拿出区间中点的值if(arr[m] <= num){ans = m; // 找到就更新到右区间继续查找l = m+1;}else{r = m-1; // 没找到就更新到左区间进行查找}}return flase;
}
例13-2 A-B数对
前置知识:
lower_bound
:
lower_bound(begin,end,val)表示在值有序的数组连续地址 [begin,end) 中找到第一个位置并且返回其地址,使得val插入在这个位置前面,整个数组依然保持有序
upper_bound
:
upper_bound(begin,end,val)标志在值有序的数组连续地址 [begin,end) 中找到最后一个位置并且返回其地址,使得val插入在这个位置前面,整个数组依然保持有序
我们要计算出所有的A-B = C的数对的个数(A和B是数列中的数,C是一个输入的值),实际上可以就算B+C 出现了多少次,我们枚举数列,找到数列中连续的一段的长度,每次的枚举量进行总和的添加就是答案
我们的目的显然就是枚举数列的每一个数字时,要找到连续区间内的左端点和右端点,显然我们可以使用上面的lower_bound
和upper_bound
函数来找到两个端点的索引,它们之间的差值就是区间的长度
#include <bits/stdc++.h>
using namespace std;
#define maxn 200010
typedef long long LL; // 把long long 替换为LL以节约打字时间
LL a[maxn];
int n,c;
int main()
{scanf("%d%d",&n,&c);for(int i = 0;i<n;i++)scanf("%lld",&a[i]);sort(a,a+n);LL tot = 0;for(int i = 0;i<n;i++)tot += upper_bound(a,a+n,a[i]+c) - lower_bound(a,a+n,a[i]+c);printf("%lld",tot);return 0;
}
我们可以进一步优化:
由于每次查找都从头开始,显然如果前一个连续区间已经被查找了,接下来我们就没有重复查找这个区间的必要性了,所以我们维护跟随移动的指针
这样我们可以把算法复杂度降到O(n)级别的
#include <bits/stdc++.h>
using namespace std;
typedef long long LL;
#define maxn 200010
LL a[maxn];
int main()
{scanf("%d%d",&n,&c);for(int i = 0;i<n;i++)scanf("%lld",&a[i]);sort(a,a+n);LL tot = 0;for(int i = 0,L = 0,R = 0;i<n;i++){while(L<n && a[L] < a[i] +c)L++; // 相当于lower_bound 找到第一个a[L] >= a[i] + c的位置while(R<n && a[R] <= a[i] + c) R++; // 相当于upper_bound 找到第一个a[R] > a[i] + c的位置tot += R-L;}printf("%lld",tot);return 0;
}
例13-3 砍树
前置知识
常用到的二分答案模板,在区间[L,R]中查找答案
int Find(int L,int R)
{int ans,mid;while(L<=R) // 闭区间上的二分结束条件{int mid = L + R >> 1;if(P(mid)) // 条件成立ans = mid,R = mid - 1;// 这里需要记录满足条件的mid,最后循环一定会结束,也一定会在ans中保留正确的答案elseL = mid+1; }return ans;
}
本题需要调整锯子切割的高度,使得可以有满足条件长度的木材被切割出来,我们可以发现有答案单调性,收集木材数量和锯子高度是成反比的,我们需要找到一个x满足上述条件,条件为当砍树高度为x时可以获取不少于m的木材
,当x超过某个数时条件一定不成立,不超过这个数时,条件一定成立,这符合我们的二分答案法
#include <bits/stdc++.h>
using namespace std;
#define maxn 1000010
typedef long long LL;
LL a[maxn],n,m;bool check(int h) // 当砍树高度为h时,能否得到大于m的木材
{LL tot = 0;for(int i = 1;i<=n;i++)if(a[i]>h)tot += a[i] - h; // 按照题意进行模拟return tot >= m;
}int main()
{scanf("%lld%lld",&n,&m);for(int i = 1;i<=n;i++)scanf("%lld",&a[i]);int L = 0,R = 1e7,ans,mid; // R的初始值需要大一些,因为最大的数据范围是1e7 最大砍树高度 R = max(a[i])while(L<=R){if(check(mid = L+R >> 1)) ans = mid,L = mid+1;// 如果check(mid)为真,则mid可以是答案,真正的答案可能在mid右侧,所以左端点右移else R = mid - 1;}printf("%d",ans);return 0;
}
例13-4 进击的奶牛
前置知识
最大最小值:指的是有多种解决方法的决策,每种决策都有最小值,但是我们要得到所有决策最小值中的最大值
以该题为例,本题样例点有很多种决策,决策对应的最小值分别是1,2,3显然我们要的答案就是3
先构建判断条件:
可以把c头牛全部安置在这些隔间使相邻两头牛距离不超过x
我们发现,当x越小时,就越可能把所有牛合法安置,当x比较大的时候,牛棚就不够进行安置的了,所以存在一个分界线ans,x大于ans就没有合法的安置方案,x小于或者等于ans时,则一定存在合法安置方案,要找到ans作为答案
当我们尝试某个间距 ans
时,如果可以成功地放下 c
头牛,那么所有比 ans
小的间距一定也可以放;所有比 ans
大的间距未必可以放,所以临界值就是ans
本题还用到了贪心的思想
限制为:任意两个相邻安置点距离不能小于x
所以从最左端开始,每隔超过x的距离,能安置就安置,可以证明安置一定比不安置更优,最后只需要看遍历了所有点之后总共安置了多少头牛即可
#include <bits/stdc++.h>
using namespace std;
#define maxn 1000010int a[maxn],n,c;bool check(int d)
{int k = 0,last = 1e9; // last记录上一头牛的安置坐标for(int i = 1;i<=n;i++)if(a[i] - last >= d) // 能安置就安置last = a[i],k++;return k>=c;
}int main()
{scanf("%d%d",&n,&c);for(int i = 1;i<=n;i++)scanf("%d",&a[i]);sort(a+1,a+1+n);int L = 0,R = a[n]-a[1],ans,mid;while(L<=R){if(check(mid = L+R >> 1))ans = mid,L = mid+1; // 尝试在右区间继续查找答案else R = mid-1;}printf("%d",ans);return 0;
}
例13-5 一元三次方程求解
由于任意两个只差不小于1,所以长度为1的区间最多只有一个解,将[-100,100]这个区间分为200份区间大小为1的子区间,然后对这个子区间使用零点定理,并且我们发现一个规律
当有一个端点和中点的正负性相同时,零点会在中点与另一个区间的端点上
#include <bits/stdc++.h>
using namespace std;
double A,B,C,D;
double f(double x)
{return A*x*x*x + B*x*x + C*x +D;
}
int main()
{cin >> A >> B >> C >> D;for(int i = -100;i<=100;i++){double L = i,R = i+1,mid; // 只处理区间[L,R]if(fabs(f(L)) < 1e-4) // 表示浮点数f(L)近似为0(第一个点就是0点)printf("%.2lf",L);else if(fabs(f(R)) < 1e-4) // 防止当前区间的右端点和下一个区间的左端点有重叠continue;else if(f(L)*f(R) < 0){ // 在(L,R)上有根,执行二分while(R-L > 1e-4) // R近似为L的时候为终止条件{mid = (L+R) / 2;if(f(mid) * f(R) > 0)R = mid; // 如果f(mid)和f(R)正负性相同,则零点在mid左侧elseL = mid; // 否则在另一侧}printf("%.2lf",L);}}return 0;
}
习题13-3 烦恼的高考志愿
先将学校的估计分数线进行从小到大的排序,使用一个for循环依次输入学生的成绩,使用二分查找函数lower_bound查找第一个大于等于d的索引,然后特判一下边界条件,最后比较当前值和第一个大于等于d的元素的差值与当前值和最后一个小于它的数的差值之间的大小关系,取小的值加入累加ans值中
#include<bits/stdc++.h>
using namespace std;
int a,b,c[100002],d,e,f,g,h,i;
long long ans;
int main()
{cin>>a>>b;for(i=1;i<=a;i++)cin>>c[i];sort(c+1,c+a+1);//先排序一下for(i=1;i<=b;i++){cin>>d;e=lower_bound(c+1,c+a+1,d)-c;//返回查询到的位置if(e==a+1)ans+=d-c[a];//特判比所有数都大的情况elseif(e==1)//特判比所有数都小的情况ans+=c[1]-d;elseans+=min(abs(c[e]-d),abs(d-c[e-1]));//前者表示 d 与第一个大于或等于它的数的距离,后者表示 d 与最后一个小于它的数的距离。}cout << ans << endl;return 0;
}
习题13-4 木材加工
本题我们使用二分查找的方法,明显如果长度l比较小,一定能分割为k段,但是如果长度l比较大,明显就会造成非法状态
#include <bits/stdc++.h>
using namespace std;
long long n,k,res = 0;
long long a[1000005];bool check(long long x)
{long long ans = 0;for(int i = 1;i<=n;i++){ans += a[i]/x;}return ans >= k;
}int main()
{cin >> n >> k;for(int i = 1;i<=n;i++)cin >> a[i];long long l = 1,r = 100000000,mid = 0;while(l<=r){mid = (l+r)/2;if(check(mid)) res = mid,l = mid+1;else r = mid-1;}cout << res << endl;return 0;
}
习题13-5 跳石头
我们二分跳跃距离,然后把这个跳跃距离“认为”是最短的跳跃距离,然后去以这个距离为标准移石头。使用一个judge判断这个解是不是可行解。如果这个解是可行解,那么有可能会有比这更优的解,那么我们就去它的右边二分,显然再右边的值肯定比左边大,那么我们就有可能找到比这更优的解,直到找不到,那么最后找到的解就有理由认为是区间内最优解。反过来,如果二分到的这个解是一个非法解,我们就不可能再去右边找了。因为性质,右边的解一定全都是非法解。那么我们就应该去左边找解judge函数每个题有每个题的写法,但大体上的思想应该都是一样的——想办法检测这个解是不是合法。拿这个题来说,我们去判断如果以这个距离为最短跳跃距离需要移走多少块石头,先不必考虑限制移走多少块,等全部拿完再把拿走的数量和限制进行比对,如果超出限制,那么这就是一个非法解,反之就是合法的解
注意我们是中间有n块石头,最后还要补上终点位置的参数a[n+1]
(起点不用补是因为起点离起点的距离是0,没有讨论的必要)
#include <bits/stdc++.h>
#define maxn 500010
using namespace std;
int d,n,m;
int a[maxn];
int l,r,mid,ans;bool judge(int x)
{int tot = 0;int i = 0; // i代表下一块石头的编号int now = 0; // 标记当前的位置while(i<n+1){i++;if(a[i] - a[now] < x)tot ++ ; // 判定成功,拿走石头else now = i;}if(tot > m) // 至多移走,可以只移走m块以下的石头return 0;else return 1;
}int main()
{cin >> d; // d表示总长度cin >> n;cin >> m;for(int i= 1;i<=n;i++)cin >> a[i];a[n+1] = d; // 终点的位置l = 1,r = d;while(l<=r){mid = (l+r) / 2;if(judge(mid)) {ans = mid, l = mid+1;}else r = mid-1;}cout << ans << endl;return 0;
}
习题13-6 路标设置
前置知识:
最小最大距离:先得到所有决策中的最大距离,再找到所有决策中最小的那个最大距离
本题和上一题跳石头有异曲同工之妙,上面是移除石头,下面是添加路标,本质上的原理都是一样的
#include <bits/stdc++.h>
using namespace std;
int n,k,d,ans = 0;
int a[100005];
bool check(int x)
{int ret = 0;if(x == 0) return 0;for(int i = 1;i<n;i++)ret += (a[i+1]-a[i]-1) /x; // 若a[i+1] = 4,a[i] = 1,则有1 2 3 4 1和4的索引是1和4,但是里面有两个元素,所以要使用a[i+1]-a[i] -1 才能表示1和5之中的间隙return ret <= k;
}
int main()
{cin >> d >> n >> k;for(int i = 1;i<=n;i++)cin >> a[i];int l = 1,r = d;while(l<=r){int mid = (l+r) / 2;if(check(mid)) ans = mid,r = mid-1;else l = mid+1;}cout << ans << endl;return 0;
}
习题13-7 数列分段 Section II
求解一个区间和的最小最大值,本题已知要分的段数,也是和上面两题一样的处理方法,每次都相加然后分段,达到要求及时check函数的1状态,否则就是0状态
#include <bits/stdc++.h>
using namespace std;int n, m;
long long a[100005], l = 0, r = 0, ans = 0;bool check(long long x)
{long long tot = 0, num = 1; // num表示段数for(int i = 1; i <= n; i++){if(tot + a[i] <= x) tot += a[i]; // 加入当前段else {tot = a[i]; // 开始新段num++; // 段数加1}}return num <= 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]; // 最大段和是整个序列的和}while(l <= r){long long mid = (l + r) >> 1;if(check(mid)) ans = mid, r = mid - 1; // mid可行,尝试更小的else l = mid + 1; // mid不可行,尝试更大的}cout << ans << endl;return 0;
}
习题13-8 银行贷款
#include<bits/stdc++.h>
using namespace std;
double n,m,k,l,r;
bool check(double x){return (pow(1.0/(1.0+x),k) >= 1-n/m*x);
}
int main(){cin>>n>>m>>k;l=0;r=10;//月利率可能大于1while(r-l>=0.0001){double mid=(l+r)/2;if(check(mid))r=mid;else l=mid;}cout<<fixed<<setprecision(1)<<l*100;//输出一位小数哦 return 0;
}
总结
本文介绍了二分查找和二分答案的方法,二分答案的大部分题都遵循一套模板,希望读者可以在读完本文之后,自己寻找相关题型进行训练