[PS] BOJ 19592 / 장난감 경주

[PS] BOJ 19592 / 장난감 경주
문제 링크: https://www.acmicpc.net/problem/19592
Thumbnail: Photo by Jefferson Sees (Unsplash)

최대값 최댓값 어느 쪽이 맞는 표기인지 헷갈려서 찾아봤는데, 최댓값이 맞다고 합니다 ㅎㅎ

풀이

이게 왜 이분 탐색?

~ 한 조건을 만족하는 최대/최솟값을 찾는 형태의 문제들을 최적화 문제 라고 부릅니다. 그러나, 최적화 문제를 왜 이분 탐색으로 풀 수 있는지는 막연히 떠오르지 않을 수 있습니다!

1) 이분 탐색은 무엇인가?

이해에 앞서, 우리가 아는 이분 탐색을 먼저 생각해봅시다. 아래와 같은 배열에서, 임의의 원소 \(K\)가 몇 번 째에 있는지를 찾는 문제를 풀어봅시다.

[ 1, 2, 3, 4, 5, 6, 7, 8, 9 ]

단순히 배열을 순회해서도 찾을 수 있으나, 그렇게 되면 시간 복잡도가 \(O(N)\)이 되며, 대부분의 문제에서 시간 초과를 받게 됩니다.

array = [1, 2, 3, 4, 5, 6, 7, 8, 9]
K = int(input())  # 찾고자 하는 배열 안의 임의의 수
for idx in range(len(array)):
    if array[idx] == K:
        print(idx)

이분 탐색은 최악의 경우에도 \(O(\log N)\)의 시간 복잡도를 가지므로, 배열을 순회하는 방식보다 더 빠르게 답을 찾아낼 수 있습니다.

array = [1, 2, 3, 4, 5, 6, 7, 8, 9]
K = int(input())  # 찾고자 하는 배열 안의 임의의 수

l = 0
r = len(array) - 1
while l <= r:
    m = (l + r) // 2
    if array[m] < K:
        l = m + 1
    elif array[m] > K:
        r = m - 1
    else:
        print(m)

다만, 이분 탐색은 탐색하고자 하는 배열이 정렬되어 있어야 한다는 전제 조건을 가집니다.

2) 결정 문제와 최적화 문제

이분 탐색을 이용해 풀 수 있는 문제 유형 중에, 결정 문제최적화 문제가 있습니다.

  • 결정 문제: Yes / No로 답할 수 있는 문제
    • ex) 배열 A의 최댓값이 3인가?
  • 최적화 문제: 어떤 조건을 만족하는 최댓값/최솟값을 구하는 문제
    • ex) 배열 A의 최댓값을 구하라.

최적화 문제는 결정 문제보다 풀이가 어렵고, 최적화 문제를 풀 수 있다면 그 문제의 결정 문제를 풀 수 있습니다.

3) 최적화 문제의 풀이

최적화 문제는 결정 문제로 바꿔서 풀이할 수 있습니다.

ex) 배열 A의 최댓값을 구하는 문제 (최적화 문제) → 배열 A의 최댓값이 \(K\) 이하인지 판단하는 문제 (결정 문제)

이후, 결정 문제를 \(K = 0, 1, 2, \cdots\) 에 대해 풀면 답을 찾을 수 있습니다.

예를 들어, 배열 A의 최댓값이 10이라 하면, \(K\)에 따라 푼 각각의 결정 문제의 답은 다음과 같습니다.

  • \(K = 0\) → No
  • \(K = 1\) → No
  • ...
  • \(K = 10\) → Yes
  • \(K = 11\) → Yes
  • ...

이를 배열처럼 나타내 보면, NNNNNNNNNYYY.... 꼴입니다.

즉, 배열 A의 최댓값을 구하는 최적화 문제는 결정 문제에서 사용한 임의의 \(K\) 중 결정 문제의 답이 처음으로 Y가 나오는 \(K\)를 찾는 문제로 바꿔 풀 수 있습니다.

정리하면 다음과 같습니다:

  • 조건을 만족하는 최댓값을 탐색
    • YYYYYYNNNN... 에서 가장 마지막으로 나오는 Y의 위치를 찾는 문제
    • 정답이 존재하는 구간 \([S, E]\)와 중간값 \(M\)에 대해, 결정 문제 decision(M)의 해가
      • Y라면 정답은 \([M, E]\)에 존재
      • N이라면 정답은 \([S, M - 1]\)에 존재
    • 이분 탐색 종료 후, \(S\)를 답으로 사용합니다.
  • 조건을 만족하는 최솟값을 탐색
    • NNNNNNYYYY... 에서 가장 처음으로 나오는 Y의 위치를 찾는 문제
    • 정답이 존재하는 구간 \([S, E]\)와 중간값 \(M\)에 대해, 결정 문제 decision(M)의 해가
      • Y라면 정답은 \([S, M]\)에 존재
      • N이라면 정답은 \([M + 1, E]\)에 존재
    • 이분 탐색 종료 후, \(E\)를 답으로 사용합니다.

이처럼 '최적화 문제를 결정 문제로 바꾸어 푸는 알고리즘' 을 매개변수 탐색(Parametric Search) 라고 합니다.

문제 분석: 매개변수 탐색

문제에서 구해야 하는 것은 '단독 우승을 하기 위해 부스터를 사용해서 이동해야 하는 최소한의 거리' 입니다. 최솟값을 찾는 매개변수 탐색으로 접근해 풀이할 수 있습니다.

최소한의 거리를 구하기 위한 범위 \([S, E]\)는 \(S = 1, E = Y + 1\)로 설정했습니다.
이후, 이분 탐색이 종료된 뒤의 답(최솟값을 찾는 경우는 \(E\)를 답으로 사용합니다.) 이 \(Y + 1\)이라면, 부스터를 사용해도 단독 우승이 불가능한 경우로 판단하고 -1을 출력합니다.

전처리

결정 문제를 만들기 위해, 먼저 자신을 제외한 나머지 참가자들 중 가장 빠른 사람의 기록이 필요합니다. 이를 fastest_record에 저장하겠습니다.

for _ in range(int(input())):
    N, X, Y = map(int, input().split())
    V = list(map(int, input().split()))

    # 자신을 제외한 참가자들 중 가장 빨리 통과한 선수가 걸린 시간을 계산한다.
    fastest_record = X
    for i in range(N - 1):
        fastest_record = min(fastest_record, X / V[i])

결정 문제를 만들기 위한 전처리

이분 탐색을 하지 않는 경우 예외처리

다음으로, 이분 탐색을 진행하기 전 부스터를 사용하지 않았을 때의 자신의 기록을 계산합니다. 만약 부스터를 사용하지 않아도 이미 단독 우승이 가능한 경우, 즉시 0을 출력하고 다음 케이스로 넘어갑니다.

my_record = X / V[-1]  # 자신의 기록은 항상 마지막에 주어진다.
# 굳이 부스터를 쓰지 않고도 이미 단독 우승이 가능한 경우
if my_record < fastest_record:
    print(0)
    continue

예외 처리

이제 '단독 우승을 하기 위해 부스터를 사용해서 이동해야 하는 최소한의 거리'를 매개변수 탐색으로 계산해야 합니다.

앞서 초기 탐색 범위 \([S, E]\)를 \(S = 1, E = Y + 1\)로 설정했습니다. 이제 결정 문제를 사용해 이분 탐색을 진행해야 합니다. 결정 문제는 다음과 같습니다:

처음 1초를 부스터로 \(M\)만큼 이동한 뒤, 자신의 속도 \(V[N - 1]\)로 나머지 거리를 이동하는데 걸린 시간(자신의 기록) 이 fastest_record보다 작은가?

이때, 오로지 '단독 우승'이 가능한 경우만 정답으로 치므로 공동 우승 또한 오답으로 처리해야 합니다. 따라서, 탐색 범위를 다음과 같이 좁혔습니다:

  • 거리 \(M\)만큼 부스터를 사용해 걸린 시간 >= fastest_record
    • 오답인 경우이므로, 다음 탐색 구간을 \([M + 1, E]\)로 한다.
  • 그렇지 않다면
    • 정답인 경우이므로, 다음 탐색 구간을 \([S, M]\)으로 한다.

이분 탐색이 종료된 후, \(E\)에 해당하는 값을 정답으로 사용한다. 이때, \(E\)가 \(Y\)보다 크다면(\(Y + 1\)이 된다.), 부스터를 써도 단독 우승이 불가능한 경우이므로 -1을 출력해준다.

l = 1
r = Y + 1
while l < r:
    mid = (l + r) // 2
    # 부스터를 사용했을 때, 최소 시간 계산 (결정 문제)
    total_time = (X - mid) / V[-1] + 1 # 최초 1초는 부스터 사용
    if total_time >= fastest_record: # 공동 우승까지는 원하지 않는 경우이므로, 같은 시간이 걸리는 경우는 N으로 처리
        l = mid + 1
    else:
        r = mid
if l > Y:
    print(-1)
else:
    print(r)

매개 변수 탐색(Parametric Search)

전체 코드

input = open(0).readline

for _ in range(int(input())):
    N, X, Y = map(int, input().split())
    V = list(map(int, input().split()))
    fastest_record = X
    for i in range(N - 1):
        fastest_record = min(fastest_record, X / V[i])
    
    if fastest_record > X / V[-1]:
        print(0)
        continue

    l = 1
    r = Y + 1
    while l < r:
        mid = (l + r) // 2
        total_time = (X - mid) / V[-1] + 1
        if total_time >= fastest_record:
            l = mid + 1
        else:
            r = mid
    if l > Y:
        print(-1)
    else:
        print(r)

solution.py