二分查找(Binary Search)【注意及实现】

概述

二分查找也称折半查找(Binary Search),它是一种效率较高的查找方法。但是,折半查找要求线性表必须采用顺序存储结构,而且表中元素按关键字有序排列。

二分查找是一种十分基础的算法,利用分治思想,将原本需要线性时间复杂度的算法优化到了对数级。二分查找的思路简单,很好理解,但要正确的写出它的一个实现却并非易事的。这一点,在《程序员面试金典》以及《编程珠玑》等经典中都有提到。

概括的说,二分查找的踩坑点主要集中于中间点的计算和边界值的选取上,稍不注意就会产生越界或者死循环的情况。

 

一个案例

我们通过一个案例来说明

// The older implementations of binary search algorithm
private static int binarySearch(int[] a, int key) {
    int low = 0;
    int high = a.length - 1;

    while (low <= high) {
        int mid = (low + high) / 2;
        int midVal = a[mid];

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

这段代码是相当早期的Java源码中对于binarySearch的一个实现。可能与很多同学的实现是差不多的。

但在《程序员面试金典》一书中有提到,这段代码中有一个隐藏近10年的BUG。如果没有专门学习过二分查找,或者看过这方面的资料,可能不太容易发现这个BUG。

问题出在第七行

int mid = (low + high) / 2;

这条语句中包含了一个隐藏的BUG。虽然在日常使用中可能不会出现,但只要这个int类型的数组足够大,就可以重现这个BUG。假如int类型的数组是100亿,或者11亿(虽然在单机上可能对内存造成压力,但也并非完全不可能出现)。同时我们知道int类型的最大值是2147483647,一旦这个方法执行了1次或者多次后, low + high 的数值就完全有可能大于MAX_INTEGER,从而造成溢出。那么第八行的 int midVal = a[mid]; 语句就必然会产生 ArrayIndexOutOfBoundsException 异常。

当然解决这个问题的方案也十分简单。只需要将以上语句改写成以下三种语句的任意一种即可。

// Method one
int mid = (low + high) >>> 1;
// Method two
int mid = low + (high - low) / 2;
// Method three
int mid = low + ((high - low) >> 1);

方法一采用了无符号右移。即使发生溢出,同样能保证取到正确的中间点。有兴趣的同学可以用两个十一亿的和进行无符号右移来验证;方法二和方法三其实是一种思路。将括号展开后,可以得到表达式(low/2 + high/2)(low <= high),又因为high不可能大于与MAX_INTEGER,所以中间点的值也不可能大于MAX_INTEGER。

以上三种方法都可以采用。但相对来说,如果使用Java语言,推荐使用方法一,相对来说效率最高。如果是其它语言,则推荐使用方法三,效率略高于方法二。方法三是一种最通用的求中点时解决溢出的方法。

如果是面试,可以将三种方法都说明一下,可以起到锦上添花的作用。

除了溢出导致数组越界异常这个踩坑点之外,在书写二分查找算法的时候,需要注意的踩坑点还有以下几个地方:

  • 差1错误。我们的左端点应该是当前可能区间的最小范围,那么右端点是最大范围呢,还是最大范围+1呢。我们取了中间值之后,在缩小区间时,有没有保持左右端点的这个假设的一致性呢?同样的,对于左端点还有加1错误。
  • 死循环。我们做的是整数运算,整除2了之后,对于奇数和偶数的行为还不一样,很有可能有些情况下我们并没有减小取值范围,而形成死循环。
  • 退出条件。特别是哪种时候才说明没有在数组中找到需要查找的数呢?
  • 边界检查。在二分查找的递归实现中,左右边界往往也是必要的参数。如果不考虑边界检查,就不免出现因为非法边界值而导致的数组越界异常。

下面,我们来看几种二分查找较为正确的实现。

 

实现

前文说到,早期的Java对于二分查找的实现是有漏洞的。当然这个BUG也早已被修复了。所以我们参考Java源码来学习二分查找正确的实现姿势。

/**
 * Searches the specified array of ints for the specified value using the
 * binary search algorithm.  The array must be sorted (as
 * by the {@link #sort(int[])} method) prior to making this call.  If it
 * is not sorted, the results are undefined.  If the array contains
 * multiple elements with the specified value, there is no guarantee which
 * one will be found.
 *
 * @param a the array to be searched
 * @param key the value to be searched for
 * @return index of the search key, if it is contained in the array;
 *         otherwise, (-(insertion point) - 1).  The
 *         insertion point is defined as the point at which the
 *         key would be inserted into the array: the index of the first
 *         element greater than the key, or a.length if all
 *         elements in the array are less than the specified key.  Note
 *         that this guarantees that the return value will be >= 0 if
 *         and only if the key is found.
 */
public static int binarySearch(int[] a, int key) {
    return binarySearch0(a, 0, a.length, key);
}

/**
 * Searches a range of
 * the specified array of ints for the specified value using the
 * binary search algorithm.
 * The range must be sorted (as
 * by the {@link #sort(int[], int, int)} method)
 * prior to making this call.  If it
 * is not sorted, the results are undefined.  If the range contains
 * multiple elements with the specified value, there is no guarantee which
 * one will be found.
 *
 * @param a the array to be searched
 * @param fromIndex the index of the first element (inclusive) to be
 *          searched
 * @param toIndex the index of the last element (exclusive) to be searched
 * @param key the value to be searched for
 * @return index of the search key, if it is contained in the array
 *         within the specified range;
 *         otherwise, (-(insertion point) - 1).  The
 *         insertion point is defined as the point at which the
 *         key would be inserted into the array: the index of the first
 *         element in the range greater than the key,
 *         or toIndex if all
 *         elements in the range are less than the specified key.  Note
 *         that this guarantees that the return value will be >= 0 if
 *         and only if the key is found.
 * @throws IllegalArgumentException
 *         if {@code fromIndex > toIndex}
 * @throws ArrayIndexOutOfBoundsException
 *         if {@code fromIndex < 0 or toIndex > a.length}
 * @since 1.6
 */
public static int binarySearch(int[] a, int fromIndex, int toIndex,
                               int key) {
    rangeCheck(a.length, fromIndex, toIndex);
    return binarySearch0(a, fromIndex, toIndex, key);
}

// Like public version, but without range checks.
private static int binarySearch0(int[] a, int fromIndex, int toIndex,
                                 int key) {
    int low = fromIndex;
    int high = toIndex - 1;

    while (low <= high) {
        int mid = (low + high) >>> 1;
        int midVal = a[mid];

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

/**
 * Checks that {@code fromIndex} and {@code toIndex} are in
 * the range and throws an exception if they aren't.
 */
private static void rangeCheck(int arrayLength, int fromIndex, int toIndex) {
	if (fromIndex > toIndex) {
	    throw new IllegalArgumentException(
	            "fromIndex(" + fromIndex + ") > toIndex(" + toIndex + ")");
    }
    if (fromIndex < 0) {
        throw new ArrayIndexOutOfBoundsException(fromIndex);
    }
    if (toIndex > arrayLength) {
        throw new ArrayIndexOutOfBoundsException(toIndex);
    }
}

受限于篇幅,笔者只选取了源码中对int类型数组的二分查找的一个实现。

可以看到,Java中的一个二分查找主要分为四个方法:

binarySearch(int[] a, int key):不带左右边界的二分查找。底层调用了binarySearch0方法,同时因为其左右边界是根据数组大小,在内部自己生成的。所以不存在越界问题,也不需要进行边界检查。

binarySearch(int[] a, int fromIndex, int toIndex, int key):带左右边界的二分查找。同样在底层调用了binarySearch0方法,但因为左右边界是用户输入的,所以在调用binarySearch0之前,必须进行一次边界检查。

binarySearch0(int[] a, int fromIndex, int toIndex, int key):真正的二分查找方法,根据数组、左右边界、关键字,通过循环完成的二分查找。

rangeCheck(int arrayLength, int fromIndex, int toIndex):边界检查方法。根据传入的左右边界,相应的抛出非法参数异常、数组越界异常(根据逻辑可分为左边界越界或右边界越界)或者什么也不做(这种情况说明通过边界检查)。

 

其它方法都十分容易理解,这里我们主要分析binarySearch0(int[] a, int fromIndex, int toIndex, int key)这个方法。

// Like public version, but without range checks.
private static int binarySearch0(int[] a, int fromIndex, int toIndex,
                                 int key) {
    int low = fromIndex;
    int high = toIndex - 1;

    while (low <= high) {
        int mid = (low + high) >>> 1;
        int midVal = a[mid];

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

由于边界检查是在rangeCheck方法中完成的,所以binarySearch0方法中并不对边界进行检查。这一点在其注释中也有说明。

// Like public version, but without range checks.

由于第一次传来的toIndex是数组长度,所以第一次使用时,必须进行-1操作,以获得真实的数组中最后一个数的索引。

int high = toIndex - 1;

Java中对二分查找的实现采用的是循环实现,实际上也可以采用递归实现。但循环实现,递归实现的二分查找不可避免的可能出现栈溢出的问题,同时由于函数调用的存在,导致时间和空间的开销都大于循环实现。所以较为推荐的还是用循环去实现二分查找。

其中循环的执行条件是 low <= high,如果出现 low > high 这种情况,就说明最后一次查找时,还是没有找到。也就意味着关键字key不存在于数组中。这也是二分查找的退出条件。

return -(low + 1);  // key not found.

如果未找到,就必然会退出循环,并执行上述语句。但这里并不像我们日常实现二分查找时,直接返回 -1 表示没有查找到,这里返回 - (low + 1),代表着最后一次查找的位置。相比直接返回 -1 ,这种返回方式无疑提供了更多的信息。

举个例子,如果数组中有11个数。利用二分查找,查找100。如果返回-12,那就说明整个数组的最大值小于100;如果返回-1,那就说明整个数组的最小值大于100。有兴趣的同学可以自己尝试一下。

还需要注意的是,正因为Java中的binarySearch方法并不是查找不到就返回-1,所以对二分查找的结果进行判断时,result < 0 才说明没找到,而不要想当然的觉得result == -1,才是没找到。

言归正准,我们继续分析源码。

在进入循环体以后。利用两条语句分别求出了中间点和中间点的值。可以看到,JDK1.8版本中的源码已经修复了本文开头提到的BUG(实际上是在1.6版本中修复的这个BUG)。

int mid = (low + high) >>> 1;
int midVal = a[mid];

紧接着是一条多分支语句,分别判断了当关键字key大于中间值,小于中间值,等于中间值的三种情况。

if (midVal < key)
    low = mid + 1;
else if (midVal > key)
    high = mid - 1;
else
    return mid; // key found

其中如果中间值等于关键字key的情况最好理解,说明找到了这个关键字,其位置恰好就是当前的mid,返回即可。

但如果是前两种情况,就必然要继续查找数组左段或者查找数组右段,因此就必须修改low或high的值。这个时候,就可能会出现差1错误或者加1错误。

感兴趣的朋友们可以尝试一下,如果修改左右边界的语句不分别进行 +1 或者 -1,看看会出现什么问题。

举个例子,如果多分支语句改成这样。会出现什么问题?

if (midVal < key)
    low = mid + 1;
else if (midVal > key)
    high = mid; // 不进行减1操作
else
    return mid;

大家可以在草稿纸上实验一下。如果需要查找的值比索引位置为0的数还小,那么最后一次查找的范围将会是[0,1]。但mid = (0 + 1) / 2 = 0。所以 low = 0, high = 0, mid = 0。同时key < a[0]。那么继续执行第二个分支。就必然产生死循环。

这就是实现二分查找时,因为边界值的修改有误而产生的踩坑点。

 

这里笔者给出一个二分查找的递归实现代码。与循环实现相差不大,但也有一些小的注意点。感兴趣的同学可以自己去尝试一下。

public static int binarySearch(int[] a, int key) {
	return binarySearch0(a, 0, a.length, key);
}

public static int binarySearch(int[] a, int fromIndex, int toIndex, int key) {
	rangeCheck(a.length, fromIndex, toIndex);
	return binarySearch0(a, fromIndex, toIndex, key);
}

private static void rangeCheck(int arrayLength, int fromIndex, int toIndex) {
	if (fromIndex > toIndex) {
	    throw new IllegalArgumentException(
	            "fromIndex(" + fromIndex + ") > toIndex(" + toIndex + ")");
    }
    if (fromIndex < 0) {
        throw new ArrayIndexOutOfBoundsException(fromIndex);
    }
    if (toIndex > arrayLength) {
        throw new ArrayIndexOutOfBoundsException(toIndex);
    }
}

private static int binarySearch0(int[] a,int fromIndex, int toIndex, int key) {
    int low = fromIndex;
    int high = toIndex - 1;

    if(low > high)
    	return -(low + 1);
    
    int mid = (low + high) >>> 1;
    // int mid = low + (high - low) / 2;
    // int mid = low + ((high - low) >> 1);
    int midVal = a[mid];
    
    if(key < midVal)
    	return binarySearch0(a, low, mid, key);
    else if(key > midVal)
    	return binarySearch0(a, mid + 1, high + 1, key);
    else
    	return mid;
    
}

最后,需要指出以上这些代码的共性问题,如果在面试笔试的时候手写代码。建议写完后补充说明一下。对于以上代码,还有个问题是可能存在规模不够大的问题,如果数较大,那么可能无法保存和查找。

解决的思路是将数据类型转为long,或者BigInteger,或者BigDecimal。推荐使用循环实现。

有了上文的思路和注意点的提示,转换数据类型并不困难。

以上内容,挂一漏万。如有缺漏,欢迎指出。

你可能感兴趣的:(算法&数据结构)