二分查找也称折半查找(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语言,推荐使用方法一,相对来说效率最高。如果是其它语言,则推荐使用方法三,效率略高于方法二。方法三是一种最通用的求中点时解决溢出的方法。
如果是面试,可以将三种方法都说明一下,可以起到锦上添花的作用。
除了溢出导致数组越界异常这个踩坑点之外,在书写二分查找算法的时候,需要注意的踩坑点还有以下几个地方:
下面,我们来看几种二分查找较为正确的实现。
前文说到,早期的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。推荐使用循环实现。
有了上文的思路和注意点的提示,转换数据类型并不困难。
以上内容,挂一漏万。如有缺漏,欢迎指出。