首页 > 其他分享 >详解二分查找

详解二分查找

时间:2024-06-09 23:34:22浏览次数:16  
标签:二分 二分法 right 指向 int mid 查找 详解 left

二分法详解

大家好,我是Weekoder!

这是我的第一篇文章,如果有做的不好的地方,请见谅,我会尽力改正。

本文中的图片截取于网络视频,非恶意搬运。

二分法,是一个高效的算法,查找一个数的时间复杂度只需要\(O(\log n)\),大大优化了朴素算法(从头到尾地遍历)\(O(n)\)的线性复杂度。稍后我会对它的对数复杂度做分析。

举个例子,当你要在一个长度为\(2\times 10^9\)(20亿)的数组中里查找一个数时,朴素算法\(O(n)\)的复杂度肯定会超时,更别说去寻找多个数了。但如果使用二分法进行查找,查找一次只需大约运行30次!真是恐怖的差别。

那么,到底该怎么实现二分法,实现二分法又有什么条件呢?这是我们接下来要解决的问题。

二分法的理论概念

二分法,是指在有序序列中通过折半的方式快速锁定目标的位置,在不断的二分下,最终找到答案。别急,我第一次看到的时候也是一头雾水。那么,我们来分析一下这段话。

  • 折半是具体怎样折半?

这个问题很好地引入了二分法的实现。我们来看一段伪代码

int left = 0, right = n + 1;
while (还没有结束) {
    int mid = (left + right) / 2;
    if (...)
		left = mid;
    else
		right = mid;
}

可以看到,我们先定义了两个指针 leftright(其实就是类似于 for 循环中的 ij,不是什么很深奥的东西,不要像我一开始一样被误导了),分别指向数组的第一个元素的前一个位置最后一个元素的后一个位置,它们之间就是答案所在的范围。在while循环中间,又定义了一个 mid,它指向的是left和right的中间,最好是写作\(mid=(left+right)>>1\)(位运算,等同于除以2)。然后,当触发了某个条件,left会指向mid,否则会让right指向mid。请思考这样做的含义。等等,这不就是相当于把答案的范围折半了吗?于是,我们就顺利地完成了折半的操作。总结一下,就是每次计算 leftright 的中间,并在某种判断条件下让 leftright 指向 mid,也就是折半。

现在,让我们换一种角度思考。不是去思考left和right之间是什么,而是去思考left和right之前是什么,即1--left和right--n这两个区间。请认真再反复思考这句话的含义。

我们现在来看这样一张图片:left 指向蓝色区域(下标 1--left)从左往右的最后一个元素4,而 right 指向红色区域(下标 right--n(8))从右往左的最后一个元素5。

这样就好理解了,蓝色区域 1--left 可以理解为left 扩展的区域,而红色区域 right--n 可以理解为right 扩展的区域。也就是说,二分查找其实就是在不断扩展 leftright,最后根据情况返回 leftright 为什么是根据情况返回 leftright 呢?因为在实际情况中,有可能要求返回 left,也有可能要求返回 right,但肯定是不会直接像“请返回 left”这样直接告诉你的。接下来我们逐一来补全伪代码中未完成的部分。

  • 为什么非得是在有序序列中?无序不行吗?

这是实现二分法的条件:数组需要有序。可以是单调不递减(从小到大)或单调不递增(从大到小)。为解决这个疑问,我们来补全伪代码中while循环里的if条件,也就是leftright 指向 mid是让 left 还是 right 指向 mid 的条件。

我们来看为什么朴素算法的效率低下。从我们之前扩展的角度来看,朴素算法相当于是两个指针在一个个缓慢扩展,直到遇到对方区域才停止。

可以看到,这样的效率是很低下的

那么,二分是怎样对扩展优化的呢?

答案是每次计算中间值 mid判断 mid 属于哪种颜色,并直接让 leftright 指向 mid,于是就一下子扩展了很多。 这里假设 mid 现在指向的区域是蓝色的,那么我们就会让 left 直接指向 mid 这意味着什么? 既然蓝色区域已经扩展到 mid 了,那么就说明 mid 之前的数也必须是蓝色的,这样这个操作才合法,才是正确的。那我们怎么保证 mid 之前的数是蓝色的呢? 很简单,只要让数组有序就行了,这样就能保证 mid 之前的数全部小于现在 mid 指向的数,也就全部是蓝色的了。 同理,只要数组有序, right
之前的数也全部都大于 right 现在指向的数,这个扩展操作也能成功。

总结一下,数组需要有序是因为这样二分时扩展的优化才能合法,并且我们又解决了一个问题:while循环里的if条件是在判断 mid 是属于什么颜色的。 我们把这个判断称为\(IsBlue\)(属于蓝色区域)。

现在更新伪代码为:

int mid = (left + right) >> 1;
if (IsBlue(mid))
    left = mid;
else
    right = mid;
  • 不断地二分具体是要分几次?

到这里,我们终于把伪代码补全了。具体要分几次取决于while循环的条件。

我们知道,二分法其实就是不断扩展 leftright 的过程,而我们观察上一幅图,leftright 处于什么关系时,扩展就完成了?答案也呼之欲出了:\(left+1=right\) 。

于是,我们完成伪代码的最后更新:

int left = 0, right = n + 1;
while (left + 1 != right) {
    int mid = (left + right) / 2;
    if (IsBlue(mid))
		left = mid;
    else
		right = mid;
}
return left or right;

至此,二分法的概念和实现就讲得差不多了。

那么,我们也就知道了,因为二分查找其实是在不断折半,所以总时间复杂度刚好是 \(O(\log n)\)。

二分法的细节处理

  • 细节1

为什么left的初始值为0,right的初始值为n+1?不能等于1和n吗?

设想一下,如果整个数组都是红色,那么 left 一开始就会指向红色区域,造成错误;同理,如果整个数组都是蓝色,那么 right 一开始就会指向蓝色区域,同样会造成错误,所以将指针初始化为 \(0\) 和 \(n+1\)。

  • 细节2

在更新指针时,能写成 \(left=mid+1\) 或者 \(right=mid-1\)吗?

我们来看一个例子:

设想一下,这个时候如果 \(left=mid+1\),会发生什么?没错,left 会指向红色区域,导致错误。同理,如果 mid 指向红色区域的最后一个元素,right 也会指向蓝色区域,导致错误。所以,将 leftright 直接指向 mid 更合适。

二分法的实现与建模

做一道例题就明白了。
给定一个有序整数数组 \(a[]\) 和一个整数数组 \(x[]\) 以及它们的长度 \(aLen\) 和 \(xLen\)。\((0\le a_i\le 2\times 10^6,0\le x_i\le 10^8,aLen,xLen\le 10^6)\)

现在定义 \(f(i)\) 为第一个符合 \(a_j\ge x_i\) 的 \(j\),如果没有,返回 \(0\)。

试求出 \(f(1,2,3,...xLen-1,xLen)\)。保证有解。

数组又是有序的,又要查找多个数,很容易想到效率高的二分查找。那我们做题时该怎么建模呢?放心,一点也不难。我们只需要把伪代码中的 \(IsBlue\) 条件和到底是要返回 left 还是 right 搞清楚就行了。现在我们来划分红蓝区域

首先,题目要求我们返回的是第一个符合条件的数,我们来看一下能不能把蓝色区域定义为大于等于 \(x_i\) 的数。显然是不可以的,因为蓝色区域是从左到右的,指向的是最后一个大于等于 \(x_i\) 的元素,所以要把红色区域定义为大于等于 \(x_i\) 的数,蓝色区域就是小于 \(x_i\) 的元素。\(IsBlue\) 的条件就是 \(a[mid]<x_i\),最后返回 right。给出代码如下。

\(Code:\)

#include <bits/stdc++.h>
using namespace std;
const int N = 1e6 + 5; // 数组大小
int a[N], x[N], aLen, xLen; // a数组,x数组,它们的大小
void bin_search(int x) { // 写成函数方便快捷
    int l = 0, r = aLen + 1; // 指针初始化要在边界外
    while (l + 1 != r) { // 当扩展还未结束
        int mid = (l + r) >> 1; // 计算中间值,>> 位运算,等同于除以2
        if (a[mid] < x) // 当处于蓝色区域
	    	l = mid; // 蓝色区域扩展
        else // 否则就是红色区域
            r = mid; // 红色区域扩展
    }
    if (a[r] == x) cout << r << " "; // 如果查找的答案符合
    else cout << 0 << " "; // 没有找到,输出0
    return ; // 函数最好都要写返回
}
int main() {
    cin >> aLen >> xLen; // 输入数组大小
    for (int i = 1; i <= aLen; i++) cin >> a[i]; // 输入a数组
    for (int i = 1; i <= xLen; i++) cin >> x[i]; // 输入x数组
    for (int i = 1; i <= xLen; i++) // 循环输出f(i)
		bin_search(x[i]); // 二分查找函数
    return 0; // 大功告成!
}

可以输入样例自测。

输入样例:

5 3

2 5 7 9 11

6 2 15

输出样例:

3 1 0

总结一下二分法的总体建模思路,就是确定红蓝区域以及返回 left 还是 right,并套用模板求解。当然,有一些细节也要处理,比如指针的初始值,扩展时防止跑到对面区域等。

最后

怎么样,你是否看懂了二分法的所有过程并理解了呢?其实二分法的思想很简单,但实现的过程中总会遇到一些麻烦。所以我才写了我的第一篇文章,想帮助大家理解二分法并能熟练运用它。希望你喜欢。

再见!

标签:二分,二分法,right,指向,int,mid,查找,详解,left
From: https://www.cnblogs.com/Weekoder/p/18240234

相关文章

  • 【二分答案】P2390 地标访问
    \(\color{black}\text{P2390地标访问(传送门)}\)学过区间DP的,看到这题的第一反应都是:访问的地标一定是一个区间,并且在不断扩大,区间DP!可看到数据范围,又瞬间放弃了。与P1220关路灯不同,这题由于没有电量的消耗等额外因素,有这样一个小性质:贝西的行走路线只可能是三种:一路向......
  • 深入剖析C++多态的实现与原理-详解
    目录多态基础虚函数虚函数的继承虚类/虚基类重写/覆盖条件:概念:多态的条件其他的多态行为多态中子类可以不写virtual协变代码举例继承遗留问题解决析构函数具体解决方式:题目1答案:解析:题目2答案:C++11override和finalfinal功能1:禁用继承使用场景:功能2:禁用重写使用场景overr......
  • 【数据结构】链式二叉树详解
    个人主页~链式二叉树基本内容~链式二叉树详解1、通过前序遍历的数组来构建二叉树2、二叉树的销毁3、二叉树节点个数4、二叉树叶子节点个数5、二叉树第k层节点个数6、二叉树查找7、前序遍历8、中序遍历9、后序遍历10、层序遍历与检查二叉树是否为完全二叉树Queue.hQue......
  • AcWing算法基础课笔记——最小生成树与二分图
    目录朴素版prim算法——模板题AcWing858.Prim算法求最小生成树题目代码Kruskal算法——模板题AcWing859.Kruskal算法求最小生成树题目代码染色法判别二分图——模板题AcWing860.染色法判定二分图题目代码匈牙利算法——模板题AcWing861.二分图的......
  • 一站式详解Maven工程的setting文件内容
    maven 是目前java 常见的一款jar包管理工具,除了大家熟知的依赖管理外, maven也可以很方便的对项目进行编译、测试,打包、部署等操作。本文将详细带大家了解一下Maven工程conf文件夹下的setting.xml文件,需要的朋友可以参考下:setting.xml文件是Maven的主要配置文件,它包......
  • [AIGC] 字典树Trie树详解及其Java实现
    字典树,也称为Trie树或前缀树,是一种常见的搜索数据结构,广泛应用于字符串查询的场景中,比如网络词典的实现,或者是搜索引擎中词语的自动补全。文章目录Trie树的概念Trie树特性Trie树的操作插入操作查询操作Java实现Trie树Trie树的概念Trie树是一种特别的n叉树模型......
  • 计算机组成原理-cache详解
    一、Cache的概念和原理1、cache原理2、cache性能分析一道例题3、cache和主存数据交换的单位每次访问到的主存块会立即放入cache中小结二、cache和主存之间的映射关系全相联映射全相联访存过程直接映射组相联映射小结三、cache替换算法在直接映射中,每......
  • Django API开发实战:前后端分离、Restful风格与DRF序列化器详解
    系列文章目录Django入门全攻略:从零搭建你的第一个Web项目DjangoORM入门指南:从概念到实践,掌握模型创建、迁移与视图操作DjangoORM实战:模型字段与元选项配置,以及链式过滤与QF查询详解DjangoORM深度游:探索多对一、一对一与多对多数据关系的奥秘与实践跨域问题与Django解决......
  • 6、组件通信详解(父子、兄弟、祖孙)
    一、父传子1、props用法:(1)父组件用props绑定数据,表示为v-bind:props="数据"(v-bind:简写为:,props可以任意命名)(2)子组件用defineProps(['props',....])接收注意:(1)v-bind:c="数据"表示父组件给数据绑定了一个名为c的prop属性。这样当父组件的数据发生改变,子组件也能接收......
  • 【30天精通Prometheus:一站式监控实战指南】第16天:snmp_exporter从入门到实战:安装、配
    亲爱的读者们......