"一听就会,一写就废"的二分法
二分法(Binary Search Algorithm),是一种在有序数组中查找某一特定元素的搜索算法。搜索过程从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜索过程结束;如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。这种搜索算法每一次比较都使搜索范围缩小一半。
二分法在最好情况下是常数复杂度,即 ,而最坏情况下是对数时间复杂度的,即 。二分查找算法使用常数空间,对于任何大小的输入数据,算法使用的空间都是一样的。除非输入数据数量很少,否则二分查找算法比线性搜索更快,但数组必须事先被排序。
- 二分法最好情况
- 二分法最坏情况
二分法“一听就会,一写就废”的原因在于以下几点:
区间模型
如何选取左指针 ,右指针 ?
- 左闭右闭
- 左闭右开
- 左开右闭
- 左开右开
循环条件
如何选取二分法循环条件 ?
mid指针取值
如何计算 指针 ?
如果上述条件选取不好,就有可能出现“漏值(边界条件遗漏)”、“死循环( 或 反复被赋值相同的 )”等的情况。故我们基于最简单的二分模型(即, 是单调递增且无重复元素的数组,参考704. 二分查找)对以上情况进行展开讨论,从而找到最佳实践。
左闭右闭的区间模型
不忘初心,方得始终,既然“初心”选择了 [,],那么接下来收敛二分区间的过程都要遵循这个区间模型。
1、区间初始定义
区间初始值定义:, ,其中 是数组 的长度。
2、循环条件选择
这里需要思考一下,究竟是 还是 ?一言蔽之,就判断 是否有意义。显然,对于 区间模型, 是有意义的。因为当 时,代表当前区间只有一个元素。故,循环条件选择:
3、mid指针讨论
我们分别讨论 与 指针之间元素个数(不包含 与 指针指向的边界元素)的奇偶性来确定 指针的计算模型。
当left与right之间的元素个数为偶数时
设 数组的长度为 ( 是正整数)。索引从 到 共 个元素,索引从 到 共 个元素,所以索引为 (中间偏左) 或 (中间偏右) 的元素即为中间元素。初始情况下 , , 与 之间的元素个数为 (该表达式可转换成 ,其计算结果一定为偶数)。
👉使用 会使 指向中间偏左的元素
此时 ,此时取值 (向下取整)
👉使用 会使 指向中间偏右的元素
此时
👉使用 会使 指向中间偏左的元素
此时
当left与right之间的元素个数为奇数时
设 数组的长度为 ( 是正整数)。索引从 到 共 个元素,索引从 到 共 个元素,所以索引为 的元素即为中间元素。初始情况下 , , 与 之间的元素个数为 (一定为奇数)。
👉使用 会使 指向恰为中心的元素。
此时
👉使用 会使 指向恰为中心的元素。
此时 ,此时取值 (向下取整)
👉使用 会使 指向中间偏左的元素。
此时 ,此时取值 (向下取整)
截止目前的讨论,对于 区间模型,无论 选择哪种计算模型,均可指向区域中心的元素(恰为中心的元素,或中间偏左、中间偏右的元素,均为区域中心的元素),且左右区间的长度大体均等。因此,要选择出合适的计算方法,还需要看计算出的落盘新区间,方可确定 的计算模型。
4、计算落盘新区间
通过比较 与 ,从而计算新的落盘区间,即
-
,直接返回 即可;
-
,说明 一定在 区间内。我们发现,此区间模型已经违背了我们的“初心”(左闭右闭区间模型),新的落盘区间应为 。故,此时应将右闭区间赋值给 指针,即 ;
-
,说明 一定在 区间内。我们发现,此区间模型又违背了我们的“初心”,因此新的落盘区间应为 。故,此时应将左闭区间赋值给 指针,即 。
我们发现, 与 指针在每次计算落盘新区间时都会发生改变(每次循环后的 会比前一次循环的 多1,亦或是,每次循环后的 会比前一次循环的 少1),故三种 计算方式均可以满足要求。为防止计算精度溢出问题, 计算方式选择为 (该计算方式在数理上等价于:)。
5、最终返回值
如果 的确存在于 数组中,其索引位置一定会在第四步中 时返回,否则一定返回 。
6、参考代码
迭代法
/**
* 左闭右闭区间模型[left, right]下的二分法
* 空间复杂度 o(1)
* 时间复杂度 o(logN)
*/
function search(nums: number[], target: number): number {
let left = 0;
let right = nums.length - 1;
while(left <= right) {
// [0, +infinity), 强行向下取整, 例如 ~~(1.5) == 1
// (-infinity, 0), 强行向上取整, 例如 ~~(-0.5) == 0, ~~(-1.5) == -1
const mid = left + ~~((right - left) / 2); // 执行耗时最短
// const mid = left + ~~((right - left + 1) / 2); // 亦可
// const mid = left + ~~((right - left - 1) / 2); // 亦可
const midVal = nums[mid];
if (midVal === target) {
return mid;
}
if (midVal > target) {
right = mid - 1;
} else {
left = mid + 1;
}
}
return -1;
}
递归法
/**
* 左闭右闭区间模型[left, right]下的二分法
* 空间复杂度 O(logN)
* 时间复杂度 O(logN)
*/
function search(nums: number[], target: number): number {
return searchHelper(nums, target, 0, nums.length - 1);
}
function searchHelper(nums: number[], target: number, left: number, right: number): number {
// 因为循环条件为 left <= right,故退出的条件(取反)为 left > right
if (left > right) {
return -1;
}
// [0, +infinity), 强行向下取整, 例如 ~~(1.5) == 1
// (-infinity, 0), 强行向上取整, 例如 ~~(-0.5) == 0, ~~(-1.5) == -1
const mid = left + ~~((right - left) / 2); // 执行耗时最短
// const mid = left + ~~((right - left + 1) / 2); // 亦可
// const mid = left + ~~((right - left - 1) / 2); // 亦可
if (nums[mid] === target) {
return mid;
}
if (nums[mid] > target) {
return searchHelper(nums, target, left, mid - 1);
}
return searchHelper(nums, target, mid + 1, right);
}
总结
| 区间模型 | 循环条件 | 指针取值 |
|---|---|---|
| 、、 |
如果 ,直接返回 提前结束二分搜索;
如果 , 指针不动, 后继续二分;
如果 , 指针不动, 后继续二分;
接下来继续分析其他三种区间模型,即左闭右开 、左开右闭 、左开右开