二分查找

二分查找的思想相当简单,但是实现起来却有很多的坑,本文来剖析一下各个坑点,并给出无 bug 的实现,便于复制粘贴。

二分查找要点

下面是二分查找算法的大体样子,自己动手实现时,需要注意的点,就是标 ??? 的地方。

int binary_search(int[] a, int len, int x) {
    int lo = 0, hi = ???;

    while(???) {
        int mid = lo + (hi - lo) / 2;
        if (a[mid] == x) {
            return mid
        } else if (a[mid] < x) {
            lo = ???
        } else {
            hi = ???
        }
    }
    return -1;
}

hi 该如何初始化

lohi 用于确定搜索区间,这个区间可以是 [lo, hi] 也可以是 [lo, hi)。如果采用闭区间,hi 就是最后一个元素的下标,hi=len-1。要是采用开区间,hi=len

终止条件怎么写

while 循环的条件可能有两种 lo < hi 或者 lo <= hi。到底要不要等号,这决定于前一步开闭区间的选择。二分搜索停止的条件就是待搜索的区间长度为 0。把握住这个想法,这个条件就很好决定了。

如果采用闭区间,lo==hi 时,表示区间长度为 1。因此,如果采用闭区间,就需要有等号。否则,你想想如果列表中只有一个元素,那么 lohi 是相等的,你要不加等号,就进不去主循环。

如果采用开区间,当区间为空时,lo==hi。只要满足 lo<hi ,就说明区间不为空。所以此时是不加等号的。

lohi 该如何调整

a[mid] < x 时,说明中点的值是小于目标值,因此,mid 及其左边的范围可以抛掉了,因此可以放心大胆地写 lo = mid + 1

a[mid] > x 时,说明中点的值是大于目标值,中点及其右边的范围可以抛掉了。这个时候,采用开区间还是闭区间,写法自然是不一样的。闭区间,当然就是 hi = mid - 1,开区间就是 hi = mid

二分查找实现

根据前面的分析,我们可以写出两种二分查找的实现:

// 实现一
int binary_search(int[] a, int len, int x) {
    int lo = 0, hi = len-1;

    while(lo<=hi) {
        int mid = lo + (hi - lo) / 2;
        if (a[mid] == x) {
            return mid
        } else if (a[mid] < x) {
            lo = mid+1;
        } else {
            hi = mid-1;
        }
    }
    return -1;
}
//实现二
int binary_search(int[] a, int len, int x) {
    int lo = 0, hi = len;

    while(lo<hi) {
        int mid = lo + (hi - lo) / 2;
        if (a[mid] == x) {
            return mid
        } else if (a[mid] < x) {
            lo = mid+1;
        } else {
            hi = mid;
        }
    }
    return -1;
}

关于开闭区间

涉及到边界的时候,常常要纠结到底右端用开区间还是闭区间。在快速排序,归并排序中也要纠结这个问题。如果你经常使用 Python,那么使用开区间就很自然。因为 Python 中,切片就是用的开区间。不过大多数语言中,我们都在使用开区间,比如下面的循环语句。因此,我建议以后不要纠结了,都采用开区间好了。大佬 E. W. Dijkstra 论证过使用开区间的好处。

for(int i=0;i<n;i++){
    //...
}

二分查找衍生算法

搜索结束后 lohi 分别指向哪里

分析完上面几点,二分查找就说完了。但是常常我们使用二分查找来寻找某个位置,然后插入元素,而不是直接用来寻找元素。因此,有必要了解一下如果没有查到某个元素,循环退出时 lohi 指向哪里。

如果采用的是闭区间,那么退出循环后,有 lo>hilo=hi+1。在退出前的一次迭代中,有 lo=hi=mid,此时,如果 a[mid]>x 会执行 hi=mid-1,如果 a[mid]<x 会执行 lo=mid+1。这带来的结果是退出循环后,有 a[lo]>x。而且 lo 指向的是第一个大于 x 的元素。而 hi 指向最后一个小于 x 的元素。

如果采用开区间,循环退出后,有 hi==lo,都指向第一个大于 x 的元素。

有了以上分析,我们可以很轻松地实现其他从二分查找中衍生出的算法。比如,寻找第一个大于等于 x 的元素的下标,寻找最后一个小于 x 的元素的下标等。

寻找第一个大于等于 x 的元素的下标

根据前面的分析,在二分查找没有找到时,不要返回 -1 而返回 lo,就可以实现这个算法了。不过可以实现的更加简洁。

我在考虑这个算法时,想法是不断减小区间范围。如果 a[mid] 小于 x,那么 mid 及其之前的范围不符合要求。否则,mid 后面的范围不符合要求,而 mid 有可能符合要求。但是在退出循环是 lo = hi,因此可以设 hi = mid。最终返回 lo 即得到结果。

def lower_bound(a, x):
    lo, hi = 0, len(a)
    
    while lo < hi:
        mid = (lo+hi)//2
        if a[mid] < x:
            lo = mid + 1
        else:
            hi = mid
    return lo

寻找第一个大于 x 的元素的下标

同理,可以写出如下代码:

def upper_bound(a, x, lo=0, hi=None):
    lo, hi = 0, len(a)
    while lo < hi:
        mid = (lo + hi) // 2
        if a[mid] <= x:
            lo = mid + 1
        else:
            hi = mid
    return lo

这里借用了 C++ STL 中的两个函数名,lower_boundupper_bound。在列表中,如果存在重复元素,比如 a = [1 2 3 3 3 5 6]。对于元素 3 而言,lower_bound(a, 3)upper_bound(a, 3) 分别返回 3 在序列中的下边界和上边界,下边界是闭区间,上边界是开区间。如果不存在 3,那么 lower_bound 会返回第一个大于 3 的元素的下标。

寻找最后一个小于 x 的元素的下标

返回 lower_bound(a, x) - 1 即可。

总结

二分查找之所以容易出错,我想是因为人们常看到采用开闭区间的不同写法,另外没有仔细考虑这两种情况的边界情况。如果考虑清楚了,就很难出错了。