【算法】二分查找(BinarySearch)学习笔记

160 阅读5分钟

【算法】二分查找——BinarySearch

个人学习过程产物,希望能帮到大家。第一次写博客,欢迎大家指出错误和不足!

一、概述

二分查找又称折半查找,是一种能够大幅减少时间复杂度的查找方法,但是二分查找要求线性表必须使用顺序储存结构,而且表中元素按关键字有序排列。

在后续讨论中,我们假设有序表递增有序。

二分查找中使用的术语:

目标 Target —— 你要查找的值

索引 Index —— 你要查找的当前位置

左、右指示符 Left,Right —— 我们用来维持查找空间的指标

中间指示符 Mid —— 我们用来应用条件来确定我们应该向左查找还是向右查找的索引。

二、一个典型的二分查找

二分查找的过程为:

从表的中间记录middle开始,如果要查找的目标值target等于middle,则查找成功;如果target>middle,则说明应从middle的右侧寻找target,将left的值更改为middle+1;反之则从middle的左侧寻找,将right的值更改为middle-1。重复操作直至查找成功,或者当查找区间为空时查找失败。

C++代码如下:

class Solution {
public:
    int Binary_Search(vector<int>& nums, int target) {
        int left=0;
        int right=nums.size()-1;
        while(left<=right)//应注意这里是left<=right而不是left<right,因为查找区间还有最后一个节点,需要进一步比较。
        {
            int middle=left+(right-left)/2;//相当于(right+left)/2,这么写是为了防止溢出
            if(nums[middle]<target){
                left=middle+1;
            }
            else if(nums[middle]>target){
                right=middle-1;
            }
            else {
                return middle;
            }
        }
        return -1;
    }

时间复杂度为O(log2n)O(log_2n),空间复杂度为O(1)O(1)。

三、习题与胡乱分析

题单来源主要是这本书:二分查找 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台 (leetcode-cn.com)

它将二分查找与相关习题总结概括成了三种代码模板,对小白入门很友好。模板的组合不是一成不变的,做题时也不能一味套用。

但是和所有的算法学习一样,只有真正看懂别人的代码,并能够自己敲出来,才能说得上是学会了。

(偷懒的话就会像我一样被迫重开)

1. 猜数字

习题链接:374. 猜数字大小 - 力扣(LeetCode) (leetcode-cn.com)

猜数字游戏的规则如下:

  • 每轮游戏,我都会从 1 到 n 随机选择一个数字。 请你猜选出的是哪个数字。
  • 如果你猜错了,我会告诉你,你猜测的数字比我选出的数字是大了还是小了。

你可以通过调用一个预先定义好的接口 int guess(int num) 来获取猜测结果,返回值一共有 3 种可能的情况(-11 或 0):

  • -1:我选出的数字比你猜的数字小 pick < num
  • 1:我选出的数字比你猜的数字大 pick > num
  • 0:我选出的数字和你猜的数字一样。恭喜!你猜对了!pick == num

返回我选出的数字。

示例 1:

输入: n = 10, pick = 6
输出: 6

示例 2:

输入: n = 1, pick = 1
输出: 1

示例 3:

输入: n = 2, pick = 1
输出: 1

示例 4:

输入: n = 2, pick = 2
输出: 2

提示:

  • 1 <= n <= 231 12^{31} - 1
  • 1 <= pick <= n

典型的二分查找,比较简单。首先将left与right分别设定成1和n作为二分查找的初值,然后依据二分查找的基本步骤进行即可。

这道题是经典的二分查找,是很多使用二分法解题的题目的基础。

代码如下:

class Solution {
public:
    int guessNumber(int n) {
        int left=1;
        int right=n;
        int middle=1;
        while(left<=right){
            middle=left+(right-left)/2;
            if(guess(middle)==1){left=middle+1;}
            else if(guess(middle)==-1){right=middle-1;}
            else if(guess(middle)==0){return middle;}
        }
        return middle;
    }
};

2. Sqrt(x)

习题链接:69. Sqrt(x) - 力扣(LeetCode) (leetcode-cn.com)

给你一个非负整数 x ,计算并返回 x 的 算术平方根 。

由于返回类型是整数,结果只保留 整数部分 ,小数部分将被 **舍去 **。

注意: 不允许使用任何内置指数函数和算符,例如 pow(x, 0.5) 或者 x ** 0.5 。

示例 1:

输入: x = 4
输出: 2

示例 2:

输入: x = 8
输出: 2
解释: 8 的算术平方根是 2.82842..., 由于返回类型是整数,小数部分将被舍去。

提示:

  • 0<=x<=231 10 <= x <= 2^{31} - 1

按照典型的二分查找来寻找最相近的根即可。但是要注意不能使用middle * middle > x或者与之类似的方式判断middle的大小,因为当数据较大时,这种写法会导致溢出。因此采用x/middle > middle或与之类似的方式进行比较。

(不过官方解答竟然是用long long的方式解决了溢出问题……学习了)

结束循环时返回的不是middle,这里我一开始没想到。不过具体返回的是哪一个值还是要看前面代码的。

解答如下:

class Solution {
public:
    int mySqrt(int x) {
        int left=1;
        int right=x;
        while(left<=right){
            int middle=left+(right-middle)/2;
            if(x/middle>middle){left=middle+1;}
            else if(x/middle<middle){right=middle-1;}
            else {return middle;}
        }
        return right;
    }
};

我这里返回的是right。以输入为8为例,最后一次循环开始时,left与right都为3,循环依然可以进行。执行操作后,right=2而left=3,此时方才退出循环。

看题解,发现每个人的做法都不尽相同,常见的有下面这些区别

  • 首先判断特殊值
  • 循环条件不是while(left<=right),而是while(left<right)
  • 并不是left=middle+1,而是left=middle,最后return left

对模板各个部分不同的修改导致了整体的不同。(个人胡乱理解)

3. 第一个错误的版本

习题链接:278. 第一个错误的版本 - 力扣(LeetCode) (leetcode-cn.com)

你是产品经理,目前正在带领一个团队开发新的产品。不幸的是,你的产品的最新版本没有通过质量检测。由于每个版本都是基于之前的版本开发的,所以错误的版本之后的所有版本都是错的。

假设你有 n 个版本 [1, 2, ..., n],你想找出导致之后所有版本出错的第一个错误的版本。

你可以通过调用 bool isBadVersion(version) 接口来判断版本号 version 是否在单元测试中出错。实现一个函数来查找第一个错误的版本。你应该尽量减少对调用 API 的次数。

示例 1:

输入: n = 5, bad = 4
输出: 4
解释:
调用 isBadVersion(3) -> false 
调用 isBadVersion(5-> true 
调用 isBadVersion(4-> true
所以,4 是第一个错误的版本。

示例 2:

输入: n = 1, bad = 1
输出: 1

提示:

  • 1<=bad<=n<=231 11 <= bad <= n <= 2^{31} - 1

依然是一个经典的二分查找,如果不是错误版本,那么就要在右侧区间继续查找,否则则在左侧。要注意修改循环、左右区间更改条件等。

第一次把分号放在括号外面提交好几次……最后对着题解写艰难过了。

第二次很快就写出来了,主要是考虑清楚条件防止死循环。

代码如下:

// The API isBadVersion is defined for you.
// bool isBadVersion(int version);

class Solution
{
public:
    int firstBadVersion(int n)
    {
        int left = 1, right = n;
        while (left < right)
        { //不能是<=,会死循环
            int middle = left + (right - left) / 2;
            if (isBadVersion(middle))
            {
                right = middle;
            }
            else
            {
                left = middle + 1;
            }
        }
        return right;
    }
};

4. 寻找峰值

习题链接:162. 寻找峰值 - 力扣(LeetCode) (leetcode-cn.com)

峰值元素是指其值严格大于左右相邻值的元素。

给你一个整数数组 nums,找到峰值元素并返回其索引。数组可能包含多个峰值,在这种情况下,返回 任何一个峰值 所在位置即可。

你可以假设 nums[-1] = nums[n] = -∞ 。

你必须实现时间复杂度为 O(log n) **的算法来解决此问题。

示例 1:

输入: nums = [1,2,3,1]
输出: 2
解释: 3 是峰值元素,你的函数应该返回其索引 2。

示例 2:

输入: nums = [1,2,1,3,5,6,4]
输出: 1 或 5 
解释: 你的函数可以返回索引 1,其峰值元素为 2;
     或者返回索引 5, 其峰值元素为 6。

提示:

  • 1<=nums.length<=10001 <= nums.length <= 1000
  • 231 <=nums[i]<=231 1-2^{31} <= nums[i] <= 2^{31} - 1
  • 对于所有有效的 i 都有 nums[i] != nums[i + 1]

根据罗尔中值定理,要判断是否为峰值应当看这个位置导数是否为0。那么在这道题中,如果一个数比右边位置的数大,就应该在左侧区间内继续查找,反之则在右边位置查找(但是不能同时和左右两边的数字比较,不然就会陷入不知道查找哪一边区间的尴尬境地)。

另外这道题还有一点不严谨,那就是没有明确数组两端点是否可以为峰值。根据测试用例,可以猜测出题人是认为两端点可以作为峰值的。

代码如下:

class Solution {
public:
    int findPeakElement(vector<int>& nums) {
        int n=nums.size();
        if(n==1||nums[0]>nums[1]){
            return 0;
        }
        if(nums[n-1]>nums[n-2]){
            return n-1;
        }
        //排除几种特殊情况。
        int left=-1,right=n;
        while(left+1!=right){
            int middle=left+(right-left)/2;
            if(nums[middle]>nums[middle+1]){
                right=middle;
            }
            else left=middle;
        }
        return right;
    }
};

5. 找到K个最接近的元素

习题链接:658. 找到 K 个最接近的元素 - 力扣(LeetCode) (leetcode-cn.com)

给定一个排序好的数组 arr ,两个整数 k 和 x ,从数组中找到最靠近 x(两数之差最小)的 k 个数。返回的结果必须要是按升序排好的。

整数 a 比整数 b 更接近 x 需要满足:

  • |a - x| < |b - x| 或者
  • |a - x| == |b - x| 且 a < b

示例 1:

输入: arr = [1,2,3,4,5], k = 4, x = 3
输出: [1,2,3,4]

示例 2:

输入: arr = [1,2,3,4,5], k = 4, x = -1
输出: [1,2,3,4]

提示:

  • 1 <= k <= arr.length
  • 1<=arr.length <=1041 <= arr.length <= 10^4
  • 数组里的每个元素与 x 的绝对值不超过 10410^4

其实这道题最简单最高效的方法是滑动窗口法,这个方法在之后会总结成专题(或许吧(咕咕咕))

也可以采用二分法来解题。

代码如下:

class Solution {
public:
    vector<int> findClosestElements(vector<int>& arr, int k, int x) {
        int left=0,right=arr.size()-k;
        while(left<right){
            int middle=left+(right-left)/2;
            if((x-arr[middle])>(arr[middle+k]-x)){//右边比左边更适合,所以选择右区间。
                left=middle+1;
                }
            else{right=middle;}
        }
        return vector<int>(arr.begin()+left,arr.begin()+left+k);
    }
};

四、小结与小尾巴

  • 使用二分法解题可以使时间复杂度降至O(log2n)O(log_2n),可以完成一些算法的要求。
  • 使用二分法拥有一个大概的框架,但是每一步具体的条件内容随着具体题目的要求而改变。

小尾巴

在写这篇总结时,我突然想到,怎么样才能更快地写出一个正确的二分查找呢?有没有什么分析方法?

于是我参考了这篇博客:如何写出正确的二分查找?——利用循环不变式理解二分查找及其变体的正确性以及构造方式 - 五岳 - 博客园 (cnblogs.com)

然后我找到了 《计算机算法设计分析习题解答》 中的习题2-2,可能参悟了这七个算法就能掌握写出正确二分查找的精髓了吧。

于是这里留一个小尾巴~

五、参考资料

  1. 如何写出正确的二分查找?——利用循环不变式理解二分查找及其变体的正确性以及构造方式 - 五岳 - 博客园 (cnblogs.com)
  2. 二分查找 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台 (leetcode-cn.com)
  3. 《计算机算法设计与分析习题解答》第5版(王晓东)