二分查找是面试中经常考察的一个点,在实际工作中的应用也非常广泛,这篇文章是本人读过关于二分查找解释最为精辟的文章之一,翻译过来以飨大家~ 感谢原文作者 Roman Elizarov,他也是开发 Kotlin 语言的 Team Lead
二分查找是计算科学里面一个基础的算法。其基本思想就是分而治之。 很多人听说过这个算法,但却少有人知道如何优雅简洁的实现它。
有一个很多人都忽略的事实,在 Java / Kotlin 中,如果数组中要查找的元素并不是唯一的,标准库实现的二分查找返回的下标是没有明确定义的,也就是说可能是任意一个。
例如下面代码返回的既不是 1 也不是 3,而是 2.
println(listOf(6, 7, 7, 7, 9, 9).binarySearch(7))
如果我们想要返回目标元素第一个出现的位置呢?更准确一点,找出来最下的下标 i 使得 list[i] >= value,如果数组中所有元素都比 value 小,则返回 list.size。用集合运算符表示就是:
list.indexOfFirst { it >= value }.takeIf { it >= 0 } ?: list.size
想练手的话可以到这里。😊
通用的二分查找算法实现
二分查找适用于以下问题。
这个问题的答案是一个数字,你不知道这个答案,但如果给你这个答案,你可以很快的检验它是否是正确答案。所以可以假设你有一个检验的方式,一个 predicate 函数 ok(x),当 x 是一个可能的答案的时候就返回 true,你的任务就是去找到使得 ok(x) 返回 true 的最小 x 值。
还一个关键条件是这个 ok(x) 必须是单调的,也就是说如果 ok(x) 返回了 true,那么对于所有大于 x 的值 y,ok(y) 必须也是 true.
在设计二分查找的时候,我们会引入两个变量,一个 l 代表当前搜索范围的左边界,一个 r 代表当前搜索范围的右边界,这个算法在运行过程中保持一下两个条件:ok(l) = false && ok(r) = true
二分查找的过程就是分而治之的过程,每次迭代搜索范围就减半,同时也要维持前面的两个条件,最终 l 和 r 相差 1,也就是说是元素从 false 变为 true 的位置。
x : 1 2 3 4 5 6
ok(x) : F F T T T T
^ ^
l r // at the end
所以标准的解法就是执行这个循环。
while (r - l > 1) {
val m = (l + r) / 2
if (ok(m))
r = m
else
l = m
}
如果记住这个循环的话,if 语句的条件很容易带入,但我们还差了一步,就是需要初始化 l 和 r 的值。那我们如何才能找到合适的 l/r 从而满足前面的两个条件的,显然,我们不能简单的将 l/r 赋值为 0/list.size,这种情况下通常的选择是设置两个哨兵,第一个哨兵位于搜索范围左边界的前一位,第二个哨兵位于搜索右边界的下一位,然后我们假设他们满足我们的条件。
x : 0 1 2 3 4 5 6 7
ok(x) : [F] F F T T T T [T] // [x] are virtual values
^ ^
l r // at the beginning
因为在迭代过程中,我们只会检测 ok(m),所以不用担心 ok(m) 会超出范围。
回到开头的问题,ok(i) 就是 list[i] >= value,搜索的范围是 0 到 list.size - 1,所以 l = -1, r = list.size. 因为要求是返回第一个 ok(i)=true 的元素下标,所以我们应该在迭代结束的时候返回 r.
fun searchLowerBound(list: List<Int>, value: Int): Int {
var l = -1
var r = list.size
while (r - l > 1) {
val m = (l + r) / 2
if (list[m] >= value)
r = m
else
l = m
}
return r
}
Integer 溢出问题
上面的代码已经很不错了,但还有两个问题。
第一个,r -l > 1, 当 l = -1, r = Int.MAX_VALUE 的时候,r - l 就溢出了,变成了负值,导致循环退出,返回了错误的结果。更好的方式是检查 l + 1 < r, 因为 l 总是小于 r,所以 l + 1 肯定不会溢出。
第二个溢出的问题更明显了,就是 (l + r) / 2 的时候,当 l 和 r 都接近 Int.MAX_VALUE 的时候, l + r 就会溢出。当然我们可以用 Long 来计算,但如果我们搜索范围是 Long 怎么办呢?这里其实最好的办法是使用 unsigned shift,即 (l + r) ushr 1,鉴于事实上 m 永远不会是负值,也永远不会超出最高位,这个表达式完美解决的溢出的问题。
所以下面才是问题的最终解答。
fun searchLowerBound(list: List<Int>, value: Int): Int {
var l = -1
var r = list.size
while (l + 1 < r) {
val m = (l + r) ushr 1
if (list[m] >= value) {
r = m
} else {
l = m
}
}
return r
}
conclusion
二分查找看起来是一个简单的问题,但实际上并不容易写对。学会其背后的套路能极大的提升技术面试的通过率,其背后的算法设计哲学也可以被应用在很多的地方。