이분 탐색 (Binary Search)

한눈에 보기

정렬된 배열에서 탐색 범위를 절반씩 줄여 O(log n)에 값을 찾는 이분 탐색의 원리와 동작 과정을 그림으로 정리했다. lower bound와 upper bound, 코딩테스트 단골인 파라메트릭 서치와 회전 정렬 배열, 구현 코드와 자주 하는 실수까지 다룬다.

최종 업데이트 2026.09.17

이분 탐색(Binary Search)은 정렬된 배열에서 탐색 범위를 절반씩 잘라내며 답을 찾는 알고리즘이다. 비교 한 번에 후보의 절반이 사라지므로 원소가 n개일 때 O(log n)에 끝난다. 원소가 100만 개여도 20번, 10억 개여도 30번이면 충분하다.

코딩테스트에서 이분 탐색이 중요한 진짜 이유는 따로 있다. "정렬된 배열에서 값 찾기"로 나오는 문제는 오히려 드물고, 정렬된 배열이 문제에 아예 등장하지 않는데도 이분 탐색으로 푸는 유형이 훨씬 자주 나오기 때문이다. 이 글은 기본 형태에서 시작해 그 응용 유형까지 이어서 다룬다.

개념과 원리

정렬된 배열의 가운데 원소 하나를 목표 값과 비교하면 세 갈래가 나온다. 같으면 끝이고, 가운데 값이 목표보다 크면 목표는 왼쪽에만 있을 수 있으며, 작으면 오른쪽에만 있을 수 있다. 어느 쪽이든 단 한 번의 비교로 후보의 절반을 근거 있게 버린다는 것이 전부다.

이분 탐색: 정렬된 11칸 배열에서 가운데 값 23을 목표 91과 비교해 왼쪽 절반 6칸을 한 번에 버리는 그림
목표 값 91을 찾는 상황. 가운데 값 23이 91보다 작다는 사실 하나로 인덱스 0부터 5까지 여섯 칸을 검사도 하지 않고 버린다.

여기서 꼭 짚고 갈 것이 하나 있다. 이분 탐색에 정말로 필요한 조건은 "정렬"이 아니라 한 번만 뒤집히는 성질이다. 배열이 정렬되어 있으면 "arr[i]가 목표보다 작은가?"라는 질문의 답이 [참, 참, 참, 거짓, 거짓]처럼 딱 한 번만 바뀐다. 이분 탐색이 쓰는 건 정렬 자체가 아니라 바로 이 성질이다.

그래서 정렬된 배열이 없어도, 어떤 질문의 답이 한 번만 뒤집히기만 하면 이분 탐색을 쓸 수 있다. 뒤에 나올 파라메트릭 서치가 정확히 이 이야기이고, 코딩테스트에서 이분 탐색이 나오는 방식의 대부분이 이쪽이다.

동작 과정

  1. 탐색 범위의 양 끝 low, high를 배열의 처음과 끝 인덱스로 둔다.
  2. 범위가 남아 있는 동안 가운데 mid = low + (high - low) // 2를 계산한다.
  3. arr[mid]와 목표를 비교해 같으면 mid를 반환한다.
  4. arr[mid]가 목표보다 작으면 low = mid + 1, 크면 high = mid - 1로 옮긴다.
  5. 범위가 비면 배열에 목표가 없다는 뜻이므로 실패를 반환한다.
이분 탐색: 11칸 배열에서 91을 찾는 이분 탐색이 3회차 만에 끝나는 과정을 회차별로 보여주는 그림
11칸 배열에서 91을 찾는 전 과정. 순차 탐색이었다면 10번 비교했을 일을 3번으로 끝낸다. 회차마다 흐리게 처리된 칸이 이미 후보에서 제외된 구간이다.

경계 찾기 — lower bound와 upper bound

실전에서 더 자주 쓰는 쪽은 값을 찾는 이분 탐색이 아니라 경계를 찾는 이분 탐색이다. 중복이 있는 배열에서 "이 값이 처음 등장하는 위치"나 "이 값보다 큰 첫 위치"를 알아야 하는 상황이 훨씬 많기 때문이다.

  • lower bound: target 이상인 첫 인덱스. target이 없으면 삽입해야 할 자리를 가리킨다.
  • upper bound: target 초과인 첫 인덱스.
이분 탐색: 4가 네 번 중복된 배열에서 lower bound는 인덱스 1, upper bound는 인덱스 5를 가리키는 그림
두 경계를 함께 구하면 중복 개수를 O(log n)에 셀 수 있다. 정렬된 배열에서 "특정 값이 몇 개인가"를 묻는 문제의 표준 답이다.

경계 탐색은 값을 찾자마자 멈추지 않고 범위가 한 점으로 좁혀질 때까지 끝까지 진행한다는 점이 일반 이분 탐색과 다르다. 그래서 반복문 조건도 low < high이고, high의 초기값도 len(arr) - 1이 아니라 len(arr)이다. 모든 원소가 target보다 작을 때 "배열 끝"이라는 답을 표현해야 하기 때문이다.

시간 복잡도

구분복잡도설명
탐색 1회O(log n)n = 100만이면 약 20번, n = 10억이면 약 30번 비교
정렬이 선행되는 경우O(n log n)정렬 비용이 지배적
공간 (반복문)O(1)인덱스 변수 몇 개만 사용
공간 (재귀)O(log n)호출 스택 깊이만큼

탐색을 딱 한 번만 한다면 정렬 없이 순차 탐색하는 O(n)이 오히려 빠르다. 이분 탐색이 이득을 보는 지점은 같은 배열에 질의가 여러 번 들어올 때다. 질의가 q번이면 순차 탐색은 O(nq), 이분 탐색은 O(n log n + q log n)이다. n과 q가 각각 10만이면 전자는 100억 번, 후자는 약 340만 번이라 시간 초과와 통과를 가르는 차이가 된다.

구현 코드 예시

def binary_search(arr, target):
    """target의 인덱스를 반환한다. 없으면 -1."""
    low, high = 0, len(arr) - 1
    while low <= high:
        mid = low + (high - low) // 2   # (low + high) // 2 는 오버플로 위험
        if arr[mid] == target:
            return mid
        if arr[mid] < target:
            low = mid + 1
        else:
            high = mid - 1
    return -1


def lower_bound(arr, target):
    """target 이상이 처음 나오는 인덱스. 없으면 len(arr)."""
    low, high = 0, len(arr)          # high가 len(arr)인 것에 주의
    while low < high:
        mid = (low + high) // 2
        if arr[mid] < target:
            low = mid + 1
        else:
            high = mid               # mid는 답 후보이므로 버리지 않는다
    return low


def upper_bound(arr, target):
    """target 초과가 처음 나오는 인덱스."""
    low, high = 0, len(arr)
    while low < high:
        mid = (low + high) // 2
        if arr[mid] <= target:       # lower_bound와 부등호 하나만 다르다
            low = mid + 1
        else:
            high = mid
    return low


def count_of(arr, target):
    """정렬된 배열에서 target의 개수를 O(log n)에 센다."""
    return upper_bound(arr, target) - lower_bound(arr, target)

실제 코딩테스트에서는 직접 구현할 필요가 없다. 파이썬은 bisect.bisect_left가 lower bound, bisect.bisect_right가 upper bound다. 자바는 Arrays.binarySearch가 있지만 중복이 있을 때 어느 인덱스를 줄지 보장하지 않으므로, 경계가 필요하면 직접 짜는 편이 안전하다. 다만 면접에서는 손으로 쓸 수 있어야 하고, 두 함수의 차이가 <와 <= 하나뿐이라는 점을 설명할 수 있어야 한다.

파라메트릭 서치 — 배열이 없어도 쓴다

코딩테스트에서 이분 탐색이 가장 많이 나오는 형태다. 문제에 정렬된 배열이 없고, 대신 이렇게 생겼다.

"조건을 만족하는 최솟값(또는 최댓값)을 구하시오."

이런 문제는 답을 직접 계산하려 하지 말고, 답을 하나 찍은 뒤 그게 가능한지만 판정하면 된다. 판정이 쉬운 문제라면 이분 탐색으로 답의 범위를 절반씩 좁혀 나갈 수 있다. 이것을 파라메트릭 서치(Parametric Search)라고 부른다.

핵심은 판정 결과가 한 번만 뒤집혀야 한다는 것이다. 예를 들어 "하루 처리량이 x일 때 기한 안에 끝나는가?"라는 질문은, x가 작을 땐 계속 불가능하다가 어느 지점부터 계속 가능해진다. 중간에 왔다 갔다 하지 않는다.

이분 탐색: 처리량 x가 1부터 12까지 변할 때 판정 결과가 거짓에서 참으로 한 번만 바뀌며 그 경계인 7이 정답이 되는 그림
판정 함수의 결과를 늘어놓으면 결국 [F, F, …, F, T, …, T] 배열이 된다. 정렬된 배열이 문제에 없어도, 머릿속에 이 배열을 그리는 순간 이분 탐색 문제가 된다.

구현은 lower bound와 똑같다. 배열을 보는 대신 판정 함수를 호출할 뿐이다.

def min_capacity(tasks, days):
    """하루에 x만큼 처리할 때 days일 안에 tasks를 다 끝내는 최소 x."""

    def possible(x):
        need, cur = 1, 0            # need: 걸리는 날짜
        for t in tasks:
            if cur + t > x:         # 오늘 못 담으면 다음 날로
                need += 1
                cur = 0
            cur += t
        return need <= days

    lo, hi = max(tasks), sum(tasks)  # 답이 존재할 수 있는 최소·최대 범위
    while lo < hi:
        mid = (lo + hi) // 2
        if possible(mid):
            hi = mid                 # 가능하면 더 줄여본다
        else:
            lo = mid + 1             # 불가능하면 늘려야 한다
    return lo

문제를 만났을 때 확인할 것은 세 가지뿐이다. ① 답의 범위를 lo, hi로 잡을 수 있는가. ② 판정 함수를 O(n) 정도로 짤 수 있는가. ③ 판정 결과가 한 번만 뒤집히는가. 세 개가 되면 전체 복잡도는 O(n log(범위))로 끝난다.

답이 정수가 아니라 실수인 문제도 가끔 나온다. 이때는 lo < hi로 끝낼 수 없으니 for _ in range(100)처럼 고정 횟수만큼 반복하면 된다. 100번이면 어떤 정밀도 요구도 충족하고, 부동소수점 오차 때문에 반복문이 끝나지 않는 사고도 막는다.

회전된 정렬 배열

[4, 5, 6, 7, 0, 1, 2]처럼 정렬된 배열을 어느 지점에서 끊어 앞뒤를 바꾼 배열이다. 전체로 보면 정렬이 깨졌으니 이분 탐색을 못 쓸 것 같지만, 반으로 자르면 둘 중 한쪽은 반드시 정상 정렬 상태라는 성질이 남아 있다.

이분 탐색: 회전된 정렬 배열을 반으로 자르면 왼쪽 절반은 정렬되어 있고 오른쪽 절반만 끊겨 있음을 보여주는 그림
왼쪽 절반이 정렬되어 있으므로 "target이 4와 7 사이인가"만 확인하면 어느 쪽을 버릴지 즉시 정해진다. 정렬된 쪽을 기준으로 판단하는 것이 요령이다.
def search_rotated(arr, target):
    low, high = 0, len(arr) - 1
    while low <= high:
        mid = (low + high) // 2
        if arr[mid] == target:
            return mid

        if arr[low] <= arr[mid]:                    # 왼쪽 절반이 정렬됨
            if arr[low] <= target < arr[mid]:       # target이 그 안에 있으면
                high = mid - 1                      # 왼쪽으로
            else:
                low = mid + 1
        else:                                       # 오른쪽 절반이 정렬됨
            if arr[mid] < target <= arr[high]:
                low = mid + 1
            else:
                high = mid - 1
    return -1

자주 하는 실수

  • 정렬을 확인하지 않는다. 정렬이 깨지면 결과는 그냥 틀린 값이다. 예외도 오류도 나지 않고 조용히 틀리기 때문에 발견이 늦다.
  • mid = (low + high) // 2로 오버플로를 낸다. 파이썬은 정수 크기 제한이 없어 안전하지만, 자바나 C++에서 배열이 크면 두 인덱스의 합이 int 범위를 넘어 음수가 된다. 자바 표준 라이브러리에서도 실제로 발견돼 수정된 유명한 버그다.
  • low <= high와 low < high를 섞어 쓴다. 값 탐색은 <=, 경계 탐색과 파라메트릭 서치는 <다. 잘못 고르면 마지막 원소를 빠뜨리거나 무한 루프에 빠진다.
  • 범위가 줄지 않는 갱신을 쓴다. 경계 탐색에서 low = mid로 두면 low와 high가 1 차이일 때 mid == low가 되어 영원히 제자리를 돈다. 한쪽은 반드시 mid + 1이어야 한다.
  • 파라메트릭 서치에서 답의 범위를 너무 좁게 잡는다. hi를 실제 답보다 작게 잡으면 조용히 틀린 답이 나온다. 확실히 가능한 값으로 넉넉히 잡아도 log라 비용이 거의 안 든다.
  • 반복문 안에서 매번 정렬한다. 질의마다 sort()를 부르면 O(log n)을 얻으려다 O(n log n)을 q번 치른다. 정렬은 루프 밖에서 한 번만 한다.

면접에서 이렇게 물어본다

  • 이분 탐색이 O(log n)인 이유를 설명해 보라.
  • 중복이 있는 배열에서 target의 개수를 O(log n)에 세려면 어떻게 하겠는가.
  • lower_bound와 upper_bound의 구현 차이는 무엇인가.
  • mid 계산에서 오버플로가 나는 조건과 해결 방법을 말해 보라.
  • 정렬된 배열이 주어지지 않는 문제에 이분 탐색을 적용하려면 무엇이 성립해야 하는가.
  • 파라메트릭 서치 문제를 풀 때 답의 범위와 판정 함수를 어떻게 정하는지 예를 들어 설명해 보라.
  • 회전된 정렬 배열에서 값을 찾으려면 어떻게 변형하겠는가.
알고리즘 문서 전체 보기

이 개념, 내 코드에서는 어떻게 물어볼까요

데브핏(DevFit)에 채용공고 URL을 붙여 넣고 레포지토리를 연결하면, 내가 쓴 커밋에서 예상 질문과 어필 포인트를 만듭니다. 하루 10회까지 무료입니다. 합불 판정이나 지원자 랭킹은 만들지 않고, 최종 판단은 사람이 합니다.

공고 넣고 시작하기