Binary Seach在概念上很容易理解,无非就是每次将搜索空间划分为两份,只保留这二者中可能包含目标值的那一份,持续二分下去,直到完成搜索为止,由此将搜索复杂度从线性时间 O ( n ) O(n) O(n)减少至对数时间 O ( l o g n ) O(log\ n) O(log n)。但在具体实现上,想要在几分钟内成功写出一份bug free的代码并不是件简单的事情。我们常常会遇到一些恼人的细节问题,诸如while循环的条件应该用while left < right
还是用while left <= right
,初始化边界的时候left
和right
该设为什么值,更新边界的时候应该如何从left = mid
, left = mid + 1
, right = mid
, right = mid - 1
之中选择合适的组合。另外,Binary Search并不是只能适用于「给定一个数组,搜索一个目标数字」这样简单的场合,它有着更为一般化的应用场景。
我会在这篇文章里详细地总结这些内容,并把它应用到LeetCode的实际题目中。我不希望只是简单地贴出每道题目的代码,我所希望分享的是思路,是如何将最一般化的Binary Search模板应用到各种题目上面,让大家不要再有“诶呀原来这道题目可以用二分查找来做! 我怎么就没有想到呢!”的懊恼。
一般化的Binary Search
给定一个从小到大排好序的数组array
,在不同的场景下,我们会有不同的搜索目标,诸如搜索特定的数字target
,给一个数字寻找插入的位置等。对于绝大多数任务,我们都可以将其转化下面的最一般化的形式:
m i n k s . t . c o n d i t i o n ( k ) i s T r u e min\ k \\ s.t. \ condition(k) \ is\ True\\ min ks.t. condition(k) is True
下面是最一般化的Binary Search模板:
def binary_search(array) -> int:
def condition(value) -> bool:
pass
left, right = 0, len(array)
while left < right:
mid = left + (right - left) // 2
if condition(mid):
right = mid
else:
left = mid + 1
return left
这一模板的强大之处在于,对于绝大多数的Binary Search题目,我们只需要修改三个部分就可以照搬这个模板,而不必再去担心代码bug和角点解的问题。
- 正确地初始化
left
和right
这两个变量。只需要满足一点要求即可:初始化之后的边界必须囊括所有可能的元素; - 根据具体应用场景修改返回值,是
return left
还是return left - 1
还是return -1
。只需要记住一点:在while循环终止之后,left
便是上面一般化公式中的最小的k值,并且此时left
和right
相等; - 构思好
condition
函数具体是什么,这是最困难最巧妙的部分,也是最需要勤加练习的部分。
下面我会结合一些经典高质量的LeetCode题目来展示如何套用这个万能的模板。
基本应用
278. First Bad Version [Easy]
You are a product manager and currently leading a team to develop a new product. Since each version is developed based on the previous version, all the versions after a bad version are also bad. Suppose you have n
versions [1, 2, ..., n]
and you want to find out the first bad one, which causes all the following ones to be bad. You are given an API bool isBadVersion(version)
which will return whether version
is bad.
Example:
Given n = 5, and version = 4 is the first bad version.
call isBadVersion(3) -> false
call isBadVersion(5) -> true
call isBadVersion(4) -> true
Then 4 is the first bad version.
首先初始化边界,因为搜索范围是[1, 2, ..., n]
所以我们设置left, right = 1, n + 1
,然后我们注意到,模板里的condition函数实际上已经给好了(直接调用提供的API即可),我们的搜索目标是找到最小的索引值 k ∗ k^{*} k∗,此时版本 k ∗ k^{*} k∗是First Bad Version,而版本 k ∗ − 1 k^{*} - 1 k∗−1则是Last Good Version,于是解法如下:
class Solution:
def firstBadVersion(self, n) -> int:
left, right = 1, n
while left < right:
mid = left + (right - left) // 2
if isBadVersion(mid):
right = mid
else:
left = mid + 1
return left
69. Sqrt(x) [Easy]
Implement int sqrt(int x)
. Compute and return the square root of x, where x is guaranteed to be a non-negative integer. Since the return type is an integer, the decimal digits are truncated and only the integer part of the result is returned.
Example:
Input: 4
Output: 2
Input: 8
Output: 2
简单明了的题目,我们需要找的是满足 k 2 ≤ x k^2 \le x k2≤x的最大k值,于是很轻松地就能写出解法:
def mySqrt(x: int) -> int:
left, right = 0, x + 1
while left < right:
mid = left + (right - left) // 2
if mid * mid <= x:
left = mid + 1
else:
right = mid
return left - 1
这里有一点需要注明的是,文章开头我们说**「寻找满足条件的最小 k k k值是最具一般化的形式」**,而现在却在搜索最大的 k k k值,是否前后矛盾?不矛盾。满足feasible(k) == False
的最大 k k k值加上1,便等于满足feasible(k) == True
的最小 k