返回文章列表
数据结构与算法
算法数组二分查找

二分查找:循环不变量与边界

二分查找是每个程序员的入门第一课,但也是写错率最高的模板之一:while 条件用 < 还是 <=?right 更新成 mid 还是 mid - 1?靠背答案迟早翻车。这篇文章用"循环不变量"一个思想,把所有边界问题一次理顺。

二分的前提条件

二分查找有两个硬性前提,缺一个就不能用:

  1. 有序性:数据必须按目标属性有序(升序或降序)。有序保证了"排除一半"的正确性——中点与目标比较后,目标必然只可能落在剩下的一半里。
  2. 可随机访问:必须能 O(1) 拿到任意位置的元素。数组满足,链表不满足(链表找中点本身就要 O(n),二分失去意义)。

一个容易忽略的推论:二分不只用于"数组里找数"。只要问题能定义出一个"单调的判定函数"——比如 69 求平方根里"mid² ≤ x 是否成立"、35 搜索插入位置里"nums[mid] < target 是否成立"——就能对解空间二分。这叫"二分答案",是后面大量题目的进阶套路。

两套区间写法:选一套,贯彻到底

二分的所有纠结都来自一个定义问题:当前搜索区间是 [left, right] 还是 [left, right)。两种定义都正确,但区间定义必须和三处代码保持自洽:while 循环条件、right 的初始值、区间收缩时 right 的更新值。

写法一:闭区间 [left, right]

循环不变量:target 一定在 [left, right] 内(若存在)。

public int search(int[] nums, int target) {
    int left = 0, right = nums.length - 1;   // 闭区间 [0, n-1]
    while (left <= right) {                  // 区间非空才继续:left==right 时 [x,x] 仍有效
        int mid = (left + right) >>> 1;
        if (nums[mid] == target) {
            return mid;
        } else if (nums[mid] < target) {
            left = mid + 1;                  // mid 已排除,新区间 [mid+1, right]
        } else {
            right = mid - 1;                 // mid 已排除,新区间 [left, mid-1]
        }
    }
    return -1;
}

自洽性逐条验证:

  • 区间是闭的 → left == right 时区间 [x, x] 还有一个元素,必须用 <=;用 < 会漏查一个元素。
  • nums[mid] 已比较过 → 它必须被排除出区间 → left 跳到 mid + 1、right 退到 mid - 1,两个分支都是"舍弃 mid"。

写法二:半开区间 [left, right)

循环不变量:target 一定在 [left, right) 内(若存在)。

public int search(int[] nums, int target) {
    int left = 0, right = nums.length;       // 半开区间 [0, n)
    while (left < right) {                   // 区间非空才继续:left==right 意味着空区间
        int mid = (left + right) >>> 1;
        if (nums[mid] == target) {
            return mid;
        } else if (nums[mid] < target) {
            left = mid + 1;                  // 新区间 [mid+1, right)
        } else {
            right = mid;                     // 新区间 [left, mid),mid 被排除但 right 本身不在区间内
        }
    }
    return -1;
}

自洽性逐条验证:

  • 区间右端开 → left == right(如 [3, 3))就是空区间,必须用 <。
  • 右分支收缩时写成 right = mid:因为 right 这个位置本来就不属于区间,把边界挪到 mid(含)不会把 mid 漏掉,同时 mid 也被排除了。这是半开区间最反直觉的一行,靠不变量推而不是靠背。

两套写法对照表

要素 闭区间 [l, r] 半开区间 [l, r)
right 初始值 nums.length - 1 nums.length
while 条件 left <= right left < right
右收缩 right = mid - 1 right = mid
左收缩 left = mid + 1 left = mid + 1
循环结束含义 区间为空(交错) 区间为空(重合)

建议主用闭区间写法(更符合直觉),半开区间做到能看懂、能判断别人代码的对错。考试式的错误示范是:while 用 <,right 更新却写 mid - 1——区间定义前后矛盾,某些用例必然漏解或死循环。

mid 计算的防溢出写法

朴素写法 mid = (left + right) / 2 有一个隐患:left 和 right 都是 int,相加可能超过 Integer.MAX_VALUE,溢出成负数,导致数组越界异常。LeetCode 极端用例下真的会踩到。

int mid = left + (right - left) / 2;    // 写法 A:先减后加,永不溢出
int mid = (left + right) >>> 1;         // 写法 B:无符号右移,等价且更简洁

两种都行。>>> 是无符号右移:即使和溢出到负数,最高位被当作数值位右移,结果仍然正确。注意 >>(带符号右移)不具备这个性质。Java 里推荐 >>> 1。

查找边界:34 的左右边界二分

704 这类"找到就返回"是二分的入门形态。进阶形态是 LeetCode 34:找到 target 在数组中的起始和结束位置,要求 O(logn)。关键区别:数组里有重复元素,不能找到就停,要把查找"推"到最左或最右。

思路:写一个"找第一个 ≥ x 的下标"的函数(俗称 lowerBound),它本身就是二分答案:

// 返回第一个 >= x 的下标;不存在则返回 nums.length
private int lowerBound(int[] nums, int x) {
    int left = 0, right = nums.length - 1;
    int ans = nums.length;                    // 记录候选答案
    while (left <= right) {
        int mid = (left + right) >>> 1;
        if (nums[mid] >= x) {
            ans = mid;                        // mid 可能是答案,先记下
            right = mid - 1;                  // 继续往左找更靠前的
        } else {
            left = mid + 1;
        }
    }
    return ans;
}
 
public int[] searchRange(int[] nums, int target) {
    int start = lowerBound(nums, target);     // 第一个 >= target
    if (start == nums.length || nums[start] != target) {
        return new int[]{-1, -1};             // target 不存在
    }
    int end = lowerBound(nums, target + 1) - 1;  // 第一个 >= target+1 的前一位
    return new int[]{start, end};
}

两个要点:

  • 找右边界不用单独写模板:lowerBound(target + 1) - 1 一步到位,少背一个容易混淆的版本。
  • 与 704 的区别在于:命中后不 return,而是记录答案继续收缩,把"找到一个解"升级为"找最靠边的解"。这个"记录 + 收缩"的手法是所有边界二分的通用骨架。

复杂度与易错点

每轮循环区间至少缩小一半,n 个元素最多 log₂n + 1 轮:时间 O(logn),空间 O(1)。这也是"有序数据优先想二分"的理由——O(n) 遍历和 O(logn) 二分在 n = 10⁹ 时是天壤之别。

易错点清单:

  • while 条件与 right 更新不自洽(最常见,用不变量自查)。
  • (left + right) / 2 溢出,未用 >>> 1 或 left + (right - left) / 2。
  • 数组无序就上二分——先确认前提。
  • 34 类边界题在"target 不存在"分支漏判:lowerBound 返回 n 或 nums[start] != target 都要处理。
  • 收缩时写成 left = mid(而不是 mid + 1),在两元素区间可能死循环。

对应 LeetCode 题目

题号 题名 一句话考点
704 二分查找 两套区间写法的基础模板
35 搜索插入位置 lowerBound 的直接应用,target 不存在时返回插入点
34 在排序数组中查找元素的第一个和最后一个位置 左右边界二分,记录 + 收缩手法
69 x 的平方根 二分答案:对"mid² ≤ x"的单调判定函数二分

参考