你真的懂二分吗:从蓝桥杯真题看“何时用”比“如何写”更重要

一、基本二分查找

① 引言

相信大家对二分算法肯定不陌生,毕竟不管学习哪一个编程语言,最先接触算法时一定会接触到"二分查找"算法

对于二分查找的代码而言,其实是比较容易实现的。但你有没有发现,不管是"面试",还是各种"算法竞赛",二分都是避不开的,几乎必考的知识点。而二分的代码既然容易实现,又为什么会那么高频的出现考题中呢?

接下来,不管你是"刚刚学会基础二分查找",还是"能够熟练二分查找左右端点",我都将用四道蓝桥杯真题,一步步为你解析"为什么二分考点频频出现,应该如何想到使用二分?"


如果不知道二分算法是什么的话,可以先看一下我之前的一篇"二分算法详解",然后再来看这篇~
🚪传送门: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;
    }
}

那么这篇关于二分算法的分析到这里就结束啦~作者能力有限,如果有哪里说的不准确或者不够清晰,还请大家在评论区多多提醒,让我们一起学习!

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值