二分查找算法详解(经典二分和左右边界查找)

目录

  • 二分查找算法
    • 1. 二分查找算法框架
    • 2. 经典二分查找算法
      • 问题1. 为什么while循环中使用<=号而不是用<号,右边区间right为什么要对数组大小减一?
      • 问题2. 为什么 left = mid + 1,right = mid - 1?有的二分查找代码是 right = mid 或者 left = mid,怎么判断?
    • 3. 寻找左侧边界
      • 问题1. 为什么在找到目标值后不返回该索引,而是重新对right进行赋值?
      • 问题2. 为什么left是正确的返回值?
      • 问题3. 为什么没有找到时能正确返回-1?
    • 4. 寻找右侧边界
      • 问题1. 为什么在找到目标值后不返回该索引,而是重新对left进行赋值?
      • 问题2. 为什么right是正确的返回值?
      • 问题3. 为什么没有找到时能正确返回-1?
    • 5. 经典例题

查找是指根据给定的某个值,在查找表中确定一个其关键字等于给定值的数据元素(或记录)。

  对于乱序的一组数,要找到给定数的位置是比较困难的,查找的时间复杂度通常为O(n)。但是对于一组有序的数查找到给定数的位置相对较为简单,通常可以使用二分查找法(Binary Search),时间复杂度为O(logn)。当然,要想有更快的查找速度可以改变存储方式,用空间换时间,使用散列表(哈希表)存储,查找的时间复杂度为O(1)。下面主要对二分查找算法进行讲解,本文讲解的都是以升序排列的数据作为待查找数据。

二分查找算法

  对于已经排好序的数据(也称为顺序表存储的数据)查找指定值的位置通常可以使用二分查找算法,时间复杂度比顺序查找快得多。本文就来探究几个最常用的二分查找场景:寻找一个数、寻找左侧边界、寻找右侧边界。二分查找算法虽然简单,但是细节很重要,不注意细节会产生难以预料的bug。

  二分查找算法的基本思想:在有序表中,每次都取中间记录作为比较对象,若给定值与中间记录的关键字相等则查找成功,返回该关键字的索引;若给定值小于中间记录的关键字,则在中间记录的左半区继续查找;若给定值大于中间记录的关键字,则在中间记录的右半区继续查找。不断重复上诉过程,直到查找成功,或者所有查找区域无记录,查找失败为止,返回一个值代表没有找到。

基于二分查找算法的基本思想,可以得到二分查找算法的基本框架。

1. 二分查找算法框架

int binarySearch(vector<int>& nums, int target) {
    int left = 0;
    int right = ...;//注意

    while(...) {//注意
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            ...//注意
        } else if (nums[mid] < target) {
            left = ...//注意
        } else if (nums[mid] > target) {
            right = ...//注意
        }
    }
    return ...;//注意
}

  框架中需要注意的细节都用…标注出来了,只有将这些细节都理解清楚才能写出正确的二分查找算法。注意这句代码:int mid = left + (right - left) / 2;,不用int mid=(left+right)/2;这种写法是为了防止数据直接相加产生溢出。

2. 经典二分查找算法

经典二分查找算法是最简单的,即搜索一个数,如果存在,返回其索引,否则返回 -1。

我们先写出代码再分析具体的细节。

int binarySearch(vector<int>& nums, int target) {
    int left = 0; 
    int 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 if (nums[mid] > target)
            right = mid - 1; // 注意
    }
    return -1;
}

需要注意的细节都已经在代码中标注出来了,我们针对这个代码提出两个细节问题:

问题1. 为什么while循环中使用<=号而不是用<号,右边区间right为什么要对数组大小减一?

  这两个问题应该放在一起考虑,首先说结论:如果right减一那么循环里面就应该包含等于;如果right不减一那么循环里面就不应该包含等于
原因如下:

如果初始值right是减一的,那么初始的搜索区间就是[left,right],即左闭右闭,那么如果没有找到应该是left>right的时候,或者说left=right+1,此时搜索区间为[right+1,right],搜索区间为空。如果循环里面不包含等号,那么left==right的情况会被忽略,可能会漏解。
如果初始值right是不减一的,那么初始的搜索区间就是[left,right),即左闭右开,那么如果没有找到应该是left等于right的情况,此时搜索区间是[right,right),该区间为空,因此不会漏解。

  当然如果你非要在循环里面用小于号也没有问题,因为我们已经知道了可能会漏掉的一个解是target刚好等于退出循环时候的索引值,我们只需要在最后返回值里面打一个补丁就好:

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

问题2. 为什么 left = mid + 1,right = mid - 1?有的二分查找代码是 right = mid 或者 left = mid,怎么判断?

  这也是二分查找中特别需要注意的一个点,如果你能理解前面的内容,就能够很容易判断。
  上面明确了「搜索区间」这个概念,而且本算法的搜索区间是两端都闭的,即 [left, right]。那么当我们发现索引 mid 不是要找的 target 时,下一步当然是去搜索 [left, mid-1] 或者 [mid+1, right] ,因为 mid 已经搜索过,应该从搜索区间中去除。
  解决完这两个问题后,我们就可以写出很多经典二分查找算法的变种,这个可以自己去尝试,这里不再赘述。

3. 寻找左侧边界

问题引出:如果待查找数在有序数组中是重复数字,那么上述算法虽然可以查找出一个正确的解,但是不能查找到处于边界的数字索引。比如有序数组nums = [1,2,2,2,3],target 为 2,此算法返回的索引是 2,该解没错。但是如果我想得到 target的左侧边界,即索引 1,或者我想得到 target 的右侧边界,即索引 3,这样的话此算法是无法处理的。这样的需求很常见,你也许会说,找到一个target,然后向左或向右线性搜索不行吗?可以,但是不好,因为这样难以保证二分查找对数级的复杂度了。

int leftBound(vector<int>& nums, int target) {
    int left = 0;
    int right = nums.size()-1; // 注意
	// 搜索区间为 [left, right]
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] < target) {
            // 搜索区间为 [mid+1, right]
            left = mid + 1;
        } else if (nums[mid] > target) {
            // 搜索区间为 [left, mid-1]
            right = mid - 1;
        } else if (nums[mid] == target) {
            // 注意:收缩右侧边界
            right = mid - 1;
        }
    }
    // 检查出界情况
    if (left >= nums.size() || nums[left] != target)
        return -1;
    return left;
}

这里的right取值以及while循环条件需要注意的细节和经典的二分查找算法一样,不清楚的可以往上面再翻一下。
可以看到寻找左侧边界的算法与经典二分查找算法只有两处不同,一个是当找到相同值时的操作,一个是返回值判断。下面我们一一进行讲解。

问题1. 为什么在找到目标值后不返回该索引,而是重新对right进行赋值?

  这正是该算法能找到边界值的关键点,找到 target 时不要立即返回,而是缩小「搜索区间」的上界 right,在区间 [left, mid] 中继续搜索,即不断向左收缩,达到锁定左侧边界的目的。

问题2. 为什么left是正确的返回值?

  如果该值存在于数组中,那么在找到相等值后right边界会不断向左收缩,直到right索引位于target目标值的左边一个索引,而退出循环的条件刚好是left=right+1,刚好是target的左侧边界索引值!

问题3. 为什么没有找到时能正确返回-1?

  没有找到target返回-1时有两种情况:
  一种是target大于数组中的所有值,如果target大于数组中的所有值,那么right永远不会改变,即right = nums.size()-1,left边界会不断收缩,直到退出循环时left=right+1,即left=nums.size();此时left超出边界,不能用nums[left] != target进行判断。
  另一种情况是target没有超出边界,但是不存在,这时只需要判断left索引对应的值是否等于target即可。这里还能判断target小于数组中所有值的情况,因为这时left的值始终不会改变,没有超出边界,可以通过nums[left] != target进行判断。

4. 寻找右侧边界

寻找右侧边界的应用场景和寻找左侧边界相似,同样我们也先来看代码:

int rightBound(vector<int>& nums, int target) {
    int left = 0, right = nums.size() - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid - 1;
        } else if (nums[mid] == target) {
            // 这里改成收缩左侧边界即可
            left = mid + 1;
        }
    }
    // 这里改为检查 right 越界的情况
    if (right < 0 || nums[right] != target)
        return -1;
    return right;
}

可以看到寻找右侧边界的代码和寻找左侧边界的代码相似,因此我们也像寻找左侧边界一样来回答3个问题:

问题1. 为什么在找到目标值后不返回该索引,而是重新对left进行赋值?

  这正是该算法能找到边界值的关键点,找到 target 时不要立即返回,而是缩小「搜索区间」的下界 left,在区间 [mid,right] 中继续搜索,即不断向右收缩,达到锁定右侧边界的目的。

问题2. 为什么right是正确的返回值?

  如果该值存在于数组中,那么在找到相等值后left边界会不断向右收缩,直到left索引位于target目标值的右边一个索引,而退出循环的条件刚好是left=right+1,即right=left-1,right刚好是target的右侧边界索引值!

问题3. 为什么没有找到时能正确返回-1?

  没有找到target返回-1时有两种情况:
  一种是target小于数组中的所有值,如果target小于数组中的所有值,那么left永远不会改变,即left = 0,right边界会不断收缩,直到退出循环时right=left-1,即right=-1;此时right超出边界,不能用nums[right] != target进行判断。
  另一种情况是target没有超出边界,但是不存在,这时只需要判断right索引对应的值是否等于target即可。这里还能判断target大于数组中所有值的情况,因为这时right的值始终不会改变,没有超出边界,可以通过nums[right] != target进行判断。

5. 经典例题

给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。 如果数组中不存在目标值
target,返回 [-1, -1]。

示例 1: 输入:nums = [5,7,7,8,8,10], target = 8 输出:[3,4]

示例 2: 输入:nums = [5,7,7,8,8,10], target = 6 输出:[-1,-1]

示例 3: 输入:nums = [], target = 0 输出:[-1,-1]

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/find-first-and-last-position-of-element-in-sorted-array

  这道题就是明显的边界搜索题目,我们可以通过上面讲述的左右边界搜索算法很快将这道题写出来。具体代码如下:

class Solution {
public:
    //寻找第一个的匹配值
    int leftBound(vector<int>& nums, int target) {
        int left = 0;
        int right = nums.size()-1; // 注意
        // 搜索区间为 [left, right]
        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] < target) {
                // 搜索区间为 [mid+1, right]
                left = mid + 1;
            } else if (nums[mid] > target) {
                // 搜索区间为 [left, mid-1]
                right = mid - 1;
            } else if (nums[mid] == target) {
                // 注意:收缩右侧边界
                right = mid - 1;
            }
        }
        // 检查出界情况
        if (left >= nums.size() || nums[left] != target)
            return -1;
        return left;
    }
    //寻找最后一个匹配值
    int rightBound(vector<int>& nums, int target) {
        int left = 0, right = nums.size() - 1;
        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] < target) {
                left = mid + 1;
            } else if (nums[mid] > target) {
                right = mid - 1;
            } else if (nums[mid] == target) {
                // 这里改成收缩左侧边界即可
                left = mid + 1;
            }
        }
        // 这里改为检查 right 越界的情况
        if (right < 0 || nums[right] != target)
            return -1;
        return right;
    }
    vector<int> searchRange(vector<int>& nums, int target) {
        if(nums.size()==0)
        {
            return {-1,-1};
        }
        int left=leftBound(nums,target);
        int right=rightBound(nums,target);
        return {left,right};
    }
};

你可能感兴趣的:(算法,C++编程,LeetCode,算法,二分查找,c++)