이진 탐색 불변식과 경계 탐색 패턴
이진 탐색 문제는 값 찾기보다 경계 찾기 형태로 더 자주 나옵니다. 처음에는 코드가 짧아 보여도 구간 규칙을 섞는 순간 오프바이원과 무한 루프가 바로 생깁니다. 그래서 템플릿 암기보다 불변식 고정이 우선입니다.
해결 순서는 단순합니다. 구간 표현을 하나로 고정하고, 판정 함수의 단조성을 확인한 뒤, 같은 규칙으로 끝까지 밀어붙입니다. 아래에서는 그 과정을 경계 탐색 기준으로 정리합니다.
핵심 개념 맵: 이분 탐색 불변식
이진 탐색은 코드 자체보다 전제 세 가지를 정확히 고정하면 안정적으로 구현할 수 있습니다.
우선 본인 코드에서 쓰는 구간 표기([], [))를 한 줄로 적어 두고 시작하세요.
단조성은 인덱스가 커질수록 판정 결과가 한 번만 바뀌는 성질입니다.
ok(x)가 false...false,true...true 형태(또는 반대 형태)를 유지해야 이진 탐색 경계가 생깁니다.
예를 들어 처리 속도 v가 커질수록 시간 내 완료 가능이 한 번만 false -> true로 바뀌면 경계 탐색이 가능합니다.
구간 불변식은 반복 중에도 "정답이 현재 구간 안에 있다"는 약속입니다.
반복문 각 단계가 끝난 뒤에도 정답 후보 집합이 탐색 구간에 남아 있어야 무한 루프와 오프바이원을 피할 수 있습니다.
[lo, hi) 규칙이라면 반복이 끝날 때까지 lo <= ans < hi가 계속 유지되어야 합니다.
판정 함수는 후보 값이 정답 조건을 만족하는지 알려주는 결정 함수입니다.
가능/불가능 판정이 단조성을 가지면 판정 함수 기반으로 최소값 또는 최대값 경계를 찾을 수 있습니다.
ok(mid)=mid 속도로 시간 내 완료 가능한가처럼 정의하면 최소 가능 속도를 이진 탐색으로 계산할 수 있습니다.
경계 탐색 설계가 실무에서 중요한 이유
이진 탐색 실패의 대부분은 알고리즘을 몰라서가 아닙니다.
lo, hi, mid 갱신 규칙이 서로 다른 템플릿이 섞여
무한 루프나 오프바이원 오류가 생겨서 실패합니다.
또한 문제는 정렬 배열 탐색처럼 보이지 않아도 가능/불가능 판정 함수로 바꾸면 이진 탐색으로 풀리는 경우가 많습니다. 이 관점 차이가 난이도 차이를 만듭니다.
경계값 오답 재현으로 재확인하기
오답 -> 디버깅 -> 교정 -> 검증 순서를 먼저 고정하고 예제를 따라가면 경계 버그를 빠르게 줄일 수 있습니다.
lower_bound를 작은 배열로 직접 추적합니다.
- 배열:
[1, 2, 2, 2, 4, 7] - 타깃:
2
mid값을 보며arr[mid] < target이면 오른쪽으로 이동합니다.- 그렇지 않으면
mid도 후보이므로 왼쪽 경계를 줄입니다. - 루프 종료 시
lo가 첫 번째>= target위치가 됩니다. - 중요한 기준은 "답은 항상
[lo, hi)안에 있다"는 불변식 유지입니다.
이분 탐색 적용 관찰 기준
아래 질문에 모두 예라고 답할 수 있으면 이진 탐색 후보입니다.
- 탐색 구간에 순서가 존재하는가?
- 판정 결과가 단조한가?
- 구간을 절반씩 버리는 것이 안전한가?
- 경계값을 반환하는 요구인가?
반대로 단조성이 없다면 이진 탐색을 강행하면 안 됩니다.
경계 탐색: lower_bound 구현
문제 의도: 첫 번째 >= target 인덱스를 찾는 이진 탐색 템플릿을 고정합니다.
입력 예시: arr=[1,2,2,2,4,7], target=2
출력 예시: 1
#include <iostream>
#include <vector>
using namespace std;
int lowerBound(const vector<int>& arr, int target) {
int lo = 0;
int hi = static_cast<int>(arr.size()); // [lo, hi)
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (arr[mid] < target) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo;
}
int main() {
vector<int> nums = {1, 2, 2, 2, 4, 7};
cout << lowerBound(nums, 2) << "\n";
cout << lowerBound(nums, 3) << "\n";
return 0;
}import java.util.List;
public class Main {
static int lowerBound(List<Integer> arr, int target) {
int lo = 0;
int hi = arr.size(); // [lo, hi)
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (arr.get(mid) < target) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo;
}
public static void main(String[] args) {
List<Integer> nums = List.of(1, 2, 2, 2, 4, 7);
System.out.println(lowerBound(nums, 2));
System.out.println(lowerBound(nums, 3));
}
}def lower_bound(arr, target):
lo, hi = 0, len(arr) # [lo, hi)
while lo < hi:
mid = lo + (hi - lo) // 2
if arr[mid] < target:
lo = mid + 1
else:
hi = mid
return lo
nums = [1, 2, 2, 2, 4, 7]
print(lower_bound(nums, 2))
print(lower_bound(nums, 3))
# 출력
# 1
# 4fn lower_bound(arr: &[i32], target: i32) -> usize {
let mut lo: usize = 0;
let mut hi: usize = arr.len(); // [lo, hi)
while lo < hi {
let mid = lo + (hi - lo) / 2;
if arr[mid] < target {
lo = mid + 1;
} else {
hi = mid;
}
}
lo
}
fn main() {
let nums = vec![1, 2, 2, 2, 4, 7];
println!("{}", lower_bound(&nums, 2));
println!("{}", lower_bound(&nums, 3));
}lower_bound 수렴 단계 체크 포인트
arr[mid] < target이면 답은 오른쪽에 있으므로 lo = mid + 1입니다.
거짓이면 mid도 답 후보이므로 hi = mid로 줄여야 합니다.
이 규칙은 "답은 항상 [lo, hi) 안에 있다"는 불변식을 유지합니다.
- 평균 시간복잡도:
O(log N) - 최악 시간복잡도:
O(log N) - 공간복잡도:
O(1)
경계 탐색: 첫 번째 참 위치 찾기
문제 의도: 단조 판정 함수 기반으로 경계값을 찾는 일반 이진 탐색 패턴을 학습합니다.
입력 예시: 단조 불리언 판정 시퀀스
출력 예시: 첫 true 위치 인덱스
#include <iostream>
using namespace std;
bool isOk(int x, int threshold) {
return x * x >= threshold;
}
int firstTrue(int lo, int hi, int threshold) {
// [lo, hi) 범위에서 처음 true가 되는 인덱스
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (isOk(mid, threshold)) {
hi = mid;
} else {
lo = mid + 1;
}
}
return lo;
}
int main() {
int threshold = 37;
int ans = firstTrue(0, 100, threshold);
cout << ans << " " << ans * ans << "\n";
return 0;
}public class Main {
static boolean isOk(int x, int threshold) {
return x * x >= threshold;
}
static int firstTrue(int lo, int hi, int threshold) {
// [lo, hi) 범위에서 처음 true가 되는 인덱스
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (isOk(mid, threshold)) {
hi = mid;
} else {
lo = mid + 1;
}
}
return lo;
}
public static void main(String[] args) {
int threshold = 37;
int ans = firstTrue(0, 100, threshold);
System.out.println(ans + " " + (ans * ans));
}
}def first_true(lo, hi, ok):
# [lo, hi) 범위에서 처음 True가 되는 인덱스를 찾음
while lo < hi:
mid = lo + (hi - lo) // 2
if ok(mid):
hi = mid
else:
lo = mid + 1
return lo
threshold = 37
def ok(x):
return x * x >= threshold
ans = first_true(0, 100, ok)
print(ans, ans * ans)
# 출력: 7 49fn is_ok(x: i32, threshold: i32) -> bool {
x * x >= threshold
}
fn first_true(mut lo: i32, mut hi: i32, threshold: i32) -> i32 {
// [lo, hi) 범위에서 처음 true가 되는 인덱스
while lo < hi {
let mid = lo + (hi - lo) / 2;
if is_ok(mid, threshold) {
hi = mid;
} else {
lo = mid + 1;
}
}
lo
}
fn main() {
let threshold = 37;
let ans = first_true(0, 100, threshold);
println!("{} {}", ans, ans * ans);
}firstTrue 판정 함수 체크 포인트
이 패턴은 배열이 없어도 적용됩니다. 정답 공간 자체를 이진 탐색하는 방식이므로 파라메트릭 서치의 기반 템플릿으로 재사용할 수 있습니다.
중요한 기준은 ok(mid)가 단조해야 한다는 점입니다.
단조성이 깨지면 절반 제거 논리가 성립하지 않습니다.
- 평균 시간복잡도:
O(log R) - 최악 시간복잡도:
O(log R) - 공간복잡도:
O(1)
경계 탐색: upper_bound 변형
문제 의도: 첫 번째 > target 위치를 찾아 구간 길이 계산에 활용합니다.
입력 예시: arr=[1,2,2,2,4,7], target=2
출력 예시: 4
#include <iostream>
#include <vector>
using namespace std;
int upperBound(const vector<int>& arr, int target) {
int lo = 0;
int hi = static_cast<int>(arr.size());
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (arr[mid] <= target) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo;
}
int main() {
vector<int> nums = {1, 2, 2, 2, 4, 7};
cout << upperBound(nums, 2) << "\n";
cout << upperBound(nums, 5) << "\n";
return 0;
}import java.util.List;
public class Main {
static int upperBound(List<Integer> arr, int target) {
int lo = 0;
int hi = arr.size();
while (lo < hi) {
int mid = lo + (hi - lo) / 2;
if (arr.get(mid) <= target) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo;
}
public static void main(String[] args) {
List<Integer> nums = List.of(1, 2, 2, 2, 4, 7);
System.out.println(upperBound(nums, 2));
System.out.println(upperBound(nums, 5));
}
}def upper_bound(arr, target):
lo, hi = 0, len(arr)
while lo < hi:
mid = lo + (hi - lo) // 2
if arr[mid] <= target:
lo = mid + 1
else:
hi = mid
return lo
nums = [1, 2, 2, 2, 4, 7]
print(upper_bound(nums, 2))
print(upper_bound(nums, 5))
# 출력
# 4
# 5fn upper_bound(arr: &[i32], target: i32) -> usize {
let mut lo: usize = 0;
let mut hi: usize = arr.len();
while lo < hi {
let mid = lo + (hi - lo) / 2;
if arr[mid] <= target {
lo = mid + 1;
} else {
hi = mid;
}
}
lo
}
fn main() {
let nums = vec![1, 2, 2, 2, 4, 7];
println!("{}", upper_bound(&nums, 2));
println!("{}", upper_bound(&nums, 5));
}upper_bound 초과 구간 체크 포인트
upper_bound는 target 초과가 처음 나오는 위치를 반환합니다.
조건식 하나만 바뀌어도 의미가 완전히 달라지므로
lower_bound와 섞어 쓰지 않도록 함수명을 분리하는 편이 좋습니다.
- 시간복잡도:
O(log N) - 공간복잡도:
O(1)
lower/upper 구현 비용 상쇄 지점
이진 탐색의 장점과 비용은 다음과 같습니다.
- 장점:
O(log N)탐색, 경계 문제에 강함 - 비용: 단조성 증명이 필요함
- 장점: 구현이 짧아 재사용 쉬움
- 비용: 경계 규칙 혼합 시 디버깅 난이도 급상승
짧은 코드일수록 불변식 주석이 중요합니다.
이분 탐색 로그 실수 포인트
실전에서 자주 나오는 실패 유형입니다.
[lo, hi]템플릿과[lo, hi)템플릿 혼용mid갱신 후 같은 구간을 다시 선택해 무한 루프 발생- 빈 배열/전부 작은 값/전부 큰 값 테스트 누락
- 단조성 가정이 틀렸는데 검증 없이 적용
반례 세트에는 최소 아래 케이스를 포함해야 합니다.
- 길이 0 배열
- 길이 1 배열
- 중복 다수 배열
- 답이 양 끝 경계에 있는 경우
탐색 연산 비용 모델 정리
입력 분포가 치우치면 같은 알고리즘이라도 성능 체감이 달라지므로, 적용 관점을 데이터 특성과 함께 기록해 두는 편이 안전합니다.
| 패턴 | 평균 시간 | 최악 시간 | 공간 |
|---|---|---|---|
| 배열 탐색형 | O(log N) | O(log N) | O(1) |
| 판정 함수형 | O(log R) | O(log R) | O(1) |
R은 답의 탐색 범위 크기이며,
판정 함수 비용이 크면 전체 시간은 O(log R * check_cost)가 됩니다.
수렴 실패 방지 포인트
- 파이썬에서는
bisect표준 라이브러리를 우선 검토하는 편이 안전합니다. - 커스텀 구현이 필요하면 반환 의미(
첫 >=,첫 >)를 함수명에 포함하세요. - 실수 범위 탐색에서는 종료 조건(
eps)을 명확히 두지 않으면 무한 루프가 날 수 있습니다. - 정렬 전제 데이터에 대해 삽입/삭제가 잦다면 트리 기반 구조를 검토해야 합니다.
탐색 구현 전후 점검 목록
- 구간 표현(
[lo, hi)또는[lo, hi])을 문서와 코드에 고정했는가 mid갱신 규칙과 반환 의미를 일치시켰는가- 경계 반례(빈 배열, 단일 원소, 전부 동일 값)를 테스트했는가
- 판정 함수 단조성을 증명 또는 확인했는가
경계 탐색 운용 정리
이진 탐색은 코드 한 줄 트릭이 아니라 경계 증명 도구입니다. 불변식을 명확히 말할 수 있다면 대부분의 변형 문제도 같은 구조로 안정적으로 해결할 수 있습니다.