一、基本二分查找
① 引言
相信大家对二分算法肯定不陌生,毕竟不管学习哪一个编程语言,最先接触算法时一定会接触到"二分查找"算法
对于二分查找的代码而言,其实是比较容易实现的。但你有没有发现,不管是"面试",还是各种"算法竞赛",二分都是避不开的,几乎必考的知识点。而二分的代码既然容易实现,又为什么会那么高频的出现考题中呢?
接下来,不管你是"刚刚学会基础二分查找",还是"能够熟练二分查找左右端点",我都将用四道蓝桥杯真题,一步步为你解析"为什么二分考点频频出现,应该如何想到使用二分?"
如果不知道二分算法是什么的话,可以先看一下我之前的一篇"二分算法详解",然后再来看这篇~
🚪传送门:Java算法-一文搞懂二分查找(朴素二分和左右端点二分)_java 二分查找法-优快云博客
② 朴素二分查找
① 暴力枚举法:
从头到尾遍历数组,直到找到目标元素,停止遍历,返回元素下标。
时间复杂度:O(n)
空间复杂度:O(1)
这个方法不可取,因为查找一个元素的时间复杂度太高。
比如这个数组中,想要查找8,直接查找的话,需要遍历数组。
② 二分查找法:
通过二分查找的方式进行查找目标元素。
时间复杂度:O(logn)
空间复杂度:O(1)
使用左右两个端点来取中间值mid
因为是有序数组,所以mid对应值小于目标值时,代表mid及其左侧位置都不符合要求,右侧同理。这样就能一次性删除掉"两端范围内的一半数据"。
class Solution {
public int search(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
while(left <= right){
int mid = left + (right - left) / 2;//防止溢出
if(nums[mid] == target){
return mid;
}else if(nums[mid] > target){
right = mid - 1;
}else {
left = mid + 1;
}
}
return -1;
}
}
③ 二分查找目标元素左右端点
证明起来细节还是很多的,不会的话可以直接去上面的传送门了解一下,这边就直接展示代码模板了。
📖 查找目标元素左端点:
public int searchLeft(int[] arr, int num) {
int left = 0;
int right = arr.length - 1;
while(left < right){
int mid = left + (right - left) / 2;
if(arr[mid] >= num){
right = mid;
}else {
left = mid + 1;
}
}
if(arr[left] == num){
return left;
}
return -1;
}
📖 查找目标元素右端点:
public int searchRight(int[] arr, int num) {
int left = 0;
int right = arr.length - 1;
while(left < right){
int mid = left + (right - left + 1) / 2;
if(arr[mid] <= num){
left = mid;
}else {
right = mid - 1;
}
}
if(arr[left] == num){
return left;
}
return -1;
}
二、如何联想到二分?
如果大家做过一些算法题的话,其实不难发现,大部分考察二分的题目中,并不会明显的体现出"二分算法"的相关知识。并且通常也不是直接给你一个有序数组,或者告诉你要去有序序列中查找什么。
而想要通过一道题直接联想到二分,还是一件比较困难的事情的。所以最合适的方法其实是"先考虑暴力的解决问题,然后再对暴力的思路进行优化"。
① 函数单调
在上面,我们将"数组有序"作为二分查找的基本条件。而现在,让我们发散思维:
如果我们设定一个函数为 f(x),那么为了符合"二分"的条件,它一定要具有某个"单调性":
② 01单调
在日常做题中,我们并不一定是查找一个数组,甚至都不一定是查找某一个序列,但不管查找的是什么,我们只需要确保当前查找的"函数",也具有某种"单调性"即可。
而当我们要查找的对象并不是一个"整数数组",或者不是"整数序列"时,函数图像就很难再像上述一样表述出来了,因为我们只能通过"mid"去判断 f(mid) 是否是一个可行方案,这个返回值可能就只能代表"true"或"false"。
那么此时,我们将"true"标志为"1",将"false"标志为"0",新的函数图像就可以这样表示:
当然,即便是如此抽象的"01单调",也不影响它具有"单调性"!只要具有单调性,就可以二分~
比如此时给你一个 " 方案函数 " -> f(x) ,给你一些条件 -> x,问可行方案中能够满足最多条件的条件个数为多少?
按常理来说,我们会一个个条件的加上去,然后判断是否为一个可行方案。这思路不错,这就是"暴力解法"。而在解题过程中,如果我们发现时间复杂度过高而导致代码超时,那么我们就可以考虑这个"方案函数"是否存在"01单调",如果存在,就可以采取以下做法:
那么我们只需要 设置 left = 1(一个条件),right = max(所有条件),然后取 mid,通过 f(mid) 来判断当前条件个数是否是一个"可行方案",是则返回"1",反之返回"0"。这样不断二分查找,最后找出"1"的右端点即可~
那么究竟如何在做题的时候想到二分,并且实现二分的优化呢?一起做个例题练练手吧~
③ [蓝桥杯 2017 省AB] 分巧克力
📚 题目解析:
📕 通过题目我们可以知道:
现在有 K 个小朋友,并且有 N 块巧克力(每块都是由不同的 长 和 宽 组成的长方形)
我们要从 N 块巧克力中切出 K 块巧克力(大于K也行,保证每个小朋友都能分到即可)
目标是:切出大小全部相等的"正方形巧克力",并且要求在足够分的情况下,巧克力尽可能大。
首先,我们先要想清楚,一块巧克力中按照边长为 " i " 的规律,如何求出能切出的个数:
比如此时我们有一个大小为 "6 * 5" 的巧克力,目标切出 "(边长)c = 2" 的正方形巧克力,那我们应该确定:
一行能切几块:"长方形的长最多允许切几块巧克力:6 / c = 3"
一共能切几行:"长方形的宽最多允许切几块巧克力:5 / c = 2"
所以能够切出的巧克力个数就是:3 * 2 = 6块
我们要知道,如果一个边长不行,那么比他大的肯定也不行,同理,一个边长可以,那么比他小的边长肯定也可以。(01单调!!!)
而此时要求我们找到,足够小朋友分,并且边长最大的巧克力切法,暴力来看的话,就可以从"切出边长为1","切出边长为2"......一个个进行尝试,并且判断是否能够满足"切出的个数足够小朋友分",不断枚举直到找到最大的边长。
那么刚刚我们已经知道"分巧克力"具有"01单调",想要对暴力进行优化我们就可以定义一个 check(mid) 函数,传入正方形边长,返回当前边长是否合法,然后二分即可~
📖 代码示例:
import java.util.*;
public class Main {
public static int n;
public static int k;
public static int[] h;
public static int[] w;
public static void main(String[] args){
Scanner in = new Scanner(System.in);
n = in.nextInt();
k = in.nextInt();
h = new int[n + 1];
w = new int[n + 1];
for(int i = 1;i <= n;i++){
h[i] = in.nextInt();
w[i] = in.nextInt();
}
int l = 1;
// r取决于数据范围,这里的长方形边长最大为1e5,我们要选大于1e5的边界
int r = 100005;
//取"01单调"中,1的最右端点
while(l < r){
int mid = l + (r - l + 1) / 2;
if(check(mid) == 0){
r = mid - 1;
}else {
l = mid;
}
}
System.out.println(l);
}
//传入切出巧克力的边,判断是否足够小朋友分
public static int check(int c){
long sum = 0;
for(int i = 1;i <= n;i++){
sum += ((h[i] / c) * (w[i] / c));
}
return sum >= k ? 1 : 0;
}
}
三、真题练习
① [蓝桥杯 2022 国B] 卡牌
📚 题目解析:
有一套牌组,每张牌都有特定的牌号和数量,如果将每张牌取出一张,那么就认定这是"一套牌"。
同时我们还有一些空白牌,可以把它当作任意牌使用。并且它对每张牌使用空白牌都有个数限制。
还是和上一题一样,我们先考虑 " 如何判断牌组是否能凑出?套牌 ":
而同样的,这题也满足:当前要凑的套数不能凑出,则更多的套数也无法凑出;当前要凑的套数能够凑出,则更少的套数也能够凑出(01单调)
所以直接实现 check(mid) 方法,然后二分即可~
📖 代码示例:
import java.util.*;
public class Main {
public static int n;
public static long m;
public static int[] a;
public static int[] b;
public static void main(String[] args){
Scanner in = new Scanner(System.in);
n = in.nextInt();
m = in.nextLong();
a = new int[n + 1];
b = new int[n + 1];
for(int i = 1;i <= n;i++){
a[i] = in.nextInt();
}
for(int i = 1;i <= n;i++){
b[i] = in.nextInt();
}
int l = 1;
//a,b最大值为2n,最多能分4n套
int r = 800010;
while(l < r){
int mid = l + (r - l + 1) / 2;
if(check(mid) == 0){
r = mid - 1;
}else {
l = mid;
}
}
System.out.println(l);
}
public static int check(int mid){
long sum = 0;
for(int i = 1;i <= n;i++){
// 足够,直接跳过
if(a[i] >= mid) continue;
// 如果(允许使用的空白牌数 不足以将牌凑到mid个,则失败)
if(b[i] < mid - a[i]) return 0;
sum += mid - a[i];
if(sum > m) return 0;
}
return 1;
}
}
② [蓝桥杯 2022 省B] 求阶乘
这题看着是不是很简单?别高兴的太早,看看数据范围:
📚 题目解析:
很容易理解的问题,我们只需要找到 "结尾有 K个0 的 N!(最小的N)" ,输出N即可。
从这个数据来看,暴力枚举 N! 肯定是不能通过所有测试案例了,那我们来思考一下,N! 后面有几个 0 取决于什么因素:
实际上,想要产生 0 (10的倍数),其实只取决于数据中的 (2 和 5) 的组合数量。
而在 N! 中,2的数量总是大于5的数量,所以其实只取决于数据(阶乘因子)中 5 的数量。
让我们稍微证明一下:
我们逐个分析一下:
5! = 1 * 2 * 3 * 4 * 5 有一个5
10! = 1 * 2 * 3 * 4 * 5 * 6 * 7 * 8 * 9 * 10 (2 * 5) 有两个5
15! = 1 * 2 * ... * 9 * (2 * 5) * ... * 14 * (3 * 5) 有三个5
20! = 1 * 2 * ... * 9 * (2 * 5) * ... * 14 * (3 * 5) * ... * 19 * (4 * 5) 有四个5
这里就要有些不同了,同时这也是解题的重点!!!
25! = 1 * 2 ... * 19 * (4 * 5) * ... * 24 * (5 * 5) 有六个5
所以我们求有 N! 中有多少个5时,是求每个数的因子中5的个数总和~
此时我们可以把问题抽象一下:我们拥有 k 个0,现在我们需要用这 k个0 去组成一个 N!,如果的k足够,则代表可以组成这个 N! ,如果k不足够,则代表组成不了 N!,而我们想要得到的结果也就变成了 "k个0能够组成的 N! ,N的最小值 ",而因为我们要得到 "N的最小值",为了得到这个最小值,我们应该把"正好耗尽k个0"的情况也设置为"false",使它在右边,更便于查找目标值。
我们可以设定一个 check(mid) 函数,来确定传入的 mid 变成的 mid! 的末尾0个数,如果末尾的0个数 < k,则使它充当"01单调"中的1,如果末尾的0的个数 >= k,则充当"01单调"中的0。
📖 代码示例:
import java.util.*;
public class Main {
public static long k;
public static void main(String[] args){
Scanner in = new Scanner(System.in);
k = in.nextLong();
long l = 1;
long r = Long.MAX_VALUE;
while(l < r){
long mid = l + (r - l) / 2;
if(check(mid) < k){
l = mid + 1;
}else {
r = mid;
}
}
System.out.println(check(l) == k ? l : -1);
}
public static long check(long mid){
long sum = 0;
while(mid != 0){
sum += mid / 5;
mid /= 5;
}
return sum;
}
}
③ [蓝桥杯 2023 省B] 冶炼金属
📚 题目解析:
这题就能明显感觉到,和之前的题不太一样了。具体哪里不一样呢?
是的,这题里我们要求的值有两个,一个 V 可能的最大值,一个 V 可能的最小值。
而在上面的问题中,都只是问一个最大值或者一个最小值。
这样看确实也不一样,但是实际上,也是一样的!我们分别求最大值和最小值,不就行了嘛~
那么想要求 V 的最大值和最小值,就需要知道 V的上界和下界都为多少:
然而,由于每一次的冶炼的数据都不同,也就代表估计出的 V 的范围也不同,想要得到所有数据都合理的"最大值V"和"最小值V",首先得确保这两个值在所有的冶炼数据下都符合条件,就比如:此时我们取V为100,有10组数据,求最小值时,前九次都符合[V > A/(B + 1)],而第十次不符合,那么这个100也是不合法的。
而想要让它合法,肯定要选取更大的V,使得 V 能够大于第十次的数据(A / (B + 1))。
那么由此案例,我们又能得出查找最小的V时,符合"01单调":
当此时的 V 不符合 [V > A/(B + 1)],那么比 V 小的值也不符合。
如果此时的 V 符合 [V > A/(B + 1)],那么比 V 大的值也都符合。
同样的,求最大值 V 也是如此,由此思路,我们就能写出两个"01单调",分别查找最大最小了~
📖 代码示例:
import java.util.*;
public class Main {
public static long k;
// [A / V] = B
// B <= A/V < B+1
// 1/(B + 1) < V/A <= 1/B
// A/(B + 1) < V <= A/B
public static int n;
public static long[] a;
public static long[] b;
public static void main(String[] args){
Scanner in = new Scanner(System.in);
n = in.nextInt();
a = new long[n + 1];
b = new long[n + 1];
for(int i = 1;i <= n;i++){
a[i] = in.nextLong();
b[i] = in.nextLong();
}
long l1 = 0;
long r1 = 10000000010l;
while(l1 < r1){
long mid = l1 + (r1 - l1) / 2;
if(checkMax(mid) == 1){
r1 = mid;
}else {
l1 = mid + 1;
}
}
long l2 = 0;
long r2 = 100000000010l;
while(l2 < r2){
long mid = l2 + (r2 - l2 + 1) / 2;
if(checkMin(mid) == 1){
r2 = mid - 1;
}else {
l2 = mid;
}
}
System.out.println(l2+1 + " " + l1);
}
//获取最大值(A/B的左端点)
public static int checkMax(long mid){
for(int i = 1;i <= n;i++){
if(mid >= a[i] / b[i]){
return 1;
}
}
return 0;
}
//获取最小值(A/(B+1)的右端点)
public static int checkMin(long mid){
for(int i = 1;i <= n;i++){
if(mid <= a[i] / (b[i] + 1)){
return 0;
}
}
return 1;
}
}
那么这篇关于二分算法的分析到这里就结束啦~作者能力有限,如果有哪里说的不准确或者不够清晰,还请大家在评论区多多提醒,让我们一起学习!