以力扣35题为例讲解二分查找及其扩展

374 阅读4分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第7天,点击查看活动详情

二分查找的概念

如果有一个数组,我们要在里面查找某一个目标值存不存在,最简单的办法,就是遍历一遍,遍历的时候,直接就能看出目标值是否存在于数组中。

但是我们如果给定这个数组是已经排好序的,那么就不用遍历每一个元素。

大概的思路就是,先找到数组中间位置的元素,拿这个元素与目标值比较,如果刚好等于目标值,则退出。

如果中间元素比目标值大,说明目标值肯定出现在前面;
如果中间元素比目标值小,说明目标值肯定出现在后面。

以此类推,循环即可。

这就是二分查找的思路。

力扣35题描述

这里直接copy过来原题(35. 搜索插入位置 - 力扣(LeetCode)):

  • 给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。

注意原题中,确保了这个数组无重复数组,我们这里去掉这个限定,也就是说数组元素可以重复。

这个题非常具有代表性,可以说这一题的解法非常容易扩展到其他二分的场景中。所以我们先来分析这一题如何正确的使用代码来进行二分查找。

之所以这个题妙,妙就妙在,这个题包含了目标值不存在于数组中的情况, 此时我们需要返回,插入这个目标值之后,这个目标值的下标。

  • 比如说这个目标值比第一个值还要小,我们就返回下标0,因为插入之后,这个目标值肯定是位于下标0的位置。
  • 比如说这个目标值比最后一个值还要大,我们就返回数组长度,因为插入之后,这个目标值肯定是位于最后的位置。

这里还要关注一点,因为有重复元素,我们先规定一下,如果目标值刚好与某个重复元素相等,此时应该返回第一个下标。

比如说在

[1,1,2,2,2,3,3,3,4,5][1,1,2,2,2,3,3,3,4,5]

这个数组中,查找目标3,应该返回第一个3的下标,也就是55

针对上述问题的代码思路

现在撇开二分不谈,我们给出一个思考题:

上述数组为data, 查找目标记作target, 结果输出记作ans

问题1:data[index] < target

如果在下标index处, 有data[index] < target,即此处的值比目标值要大。
此时ans还有没有可能等于index,或者比index还要小?

答案是:绝无可能,ans一定是在index右边。

因为这个题的题意是要找,插入位置的下标,试想,如果把这个目标值放入index的位置,那么势必该位置原有的元素会往后挪一位,此时已经违背了有序数组这个前提了。

有了上面的结论,在更新前后查找下标的时候,思路将会十分清晰。

再来思考一个相应的问题:

问题2:data[index] > target

如果在下标index处, 有data[index] > target,即此处的值比目标值要大。
此时ans是否一定在index左边?

答案:ans可能在index左边,也有可能ans==index

来看这个例子, 数组[1,2,5][1,2,5], 目标值33,此时目标值33应该是顶替55的位置,所以ans = 2。

我们发现,即使在原有的数组里,下标22的元素大于目标值33, 但是目标值依然可能就插入在这个位置。

问题3:data[index] == target

这种情况下,说明 index 出的元素刚好是我们的目标值,但是此时还不能立即返回,因为我们上面有规定,是要查找到第一个出现目标值的位置,所以此情况下,最终的ans依然有可能在index左边。也就是说此情况与问题2的情况相同对待。

代码如下

有了上面三种情况的分析之后,我们代码如下:

function bs(data, target, begin, end) {
    let left = begin;
    let right = end;
    let mid = 0;
    while(left < right){
        mid = (left + right)/2;
        if (nums[mid]<target) 
        {
            left = mid + 1;
        }else{
            right = mid;
        }
    }
    return left;
}

其中begin和end是ans可能取值的最小和最大值,在一般情况下,begin 传入0,end传入数组的长度即可。

注意,end 不是 数组的长度 - 1, 而就是数组的长度,因为ans在极端情况下,就是数组的长度。

mid的计算方式

看这一句:

mid = (left + right)/2

这种计算方式,mid是靠左的,也就是说,极端情况下,mid == left。

会发生无限循环吗,不会。

因为下面的两个语句分支,都直接会导致left > right。所以不会发生无限循环。

改动查找目标为从左往右最后一个相等的元素

[1,1,2,2,2,3,3,3,4,5][1,1,2,2,2,3,3,3,4,5], 来搜索3,按照上面的代码,会得到下标5。

现在想要得到最后一个3,也就是要得到7, 代码如何实现?

我们在上面的代码里,是这样来执行逻辑的:

  • 如果处于mid下标的元素 = target,我们就把最终范围的右边界right缩到了mid处。

这很明显不行,因为mid的右边很有可能依然有等于target的元素存在。

所以我们遇到这种情况,需要把左边界left移动到mid+1。

此时也会出现一个问题,那就是,left代表了最终ans的最小值。如果mid+1处的元素已经大于target,很明显返回的left是一个错误的ans。

不要紧,如果真出现了 data[ans]>targetdata[ans] > target, 此时做一次额外的判断就行了。

代码如下:

function bs(data, target, begin, end) {
    let left = begin;
    let right = end;
    let mid = 0;
    while(left < right){
        mid = (left + right)/2;
        if (nums[mid]<=target) // 注意这里变成<=
        {
            left = mid + 1;
        }else{
            right = mid;
        }
    }
    // 如果left已经越界或者left处的元素大于target
    if (left == end || data[left]>target ) {
        if (data[left - 1] == target) {
            return left - 1;
        }
    }
    return left;
}