详解二分查找

· 浏览次数 : 3

小编点评

二分法是一种高效的搜索算法,其时间复杂度为O(log n),远优于朴素算法的O(n)。在有序数组中,二分法通过不断折半来定位目标数值,从而大幅提升查找效率。 二分法的实现依赖于数组的有序性,通常数组应满足单调不递减(从小到大)或单调不递增(从大到小)的条件。这样,在扩展查找范围时,可以确保新扩展的区间内的所有元素都与目标数值具有相同的性质。 具体实现时,二分法通过不断缩小查找范围来定位目标数值。算法首先将查找范围的左右两端分别初始化为数组的第一个元素和最后一个元素。随后,在一个while循环中,计算中间位置的值,并根据其与目标数值的关系来更新查找范围的左右端点。当查找范围缩小至恰好包含目标数值时,算法返回该数值。 在实际应用中,可能需要根据具体问题调整二分法的实现细节,例如初始值的设置、如何处理扩展超界等情况。 通过上述内容,我们可以清晰地看到二分法的原理及其在实际问题中的应用。掌握二分法对于提高算法效率具有重要意义。

正文

二分法详解

大家好,我是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,并套用模板求解。当然,有一些细节也要处理,比如指针的初始值,扩展时防止跑到对面区域等。

最后

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

再见!

与详解二分查找相似的内容:

详解二分查找

二分法详解 大家好,我是Weekoder! 这是我的第一篇文章,如果有做的不好的地方,请见谅,我会尽力改正。 本文中的图片截取于网络视频,非恶意搬运。 二分法,是一个高效的算法,查找一个数的时间复杂度只需要\(O(\log n)\),大大优化了朴素算法(从头到尾地遍历)\(O(n)\)的线性复杂度。

小白也能懂的Mysql数据库索引详解

核心概念 主键索引/二级索引 聚簇索引/非聚簇索引 回表/索引覆盖 索引下推 联合索引/最左联合匹配 前缀索引 explain 一、[索引定义] 1.索引定义 在数据之外,数据库系统还维护着满足特定查找算法的数据结构,这些数据结构以某种方式引用(指向)数据,这样就可以在这些数据结构上实现高级查找算法

[转帖]深入理解mysql-第十章 mysql查询优化-Explain 详解(上)

目录 一、初识Explain 二、执行计划-table属性 三、执行计划-id属性 四、执行计划-select_type属性 一条查询语句在经过MySQL查询优化器的各种基于成本和规则的优化会后生成一个所谓的执行计划,这个执行计划展示了接下来具体执行查询的方式,比如多表连接的顺序是什么,对于每个表采

详解GaussDB(DWS)中的行执行引擎

本文分享自华为云社区《GaussDB(DWS)行执行引擎详解》,作者:yd_227398895。 1.前言 GaussDB(DWS)包含三大引擎,一是SQL执行引擎,用来解析用户输入的SQL语句,生成执行计划,供执行引擎来执行;二是执行引擎,其中包含了行执行引擎和列执行引擎,执行引擎即查询的执行者,

[转帖]【技术剖析】17. Native Memory Tracking 详解(3):追踪区域分析(二)

https://bbs.huaweicloud.com/forum/thread-0227103792775240073-1-1.html 应用性能调优 发表于 2022-11-14 15:19:36143查看 上篇文章 Native Memory Tracking 详解(2):追踪区域分析(一) 

[转帖]Redis学习六(Redis 阻塞的原因及其排查方向).

https://www.cnblogs.com/jmcui/p/13926397.html 一、慢查询 因为 Redis 是单线程的,大量的慢查询可能会导致 redis-server 阻塞,可以通过 slowlog get n 获取慢日志,查看详情情况。 回到顶部 二、bigkey 大对象 bigk

高一下三调模拟赛5.13(附关于二分图匈牙利建边的详细思考)

前言注:本篇为知识性内容,A题附详解关于匈牙利算法求最大独立子集难以理解的建边问题的思考,若有不当之处感谢指出。暂时只写了A篇题解,以供帮助大家理解相关问题,剩余题解会进行补充。 又是小集训的一周,总要伴随着模拟赛... 还是五道题目: A. 攻击装置 B. 循环 C. 漫步 D. 穿越 E. 结队

[转帖]CPU状态信息us,sy等含义

https://www.cnblogs.com/rxysg/p/15670435.html 目录 一.概述概述 二.详解 us和sy ni id wa hi和si st 三.总结 一.概述概述 比如一秒内有100个cpu时间片,这个cpu时间片就是cpu工作的最小单位。那么这100个cpu时间片在不

SQLSERVER 阻塞之 PFS 页到底是什么?

一:背景 1. 讲故事 在 SQLSERVER 的众多阻塞场景中,有不小的一部分是由于 PFS 页上的 闩锁 等待造成的,毕竟写页操作一定是要串行化的,在面对 闩锁(PAGELATCH_X) 等待问题上,一定要搞明白 PFS 页到底是什么? 这篇就来好好聊一聊。 二:PFS 详解 1. 什么是 PF

SQLSERVER 的 truncate 和 delete 有区别吗?

一:背景 1. 讲故事 在面试中我相信有很多朋友会被问到 truncate 和 delete 有什么区别 ,这是一个很有意思的话题,本篇我就试着来回答一下,如果下次大家遇到这类问题,我的答案应该可以帮你成功度过吧。 二:区别详解 1. 思考 从宏观角度来说, delete 是 DML 语句, tru