技术

二分查找:十亿条数据,三十次比较

字号
二分查找:十亿条数据,三十次比较

二分查找是「听起来最简单、写起来最容易错」的算法代表。本文讲清它的不变量、复杂度,以及两桩可核查的著名翻车事件。

一、思路与不变量

在有序数组中找目标值:每次取中点比较,排除一半,重复直到找到或区间为空。

写出正确实现的关键是守住一个不变量。以左闭右开区间 [lo, hi) 为例,全程维持「若目标存在,必在 [lo, hi) 内」:

def bsearch(a, target):
    lo, hi = 0, len(a)          # 不变量:目标在 [lo, hi)
    while lo < hi:
        mid = lo + (hi - lo) // 2
        if a[mid] == target:
            return mid
        elif a[mid] < target:
            lo = mid + 1        # [lo, mid] 全部排除
        else:
            hi = mid            # [mid, hi) 全部排除
    return -1

循环结束时 lo == hi,区间为空,说明不存在。每一行都与不变量对得上,这就是它「想错都难」的写法。

二、复杂度

每次迭代区间减半,最坏比较次数为 ⌈log₂(n+1)⌉。十亿(10⁹)条有序数据只需约 30 次比较;百万级数据 20 次。这是对数复杂度 O(log n) 最直观的注脚。

三、两桩著名的翻车(均可核查)

1. 它比看起来难写得多。 Jon Bentley 在《编程珠玑》(Programming Pearls)中记述:他在职业程序员课堂上要求大家写二分查找并自行验证,结果约九成的人第一次都写不对。他还引用 Knuth《计算机程序设计艺术》卷 3 的考证:二分查找的思想 1946 年就已发表,但第一个对任意大小数组都正确的版本迟至 1962 年才出现。

2. JDK 藏了近九年的溢出 bug。 2006 年,Google 的 Joshua Bloch 在 Google Research Blog 发文指出:Java 标准库 Arrays.binarySearch 用 (low + high) / 2 求中点,当数组大到两数之和超出 int 上限(约 21 亿)时会溢出为负,导致数组越界异常。修复即本文采用的写法 low + (high - low) / 2。这个 bug 自 JDK 1.4 时代便潜伏在标准库里。

四、使用前提

  • 数组必须有序——排序成本 O(n log n),若只查一次,直接线性扫描更划算;
  • 底层结构必须支持随机访问,链表上二分没有意义。

参考与核查

  • Jon Bentley, Programming Pearls(Addison-Wesley),「Writing Correct Programs」一章
  • Donald Knuth, The Art of Computer Programming, Vol. 3 §6.2.1(二分查找历史考证)
  • Joshua Bloch, Extra, Extra – Read All About It: Nearly All Binary Searches and Mergesorts are Broken, Google Research Blog, 2006

评论