刨析二分查找本质:超越“单调性”的边界判定与竞赛实战

刨析二分查找本质:超越“单调性”的边界判定与竞赛实战

在算法竞赛(如 ICPC、PTA 天梯赛、洛谷题库)中,二分查找(Binary Search)是出场率极高且极易写出 Bug 的基础算法。大多数教材将二分查找定义为“在有序数组中寻找特定元素”,这种理解在工业业务代码中勉强够用,但在高强度的算法竞赛中是远远不够的。

二分的真正本质并非“单调性”,而是“边界判定”。本文将刨析二分的底层逻辑,提供绝对不会死循环的 C++ 竞赛模板,并深入分析“二分答案”这一高阶实战技巧。


一、 二分的本质:寻找性质的边界

假设我们有一个区间,区间内的元素并不一定具有严格的数值单调性,但存在某种判定性质(Predicate)
如果这个区间可以通过该性质被完美划分为两半:

  • 左半部分:全部满足该性质。
  • 右半部分:全部不满足该性质。

那么,二分查找的作用,就是以 \(O(\log N)\) 的时间复杂度,精准地找出这个分界点。这就是为什么很多看似没有“顺序”的图论题或动态规划题,依然能嵌套二分的原因——只要你能构造出一个将搜索空间“一分为二”的判定条件,就能二分。


二、 整数二分的终极模板:告别死循环

在手写二分时,最痛苦的莫过于陷入 while(l < r) 的死循环,或者在 l = mid 还是 l = mid + 1 之间犹豫不决。

以下两套 C++ 模板通过严格的区间划分,彻底杜绝了边界问题。其核心在于:mid 的计算方式必须与区间的更新方式严格匹配。

模板 1:寻找左边界(满足条件的第一个元素)

当我们将区间 [l, r] 划分为 [l, mid][mid + 1, r] 时,使用此模板。

// 寻找满足 check(x) 的最小值(左边界)
int binary_search_left(int l, int r) {
    while (l < r) {
        int mid = l + r >> 1; // 等价于 (l + r) / 2
        if (check(mid)) {
            r = mid;          // mid 满足条件,答案在 [l, mid] 中
        } else {
            l = mid + 1;      // mid 不满足,答案在 [mid + 1, r] 中
        }
    }
    return l;
}

模板 2:寻找右边界(满足条件的最后一个元素)

当我们将区间 [l, r] 划分为 [l, mid - 1][mid, r] 时,使用此模板。
避坑核心:此时计算 mid 必须加上 1,即 l + r + 1 >> 1。因为 C++ 的整数除法是向下取整的,如果 l = r - 1,且没有加 1,mid 会等于 l。若此时执行 l = mid,区间大小不变,程序将陷入死循环。

// 寻找满足 check(x) 的最大值(右边界)
int binary_search_right(int l, int r) {
    while (l < r) {
        int mid = l + r + 1 >> 1; // 必须加 1,防止向下取整导致的死循环
        if (check(mid)) {
            l = mid;              // mid 满足条件,答案在 [mid, r] 中
        } else {
            r = mid - 1;          // mid 不满足,答案在 [l, mid - 1] 中
        }
    }
    return l;
}

三、 竞赛核心杀器:二分答案 (Bisection of Answer)

在洛谷和 PTA 的中高阶题目中,纯粹的“在数组中二分”极少出现。取而代之的是“二分答案”

1. 题型特征

如果你在题目中看到以下字眼,基本可以直接确立“二分答案”的思路:

  • “最大化最小值”(如:把木头锯成 \(K\) 段,让最短的那段尽可能长)。
  • “最小化最大值”(如:将数组分为 \(M\) 个连续子数组,让和最大的子数组尽可能小)。
  • 题目要求一个极值,直接正向推导极其困难,但如果我“猜”一个答案,去验证它是否合法,却很容易(验证复杂度通常为 \(O(N)\))。

2. 核心逻辑:从“求解”降维到“判定”

二分答案的做法,就是直接在可能的答案值域(例如 \([1, 10^9]\))上进行二分查找。
每次取一个中间值 mid,将求解最优解的问题,转化为一个非黑即白的判定问题:check(mid)

实战案例分析:最小化最大值(如洛谷 P1182 数列分段)
要求将长度为 \(N\) 的数组分成 \(M\) 段,使得各段和的最大值最小。

  • 直接求:状态转移复杂,动态规划可能超时。
  • 二分答案:假设最大和为 mid。此时问题降维成:“在每段和都不超过 mid 的前提下,能否将数组分成的段数 \le M?”
  • check 函数实现:贪心地从左到右累加,一旦当前和加上下一个元素超过了 mid,就截断开辟新的一段。最后统计总段数是否 \le M。这只需要 \(O(N)\) 的时间。

整体时间复杂度从不可接受的级别,瞬间降到了 \(O(N \log(\text{MAX_VAL}))\),这就是二分答案的降维打击能力。


四、 浮点数二分与精度控制

除了整数二分,计算几何或物理模拟类题目中常涉及浮点数二分(例如求二次函数的根,或求解某条射线的相交时间)。
浮点数二分不需要考虑边界死循环问题(不需要 \(+1\)),但核心在于精度控制

double float_binary_search(double l, double r) {
    // eps 一般取题目要求精度的后两位,例如要求保留 4 位小数,eps 就取 1e-6
    const double eps = 1e-6; 
    
    while (r - l > eps) {
        double mid = (l + r) / 2.0;
        if (check(mid)) {
            r = mid; 
        } else {
            l = mid;
        }
    }
    return l;
}

暴力替代法:为了防止某些题目极端数据下精度判定失效,很多顶尖选手会直接放弃 while (r - l > eps),直接固定循环次数(如 for (int i = 0; i < 100; i++))。二分 100 次足以在浮点数范围内达到惊人的精确度,且绝对不会超时。


五、 总结

二分查找在算法竞赛中的地位,类似于物理学中的牛顿定律——形式简单,但衍生出的应用无穷无尽。

剥开表象,二分的本质是在一维空间中利用 \(O(\log N)\) 的时间代价进行快速试错。掌握好 l + r >> 1l + r + 1 >> 1 的边界选择,明确 check() 判定的单调性分界点,你就彻底掌握了解决“最值优化问题”最有效的一把钥匙。

posted @ 2026-08-02 15:17  阿尹想学会C++  阅读(24)  评论(0)    收藏  举报