【二分查找法】你真的写对了吗?

导语

在众多有趣又有难度的题目中,有一道老题却是大家都纷纷选择避开的,那就是去实现二分查找。

因为它很好写,却很难写对。可以想象问了这道题后,在5分钟之内面试的同学会相当自信的将那一小段代码交给我们,剩下的就是考验面试官能否在更短的时间内看出这段代码的bug了。 ---- ccmouse

看起来是个很小的问题,其实也不容易。据说第一篇二分搜索论文是1946年发表,但是完全没有错误的二分搜索程序却是在1962年才出现,用了16年的时间。可想而知,要想写出一个基本没有错误的二分搜索程序并不像看起来的那么简单。

错误写法示例

1.第一种写法

public static int bs1(int a[], int x, int n) {
    int left = 0;
    int right = n - 1;
    while (left <= right) {
        int middle = (left + right) / 2;
        if (x == a[middle])
            return middle;
        if (x > a[middle])
            left = middle;
        else
            right = middle;
    }
    return -1;
}

2.第一种写法评析

[x] 是错误的
当你输入bs1(new int[]{1,2,3,4,5},9,5)时,你会神奇的发现你的代码卡着不动了(实际上是陷入了死循环)。

仔细分析一下代码,当x>middle时,left=middle,这会造成一个很大的问题,举个栗子,当你left=3,right=4,middle=3时,无论循环多少次,left始终为3,无法继续查找,也就是之前陷入的死循环。
同理right=middle也会造成同样的错误。

而且这里还有一个问题,当我的数组特别特别大,例如有2147483646个元素,因为整数表示的范围有限,为-2147483648到2147483648,那么上述代码中的int middle = (left + right) / 2;语句就有可能会发生溢出,实际测试会抛出如下异常:

Exception in thread "main" java.lang.OutOfMemoryError: Requested array size exceeds VM limit
    at bin_search.Demo1.main(Demo1.java:21)

3.第二种写法

public static int bs2(int a[], int x, int n) {
    int left = 0;
    int right = n - 1;        
    while (left < right -1) {
        int middle = (left + right) / 2;
        if (x < a[middle]) 
            right = middle;
        else 
            left = middle;
    }
    if (x == a[left]) return left;
    return -1;
}

4.第二种写法评析

[x] 是错误的
举个栗子验证一下,输入bs2(new int[]{1,2,3,4,5},5,5),竟然返回-1!说明该方法不能查找到数组尾元素。

仔细分析一下代码,我们发现除了和之前一样可能存在整数溢出的错误之外,还存在一个无法查找尾元素的bug,查找的最后几步过程如下:

运行流程

所以最后一个元素就被“略”过了,所以这个写法就不能得到正确的结果。

正确写法

看起来,二分搜索虽然思路简单但是很难写对。《编程珠玑》的作者Jon Bentley曾经收集过学生的代码,发现其中有90%都是错的,甚至连以前java的库中,二分搜索也存在着一个隐藏了10年的严重bug。
如果感兴趣的话可以看下面这篇文章的详细介绍:

二分查找--那个隐藏了10年的Java Bug

我们来看看官方的二分搜索法是怎么实现的。

public static <T>
    int binarySearch(List<? extends Comparable<? super T>> list, T key) {
        if (list instanceof RandomAccess || list.size()<BINARYSEARCH_THRESHOLD)
            return Collections.indexedBinarySearch(list, key);
        else
            return Collections.iteratorBinarySearch(list, key);
}

private static <T> 
    int indexedBinarySearch(List<? extends Comparable<? super T>> list, T key) {
        int low = 0;
        int high = list.size()-1;

        while (low <= high) {
            int mid = (low + high) >>> 1;
            Comparable<? super T> midVal = list.get(mid);
            int cmp = midVal.compareTo(key);

            if (cmp < 0)
                low = mid + 1;
            else if (cmp > 0)
                high = mid - 1;
            else
                return mid; // key found
        }
        return -(low + 1);  // key not found
}

private static <T>
    int iteratorBinarySearch(List<? extends Comparable<? super T>> list, T key) {
        int low = 0;
        int high = list.size()-1;
        ListIterator<? extends Comparable<? super T>> i = list.listIterator();

        while (low <= high) {
            int mid = (low + high) >>> 1;
            Comparable<? super T> midVal = get(i, mid);
            int cmp = midVal.compareTo(key);

            if (cmp < 0)
                low = mid + 1;
            else if (cmp > 0)
                high = mid - 1;
            else
                return mid; // key found
        }
        return -(low + 1);  // key not found
}

上面的low就对应left,high对应right。

但是对于一些非常大的数组进行二分查找问题,就连Java的库函数也无法胜任。
由此看来,二分搜索需要重新更好地设计才能适应超大型的数组。(当然,如果真有那么大的数组,我们是不会用二分搜索的,因为它太慢了)

总结

总结一下,二分搜索需要注意的点有以下几条:

  1. 数组一定记得要先排序!!!(不排序会出现各种莫名其妙的返回值)
  2. 取中位值的时候,需要注意整数加法是否会溢出的问题。
  3. 当查找不在数组内的元素时,需要返回-1代表没有找到。
  4. 如果出现待查找元素有重复的元素,需要确定返回的是哪一个元素的下标。

参考资料

阅读 5.3k

推荐阅读