二分查找总结——二分查找细节分析 + 红蓝染色法(Leetcode 34)

1. 写在前面

  本文为个人学习总结,二分算法参考:B站Up:灵茶山艾府(二分查找 红蓝染色法)

视频链接:https://www.bilibili.com/video/BV1AP41137w7/

Leetcode题目:34. 在排序数组中查找元素的第一个和最后一个位置
给你一个按照非递减顺序排列的整数数组 nums,和一个目标值 target。请你找出给定目标值在数组中的开始位置和结束位置。
如果数组中不存在目标值 target,返回 [-1, -1]。
你必须设计并实现时间复杂度为 O(log n) 的算法解决此问题。

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

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

2. 二分查找的区间细节分析

  二分查找(Binary Search)的算法思想是十分简单的,但二分法的细节却惊人的多。以下是一份二分查找的代码示例,在敲定二分法的代码时,我们通常会遇到注释的细节问题:

  // 下方代码的作用:查找有序正整数数组nums[]中第一个≥target的数的位置(下标)

  // 待查找数组 nums[], 待查找数 target
  int left = 0; // 数组最左端元素下标
  int right = nums.length - 1; // 数组最右端元素下标

  while(left <= right){ // 是left<right呢还是left<=right呢
    int mid = (left + right) / 2;
    if(nums[mid] >= target){ // 可以是nums[mid] > target吗
        right = mid - 1; // 是right = mid - 1呢还是right = mid呢?
    }else{
        left = mid + 1;  // 同理,可以是left = mid 吗?
     }
  }
  return left;

  其实,上面的情况都是可能存在的,而具体的代码写法要取决于我们对于查找区间的开闭选定,这个区间其实指的就是我们查找的范围,即left 和 right圈定的范围,常见的区间选定方法有:

  1. 左闭右闭 [left, right]
  2. 左闭右开 [left, right)
  3. 左开右开 (left, right)

  当我们在写二分查找的代码的时候,一定要先明确我们选定的区间的闭合情况,在整个二分查找的过程中都应该去遵循这个区间,才能保证我们的二分查找代码不出错。

  什么意思呢?我们来对不同情况分析一下。

① 左闭右闭[left, right]

  区间左闭右闭,也就是说我们每次二分查找的时候是包含left和right索引指向的那个数的,那么,我们对left和right的初始化应该如下所示:

  // 待查找数组 nums[], 待查找数 target
  // 假设nums为[1,2,5,8,11,12,13]

  int left = 0; // 数组最左端元素下标
  int right = nums.length - 1; // 数组最右端元素下标

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

  在代码中,left指向nums的第一个数,right指向nums的最后一个数,此时查找范围涵盖了整个数组,没有问题,对于其他的编写细节:
i) while(left<=right)
  前文提及,我们需要一直遵守我们选择的区间,那么,我们就需要去判断left=right对于当前选择的区间是不是合法的。
  当left=right时,显然,对于[left, right]这个区间有意义,因此我们此时还不能结束循环。循环条件为left<=right

ii) right = mid - 1 || left = mid + 1
  由于我们的区间是左闭右闭区间,包含两端,如果right = mid,我们的下一次查找区间为:[left, mid],此时mid指向的元素又被包括进来了,但是在之前的if条件判断中,我们知晓了nums[mid]与target的关系,所以下一次查找没有必要将mid指向的元素包括进来了,因此right = mid - 1。
  left = mid + 1的情况同理。

  // nums为[1,2,5,8,11,12,13], target为2

  第一次查找区间索引:[0, 6], mid = 3
  nums[mid] = 8 > 2, right = mid - 1 = 2
  
  下一次查找区间索引:[0, 2] (1,2,5)

  //假设right = mid 则下一次查找区间索引:[0, 3],包括的元素为1,2,5,8
  //但我们之前已经知道了mid指向的8是不符合条件的了,所以没有必要包含到下一次查找了

① 左闭右开[left, right)

  左闭右开,即不包含right指向的数,那么我们的代码会发生如下变化:

  // 待查找数组 nums[], 待查找数 target
  // 假设nums为[1,2,5,8,11,12,13]

  int left = 0; 
  int right = nums.length; // 数组长度!这里变了!

  while(left < right){  // 循环条件变了!
    int mid = (left + right) / 2;
    if(nums[mid] >= target){ 
        right = mid;  // 变啦!
    }else{
        left = mid + 1;
     }
  }
  return left;

i) 变化一:right的值
  int right = nums.length;
  区间为左闭右开,即不包含right指向的元素。因此,为了将查找范围涵盖整个数组,right不能为nums.length - 1,否则会丢失数组最后一个元素。
ii) 变化二:循环条件
  在左闭右开区间 [left, right) 中,left = right是没有意义的(如:(1,1]是没有意义的),因此在left = right时就可以结束循环了。循环条件为left<right,而非left<=right。
** iii) 变化三:下一次查找的right值**
  right = mid, 即下一次查找的区间为:[left, mid),下一次查找不会包含mid了,是符合我们之前的做法的。left = mid + 1是因为区间左闭,假如left = mid,下一次查找又会将mid指向的元素包括进来了,没有必要。


3. ☆☆红蓝染色法☆☆

  红蓝染色法,即将我们二分查找的数组划分为蓝色、红色两个区域,蓝色区域表示符合条件的数(True)的范围,红色区域表示不符合条件的数(False)的范围

  我们二分查找主要有如下情况:
① 查找 ≥x 的第一个元素
② 查找 >x 的第一个元素
③ 查找 <x 的最后一个元素
④ 查找 ≤x 的最后一个元素
  但系!这上面这四种情况都可以用一个代码来解决,我们后续会进行分析。

  我们先来看第一种情况,即查找≥x的第一个元素。我们用红蓝染色法来分析一下过程:

  示例数组nums[]:5 7 7 8 8 10
  目标:查找 ≥8 的第一个元素的下标
  
  定义:L的左边为红色区域,R的右边为蓝色区域
  初始时,红色和蓝色区域未扩展,分别在两侧  

  红色区域:[]
  蓝色区域:[]
  第一次: M = (L + R) / 2 = 2 (下取整)
      红|            | 蓝
         5 7 7 8 8 10
         ↑   ↑     ↑
         L   M     R
  由于nums[M]<8, L = M + 1
-----------------------------------------
  红色区域:[5,7,7]
  蓝色区域:[]
  第二次: M = (L + R) / 2 = 4 (下取整)
           红 |      | 蓝
         5 7 7 8 8 10
               ↑ ↑ ↑
               L M R
  由于nums[M]≥8, R = M - 1
-----------------------------------------
  红色区域:[5,7,7]
  蓝色区域:[10]
  第三次: M = (L + R) / 2 = 3 (下取整)
            红 |  | 蓝
         5 7 7  8  8 10
               ↑↑↑
               LMR
  由于nums[M]≥8, R = M - 1
-----------------------------------------
  最终:循环结束时,R在L的左边,如下:
  红色区域:[5,7,7]
  蓝色区域:[8,8,10]
             红|蓝
         5 7 7   8  8 10
             ↑   ↑
             R   L
  此时,返回R+1位置的元素即为所求,或者返回L位置的元素也可以。
  
// 采用左闭右闭区间,代码如下(其实就是我们上面一直在分析的代码):
class Solution {
    public int[] searchRange(int[] nums, int target) {
        int[] res = new int[2];
        // 寻找第一个 ≥target 的数的位置
        int start = binarySearch1(nums, target);
        // 数组中的数都<target 或 找到的数并不满足条件
        if(start==nums.length||nums[start]!=target) return new int[]{-1,-1};
        // 找 ≥target 的最后一个元素的位置 => 等价于找≥target+1 的第一个元素的左边一个数
        // 如:5 7 7 8 8 10,找8的最后一个,即找满足≥9的第一个(10)的左边一个数
        int end = binarySearch1(nums, target+1)-1;
        res[0] = start;
        res[1] = end;
        return res;
    }
    // 二分搜索 红蓝染色法 左闭右闭区间~~
    int binarySearch1(int[] nums, int target){
        int left = 0, right = nums.length-1;
        while(left<=right){
            int mid = (right-left)/2 + left;
            if(nums[mid]<target){
                left = mid + 1;
            }else{
                right = mid - 1;
            }
        }
        return left;
    }
}

PS:二分查找的情况转换
  在上面,我们给出了查找 ≥x 的第一个元素的代码,对于其他情况:
① 查找 >x 的第一个元素
  可转换为:查找满足≥(x+1)的第一个元素

② 查找 <x 的最后一个元素
  可转换为:查找满足≥x的第一个元素的左边那个数

③ 查找 ≤x 的最后一个元素
  可转换为:查找满足>x的第一个元素的左边那个数

posted @ 2024-03-19 19:51  糖苹魔芋怪  阅读(685)  评论(0)    收藏  举报