알고리즘 · 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는 맨 앞인 4가 아니라 5).리스트는 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) 하나를 익혀 두는 게 안전해요.
(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]에서 30을 bisect_left로 찾으면 오류 없이 3(없다는 뜻)이 나와요.시작 값도 한 칸이 중요해요.
예제에서
hi를 n 대신 n - 1로 시작하면, [10, 20, 30]에서 모든 값보다 큰 99를 찾을 때 3이 아니라 2가 나와요.