二分查找之魔鬼的后妈
Donald Knuth 在其著作 The Art of Computer Programming, Volume 3: Sorting and Searching 中提到,“虽然第一篇二分搜索的论文在1946年就发表了,但是第一个没有错误的二分搜索程序却直到1962年才出现。”
《编程珠玑》的作者Jon Bentley曾经收集过学生的代码,发现其中有90%都是错的,甚至连以前java的库中,二分搜索也存在着一个隐藏了10年的严重bug。埋下这个bug的人,也正是Jon Bentley的学生。详见二分查找–那个隐藏了10年的Java Bug
以下示例均以非降序排列为前提。
错误示例
public int bs0(int[] nums, int target) {
int l = 0, r = nums.length - 1;
while (l <= r) {
int mid = (l + r) / 2;
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
l = mid;
} else {
r = mid;
}
}
return -1;
}
一共有两处bug,第一处是逻辑上的错误,很容易看出来,比如nums={1,2,3,4,5},target = 9时,会陷入死循环。
第二处则是当数组很大时,l + r
可能会造成溢出。有两种常用解法。第一种是改成mid = l + (r - l) / 2
,第二种则比较巧妙,也是Java库中的写法,mid = (l + r) >>> 1
,当然此种求中位数的方法并不通用,因为二分搜索中l
和r
都是非负数才可以这么用。理解起来也很简单,成也萧何败萧何,溢出是由于符号位由0变1,而无符号右移>>>
把符号位当做数值位,前面补零,又正好得到正确结果。
标准版银弹
public int bs1(int[] nums, int target) {
int l = 0, r = nums.length - 1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (nums[mid] == target) {
return mid;
} else if (nums[mid] > target) {
r = mid - 1;
} else {
l = mid + 1;
}
}
return l;
}
银弹正常工作的前提是 - 元素空间内没有重复值,之所以称为银弹是因为只需少量修改即可推广到有重复元素空间。
如何理解l
和r
最后的收敛位置?参考Java source code中的binarySearch代码:
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.
}
以及JDK Doc:
Parameters:
a - the array to be searched
fromIndex - the index of the first element (inclusive) to be searched
toIndex - the index of the last element (exclusive) to be searched
key - the value to be searched for
Returns:
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.
可知l
即为最后的insertion point
,那为什么不直接返回-l
呢?因为需要用负值表示not found,之所以return -(low + 1)
是因为如果target在数组左边,比如nums={1, 2, 3, 4, 5}, target = 0,那么最后l=0,r=-1,需要+1来保证not found情况下返回值都是负数。当然面试时一般不会像API这么要求,只会要求返回-1表示未找到。
有重复元素空间
模板的终结条件是l > r
,即l = r + 1
,针对以下四种情况,需要熟练知道终止时l
和r
的收敛位置。当然最好经常手撕一遍,形成肌肉记忆。
- 求最小的i,使得 nums[i] == target,若不存在返回-1
public int bs2(int[] nums, int target) {
int l = 0, r = nums.length - 1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (nums[mid] < target) {
l = mid + 1;
} else {
r = mid - 1;
}
}
return l < nums.length && nums[l] == target ? l : -1;
}
- 求最大的i,使得 nums[i] == target,若不存在返回-1
public int bs3(int[] nums, int target) {
int l = 0, r = nums.length - 1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (nums[mid] > target) {
r = mid - 1;
} else {
l = mid + 1;
}
}
return r > 0 && nums[r] == target ? r : -1;
}
- 求最小的i,使得 nums[i] >= target,若不存在返回-1
public int bs4(int[] nums, int target) {
int l = 0, r = nums.length - 1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (nums[mid] < target) {
l = mid + 1;
} else {
r = mid - 1;
}
}
return l < nums.length ? l : -1;
}
- 求最大的i,使得 nums[i] <= target,若不存在返回-1
public int bs5(int[] nums, int target) {
int l = 0, r = nums.length - 1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (nums[mid] > target) {
r = mid - 1;
} else {
l = mid + 1;
}
}
return r > 0 ? r : -1;
}
进阶版银弹
以上的binary search都是基于左闭右闭区间[l, r]
,免不了要多写几个+1, -1, return
,而且区间为空时,l
和r
只有一个是正确答案,面试时很容易写错。那有没有更通用的不易犯错的模板呢?
二分搜索写法可以分为求上下界两种,并转化为以下等价写法,可以解决各种细节问题:
- 求下界,即找满足nums[i] >= value或nums[i] > value条件的最小i的位置
- 求上界,即找满足nums[i] < value 或 nums[i] <= value条件的最大i的位置
求下界时对应C++标准库中的lower_bound()
和upper_bound()
函数,用左闭右开搜索区间[left, right]
,区间为空时终止并返回left或right,此时二者重合,无需纠结。求中点时从闭区间侧left出发,即mid = left + (right - left) / 2
,以确保长度为1时,mid = left
仍然在[left, left + 1)
区间内。
求上界可以调用互补的求下界的函数再减1得到,比如nums[i] >= target的下界再减一就是nums[i] < target的上界,所以C++标准库只提供求下界的两个函数。如果非要写,不推荐,则是求下界的镜面情况,需要把所有数组下标反过来。即用左开右闭区间(left, right]
,区间为空时终止并返回left或right,二者重合。但求中点时,仍需要从闭区间侧出发即mid = right - (right - left) / 2
,以保证区间长度为1时,mid = right
仍在(right - 1, right]
区间内。
借鉴C++ STL中的lower_bound()
和upper_bound()
函数:
// 求下界lower_bound(),即求最小的i,使得nums[i] >= target,若不存在返回-1
public static int bs6(int[] nums, int target) {
int l = 0, r = nums.length;
int mid = 0;
while (l < r) {
mid = l + (r - l) / 2;
if (nums[mid] < target) {
l = mid + 1;
} else {
r = mid;
}
}
return l < nums.length ? l : -1;
}
// 求下界upper_bound(),即求最小的i,使得nums[i] > target,若不存在返回-1
public static int bs7(int[] nums, int target) {
int l = 0, r = nums.length;
int mid = 0;
while (l < r) {
mid = l + (r - l) / 2;
if (nums[mid] <= target) {
l = mid + 1;
} else {
r = mid;
}
}
return l < nums.length ? l : -1;
}
如何用lower_bound()
和upper_bound()
在区间[left, right)内完成四种binary search,即上下界,开闭区间?
- lower_bound(target) 找的是nums[i] >= target的下界,若为right则不存在
- upper_bound(target) 找的是nums[i] > target的下界,若为right则不存在
- lower_bound(target) - 1即为nums[i] < target的上界,若为left - 1则不存在
- upper_bound(target) - 1即为nums[i] <= target的上界,若为left - 1则不存在
Talk is cheap
- 33. Search in Rotated Sorted Array
- 74. Search a 2D Matrix
- 81. Search in Rotated Sorted Array II
- 153. Find Minimum in Rotated Sorted Array
- 154. Find Minimum in Rotated Sorted Array II