문서 읽는 데 46분 · D3

D-3: 이진 탐색·매개변수 탐색 — 정렬된 공간을 절반씩, 답 자체를 이분한다

목차 27
전체 20강 중 11강 · 자료구조·알고리즘
난이도 · 중급선수지식파이썬 기초

ℹ️코딩테스트의 관문 — 자료구조·알고리즘을 직접 구현하고 유형별로 풀어요. 언어 하나(파이썬·자바)를 뗀 다음에 권해요.

안녕하세요, 홍순구 튜터입니다. 지난 시간까지 우리는 데이터를 줄 세우는 정렬을 손으로 짰습니다. 그리고 정렬은 그 자체가 목적이 아니라 '다음 알고리즘의 발판'이라고 여러 번 말했죠. 오늘 드디어 그 발판 위에 첫 무기를 올립니다. 정렬된 배열에서 원하는 값을 눈 깜짝할 사이에 찾는 이진 탐색이에요.

먼저 감을 잡아 봅시다. 700쪽짜리 전화번호부에서 '홍순구'를 찾는다고 해 봐요. 첫 장부터 한 장씩 넘기면 운이 나쁠 때 700번을 넘겨야 합니다. 하지만 우리는 그렇게 안 하죠. 중간쯤 펼쳐 보고 '홍'이 뒤에 있으면 앞쪽 절반을 통째로 덮어 버립니다. 한 번 펼칠 때마다 볼 곳이 반으로 줄어요. 이게 오늘의 주인공, 이진 탐색입니다.

여기서 한 걸음 더 나아가면 오늘의 진짜 반전이 나옵니다. "가장 길게 자르면 몇 미터일까?" 같은, 배열에서 값을 찾는 것과는 전혀 달라 보이는 문제도 사실은 '답 자체를 절반씩 좁혀' 풀 수 있어요. 최적화 문제를 예/아니오 결정 문제로 바꾸는 매개변수 탐색까지, 오늘 함께 가 봅니다.

텍스트
 오늘의 여정 — 이진 탐색, 절반씩 버리는 힘

   ① 절반씩 버린다     정렬된 배열에서 O(log n)에 찾기 (전화번호부 넘기듯)
   ② 경계를 찾는다     lower/upper bound · 내장 bisect (값이 아니라 경계)
   ③ 답을 이분한다     매개변수 탐색 — 최적화 문제를 결정 문제로

💡 오늘 수업의 핵심 — "정렬된 배열을 절반씩 좁혀 O(log n)에 찾고, 나아가 '답 자체를 이분'하는 매개변수 탐색으로 최적화 문제를 결정 문제로 바꾼다"

🎯 학습 목표

  • 이진 탐색으로 정렬된 배열을 O(log n)에 탐색하고, 경계(off-by-one)를 정확히 처리한다.
  • lower/upper bound로 값의 경계를 찾고, 내장 bisect를 실전에서 언제 쓸지 판단한다.
  • 매개변수 탐색으로 최적화 문제를 예/아니오 결정 문제로 바꿔 답을 이분한다.

Step 1: "절반씩 버린다 — 왜 이진 탐색인가"

지난 시간까지의 정렬은 전부 데이터를 '먼저 줄 세우는' 준비였습니다. 오늘부터는 그 줄 세운 배열을 '빠르게 쓰는' 쪽으로 넘어옵니다. 그 첫 번째가 탐색이에요.

정렬된 배열 [1, 3, 5, 7, 9, 11]에서 9를 찾는다고 해 봅시다. 가장 단순한 방법은 앞에서부터 하나씩 견주는 겁니다. 이걸 선형 탐색(linear search)이라 하고, 최악의 경우 원소 n개를 전부 봐야 하니 O(n)이에요. 배열이 커질수록 정직하게 느려집니다.

하지만 배열이 '정렬돼 있다'는 사실을 쓰면 이야기가 달라져요. 가운데 원소를 하나 찍어 찾는 값과 견주면, 한쪽 절반은 볼 필요조차 없어집니다. 찾는 값이 가운데보다 크면 왼쪽 절반은 통째로 버리고, 작으면 오른쪽을 버려요. 한 번 비교에 후보가 반으로 줄어드니, n개를 훑어도 약 log₂n번이면 끝납니다.

텍스트
 선형 탐색 — 앞에서부터 하나씩          이진 탐색 — 가운데를 찍어 절반을 버린다

   n = 16    최악 16번                  n = 16    최악 4번   (168421)
   n = 10억  최악 10억 번               n = 10억  최악 30번  (log₂10억 ≈ 30)

이 차이를 이 과목의 잣대 '1초 ≈ 1억 연산'으로 재 봅시다. n이 10억일 때 선형 탐색은 약 10억 번, 즉 10초 넘게 걸려 시간 초과예요. 그런데 이진 탐색은 고작 30번입니다. 눈 깜짝할 사이죠. 정렬만 돼 있다면, 탐색은 사실상 공짜에 가까워집니다.

방법 시간 복잡도 최악 비교 횟수 (n=10억)
선형 탐색 O(n) 약 10억 번
이진 탐색 O(log n) 약 30번

핵심 전제는 딱 하나예요. '정렬돼 있어야 한다'는 것. 뒤죽박죽인 배열에선 가운데를 찍어도 어느 절반을 버릴지 근거가 없으니까요. 그래서 이진 탐색은 늘 정렬된 배열을 발판으로 깔고 시작합니다. 지난 시간에 "정렬해 두면 뭐가 좋은가"의 답이 바로 이겁니다.

🎯 코테에서는 "정렬한 뒤 이진 탐색"이 하나의 세트로 자주 나옵니다. 입력이 정렬돼 있거나, 정렬해도 되는 데이터라면 '어떤 값이 있는지', '조건을 만족하는 첫 위치가 어디인지'를 O(log n)에 물을 수 있어요. n이 수십만~수억으로 크면서 "빠르게 찾아라"가 보이면 이진 탐색을 먼저 의심해 봅니다.

💡 한 줄 정리

이진 탐색은 정렬된 배열에서 가운데를 찍어 절반을 버려 O(log n)에 찾으며, 그 전제는 '배열이 정렬돼 있어야 한다'는 것이다.

🙋 학생 질문 — "정렬 안 된 배열이면 이진 탐색을 못 쓰나요?"

정렬돼 있지 않으면 곧바로는 못 씁니다. 절반을 버릴 근거가 없으니까요. 물론 먼저 정렬하면 되지만, 정렬은 O(n log n)이 듭니다.

그래서 "한 번만 찾을 거냐, 여러 번 찾을 거냐"를 따져야 해요. 딱 한 번 찾을 거면 정렬 비용(O(n log n))을 내느니 그냥 선형 탐색 O(n)이 더 쌉니다. 반대로 같은 배열에서 수천 번 반복해 찾을 거면, 한 번 정렬해 두고 매번 O(log n)으로 찾는 게 압도적으로 이득이죠. 이 트레이드오프는 마지막에 다시 정리합니다.


Step 2: "직접 짜 보는 이진 탐색 — 경계와의 싸움"

원리를 봤으니 손으로 짜 봅시다. 이진 탐색의 뼈대는 '아직 후보인 구간'을 두 변수 lo, hi로 잡고, 가운데 mid를 찾는 값과 견줘 그 구간을 좁혀 가는 겁니다.

Python
# algorithms/binary_search.py
def binary_search(arr, target):
    lo, hi = 0, len(arr) - 1
    while lo <= hi:
        mid = (lo + hi) // 2
        if arr[mid] == target:
            return mid
        elif arr[mid] < target:
            lo = mid + 1
        else:
            hi = mid - 1
    return -1

한 줄씩 뜯어봅시다. lohi는 후보 구간의 양 끝 index예요. 처음엔 배열 전체라 0len(arr) - 1입니다. 가운데 mid를 찾은 뒤 세 갈래로 나뉘어요. arr[mid]가 찾는 값과 같으면 바로 그 index를 돌려주고, 찾는 값보다 작으면 target은 오른쪽에 있으니 lomid + 1로 당깁니다. 크면 반대로 himid - 1로 줄여요.

+1, -1이 이진 탐색에서 가장 많이 틀리는 곳, 이른바 off-by-one(경계 하나 차이)입니다. mid는 방금 이미 확인했으니 다음 후보에서 빼야 해요. 그래서 버릴 때 mid 자신을 다시 넣지 않으려고 +1, -1을 붙입니다. 구간을 닫힌 구간 [lo, hi]로 잡았으니 반복 조건도 lo <= hi(양 끝이 같아도 아직 후보 하나가 남음)가 됩니다. 구간이 완전히 비면(lo > hi) 없는 값이라 -1을 돌려줘요.

실제로 도는 과정을 눈으로 따라가 봅시다.

텍스트
 binary_search([1, 3, 5, 7, 9, 11], 9)   — 닫힌 구간 [lo, hi]를 좁힌다

   index:   0   1   2   3   4   5
   arr:     1   3   5   7   9  11

   ① lo=0, hi=5  mid=2, arr[2]=5  < 9   lo = mid+1 = 3   (index 0~2 버림)
   ② lo=3, hi=5  mid=4, arr[4]=9  == 9  찾았다! index 4 반환

여섯 개짜리 배열을 단 두 번의 비교로 끝냈어요. 실제로 binary_search([1, 3, 5, 7, 9, 11], 7)3을, 없는 값인 binary_search([1, 3, 5, 7, 9, 11], 6)-1을 돌려줍니다. 시간 복잡도는 후보가 매번 절반이 되니 O(log n), 추가로 쓰는 공간은 변수 몇 개뿐이라 O(1)이에요.

🎯 코테에서는 이 열두 줄이 손에 붙어 있어야 합니다. 경계 조건(<=인지 <인지, mid를 버릴 때 ±1을 붙이는지)에서 한 끗을 틀리면 무한 루프에 빠지거나 답이 하나씩 어긋나요. 그래서 아예 '내 손에 맞는 한 가지 틀'을 정해 두고 늘 똑같이 쓰는 걸 추천합니다.

💡 한 줄 정리

이진 탐색은 닫힌 구간 [lo, hi]while lo <= hi로 좁히며, mid를 버릴 때 ±1을 붙이는 경계 처리(off-by-one)가 정확성의 핵심이다.

🙋 학생 질문 — "mid = (lo + hi) // 2 는 오버플로 위험이 없나요?"

파이썬에서는 걱정할 필요가 없습니다. 파이썬 정수는 자릿수 제한이 없어서 lo + hi가 아무리 커도 넘치지 않아요.

다만 자바나 C처럼 정수 크기가 정해진 언어에서는 lo + hi가 int 최댓값을 넘어 음수로 뒤집히는 유명한 버그가 있습니다. 그래서 그런 언어에선 mid = lo + (hi - lo) // 2로 씁니다. 두 식은 결과가 같지만, 뒤 식은 큰 수를 더하지 않아 안전해요. 파이썬만 쓸 거면 (lo + hi) // 2로 충분하지만, "왜 저렇게도 쓰나"는 알아 두면 면접에서 한 번씩 나옵니다.


Step 3: "값이 아니라 경계를 찾는다 — lower/upper bound"

방금 만든 binary_search에는 빈틈이 하나 있어요. 같은 값이 여러 개면 어떻게 될까요? [1, 2, 2, 2, 5, 7]에서 2를 찾으면, 세 개의 2 중 '어느 것'의 index가 나올지 알 수 없습니다. 가운데를 어떻게 찍느냐에 따라 1일 수도, 3일 수도 있어요.

그런데 실전에서는 "2가 있냐"보다 "2가 처음 나오는 위치가 어디냐", "2가 몇 개냐" 같은 질문이 훨씬 자주 나옵니다. 그래서 값을 찾는 대신 '경계'를 찾는 이진 탐색을 따로 익힙니다. 두 가지예요.

  • lower bound: target '이상'이 처음 나오는 위치
  • upper bound: target '초과'가 처음 나오는 위치
Python
# algorithms/binary_search.py
def lower_bound(arr, target):
    lo, hi = 0, len(arr)
    while lo < hi:
        mid = (lo + hi) // 2
        if arr[mid] < target:
            lo = mid + 1
        else:
            hi = mid
    return lo

Step 2와 달라진 곳을 짚어 봅시다. hilen(arr) - 1이 아니라 len(arr)에서 시작하고(찾는 값이 배열 맨 끝보다 커서 '끝에 끼워 넣을 위치'가 답일 수 있으니까요), 조건도 lo < hi인 반열린 구간 [lo, hi)입니다. arr[mid]가 target보다 작으면 mid는 절대 답이 될 수 없으니 버리고(lo = mid + 1), target 이상이면 mid도 후보라서 hi = mid로 남겨 둬요. 이렇게 좁히면 '이상이 처음인 위치'로 정확히 수렴합니다.

upper bound는 부등호 딱 하나만 다릅니다.

Python
def upper_bound(arr, target):
    lo, hi = 0, len(arr)
    while lo < hi:
        mid = (lo + hi) // 2
        if arr[mid] <= target:
            lo = mid + 1
        else:
            hi = mid
    return lo

arr[mid] < targetarr[mid] <= target으로 바뀌었죠. target과 '같은' 값까지 버리니, 같은 값들의 오른쪽 끝 다음으로 수렴해요. 이 둘을 그림으로 보면 한눈에 들어옵니다.

텍스트
 arr = [1, 2, 2, 2, 5, 7]        target = 2
   index:  0   1   2   3   4   5
   값:     1   2   2   2   5   7
              └─── 2 ───┘

   lower_bound(2) = 1    2 이상이 처음 나오는 위치 (첫 2의 index)
   upper_bound(2) = 4    2 초과가 처음 나오는 위치 (index 4, 값 5)
   개수 = upper - lower = 4 - 1 = 3

경계 둘을 빼면 그 폭이 곧 '값의 개수'가 됩니다. 코드베이스에도 이 조합을 담아 뒀어요.

Python
def count_occurrences(arr, target):
    return upper_bound(arr, target) - lower_bound(arr, target)

[1, 2, 2, 2, 5, 7]에서 count_occurrences(arr, 2)3을 돌려줍니다. 하나하나 세면 O(n)이지만, 경계 두 번이면 O(log n)이에요. 없는 값 6은 lower와 upper가 똑같이 5(끼워 넣을 위치)로 나와 개수 0이 됩니다.

🎯 코테에서는 "정렬된 수열에서 특정 값이 몇 개인가", "x 이상 y 이하가 몇 개인가" 같은 문제가 lower/upper bound의 단골입니다. 정렬 배열에서 범위를 세는 질문이 보이면, 하나씩 세지 말고 경계 두 번을 떠올리세요. 오늘 과제에서 직접 풀어 봅니다.

💡 한 줄 정리

lower bound는 target 이상이 처음인 위치, upper bound는 초과가 처음인 위치를 O(log n)에 찾으며, 부등호 하나(< vs <=) 차이로 갈리고 두 경계의 폭이 곧 값의 개수다.

🙋 학생 질문 — "왜 hi를 len(arr)로 두고 while lo < hi 로 바꿨나요?"

경계 탐색은 '값의 index'가 아니라 '끼워 넣을 위치'를 답으로 내기 때문이에요. 그 위치는 배열 맨 끝, 즉 len(arr)일 수도 있습니다(모든 값이 target보다 작을 때). 닫힌 구간 [0, len-1]로 잡으면 이 '맨 끝 다음' 위치를 표현할 수가 없어요.

그래서 반열린 구간 [lo, hi)로 잡고 hilen(arr)에서 시작합니다. 조건이 lo < hi인 것도 이 때문이에요. lohi가 만나는 순간이 답입니다. Step 2의 닫힌 구간과 헷갈리기 쉬우니, "값 찾기는 [lo, hi]·<=, 경계 찾기는 [lo, hi)·<"로 두 틀을 따로 외워 두면 편합니다.


Step 4: "실전은 bisect — 직접 구현 vs 내장"

여기까지 lower/upper bound를 손으로 짜 봤습니다. 이제 좋은 소식이에요. 파이썬은 이 경계 탐색을 표준 라이브러리 bisect로 이미 제공합니다. C-2에서 힙을 직접 짠 뒤 heapq를 썼던 것과 똑같은 흐름이에요. 원리를 손으로 봤으니, 실전에선 검증된 내장을 씁니다.

Python
import bisect

arr = [1, 2, 2, 2, 5, 7]
bisect.bisect_left(arr, 2)     # → 1  (우리가 만든 lower_bound과 같다)
bisect.bisect_right(arr, 2)    # → 4  (upper_bound과 같다)

bisect.insort(arr, 4)          # arr가 [1, 2, 2, 2, 4, 5, 7]로 — 정렬을 유지하며 삽입

이름만 다를 뿐 동작이 똑같아요. bisect_left가 lower bound, bisect_right가 upper bound입니다. insort는 정렬을 유지한 채 값을 끼워 넣는데, 넣을 위치는 이진 탐색으로 O(log n)에 찾지만 실제로 배열 중간에 밀어 넣는 건 O(n)이 든다는 점만 기억하세요. 코드베이스 binary_search.py 아래에도 이 대조를 메모로 적어 뒀습니다.

Python
# algorithms/binary_search.py (파일 끝, 실전 대조 메모)
#   bisect.bisect_left(arr, x)   ==  lower_bound(arr, x)
#   bisect.bisect_right(arr, x)  ==  upper_bound(arr, x)
#   bisect.insort(arr, x)        →  정렬을 유지하며 삽입(삽입 자체는 O(n))

그럼 언제 직접 짜고 언제 bisect를 쓸까요? 기준은 명확합니다.

상황 무엇을 쓰나
실전 코테에서 표준 경계 탐색 bisect (C로 구현돼 빠르고 버그가 없다)
면접에서 원리를 물을 때 직접 구현 (경계 처리를 설명할 수 있어야)
표준에 없는 변형 (매개변수 탐색 등) 직접 구현 (다음 Step에서 바로 나온다)

🎯 코테에서는 정렬 배열의 경계 탐색은 bisect 두 줄이면 끝납니다. 하지만 곧 배울 매개변수 탐색처럼 '판정 조건이 배열이 아닌 곳'에 있으면 bisect로는 안 되고 직접 짜야 해요. 그래서 원리를 손에 쥔 사람만 라이브러리를 언제 믿고 언제 직접 짤지 판단할 수 있습니다.

💡 한 줄 정리

bisect_left는 lower bound, bisect_right는 upper bound와 같아 실전 경계 탐색은 bisect로 끝나지만, 표준에 없는 변형은 직접 짜야 하므로 원리를 쥐고 있어야 한다.

🙋 학생 질문 — "bisect로 '값이 있는지'만 확인하려면요?"

bisect에는 "있냐/없냐"를 바로 답하는 함수가 없어요. 대신 bisect_left로 '들어갈 위치'를 찾은 뒤, 그 위치에 정말 그 값이 있는지 한 번 더 확인하면 됩니다.

Python
i = bisect.bisect_left(arr, x)          # x가 들어갈 위치
exists = i < len(arr) and arr[i] == x   # 그 위치에 정말 x가 있으면 존재

[1, 2, 2, 2, 5, 7]에서 x = 5i = 4이고 arr[4] == 5라 존재(True), x = 6이면 i = 4인데 arr[4]5라 없음(False)이 됩니다. i < len(arr) 조건은 들어갈 위치가 배열 맨 끝 다음일 때 arr[i]를 읽다 터지지 않게 막아 주는 안전선이에요.


Step 5: "답을 이분한다 — 최적화 문제를 결정 문제로"

지금까지는 '정렬된 배열' 안에서 값을 찾았습니다. 이제 오늘의 반전이에요. 배열이 아예 없는데도 이진 탐색을 쓰는, 매개변수 탐색(parametric search)입니다.

문제부터 봅시다. 길이가 제각각인 랜선 몇 개를 잘라 똑같은 길이의 조각 N개를 만들려고 해요. 이때 만들 수 있는 조각의 '최대 길이'는 얼마일까요? 이런 걸 최적화 문제라고 합니다. 곧장 풀려고 하면 막막해요. 길이를 1부터 하나씩 다 넣어 보면 되지만, 답이 수억이면 그 방법은 시간 초과입니다.

여기서 발상을 뒤집습니다. '가장 긴 길이'를 곧장 구하는 대신, 이렇게 물어봐요. "길이 L로 자르면 조각이 N개 이상 나오나?" 이건 예/아니오로 답하는 결정 문제입니다. 특정 L 하나만 주어지면, 각 랜선을 L로 나눠(랜선 길이 // L) 조각 수를 더해 N과 견주기만 하면 되니 아주 쉬워요.

그런데 이 예/아니오에 결정적인 성질이 하나 숨어 있습니다. L을 키우면 조각 수는 줄기만 해요(조각이 길어지니 개수는 적어지죠). 그래서 어떤 경계까지는 계속 '된다(O)'가 나오다가, 그 경계를 넘는 순간부터 쭉 '안 된다(X)'가 나옵니다. 이렇게 O와 X가 딱 한 번만 갈리는 걸 단조성(monotonic)이라고 해요.

텍스트
 "길이 L로 자르면 11개 이상 나오나?" — L이 커질수록 조각은 준다(단조)

   L =  ... 198  199  200 | 201  202  ...
   판정      O    O    O   |  X    X           O(된다)와 X(안 된다)가 딱 한 번 갈린다
                      └ 되는 L 중 최댓값 = 200 (정답)

   "가장 긴 L?"(최적화)    "L로 자르면 11개 나오나?"(결정 O/X)

O와 X가 한 번만 갈린다는 건, 그 경계를 이진 탐색으로 찾을 수 있다는 뜻입니다. 정렬된 배열에서 값을 찾던 것과 똑같아요. 다만 이번엔 배열이 아니라 '답이 될 수 있는 값의 범위(1부터 최대 랜선 길이까지)'를 절반씩 좁힙니다. 가운데 길이를 판정해서 '되면' 더 길게, '안 되면' 더 짧게 밀어붙이는 거죠. 이게 답 자체를 이분한다는 매개변수 탐색의 정체입니다.

🎯 코테에서는 "최대/최소 ○○를 구하라"인데 그 값을 직접 구하기는 어렵고, "특정 값이면 조건을 만족하나?"는 쉽게 판정되며 그 판정이 단조로우면 — 이 세 박자가 보이면 매개변수 탐색입니다. 랜선 자르기, 나무 자르기, 예산 배정이 전부 이 틀이에요.

💡 한 줄 정리

매개변수 탐색은 풀기 어려운 최적화 문제("최대 L?")를 쉬운 결정 문제("L이면 되나?")로 바꾸고, 그 판정이 단조로우면 답의 범위를 이진 탐색으로 좁혀 O(log(범위)×판정비용)에 푼다.

🙋 학생 질문 — "배열도 없는데 이게 왜 이진 탐색인가요?"

이진 탐색의 본질은 '정렬된 배열'이 아니라 '절반을 확실히 버릴 수 있느냐'예요. 정렬된 배열에서는 가운데보다 크면 왼쪽을 버릴 수 있었죠. 매개변수 탐색에서는 '판정이 단조롭다'는 성질이 그 근거를 대신합니다.

가운데 길이 L을 판정해서 '된다(O)'가 나오면, L보다 작은 값들은 볼 것도 없이 전부 O예요(단조니까). 우리는 '되는 것 중 가장 큰 값'을 원하니 그 왼쪽 절반은 통째로 버리고 오른쪽만 봅니다. 반대로 '안 된다(X)'면 오른쪽은 전부 X라 버려요. 배열이 없을 뿐, 절반을 버리는 원리는 완전히 똑같습니다. 그래서 '답 공간에 대한 이진 탐색'이라고 부릅니다.


Step 6: "랜선 자르기로 굳히기 — 매개변수 탐색 구현"

개념을 코드로 옮겨 봅시다. 답의 범위는 길이 1부터 max(cables)까지예요(가장 긴 랜선보다 길게는 못 자르니까요). 이 범위를 이진 탐색으로 좁힙니다.

Python
# algorithms/binary_search.py
def max_cut_length(cables, n):
    lo, hi = 1, max(cables)
    best = 0
    while lo <= hi:
        mid = (lo + hi) // 2
        pieces = sum(c // mid for c in cables)   # 길이 mid로 자를 때 나오는 조각 수
        if pieces >= n:                          # 충분하다 → 더 길게 잘라 본다
            best = mid
            lo = mid + 1
        else:                                    # 모자란다 → 더 짧게
            hi = mid - 1
    return best

Step 5의 그림이 그대로 코드가 됐어요. pieces가 판정 함수입니다. 길이 mid로 자를 때 나오는 조각 수를 세서 n과 견줘요. 충분하면(pieces >= n) 답을 더 키울 여지가 있으니 mid를 일단 best에 기록해 두고 lo를 올려 더 긴 쪽을 봅니다. 모자라면 hi를 내려 더 짧은 쪽을 봐요.

여기서 눈여겨볼 건 best에 따로 기록한다는 점입니다. 기본 이진 탐색은 값을 찾으면 바로 반환했지만, 매개변수 탐색은 '되는 것 중 최댓값'을 원해요. 그래서 '될 때마다' 그 값을 후보로 저장해 두고 계속 더 큰 값을 노립니다. 반복이 끝나면 마지막으로 저장된 best가 정답이에요.

텍스트
 max_cut_length([802, 743, 457, 539], 11)   — 답 L을 [1, 802]에서 이분

   lo=1,   hi=802  mid=401  조각 2+1+1+1 = 5  < 11   짧게 (hi=400)
   lo=1,   hi=400  mid=200  조각 4+3+2+2 = 11 >= 11  기록 best=200, 길게 (lo=201)
   lo=201, hi=400  mid=300  조각 2+2+1+1 = 6  < 11   짧게 (hi=299)
   ... 계속 좁히면 lo > hi, 최종 best = 200

실제로 max_cut_length([802, 743, 457, 539], 11)200을 돌려줍니다. 답의 범위가 최대 802라 이진 탐색은 약 10번, 매번 랜선 K개를 훑어 판정하니 전체는 O(log(max) × K)예요. 1부터 하나씩 넣어 보는 O(max × K)와는 하늘과 땅 차이입니다.

이 틀의 진짜 힘은 '판정 함수만 갈아 끼우면' 다른 문제도 똑같이 풀린다는 거예요. 예를 들어 나무 자르기(절단기 높이 H로 잘라 얻는 나무 총량이 M 이상인 최대 H)는 판정 한 줄만 바뀝니다.

Python
        harvested = sum(t - mid for t in trees if t > mid)

랜선의 pieces = sum(c // mid ...)를 이 harvested로 바꾸고, "많이 얻으면 절단기를 높이고, 모자라면 낮춘다"로 방향만 맞추면 끝이에요. 코드베이스 max_tree_cut_height가 바로 그렇게 돼 있고, max_tree_cut_height([20, 15, 10, 17], 7)15를 돌려줍니다. 골격은 그대로, 판정만 바뀐 겁니다.

🎯 코테에서는 랜선 자르기·나무 자르기·예산 배정이 매개변수 탐색의 3대 단골입니다. 셋 다 뼈대(답의 범위를 이분 + best 기록)는 똑같고 판정 함수만 달라요. "최댓값을 최대로" 또는 "최솟값을 최소로"가 보이면 이 틀을 꺼내 판정 함수부터 설계하세요. 예산 배정은 오늘 과제로 직접 풀어 봅니다.

💡 한 줄 정리

매개변수 탐색은 답의 범위를 [1, max]로 잡아 이진 탐색하되, '될 때마다' best에 기록해 최댓값을 남기며, 판정 함수만 갈아 끼우면 랜선·나무·예산이 같은 틀로 풀린다.

🙋 학생 질문 — "lo, hi 초기값이나 판정 경계에서 하나만 틀리면 어떻게 되나요?"

매개변수 탐색은 경계 실수가 특히 아프게 돌아옵니다. 답의 범위를 잘못 잡으면(예: himax(cables)가 아니라 그보다 작게) 정답이 범위 밖으로 새어 나가 영영 못 찾아요. 판정의 부등호(>=인지 >인지)를 헷갈리면 정답에서 하나 어긋난 값이 나오고요.

그래서 순서를 정해 두면 좋습니다. ① 답이 가질 수 있는 최소·최대를 먼저 확정해 lo, hi를 잡는다. ② 판정 함수 feasible(mid)를 예/아니오로 명확히 정의한다. ③ '되면 어느 쪽으로 갈지'(최댓값이면 lo 올리고 best 기록)를 정한다. 이 세 가지를 코드 짜기 전에 말로 먼저 정리하면 경계 실수가 확 줄어요. 확신이 안 서면 작은 입력(랜선 2~3개)으로 손으로 돌려 보는 게 제일 확실합니다.


Step 7: "정리 — 세 가지 이진 탐색 + 언제 쓰나"

오늘 이진 탐색을 세 가지 모습으로 만났습니다. 겉보기엔 달라 보여도 뿌리는 하나, '절반을 확실히 버린다'예요. 한 표로 정리해 봅시다.

종류 무엇을 찾나 구간·조건 빅오
기본 이진 탐색 값이 있나 / 어디에 있나 [lo, hi] · lo <= hi · ±1 O(log n)
경계 탐색 (lower/upper) 값의 처음 / 초과 위치 [lo, hi) · lo < hi · hi = len O(log n)
매개변수 탐색 답 자체 (최대 / 최소) 답의 범위를 이분 + best 기록 O(log(범위) × 판정)

세 가지를 관통하는 판단 기준도 정리해 두죠. 정렬된 배열에서 값을 찾거나 경계를 셀 거면, 실전에선 bisect 두 줄이면 됩니다. 원리를 물어보는 면접이나, bisect로는 안 되는 변형(매개변수 탐색처럼 판정이 배열 밖에 있는 경우)에서는 직접 짜야 하고요. 그래서 오늘 세 가지를 다 손으로 짜 본 겁니다.

빅오도 다시 새겨 둡시다. 이진 탐색의 O(log n)은 정말 강력해요. n이 10억이어도 30번, 1조여도 40번입니다. 그래서 "입력이 크고 정렬돼 있다"거나 "답의 범위가 넓지만 판정은 쉽고 단조롭다"가 보이면, 이진 탐색으로 지수적인 크기를 로그로 눌러 버릴 수 있는지 가장 먼저 의심해 보세요.

💡 한 줄 정리

이진 탐색은 값 찾기(기본)·경계 찾기(lower/upper)·답 찾기(매개변수) 세 가지로 쓰이며, 공통 뿌리는 '절반을 확실히 버린다'이고 O(log n)으로 큰 입력을 눌러 준다.

🙋 학생 질문 — "정수 말고 실수 답도 이분할 수 있나요?"

가능합니다. 답이 실수(예: "가장 긴 밧줄의 길이를 소수점까지")면 lo, hi를 실수로 두고 똑같이 이분해요. 다만 정수처럼 lo > hi로 딱 끝나지 않으니, "hi - lo가 아주 작은 값(예: 0.000001) 이하가 될 때까지" 또는 "100번쯤 반복"으로 멈춥니다. 절반씩 좁히니 100번이면 소수점 아래 서른 자리까지 정밀해져요. 실수 이분 탐색은 더 뒤에서 필요할 때 다시 꺼내겠습니다. 오늘은 '정수 답을 이분한다'는 골격을 확실히 잡는 데 집중하세요.


마무리

오늘은 정렬이라는 발판 위에 첫 무기를 올렸습니다. 정렬된 배열을 절반씩 버려 O(log n)에 찾는 이진 탐색을 손으로 짰고, 값이 아니라 경계를 찾는 lower/upper bound와 그 실전판 bisect까지 익혔어요. 그리고 마지막엔 '답 자체를 이분한다'는 매개변수 탐색으로, 최적화 문제를 예/아니오 결정 문제로 바꾸는 발상까지 나아갔습니다.

오늘 배운 핵심 세 가지

  • 💡 하나 — 이진 탐색은 절반을 버린다. 정렬된 배열이라는 전제 위에서 가운데를 찍어 후보를 반씩 줄이면 O(log n). n이 10억이어도 30번이면 끝난다. 대신 경계(off-by-one) 처리가 정확성의 전부다.
  • 💡 둘 — 값이 아니라 경계를 찾을 수 있다. lower/upper bound는 '이상/초과가 처음인 위치'를 O(log n)에 찾고, 두 경계의 폭이 곧 값의 개수다. 실전에선 bisect_left·bisect_right로 끝난다.
  • 💡 셋 — 답 자체를 이분한다. 매개변수 탐색은 어려운 최적화 문제를 쉬운 결정 문제로 바꾼 뒤, 판정이 단조로우면 답의 범위를 이진 탐색으로 좁힌다. 랜선·나무·예산이 같은 틀이다.

다음 시간 예고

오늘 이진 탐색은 '정렬된 배열'을 절반씩 좁혀 O(log n)에 찾았죠. 다음 시간(D-4)엔 정렬된 배열을 이번엔 '양 끝에서' 조여 들어가는 투 포인터를 배웁니다. 두 개의 손가락을 배열 양쪽에 두고 안쪽으로 좁히면, 이중 반복문 O(n²)로 풀던 '두 수의 합' 같은 문제를 단번에 O(n)으로 끌어내려요. 나아가 창문을 미끄러뜨리듯 구간을 훑는 슬라이딩 윈도우, 미리 더해 두고 빼서 쓰는 누적합까지, 'O(n)으로 구간을 다루는' 무기들을 손에 넣습니다. 오늘 정렬이 이진 탐색의 발판이었듯, 정렬은 투 포인터의 발판이기도 합니다.


과제

오늘 배운 경계 탐색(lower/upper bound)과 매개변수 탐색을 손에 붙이는 문제들입니다. 코드베이스 algorithms/binary_search.py의 함수를 가져다 쓰거나, 직접 이진 탐색을 짜서 풀어 보세요. 각 문제의 시간·공간 복잡도를 빅오로 함께 적는 걸 잊지 마세요.

[기초] 삽입 위치 찾기

정렬된 정수 리스트 arr와 값 target이 주어질 때, target이 배열에 있으면 그 위치를, 없으면 정렬을 유지한 채 끼워 넣을 위치를 반환하는 search_insert_position(arr, target)을 작성하세요. 예를 들어 search_insert_position([1, 3, 5, 6], 5)2, search_insert_position([1, 3, 5, 6], 2)1(1과 3 사이), search_insert_position([1, 3, 5, 6], 7)4(맨 뒤)를 반환합니다.

  • 이 문제는 오늘 배운 경계 탐색 하나면 끝납니다. '있으면 그 위치, 없으면 삽입 위치'가 어느 경계와 정확히 같은지 떠올려 보세요.
  • 선형으로 훑으면 O(n), 이진 탐색으로 하면 O(log n)입니다. 왜 그런지 한 줄로 설명하세요.

[응용] 범위 안의 개수 세기

정렬된 정수 리스트 arr와 두 값 lo_val, hi_val이 주어질 때, lo_val 이상 hi_val 이하인 원소의 개수를 반환하는 count_in_range(arr, lo_val, hi_val)을 작성하세요. 예를 들어 arr = [1, 2, 2, 3, 5, 7]에서 count_in_range(arr, 2, 5)4(2, 2, 3, 5)를, count_in_range(arr, 8, 9)0을 반환합니다.

  • 하나씩 세면 O(n)이지만, 경계 두 번이면 O(log n)입니다. 오늘 배운 lower_boundupper_bound를 어떻게 조합할지 생각해 보세요.
  • 어떤 경계에 lo_val을 넣고 어떤 경계에 hi_val을 넣어야 '이상 ~ 이하'가 정확히 세어지는지가 핵심입니다.

[심화] 예산 배정 상한 구하기

각 지방의 요청 예산 리스트 requests와 총예산 total이 주어집니다. 상한액을 하나 정해서, 요청액이 상한보다 크면 상한만큼만, 작으면 요청액 그대로 배정합니다. 배정 총합이 total을 넘지 않는 '최대 상한액'을 반환하는 max_budget_cap(requests, total)을 작성하세요(백준 예산 문제). 단, 모든 요청의 합이 total 이내라면 상한이 필요 없으니 가장 큰 요청액을 반환합니다. 예를 들어 max_budget_cap([120, 110, 140, 150], 485)127입니다.

  • 매개변수 탐색입니다. "상한을 cap으로 두면 배정 총합이 total 이내인가?"라는 결정 문제로 바꿔 보세요. 이 판정은 cap이 커질수록 어느 방향으로 단조로운가요?
  • 답의 범위(lo, hi)를 먼저 확정하고, '되면 상한을 올리고 안 되면 내리는' 방향을 정하세요. 요청 합이 예산 이내인 예외 케이스도 잊지 마세요.

생각해볼 주제

1. 이진 탐색은 왜 정렬을 전제로 할까, 그리고 언제 정렬 비용이 아깝지 않을까?

이진 탐색은 O(log n)으로 빠르지만, 정렬돼 있지 않으면 못 씁니다. 정렬을 먼저 하면 O(n log n)이 드는데, 이건 선형 탐색 O(n)보다 오히려 비싸요. 그렇다면 "정렬 후 이진 탐색"이 "그냥 선형 탐색"을 이기는 건 정확히 어떤 상황일까요? '탐색을 몇 번 반복하느냐'를 기준으로, 정렬 비용을 언제 감수할 만한지 따져 보세요. 이 감각은 자료구조 선택(정렬 배열 vs 해시)에서도 똑같이 쓰입니다.

2. 경계 탐색은 왜 닫힌 구간이 아니라 반열린 구간으로 짤까?

기본 이진 탐색은 닫힌 구간 [lo, hi]lo <= hi로 짰고, 경계 탐색(lower/upper bound)은 반열린 구간 [lo, hi)lo < hi, hi = len(arr)로 짰습니다. 왜 굳이 두 스타일을 나눴을까요? 경계 탐색을 굳이 닫힌 구간으로 짜면 어떤 실수가 생기기 쉬운지, 반열린 구간이 off-by-one을 왜 덜 틀리게 만드는지 생각해 보세요. "경계를 하나 옮겨도 답이 맞으려면 어떤 불변식(invariant)이 유지돼야 하는가"가 힌트입니다.

3. 아무 최적화 문제나 매개변수 탐색으로 풀 수 있을까?

매개변수 탐색은 "최대/최소 답을 이분한다"는 강력한 틀이지만, 아무 문제에나 통하지는 않습니다. 이 틀이 성립하려면 판정 함수가 반드시 '단조로워야' 해요. 어떤 문제가 단조롭고 어떤 문제가 그렇지 않은지, 단조성이 깨지면 왜 이진 탐색이 엉뚱한 답을 내는지 예를 들어 설명해 보세요. "L이면 되는데 L보다 작은 어떤 값은 안 된다"가 가능한 문제라면 어떤 일이 벌어질지 상상해 보면 감이 옵니다.

✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 삽입 위치 찾기

채점 포인트

항목 확인
경계 탐색 '있으면 그 위치, 없으면 삽입 위치'가 곧 lower bound다
함수 재사용 오늘 만든 lower_bound 한 줄로 끝난다
빅오 이진 탐색이라 O(log n)·공간 O(1)
실전 감각 선형으로 훑으면 O(n) — 정렬 배열이니 O(log n)으로 눌러야

풀이 예시

"target이 있으면 그 위치, 없으면 끼워 넣을 위치"를 잘 뜯어보면, 이건 정확히 lower bound의 정의와 같습니다. target 이상이 처음 나오는 위치는 — target이 있으면 그 자신의 위치이고, 없으면 딱 끼워 넣을 위치예요. 그래서 오늘 만든 lower_bound를 그대로 부르면 끝납니다.

Python

def search_insert_position(arr, target):
    return lower_bound(arr, target)

lower_boundbinary_search.py에서 가져다 씁니다. 새로 짤 게 없어요. [1, 3, 5, 6]에 대해 값별로 따라가 봅시다.

텍스트
 search_insert_position([1, 3, 5, 6], target)  = lower_bound(...)

   index:  0   1   2   3
   값:     1   3   5   6

   target=5  index 2  (5가 있는 위치)
   target=2  index 1  (1과 3 사이, 끼워 넣을 위치)
   target=7  index 4  (맨 뒤, 배열 끝에 붙는다)
   target=0  index 0  (맨 앞)

있는 값(5)은 자기 위치를, 없는 값(2, 7, 0)은 정렬을 유지한 채 끼워 넣을 위치를 정확히 돌려줍니다. 특히 7처럼 모든 값보다 큰 경우 len(arr)4가 나오는데, 이건 경계 탐색에서 hilen(arr)로 잡았기에 자연스럽게 표현돼요. 빅오는 이진 탐색이라 O(log n), 추가 공간은 O(1)입니다.

💡 튜터의 한마디: 실전에선 import bisectbisect.bisect_left(arr, target) 한 줄이면 똑같습니다. LeetCode 35번 "Search Insert Position"이 정확히 이 문제라, "삽입 위치 = lower bound"라는 등식을 한 번 새겨 두면 두고두고 써먹어요.


🎯 [과제 2 예시답안] 범위 안의 개수 세기

채점 포인트

항목 확인
경계 두 번 lower_bound(lo_val)upper_bound(hi_val)의 폭이 개수다
경계 선택 '이상'은 lower, '초과 다음'은 upper — 어느 값을 어디에 넣을지 정확히
빅오 경계 두 번이라 O(log n)·공간 O(1)
실전 감각 하나씩 세면 O(n) — 정렬 배열이면 경계 두 번으로

풀이 예시

"lo_val 이상 hi_val 이하"의 개수는, 그 구간의 시작과 끝 경계만 알면 빼기 한 번으로 나옵니다. 시작은 "lo_val 이상이 처음 나오는 위치"라 lower_bound(lo_val), 끝은 "hi_val 초과가 처음 나오는 위치"라 upper_bound(hi_val)이에요. 이 둘을 빼면 그 사이(= lo_val 이상 hi_val 이하)에 든 원소 수가 됩니다.

Python
# algorithms/exercises_d3.py
def count_in_range(arr, lo_val, hi_val):
    return upper_bound(arr, hi_val) - lower_bound(arr, lo_val)

왜 끝에 upper_bound를 쓰는지가 핵심이에요. hi_val '이하'를 포함해야 하니, hi_val과 같은 값들까지 세어야 합니다. 그래서 '초과가 처음인 위치'(같은 값 다음)를 잡는 upper_bound라야 hi_val까지 폭에 들어와요. arr = [1, 2, 2, 3, 5, 7]에서 count_in_range(arr, 2, 5)를 따라가 봅시다.

텍스트
 count_in_range([1, 2, 2, 3, 5, 7], 2, 5)

   index:  0   1   2   3   4   5
   값:     1   2   2   3   5   7
              └── 2 이상 ~ 5 이하 ──┘

   lower_bound(2) = 1      2 이상이 처음인 위치
   upper_bound(5) = 5      5 초과가 처음인 위치 (index 5, 값 7)
   개수 = 5 - 1 = 4        2, 2, 3, 5

경계 두 개를 빼니 4가 나옵니다. 범위 밖(count_in_range(arr, 8, 9))이면 두 경계가 똑같이 6으로 나와 개수 0이에요. 빅오는 경계 탐색 두 번이라 O(log n), 공간은 O(1)입니다. 하나씩 세는 O(n)과 비교하면 큰 배열에서 확연히 빨라요.

💡 튜터의 한마디: 만약 "lo_val 초과 hi_val 미만" 같은 열린 구간이면 어떻게 바뀔까요? 시작을 upper_bound(lo_val)로, 끝을 lower_bound(hi_val)로 바꾸면 됩니다. '이상/초과', '이하/미만'을 lower와 upper 어느 쪽에 매칭할지가 이 문제의 전부예요. 부등호를 그림으로 그려 놓고 경계를 고르면 헷갈리지 않습니다.


🎯 [과제 3 예시답안] 예산 배정 상한 구하기

채점 포인트

항목 확인
결정 문제 전환 "상한 cap이면 배정 총합이 total 이내인가?"로 바꾼다
단조성 cap이 커지면 배정 총합도 커진다 → 되는 최대 cap을 이분
예외 처리 요청 합이 total 이내면 상한 불필요 → max(requests)
빅오 O(log(max) × N)·공간 O(1)

풀이 예시

매개변수 탐색입니다. "최대 상한액"을 곧장 구하는 대신, "상한을 cap으로 두면 배정 총합이 total 이내인가?"라는 결정 문제로 바꿔요. 각 지방엔 min(요청액, cap)만큼 배정하니, 판정은 sum(min(r, cap)) <= total입니다. cap이 커지면 배정 총합도 커지니(단조), '되는 것 중 최대 cap'을 이진 탐색으로 찾습니다.

Python
# algorithms/exercises_d3.py
def max_budget_cap(requests, total):
    if sum(requests) <= total:
        return max(requests)
    lo, hi = 0, max(requests)
    best = 0
    while lo <= hi:
        mid = (lo + hi) // 2
        allocated = sum(min(r, mid) for r in requests)
        if allocated <= total:                   # 예산 안에 든다 → 상한을 더 올려 본다
            best = mid
            lo = mid + 1
        else:                                    # 넘는다 → 상한을 낮춘다
            hi = mid - 1
    return best

맨 앞의 if가 예외 처리예요. 모든 요청을 다 들어줘도 예산이 남으면 상한을 걸 이유가 없으니, 가장 큰 요청액을 바로 돌려줍니다. 이 줄이 없으면 상한이 최대 요청액에 갇혀 엉뚱한 답이 나와요. 본체는 오늘 배운 랜선 자르기와 뼈대가 똑같습니다. 답의 범위 [0, max(requests)]를 이분하고, '되면(allocated <= total)' 상한을 올리며 best에 기록하죠. max_budget_cap([120, 110, 140, 150], 485)의 경계를 봅시다.

텍스트
 상한 cap을 [0, 150]에서 이분 — cap이 커지면 배정 총합도 큰다(단조)

   cap=127  120+110+127+127 = 484 <= 485   된다(O), 기록 best=127, 올린다
   cap=128  120+110+128+128 = 486 >  485   안 된다(X), 내린다
             └ O와 X가 갈리는 경계 = 127 (정답)

127에서는 배정 총합이 484로 예산 485 안에 들지만, 128이면 486으로 넘칩니다. 그 경계인 127이 답이에요. 빅오는 답의 범위가 최대 max(requests)라 이진 탐색 O(log(max))에, 매번 지방 N곳을 훑어 판정하니 전체 O(log(max) × N)입니다.

💡 튜터의 한마디: 랜선 자르기와 판정 함수만 다르고 뼈대가 완전히 같다는 걸 느끼셨나요? 랜선은 sum(c // mid), 예산은 sum(min(r, mid))로 판정 한 줄만 바뀝니다. "최댓값을 최대로 / 최솟값을 최소로"가 보이면 이 틀을 꺼내 판정 함수부터 설계하는 습관을 들이세요. 백준 예산·랜선 자르기·나무 자르기·공유기 설치가 전부 이 한 틀입니다.


🤔 [생각해볼 주제 1] 이진 탐색은 왜 정렬을 전제로 할까, 그리고 언제 정렬 비용이 아깝지 않을까

문제 상황 요약

이진 탐색은 O(log n)으로 빠르지만 정렬돼 있어야만 씁니다. 정렬을 먼저 하면 O(n log n)이 드는데, 이건 선형 탐색 O(n)보다 오히려 비싸요. "정렬 후 이진 탐색"이 "그냥 선형 탐색"을 이기는 건 정확히 언제일까요?

튜터의 가이드 및 해설

이진 탐색이 절반을 버릴 수 있는 근거는 '정렬돼 있다'는 사실 하나입니다. 정렬돼 있으니 가운데보다 크면 왼쪽은 볼 것도 없죠. 그 근거가 없으면(뒤죽박죽이면) 어느 절반도 버릴 수 없어 이진 탐색이 성립하지 않아요.

그래서 비용을 '탐색 횟수'로 나눠 봐야 합니다. 배열에서 딱 한 번만 찾을 거라면, 정렬 비용 O(n log n)을 내느니 그냥 한 번 훑는 선형 탐색 O(n)이 더 쌉니다. 정렬해서 O(log n)으로 찾아 봐야, 정렬에 이미 O(n log n)을 썼으니 손해예요.

반대로 같은 배열에서 여러 번 찾을 거라면 이야기가 뒤집힙니다. 한 번 정렬(O(n log n))해 두고 이후 매 탐색을 O(log n)에 하면, 탐색을 Q번 할 때 전체가 O(n log n + Q log n)이에요. 선형 탐색을 Q번 반복하는 O(Q·n)과 비교하면, Q가 커질수록 정렬해 두는 쪽이 압도적으로 유리해집니다. 대략 "찾는 횟수가 log n을 넘어서면 정렬이 이득"이라는 감각이면 충분해요.

🎯 코테·면접에선 이렇게 나온다

"이진 탐색 쓰려고 정렬부터 하면 O(n log n)인데 손해 아니냐"는 함정성 질문이 있어요. "한 번 찾으면 손해, 여러 번 반복 조회하면 정렬 비용이 분산돼 이득"이라고 답하면 됩니다. 쿼리가 여러 개인 문제(예: "M개의 수가 배열에 있는지 각각 판정하라")가 정확히 이 상황이라, 정렬 한 번 + 이진 탐색 M번이 정석이에요.

💡 실무에선

이 트레이드오프는 자료구조 선택으로 이어집니다. "정렬해서 이진 탐색"은 데이터가 거의 안 바뀌고 조회가 많을 때 좋아요. 반대로 삽입·삭제가 잦으면 정렬을 유지하는 비용이 커져, 해시(평균 O(1) 조회)나 균형 트리가 낫습니다. "한 번 정렬해 두고 반복 조회하나, 계속 바뀌나"가 실무의 갈림길이에요.


🤔 [생각해볼 주제 2] 경계 탐색은 왜 닫힌 구간이 아니라 반열린 구간으로 짤까

문제 상황 요약

기본 이진 탐색은 닫힌 구간 [lo, hi]lo <= hi로 짰고, 경계 탐색(lower/upper bound)은 반열린 구간 [lo, hi)lo < hi, hi = len(arr)로 짰습니다. 왜 굳이 두 스타일을 나눴을까요?

튜터의 가이드 및 해설

경계 탐색은 '값의 index'가 아니라 '끼워 넣을 위치'를 답으로 냅니다. 그리고 그 위치는 배열 맨 끝, 즉 len(arr)일 수 있어요(모든 값이 target보다 작을 때). 닫힌 구간 [0, len-1]로는 이 '맨 끝 다음' 위치를 표현할 수가 없습니다. 그래서 hilen(arr)로 두는 반열린 구간이 자연스러워요.

핵심은 '불변식(invariant)'입니다. 반열린 구간 [lo, hi)로 짜면 "답은 항상 [lo, hi) 안에 있다"는 불변식이 반복 내내 깨지지 않아요. arr[mid] < target이면 mid는 답이 될 수 없으니 lo = mid + 1로 확실히 버리고, 아니면 mid도 후보라 hi = mid로 남깁니다. lohi가 만나는 순간 구간이 정확히 비면서 답 하나로 수렴해요. 버릴 때 hi-1을 붙이지 않는 것도 이 불변식 때문입니다.

경계 탐색을 굳이 닫힌 구간으로 짜면 실수가 잦아집니다. hilen-1로 두면 맨 끝 위치를 놓치고, mid를 버릴 때 +1/-1을 붙일지 말지가 헷갈려 무한 루프나 하나 어긋난 답이 나기 쉬워요. 그래서 "값 찾기는 닫힌 구간, 경계 찾기는 반열린 구간"으로 두 틀을 아예 분리해 외우는 편이 안전합니다.

🎯 코테·면접에선 이렇게 나온다

"이진 탐색에서 <=<, mid-1mid 중 뭘 쓰냐"는 경계 감각을 보는 질문이에요. "구간을 닫힌으로 잡았냐 반열린으로 잡았냐에 따라 조건이 정해지고, 나는 값 찾기는 닫힌 구간, 경계 찾기는 반열린 구간으로 통일한다"고 답하면 됩니다. "불변식을 정해 두면 경계를 안 틀린다"는 시각을 보이면 좋아요.

💡 실무에선

실무에선 대부분 검증된 라이브러리(bisect)를 쓰니 직접 경계를 짤 일은 드뭅니다. 하지만 그 라이브러리가 반열린 구간 규약으로 만들어졌다는 걸 알아야, bisect_leftbisect_right가 왜 그렇게 동작하는지 이해하고 골라 쓸 수 있어요. 직접 짜는 순간(매개변수 탐색 등)엔 이 불변식 감각이 버그를 막아 줍니다.


🤔 [생각해볼 주제 3] 아무 최적화 문제나 매개변수 탐색으로 풀 수 있을까

문제 상황 요약

매개변수 탐색은 "최대/최소 답을 이분한다"는 강력한 틀이지만 아무 문제에나 통하지는 않습니다. 이 틀이 성립하려면 판정 함수가 반드시 '단조로워야' 해요. 단조성이 깨지면 어떤 일이 벌어질까요?

튜터의 가이드 및 해설

매개변수 탐색의 심장은 단조성입니다. "L이면 된다(O)"일 때, L보다 작은(또는 큰) 값들이 전부 같은 답이어야 절반을 통째로 버릴 수 있어요. 랜선 자르기가 그렇죠. 길이 L로 11개가 나오면, L보다 짧은 길이는 무조건 11개 이상 나옵니다. 그래서 O 구간과 X 구간이 딱 한 번만 갈리고, 그 경계를 이진 탐색으로 찾아요.

만약 단조성이 깨져서 "L이면 되는데 L보다 작은 어떤 값 L'은 안 된다"가 가능하다면, O와 X가 여기저기 뒤섞입니다. 그러면 가운데를 판정해서 O가 나와도 "왼쪽을 다 버려도 되나?"를 확신할 수 없어요. 버린 절반 안에 정답이 숨어 있을 수 있으니까요. 이진 탐색은 '버린 절반엔 답이 없다'는 보장 위에서만 도는데, 그 보장이 사라지면 엉뚱한 답을 내거나 정답을 놓칩니다.

그래서 문제를 만나면 "판정 함수가 단조로운가"부터 확인해야 합니다. "이 값에서 되면, 더 작은(또는 큰) 값에서도 반드시 되는가?"를 자문해 O가 참이면 매개변수 탐색이 통해요. 반대로 판정이 들쭉날쭉하면(예: 특정 조합에서만 성립하는 문제) 이진 탐색이 아니라 완전탐색이나 다른 패러다임을 꺼내야 합니다.

🎯 코테·면접에선 이렇게 나온다

"이 문제를 매개변수 탐색으로 풀 수 있나?"의 판단 근거가 바로 단조성이에요. "답을 하나 정했을 때 예/아니오 판정이 쉽고, 그 판정이 단조로우면 매개변수 탐색"이라고 답하면 됩니다. 랜선·나무·예산이 왜 다 되는지를 "판정이 단조라서"로 설명하면 원리를 꿰뚫은 인상을 줘요.

💡 실무에선

단조성을 찾는 눈은 실무 최적화에도 쓰입니다. "임계값을 올리면 성능은 좋아지는데 비용도 는다" 같은 트레이드오프에서, 조건을 만족하는 최적 임계값을 이분으로 찾는 식이에요. 서버 타임아웃·배치 크기·리소스 한도를 튜닝할 때 "이 값이면 조건을 만족하나?"가 단조롭다면, 무작정 다 넣어 보는 대신 이진 탐색으로 몇 번 만에 최적점을 좁힐 수 있습니다.

전체 목록 자료구조·알고리즘