一文搞懂二分搜索的细节

467 阅读1分钟

二分搜索相关的题目,大概有两类:一类是找值的,一类是找范围的。二分搜索的大概思路是很快能想出来的,但是细节方面很折磨人,比如以下几点:

  1. 返回 left 还是 right
  2. 右边界是 nums.length 还是 nums.length - 1
  3. while ( left ? right) 是取 < 还是 <= 本文参考了 LeetCode 评论区的大佬的解法和labuladong的算法小抄,将对以上细节做补充,并应用到相应的题目。

查找一个数

这个场景是大家比较熟悉的,在一个排序的数组中查找一个数,找到就返回索引,没有就返回 -1,先来看模板

public int binarySearch(int[] nums, int target) {
    int left = 0; 
    int right = nums.length - 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. right 的初始化值,影响 while 循环中 left<right 还是 left<=right
  2. right 的初始化值也影响 right 应该更新为 mid+1 还是 mid

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

  • 因为 right 初始化的值是 nums.length-1,即最后一个元素的索引,对应的是一个闭区间 [left, right],这个闭区间中 left是可以等于right的,所以使用 <=

  • 如果 right 初始化的值是 nums.length,那么对应的是左闭右开区间 [left, right),这个闭区间中 left是不能等于right 的,则应当使用<

我们这个算法中使用的是前者 [left, right] 两端都闭的区间。这个区间其实就是每次进行搜索的区间。什么时候应该停止搜索呢,在找到目标值的时候可以停止搜素,但是如果没有找到目标值呢,就需要终止 while 循环,然后返回 -1

while(left <= right) 的终止条件是 left=right+1,写成区间是 [right+1, right],区间为空,此时终止 while 循环是正确的。如果使用while(left < right),终止条件是 left=right,写成区间是 [right, right],还有个 right 没有遍历到,此时直接终止循环就是错误的,如果硬是要使用 <,就需要在返回的时候做一下处理:

return nums[left] == target ? left : -1;

2. while 循环中 right 是等于 mid 还是 mid+1 ?

这个就是上面分析的第二点了:

  • right 初始化为 nums.length-1的时候,代表的是一个左闭右闭区间 [left, right],在 nums[mid] 判断之后,下一个循环 nums[mid] 就不用再判断了,所以左边区间 [left,mid-1],右边区间是 [mid+1,right]
  • right 初始化为 nums.length的时候,代表的是一个左闭右开区间 [left, right),在 nums[mid] 判断之后,下一个循环 nums[mid] 就不用再判断了,但是因为右边是开,所以左边区间 [left,mid),右边区间是 [mid+1,right)

查找左边界

先看模板,尽量把三种 if 都列举出来,方便自己搞懂三种都是什么情况

public 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 - left) / 2;
        if (nums[mid] == target) {
            right = mid;
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid; // 注意
        }
    }
    return left;
}

同样的,while 循环 使用 < 还是 <= 取绝于 right 的取值,只是查找边界比较常用左闭右开的模板,用左闭右闭也是可以的。

退出循环的时候 left==right,所以返回 left 还是 right 是一样的

和上面查找某个值不同的是,当 nums[mid] == target 的时候,right=mid,查找的是左边界,所以往左靠,至于为什么是 right=mid,还是取绝于 right 的取值。

1.如果我就是要right = nums.length - 1呢?要怎么写

也是可以的,while 的终止条件应该是 left == right + 1,也就是其中应该用 <=

public int left_bound(int[] nums, int target) {
    if (nums.length == 0) return -1;
    int left = 0;
    int right = nums.length - 1; // 注意

    while (left <= right) { // 注意
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            right = mid - 1; // 注意
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid - 1; // 注意
        }
    }
    
    // 检查出界情况
    if (left >= nums.length || nums[left] != target){
        return -1;
    }
    return left; // 为什么这个还是left呢,自己写一遍就知道了
}

因为退出循环的条件是 left == right + 1,所以当 targetnums 中所有元素都大时,会使得索引越界,所以要检查出界情况,也可以一开始就判断 target 是否大于数组中的最大元素,见下图

查找右边界

直接上代码,还是左闭右开的写法

public int right_bound(int[] nums, int target) {
    if (nums.length == 0) return -1;
    int left = 0, right = nums.length;

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

为什么返回的结果是 left - 1 呢,自己写一遍就知道了,这个我也说不出个所以然,姑且记住吧。

同样给出 right = nums.length - 1 的写法

public 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;
}

到这里,二分搜索算法就结束了,如果有不理解的可以看本文开头的链接(labuladong的算法小抄)。