[PAST 알고리즘 시리즈] Part 1 — 이분 탐색 심화 (二分探索 発展)

[PAST 알고리즘 시리즈] Part 1 — 이분 탐색 심화 (二分探索 発展)

개요

이분 탐색(Binary Search)은 정렬된 데이터에서 특정 값을 찾는 대표적인 알고리즘입니다. 초급~중급 수준에서는 "값의 단조성(단순 증가/감소)에 주목하여 문제를 풀 수 있도록" 학습합니다.

이번 파트에서는 한 걸음 더 나아가, 이분 탐색이 직접 적용되기 어려운 문제를 변형하여 적용하는 테크닉을 다룹니다.

다루는 주제

섹션 주제 핵심 아이디어
1.1 이분 탐색을 적용할 수 있는 문제 판정 문제 + 단조성
1.2 최솟값의 최대화 (최댓값의 최소화) 최적화 → 판정 문제 변환
1.3 평균값 최대화 · 중앙값 최대화 합계값 문제로 변환 후 이분 탐색

1.1 이분 탐색을 적용할 수 있는 문제

핵심 조건

이분 탐색을 적용하려면, 해당 문제가 다음 두 가지 조건을 만족해야 합니다:

  1. 판정 문제를 빠르게 풀 수 있다 — 어떤 값 X에 대해 조건을 만족하는지 여부를 빠르게 판별 가능
  2. 판정 결과가 단조적으로 변화한다 — 조건을 만족하는 구간과 만족하지 않는 구간의 경계가 딱 한 곳만 존재

이 두 조건이 충족되면, "조건 A를 만족하는 X의 최댓값(또는 최솟값)을 구하라"라는 최적화 문제를 이분 탐색으로 해결할 수 있습니다.

시간 복잡도 감각

탐색 범위가 $0 \leq X \leq 10^{18}$ 인 경우, 이분 탐색은 약 60회 반복 ($2^{60} \simeq 10^{18}$)으로 범위를 좁힐 수 있습니다. 실행 시간 제한이 2초라면, 1회 판정에 약 30ms 이내로 해결해야 합니다.

예제: おまかせ (제1회 PAST · M문제)

문제 요약

  • N마리의 소유 몬스터와 M마리의 도우미 몬스터가 있음
  • 각 몬스터는 질량(A 또는 C)과 마력(B 또는 D)을 가짐
  • N + M 마리 중 정확히 5마리를 뽑아 합성 (단, 도우미 몬스터는 최대 1마리)
  • 합성 몬스터의 강도 = (마력의 합) / (질량의 합)
  • 강도의 최댓값을 구하라

사고 과정

직접 계산의 한계: 모든 조합을 시도하면, 도우미 미사용 시 ${}_NC_5$가지, 도우미 사용 시 ${}_NC_4 + M$가지의 조합이 존재합니다. $5 \leq N \leq 1000$이므로 실행 시간 제한 내에 처리 불가.

부분 문제로 변환: "합성 몬스터의 강도가 X 이상이 되는 조합이 존재하는가?"라는 판정 문제를 풀어봅시다.

5마리의 몬스터를 선택했을 때, 강도가 X 이상이 되려면:

$\frac{(B_a + B_b + B_c + B_d + B_e)}{(A_a + A_b + A_c + A_d + A_e)} \geq X$

이를 변형하면:

$(B_a - A_a X) + (B_b - A_b X) + (B_c - A_c X) + (B_d - A_d X) + (B_e - A_e X) \geq 0$

각 몬스터 $i$에 대해 $P_i = B_i - A_i X$를 계산하면, P 값이 큰 순서대로 5마리를 선택했을 때 합이 0 이상이면 조건을 만족합니다.

알고리즘 정리

  1. 각 소유 몬스터 $i$에 대해 $P_i = B_i - A_i X$ 를 계산
  2. P를 정렬하여 큰 쪽에서 5개 선택
  3. 각 도우미 몬스터 $j$에 대해 $Q_j = D_j - C_j X$ 를 계산
  4. 도우미 몬스터를 사용하는 경우: P에서 상위 4개 + Q의 최댓값 1개를 선택
  5. 위 두 경우 중 합이 0 이상인 경우가 있으면 → 강도 X 이상 달성 가능

단조성

"강도 X 이상을 달성 가능"이라는 사실이 어떤 값까지는 성립하고, 그 이후로는 성립하지 않음 → 이분 탐색 적용 가능!

Java 구현 예시

import java.util.*;

public class Main {
    static int N, M;
    static long[] A, B, C, D;

    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        N = sc.nextInt();
        M = sc.nextInt();
        A = new long[N]; B = new long[N];
        C = new long[M]; D = new long[M];
        for (int i = 0; i < N; i++) { A[i] = sc.nextLong(); B[i] = sc.nextLong(); }
        for (int i = 0; i < M; i++) { C[i] = sc.nextLong(); D[i] = sc.nextLong(); }

        double lo = 0, hi = 1e9;
        for (int iter = 0; iter < 100; iter++) {
            double mid = (lo + hi) / 2.0;
            if (check(mid)) lo = mid;
            else hi = mid;
        }
        System.out.printf("%.13f%n", lo);
    }

    static boolean check(double X) {
        // 소유 몬스터의 P 값 계산
        double[] P = new double[N];
        for (int i = 0; i < N; i++) P[i] = B[i] - A[i] * X;
        Arrays.sort(P);

        // P에서 상위 5개의 합 (도우미 미사용)
        double sum5 = 0;
        for (int i = N - 1; i >= Math.max(0, N - 5); i--) sum5 += P[i];
        if (sum5 >= -1e-9) return true;

        // P에서 상위 4개 + Q의 최댓값 (도우미 사용)
        double sum4 = 0;
        for (int i = N - 1; i >= Math.max(0, N - 4); i--) sum4 += P[i];
        double maxQ = Double.NEGATIVE_INFINITY;
        for (int j = 0; j < M; j++) {
            maxQ = Math.max(maxQ, D[j] - C[j] * X);
        }
        return sum4 + maxQ >= -1e-9;
    }
}

포인트

실수 값에 대한 이분 탐색이므로

while (hi - lo > 1) 대신 반복 횟수를 고정 (100회면 충분)하여 오차를 줄이는 것이 일반적입니다.

🔰 참고

아래의 링크는 해당 문제의 상세 풀이에 대한 과정이 담겨있습니다.

1.2 최솟값의 최대화 (최댓값의 최소화)

개념

"조건 A를 만족하는 수열 B 중, 최솟값이 가장 큰 것을 구하라"는 유형의 문제입니다.

이를 이분 탐색으로 접근하면:

"조건 A를 만족하고, 최솟값이 X 이상인 수열 B를 만들 수 있는가?"

라는 판정 문제를 만들어, X의 값을 이분 탐색으로 결정합니다.

예제: 棒の出荷 (제5회 PAST · M문제)

문제 요약

  • 길이가 $A_1, A_2, \ldots, A_N$인 N개의 구간으로 나뉜 긴 막대
  • N−1개의 절단 위치 중 일부를 선택하여 절단
  • 모든 막대의 길이가 L 이하가 되어야 함
  • 가장 짧은 막대의 길이를 최대화하라

왜 "최솟값의 최대화"인가?

길이 S인 막대를 M개로 자유롭게 분할하면, 최솟값의 최대치는 S/M입니다 (모든 조각을 동일하게).

하지만 실제로는 미리 정해진 위치에서만 절단 가능하므로 완전히 균등하게는 만들 수 없습니다. 그래도 이 "균등 분배" 상태를 목표로 합니다.

판정 문제 설계

"절단 후 가장 짧은 막대의 길이가 X 이상인 절단 방법이 존재하는가?"

이 문제를 O(N)에 판정할 수 있다면, 이분 탐색으로 최대의 X를 구할 수 있습니다.

판정 알고리즘 — いもす法(이모스법) 활용

いもす法(이모스법)이란?

배열의 구간 [l, r]에 일괄적으로 값을 더하고 싶을 때, 원래 배열 대신 그 차분 배열의 시작점(+1)과 끝점+1(-1)만 갱신한 뒤, 나중에 누적합을 구하는 테크닉입니다.

서적에서는 "어떤 절단 위치 i에서 절단한 후, 다음에 절단할 수 있는 범위 [l, r]"을 효율적으로 관리하는 방법을 설명합니다.

핵심 아이디어:

  1. 절단 위치의 누적합 배열 S[i]를 전처리
  2. 절단 위치 i에서 절단했을 때:
    • 다음 절단까지 최소 X 이상 떨어져야 함 → S[l] - S[i] ≥ X를 만족하는 최소 l
    • 다음 절단까지 최대 L 이하여야 함 → S[r] - S[i] ≤ L을 만족하는 최대 r
  3. C[i] = "절단 위치 i에서 절단 가능한가" (도달 가능 여부)

단순 구현은 O(N²)이 되지만, 누적합의 차분 배열(いもす法)을 사용하면 O(N)으로 줄일 수 있습니다.

Java 구현 예시

import java.util.*;

public class Main {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        int N = sc.nextInt();
        long L = sc.nextLong();
        long[] A = new long[N];
        for (int i = 0; i < N; i++) A[i] = sc.nextLong();

        // 누적합 전처리
        long[] S = new long[N + 1];
        for (int i = 0; i < N; i++) S[i + 1] = S[i] + A[i];

        // 이분 탐색: 최솟값의 최대화
        long lo = 0, hi = L + 1;
        while (hi - lo > 1) {
            long mid = (lo + hi) / 2;
            if (check(N, L, S, mid)) lo = mid;
            else hi = mid;
        }
        System.out.println(lo);
    }

    static boolean check(int N, long L, long[] S, long X) {
        int l = 0, r = 0;
        int[] C = new int[N + 1];
        int[] D = new int[N + 1]; // 차분 배열

        D[0] += 1;
        if (1 <= N) D[1] -= 1;

        for (int i = 0; i < N; i++) {
            if (i > 0) C[i] = C[i - 1] + D[i];
            else C[i] = D[i];

            if (C[i] == 0) continue;

            // 다음 절단까지 최소 X 이상
            while (l <= N && S[l] - S[i] < X) l++;
            if (l > N) continue;

            // 다음 절단까지 최대 L 이하
            while (r + 1 <= N && S[r + 1] - S[i] <= L) r++;

            // [l, r] 구간에 도달 가능 표시 (いもす법)
            D[l] += 1;
            if (r + 1 <= N) D[r + 1] -= 1;
        }

        C[N] = C[N - 1] + D[N];
        return C[N] > 0;
    }
}

포인트

이 문제에서는 정수 범위의 이분 탐색을 사용합니다. while (hi - lo > 1) 패턴이 정수 이분 탐색의 표준입니다.

🔰 참고

아래의 링크는 해당 문제의 상세 풀이에 대한 과정이 담겨있습니다.

1.3 평균값 최대화 · 중앙값 최대화

개념

"배열에서 조건을 만족하도록 요소를 선택했을 때, 선택한 요소의 평균값 또는 중앙값을 최대화하라"는 유형입니다.

핵심 변환 아이디어:

  • 평균값 ≥ X ↔ 각 요소에서 X를 빼서 새 배열 $B_i = A_i - X$를 만들면, B에서 선택한 요소의 합계 ≥ 0
  • 중앙값 ≥ X ↔ 각 요소를 $A_i \geq X$이면 +1, 아니면 -1로 변환하여, 변환 배열에서 선택한 요소의 합계 > 0

둘 다 "합계 ≥ 0인 선택 방법이 존재하는가?"라는 판정 문제로 귀결되며, X의 값을 이분 탐색할 수 있습니다.

예제: Average and Median (ABC236 · E문제)

문제 요약

  • N장의 카드, i번째 카드에 정수 $A_i$가 적혀 있음
  • 카드를 원하는 만큼 선택 (단, 연속 2장을 모두 선택하지 않는 것은 불가 → 인접한 i번과 i+1번 중 적어도 하나는 반드시 선택)
  • 선택한 카드의 평균값 최대중앙값 최대를 각각 구하라

평균값 최대화 풀이

  1. 판정 문제: "평균값이 X 이상이 되도록 선택하는 방법이 존재하는가?"
  2. $B_i = A_i - X$ 로 변환하면 → "B에서 선택한 요소의 합계 ≥ 0인 선택 방법이 존재하는가?"
  3. "인접한 2개 중 적어도 하나는 선택" 조건 하에서 합계를 최대화하는 것은 DP로 해결

DP 정의:

  • $S_{i+1}$ = B의 i번째까지 중, 조건을 만족하면서 i번째를 선택했을 때의 합계 최댓값
  • $T_{i+1}$ = B의 i번째까지 중, 조건을 만족하면서 i번째를 선택하지 않았을 때의 합계 최댓값

점화식:

$$S_{i+1} = \max(S_i, T_i) + B_i$$

$$T_{i+1} = S_i$$

최종 답

$\max(S_{N+1}, T_{N+1}) \geq 0$ 이면 판정 성공!

중앙값 최대화 풀이

  1. $B_i = A_i \geq X$ 이면 1, 아니면 -1
  2. 같은 DP로 합계가 0보다 크면 → 중앙값 X 이상 달성 가능
  3. 중앙값은 반드시 정수이므로 정수 범위 이분 탐색 사용

Java 구현 예시

import java.util.*;

public class Main {
    static int N;
    static long[] A;

    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        N = sc.nextInt();
        A = new long[N];
        for (int i = 0; i < N; i++) A[i] = sc.nextLong();

        // ===== 평균값 최대화 (실수 이분 탐색) =====
        double avLo = 0, avHi = 1e10;
        for (int iter = 0; iter < 100; iter++) {
            double mid = (avLo + avHi) / 2.0;
            if (checkAverage(mid)) avLo = mid;
            else avHi = mid;
        }
        System.out.printf("%.13f%n", avLo);

        // ===== 중앙값 최대화 (정수 이분 탐색) =====
        long mdLo = 0, mdHi = 1_000_000_001L;
        while (mdHi - mdLo > 1) {
            long mid = (mdLo + mdHi) / 2;
            if (checkMedian(mid)) mdLo = mid;
            else mdHi = mid;
        }
        System.out.println(mdLo);
    }

    // 평균값이 X 이상이 되는 선택 방법이 존재하는가?
    static boolean checkAverage(double X) {
        double[] B = new double[N];
        for (int i = 0; i < N; i++) B[i] = A[i] - X;

        double[] S = new double[N + 1];
        double[] T = new double[N + 1];
        for (int i = 0; i < N; i++) {
            S[i + 1] = Math.max(T[i], S[i]) + B[i];
            T[i + 1] = S[i];
        }
        return Math.max(S[N], T[N]) >= -1e-9;
    }

    // 중앙값이 X 이상이 되는 선택 방법이 존재하는가?
    static boolean checkMedian(long X) {
        int[] B = new int[N];
        for (int i = 0; i < N; i++) B[i] = (A[i] >= X) ? 1 : -1;

        long[] S = new long[N + 1];
        long[] T = new long[N + 1];
        for (int i = 0; i < N; i++) {
            S[i + 1] = Math.max(T[i], S[i]) + B[i];
            T[i + 1] = S[i];
        }
        return Math.max(S[N], T[N]) > 0;
    }
}

🔰 참고

아래의 링크는 해당 문제의 상세 풀이에 대한 과정이 담겨있습니다.

정리

테크닉 핵심 아이디어 판정 문제 형태
이분 탐색 기본 패턴 최적화 문제를 판정 문제로 변환 "X 이상(이하)을 만족하는 해가 존재하는가?"
최솟값의 최대화 min-max를 판정 문제로 전환 "최솟값이 X 이상인 해가 존재하는가?"
평균값 최대화 $B_i = A_i - X$로 변환 후 합계 문제로 "B의 합계 ≥ 0인 선택이 가능한가?"
중앙값 최대화 $B_i = \pm 1$ 변환 후 합계 문제로 "B의 합계 > 0인 선택이 가능한가?"

실전 팁

  1. 실수 이분 탐색: 반복 횟수를 100회로 고정하면 안전 ($10^{-30}$ 수준의 정밀도)
  2. 정수 이분 탐색: while (hi - lo > 1) 패턴 사용
  3. "답을 결정하고 판정"하는 사고법은 이분 탐색 외에도 다양한 곳에서 활용됩니다
  4. いもす法(이모스법)은 구간 일괄 갱신을 O(1)로 처리하는 강력한 도구입니다

관련 문제 링크

문제 출처 난이도
おまかせ 제1회 PAST · M ★★★
棒の出荷 제5회 PAST · M ★★★
Average and Median ABC 236 · E ★★★

참고 자료

다음 파트 예고

[PAST 알고리즘 시리즈] Part 2 — 동적 계획법 심화 (1) 에서는 비트마스크 DP, 구간 DP 등 DP의 고급 테크닉을 다룹니다.

Previous Post

[PAST 알고리즘 시리즈] Part 0 — 시리즈 소개 & PAS...

이번 시리즈의 기반이 되는 서적 소개와 PAST ...

[PAST 알고리즘 시리즈] Part 0 — 시리즈 소개 & PAST 개요

Next Post

[PAST 알고리즘 시리즈] Part 1 보충 — おまかせ 문제 풀이

이분 탐색 적용 문제(おまかせ, 제1회 PAST ...

[PAST 알고리즘 시리즈] Part 1 보충 — おまかせ 문제 풀이
scroll to top