개념

정렬된 배열에서 중간값과 비교하여 탐색 범위를 절반씩 줄여가는 알고리즘

“반으로 나누어 탐색” - Divide and Conquer


시간복잡도

케이스시간복잡도
최선O(1)
평균O(log n)
최악O(log n)
공간복잡도O(1) (반복), O(log n) (재귀)

특징

장점

  • 매우 빠름: O(log n) 시간복잡도
  • 효율적: 큰 데이터에서도 빠른 탐색
  • 간단한 구현: 반복문 또는 재귀로 구현

단점

  • 정렬 필요: 반드시 정렬된 배열이어야 함
  • 정적 데이터: 삽입/삭제가 빈번하면 비효율

구현 방법

반복문 구현 (추천)

def binary_search(arr, target):
    """이진 탐색 - 반복문"""
    left = 0
    right = len(arr) - 1
 
    while left <= right:
        mid = (left + right) // 2
 
        if arr[mid] == target:
            return mid  # 찾음
        elif arr[mid] < target:
            left = mid + 1  # 오른쪽 탐색
        else:
            right = mid - 1  # 왼쪽 탐색
 
    return -1  # 찾지 못함
 
# 테스트
arr = [1, 3, 5, 7, 9, 11, 13, 15]
print(binary_search(arr, 7))   # 3
print(binary_search(arr, 10))  # -1
 
# 시간복잡도: O(log n)
# 공간복잡도: O(1)

재귀 구현

def binary_search_recursive(arr, target, left, right):
    """이진 탐색 - 재귀"""
    # 기저 조건
    if left > right:
        return -1
 
    mid = (left + right) // 2
 
    if arr[mid] == target:
        return mid
    elif arr[mid] < target:
        return binary_search_recursive(arr, target, mid + 1, right)
    else:
        return binary_search_recursive(arr, target, left, mid - 1)
 
# 테스트
arr = [1, 3, 5, 7, 9, 11, 13, 15]
print(binary_search_recursive(arr, 7, 0, len(arr) - 1))  # 3
 
# 시간복잡도: O(log n)
# 공간복잡도: O(log n) - 재귀 스택

Python bisect 모듈

import bisect
 
arr = [1, 3, 5, 7, 9, 11, 13, 15]
 
# bisect_left: target 이상인 첫 위치
index = bisect.bisect_left(arr, 7)
print(index)  # 3
 
# bisect_right: target 초과인 첫 위치
index = bisect.bisect_right(arr, 7)
print(index)  # 4
 
# 존재 여부 확인
def binary_search_bisect(arr, target):
    i = bisect.bisect_left(arr, target)
    if i < len(arr) and arr[i] == target:
        return i
    return -1
 
print(binary_search_bisect(arr, 7))   # 3
print(binary_search_bisect(arr, 10))  # -1

동작 과정

배열: [1, 3, 5, 7, 9, 11, 13, 15]
찾는 값: 7

1회차:
left=0, right=7, mid=3
arr[3] = 7 = target  ✓ 발견!

결과: 인덱스 3 반환
배열: [1, 3, 5, 7, 9, 11, 13, 15]
찾는 값: 11

1회차:
left=0, right=7, mid=3
arr[3] = 7 < 11  → left = 4

2회차:
left=4, right=7, mid=5
arr[5] = 11 = target  ✓ 발견!

결과: 인덱스 5 반환

템플릿

def binary_search_template(arr, target):
    left = 0
    right = len(arr) - 1
 
    while left <= right:
        # 중간 인덱스 계산
        mid = (left + right) // 2
 
        # 비교
        if arr[mid] == target:
            return mid  # 찾음
        elif arr[mid] < target:
            left = mid + 1  # 오른쪽 절반 탐색
        else:
            right = mid - 1  # 왼쪽 절반 탐색
 
    return -1  # 찾지 못함

변형 문제 패턴

1. Lower Bound (이상)

def lower_bound(arr, target):
    """target 이상인 첫 번째 위치"""
    left = 0
    right = len(arr)
 
    while left < right:
        mid = (left + right) // 2
 
        if arr[mid] < target:
            left = mid + 1
        else:
            right = mid
 
    return left
 
# 테스트
arr = [1, 2, 2, 2, 3, 4, 5]
print(lower_bound(arr, 2))  # 1 (첫 번째 2)
print(lower_bound(arr, 6))  # 7 (6보다 큰 값 없음)
 
# bisect.bisect_left와 동일

2. Upper Bound (초과)

def upper_bound(arr, target):
    """target을 초과하는 첫 번째 위치"""
    left = 0
    right = len(arr)
 
    while left < right:
        mid = (left + right) // 2
 
        if arr[mid] <= target:
            left = mid + 1
        else:
            right = mid
 
    return left
 
# 테스트
arr = [1, 2, 2, 2, 3, 4, 5]
print(upper_bound(arr, 2))  # 4 (2보다 큰 첫 값 3)
print(upper_bound(arr, 5))  # 7
 
# bisect.bisect_right와 동일

3. 첫 번째/마지막 위치 찾기

def find_first(arr, target):
    """target이 처음 나타나는 위치"""
    left = 0
    right = len(arr) - 1
    result = -1
 
    while left <= right:
        mid = (left + right) // 2
 
        if arr[mid] == target:
            result = mid
            right = mid - 1  # 더 왼쪽 탐색
        elif arr[mid] < target:
            left = mid + 1
        else:
            right = mid - 1
 
    return result
 
def find_last(arr, target):
    """target이 마지막으로 나타나는 위치"""
    left = 0
    right = len(arr) - 1
    result = -1
 
    while left <= right:
        mid = (left + right) // 2
 
        if arr[mid] == target:
            result = mid
            left = mid + 1  # 더 오른쪽 탐색
        elif arr[mid] < target:
            left = mid + 1
        else:
            right = mid - 1
 
    return result
 
# 테스트
arr = [1, 2, 2, 2, 3, 4, 5]
print(find_first(arr, 2))  # 1
print(find_last(arr, 2))   # 3

핵심 요약

개념설명
원리중간값 비교로 범위를 절반씩 줄임
시간복잡도O(log n)
공간복잡도O(1) (반복), O(log n) (재귀)
적용 조건정렬된 배열
활용빠른 탐색, 범위 쿼리

다른 탐색과 비교

탐색시간복잡도정렬 필요공간복잡도특징
선형 탐색O(n)불필요O(1)단순, 느림
이진 탐색O(log n)필요O(1)빠름
해시 탐색O(1)불필요O(n)가장 빠름

주의사항

1. 오버플로우 방지

# 잘못된 방법 (오버플로우 가능)
mid = (left + right) // 2
 
# 올바른 방법
mid = left + (right - left) // 2
 
# Python은 임의 정밀도라 문제없지만,
# C/Java 등에서는 중요

2. 종료 조건

# left <= right (일반적)
while left <= right:
    mid = (left + right) // 2
    # ...
 
# left < right (lower/upper bound)
while left < right:
    mid = (left + right) // 2
    # ...

3. 정렬 확인

def binary_search_safe(arr, target):
    # 정렬 확인 (디버깅용)
    if arr != sorted(arr):
        raise ValueError("배열이 정렬되지 않았습니다")
 
    # 이진 탐색 수행
    return binary_search(arr, target)

자주 나오는 문제 유형

1. 회전된 배열에서 탐색

문제: 회전된 정렬 배열에서 target 찾기

def search_rotated(arr, target):
    """회전된 정렬 배열 탐색"""
    left = 0
    right = len(arr) - 1
 
    while left <= right:
        mid = (left + right) // 2
 
        if arr[mid] == target:
            return mid
 
        # 왼쪽이 정렬되어 있는 경우
        if arr[left] <= arr[mid]:
            if arr[left] <= target < arr[mid]:
                right = mid - 1
            else:
                left = mid + 1
        # 오른쪽이 정렬되어 있는 경우
        else:
            if arr[mid] < target <= arr[right]:
                left = mid + 1
            else:
                right = mid - 1
 
    return -1
 
# 테스트
arr = [4, 5, 6, 7, 0, 1, 2]
print(search_rotated(arr, 0))  # 4
print(search_rotated(arr, 3))  # -1
 
# 시간복잡도: O(log n)

핵심: 절반은 항상 정렬되어 있음


2. 최솟값 찾기

문제: 회전된 정렬 배열에서 최솟값 찾기

def find_min_rotated(arr):
    """회전된 배열의 최솟값"""
    left = 0
    right = len(arr) - 1
 
    while left < right:
        mid = (left + right) // 2
 
        # 오른쪽이 더 작으면 오른쪽에 최솟값
        if arr[mid] > arr[right]:
            left = mid + 1
        else:
            right = mid
 
    return arr[left]
 
# 테스트
print(find_min_rotated([4, 5, 6, 7, 0, 1, 2]))  # 0
print(find_min_rotated([3, 4, 5, 1, 2]))        # 1
 
# 시간복잡도: O(log n)

핵심: 중간값과 끝값 비교


3. 제곱근 구하기

문제: x의 제곱근 (소수점 버림)

def sqrt(x):
    """이진 탐색으로 제곱근"""
    if x < 2:
        return x
 
    left = 1
    right = x // 2
 
    while left <= right:
        mid = (left + right) // 2
        square = mid * mid
 
        if square == x:
            return mid
        elif square < x:
            left = mid + 1
        else:
            right = mid - 1
 
    return right  # 내림
 
# 테스트
print(sqrt(8))   # 2
print(sqrt(16))  # 4
 
# 시간복잡도: O(log n)

핵심: 1부터 x까지 이진 탐색


4. 범위 안의 개수 세기

문제: target과 같은 요소의 개수

def count_occurrences(arr, target):
    """target의 개수"""
    first = find_first(arr, target)
 
    if first == -1:
        return 0
 
    last = find_last(arr, target)
    return last - first + 1
 
# 테스트
arr = [1, 2, 2, 2, 3, 4, 5]
print(count_occurrences(arr, 2))  # 3
print(count_occurrences(arr, 6))  # 0
 
# 또는 bisect 사용
import bisect
 
def count_occurrences_bisect(arr, target):
    left = bisect.bisect_left(arr, target)
    right = bisect.bisect_right(arr, target)
    return right - left
 
print(count_occurrences_bisect(arr, 2))  # 3

핵심: upper_bound - lower_bound


5. 이진 탐색으로 최적값 찾기

문제: 조건을 만족하는 최솟값/최댓값 찾기

def find_min_satisfying(condition, left, right):
    """조건을 만족하는 최솟값"""
    result = right + 1
 
    while left <= right:
        mid = (left + right) // 2
 
        if condition(mid):
            result = mid
            right = mid - 1  # 더 작은 값 찾기
        else:
            left = mid + 1
 
    return result if result != right + 1 else -1
 
# 예: x^2 >= 100인 최소 x
def condition(x):
    return x * x >= 100
 
print(find_min_satisfying(condition, 0, 100))  # 10
 
# 시간복잡도: O(log n)

핵심: Parametric Search (매개변수 탐색)


6. 나무 자르기 (백준 2805)

문제: 적어도 M미터가 되도록 하는 최대 높이

def cut_trees(trees, m):
    """나무 자르기"""
    left = 0
    right = max(trees)
    result = 0
 
    while left <= right:
        mid = (left + right) // 2
 
        # mid 높이로 잘랐을 때 얻는 나무
        total = sum(tree - mid if tree > mid else 0 for tree in trees)
 
        if total >= m:
            result = mid
            left = mid + 1  # 더 높게 자르기
        else:
            right = mid - 1
 
    return result
 
# 테스트
trees = [20, 15, 10, 17]
print(cut_trees(trees, 7))  # 15
 
# 시간복잡도: O(n log H) - H: 최대 높이

핵심: 답의 범위를 이진 탐색


7. 공유기 설치 (백준 2110)

문제: 공유기 사이 최대 거리

def install_routers(houses, c):
    """공유기 설치"""
    houses.sort()
 
    left = 1
    right = houses[-1] - houses[0]
    result = 0
 
    while left <= right:
        mid = (left + right) // 2
 
        # mid 거리로 설치 가능한 개수
        count = 1
        last_pos = houses[0]
 
        for house in houses[1:]:
            if house - last_pos >= mid:
                count += 1
                last_pos = house
 
        # c개 이상 설치 가능
        if count >= c:
            result = mid
            left = mid + 1  # 거리 늘리기
        else:
            right = mid - 1
 
    return result
 
# 테스트
houses = [1, 2, 8, 4, 9]
print(install_routers(houses, 3))  # 3
 
# 시간복잡도: O(n log D) - D: 최대 거리

핵심: 거리를 이진 탐색


추천 연습 문제

기초

중급

고급


이진 탐색 유형 정리

1. 기본 이진 탐색

# 정확한 값 찾기
while left <= right:
    mid = (left + right) // 2
    if arr[mid] == target:
        return mid

2. Lower/Upper Bound

# 이상/초과 위치 찾기
while left < right:
    mid = (left + right) // 2
    if arr[mid] < target:  # 또는 <=
        left = mid + 1
    else:
        right = mid
# 조건을 만족하는 최대/최소값
while left <= right:
    mid = (left + right) // 2
    if condition(mid):
        result = mid
        # 더 좋은 값 찾기
    else:
        # 범위 조정

언제 사용할까?

사용 가능

  • 정렬된 배열: 전제 조건
  • 대용량 데이터: O(log n) 필요
  • 범위 쿼리: lower/upper bound
  • 최적값 찾기: parametric search

사용 불가

  • 비정렬 데이터: 선형 탐색 또는 해시
  • 빈번한 삽입/삭제: BST 또는 해시
  • 작은 데이터: 선형 탐색도 충분

결론: 정렬된 대용량 데이터 탐색의 최적 알고리즘!