【数据结构与算法】二分查找模板(Java)

1 二分查找基本思想

二分查找的基本思想是将 n 个元素分成大致相等的两部分,取 a [ n / 2 ] a[n/2] a[n/2] 与 x 做比较:

  • 如果 x = a [ n / 2 ] x=a[n/2] x=a[n/2] , 则找到 x , 算法中止
  • 如果 x < a [ n / 2 ] x<a[n/2] x<a[n/2] , 则只需要在数组 a 的左半部分继续搜索 x
  • 如果 x > a [ n / 2 ] x>a[n/2] x>a[n/2] , 则只需要在数组 a 的右半部分继续搜索 x

2 二分查找时间复杂度

时间复杂度即是 while 循环的次数。

总共有 n 个元素,渐渐跟下去就是 n, n/2 , n/4 ,… n / 2 k n/2 ^ k n/2k(接下来操作元素的剩余个数),其中 k 就是循环的次数

由于 n / 2 k n/2^k n/2k 取整后 >= 1

即令 n / 2 k = 1 n/2^k=1 n/2k=1

可得 k = l o g 2 n k=log_2n k=log2n,(以2为底,n的对数)

所以时间复杂度可以表示 O ( n ) = O ( l o g 2 n ) O(n)=O(log_2n) O(n)=O(log2n) 或者 O ( n ) = O ( l o g n ) O(n)=O(logn) O(n)=O(logn)

3 二分查找模板

一般而言,当一个题目出现以下特性时,我们就应该立即联想到它可能需要使用二分查找:

  • 待查找的数组有序或者部分有序

  • 要求时间复杂度低于 O(n),或者直接要求时间复杂度为 O(log n)

3.1 模板一:标准的二分查找

首先,标准二分查找的适用场景是:数组元素有序且不重复

我们以 “搜索一个数,如果存在,返回其索引,否则返回 -1” 为例,给出标准二分查找的模板:

// 二分查找某个数
public int binarySearch(int[] nums, int target) {
    int left = 0;
    int right = nums.length - 1; 
    while (left <= right) { // 注意
        int mid = left + ((right - left) >> 1);
        if (nums[mid] == target) 
            return mid;
        // 收缩右边界
        else if (nums[mid] > target)
            right = mid - 1;
        // 收缩左边界
        else // (nums[mid] < target)
            left = mid + 1; 
    }
    return -1;
}
  • 循环条件: left <= right
  • 中间位置计算: mid = left + ((right -left) >> 1) ( 位运算 >> 1 即 除法运算 / 2
  • 左边界更新:left = mid + 1
  • 右边界更新: right = mid - 1
  • 返回值: mid-1

🚨 这里有几点需要注意:

🔸 ① 为什么 while 循环的条件是 <=,而不是 < ?

答:因为初始化 right 的赋值是 nums.length - 1,即最后一个元素的索引,而不是 nums.length

在 right 是这样赋值的前提下,<= 相当于两端都是闭区间 [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 就是错误的。

💡 当然,如果你非要用 while(left < right) 也可以,我们已经知道了出错的原因,打个补丁好了:

    //...
    while(left < right) {
        // ...
    }
    return nums[left] == target ? left : -1; // 判断 nums[left] 和 nums[right] 一样,因为最后循环终止的时候 left == right

🔸 ② 计算 mid 时需要防止溢出

left + ((right -left) >> 1) 其实和 (left + right) / 2 是等价的,这样写的目的一个是为了防止 (left + right) 出现溢出,另外用位运算替代除法提升性能。

left + ((right -left) >> 1) 对于目标区域长度为奇数而言,是处于正中间的,对于长度为偶数而言,是中间偏左的。因此左右边界相遇时,只会是以下两种情况:

  • left/mid , right ( left, mid 指向同一个数,right 指向它的下一个数)
  • left/mid/right ( left, mid, right 指向同一个数)

因为 mid 对于长度为偶数的区间总是偏左的,所以当区间长度小于等于 2 时,mid 总是和 left 在同一侧。

3.2 模板二:二分查找边界

上述算法自然是无法应用到所有场景的,比如说给你有序数组 nums = [1,2,2,2,3]target 为 2,此算法返回的索引是 2。但是如果我想得到 target 的左侧边界,即索引 1,或者我想得到 target 的右侧边界,即索引 3,这样的话上述标准形式的二分查找算法是无法处理的。

利用二分法寻找左(右)边界是二分查找的一个变体,一般适用场景可以分为两种:

1)第一种情况

  • 数组有序,但包含重复元素
  • 数组部分有序,且不包含重复元素

在这里插入图片描述

2)第二种情况

  • 数组部分有序,且包含重复元素

3.2.1 二分查找左边界

1)先来看第一种情况

  • 数组有序,但包含重复元素
  • 数组部分有序,且不包含重复元素

查找右边界和左边界的模板差不多,这里我们先以查找左边界为例:

既然要寻找左边界,搜索范围就需要从右边开始,不断往左边收缩,也就是说即使我们找到了nums[mid] == target, 这个 mid 的位置也不一定就是最左侧的那个边界,我们还是要向左侧查找,也就是说,除了在 nums[mid] 偏大的时候需要收锁右边界,在 nums[mid] 等于目标值的时候,不同于标准的二分查找直接返回,我们此时仍然需要继续收缩右边界

算法模板如下:

// 二分查找某个数的左边界
public int binarySearch(int[] nums, int target) {
    int left = 0;
    int right = nums.length - 1;
    while (left < right) { // 注意
        int mid = left + ((right - left)) >> 1;
        // 收缩左边界
        if (nums[mid] < target) {
            left = mid + 1;
        } else { // nums[mid] >= target
            // 收缩右边界
            right = mid;
        }
    }
    
    // 打个补丁
    return nums[left] == target ? left : -1;
}
  • 循环条件: left < right
  • 中间位置计算: mid = left + ((right -left) >> 1)
  • 左边界更新:left = mid + 1
  • 右边界更新: right = mid
  • 返回值: nums[left] == target ? left : -1

🚨 与标准的二分查找不同:

① 这里的右边界的更新是 nums[mid] >= targets,因为我们需要在找到目标值后,继续向左寻找左边界。

② 这里的循环条件是 left < right 而不是 <=

我们把处理到 left + 1 = right 位置上的 while 循环称为倒数第二轮循环,把处理到 left = right 也即最后一个数的 while 循环称为最后一轮循环。

因为在最后 leftright 相邻的时候也即 left + 1 = right 倒数第二轮循环的时候,midleft 处于相同的位置(前面说过,mid 偏左),那么,此时无论是走到 left = mid + 1 也好,还是 right = mid 也好,如果此时循环的条件是 left <= right,就会进入最后一轮循环(left = right,只剩最后一个数了),left 和 right 必定是处于同一个位置,也就是说 left, mid, right 都将指向同一个位置,对吧,其实这逻辑没有问题,标准的二分查找中也确实需要走到最后一步循环。

但是,我们这的代码和标准二分查找的代码不同:

  • 如果 nums[mid] < target,left 会变成 mid + 1 从而破坏 while 条件从而循环正常终止;
  • 🟡 但是,如果 nums[mid] >= target ,我们会令 right = mid,这个时候总共只有一个数了,mid 赋值给 right 并没有改变 left,mid,right 的位置,这将导致彻底进入死循环!

那既然如果走到最后一轮循环会可能导致死循环的发生,那我们只走到倒数第二轮循环即 leftright 相邻就好了,把最后一个数单独拉出来处理(或者说打个补丁):

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

2)再来看第二种情况

  • 数组部分有序,且包含重复元素

同样地,我们先以查找左边界为例:

这种条件下,当我们找到目标元素的时候(nums[mid] = target),向左收缩右边界不能简单的令 right = mid,因为有重复元素的存在,这会导致我们有可能遗漏掉一部分区域。此时需要采用比较保守的方式即 right --,代码模板如下:

// 二分查找某个数的左边界
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) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            // 收缩右边界
            right = mid;
        } else { // nums[mid] == target
            // 保守收缩右边界
            right--;
        }
    }
    
    // 打补丁
    return nums[left] == target ? left : -1;
}

它与类型 1 的唯一区别就在于对右侧值的收缩更加保守。这种收缩方式可以有效地防止我们一下子跳过了目标边界从而导致了搜索区域的遗漏。

3.2.2 二分查找右边界

有了寻找左边界的分析之后,再来看寻找右边界就容易很多了,毕竟左右两种情况是对称的,适用场景也都是一样的,大家对称着理解就好,这里我们给出二分查找右边界的第一种情况的模板(即数组有序,但包含重复元素;数组部分有序,且不包含重复元素):

查找左边界的代码中我们是先收缩左边界然后收缩右边界,那么相反的,查找右边界的时候我们就需要先收缩右边界然后收缩左边界。

// 二分查找某个数的右边界
public int binarySearch(int[] nums, int target) {
    int left = 0;
    int right = nums.length - 1;
    while (left < right) {
        int mid = left + ((right - left) / 2) + 1;
        // 收缩右边界
        if (nums[mid] > target) {
            right = mid - 1;
        } else {
            // 收缩左边界
            left = mid;
        }
    }
    return nums[right] == target ? right : -1;
}
  • 循环条件: left < right
  • 中间位置计算: mid = left + ((right -left) >> 1) + 1
  • 左边界更新:left = mid
  • 右边界更新: right = mid - 1
  • 返回值: nums[right] == target ? right : -1

🚨 这里大部分和寻找左边界是对称着来写的,唯独有一点需要尤其注意 —— 中间位置的计算变了,我们在末尾多加了 1。这样,无论对于奇数还是偶数,这个中间的位置都是偏右的。

对于这个操作的理解,从对称的角度看,寻找左边界的时候,中间位置是偏左的,那寻找右边界的时候,中间位置就应该偏右,但是这显然不是根本原因。

根本原因是,在最后leftright相邻时,如果mid偏左,则left, mid指向同一个位置,right指向它们的下一个位置,在nums[left]已经等于目标值的情况下,这三个位置的值都不会更新,从而进入了死循环。所以我们应该让mid偏右,这样left就能向右移动。这也就是为什么我们之前一直强调查找条件,判断条件和左右边界的更新方式三者之间需要配合使用。

右边界的查找一般来说不会单独使用,如有需要,一般是需要同时查找左右边界。

3.2.3 二分查找左右边界

前面我们介绍了左边界和右边界的查找,那么查找左右边界就容易很多了——只要分别查找左边界和右边界就行了。

这里给出符合第一种情况的模板,(即数组有序,但包含重复元素;数组部分有序,且不包含重复元素)

// 二分查找某个数的左右边界
public int[] searchRange(int[] nums, int target) {

    // 存储左右边界 res[0] 左边界, res[1] 右边界
    int[] res = new int[]{-1, -1}; 

    if(nums == null || nums.length == 0) 
        return res;

    // 寻找左边界
    int left = 0;
    int right = nums.length - 1;
    while (left < right) {
        int mid = ((left + right) >> 1);
        if (nums[mid] < target) {
            left = mid + 1;
        } else {
            right = mid;
        }
    }
    res[0] = nums[left] == target ? left : -1;

    // 如果左边界是最后一个数的下标或者该数没有重复只存在一个,那么可以直接令右边界 = 左边界
    if (res[0] != -1) {
        if (left == nums.length - 1 || nums[left + 1] != target) {
            res[1] = left;
        } else {
            // 寻找右边界
            right = nums.length - 1;
            while (left < right) {
                int mid = ((left + right) >> 1) + 1;
                if (nums[mid] > target) {
                    right = mid - 1;
                } else {
                    left = mid;
                }
            }
            res[1] = nums[right] == target ? right : -1;
        }
    }

    return res;
}

3.3 模板三:二分查找极值点

标准的二分查找还有一种变体是二分查找极值点,之前我们使用 nums[mid] 去比较的时候,常常是和给定的目标值 target 比,或者和左右边界比较,在二分查找极值点的应用中,我们是和相邻元素去比,以完成某种单调性的检测

public int binarySearch(int[] nums){
    int left = 0;
    int right = nums.length - 1;

    while(left <= right){
        int mid = left + ((right - left) >> 1);
        // 极值点
        if (nums[mid] > nums[mid + 1] && nums[mid] > nums[mid - 1]) {
            return mid;
        } else if (nums[mid] > nums[mid + 1]){ 
            // 极值点在 mid 左边
            right = mid - 1;
        } else {
            // 极值点在 mid 右边
            left = mid + 1;
        }
    }

    return -1;
}

可以看到和标准二分查找的代码唯一不同之处就是 if 判断的不同,其他都是一样的。

⭐ 需要注意的是,这里 if 判断中一定要先处理 nums[mid + 1],不然 nums[mid - 1] 很可能下标越界

4 小结

总结下二分查找的大模板:(都是采用 [left, right] 左闭右闭区间)

1)标准的二分查找(有序,无重复元素

重点关注:while 循环内部 left <= right,满足条件后执行 left = mid + 1, right = mid - 1;

2)二分查找边界

重点关注:while 循环内部 left < right,跳出 while 后需要打补丁判断

  • 二分查找左边界

    • 情况 1(部分有序,无重复元素;或者 有序,有重复元素

      重点关注:在 nums[mid] 等于目标值的时候,不同于标准的二分查找直接返回,我们此时仍然需要继续收缩右边界,即收缩右边界的条件是:nums[mid] >= target,而不是 nums[mid] > target,收缩右边界执行 right = mid 而不是 right = mid - 1

    • 情况 2(部分有序,有重复元素

      重点关注:当我们找到目标元素的时候(nums[mid] = target),向左收缩右边界不能简单的令 right = mid,因为有重复元素的存在,这会导致我们有可能遗漏掉一部分区域。此时需要采用比较保守的方式即 right --。即将上述情况一的 nums[mid] >= target 拆分开来,nums[mid] > target 时执行 right = midnums[mid] == target 时,执行 right --

  • 二分查找右边界(跟上面一样,将 left 和 right 的处理互换个位置就行了)

3)二分查找极值点

重点关注:和标准二分查找的代码唯一不同之处就是 if 判断的不同,其他都是一样的。将和 target 的比较改成和 numd[mid - 1] 和 nums[mid + 1] 的比较就行了。

另外,if 判断中一定要先处理 nums[mid + 1],不然 nums[mid - 1] 很可能下标越界

5 参考

https://gitee.com/veal98/cs-wiki

  • 2
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值