二分查找与边界的讨论

二分查找与边界的讨论

因为在vue的一个项目中需要滚动列表并联动到右部固定导航栏,因此得需找到某个高度对应的所属区间索引,考虑到列表长度可能很长且线性的搜索会比较慢,因此考虑了使用二分搜索进行优化,但是正如Knuth所说:

二分查找思路很简单,细节是魔鬼。

接下来让我们一起来对抗evil😈😈😈吧!

基础框架

就像学动态规划、深度优先抑或广度优先一样,每个算法都有自己的一套模板,或者叫框架。这种骨架式的、类似于咱前端熟悉的HTML结构必须得熟练写出来,美其名曰需掌握基本的DOM渲染。二分查找的模板如下:

function binarySearch(nums, target) {
    let [left, right] = [0, xxx];
    while(xxx) {
        let mid = left + Math.floor((right - left) / 2);
        if (nums[mid] === target) {
            xxx;
        } else if (nums[mid] < target) {
            left = xxx;
        } else if (nums[mid] > target) {
            right = xxx;
        }
    }
    return xxx;
}

其中xxx表示我们需要根据不同的业务需求填补的代码,另外需要注意的是在计算mid的时候采用了上述那种看似复杂的写法,而没有直接使用:

let mid = Math.floor((right + left)) / 2 ;

因为right + left的结果有可能很大而导致溢出(Javascript的应用中可能见得不多),但为了统一也就这样吧。

查找一个数的索引

查找一个数并返回索引是二分查找最常用的场景,即在一个数组中查找target,如果找到了则返回索引,否则就返回-1。这就引出了问题了:

  1. while里面到底是<=还是<
  2. left, right应该如何移动?

首先我们假设right = nums.length - 1,则对此搜索空间变为[left, right],即前闭后闭区间。如果我们在<的时候就返回,则该leftright索引的值根本没有进行判断就返回就有可能导致搜索结果出错。因此在前闭后闭的情况下,退出条件为left > right,则才说明了搜索完成。当然了,由于是闭区间,所以哪一边不满足我们就应该移动哪一边,也就是leftright都移动。一个简单的二分查找代码如下:

function binarySearch(nums, target) {
    let [left, right] = [0, nums.length - 1];
    while(left <= right) {
        let mid = left + Math.floor((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, 2, 2, 2, 3, 4]而言,如果target = 2,则我们直接就返回了索引2,但万一我们需要索引为1的2,或者需要索引为3的2呢?就像我刚刚提到的项目那样,实际上我需要的是索引为1的2,那么代码该怎么改呢?

左侧边界的二分查找

考虑到需要查找某一侧的边界,则为了使得模板的代码更为简洁统一,我们可以假设:[left, right] = [0, nums.length]。这样搜索空间就变成了前闭后开,符合我们日常学数学的那套逻辑了。

那么既然是前闭后开,我们就可以得出结束条件是<而不是<=了,因为<已经确保了完成所有搜索,就没必要<=了。然后再来举例说明什么叫左侧边界:

假设数组为[1, 2, 2, 2, 3, 4],如果target = 2,那么返回索引为1,即最左边那个2,其表示的数学含义就是返回整个数组中严格小于target数的个数,如果target = 0,则返回0表示没有一个数比0还小,或者如果target = 5,则返回的6表示数组中有6个数都严格小于5

那么leftright的移动就很好理解了。由于是前闭后开区间,当我们遇到nums[mid]小于target,则说明至少有mid+1个数严格小于target,理应将left = mid + 1,而当我们遇到nums[mid]大于target的时候,令right = mid从而保证正确的上界。换句话说因为前闭后开,我们通过mid分割了两个不同的搜索空间:[left, mid)[mid+1, right)

最重要的一点就是,当nums[mid] == target的时候,我们不需要直接返回,而是令right = mid即可。道理同上面一样,就是不断地收缩左边界,达到搜索左边界的目的。享用代码:

function binarySearchLeft(nums, target) {
    if (nums.length === 0) {
        return -1;
    }
    let [left, right] = [0, nums.length];
    while(left < right) {
        let mid = left + Math.floor((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;
}

退出条件为left == right, 所以返回leftright没什么区别。

右侧边界的二分查找

其实同上面左侧边界的查找方法类似,我们还是假设前闭后开的区间,然后有两个点同左侧边界不同:

  1. nums[mid] == target的时候
  2. 函数返回的时候

这里做一个简单的说明。假设数组为[1, 2, 2, 2, 3, 4],如果target = 2,那么返回索引为3,即最右边那个2,其表示的数学含义就是返回整个数组中小于等于target数的个数 - 1,当target = 0的时候返回-1,表示没有查找到该数字,当target = 5时返回5表示数组中所有数都小于5(因为数组个数为6)。相信不用举例也能大概看出一些端倪了。很显然,右侧边界则同样需要left = mid + 1进行左边界的收缩,而当nums[mid] == target的时候,我们同样令left = mid + 1来继续搜索右侧边界,这样最后得到的left则表示了整个数组中小于等于target数的个数了,然后如上所述,返回left - 1即可。代码如下:

function binarySearchRight(nums, target) {
    if (nums.length === 0) {
        return -1;
    }
    let [left, right] = [0, nums.length];
    while(left < right) {
        let mid = left + Math.floor((right - left) / 2);
        if (nums[mid] === target) {
            left = mid + 1; // different from binarySearchLeft here
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid;
        }
    }
    return left - 1; // different from binarySearchLeft here
}

记忆公式

上面的讨论当然是偏学术化了一些,在实际应用中还是记忆公式来得快。看网上有人总结得挺好1,归纳如下:

最基本的二分查找算法

因为我们初始化 right = nums.length - 1
所以决定了我们的「搜索区间」是 [left, right]
所以决定了 while (left <= right)
同时也决定了 left = mid+1 和 right = mid-1

因为我们只需找到一个 target 的索引即可
所以当 nums[mid] == target 时可以立即返回

寻找左侧边界的二分查找

因为我们初始化 right = nums.length
所以决定了我们的「搜索区间」是 [left, right)
所以决定了 while (left < right)
同时也决定了 left = mid + 1 和 right = mid

因为我们需找到 target 的最左侧索引
所以当 nums[mid] == target 时不要立即返回
而要收紧右侧边界以锁定左侧边界

寻找右侧边界的二分查找

因为我们初始化 right = nums.length
所以决定了我们的「搜索区间」是 [left, right)
所以决定了 while (left < right)
同时也决定了 left = mid + 1 和 right = mid

因为我们需找到 target 的最右侧索引
所以当 nums[mid] == target 时不要立即返回
而要收紧左侧边界以锁定右侧边界

又因为收紧左侧边界时必须 left = mid + 1
所以最后无论返回 left 还是 right,必须减一

好了,二分查找也就差不多了。看来小小的算法也不简单啊!

参考

二分查找详解

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Key Board

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值