算法

二分查找

每步消去一半搜索空间,在有序或单调结构上实现 O(log n) 查询

概览

二分查找的核心思想:利用单调性,每次将搜索范围缩小一半,从而在 O(log n) 时间内解决问题。它不仅适用于有序数组的精确查找,也可用于边界查找、旋转数组搜索,以及对答案值域二分解决「最小化最大值」类优化问题。

核心前提:单调谓词

存在某个分界点,左侧全为 false,右侧全为 true(或反之)。只要满足这个条件,就可以使用二分查找——不要求数组有序,只要谓词单调即可。

两种模板对比

while (lo <= hi)精确查找目标值。lo > hi 时退出,此时 lo = hi + 1。mid 更新:lo = mid+1 或 hi = mid-1
while (lo < hi)查找边界位置。lo == hi 时退出,lo 即为答案。hi 初始取 n(开区间),hi 可更新为 mid

防溢出中点计算

永远使用 mid = lo + (hi - lo) / 2,而非 (lo + hi) / 2。当 lo + hi > INT_MAX 时后者溢出为负数,导致死循环。

int mid = lo + (hi - lo) / 2;   // ✅ overflow-safe
int mid = (lo + hi) / 2;        // ❌ can overflow when lo + hi > INT_MAX

复杂度

时间 O(log n)每次迭代将搜索范围减半,最多 ⌈log₂n⌉ 次迭代
空间 O(1)只需常数个指针变量(递归版本为 O(log n) 栈空间)

经典二分

在已排序数组中查找目标值,返回下标;不存在则返回 -1。使用 while (lo <= hi) 闭区间模板。

核心思想

维护循环不变量:若 target 存在,则它必在 [lo, hi] 闭区间内。每次检查中点 mid:命中则返回;target 在右侧则 lo = mid+1;target 在左侧则 hi = mid-1。lo > hi 时区间为空,说明不存在。

步骤

  1. 初始化 lo = 0,hi = n - 1(闭区间,hi 包含最后一个元素)
  2. 计算 mid = lo + (hi - lo) / 2(防溢出)
  3. 若 nums[mid] == target,返回 mid
  4. 若 nums[mid] < target,目标在右半段,令 lo = mid + 1
  5. 若 nums[mid] > target,目标在左半段,令 hi = mid - 1
  6. lo > hi 时循环退出,返回 -1(未找到)

C++ 实现

int binarySearch(vector<int>& nums, int target) {
    int lo = 0, hi = (int)nums.size() - 1;
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;   // overflow-safe
        if (nums[mid] == target) return mid;
        else if (nums[mid] < target) lo = mid + 1;
        else                         hi = mid - 1;
    }
    return -1;   // not found
}

复杂度

时间 O(log n)每轮将区间缩半
空间 O(1)原地操作,无额外空间

常见错误

lo + hi 溢出改用 lo + (hi - lo) / 2,永不溢出
hi 初始值错误闭区间模板 hi = n-1,不是 n;开区间模板 hi = n
死循环更新时必须 lo = mid+1 / hi = mid-1,直接写 lo = mid 或 hi = mid 会死循环
练习:经典二分查找代码练习
测试用例(5 个)
用例 1
输入:5 3 1 2 3 4 5
期望:2
用例 2
输入:5 6 1 2 3 4 5
期望:-1
用例 3
输入:6 7 1 3 5 7 9 11
期望:3
用例 4
输入:1 1 1
期望:0
用例 5
输入:4 0 1 2 3 4
期望:-1

边界二分

查找目标值的最左或最右出现位置,处理重复元素。统一使用 while (lo < hi) 模板,hi 取 n(开区间)。

左边界(Lower Bound)

找第一个 >= target 的下标。即 C++ std::lower_bound 的语义。若所有元素 < target,返回 n。

步骤(左边界)

  1. 初始化 lo = 0,hi = n(hi 是开区间,超出末尾)
  2. 计算 mid = lo + (hi - lo) / 2
  3. 若 nums[mid] < target,目标在右侧,令 lo = mid + 1
  4. 否则(nums[mid] >= target),收缩上界 hi = mid
  5. 退出时 lo == hi,即为第一个 >= target 的下标
// First index where nums[i] >= target  (std::lower_bound semantics)
int lowerBound(vector<int>& nums, int target) {
    int lo = 0, hi = (int)nums.size();  // hi = n (open interval)
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (nums[mid] < target) lo = mid + 1;
        else                    hi = mid;      // nums[mid] >= target: keep mid
    }
    return lo;  // first index >= target (== n if all elements < target)
}

右边界(Upper Bound)

找第一个 > target 的下标,即 C++ std::upper_bound 的语义。该位置减 1 是最后一个 <= target 的下标。

步骤(右边界)

  1. 初始化 lo = 0,hi = n
  2. 计算 mid = lo + (hi - lo) / 2
  3. 若 nums[mid] <= target,令 lo = mid + 1
  4. 否则(nums[mid] > target),收缩上界 hi = mid
  5. 退出时 lo - 1 是最后一个 <= target 的下标
// First index where nums[i] > target  (std::upper_bound semantics)
int upperBound(vector<int>& nums, int target) {
    int lo = 0, hi = (int)nums.size();
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (nums[mid] <= target) lo = mid + 1;  // note: <= (not <)
        else                     hi = mid;
    }
    return lo;  // lo - 1 is last index <= target
}

// Count occurrences in O(log n):
// count(target) = upperBound(target) - lowerBound(target)

统计出现次数

count(target) = upperBound(target) − lowerBound(target),即两个边界下标之差。时间 O(log n),远优于线性扫描。

常见错误

hi 初始值边界二分 hi = n(不是 n-1),否则最后一个元素无法被选为左边界答案
更新方向混淆左边界:nums[mid] < target → lo=mid+1;右边界:nums[mid] <= target → lo=mid+1(多了等号)
while 条件必须用 lo < hi,否则 hi = mid 不移动时会死循环
练习:统计目标值出现次数代码练习
测试用例(5 个)
用例 1
输入:6 3 1 2 3 3 3 4
期望:3
用例 2
输入:5 5 1 2 3 4 6
期望:0
用例 3
输入:5 1 1 1 1 1 1
期望:5
用例 4
输入:4 2 1 2 2 4
期望:2
用例 5
输入:1 1 1
期望:1

旋转数组二分

在经过旋转的有序数组中查找目标值(LeetCode 33)。数组被旋转后仍有一个性质:每次二分后,[lo, mid] 和 [mid, hi] 中必有一段是完全有序的。

核心思想

通过比较 nums[lo] 与 nums[mid] 判断哪半段有序:若 nums[lo] <= nums[mid],则左半段有序;否则右半段有序。在有序半段中用普通范围检查判断 target 是否在其中,从而决定丢弃哪半段。

步骤

  1. 使用 while (lo <= hi) 模板,初始 lo=0, hi=n-1
  2. 若 nums[mid] == target,返回 mid
  3. 若 nums[lo] <= nums[mid](左半段有序)
  4. → 若 nums[lo] <= target < nums[mid],令 hi = mid - 1(target 在左半)
  5. → 否则令 lo = mid + 1(target 在右半)
  6. 若左半段无序(则右半段 [mid, hi] 有序)
  7. → 若 nums[mid] < target <= nums[hi],令 lo = mid + 1(target 在右半)
  8. → 否则令 hi = mid - 1(target 在左半)

C++ 实现

int searchRotated(vector<int>& nums, int target) {
    int lo = 0, hi = (int)nums.size() - 1;
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;
        if (nums[mid] == target) return mid;

        if (nums[lo] <= nums[mid]) {          // left half [lo, mid] is sorted
            if (nums[lo] <= target && target < nums[mid])
                hi = mid - 1;                 // target in left half
            else
                lo = mid + 1;                 // target in right half
        } else {                              // right half [mid, hi] is sorted
            if (nums[mid] < target && target <= nums[hi])
                lo = mid + 1;                 // target in right half
            else
                hi = mid - 1;                 // target in left half
        }
    }
    return -1;
}

注意事项

= 号边界判断左半有序用 nums[lo] <= nums[mid](含等号),因为 lo 本身也是候选
含重复元素(LC 81)当 nums[lo] == nums[mid] 时无法判断哪段有序,需 lo++ 跳过,最坏退化为 O(n)
找最小值变体LC 153:最小值在无序半段(nums[mid] > nums[hi] 则在右半,否则 hi = mid)
练习:旋转数组查找代码练习
测试用例(5 个)
用例 1
输入:7 0 4 5 6 7 0 1 2
期望:4
用例 2
输入:7 3 4 5 6 7 0 1 2
期望:-1
用例 3
输入:6 4 4 5 6 1 2 3
期望:0
用例 4
输入:6 3 4 5 6 1 2 3
期望:5
用例 5
输入:1 0 0
期望:0

答案空间二分

不对数组下标二分,而是对答案的值域二分。适用于「最小化最大值」「最大化最小值」类优化问题。

核心思想

设计一个单调谓词 feasible(x):x 满足约束则返回 true,否则返回 false。随着 x 增大,feasible 从 false 变为 true(最小化问题)或从 true 变为 false(最大化问题)。在答案值域 [lo, hi] 上二分找到这个分界点。

通用步骤(最小化问题)

  1. 确定答案值域 [lo, hi](如最小速度为 1,最大速度为 max(piles))
  2. 定义 feasible(mid):判断答案为 mid 时是否能满足约束
  3. 若 feasible(mid) 为 true,答案可能更小,收缩 hi = mid
  4. 若 feasible(mid) 为 false,答案需更大,收缩 lo = mid + 1
  5. 退出时 lo == hi,即为最小可行答案

C++ 示例(875. 珂珂吃香蕉)

// LC 875 — Koko Eating Bananas
// Given piles of bananas and h hours, find minimum eating speed.

bool canFinish(vector<int>& piles, long long h, long long speed) {
    long long hours = 0;
    for (int p : piles) hours += (p + speed - 1) / speed;  // ceil(p/speed)
    return hours <= h;
}

int minEatingSpeed(vector<int>& piles, int h) {
    int lo = 1, hi = *max_element(piles.begin(), piles.end());
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (canFinish(piles, h, mid)) hi = mid;  // feasible, try smaller
        else                          lo = mid + 1;
    }
    return lo;
}

常见变体

最小化最大值
feasible(x) = 以 x 为上限能否满足条件,二分找最小 x(如:最小载重、最小速度)
最大化最小值
feasible(x) = 以 x 为下限能否满足条件,二分找最大 x(如:最大化最近相邻距离)
第 K 小元素
feasible(x) = 矩阵/序列中 <= x 的元素数量 >= k,二分找最小 x

常见错误

答案范围lo 和 hi 必须覆盖所有可能答案;lo 过大或 hi 过小会漏掉答案
feasible 方向最小化时 feasible(mid)=true → hi=mid;最大化时 feasible(mid)=true → lo=mid(用 lo < hi-1 防死循环)
整数溢出feasible 内累加时极易溢出 int,改用 long long
练习:最小进食速度(875. 珂珂吃香蕉)代码练习
测试用例(4 个)
用例 1
输入:4 8 3 6 7 11
期望:4
用例 2
输入:5 5 30 11 23 4 20
期望:30
用例 3
输入:3 6 1 1 1
期望:1
用例 4
输入:2 2 10 1
期望:10

LeetCode 题型

按模板分类的高频二分查找题目。

经典二分

704. 二分查找
直接套 while(lo<=hi) 模板
35. 搜索插入位置
左边界变形,lo 即为插入点
74. 搜索二维矩阵
把矩阵 index 映射为一维坐标再二分

边界二分

34. 排序数组中查找第一个和最后一个位置
lowerBound + upperBound 组合
278. 第一个错误的版本
左边界模板,谓词为 isBadVersion(mid)
162. 寻找峰值
比较 nums[mid] 与 nums[mid+1] 决定收缩方向

旋转数组

33. 搜索旋转排序数组
判断有序半段,二分收缩
153. 寻找旋转排序数组中的最小值
最小值在无序半段
81. 搜索旋转排序数组 II(含重复元素)
nums[lo]==nums[mid] 时 lo++ 降级处理

答案空间二分

875. 爱吃香蕉的珂珂
最小化最大进食速度
1011. 在 D 天内送达包裹的能力
最小化载重量
410. 分割数组的最大值
最小化最大子数组和
378. 有序矩阵中第 K 小的元素
在值域上二分,feasible 统计 <= x 个数