二分查找与边界的讨论
因为在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
。这就引出了问题了:
while
里面到底是<=
还是<
?left, right
应该如何移动?
首先我们假设right = nums.length - 1
,则对此搜索空间变为[left, right]
,即前闭后闭区间。如果我们在<
的时候就返回,则该left
或right
索引的值根本没有进行判断就返回就有可能导致搜索结果出错。因此在前闭后闭
的情况下,退出条件为left > right
,则才说明了搜索完成。当然了,由于是闭区间,所以哪一边不满足我们就应该移动哪一边,也就是left
和right
都移动。一个简单的二分查找代码如下:
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
。
那么left
与right
的移动就很好理解了。由于是前闭后开
区间,当我们遇到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
, 所以返回left
和right
没什么区别。
右侧边界的二分查找
其实同上面左侧边界的查找方法类似,我们还是假设前闭后开
的区间,然后有两个点同左侧边界不同:
- 当
nums[mid] == target
的时候 - 函数返回的时候
这里做一个简单的说明。假设数组为[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,必须减一
好了,二分查找也就差不多了。看来小小的算法也不简单啊!