二分查找(循序渐进由0到1掌握二分)

写在前面:博主是一位普普通通的19届双非软工在读生,平时最大的爱好就是听听歌,逛逛B站。博主很喜欢的一句话花开堪折直须折,莫待无花空折枝:博主的理解是头一次为人,就应该做自己想做的事,做自己不后悔的事,做自己以后不会留有遗憾的事,做自己觉得有意义的事,不浪费这大好的青春年华。博主写博客目的是记录所学到的知识并方便自己复习,在记录知识的同时获得部分浏览量,得到更多人的认可,满足小小的成就感,同时在写博客的途中结交更多志同道合的朋友,让自己在技术的路上并不孤单。

目录:
1.二分查找简介
2.基本二分搜索
        基本二分搜索简介
        LeetCode 69.x的平方根
        LeetCode 374.猜数字大小
        LeetCode 33.搜索旋转排序数组
3.寻找左侧边界的二分搜索
        寻找左侧边界的二分搜索简介
        LeetCode 278.第一个错误的版本
        LeetCode 162.寻找峰值
        LeetCode 153. 寻找旋转排序数组中的最小值
4.寻找右侧边界的二分查找
        寻找右侧边界的二分查找简介
        LeetCode34.在排序数组中查找元素的第一个和最后一个位置
       

1.二分查找

二分查找法(Binary Search)算法,也叫折半查找算法。二分查找针对的是一个有序(如果集合是无序的,我们可以总是在应用二分查找之前先对其进行排序。)的数据集合,查找思想有点类似于分治思想。每次都通过跟区间的中间元素对比,将带查找的区间缩小为之前的一半,知道找到要查找的元素,或者区间被缩小为0。二分查找是一种非常非常高效的查询算法,时间复杂度未O(logn)。

2.基本二分搜索

2.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;
}

可能在基本二分搜索会遇到如下问题:

2.为什么 while 循环的条件中是 <=,而不是 <?

答:因为初始化 right 的赋值是 nums.length - 1,即最后一个元素的索引,而不是 nums.length。
这二者可能出现在不同功能的二分查找中,区别是:前者相当于两端都闭区间 [left, right],后者相当于左闭右开区间 [left, right),因为索引大小为 nums.length 是越界的。
我们这个算法中使用的是前者 [left, right] 两端都闭的区间。这个区间其实就是每次进行搜索的区间。
什么时候应该停止搜索呢?当然,找到了目标值的时候可以终止:

if(nums[mid] == target)
    return mid; 

但如果没找到,就需要 while 循环终止,然后返回 -1。那 while 循环什么时候应该终止?搜索区间为空的时候应该终止,意味着你没得找了,就等于没找到嘛。
while(left <= right) 的终止条件是 left == right + 1,写成区间的形式就是 [right + 1, right],或者带个具体的数字进去 [3, 2],可见这时候区间为空,因为没有数字既大于等于 3 又小于等于 2 的吧。所以这时候 while 循环终止是正确的,直接返回 -1 即可。
while(left < right) 的终止条件是 left == right,写成区间的形式就是 [left, right],或者带个具体的数字进去 [2, 2],这时候区间非空,还有一个数 2,但此时 while 循环终止了。也就是说这区间 [2, 2] 被漏掉了,索引 2 没有被搜索,如果这时候直接返回 -1 就是错误的。

2.此算法有什么缺陷?

比如说给你有序数组 nums = [1,2,2,2,3],target 为 2,此算法返回的索引是 2,没错。但是如果我想得到 target 的左侧边界,即索引 1,或者我想得到 target 的右侧边界,即索引 3,这样的话此算法是无法处理的。
这样的需求很常见,你也许会说,找到一个 target,然后向左或向右线性搜索不行吗?可以,但是不好,因为这样难以保证二分查找对数级的复杂度了。怎么说呢,比如一个数组
[1,2,3,4,5,5,5,5,5,1],让你找出target==5的右侧边界,你一次二分可以把5找出来,但是,你需要比较五次线性搜索才能才能找到右侧边界,是不是就很难保证时间复杂度

2.2LeetCode 69.x的平方根

题目描述:
实现 int sqrt(int x) 函数。
计算并返回 x 的平方根,其中 x 是非负整数。
由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。
示例 1:
输入: 4
输出: 2
示例 2:
输入: 8
输出: 2
说明: 8 的平方根是 2.82842…,
由于返回类型是整数,小数部分将被舍去。

双百二分:

class Solution {
public:
    int mySqrt(int x) {
     if(x==1||x==0)
     return x;
     int left=0;
     int right=x;
     int mid;
     int ans;
     while(left<=right)
     {
         mid=left+(right-left)/2;
         if((long long)mid*mid<=x)
         {
             ans=mid;
             left=mid+1;
         }
         else
         right=mid-1;
     }
     return ans;
    }
};
2.3LeetCode 374.猜数字大小

我们正在玩一个猜数字游戏。 游戏规则如下:
我从 1 到 n 选择一个数字。 你需要猜我选择了哪个数字。
每次你猜错了,我会告诉你这个数字是大了还是小了。
你调用一个预先定义好的接口 guess(int num),它会返回 3 个可能的结果(-1,1 或 0):
-1 : 我的数字比较小
1 : 我的数字比较大
0 : 恭喜!你猜对了!
示例 :
输入: n = 10, pick = 6
输出: 6

双百二分:

/** 
 * Forward declaration of guess API.
 * @param  num   your guess
 * @return 	     -1 if num is lower than the guess number
 *			      1 if num is higher than the guess number
 *               otherwise return 0
 * int guess(int num);
 */
class Solution {
public:
    int guessNumber(int n) {
        int left=0;
        int right=n;
        int mid;
        while(left<=right)
        {
            mid=left+(right-left)/2;
            if(guess(mid)==1)
                left=mid+1;
            else if(guess(mid)==-1)
                right=mid-1;
            else
            return mid;
        }
        return mid;

    }
};
2.4LeetCode33.搜索旋转排序数组

假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。
搜索一个给定的目标值,如果数组中存在这个目标值,则返回它的索引,否则返回 -1 。
你可以假设数组中不存在重复的元素。
你的算法时间复杂度必须是 O(log n) 级别。
示例 1:
输入: nums = [4,5,6,7,0,1,2], target = 0
输出: 4
示例 2:
输入: nums = [4,5,6,7,0,1,2], target = 3
输出: -1

代码实现:

class Solution {
public:
    int search(vector<int>& nums, int target) {
        int n = (int)nums.size();
        if (!n) return -1;
        if (n == 1) return nums[0] == target ? 0 : -1;
        int l = 0, r = n - 1;
        while (l <= r) {
            int mid = (l + r) / 2;
            if (nums[mid] == target) return mid;
            if (nums[0] <= nums[mid]) {
                if (nums[0] <= target && target < nums[mid]) {
                    r = mid - 1;
                } else {
                    l = mid + 1;
                }
            } else {
                if (nums[mid] < target && target <= nums[n - 1]) {
                    l = mid + 1;
                } else {
                    r = mid - 1;
                }
            }
        }
        return -1;
    }
};

3.寻找左侧边界的二分搜索

3.1寻找左侧边界的二分搜索简介与代码模板
int left_bound(int[] nums, int target) {
    if (nums.length == 0) return -1;
    int left = 0;
    int right = nums.length; // 注意
    
    while (left < right) { // 注意
        int mid = (left + right) / 2;
        if (nums[mid] == target) {
            right = mid;//注意
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid; // 注意
        }
    }
    return left;
}

可能你会存在很多疑问对这段代码,不急一个一个来看

1.为什么 while 中是 < 而不是 <=?

用相同的方法分析,因为 right = nums.length 而不是 nums.length - 1。因此每次循环的「搜索区间」是 [left, right) 左闭右开。
while(left < right) 终止的条件是 left == right,此时搜索区间 [left, left) 为空,所以可以正确终止。

PS:这里先要说一个搜索左右边界和上面这个算法的一个区别,也是很多读者问的:刚才的 right 不是 nums.length - 1 吗,为啥这里非要写成 nums.length 使得「搜索区间」变成左闭右开呢?

因为对于搜索左右侧边界的二分查找,这种写法比较普遍,我就拿这种写法举例了,保证你以后遇到这类代码可以理解。你非要用两端都闭的写法反而更简单,我会在后面写相关的代码,把三种二分搜索都用一种两端都闭的写法统一起来,你耐心往后看就行了。

2.while里为什么没有返回 -1 的操作?如果 nums 中不存在 target 这个值,怎么办?

因为要一步一步来,先理解一下这个「左侧边界」有什么特殊含义:

二分查找(循序渐进由0到1掌握二分)_第1张图片

对于这个数组,算法会返回 1。这个 1 的含义可以这样解读:nums 中小于 2 的元素有 1 个。比如对于有序数组 nums = [2,3,5,7], target = 1,算法会返回 0,含义是:nums 中小于 1 的元素有 0 个。
再比如说 nums = [2,3,5,7], target = 8,算法会返回 4,含义是:nums 中小于 8 的元素有 4 个。
综上可以看出,函数的返回值(即 left 变量的值)取值区间是闭区间 [0, nums.length],所以我们简单添加两行代码就能在正确的时候 return -1:

 if(left != nums.size() && nums[left] == target) return left;
  return -1;

3.为什么 left = mid + 1,right = mid ?和之前的算法不一样?

这个很好解释,因为我们的「搜索区间」是 [left, right) 左闭右开,所以当 nums[mid] 被检测之后,下一步的搜索区间应该去掉 mid 分割成两个区间,即 [left, mid) 或 [mid + 1, right)。其实也就是while(left保证查找空间在每一步中至少有 2 个元素

4、为什么该算法能够搜索左侧边界?

关键在于对于 nums[mid] == target 这种情况的处理:

if (nums[mid] == target)
    right = mid;

5.能不能想办法把 right 变成 nums.length - 1,也就是继续使用两边都闭的「搜索区间」?这样就可以和第一种二分搜索在某种程度上统一起来了。

答案是当然可以,只要你明白了「搜索区间」这个概念,就能有效避免漏掉元素,随便你怎么改都行。下面我们严格根据逻辑来修改:因为你非要让搜索区间两端都闭,所以 right 应该初始化为 nums.length - 1,while 的终止条件应该是 left == right + 1,也就是其中应该用 <=,如下:

int left_bound(int[] nums, int target) {
    // 搜索区间为 [left, right]
    int left = 0, right = nums.length - 1;
    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;
 }
}

由于 while 的退出条件是 left == right + 1,所以当 target 比 nums 中所有元素都大时,会存在以下情况使得索引越界:

二分查找(循序渐进由0到1掌握二分)_第2张图片

因此,最后返回结果的代码应该检查越界情况:

if (left >= nums.length || nums[left] != target)
    return -1;
return left;

所以完整的改进后:

int left_bound(int[] nums, int target) {
    int left = 0, right = nums.length - 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.length || nums[left] != target)
        return -1;
    return left;
}
3.2LeetCode278. 第一个错误的版本

你是产品经理,目前正在带领一个团队开发新的产品。不幸的是,你的产品的最新版本没有通过质量检测。由于每个版本都是基于之前的版本开发的,所以错误的版本之后的所有版本都是错的。
假设你有 n 个版本 [1, 2, …, n],你想找出导致之后所有版本出错的第一个错误的版本。
你可以通过调用 bool isBadVersion(version) 接口来判断版本号 version 是否在单元测试中出错。实现一个函数来查找第一个错误的版本。你应该尽量减少对调用 API 的次数。
示例:
给定 n = 5,并且 version = 4 是第一个错误的版本。
调用 isBadVersion(3) -> false
调用 isBadVersion(5) -> true
调用 isBadVersion(4) -> true
所以,4 是第一个错误的版本。

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

class Solution {
public:
    int firstBadVersion(int n) {
     int left=1;
     int right=n;
     int mid;
     while(left<=right)
     {
         mid=left+(right-left)/2;
        if(isBadVersion(mid)==true)
        right=mid-1;
        else if(isBadVersion(mid)==false)
        left=mid+1;
        else
       right=mid-1; 
     }
      if(isBadVersion(left)==true)
      return left;
      return -1;

    }
};
3.3LeetCode 162. 寻找峰值

峰值元素是指其值大于左右相邻值的元素。
给定一个输入数组 nums,其中 nums[i] ≠ nums[i+1],找到峰值元素并返回其索引。
数组可能包含多个峰值,在这种情况下,返回任何一个峰值所在位置即可。
你可以假设 nums[-1] = nums[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。
说明:
你的解法应该是 O(logN) 时间复杂度的。

首先要注意题目条件,在题目描述中出现了 nums[-1] = nums[n] = -∞,这就代表着 只要数组中存在一个元素比相邻元素大,那么沿着它一定可以找到一个峰值

class Solution {
public:
    int findPeakElement(vector<int>& nums) {
        int left=0;
        int right=nums.size()-1;
        int mid;
        while(left<right)
        {
            mid=left+(right-left)/2;
            if(nums[mid]>nums[mid+1])
            right=mid;
            else
            left=mid+1;
        }
        return left;
    }
};
3.4LeetCode 153. 寻找旋转排序数组中的最小值

假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。
请找出其中最小的元素。
你可以假设数组中不存在重复元素。
示例 1:
输入: [3,4,5,1,2]
输出: 1
示例 2:
输入: [4,5,6,7,0,1,2]
输出: 0

class Solution {
public:
    int findMin(vector<int>& nums) {
        int left = 0;
        int right = nums.size() - 1;                /* 左闭右闭区间,如果用右开区间则不方便判断右值 */ 
        while (left < right) {                      /* 循环不变式,如果left == right,则循环结束 */
            int mid = left + (right - left) / 2;    /* 地板除,mid更靠近left */
            if (nums[mid] > nums[right]) {          /* 中值 > 右值,最小值在右半边,收缩左边界 */ 
                left = mid + 1;                     /* 因为中值 > 右值,中值肯定不是最小值,左边界可以跨过mid */ 
            } else if (nums[mid] < nums[right]) {   /* 明确中值 < 右值,最小值在左半边,收缩右边界 */ 
                right = mid;                        /* 因为中值 < 右值,中值也可能是最小值,右边界只能取到mid处 */ 
            }
        }
        return nums[left];    /* 循环结束,left == right,最小值输出nums[left]或nums[right]均可 */     
    }
};


4.寻找右侧边界的二分查找

4.1寻找右侧边界的二分查找简介
int right_bound(int[] nums, int target) {
    int left = 0, right = nums.length - 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;
}

最后一段代码当 target 比所有元素都小时,right 会被减到 -1,所以需要在最后防止越界
二分查找(循序渐进由0到1掌握二分)_第3张图片

4.2 LeetCode34.在排序数组中查找元素的第一个和最后一个位置

给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。
你的算法时间复杂度必须是 O(log n) 级别。
如果数组中不存在目标值,返回 [-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]

代码:

class Solution {
public:
    vector<int> searchRange(vector<int>& nums, int target) {
        vector<int> vec;
        int left=0;
        int right=nums.size()-1;
        int mid;
        while(left<=right)
        {
            mid=left+(right-left)/2;
            if(nums[mid]==target)
                right=mid-1;
            else if(nums[mid]>target)
               right=mid-1;
            else
                left=mid+1;
        }
        if(left==nums.size()||nums[left]!=target)
        vec.push_back(-1);
        else
          vec.push_back(left);
        left=0;
        right=nums.size()-1;
        while(left<=right)
        {
            mid=left+(right-left)/2;
            if(nums[mid]==target)
                left=mid+1;
            else if(nums[mid]<target)
               left=mid+1;
            else
            right=mid-1;
        }
        if(right<0||nums[right]!=target)
        vec.push_back(-1);
        else
         vec.push_back(right);
        return vec;
    }
};

你可能感兴趣的:(数据结构与算法,#,查找)