二分查找必知必会
Posted FunWithCS
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了二分查找必知必会相关的知识,希望对你有一定的参考价值。
0 Introduction
二分搜索人人都会,想想查字典这个过程,比如你要查询binary这个单词,你首先翻开的肯定是字典的前一小部分,因为你知道以b为首字母的单词不可能出现在字典的1/4位置以后。假如你恰好翻到的那页含有bit这个单词,你知道,你还要再向前翻一些,因为t在n的后面,接着你跟着感觉在前面一点点的位置翻了一页,刚好,到了在某页发现了你要找的单词。
上面的过程或许太过平常,以至于你从来没有想过其中的高效之处:一本高阶词典大约有200000个单词,但你只查询了3次就找到了目标单词。如果把字典的所有单词想象成一个搜索空间,你的每一次翻页都是在缩减这个搜索空间,第一次,你直接舍弃了整个搜索空间的3/4,通过和bit比较,你更加确定这一点。
为什么我们能做到这么快呢,这是基于字典中的单词排列是有序的,也就是按字典序升序排列,如果一本字典是混乱的,a-z混乱排列,那你只有一个一个的检索,如果字典有200000个单词,那你平均就需要查询100000次,大大高于上面的3次。
二分搜索就是建立在这样的基础上,在有序的序列中,通过大规模缩小搜索空间,从而在log(N)
时间内快速寻找一个数。可以看到,想法相当自然且易懂,实现起来应该也是如此吧。
在你这样想之前,先看几个事实:
-
Although the basic idea of binary search is comparatively straightforward, the details can be surprisingly tricky... by Knuth(图灵奖得主) -
二分搜索的1946年问世,但是第一个正确的二分搜索程序却在1962年才出现; -
Java类库实现的二分搜索错了20年才被人发现;
所以本文就是想总结一些经验,防止出现二分搜索的错误;
1 从分析错误开始
这是一段Pascal程序,意思很容易看懂(下标从1开始)。看起来,好像就是我们所学过的二分搜索,但是实际上却漏洞百出。
一般情况下,考虑如下几种错误:
-
没有处理特殊情况,比如数组为空; -
遗漏搜索元素; -
死循环; -
值传递; -
加法溢出;
这里没有处理数组为空的情况,不说是错误的话,至少也是一个不完善;会遗漏元素:比如数组仅有一个元素,并且要查询的key等于这个元素,这种情况下,进入ELSE分支,Index+1,循环退出,返回False;死循环这里倒是没有,因为搜索区间一定是在不断减少的;至于值传递,我不太清楚Pascal语言,但是这个问题也是值得注意的,比如在C++中,传递的是一个数组,而不是它的引用,尽管搜索的再快,但是复制数组的开销以及让程序复杂度提高了一个数量级;当lo和hi都很大时,在某些语言中,lo+hi可能导致加法溢出;
所以这段程序是有问题的,那么怎么样写出高效且正确的代码呢?
2 正确的二分搜索
强烈推荐使用左闭右开区间搜索 [lo, hi)
public int lower_bound(int[] nums, int target){
if (nums == null) return 0;
int lo = 0, hi = nums.length;
while (lo < hi){
int mid = lo + (hi - lo) / 2;
if (nums[mid] >= target)
hi = mid;
else
lo = mid + 1;
}
return lo;
}
先简单解释下:
-
循环条件 while (lo < hi)
搜索空间不为空; -
int mid = lo + (hi - lo) / 2;
防止加法溢出
那么怎么证明它是正确的呢?首先要定义一个循环不变式,然后证明对于初始条件是正确的,接着证明在每次迭代后都保持循环不变式为真,由数学归纳法可知这两步证明了无论何时循环终止,结果都是正确的。综上可知,只要循环能停止,就一定得到答案,因此我们必须证明循环能终止。
首先给出循环不变式:
-
搜索区间左边的元素,如果存在的话,一定是小于 target; -
搜索右边的元素,如果存在的话,一定是大于等于 target; -
搜索区间存在;
初始条件,int lo = 0, hi = nums.length;
左边不存在元素,符合1,右边不存在元素,符合2,搜索区间存在,符合3;
所以初始条件是正确的。
下面来看循环中的两个分支:
-
if (nums[mid] >= target)
由有序性可以得到:...nums[mid+1] > nums[mid] >= target
也就是在[mid, hi)
这个区间内,一定都是大于等于target的,所以让hi=mid,将搜索区间变为[lo, mid)
-
在else中,条件是 nums[mid] < target
由有序性可知:target > nums[mid] > nums[mid-1] > ...
也就是区间[lo, mid+1)
都是小于target的,所以将搜索区间设置为[mid+1, hi)
上面所有的变化,都是利用和mid比较,来最大程度缩减搜索空间,且保持循环不变式的正确性;
最大程度缩减搜索空间,更多的不是为了效率,而是保证循环可以正确退出,上面的两个收缩区间过程必须那样写,只有这样,才能保证无论怎么比较,搜索区间都是缩小的。否则在长度缩小到1的时候,会产生死循环。
说来说去还是二分搜索,似乎和下面这种经典的也没什么区别:
public int binary_search(int[] nums, int target){
if (nums == null) return -1;
int lo = 0, hi = nums.length - 1;
while (lo <= hi){
int mid = lo + (hi - lo) / 2;
if (nums[mid] < target)
lo = mid + 1;
else if (nums[mid] > target)
hi = mid - 1;
else
return mid;
}
return -1;
}
结果上会有一些小差异,但一定都是正确的结果,区别在哪呢?实际上这是两种截然不同的思想。从结果上:如果有重复元素,经典写法只是随机返回其中一个,但是左闭右开写法返回的是第一次出现的元素;
那深层次又有什么不同呢?经典写法解决的是二分搜索问题,而左闭右开解决的是二分问题。这怎么讲?
回忆一下上面,左闭右开得到的最终结果将数组划分为两部分,前一部分严格小于target,后半部分大于等于target;但是经典写法没有保证;第一种写法的函数名,我命名为lower_bound
表达的就是这个意思,也就是满足大于等于target的第一个元素位置;
3 抽象出一个框架
更进一步,可以将这个比较元素抽象成G(mid),从而得到二分搜索一般形式:
public int lower_bound(int[] nums, int target){
if (nums == null) return 0;
int lo = 0, hi = nums.length;
while (lo < hi){
int mid = lo + (hi - lo) / 2;
if (G(mid))
hi = mid;
else
lo = mid + 1;
}
return lo;
}
上面函数的意义是:找到满足G(mid)的最小下标。这样一来,二分搜索问题似乎可以看成转换为一个最小值问题。
4 Examples
来看一个例子:计算并返回 x 的平方根,其中 x 是非负整数。额外要求:结果只保留整数的部分,小数部分将被舍去。
如果用二分搜索来做,可以转换一下:argmax(m) {m*m <= x}
再转换一下,就是求最小的m使得m*m > x,那么m-1就是答案:
class Solution(object):
def mySqrt(self, x):
lo ,hi = 0, x + 1;
while lo < hi:
mid = (lo + hi) >> 1;
if mid * mid > x:
hi = mid;
else: lo = mid + 1;
return lo - 1;
如果你还没有看出这个方法好在哪里,简单一句话就是:它让不用脑子也可以保证答案正确。所以你要做的,就是找一下这个函数G,然后将问题转换成最小(最大)值问题即可。
难点在于,G这个函数,有时候它没有这么简单,比如LeetCode875:
珂珂喜欢吃香蕉。这里有 N 堆香蕉,第 i 堆中有 piles[i] 根香蕉。警卫已经离开了,将在 H 小时后回来。珂珂可以决定她吃香蕉的速度 K (单位:根/小时)。每个小时,她将会选择一堆香蕉,从中吃掉 K 根。如果这堆香蕉少于 K 根,她将吃掉这堆的所有香蕉,然后这一小时内不会再吃更多的香蕉。珂珂喜欢慢慢吃,但仍然想在警卫回来前吃掉所有的香蕉。返回她可以在 H 小时内吃掉所有香蕉的最小速度 K(K 为整数)。
简化一下问题:有N堆香蕉,选择一个速度K,每小时只能吃一次,在时间H内吃完,问最小的速度K。为什么有最小呢,假设K为无穷大,在N<H
时一定能吃完,所以可以逐渐降低K,这就存在一个最小值了。
这就得到了我们二分的思路,找到一个最小的K,使得能吃完:
class Solution {
public int minEatingSpeed(int[] piles, int H) {
if (piles.length > H) return -1;
int lo = 1, hi = (int)1e9;
while (lo < hi){
int mid = lo + (hi - lo) / 2;
int c = 0;
for (int i = 0; i < piles.length; i++)
c += (piles[i] + mid - 1) / mid;
if (H >= c)
hi = mid;
else
lo = mid + 1;
}
return lo;
}
}
int c = 0;
for (int i = 0; i < piles.length; i++)
c += (piles[i] + mid - 1) / mid;
这一串就是上文说到的形式复杂的G(m),(piles[i] + mid - 1) / mid
是为了向上取整。
还有一点,上面的两个例子,二分的都是value,而不是index,这根据不同的问题有不同的选择。
5 Summary
二分搜索有很多不同的写法,但是写正确很难。没有最好的写法,只有最易于你理解的写法。上面的左闭右开一个显著的缺点就是溢出,比如hi是int最大值,再+1就导致溢出。所以也要根据不同的情况来取舍。
当你使用自己的写法时,有如下几点要考虑:
6 Main reference
https://stackoverflow.com/questions/504335/what-are-the-pitfalls-in-implementing-binary-search
https://www.zhihu.com/question/36132386
http://coldattic.info/post/95/
以上是关于二分查找必知必会的主要内容,如果未能解决你的问题,请参考以下文章