{ }codelper

배우기 › C++

알고리즘 · 28

이분 탐색

정렬된 범위를 반씩 버리면서 값이나 조건을 처음 만족하는 자리를 찾아요.

이분 탐색은 정렬된 목록의 가운데를 보고, 답이 있을 수 없는 절반을 버리는 일을 되풀이해요.
아래 예제는 낮은 것부터 늘어놓은 점수에서, 질문한 점수 x마다 x 이상인 첫 자리를 찾아요.
그 자리 번호는 x보다 낮은 점수의 개수이기도 해서, 그 수와 함께 x점이 있으면 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';    }}
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); — /가 소수를 내니 꼭 내림해요.
내장 이분 탐색이 없어서 직접 써요
TypeScriptJavaScript와 같아요
Javaint 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
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]에서 30을 bisect_left로 찾으면 오류 없이 3(없다는 뜻)이 나와요.
시작 값도 한 칸이 중요해요.
예제에서 hi를 n 대신 n - 1로 시작하면, [10, 20, 30]에서 모든 값보다 큰 99를 찾을 때 3이 아니라 2가 나와요.

연습 문제