이진 탐색 (Binary Search)

전화번호부에서 “홍길동”을 찾는다고 상상해보세요. 처음부터 하나씩 넘기지 않고, 중간쯤 펴서 확인한 다음 앞쪽 또는 뒤쪽으로 이동하죠?

이것이 바로 이진 탐색입니다!

저도 처음에는 “왜 이게 빠른 거지?”라고 의문이었는데, 실제로 1,000,000개의 데이터에서 단 20번의 비교로 원하는 값을 찾는 것을 보고 깜짝 놀랐습니다.

왜 이진 탐색을 배워야 할까요?

1. 엄청난 성능 향상

// 1,000,000개의 데이터에서 검색
// 선형 탐색: 평균 500,000번 비교
// 이진 탐색: 최대 20번 비교

// 25,000배 빠릅니다! 🚀

2. 실무에서 매우 자주 사용

  • 데이터베이스 인덱스
  • 파일 시스템
  • 자동완성 기능
  • 게임 AI (A* 알고리즘의 기초)

3. 많은 문제의 기초

이진 탐색을 이해하면 다음을 배우기 쉽습니다.

  • 이진 탐색 트리 (BST)
  • 분할 정복 알고리즘
  • 최적화 문제

⚠️ 중요한 전제조건

이진 탐색은 정렬된 배열에서만 동작합니다!

// ❌ 정렬 안 됨 - 이진 탐색 불가
const unsorted = [5, 2, 8, 1, 9];
binarySearch(unsorted, 8);  // 잘못된 결과!

// ✅ 정렬됨 - 이진 탐색 가능
const sorted = [1, 2, 5, 8, 9];
binarySearch(sorted, 8);  // 올바른 결과: 인덱스 3

어떻게 동작하나요?

책 페이지 찾기 비유

1000페이지 책에서 745페이지를 찾는다고 해보세요:

1단계: 중간(500페이지) 펼침
→ 745는 500보다 크니까 뒤쪽으로

2단계: 중간(750페이지) 펼침
→ 745는 750보다 작으니까 앞쪽으로

3단계: 중간(625페이지) 펼침
→ 745는 625보다 크니까 뒤쪽으로

... 계속 반으로 나누며 탐색 ...

10번 만에 찾음! (1000페이지를 직접 넘기면 745번 필요)

단계별 시각화

const arr = [1, 3, 5, 7, 9, 11, 13, 15, 17, 19];
const target = 13;

// 1단계: 전체 범위
[1, 3, 5, 7, 9, 11, 13, 15, 17, 19]
           
        mid=9 (인덱스 4)
        13 > 9  오른쪽으로

// 2단계: 오른쪽 절반
[11, 13, 15, 17, 19]
     
   mid=15 (인덱스 7)
   13 < 15  왼쪽으로

// 3단계: 좁혀진 범위
[11, 13]
     
   mid=13 (인덱스 6)
   13 == 13  찾음! 

코드 구현

JavaScript 기본 구현

function binarySearch(arr, target) {
  let left = 0;              // 왼쪽 경계
  let right = arr.length - 1; // 오른쪽 경계

  while (left <= right) {
    // 중간 인덱스 계산
    const mid = Math.floor((left + right) / 2);

    if (arr[mid] === target) {
      return mid;  // 찾음!
    }

    if (arr[mid] < target) {
      left = mid + 1;  // 오른쪽 절반 탐색
    } else {
      right = mid - 1; // 왼쪽 절반 탐색
    }
  }

  return -1;  // 못 찾음
}

// 사용 예시
const numbers = [1, 3, 5, 7, 9, 11, 13];
console.log(binarySearch(numbers, 7));   // 3
console.log(binarySearch(numbers, 10));  // -1 (없음)

TypeScript 제네릭 버전

function binarySearch<T>(
  arr: T[],
  target: T,
  compare: (a: T, b: T) => number = (a, b) => Number(a) - Number(b)
): number {
  let left = 0;
  let right = arr.length - 1;

  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    const comparison = compare(arr[mid], target);

    if (comparison === 0) {
      return mid;
    } else if (comparison < 0) {
      left = mid + 1;
    } else {
      right = mid - 1;
    }
  }

  return -1;
}

// 객체 배열에서 사용
interface User {
  id: number;
  name: string;
}

const users: User[] = [
  { id: 1, name: 'Alice' },
  { id: 3, name: 'Bob' },
  { id: 5, name: 'Charlie' }
];

const index = binarySearch(
  users,
  { id: 3, name: '' } as User,
  (a, b) => a.id - b.id
);

console.log(users[index]);  // { id: 3, name: 'Bob' }

재귀 버전

function binarySearchRecursive(arr, target, left = 0, right = arr.length - 1) {
  // 기저 조건: 범위가 없으면 못 찾음
  if (left > right) {
    return -1;
  }

  const mid = Math.floor((left + right) / 2);

  if (arr[mid] === target) {
    return mid;
  }

  if (arr[mid] < target) {
    // 오른쪽 절반 재귀 탐색
    return binarySearchRecursive(arr, target, mid + 1, right);
  } else {
    // 왼쪽 절반 재귀 탐색
    return binarySearchRecursive(arr, target, left, mid - 1);
  }
}

디버깅 버전

function binarySearchDebug(arr, target) {
  let left = 0;
  let right = arr.length - 1;
  let step = 1;

  console.log(`\n목표: ${target}`);
  console.log(`배열: [${arr}]\n`);

  while (left <= right) {
    const mid = Math.floor((left + right) / 2);

    console.log(`단계 ${step}:`);
    console.log(`  범위: [${left}, ${right}]`);
    console.log(`  중간: arr[${mid}] = ${arr[mid]}`);

    if (arr[mid] === target) {
      console.log(`  ✅ 찾음! 인덱스 ${mid}\n`);
      return mid;
    }

    if (arr[mid] < target) {
      console.log(`  ${arr[mid]} < ${target} → 오른쪽으로\n`);
      left = mid + 1;
    } else {
      console.log(`  ${arr[mid]} > ${target} → 왼쪽으로\n`);
      right = mid - 1;
    }

    step++;
  }

  console.log(`❌ 못 찾음\n`);
  return -1;
}

// 실행
binarySearchDebug([1, 3, 5, 7, 9, 11, 13, 15], 11);

출력:

목표: 11
배열: [1, 3, 5, 7, 9, 11, 13, 15]

단계 1:
  범위: [0, 7]
  중간: arr[3] = 7
  7 < 11 → 오른쪽으로

단계 2:
  범위: [4, 7]
  중간: arr[5] = 11
  ✅ 찾음! 인덱스 5

시간 복잡도: O(log n)

왜 O(log n)일까요?

매번 탐색 범위가 절반으로 줄어듭니다.

n = 1,000,000개
1회: 500,000개
2회: 250,000개
3회: 125,000개
...
20회: 1개

log₂(1,000,000) ≈ 20번

실제 비교

데이터 크기 선형 탐색 이진 탐색 차이
10 5번 3번 1.7배
100 50번 7번 7배
1,000 500번 10번 50배
1,000,000 500,000번 20번 25,000배
// 성능 비교
function compare(arr, target) {
  console.time('선형 탐색');
  arr.indexOf(target);
  console.timeEnd('선형 탐색');

  console.time('이진 탐색');
  binarySearch(arr, target);
  console.timeEnd('이진 탐색');
}

const big = Array.from({length: 1000000}, (_, i) => i);
compare(big, 999999);

// 선형 탐색: ~15ms
// 이진 탐색: ~0.001ms

변형: Lower Bound & Upper Bound

Lower Bound

target 이상인 첫 번째 원소의 인덱스

function lowerBound(arr, target) {
  let left = 0;
  let right = arr.length;

  while (left < right) {
    const mid = Math.floor((left + right) / 2);

    if (arr[mid] < target) {
      left = mid + 1;
    } else {
      right = mid;
    }
  }

  return left;
}

const arr = [1, 2, 2, 2, 3, 5];
console.log(lowerBound(arr, 2));  // 1 (첫 번째 2의 인덱스)
console.log(lowerBound(arr, 4));  // 5 (4 이상인 첫 원소는 5)

Upper Bound

target보다 큰 첫 번째 원소의 인덱스

function upperBound(arr, target) {
  let left = 0;
  let right = arr.length;

  while (left < right) {
    const mid = Math.floor((left + right) / 2);

    if (arr[mid] <= target) {
      left = mid + 1;
    } else {
      right = mid;
    }
  }

  return left;
}

const arr = [1, 2, 2, 2, 3, 5];
console.log(upperBound(arr, 2));  // 4 (2보다 큰 첫 원소는 3)

실전 활용: 범위 찾기

// [start, end] 범위에 속하는 원소 개수
function countInRange(arr, start, end) {
  const lower = lowerBound(arr, start);
  const upper = upperBound(arr, end);
  return upper - lower;
}

const scores = [60, 70, 75, 80, 85, 90, 95];
console.log(countInRange(scores, 70, 90));  // 5개 (70~90점)

함정과 주의사항

함정 1: 정렬되지 않은 배열

// ❌ 잘못된 사용
const unsorted = [5, 2, 8, 1, 9];
binarySearch(unsorted, 8);  // 엉뚱한 결과!

// ✅ 올바른 사용
const sorted = [...unsorted].sort((a, b) => a - b);
binarySearch(sorted, 8);

함정 2: mid 계산 시 오버플로우

// ❌ 큰 배열에서 문제 발생 가능
const mid = (left + right) / 2;  // left + right가 너무 크면?

// ✅ 안전한 방법
const mid = Math.floor(left + (right - left) / 2);

// 또는 (JavaScript는 큰 수도 괜찮음)
const mid = Math.floor((left + right) / 2);

함정 3: <= vs <

// 이것이 가장 헷갈리는 부분!

// ❌ 무한 루프 가능
while (left < right) {  // right가 length일 때 문제
  const mid = Math.floor((left + right) / 2);
  if (arr[mid] === target) return mid;
  // ...
}

// ✅ 일반적으로 안전
while (left <= right) {
  const mid = Math.floor((left + right) / 2);
  if (arr[mid] === target) return mid;
  if (arr[mid] < target) {
    left = mid + 1;  // mid는 제외
  } else {
    right = mid - 1;  // mid는 제외
  }
}

실전 활용 예시

1. 자동완성

// 단어 사전에서 접두사로 시작하는 단어 찾기
function autocomplete(dictionary, prefix) {
  const start = lowerBound(dictionary, prefix);

  const results = [];
  for (let i = start; i < dictionary.length; i++) {
    if (!dictionary[i].startsWith(prefix)) break;
    results.push(dictionary[i]);
  }

  return results;
}

const words = ['apple', 'application', 'apply', 'banana', 'band'];
console.log(autocomplete(words, 'app'));
// ['apple', 'application', 'apply']

2. 최적값 찾기

// "최소 몇 시간을 공부해야 90점 이상을 받을까?"
function minimumHours(scores, targetScore) {
  // scores[i] = i시간 공부했을 때의 예상 점수
  // scores는 오름차순 정렬되어 있음

  const index = lowerBound(scores, targetScore);
  return index < scores.length ? index : -1;
}

const studyScores = [60, 70, 75, 80, 85, 90, 92, 95];
console.log(minimumHours(studyScores, 90));  // 5시간

3. 중복 원소 개수

function countOccurrences(arr, target) {
  const lower = lowerBound(arr, target);
  const upper = upperBound(arr, target);
  return upper - lower;
}

const numbers = [1, 2, 2, 2, 3, 3, 5];
console.log(countOccurrences(numbers, 2));  // 3개
console.log(countOccurrences(numbers, 4));  // 0개

연습 문제

1. 기본 구현

// 이진 탐색을 직접 구현해보세요
function binarySearch(arr, target) {
  // 여기에 코드 작성
}

const arr = [1, 3, 5, 7, 9, 11, 13];
console.log(binarySearch(arr, 7));   // 3
console.log(binarySearch(arr, 10));  // -1

2. 첫 등장 위치

// target이 처음 등장하는 인덱스 찾기
function findFirst(arr, target) {
  // 힌트: Lower Bound 활용
}

const arr = [1, 2, 2, 2, 3, 5];
console.log(findFirst(arr, 2));  // 1

3. 회전된 배열에서 최소값

// 회전된 정렬 배열에서 최소값 찾기
// [4, 5, 6, 7, 0, 1, 2] → 0

function findMin(arr) {
  // 힌트: 이진 탐색 응용
}

참고 자료

공식 문서 및 표준

학술 자료 및 이론

시각화 및 학습 도구

실습 문제

관련 문서

심화 주제

마치며

이진 탐색은 “정렬된 데이터”라는 전제만 있으면 엄청난 성능을 제공합니다.

처음에는 left, right, mid의 관계가 헷갈릴 수 있지만, 10번 정도 직접 구현해보면 자연스러워집니다.

핵심은 “절반씩 줄여나간다”는 것입니다! 🎯

다음 단계

같은 레벨 (초중급):

다음 레벨 (중급):

실전 응용:

🚀 추천 학습 순서: 버블 정렬 → 이진 탐색 → 재귀 → DFS/BFS → 동적 프로그래밍

댓글