作为二分最经典的案例,猜价格——在电视节目中,用最少的次数猜出整数的物品价格。你的最优策略很可能就是二分方法的尝试。它的核心思想是,每次减少一半的搜索区间,以减少查找次数。

我们可以抽象出一个问题,从一个有序数组中找到等于 5.2 二分查找 - 图1 的元素的下标。根据中点值是高是低,决定下一次搜索哪一半区间。

  1. // 查找元素在数组int a[]中的位置
  2. int binarySearch(int x) {
  3. int l = 0, r = n;
  4. while (l < r) {
  5. int m = l + (r - l) / 2; // 防止溢出 结果等于(l+r)/2
  6. if (a[m] == x) {
  7. return m;
  8. } else if (a[m] < x) {
  9. l = m + 1;
  10. } else if (a[m] > x) {
  11. r = m - 1;
  12. }
  13. }
  14. return -1;
  15. }

使用这种二分查找有一个条件,那就是数组必须有序,且元素必须唯一(否则有多个答案)。

为了元素的不唯一性,我们有如下模板:

二分查找模板

// 查找元素出现起始位置 (找不到返回-1)
int lower(int k) {
    int l = 0, r = n - 1;
    while (l < r) {
        int m = l + r >> 1;        // 右移1位等价于除以2,运算优先级低,此处不用打括号
        if (a[m] < k) {
            l = m + 1;
        } else {
            r = m;
        }
    }
    return a[l] == k ? l : -1;
}
// 查找元素出现终止位置(找不到返回-1)
int upper(int k) {
    int l = 0, r = n - 1;
    while (l < r) {
        int m = l + r + 1 >> 1;        // 注意 + 1
        if (a[m] > k) {
            r = m - 1;
        } else {
            l = m;
        }
    }
    return a[l] == k ? l : -1;
}

在这个模板的循环条件中,我们写成 while (l < r),这样程序会在 l == r 时停止,a[l] 就是程序没有看到的最后一个元素,再进行一个判断即可。

二分有「细节是魔鬼」的说法。模板能很好的解决边界问题,请务必熟记,避免自行书写出现失误。注意,只要出现 l = m; 语句时(后一种模板),5.2 二分查找 - 图2 的值应该更新为 5.2 二分查找 - 图3%20%2F%202#card=math&code=%28l%20%2B%20r%20%2B%201%29%20%2F%202&id=R9Anw),防止无限循环,有关这个细节请自行查阅资料。

例题 1:蓝桥 算法训练 找数2

问题描述:在一个小到大的有序序列中(不存在重复数字),查找某个数所在的位置。如果该数不在该数列中,输出其应插入点的位置。

输入说明

第一行输入N表示有N个数据(N<=1000)
  第二行N个小到大非重复的数字(<=10000)
  第三行数字X,查找X

输出说明

若X在序列中则输出其相应的位置,若不在序列中输出其应插入的位置。

样例输入 10 12 34 56 78 88 99 101 134 145 233 88

样例输出 5

样例输入 10 23 34 56 78 99 123 143 155 167 178 128

样例输出 7

说明:保证输入数据是有序的。

来试试看吗?这完全用到了刚刚提到的二分查找。尝试过之后再来看参考代码吧。

参考代码 1
#include <bits/stdc++.h>
using namespace std;
const int N = 1004;
int a[N];
int n;
int lower(int k) {
    int l = 1, r = n + 1;
    while (l < r) {
        int m = l + r >> 1;    
        if (a[m] < k) {
            l = m + 1;
        } else {
            r = m;
        }
    }
    return l;
}
int main() {
    cin >> n;
    for (int i=1; i<=n; ++i) {
        cin >> a[i];
    }
    int x;
    cin >> x;
    cout << lower(x) << endl;
    return 0;
}

我猜你刚刚提交答案错误了不止一次,看过代码过后,不会想打我吧?这怎么和说好的不一样呢,你这模板有问题啊。其实,这就是模板,模板不是用来生搬硬套的,模板只是一个一般情况,是需要理解、并作出调整。这算是一个小玩笑,希望你记住这一点,往后我不会再做错误的引导。

首先,比较明显的不同是「如果该数不在该数列中,输出其应插入点的位置」,这一点其实暗示我们使用 lower() 模板,并在 return 语句时,a[l] != k 的情况也 return l; 而不是 return -1;

其次,答案应是「第几个位置」的逻辑序号,而不是数组下标,应当从 1 开始,我们的做法是读入数组就从下标为 1 的位置开始,这样两种序号就一致了。

另外,lower() 中的 lr 也需要设为「可能为答案的区间」即 5.2 二分查找 - 图4 。举个例子,在{1, 2, 3}, n = 3;中搜索 4 ,答案是 4。也就是说,第 5.2 二分查找 - 图5 种可能的位置在数组的最后,是存在的。

希望你对二分有了更好地理解。

实际上,二分的应用不局限于元素有序,它的充分必要条件其实是待搜索答案区间的「两段性质」,即在区间上存在唯一的一个点,使得这个点一侧的答案全部满足条件、另一侧的答案全部不满足条件。根据二分的核心思想,不断判断中点是否全部满足条件,每次减少一半的搜索区间,我们就能搜索到这个分界点。

这就是另一种二分的常见应用——二分答案

二分答案问题常见的题面是,求满足条件的答案的最大/最小取值。

例题 2:力扣 278. 第一个错误的版本

你是产品经理,目前正在带领一个团队开发新的产品。不幸的是,你的产品的最新版本没有通过质量检测。由于每个版本都是基于之前的版本开发的,所以错误的版本之后的所有版本都是错的。

假设你有 n 个版本 [1, 2, ..., n],你想找出导致之后所有版本出错的第一个错误的版本。

你可以通过调用 bool isBadVersion(version) 接口来判断版本号 version 是否在单元测试中出错。实现一个函数来查找第一个错误的版本。你应该尽量减少对调用 API 的次数。

示例 1: 输入:n = 5, bad = 4 输出:4 解释: 调用 isBadVersion(3) -> false 调用 isBadVersion(5) -> true 调用 isBadVersion(4) -> true 所以,4 是第一个错误的版本。

示例 2: 输入:n = 1, bad = 1 输出:1

提示

5.2 二分查找 - 图6

在这种问题中,只需将验证 5.2 二分查找 - 图7 的条件从判断数字大小,改为判断是否符合条件的 check() 函数此题中直接给出了 check 函数,但这个函数一般需要根据题意自行编写,可能需要额外的逻辑。

参考代码 2-1
// The API isBadVersion is defined for you.
// bool isBadVersion(int version);

class Solution {
public:
    int firstBadVersion(int n) {
        int l = 1, r = n;
        while (l < r) {
            int m = l + (r - l) / 2;
            if (isBadVersion(m)) {
                r = m;
            } else {
                l = m + 1;
            }
        }
        return l;
    }
};

二分答案模板

// 满足条件最小值
int lower() {
    int l = 0, r = n - 1;
    while (l < r) {
        int m = l + r >> 1;    
        if (check(m)) {
            l = m + 1;
        } else {
            r = m;
        }
    }
    return a[l] == k ? l : -1;
}
// 满足条件最大值
int upper() {
    int l = 0, r = n - 1;
    while (l < r) {
        int m = l + r + 1 >> 1;
        if (check(m)) {
            r = m - 1;
        } else {
            l = m;
        }
    }
    return a[l] == k ? l : -1;
}

时间复杂度:5.2 二分查找 - 图8#card=math&code=O%28%5Clog%20n%29&id=lRWnX)

深入理解:写对二分查找不是套模板并往里面填空,需要仔细分析题意

练习:蓝桥 分巧克力【第八届】【省赛】

儿童节那天有K位小朋友到小明家做客。小明拿出了珍藏的巧克力招待小朋友们。
  小明一共有N块巧克力,其中第i块是Hi x Wi的方格组成的长方形。

为了公平起见,小明需要从这 N 块巧克力中切出K块巧克力分给小朋友们。切出的巧克力需要满足:

  1. 形状是正方形,边长是整数
        2. 大小相同

例如一块6x5的巧克力可以切出6块2x2的巧克力或者2块3x3的巧克力。

当然小朋友们都希望得到的巧克力尽可能大,你能帮小Hi计算出最大的边长是多少么?

输入格式

第一行包含两个整数N和K。(1 <= N, K <= 100000)
  以下N行每行包含两个整数Hi和Wi。(1 <= Hi, Wi <= 100000)
  输入保证每位小朋友至少能获得一块1x1的巧克力。

输出格式

输出切出的正方形巧克力最大可能的边长。

样例输入

2 10
 6 5
 5 6

样例输出

2

思路:对于一个特定的边长 5.2 二分查找 - 图9 ,我们可以通过遍历 5.2 二分查找 - 图10 个巧克力块,求出可以切出正方形的数量,这个数量大于等于 5.2 二分查找 - 图11 时方案可行。我们可以二分这个边长 5.2 二分查找 - 图12 的范围,找到 5.2 二分查找 - 图13 令方案可行的最大值。

参考代码 2-2
#include <iostream>
using namespace std;
const int N = 100004;
struct Choco {
    int h;
    int w;
    void read() {        // 如果你直接 cin >> c[i].h... 就不用写这个
        cin >> h >> w;
    }
    // 返回这一块能切出几个 m*m 的正方形
    int cut(int m) {
        return (h / m) * (w / m);
    }
} c[N];    // 可以在这里直接声明一个数组
int n, k;
// 判断边长为 m 的情况,能否足够切出 k 块
bool check(int m) {
    int res = 0;
    for (int i=0; i<n; ++i) {
        res += c[i].cut(m);
    }
    // cout << m << ',' << res << endl;
    return res >= k;
}
int main() {
    cin >> n >> k;
    for (int i=0; i<n; ++i) {
        c[i].read();    // 这只是一种写法,你也可以写 cin >> c[i].h >> c[i].w;
    }
    int l = 1, r = N;
    while (l < r) {
        int mid = l + (r - l + 1) / 2;
        if (check(mid)) {
            l = mid;
        } else {
            r = mid - 1;
        }
    }
    cout << l << endl;
    return 0;
}

时间复杂度:5.2 二分查找 - 图14#card=math&code=O%28n%5Clog%20m%29&id=iw9WZ),5.2 二分查找 - 图15 为巧克力块数量,5.2 二分查找 - 图16 为边长取值范围。

例题 3:AcWing 790. 数的三次方根

给定一个浮点数 5.2 二分查找 - 图17,求它的三次方根。

输入格式

共一行,包含一个浮点数 5.2 二分查找 - 图18

输出格式

共一行,包含一个浮点数,表示问题的解。

注意,结果保留 5.2 二分查找 - 图19 位小数。

数据范围

5.2 二分查找 - 图20

输入样例:

1000.00

输出样例:

10.000000

这道题二分计算的不再是整数,而是实数。我们通常的做法是,把搜索区间压缩到精度要求以内。

参考代码 3-1
#include <bits/stdc++.h>
using namespace std;
const double eps = 1e-8;
double x;
int main() {
    cin >> x;
    double l = -10000, r = 10000;
    while (r - l > eps) {
        double m = (l + r) / 2;
        if (m * m * m - x > 0) {
            r = m;
        } else {
            l = m;
        }
    }
    cout << fixed << setprecision(6) << l << endl; 
    return 0;
}

代码 cout << fixed << setprecision(6) << l << endl; 的写法是 C++ 格式化输出小数的方法,fixed表示末尾补零,setprecision 中的参数表示小数位数。

代码中使用到 eps (epsilon),表示大于零的一个很小的数。它在浮点数比较时也经常使用——由于浮点数存在误差,不能直接用 == 比较。通常做法如下:

const double eps = 1e-7;
bool equal(double a, double b) {
    return fabs(a - b) < eps;    // fabs() 取浮点数绝对值
}

在题目没有明确指出时,一般认为两数相差小于 5.2 二分查找 - 图21 的情况就足够精确。

同理,由于浮点数的误差,我们通常写平方、立方时不写 pow(x, 2)pow(x, 3),而写成连续相乘的形式。

练习:[NOIP2001]一元三次方程求解

题目描述

有形如:ax3+bx2+cx+d=0 这样的一个一元三次方程。给出该方程中各项的系数(a,b,c,d 均为实数),并约定该方程存在三个不同实根(根的范围在-100至100之间),且根与根之差的绝对值 ≥ 1。要求由小到大依次在同一行输出这三个实根(根与根之间留有空格),并精确到小数点后2位。
提示:记方程f(x) = 0,若存在2个数x1和x2,且x1 < x2,f(x1)*f(x2) < 0,则在(x1,x2)之间一定有一个根。

输入描述:

一行,4个实数A,B,C,D。

输出描述:

一行,3个实根,并精确到小数点后2位。

示例1

输入

1 -5 -4 20

输出

-2.00 2.00 5.00

思路:由于「根与根之差的绝对值 ≥ 1」,把根的范围(在-100至100之间)分割成长度为 1 的区间,并枚举这些区间,在每一个区间 5.2 二分查找 - 图22#card=math&code=%28l%2C%20r%29&id=F8NyP) 内如果有 5.2 二分查找 - 图23%5Ccdot%20f(r)%20%3C%200#card=math&code=f%28l%29%5Ccdot%20f%28r%29%20%3C%200&id=yDSpg) ,说明存在根,则二分搜索找到答案。最后,题目要从小到大顺序输出,所有整数的区间分界点需要在枚举的同时判断。

参考代码 3-2
#include <bits/stdc++.h>
using namespace std;
double a, b, c, d;
double f(double x) {
    return a*x*x*x + b*x*x + c*x + d;
}
int main() {
    cin >> a >> b >> c >> d;
    cout << fixed << setprecision(2);
    for (int i=-100; i<100; ++i) {
        // 判断区间分界点
        if (f(i) == 0) {
            cout << 1. * i << ' ';    // 乘以 1.0 转为 double 类型,满足输出精度
        }
        // 判断区间是否有根
        if (f(i) * f(i + 1) < 0) {
            double l = i, r = i + 1;
            while (r - l > 0.001) {
                double m = (l + r) / 2;
                if (f(l) * f(m) < 0) {
                    r = m;
                } else {
                    l = m;
                }
            }
            cout << l << ' ';
        }
    }
    // 单独判断最后一个区间的末尾
    if (f(100) == 0) {
        cout << 100.;    // 同样,100.0 是 double 类型
    }
    return 0;
}