学会二分法,有这一篇就够啦!

简介: 本文由blue撰写于2024年9月,深入讲解二分法这一基础但不简单的算法。文章从二分法的两大经典应用场景——二分查找与二分答案出发,详细解析其原理与实现。通过实例代码(如LeetCode第704题)和竞赛题目,探讨了不同区间定义(左闭右闭、左闭右开)下的实现方式,并延伸到寻找目标值首次/最后出现位置及二分答案的实际应用。适合初学者系统掌握二分法的核心思想与技巧。

学会二分法,有这一篇就够啦!

作者:blue

时间:2024.9

二分法是大家耳熟能详的基础算法,但是二分法,可没有你想像的那么简单,本篇将从二分法的两个经典利用场景——二分查找和二分答案,来讲懂二分法。

二分法应用在一串有序的序列中,查找一个我们所需要的值,注意一定是有序的序列。

比如说两个人玩猜数字游戏,1-10000,A心中想一个数,B来猜,B每次猜一个数,A都会回答他大了还是小了,这样B每次都可以折半的缩小查找的区间,这样就比直接把数从1数到10000要快很多。

1.二分查找

很多教学都让大家背二分法的模板,我不这样认为,因为二分法是一个非常好理解的算法,所以用不着背模板,而是要理解算法的过程知道代码是怎么写的,这样才能熟练的掌握二分法。

首先:二分法代码不能乱来,一定要根据你的区间来敲

我推荐的写法是根据左闭右闭区间的方法来写,下面是二分法的左闭右闭区间的代码

while(left<=right)  //这里是你的循环条件,left<=right,这就意味着left和rigth都是包含在我们可取的一个范围之内
{
    int mid=(left+right)/2;  //那我们这里算出mid,如果mid的值不符合条件,那么mid就不应该包含在我们接下来我们
    if(target<nums[mid]) right=mid-1;//循环的区间中,所以应该是mid-1或mid+1,这样才能将mid排除在区间之外
    else if(target>nums[mid]) left=mid+1;//我们的区间才符合我们设置的左闭右闭区间的规则
    else return mid;
 }

再次强调:你进入while循环的条件一定要符合你的区间,也就是说你更新的区间一定要与你一开始查找的区间类型一致。

你一开始是[left,right](左闭右闭),你更新完还得是左闭右闭;!!!

有了固定好区间的思想,你在写二分法的时候,才不会搞不懂,到底是应该left<=right,还是left<right,其实都是可以的,只是后者是左闭右开区间,对应的区间变化,也应该符合左闭右开区间的规则。

接下来我们来看例题:

704. 二分查找 - 力扣(LeetCode)

这是二分查找的模板题,大家可以先点击链接自己做一遍再回来看我的代码。

我在这里给出了左闭右闭,和左闭右开区间的两种写法,如果对此前讲解有不理解的同学可以结合以下代码再体会以下。

左闭右闭:

class Solution {
   
public:
    int search(vector<int>& nums, int target) {
   
        int left=0,right=nums.size()-1;//左闭右闭
        while(left<=right)
        {
   
            int mid=(left+right)/2;
            if(target<nums[mid]) right=mid-1;
            else if(target>nums[mid]) left=mid+1;
            else return mid;
        }
        return -1;
    }
};

左闭右开:

class Solution {
   
public:
    int search(vector<int>& nums, int target) {
   
        int left=0,right=nums.size();//左闭右开,为什么这里是左闭右开,因为nums.size()是取不到的下标
        while(left<right) 
        {
   
            int mid=(left+right)/2;
            if(target<nums[mid]) right=mid; //由于是左闭右开,所以mid可以包含在开区间中,不用mid-1
            else if(target>nums[mid]) left=mid+1;
            else return mid;
        }
        return -1;
    }
};

如果你成功的用两种不同的区间,写出了这道题,那么恭喜你,搞明白了区间与while循环判断条件的关系,接下来就要看一些比较特殊的二分查找:

1.二分查找元素第一次出现的位置

由于我们查找的有序序列元素可能还是重复的,那我们应该如何去找第一个符合要求元素出现的位置呢?请看如下解释:

代码中用ans来表示查找到元素的位置,与原来二分查找不同的是,这里找到目标值后并不直接退出,而是你要找在这个已经找到的目标值的位置的左边还有没有target,所以用先ans记住这个位置,然后r=mid-1查找左边还有没有目标值,这样就可以找到元素第一次出现的值了。

int ans=-1;
int l=1,r=n;//左闭右闭 
while(l<=r)
{
    int mid=(r+l)/2;
    if(a[mid]<target) l=mid+1;
    else if(a[mid]>target) r=mid-1;
    else if(a[mid]==target){
        ans=mid;
        r=mid-1;
    }
}
cout<<ans<<" ";

2.二分查找元素最后一次出现的位置

查找元素最后一次出现的位置也很简单,那就是先用ans记住当前位置,然后l=mid+1 查找右边还有没有目标值,这样就可以找到元素最后一次出现的位置。

int ans=-1;
scanf("%lld",&target);
int l=1,r=n;//左闭右闭 
while(l<=r)
{
   
    int mid=(r+l)/2;
    if(a[mid]<target) l=mid+1;
    else if(a[mid]>target) r=mid-1;
    else if(a[mid]==target){
   
        ans=mid;
        l=mid+1;
    }
}
cout<<ans<<" ";

以上的两个类型在后面有比较大的应用,请读者细细品味,掌握了再往下看

2.二分答案

其实二分答案和二分查找中找元素第一次出现的位置,和最后一次出现的位置的思想非常的像!

它具有这种典型的特征:求...最大值的最小 、 求...最小值的最大

只要你搜索的答案是一个单调有序的区间

我们直接通过例题来讲

P1873 [COCI 2011/2012 #5] EKO / 砍树 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)

题目大意:让我们找到一个尽可能高的高度(因为这样砍的树少),然后得到至少等于M的木材

这题我们对答案(也就是砍树的高度)进行一个二分搜索(只要这个高度下所得到木材>=M,那他就是一个合法的高度),题目的要求是你找到一个符合条件的答案,并不是直接输出这个答案,而是让这个值尽量大,所以你找到在这道题中 ans>=m 都是符合条件的,所以你每找到一个符合条件的,就先把他记录下来,然后再往他的右边找,那就是 l=mid+1;

#include <iostream>
using namespace std;
using ll=long long;
const int N=1e7+10;
ll a[N];
int main()
{
   
    ll n,m;
    cin>>n>>m;//M是目标木材数
    ll ans=-1;
    ll l=0x3f3f3f3f,r=0;
    for(int i=1;i<=n;i++) //边输入边找左右区间,显然最小的高度是最矮的树,最大的高度是最高的树
    {
   
        cin>>a[i];
        l=min(l,a[i]);
        r=max(r,a[i]);
    }
    //左闭右闭 
    while(l<=r)
    {
   
        ll res=0;
        int mid=l+(r-l)/2;//防止爆int
        for(int i=1;i<=n;i++){
   //算出这个高度下能获得的木材数
            if(a[i]>mid) res+=(a[i]-mid);
        }
        if(res>=m){
      //尽量高,所以要找符合的最后出现的位置 ,所以先用ans记录合法位置,再往右找看看能不能
            ans=mid; //有更高的位置
            l=mid+1;
        }
        else if(res<m){
    //说明太高了,不够,那只能降低高度
            r=mid-1;
        }
    }
    cout<<ans;
    return 0;
}

再来一道题:

P2440 木材加工 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)

题目大意:把n根木头,切成k段,长为l的木头,使这个l尽量大

我们可以二分l的长度,看看合法的l中最长的是哪一个,详细看代码中的注释

#include <iostream>
#define int long long
using namespace std;
const int N=1e5+10;
int a[N];
signed main()
{
   
    int n,k;
    int ans=0;
    int l=1,r=0;//左区间l=1,题目中描述如果连 1cm 长的小段都切不出来,输出 0。
    cin>>n>>k; //所以最后查找不到的话,就输出0即可
    for(int i=1;i<=n;i++)
    {
   
        cin>>a[i];
        r=max(a[i],r);//最长的段自然是只可能是最长的木材
    }
    while(l<=r)//左闭右闭
    {
   
        int res=0;
        int mid=l+(r-l)/2;//防止爆int,mid就是我们设置的切的段长
        for(int i=1;i<=n;i++){
   
            res+=a[i]/mid;  //看看每根木头能切几段这样的木材
        }
        if(res>=k){
     //合法,看看能不能更长 
            ans=mid;
            l=mid+1; 
        }
        else r=mid-1; //不合法,太长了,要短一点 
    }
    cout<<ans;
    return 0;
}

恭喜你已经做到这里了,我们趁热打铁来看看两道竞赛真题,这两题都有非二分的做法,但我个人认为二分的做法更易于理解

0冶炼金属 - 蓝桥云课 (lanqiao.cn)

题目大意:炉子有一个称作转换率的属性 VV 是一个正整数,这意味着消耗 V 个普通金属 O 恰好可以冶炼出一个特殊金属 X

现有N 条冶炼记录

每条记录中包含两个整数 AB,这表示本次投入了 A 个普通金属 O,最终冶炼出了 B 个特殊金属 X

(每条记录都是独立的,这意味着上一次没消耗完的普通金属 O 不会累加到下一次的冶炼当中。)

根据这 N 条冶炼记录,请你推测出转换率 V 的最小值和最大值

#include <iostream>
#define int long long
using namespace std;
const int N=1e4+10;
int A[N],B[N];
signed main()
{
   
  int n;
  int ans_min,ans_max;
  int MIN=0x3f3f3f3f;//上界
  cin>>n;
  for(int i=1;i<=n;i++)
  {
   
    cin>>A[i]>>B[i];
    MIN=min(MIN,A[i]/B[i]);//为什么我们要找一个最小的转换率来当右边界 
  }                        //因为如果你找的是一个最大的转换率,那么其他记录A个金属O,可能炼制不出B个金属X
                         //但你找最小的,至少可以保证每条记录都是合法的
  int l=1,r=MIN;//左闭右闭,找最小值,即符合要求后往左边找
  while(l<=r)
  {
   
    int flag=1;
    int mid=l+(r-l)/2;
    for(int i=1;i<=n;i++)//检测mid这个转换率是否合格
    {
   
      if((A[i]/mid)>B[i]) {
   flag=2;break;} //转换率太小当向右
      else if((A[i]/mid)<B[i]) {
   flag=3;break;}//转换率太大当向左
    }
    if(flag==1){
   ans_min=mid;r=mid-1;}
    else if(flag==2) {
   l=mid+1;}
    else if(flag==3) {
   r=mid-1;}
  }
  cout<<ans_min<<" ";
  //其实接下来这段代码可以不写,为什么,因为我们在一开始找右边界的时候,其实就已经找到了一个最大的,能让所有记录都合法的边界值了。
  /*l=1;r=MIN;
  while(l<=r)//同理,找最大值
  {
    int flag=1;
    int mid=l+(r-l)/2;
    for(int i=1;i<=n;i++)
    {
      if((A[i]/mid)>B[i]) {flag=2;break;}
      else if((A[i]/mid)<B[i]) {flag=3;break;}
    }
    if(flag==1){ans_max=mid;l=mid+1;}
    else if(flag==2) {l=mid+1;}
    else if(flag==3) {r=mid-1;}
  }
  cout<<ans_max<<" ";*/
  cout<<MIN;
  return 0;
}

0卡片 - 蓝桥云课 (lanqiao.cn)

小蓝有 k 种卡片, 一个班有 n 位同学, 小蓝给每位同学发了两张卡片, 一 位同学的两张卡片可能是同一种, 也可能是不同种, 两张卡片没有顺序。没有 两位同学的卡片都是一样的。

给定 n, 请问小蓝的卡片至少有多少种?

根据题目,我们不难看出牌的种数可以组成的不同的方案为、

(1,1) (1,2) (1,3) (1,4) (1,5)
(2,2) (2,3) (2,4) (2,5)
(3,3) (3,4) (3,5)
(4,4) (4,5)
(5,5)
牌的种类,可以构成的牌组合的总数,其实是一个等差数列
比如说有3种牌
那就可以构成 [(1+3)*3]/2=6,6种不同的组合,就可以分给6位同学。

所以题目给定同学的数量,我们就找最少几种牌,构成的组合总数,可以容纳这些同学,
这样就把问题演变成了,查找最少种类牌,那就可以用到二分答案
#include <iostream>
#define int long long
using namespace std;
signed main()
{
   
  int n;
  int ans;
  cin>>n;
  int l=1,r=1e9;
  while(l<=r)
  {
   
    int mid=(l+r)/2;
    int s=(mid+1)*mid/2;
    if(s>=n)
    {
   
      ans=mid;r=mid-1;
    }
    else l=mid+1;
  }
  cout<<ans;
  return 0;
}
目录
相关文章
|
存储 消息中间件 缓存
9 个 FastAPI 的必知资源
FastAPI 是 Python 开发人员最新、最流行的 API 框架之一。我们的工程师一次又一次需要将一个或多个第三方库与我们的 API 结合使用,以附加额外的功能和特性来丰富我们的项目。
1464 0
|
6月前
|
传感器 物联网 生物认证
【免费开源】基于STM32的智慧门禁系统设计与实现(附源码)
基于STM32的智慧门禁系统,整合了RFID、密码、指纹等多种身份验证方式,实现门锁的智能化控制。通过模块化设计,系统易扩展,可接入更多智能设备,如远程监控、访客记录上传云端等。该项目不仅适用于小型办公场所、社区门禁,也可作为智能家居控制系统的一部分,具有良好的推广价值和实用性。
【免费开源】基于STM32的智慧门禁系统设计与实现(附源码)
|
3月前
|
Python Windows
Miniconda 安装与环境配置全流程图解(2025 最新版)
Miniconda 可以看作是 Anaconda 的“轻装版”,只自带 conda 包管理器与基础的 Python 运行时。它体积小、部署速度快,特别适合按需创建与管理虚拟环境的用户。与 Anaconda 相比,Miniconda 不会预先安装一大堆科学计算库,你可以根据项目需求再单独选择、安装需要的包,因此整体更轻巧、更灵活。 本文将手把手演示在 Windows 下安装 Miniconda 的全过程:从下载安装器、完成向导配置、设置环境变量,到最后的基础验证与简单示例,帮助你迅速把 Miniconda 用起来。
3211 12
|
11月前
|
数据库 C++
【数据结构进阶】红黑树超详解 + 实现(附源码)
本文深入探讨了红黑树的实现原理与特性。红黑树是一种自平衡二叉搜索树,通过节点着色(红/黑)和特定规则,确保树的高度接近平衡,从而实现高效的插入、删除和查找操作。相比AVL树,红黑树允许一定程度的不平衡,减少了旋转调整次数,提升了动态操作性能。文章详细解析了红黑树的性质、插入时的平衡调整(变色与旋转)、查找逻辑以及合法性检查,并提供了完整的C++代码实现。红黑树在操作系统和数据库中广泛应用,其设计兼顾效率与复杂性的平衡。
2502 3
|
7月前
|
Ubuntu Linux iOS开发
SVN、TortoiseSvn下载及安装
SVN、TortoiseSvn下载及安装
3757 0
|
安全 Java 程序员
Java面试必问!run() 和 start() 方法到底有啥区别?
在多线程编程中,run和 start方法常常让开发者感到困惑。为什么调用 start 才能启动线程,而直接调用 run只是普通方法调用?这篇文章将通过一个简单的例子,详细解析这两者的区别,帮助你在面试中脱颖而出,理解多线程背后的机制和原理。
751 12
|
机器学习/深度学习 人工智能 自然语言处理
一周打完1000场官司,中科院发布首个AI法庭AgentCourt!
【9月更文挑战第27天】中国科学院近日发布了名为AgentCourt的人工智能法庭技术,引发广泛关注。该技术可在一周内完成1000场官司的审理,有望显著提升司法效率,减少人为干扰,但同时也面临质疑,如是否能准确理解案件复杂性及背后的伦理、隐私和安全等问题。支持者认为它有助于提高判决公正性和一致性,而反对者则担忧其可能导致司法过程机械化,忽视人文因素。AgentCourt在自然语言处理和知识图谱构建方面展现了最新进展。论文详情见:https://doi.org/10.48550/arXiv.2408.08089
332 9
|
SQL 运维 安全
WAF如何防御SQL注入?
【7月更文挑战第25天】WAF如何防御SQL注入?
1221 9
|
存储 算法 Java
【DFS(深度优先搜索)详解】看这一篇就够啦
本文介绍了深度优先搜索(DFS)算法及其应用。DFS从某个顶点出发,深入探索图的每条路径,直到无法前进为止,然后回溯。文章详细解释了DFS的基本思想,并通过示例图展示了其执行过程。此外,文中还探讨了三种枚举方式:指数型枚举、排列型枚举和组合型枚举,并提供了具体的代码实现。最后,文章通过几道练习题帮助读者更好地理解和应用DFS算法。
10733 19
【DFS(深度优先搜索)详解】看这一篇就够啦