算法 · 28
二分查找
把有序的范围每次丢掉一半,找到某个值,或者第一个满足条件的位置。
二分查找反复查看有序列表的中间,丢掉不可能有答案的那一半。
下面的例子里分数从低到高排好,对每个询问的分数 x,找出第一个不小于 x 的位置。
这个位置的下标也就是低于 x 的分数个数,所以输出这个数,再输出有没有人正好得 x 分:有就是 yes,没有就是 no。
def lower_bound(a, x): lo, hi = 0, len(a) # 답은 lo부터 hi까지 가운데 어딘가 while lo < hi: mid = (lo + hi) // 2 if a[mid] < x: lo = mid + 1 # mid와 그 왼쪽은 모두 x보다 작아요 else: hi = mid # mid와 그 오른쪽은 모두 x 이상이에요 return lo # x 이상인 첫 자리(없으면 len(a)) n, q = map(int, input().split())scores = list(map(int, input().split()))for x in map(int, input().split()): pos = lower_bound(scores, x) # x보다 낮은 점수의 개수이기도 해요 found = pos < n and scores[pos] == x print(pos, "yes" if found else "no")function lowerBound(a, x) { let lo = 0, hi = a.length; // 답은 lo부터 hi까지 가운데 어딘가 while (lo < hi) { const mid = Math.floor((lo + hi) / 2); if (a[mid] < x) lo = mid + 1; // mid와 그 왼쪽은 모두 x보다 작아요 else hi = mid; // mid와 그 오른쪽은 모두 x 이상이에요 } return lo; // x 이상인 첫 자리(없으면 a.length)} const lines = require("fs").readFileSync(0, "utf8").split("\n");const [n] = lines[0].split(" ").map(Number);const scores = lines[1].trim().split(" ").map(Number);for (const x of lines[2].trim().split(" ").map(Number)) { const pos = lowerBound(scores, x); // x보다 낮은 점수의 개수이기도 해요 const found = pos < n && scores[pos] === x; console.log(pos, found ? "yes" : "no");}import { readFileSync } from "node:fs"; function lowerBound(a: number[], x: number): number { let lo = 0, hi = a.length; // 답은 lo부터 hi까지 가운데 어딘가 while (lo < hi) { const mid: number = Math.floor((lo + hi) / 2); if (a[mid] < x) lo = mid + 1; // mid와 그 왼쪽은 모두 x보다 작아요 else hi = mid; // mid와 그 오른쪽은 모두 x 이상이에요 } return lo; // x 이상인 첫 자리(없으면 a.length)} const lines: string[] = readFileSync(0, "utf8").split("\n");const [n] = lines[0].split(" ").map(Number);const scores: number[] = lines[1].trim().split(" ").map(Number);for (const x of lines[2].trim().split(" ").map(Number)) { const pos: number = lowerBound(scores, x); // x보다 낮은 점수의 개수이기도 해요 const found: boolean = pos < n && scores[pos] === x; console.log(pos, found ? "yes" : "no");}import java.util.Scanner; public class Main { static int lowerBound(int[] a, int x) { int lo = 0, hi = a.length; // 답은 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 이상인 첫 자리(없으면 a.length) } public static void main(String[] args) { Scanner sc = new Scanner(System.in); int n = sc.nextInt(), q = sc.nextInt(); int[] scores = new int[n]; for (int i = 0; i < n; i++) scores[i] = sc.nextInt(); for (int i = 0; i < q; i++) { int x = sc.nextInt(); int pos = lowerBound(scores, x); // x보다 낮은 점수의 개수이기도 해요 boolean found = pos < n && scores[pos] == x; System.out.println(pos + " " + (found ? "yes" : "no")); } }}using System; class Program{ static int LowerBound(int[] a, int x) { int lo = 0, hi = a.Length; // 답은 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 이상인 첫 자리(없으면 a.Length) } static void Main() { int n = int.Parse(Console.ReadLine().Split(' ')[0]); int[] scores = Array.ConvertAll(Console.ReadLine().Trim().Split(' '), int.Parse); foreach (string s in Console.ReadLine().Trim().Split(' ')) { int x = int.Parse(s); int pos = LowerBound(scores, x); // x보다 낮은 점수의 개수이기도 해요 bool found = pos < n && scores[pos] == x; Console.WriteLine(pos + " " + (found ? "yes" : "no")); } }}#include <iostream>#include <vector> int lowerBound(const std::vector<int> &a, int x) { int lo = 0, hi = a.size(); // 답은 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 이상인 첫 자리(없으면 a.size())} int main() { int n, q; std::cin >> n >> q; std::vector<int> scores(n); for (int &s : scores) std::cin >> s; for (int i = 0; i < q; i++) { int x; std::cin >> x; int pos = lowerBound(scores, x); // x보다 낮은 점수의 개수이기도 해요 bool found = pos < n && scores[pos] == x; std::cout << pos << ' ' << (found ? "yes" : "no") << '\n'; }}#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;}lowerBound(a, x){ lo = 0; // 답은 lo부터 hi까지 가운데 어딘가 hi = a.数量; 当 (lo < hi) { mid = 数学.商(lo + hi, 2); 如果 (a[mid] < x) { lo = mid + 1; // mid와 그 왼쪽은 모두 x보다 작아요 } 否则 { hi = mid; // mid와 그 오른쪽은 모두 x 이상이에요 } } 返回 lo; // x 이상인 첫 자리(없으면 a.개수)} first = 控制台.读数字();n = first[0];scores = 控制台.读数字();遍历 (x 在 控制台.读数字()){ pos = lowerBound(scores, x); // x보다 낮은 점수의 개수이기도 해요 found = pos < n && scores[pos] == x; 控制台.输出(pos + " " + (found ? "yes" : "no"));}7 5 40 55 55 70 82 82 95 55 60 82 100 10
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”。
各语言的写法
| Python | mid = (lo + hi) // 2。内置的是 import bisect — bisect.bisect_left(a, x) 是第一个不小于 x 的位置(和例子里的函数一样),bisect.bisect_right(a, x) 是第一个大于 x 的位置 |
|---|---|
| JavaScript | const mid = Math.floor((lo + hi) / 2); — / 会得到小数,一定要向下取整。没有内置的二分查找,要自己写 |
| TypeScript | 和 JavaScript 一样 |
| Java | int 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 |
| C | int mid = lo + (hi - lo) / 2;。<stdlib.h> 里的 bsearch(&x, a, n, sizeof a[0], cmp) 只返回相等的值的地址或 NULL,找不出“第一个不小于 x 的位置”。要像例子那样自己写 |
| Craykon | mid = 数学.商(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)最稳妥。
只用 / 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。
(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 亿之间每次减半地找答案时,马上就会遇到。
各语言的差异
二分查找只在有序的列表上才正确。
在没排序的
起始值差一格也很要紧:例子里如果
二分查找只在有序的列表上才正确。
在没排序的
[30, 10, 20] 里用 bisect_left 找 30,不会报错,却得到 3(表示没有)。起始值差一格也很要紧:例子里如果
hi 从 n - 1 而不是 n 开始,在 [10, 20, 30] 里找比所有值都大的 99 时,得到的是 2 而不是 3。