{ }codelper

学习 › C

算法 · 28

二分查找

把有序的范围每次丢掉一半,找到某个值,或者第一个满足条件的位置。

二分查找反复查看有序列表的中间,丢掉不可能有答案的那一半。
下面的例子里分数从低到高排好,对每个询问的分数 x,找出第一个不小于 x 的位置。
这个位置的下标也就是低于 x 的分数个数,所以输出这个数,再输出有没有人正好得 x 分:有就是 yes,没有就是 no。

#include <stdio.h> int lower_bound(const int *a, int n, int x) {    int lo = 0, hi = n; // 답은 lo부터 hi까지 가운데 어딘가    while (lo < hi) {        int mid = lo + (hi - lo) / 2; // (lo + hi) / 2는 큰 수에서 넘쳐요        if (a[mid] < x) lo = mid + 1; // mid와 그 왼쪽은 모두 x보다 작아요        else hi = mid; // mid와 그 오른쪽은 모두 x 이상이에요    }    return lo; // x 이상인 첫 자리(없으면 n)} int main(void) {    int n, q;    static int scores[10000];    scanf("%d %d", &n, &q);    for (int i = 0; i < n; i++) scanf("%d", &scores[i]);    for (int i = 0; i < q; i++) {        int x;        scanf("%d", &x);        int pos = lower_bound(scores, n, x); // x보다 낮은 점수의 개수이기도 해요        int found = pos < n && scores[pos] == x;        printf("%d %s\n", pos, found ? "yes" : "no");    }    return 0;}
stdin
7 5
40 55 55 70 82 82 95
55 60 82 100 10
stdout
1 yes
3 no
4 yes
7 no
0 no

要点

  • 列表有序时,只看中间一个值就能丢掉一半。
    中间的值小于 x,它左边的也都小于 x;中间的值不小于 x,它右边的也都不小于 x。
  • 例子里的 lower_bound 返回第一个不小于 x 的位置。
    那个位置的值等于 x,就说明 x 存在;相同的值有多个时,它是其中最前面的一个。
    没有这样的值时,返回列表长度 n。
  • 整个循环中始终成立一个约定:lo 左边的都小于 x,从 hi 起右边的都不小于 x。
    开始时(lo = 0、hi = n)两边都是空的,所以成立;lo = mid + 1 和 hi = mid 都不会破坏它。
    因此 lo 和 hi 相遇的位置就是答案。
  • 只要 lo < hi,mid 就一定小于 hi。
    所以无论是 lo = mid + 1 还是 hi = mid,范围至少缩小一格,循环一定会结束。
  • 每次减半,所以 100 万个元素最多也只看 20 次。
    在我们的评测机上用 1 万个分数、1 万个询问实测:从头一个一个数的 Python 解法用了 3 秒多,二分查找约 0.02 秒。
  • 它不只用来找值。
    只要一个条件在前面都为假、从某个位置起都为真,就能用同样的方法找到条件第一次为真的位置。
    例子里的条件是“值不小于 x”。

各语言的写法

Pythonmid = (lo + hi) // 2。
内置的是 import bisect — bisect.bisect_left(a, x) 是第一个不小于 x 的位置(和例子里的函数一样),bisect.bisect_right(a, x) 是第一个大于 x 的位置
JavaScriptconst mid = Math.floor((lo + hi) / 2); — / 会得到小数,一定要向下取整。
没有内置的二分查找,要自己写
TypeScript和 JavaScript 一样
Javaint mid = lo + (hi - lo) / 2;。
内置的 Arrays.binarySearch(a, x) 找到时返回位置,找不到时返回 -(插入位置) - 1(例子的分数里查 60 得到 -4)。
相同的值有多个时,返回其中哪一个是没有规定的(这里查 82 得到的是 5,而不是最前面的 4)。
列表用 Collections.binarySearch
C#int mid = lo + (hi - lo) / 2;。
内置的 Array.BinarySearch(a, x) 找不到时返回负数,加上 ~ 就是插入位置(查 60 得到 -4,~(-4) 是 3)。
相同的值有多个时不一定是最前面的那个(查 82 得到 5)。
列表用 list.BinarySearch(x)
C++#include <algorithm> 里的 std::lower_bound(v.begin(), v.end(), x) - v.begin() 和例子里的函数一样。
第一个大于 x 的位置用 std::upper_bound,只想知道有没有就用 std::binary_search
Cint mid = lo + (hi - lo) / 2;。
<stdlib.h> 里的 bsearch(&x, a, n, sizeof a[0], cmp) 只返回相等的值的地址或 NULL,找不出“第一个不小于 x 的位置”。
要像例子那样自己写
Craykonmid = 数学.商(lo + hi, 2); — / 会得到小数,a[1.5] 会提示“[ ] 里请写整数下标”并停止。
没有内置的二分查找,要自己写

常见错误

用 lo = mid 缩小范围,结果停不下来

a = [10, 20, 30]
lo, hi = 0, 2  # 找最后一个不大于 20 的位置
while lo < hi:
    mid = (lo + hi) // 2
    if a[mid] <= 20:
        lo = mid
    else:
        hi = mid - 1
print(lo)

→ 不会结束。在 lo = 1、hi = 2 时 mid 又是 1,范围不再缩小

用 lo = mid 的写法时,中间要向上取整(mid = (lo + hi + 1) // 2)。
这样会输出 1 并结束。
不要把几种写法混在一起,熟记例子里的那一种(lo = mid + 1、hi = mid)最稳妥。

(lo + hi) / 2 溢出了Java · C# · C · C++

int lo = 1500000000, hi = 2000000000;
int mid = (lo + hi) / 2;
System.out.println(mid);

→ -397483648 — lo + hi 超过了 int 的上限(约 21 亿),变成了负数。C# 也得到同样的值。在 C、C++ 里 int 溢出后会发生什么是没有规定的(未定义行为)。在我们的评测机上得到了同样的值,但不能指望总是这样

写成 int mid = lo + (hi - lo) / 2; 就得到 1750000000。
在数组下标上很少见,但在 1 到 20 亿之间每次减半地找答案时,马上就会遇到。

只用 / 2 求中间JavaScript · TypeScript

const a = [10, 20, 30];
let lo = 0, hi = a.length;
while (lo < hi) {
  const mid = (lo + hi) / 2;
  if (a[mid] < 25) lo = mid + 1;
  else hi = mid;
}
console.log(lo);

→ 1 — 答案是 2。第一个 mid 是 1.5,而 a[1.5] 不报错、只是 undefined,于是转了 1,077 圈之后给出错误的位置

用 Math.floor((lo + hi) / 2) 向下取整。
这样结果是 2。

各语言的差异
二分查找只在有序的列表上才正确。
在没排序的 [30, 10, 20] 里用 bisect_left 找 30,不会报错,却得到 3(表示没有)。
起始值差一格也很要紧:例子里如果 hi 从 n - 1 而不是 n 开始,在 [10, 20, 30] 里找比所有值都大的 99 时,得到的是 2 而不是 3。

练习题