写对二分查找有多难?Java集合框架的作者也曾栽在一行mid计算上

「算法与数据结构」系列 Day05

写在前面

二分查找大概是最短小精悍的算法之一------核心逻辑十几行代码,思路小学生都能听懂:每次看中间的数,比目标大就往左找,比目标小就往右找。但就是这么简单的算法,历史上真出过一次经典翻车:java.util.Arraysjava.util.Collections里的二分查找实现,在将近十年的时间里都藏着一个溢出bug,直到2006年才被时任Google工程师、《Effective Java》作者Joshua Bloch写博客揭出来。这篇就顺着这个真实案例,把二分查找容易写错的几个边界点讲透。


一、是什么:靠"砍半"逼近目标

二分查找解决的是在有序数组 里判断某个目标值在不在、在哪的问题。核心动作只有一步:拿数组中间位置的值和目标比较------相等就找到了;比目标小,说明目标只可能在右半区间,把搜索范围收缩到右边;比目标大,同理收缩到左边。每比较一次,搜索区间就砍掉一半,一个长度为n的数组最多比较log2(n)次就能确定结果,这也是它时间复杂度O(logn)的来源。

flowchart LR A[区间left到right] --> B[取中间mid] B --> C{arr的mid<br/>等于target吗} C -- 相等 --> D[命中返回] C -- mid处的值偏小 --> E[往右收缩<br/>left变成mid加1] C -- mid处的值偏大 --> F[往左收缩<br/>right变成mid减1] E --> G{区间还有效吗} F --> G G -- 有效 --> A G -- 无效 --> H[没找到返回-1]

二、为什么:思路简单,边界条件却处处是坑

先说那个真实的历史bug。教科书和早期JDK实现里,取中间下标的写法几乎都是mid = (left + right) / 2------leftright都是int类型,一旦数组长度足够大(left + right超过Integer.MAX_VALUE,也就是约21亿),这个加法本身就会先溢出成一个负数,再除以2还是负的,直接导致数组越界。Joshua Bloch在2006年那篇文章里指出,JDK自己的java.util.Arrays.binarySearchCollections.binarySearch里就是这个写法,这个bug潜伏了大约九年才被发现------原因很直白:大多数人测试二分查找只会用几十上百个元素的小数组,根本触发不到21亿这个量级。

修复方式是把mid = (left + right) / 2换成mid = left + (right - left) / 2right - left是数组的长度差,不会超过数组本身的长度,不可能溢出,加上left之后自然就是正确的中间下标,数学上完全等价,只是换了个不会溢出的算法。这个案例说明的道理是:二分查找出错往往不是思路错了,而是这些看起来无关紧要的边界写法错了,而且这类错误在小规模测试下完全不会暴露。

除了溢出,二分查找还有两类经典的边界坑,都跟"循环什么时候停、区间怎么收缩"有关:

  • 循环条件该用left <= right还是left < right :如果写成left < right,当搜索区间只剩最后一个元素(left == right)时,循环会直接判定条件不成立提前退出,这个元素永远不会被比较到,等于凭空漏掉一个候选值。
  • 收缩区间时该写mid+1/mid-1还是直接写mid :如果确认arr[mid]不是目标后,把边界更新成left = midright = mid而不是mid+1/mid-1,会导致已经确认排除的mid这个位置又被重新纳入下一轮搜索范围,区间可能永远缩小不到空,变成死循环。

三、怎么用:代码实现 + 常见坑

标准二分查找:迭代版本

java 复制代码
public static int binarySearch(int[] arr, int target) {
    int left = 0, right = arr.length - 1;
    while (left <= right) {                        // 用 <= 而不是 <
        int mid = left + (right - left) / 2;       // 防溢出写法
        if (arr[mid] == target) {
            return mid;
        } else if (arr[mid] < target) {
            left = mid + 1;                         // 收缩到mid+1,不是mid
        } else {
            right = mid - 1;                        // 收缩到mid-1,不是mid
        }
    }
    return -1;
}

这段代码把上一节提到的三个坑都避开了:防溢出的mid计算、left <= right的循环条件、mid+1/mid-1的收缩写法,三者缺一个都可能出问题。

边界查找:找重复元素里"最左边"的那个

面试里更常考的其实是变体------数组里有重复元素,要求返回第一个等于target的下标,而不是随便一个匹配位置:

java 复制代码
public static int leftBound(int[] arr, int target) {
    int left = 0, right = arr.length - 1;
    int result = -1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (arr[mid] == target) {
            result = mid;
            right = mid - 1;   // 找到了也不能直接返回,继续往左找更靠前的target
        } else if (arr[mid] < target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    return result;
}

最容易漏的坑就是找到target后立刻return mid ------这样返回的只是随便一个匹配到的位置,不保证是最左边那个。正确做法是先把这个位置记下来,然后当成"没找到、继续往左找"处理(right = mid - 1),直到区间收缩为空,最后记录的那个才是最左边的答案。

递归版本:思路一样,但要留意额外开销

java 复制代码
public static int binarySearchRecursive(int[] arr, int target, int left, int right) {
    if (left > right) return -1;   // 递归出口对应迭代版的循环条件取反
    int mid = left + (right - left) / 2;
    if (arr[mid] == target) return mid;
    return arr[mid] < target
        ? binarySearchRecursive(arr, target, mid + 1, right)
        : binarySearchRecursive(arr, target, left, mid - 1);
}

递归版本逻辑上和迭代版完全对应(递归出口left > right就是迭代循环条件left <= right的取反),但每次递归调用都有额外的方法调用栈开销,即使递归深度只有O(logn)、不会有栈溢出的风险,工程上处理海量数据的高频查找场景,通常还是优先选迭代版本。

变体 命中重复元素时返回什么 关键改动点
标准二分查找 任意一个匹配位置 命中就直接返回
查找左边界 最左边(第一个)匹配位置 命中后继续收缩右边界right = mid - 1
查找右边界 最右边(最后一个)匹配位置 命中后继续收缩左边界left = mid + 1

四、面试追问

Q1:为什么mid要写成left + (right - left) / 2,而不是更直观的(left + right) / 2

因为leftright都是int类型,当数组长度足够大、两者之和超过Integer.MAX_VALUE时,left + right这一步加法本身就会先溢出成负数,算出来的mid是错的,还可能导致数组越界。left + (right - left) / 2在数学上完全等价,但right - left不会超过数组长度,不会发生溢出,是更安全的写法。这个问题在JDK的Arrays.binarySearch里真实存在过近十年才被修复。

Q2:为什么循环条件要写left <= right而不是left < right

因为搜索区间是闭区间[left, right],当区间收缩到只剩一个元素时left == right,这个元素依然是合法的候选值,必须进入循环体被比较一次。如果条件写成left < right,这种情况会被提前判定为循环结束,直接跳过对这个元素的比较,导致明明存在的目标值被漏掉。

Q3:查找左边界的场景里,为什么命中target之后不能直接返回?

因为要找的是重复元素里最靠左的那个,而不是随便一个匹配位置。命中之后如果直接返回,返回的下标可能是重复元素中间的某一个,不保证前面还有没有更早的匹配。正确做法是把当前命中的位置先记录下来,然后按"没找到"处理继续收缩右边界(right = mid - 1)向左搜索,直到区间为空,最后一次记录的位置才是真正最左边的答案。

Q4:二分查找的递归实现和迭代实现,工程上更推荐用哪个?

两者时间复杂度都是O(logn),逻辑完全对应,但递归版本每层递归都有额外的方法调用栈开销,迭代版本只需要几个局部变量,空间复杂度是O(1)。虽然二分查找的递归深度只有O(logn),不会有栈溢出风险,但涉及高频调用的场景,迭代版本的常数开销更小,工程上通常优先选迭代实现。


下一篇预告

Day06 KMP字符串匹配:next数组到底在记录什么。

相关推荐
Lokey86843 分钟前
transformer结构
算法
罗斯8391 小时前
EMBER恶意软件基准数据集
人工智能·算法·安全·网络安全
0+1111 小时前
算法 --二分查找
c++·算法·leetcode
Omics Pro2 小时前
新型条件传输模型!虚拟细胞扰动预测
数据库·人工智能·算法·机器学习·自然语言处理
黄金龙PLUS2 小时前
5个800比特大状态置换算法的设计与分析
算法·网络安全·密码学·哈希算法·同态加密
政企项目老覃2 小时前
大模型 Agent 自主任务编排:电商客服地址解析从 12% 失败率到 2.1% 的落地复盘
人工智能·程序人生·算法
kukubuzai2 小时前
双指针系列二--末尾篇(3道题)
c++·算法·leetcode
杜 硕2 小时前
单链表经典算法题
数据结构·算法
小O的算法实验室3 小时前
IEEE TCYB,着色旅行商问题:模型、求解与应用
算法