二分搜索算法
1 前言
曾经一度觉得二分搜索算法是一个比较简单的算法,直到我看到了这样一些问题:
- 查找一个已排序序列中第一个不小于某个值的元素
- 查找一个已排序序列中最后一个不大于某个值的元素
- 查找一个已排序序列中第一个大于某个值的元素
- 查找一个已排序序列中最后一个小于某个值的元素
很明显,这些问题都是可以通过二分搜索算法解决的,然而当我准备开始写的时候,才发现,我不能很好的把它们写出来。
或者说,我只知道怎么解决:查找一个已排序序列中等于某个值的元素。
2 中点值计算
二分搜索算法是一个很细节的算法,中点值的计算就是其需要注意的一个地方。
通常情况下,中点值的计算是这样的:
int mid = (left + right) / 2;
如果对二分搜索算法有一定的了解,应该知道,这样的计算可能存在溢出的问题,即:left 和 right 的和可能超出 int 类型的取值范围。
因此,中点值的计算应该用如下算式替代:
int mid = left + (right - left) / 2;
然而,中点值的计算也存在一个隐含的条件:向下取整,也就是说,我们平时写的二分搜索算法的中点值都是向 左边界 接近的。
所以说,以下两者是等价的:
int mid = (left + right) / 2; int mid = left + (right - left) / 2;
当然了,既然存在向下取整的写法,自然也存在向上取整的写法,只不过平时我们不怎么用罢了,而这篇博客也不做讨论,不过还是给出中点值向上取整的写法:
int mid = ceil((left + right) / 2); int mid = right - (right - left) / 2;
3 搜索区间与终止条件
很明显,二分搜索算法的搜索区间存在四种选择: [left, right]
, [left, right)
, (left, right]
, (left, right)
.
这里不会讨论所有这四种区间,只讨论其中的两种: [left, right]
和 [left, right)
以及对应的终止条件选择。
首先是区间
[left, right]
, 很明显搜索这个区间时应该包括左右两端的值。同时,我们又了解到一般情况下,中点值的计算时向左边界接近的,因此,我们不需要担心无法搜索到左边界,只需要考虑怎么能够搜索到右边界。
在中点值是向下取整的情况下,很明显,只有当 left 和 right 相等的时候,我们才能搜索到右边界,因此,循环的终止条件应该为:
while (left <= right) { ... }
然后是区间
[left, right)
, 有了前面的基础,我们可以很快的反应过来,右边界是不需要的,因此,我们只需要将终止条件修改为:while (left < right) { ... }
由于中点值的计算是向下取整的,而 left 和 right 相等的时候就会退出循环,所以可以保证不会使中点值和右边界值相等。
4 等于某值
查找一个已排序序列中等于某个值的元素是很简单的,这里就直接给出代码好了:
int bsearch(arr, left, right, target) { while (left <= right) { int mid = left + (right - left) / 2; if (arr[mid] == target) { return mid; } else if (arr[mid] > target) { right = mid - 1; // [left, mid - 1] } else { left = mid + 1; // [mid + 1, right] } } return -1; }
这个实现是针对区间 [left, right]
而言的,需要注意的地方是 搜索区间的缩减:
- 当中点值大于目标值时,说明区间
[mid, right]
内的元素都是我们不需要的,因此,执行的操作是:right = mid - 1
. - 相应的,当中点值小于目标值时,说明区间
[left, mid]
内的元素都是我们不需要的,因此,执行的操作是:left = mid + 1
.
这里需要重点关注的是由边界值的变化,因为搜索的区间是 [left, right]
, 因此,当中点值不需要时,可以让右边界值直接等于中点值减一。
但是,这对于区间 [left, right)
来说就不一样了:
int bsearch(arr, left, right, target) { while (left < right) { int mid = left + (right - left) / 2; if (arr[mid] == target) { return mid; } else if (arr[mid] > target) { right = mid; // [left, mid) } else { left = mid + 1; // [mid + 1, right]) } } return -1; }
在这种情况下,中点值大于目标值,说明区间 [mid, right]
内的元素都是我们不需要的,但是,
mid - 1
还是需要等待判断的。
因此,右边界值被修改为 mid
而不是 mid - 1
.
5 第一个不小于
从这里开始就只讨论区间 [left, right)
的写法了,对于区间 [left, right]
, 有兴趣的可以去研究一下。
有了前面的基础,中点值的计算和终止条件的选择都不是什么问题了,因此,目前最大的问题就是搜索区间的修改问题。
搜索区间的修改是根据中点值和搜索区间的开闭性来确定的,而区间的开闭性已经确定了,因此,现在需要考虑的是中点值的问题。
中点值的可能情况:
- 小于目标值,我们的目标是不小于目标值,因此,包括中点值在内的元素都不在我们的搜索范围内
- 等于目标值,此时,中点值左侧可能同样存在等于目标值的元素,因此,不能贸然修改左值,那么,修改右值?
- 大于目标值,情况和等于目标值类似
综合上面三种情况来看,当中点值小于目标值时的处理很简单,直接修改左边界就可以了,但是对于中点值大于等于目标值时,是不能轻率的修改左边界的,因此,只能考虑修改右边界。
此时,中点值有可能就是我们要的答案,因此,不可能将右边界修改为 mid - 1
, 那么,我们需要要将右边界修改为 mid + 1
吗?
这里我们可以用一个简单的程序来测试一下:
seq = [2 for i in range(10)] left, right = 0, len(seq) while left < right: mid = left + (right - left) // 2 if seq[mid] < 2: left = mid + 1 else: right = mid + 1 print(right)
测试程序的输出为:
6 4 3 2 2 2 2 2 2 2 2......
这是一个无限死循环,因为终止条件是 left < right,而中点值的计算是趋向于左边界的,此时,如果将右边界修改为 mid + 1
, 那么问题就变成了:
seq[left] = seq[left + 1] = target;
right = left + 1;
mid = left + (right - left) / 2 = left + 1 / 2 = left;
right = mid + 1 = left + 1;
因此,在这种情况下,我们只能将右边界修改为 mid
, 在这种情况下:
- 假如右边界就是我们的目标,那么,右边界往左的所有元素都不是我们需要的,这就会使得做边界不断往右边界靠近,直到触发终止条件
- 假如左边界才是我们的目标,那么,右边界就会不断往左边界靠近,直到触发终止条件
最后,我们的实现如下:
int lower_bound(arr, left, right, target) { while (left < right) { int mid = left + (right - left) / 2; if (arr[mid] < target) { left = mid + 1; // [mid + 1, right) } else { right = mid; // [left, mid) } } return left; }
最最后,在来考虑以下两种情况:
- 目标值比序列中的所有值都小,此时,左边界就是我们的结果,因为左边界的值已经不小于目标值
- 目标值比序列中的所有值都大,此时,左边界会不断逼近右边界,这个右边界是不属于我们的搜索区间的,因此,当返回值等于右边界值时,说明找不到目标值
简单来说,只要返回值比右边界值小,那么结果都是成立的。
6 第一个大于
前面考虑了第一个不小于的情况,这里再来考虑第一个大于就容易多了,核心依然是搜索区间的修改。
很明显,当中点值小于等于目标值时,区间 [left, mid]
都不是我们需要的,直接将左边界修改为 mid + 1
就可以了,右边界的修改和前面一样,因此,这里的实现只需要将前面的代码改动一点点就可以了:
int upper_bound(arr, left, right, target) { while (left < right) { int mid = left + (right - left) / 2; if (arr[mid] <= target) { left = mid + 1; // [mid + 1, right) } else { right = mid; // [left, mid) } } return left; }
对,没错,只需要将中间的判断条件 <
改成 <=
就可以了,使用上和前面的那个也是一样的。
7 最后一个不大于和最后一个小于
这两个的实现可以取个巧,首先我们可以研究一下前面两个实现返回的结果的情况:
// arr[result] >= target // arr[result - 1] < target int lower_bound(arr, left, right, target) { ... } // arr[result] > target // arr[result - 1] <= target int upper_bound(arr, left, right, target) { ... }
很明显,最后一个不大于的意思就是:
arr[result] <= target arr[result + 1] > target
这一点和第一个大于的返回结果很相似,因此,最后一个不大于可以借助第一个大于来实现:
int result = upper_bound(arr, left, right, target) - 1;
如果结果为 -1
说明目标值就不存在。
最后一个小于也是一样的道理:
int result = lower_bound(arr, left, right, target) - 1;
8 结尾
二分搜索算法真的是一个很细节的算法,各种实现之间的区别都不大,很多就是加一减一这种程度的区别,但不注意还容易搞错。
对此,我只想说,$*#&*@#^&…