출시·고도화 중
탐색 안내서 · 3/6
이 장에서는 탐색의 기본 부품인 lower_bound를 Python으로 깔끔하게 구현하고 한 줄씩 설명합니다. 이어서 같은 함수를 C++, Java, TypeScript로 옮기고, 언어마다 이미 들어 있는 표준 함수와 가운데 인덱스를 계산할 때의 오버플로 문제를 짚습니다.
def lower_bound(a, x, lo=0, hi=None):
if hi is None:
hi = len(a)
while lo < hi:
mid = (lo + hi) // 2
if a[mid] < x:
lo = mid + 1
else:
hi = mid
return lo
def upper_bound(a, x, lo=0, hi=None):
if hi is None:
hi = len(a)
while lo < hi:
mid = (lo + hi) // 2
if a[mid] <= x:
lo = mid + 1
else:
hi = mid
return lo
def contains(a, x):
i = lower_bound(a, x)
return i < len(a) and a[i] == x한 줄씩 보면 다음과 같습니다.
lo=0, hi=None: 탐색할 반열린 구간 [lo, hi)입니다. 기본값은 배열 전체이며, 일부 구간만 볼 수도 있게 bisect 모듈과 같은 매개 변수를 둡니다.while lo < hi: 구간에 후보가 하나라도 남아 있는 동안 반복합니다. lo == hi가 되면 그 자리가 답입니다.mid = (lo + hi) // 2: 가운데 인덱스입니다. Python 정수는 크기 제한이 없어서 더해도 넘치지 않습니다.if a[mid] < x: lo = mid + 1: mid의 값이 x보다 작으면 mid와 그 왼쪽은 답이 될 수 없으므로 버립니다.else: hi = mid: mid의 값이 x 이상이면 mid 자체가 답일 수 있으므로 mid는 남기고 오른쪽만 버립니다.return lo: 끝났을 때 lo는 a[i] >= x인 첫 인덱스이며, 그런 원소가 없으면 len(a)입니다.upper_bound는 비교 하나(<를 <=로)만 다릅니다. contains는 lower_bound 위치가 배열 안에 있고 그 값이 정확히 x인지 확인합니다. 인덱스 범위를 먼저 검사해야 IndexError가 나지 않습니다.
직접 구현했다면 표준 구현과 무작위로 비교해 보는 것이 가장 확실한 검증입니다.
import random
from bisect import bisect_left, bisect_right
for _ in range(10_000):
a = sorted(random.randint(0, 20) for _ in range(random.randint(0, 15)))
x = random.randint(-1, 21)
assert lower_bound(a, x) == bisect_left(a, x)
assert upper_bound(a, x) == bisect_right(a, x)
assert contains(a, x) == (x in a)
print("ok")빈 배열, 모든 원소보다 작거나 큰 값, 전부 같은 값처럼 경계 사례가 무작위 입력에 자연스럽게 섞여 들어갑니다. Python 3.10부터는 bisect_left(records, 30, key=lambda r: r.age)처럼 key도 줄 수 있습니다.
#include <cstddef>
#include <vector>
std::size_t lower_bound_index(const std::vector<int>& a, int x) {
std::size_t lo = 0, hi = a.size();
while (lo < hi) {
std::size_t mid = lo + (hi - lo) / 2;
if (a[mid] < x) lo = mid + 1;
else hi = mid;
}
return lo;
}C++ 표준 라이브러리에는 std::lower_bound(a.begin(), a.end(), x)가 있어 반복자를 돌려줍니다. 인덱스가 필요하면 a.begin()을 빼면 됩니다. std::upper_bound, 두 경계를 함께 주는 std::equal_range, 조건의 경계를 찾는 std::partition_point도 있습니다.
public final class Search {
public static int lowerBound(int[] a, int x) {
int lo = 0, hi = a.length;
while (lo < hi) {
int mid = (lo + hi) >>> 1;
if (a[mid] < x) lo = mid + 1;
else hi = mid;
}
return lo;
}
}Java의 Arrays.binarySearch는 값을 찾으면 그 인덱스를, 못 찾으면 -(끼워 넣을 자리) - 1을 돌려줍니다. 중복이 있으면 어느 위치를 돌려줄지 보장하지 않으므로, 첫 위치가 필요하면 위와 같이 lower_bound를 직접 쓰는 편이 안전합니다.
export function lowerBound(a: readonly number[], x: number): number {
let lo = 0;
let hi = a.length;
while (lo < hi) {
const mid = Math.floor((lo + hi) / 2);
if (a[mid] < x) lo = mid + 1;
else hi = mid;
}
return lo;
}
console.log(lowerBound([1, 2, 2, 2, 3, 5], 2)); // 1JavaScript에는 배열용 표준 이진 탐색 함수가 없어서 이렇게 직접 작성하는 일이 많습니다. 숫자가 64비트 부동소수점이라 lo + hi가 넘칠 걱정은 없지만, (lo + hi) >> 1은 32비트 부호 있는 정수로 바뀌므로 아주 큰 값에서는 틀릴 수 있습니다. 문자열 배열이라면 비교 함수를 매개 변수로 받도록 일반화합니다.
C, C++, Java처럼 정수 크기가 고정된 언어에서 (lo + hi) / 2는 lo와 hi가 모두 클 때 더하는 순간 넘칠 수 있습니다. Java의 Arrays.binarySearch에도 실제로 이 버그가 오래 남아 있다가 2006년에 고쳐졌습니다. 안전한 방법은 두 가지입니다.
lo + (hi - lo) / 2: 차이를 반으로 나눠 더하므로 hi를 넘지 않습니다.(lo + hi) >>> 1: 부호 없는 오른쪽 시프트라 합이 음수로 넘쳐도 올바른 값이 나옵니다(lo와 hi가 0 이상일 때).lo = mid + 1과 hi = mid 두 가지 갱신만 쓰는 lower_bound를 기본 부품으로 삼습니다.bisect, std::lower_bound, Arrays.binarySearch 같은 표준 구현이 있으니 반환값 규칙을 확인하고 씁니다.lo + (hi - lo) / 2로 오버플로를 막습니다.
댓글 0개
로그인 · 로그인하면 댓글을 남길 수 있습니다.
첫 댓글을 남겨 보세요.