二分查找

1,068 阅读4分钟

二分查找是面试中经常考察的一个点,在实际工作中的应用也非常广泛,这篇文章是本人读过关于二分查找解释最为精辟的文章之一,翻译过来以飨大家~ 感谢原文作者 Roman Elizarov,他也是开发 Kotlin 语言的 Team Lead

原文:Programming Binary Search - Roman Elizarov - Medium

二分查找是计算科学里面一个基础的算法。其基本思想就是分而治之。 很多人听说过这个算法,但却少有人知道如何优雅简洁的实现它。

有一个很多人都忽略的事实,在 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

二分查找看起来是一个简单的问题,但实际上并不容易写对。学会其背后的套路能极大的提升技术面试的通过率,其背后的算法设计哲学也可以被应用在很多的地方。