【算法详解】二分查找

news2024/11/24 12:12:28

1. 二分查找算法介绍

「二分查找算法(Binary Search Algorithm)」:也叫做 「折半查找算法」「对数查找算法」。是一种在有序数组中查找某一特定元素的搜索算法。

基本算法思想:先确定待查找元素所在的区间范围,在逐步缩小范围,直到找到元素或找不到该元素为止。

二分查找算法的过程如下所示:

  1. 每次查找时从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜索过程结束;
  2. 如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。
  3. 如果在某一步骤数组为空,则代表找不到。

举个例子来说,给定一个有序数组 [0, 1, 2, 3, 4, 5, 6, 7, 8]。如果我们希望查找 5 是否在这个数组中。

  1. 第一次区间为整个数组 [0, 1, 2, 3, 4, 5, 6, 7, 8],中位数是 4,因为 4 小于 5,所以如果 5 存在在这个数组中,那么 5 一定在 4 右边的这一半区间中。于是我们的查找范围变成了 [4, 5, 6, 7, 8]
  2. 第二次区间为 [4, 5, 6, 7, 8],中位数是 6,因为 5 小于 6,所以如果 5 存在在这个数组中,那么 5 一定在 6 左边的这一半区间中。于是我们的查找范围变成了 [4, 5, 6]
  3. 第三次区间为 [4, 5, 6],中位数是 5,正好是我们需要查找的数字。

于是我们发现,对于一个长度为 9 的有序数组,我们只进行了 3 次查找就找到了我们需要查找的数字。而如果是按顺序依次遍历数组,则最坏情况下,我们需要查找 9 次。

二分查找过程的示意图如下所示:

2. 二分查找算法思想

二分查找算法是经典的 「减而治之」 的思想。

这里的 「减」 是减少问题规模的意思,「治」 是解决问题的意思。「减」「治」 结合起来的意思就是 「排除法解决问题」。即:每一次查找,排除掉一定不存在目标元素的区间,在剩下可能存在目标元素的区间中继续查找。

每一次通过一些条件判断,将待搜索的区间逐渐缩小,以达到「减少问题规模」的目的。而于问题的规模是有限的,经过有限次的查找,最终会查找到目标元素或者查找失败。

3. 二分查找细节

从上面的例子中我们了解了二分查找的思路和具体代码。但是真正在解决二分查找题目的时候还是需要考虑很多细节的。比如说以下几个问题:

  1. 区间的开闭问题:区间应该是左闭右闭,还是左闭右开?
  2. mid 的取值问题mid = (left + right) // 2,还是 mid = (left + right + 1) // 2
  3. 出界条件的判断left <= right,还是 left < right
  4. 搜索区间范围的选择left = mid + 1right = mid - 1left = mid right = mid 应该怎么写?

下面一一进行讲解。

3.1 区间的开闭问题

区间的左闭右闭、左闭右开指的是初始待查找区间的范围。

  • 左闭右闭:初始化赋值时,left = 0right = len(nums) - 1left 为数组第一个元素位置,right 为数组最后一个元素位置,从而区间 [left, right] 左右边界上的点都能取到。
  • 左闭右开:初始化赋值时,left = 0right = len(nums)left 为数组第一个元素位置,right 为数组最后一个元素的下一个位置,从而区间 [left, right) 左边界点能取到,而右边界上的点不能取到。

关于区间的左闭右闭、左闭右开,其实在网上都有对应的代码和解法。但是相对来说,左闭右开这种写法在解决问题的过程中,需要考虑的情况更加复杂,所以建议 全部使用「左闭右闭」区间

3.2 mid 的取值问题

在二分查找的实际问题中,最常见的 mid 取值就是 mid = (left + right) // 2 或者 mid = left + (right - left) // 2 。前者是最常见写法,后者是为了防止整型溢出。式子中 // 2 就代表的含义是中间数「向下取整」。当待查找区间中有偶数个元素个数时,则位于最中间的数为 2 个,这时候使用上面式子只能取到中间靠左边那个数,而取不到中间靠右边的那个数。那么,右边的那个数到底能取吗?

其实,右边的数也是可以取的,令 mid = (left + right + 1) // 2,或者 mid = left + (right - left + 1) // 2。这样如果待查找区间的元素为偶数个,就能取到中间靠右边的那个数了,把这个式子代入到 704. 二分查找 中试一试,发现也是能通过题目评测的。

这是因为二分查找的思路是根据每次选择中间位置上的数值来决定下一次在哪个区间查找元素。每一次选择的元素位置可以是中间位置,但并不是一定非得是区间中间位置元素,靠左一些、靠右一些、甚至区间三分之一、五分之一处等等,都是可以的。比如说 mid = left + (right - left + 1) * 1 // 5 也是可以的。

但一般来说,取中间位置元素在平均意义下所达到的效果最好。同时这样写最简单。而对于 mid 值是向下取整还是向上取整,大多数时候是选择不加 1。但有些写法中,是需要考虑加 1 的,后面会讲解这种写法。

3.3 出界条件的判断

我们经常看到二分查找算法的写法中,while 语句出界判断的语句有left <= rightleft < right 两种写法。那我们究竟应该在什么情况用什么写法呢?

这就需要判断一下导致 while 语句出界的条件是什么。

  • 如果判断语句为 left <= right,且查找的元素不存在,则 while 判断语句出界条件是 left == right + 1,写成区间形式就是 [right + 1, right],此时待查找区间为空,待查找区间中没有元素存在,所以此时终止循环可以直接返回 -1 是正确的。
    • 比如说区间 [3, 2],不可能存在一个元素既大于等于 3 又小于等于 2,此时直接终止循环,返回 -1 即可。
  • 如果判断语句为left < right,且查找的元素不存在,则 while 判断语句出界条件是 left == right,写成区间形式就是 [right, right]。此时区间不为空,待查找区间还有一个元素存在,并不能确定查找的元素不在这个区间中,此时终止循环返回 -1 是错误的。
    • 比如说区间 [2, 2],元素 2 就属于这个区间,此时终止循环,返回 -1 就漏掉了这个元素。

但是如果我们还是想要使用 left < right 的话,怎么办?

可以在返回的时候需要增加一层判断,判断 left 所指向位置是否等于目标元素,如果是的话就返回 left,如果不是的话返回 -1。即:

// ...
while (left < right) {
    // ...
}
return nums[left] == target ? left : -1;

此外,循环语句用 left < right 还有一个好处,就是在退出循环的时候,一定有 left == right,我们就不用判断应该返回 left 还是 right 了。

3.4 搜索区间范围的选择

在进行区间范围选择的时候,有时候是 left = mid + 1right = mid - 1,还有的时候是 left = mid + 1 right = mid,还有的时候是 left = midright = mid - 1。那么我们到底应该如何确定搜索区间范围呢?

这是二分查找的一个难点,写错了很容易造成死循环,或者得不到正确结果。

这其实跟二分查找算法的两种不同思路有关。

  • 思路 1:「直接找」—— 在循环体中找到元素后直接返回结果。
  • 思路 2:「排除法」—— 在循环体中排除目标元素一定不存在区间。

4. 查找的三种常见模板

4.1 基础二分

思路 1:「直接找」

第 1 种思路:一旦我们在循环体中找到元素就直接返回结果。

这种思路比较简单,其实我们在上边 「3. 简单二分查找 - 704. 二分查找」 中就已经用过了。这里再看一下思路和代码:

思路:

  • 取两个节点中心位置 mid,先看中心位置值 nums[mid]

    • 如果中心位置值 nums[mid] 与目标值 target 相等,则 直接返回 这个中心位置元素的下标。
    • 如果中心位置值 nums[mid] 小于目标值 target,则将左节点设置为 mid + 1,然后继续在右区间 [mid + 1, right] 搜索。
    • 如果中心位置值 nums[mid] 大于目标值 target,则将右节点设置为 mid - 1,然后继续在左区间 [left, mid - 1] 搜索。
      二分查找的基础模板,适用于可以通过访问数组中单个索引来确定元素或条件的情况。
int binarySearch(vector<int>& nums, int target) {
    if (nums.size() == 0) return -1;
    int left = 0, right = nums.size() - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) return mid;
        else if (nums[mid] < target) left = mid + 1;
        else right = mid - 1;
    }
    return -1;
}

细节:

  • 这种思路是在一旦循环体中找到元素就直接返回。
  • 循环可以继续的条件是 left <= right
  • 如果一旦退出循环,则说明这个区间内一定不存在目标元素。

4.2 排除法

思路 2:「排除法」

第 2 种思路:在循环体中排除目标元素一定不存在区间。

思路:

  • 取两个节点中心位置 mid,根据判断条件先将目标元素一定不存在的区间排除。
  • 然后在剩余区间继续查找元素,继续根据条件排除不存在的区间。
  • 直到区间中只剩下最后一个元素,然后再判断这个元素是否是目标元素。

根据第二种排除法的思路,我们可以写出来两种代码。

  1. 寻找左端点
// 区间[l, r]被划分成[l, mid]和[mid + 1, r]时使用:
//寻找左边界
//找到 ≥target的最小值
int search(vector<int>& nums, int target) {
        int left = 0, right = nums.size() - 1;
        
        // 在区间 [left, right] 内查找 target
        while (left < right) {
            // 取区间中间节点
            int mid = left + (right - left) / 2;
            // nums[mid] 小于目标值,排除掉不可能区间 [left, mid],在 [mid + 1, right] 中继续搜索
            if (nums[mid] < target) {
                left = mid + 1;
            // nums[mid] 大于等于目标值,目标元素可能在 [left, mid] 中,在 [left, mid] 中继续搜索
            } else {
                right = mid;
            }
        }
        // 判断区间剩余元素是否为目标元素,不是则返回 -1
        return nums[left] == target ? left : -1;
    }
  1. 寻找右端点
// 区间[l, r]被划分成[l, mid - 1]和[mid, r]时使用:
//寻找右边界
//找到 ≤target 的最大值
 int search(vector<int>& nums, int target) {
        int left = 0, right = nums.size() - 1;
        
        // 在区间 [left, right] 内查找 target
        while (left < right) {
            // 取区间中间节点
            int mid = left + (right - left + 1) / 2;
            // nums[mid] 大于目标值,排除掉不可能区间 [mid, right],在 [left, mid - 1] 中继续搜索
            if (nums[mid] > target) {
                right = mid - 1;
            // nums[mid] 小于等于目标值,目标元素可能在 [mid, right] 中,在 [mid, right] 中继续搜索
            } else {
                left = mid;
            }
        }
        // 判断区间剩余元素是否为目标元素,不是则返回 -1
        return nums[left] == target ? left : -1;
    }

细节:

  • 判断语句是 left < right。这样在退出循环时,一定有left == right 成立,就不用判断应该返回 left 还是 right 了。同时方便定位查找元素的下标。但是一定要注意最后要对区间剩余的元素进行一次判断。
  • 在循环体中,优先考虑 nums[mid] 在什么情况下一定不是目标元素,排除掉不可能区间,然后再从剩余区间中确定下一次查找区间的范围。
  • 在考虑 nums[mid] 在什么情况下一定不是目标元素之后,它的对立面(即 else 部分)一般就不需要再考虑区间范围了,直接取上一个区间的反面区间。如果上一个区间是 [mid + 1, right],那么相反面就是 [left, mid]。如果上一个区间是 [left, mid - 1],那么相反面就是 [mid, right]
  • 当区分被分为 [left, mid - 1][mid, right] 两部分时,mid 取值要向上取整。即 mid = left + (right - left + 1) // 2。因为如果当区间中只剩下两个元素时(此时 right = left + 1),一旦进入 left = mid 分支,区间就不会再缩小了,下一次循环的查找区间还是 [left, right],就陷入了死循环。
  • 关于边界设置可以记忆为:只要看到 left = mid 就向上取整。或者记为:
    • left = mid + 1right = midmid = left + (right - left) /2 一定是配对出现的。
    • right = mid - 1left = midmid = left + (right - left + 1) / 2 一定是配对出现的。

4.3 两种思路适用范围

  • 二分查找的思路 1:因为判断语句是 left <= right,有时候要考虑返回是 left 还是 right。循环体内有 3 个分支,并且一定有一个分支用于退出循环或者直接返回。这种思路适合解决简单题目。即要查找的元素性质简单,数组中都是非重复元素,且 ==>< 的情况非常好写的时候。
  • 二分查找的思路 2:更加符合二分查找算法的减治思想。每次排除目标元素一定不存在的区间,达到减少问题规模的效果。然后在可能存在的区间内继续查找目标元素。这种思路适合解决复杂题目。比如查找一个数组里可能不存在的元素,找边界问题,可以使用这种思路。

5. 题目描述

给定一个按照升序排列的长度为n的整数数组,以及 q 个查询。对于每个查询,返回一个元素k的起始位置和终止位置(位置从0开始计数)。如果数组中不存在该元素,则返回“-1 -1”。

输入格式

  • 第一行包含整数n和q,表示数组长度和询问个数。
  • 第二行包含n个整数(均在1~10000范围内),表示完整数组。
  • 接下来q行,每行包含一个整数k,表示一个询问元素。

输出格式

  • 共q行,每行包含两个整数,表示所求元素的起始位置和终止位置。如果数组中不存在该元素,则返回“-1 -1”。

数据范围

  • 1 ≤ n ≤ 100000
  • 1 ≤ q ≤ 10000
  • 1 ≤ k ≤ 10000

输入样例

6 3
1 2 2 3 3 4
3
4
5

输出样例

3 4
5 5
-1 -1

题解思路

这道题可以使用二分查找来解决。我们首先实现两个二分查找函数,一个用于找到元素k的起始位置,另一个用于找到元素k的终止位置。然后,对于每个查询,我们使用这两个函数分别找到起始位置和终止位置,并输出结果。

参考代码

#include<iostream>
using namespace std;

int n, q;
const int N = 100010;
int a[N];

int binary_search(int k) {
    int l = 0, r = n - 1;
    while (l < r) {
        int mid = l + r >> 1;
        if (a[mid] < k) l = mid + 1;
        else r = mid;
    }
    return l;
}

int binary_search2(int k) {
    int l = 0, r = n - 1;
    while (l < r) {
        int mid = l + r + 1 >> 1;
        if (a[mid] > k) r = mid - 1;
        else l = mid;
    }
    return l;
}

int main() {
    scanf("%d%d", &n, &q);
    for (int i = 0; i < n; i++)
        scanf("%d", &a[i]);
    while (q--) {
        int temp;
        scanf("%d", &temp);
        int p = binary_search(temp);
        int q = binary_search2(temp);
        if (a[p] == temp)
            cout << p << " " << q << endl;
        else cout << "-1 -1" << endl;
    }
    return 0;
}

这样,我们就完成了对这道题目的解答。通过这个例子,我们可以看到二分查找在处理有序数组时的应用,以及如何利用二分查找来解决一些问题。

5.二分查找总结

需要注意的是,不存在 target 的时候,直接返回 -1。在二分查找值时,返回条件是 nums[mid] == target 时直接 return,而查找左右侧边界时,返回条件则需要等 while() 循环完毕后,才能返回。观察下表可知,区间右侧开闭主要影响 right 的更新和 while 判断。

场景左闭右开 [left, right)左闭右闭 [left, right]备注
初始赋值left = 0, right = numsSizeleft = 0, right = numsSize - 1部分不同
while条件left < rightleft <= right不同
nums[mid] < targetleft = mid + 1left = mid + 1相同
nums[mid] > targetright = midright = mid - 1不同
nums[mid] == target返回 mid返回 mid相同

下面左右侧边界查找采用的是左闭右开区间,读者有兴趣可自行分析左闭右闭区间对应的情况。注意,如果有左边界不存在的场景,在 while 循环后,要判断下标对应值是否与 target 相等。

观察下表可知,在区间开闭情况相同时,左右侧边界的查找的主要区别在于 nums[mid] == target 时边界更新和返回值。

场景左侧边界右侧边界备注
初始赋值left = 0, right = numsSizeleft = 0, right = numsSize相同
while条件left < rightleft < right相同
nums[mid] < targetleft = mid + 1left = mid + 1相同
nums[mid] > targetright = midright = mid相同
nums[mid] == targetright = midleft = mid + 1不同
返回值leftleft - 1不同

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1579288.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

SSM党员管理系统

一、系统介绍 党员管理系统: 可以方便管理人员对党员管理系统的管理&#xff0c;提高信息管理工作效率及查询效率&#xff0c;有利于更好的为用户提供服务。 主要的模块包括&#xff1a; 1、后台功能&#xff1a; 管理员角色&#xff1a;首页、个人中心&#xff0c;党员管理…

MySQL高级(索引结构B + Tree)

以一颗最大度数&#xff08;max - degree&#xff09;为 4 &#xff08;4阶&#xff09;的 b tree 为例&#xff1a; 绿色框框起来的部分&#xff0c;是索引部分&#xff0c;仅仅起到索引数据的作用&#xff0c;不存储数据。红色框框起来的部分&#xff0c;是数据存储部分&…

PostgreSQL入门到实战-第七弹

PostgreSQL入门到实战 PostgreSQL查询语句(四)官网地址PostgreSQL概述PostgreSQL中DISTINCT 语句介绍PostgreSQL中DISTINCT 语句实操更新计划 PostgreSQL查询语句(四) 官网地址 声明: 由于操作系统, 版本更新等原因, 文章所列内容不一定100%复现, 还要以官方信息为准 https:…

【运输层】TCP 的可靠传输是如何实现的?

目录 1、发送和接收窗口&#xff08;滑动窗口&#xff09; &#xff08;1&#xff09;滑动窗口的工作流程 &#xff08;2&#xff09;滑动窗口和缓存的关系 &#xff08;3&#xff09;滑动窗口的注意事项 2、如何选择超时重传时间 &#xff08;1&#xff09;加权平均往返…

React面试

React渲染流程(重点) jsx描述界面 jsx babel render function>vdom vdom fiber 在进行渲染 vdom 转换fiber reconcile 转换过程创建dom commit 到domvdom React Element 对象, 只记录了子节点, 没有记录兄弟节点, 因为渲染不可中断 fiber fiberNode 对象, 是一个链表 父节…

lua学习笔记13(一些特殊用法的学习和三目运算符的实现)

print("*****************************一些特殊用法的学习*******************************") print("*****************************多变量赋值*******************************") local a,b,c114514,8848,"凌少" print(a) print(b) print(c) -…

C#操作MySQL从入门到精通(7)——对查询数据进行简单过滤

前言 我们在查询数据库中数据的时候,有时候需要剔除一些我们不想要的数据,这时候就需要对数据进行过滤,比如学生信息中,我只需要年龄等于18的,类似这种操作,本文就是详细介绍如何对查询的数据进行初步的过滤。 1、等于操作符 本次查询student_age 等于20的数据,使用我…

导出加入域控的电脑清单

要导出加入域控的所有电脑清单&#xff0c;包括计算机名等基本信息&#xff0c;可以使用 PowerShell 中的 Get-ADComputer cmdlet。下面是一个基础示例脚本&#xff0c;用于从Active Directory (AD)中批量导出所有加入域的计算机名称及可能的其他相关信息&#xff0c;并将其保存…

Mudbus协议CRC校验码C#

Mudbus协议CRC校验码C# 什么是modbus协议特点协议格式modbus-crc16校验原理方法帧校验CRC计算方法&#xff1a;例子 C#代码Demo源码下载 什么是modbus Modbus是一种串行通信协议&#xff0c;最初由Modicon&#xff08;目前属于施耐德电气公司&#xff09;于1979年开发 Modbus协…

设计模式之访问者模式讲解

概念&#xff1a;封装一些作用于某种数据结构中的各元素的操作&#xff0c;它可以在不改变数据结构的前提下定义作用于这些元素的新操作。 抽象访问者&#xff1a;声明一个或多个访问操作&#xff0c;定义访问者可以访问哪些元素。具体访问者&#xff1a;实现抽象访问者的所有操…

2-4 AUTOSAR ASW Port端口

返回总目录->返回总目录<- 一、端口Port 软件组件的端口根据输入/输出方向可分为需型端口(Require Port,RPort)与供型端口(Provide Port,PPort),以及供需端口(Provide and Require Port,PRPort)。 需型端口可以和供型端口连接。 需型端口:用于从其他软件组件获…

多线程原理详解01(程序、进程、线程介绍,线程创建的三种方式(Thread、Runnable、Callable)、三种方式各自实现多线程的具体操作、代码解析)

目录 多线程原理详解01_线程简介多任务多线程程序、进程、线程Process&#xff08;进程&#xff09;与 Thread &#xff08;线程&#xff09;总结&#xff1a; 02_线程创建三种方式&#xff1a;1、继承 Thread 类1-1&#xff1a;通过继承Thread类实现多线程演示代码 1-2&#x…

干货分享 | 在TSMaster中加载基于DotNet平台的SeedKey

在UDS诊断过程中&#xff0c;会涉及到安全访问的问题&#xff0c;也就是所谓的Seed&Key。TSMaster 诊断模块支持通过.dll文件载入 Seed&Key 算法用于安全访问解锁。在最近发布的TSMaster 2024.03版本中不仅支持了C/C&#xff0c;Delphi等语言封装的DLL文件&#xff0c;…

基于GD32的简易数字示波器(3)- PCB设计

这期记录的是项目实战&#xff0c;做一个简易的数字示波器。 教程来源于嘉立创&#xff0c; 本期介绍PCB设计的大致流程。 下图为示波器的指标 具有选择交流耦合还是直流耦合功能、输入信号不衰减或衰减50倍 输入频率理论最大800KHz输入幅值&#xff08;不衰减&#xff09;…

【RISC-V 指令集】RISC-V 向量V扩展指令集介绍(四)- 配置和设置指令(vsetvli/vsetivli/vsetvl)

1. 引言 以下是《riscv-v-spec-1.0.pdf》文档的关键内容&#xff1a; 这是一份关于向量扩展的详细技术文档&#xff0c;内容覆盖了向量指令集的多个关键方面&#xff0c;如向量寄存器状态映射、向量指令格式、向量加载和存储操作、向量内存对齐约束、向量内存一致性模型、向量…

SUSE Linux Enterprise Server安装

1. SUSE镜像下载 下载地址&#xff1a;Evaluation Copy of SUSE Linux Enterprise Server | SUSE 选择自己需要的版本和对应的架构 选择下载SLE-15-SP5-Full-x86_64-GM-Media1.iso&#xff0c;下载时需要注册请按照提示进行注册。 2. 安装SUSE Linux 安装时可以通过连接服务…

MySQL高级(索引结构Hash,为什么InnoDB存储引擎选择使用B+tree索引结构?)

目录 1、Hash索引结构 2、Hash索引特点 3、存储引擎支持 4、为什么InnoDB存储引擎选择使用Btree索引结构&#xff1f; 1、Hash索引结构 哈希索引就是采用一定的hash算法&#xff0c;将键值换算成新的hash值&#xff0c;映射到对应的槽位上&#xff0c;然后存储在hash表中。 如…

希尔排序解读

在算法世界中&#xff0c;排序算法是至关重要的一部分。而希尔排序&#xff08;Shell Sort&#xff09;作为一种基于插入排序的改进算法&#xff0c;通过允许交换非相邻元素&#xff0c;从而在一定程度上提高了排序效率。本文将深入探讨希尔排序的原理、实现方式以及它的性能特…

电商技术揭秘十五:数据挖掘与用户行为分析

相关系列文章 电商技术揭秘一&#xff1a;电商架构设计与核心技术 电商技术揭秘二&#xff1a;电商平台推荐系统的实现与优化 电商技术揭秘三&#xff1a;电商平台的支付与结算系统 电商技术揭秘四&#xff1a;电商平台的物流管理系统 电商技术揭秘五&#xff1a;电商平台…

【3GPP】【核心网】核心网/蜂窝网络重点知识面试题一(超详细)

目录 1. 核心网技术演进、各大运营商采用的技术是什么&#xff1f; 2. 整体掌握lte网络架构&#xff0c;能画出网络拓扑&#xff0c;并指出关键接口的位置 3. 对于主要的LTE核心网网元&#xff0c;讲讲自己对其功能的理解 4. 核心网常用字段概念&#xff1a;imsi、msisdn、…