본문 바로가기

3. 알고리즘 공부

[461] 여러 수열에서 수 하나씩 골라 최소 범위 만들기

1. 문제

수열이 여러 개(N개) 있을 때, 각 수열에서 수를 하나씩만 고릅니다.

그렇게 고른 수 중 최대값과 최소값의 차이가 가장 작게 골라봅시다.

예를 들어, {49, 97, 1, 64, 25}, {93, 33, 54, 78, 12}, {71, 6, 80, 20, 44}가 있을 때, 각 수열에서 수 하나씩 고릅니다.

이 때 만약 첫 번째 수열에서 49, 두 번째 수열에서 54, 세 번째 수열에서 44를 고른다면, 이 중 최대값인 54와 최소값인 44의 차이는 10입니다.

이렇게 선택한 수 중 최대값과 최소값의 차이가 가장 작게 고르는 방법을 알아봅시다.

2. 해결 방법

가. Brute-force

모든 경우의 수를 일일이 살펴보고, 정답을 찾는 방법입니다.

구현하기 가장 쉽지만, 비효율적입니다.

시간복잡도를 계산해보면 수열의 개수를 N, 수열의 길이를 M이라고 할 때, \(O(M^{N})\)으로 N이 좀만 커져도 현실적으로 사용하기가 불가능합니다.

S = [
    [49, 97, 1, 64, 25],
    [93, 33, 54, 78, 12],
    [71, 6, 80, 20, 44]
]
res = [float("inf"), []]

def recur(depth, mn, mx, arr):
    if depth == len(S):
        if res[0] > mx-mn:
            res[0] = mx-mn
            res[1] = arr
        return
    for i in range(len(S[depth])):
        cur = S[depth][i]
        recur(depth+1, min(mn, cur), max(mx, cur), arr+[cur])

recur(0, float("inf"), 0, [])

print(*res, sep="\n")
# 10
# [49, 54, 44]

나. Sort + Greedy

각 수열에서 숫자를 고를 때, 최대한 비슷한 숫자들끼리 고르는 것이 유리하다는 것을 알 수 있습니다.

따라서 각 수열을 정렬하여 그리디하게 숫자를 고르면 더 효율적으로 고를 수 있습니다.

각 수열에서 무조건 한 개의 숫자를 골라야하므로, 모든 수열에서 가장 작은 수를 선택합니다.

이후 그 중에 가장 작은 수가 있는 수열에서 하나씩 뒤로 미룹니다.

이 경우 시간 복잡도는 \(O(N  M \lg{M})\)입니다.

대신 매 순간 선택된 수 중에서 가장 작은 수와 가장 큰 수를 찾기 위한 알고리즘이 필요하며 이에 따른 시간복잡도가 추가됩니다.

그래도 브루트포스 보다 훨씬 효율적입니다.

아이디어는 준비됐으니 이제 이 방법을 구현할 두 가지 방법을 알아봅시다.

(1) Priority Queue

위의 방법에서 매 순간 선택된 수 중에서 가장 작은 수를 찾기 위한 방법으로 우선순위 큐를 사용할 수 있습니다.

우선순위 큐의 길이는 항상 M이 유지됩니다. 우선순위 큐에 넣는 값은 각 수열에서 단조증가형태이므로 최대값은 넣는 과정에서 관리할 수 있습니다.

최소값은 우선순위 큐에서 하나씩 제거하며 확인할 수 있습니다.

우선순위 큐에서 최소값을 제거할 때, 그 최소값이 어느 수열인지 알아야 하므로 실제 구현할 때에는 수열을 구분할 수 있는 데이터도 함께 우선순위 큐에 넣습니다.

이 과정을 반복하면 위에서 생각한 방법 그대로 구현을 할 수 있습니다.

우선순위 큐를 구현한 최소힙의 heappop과 heappush의 시간 복잡도는 \(O(\lg{N})\)이므로 이 방법의 총 시간 복잡도는 \(O(NM\lg{N})\)입니다.

(2) Two-pointer

최소값과 최대값을 일정한 규칙으로 유지시키며 탐색하는 경우 두 개의 포인터를 사용할 수 있습니다.

이 문제도 매 순간 최소값을 제외해야하고, 또 최대값과의 차이를 계산해야하므로 두 개의 포인터를 사용하여 구현해봅시다.

수열의 개수가 두 개 이상인데 어떻게 두 개의 포인터만 가지고 구현할 수 있을까요?

이를 위해 두 가지 아이디어가 더 필요합니다.

먼저, 모든 수를 하나의 정렬된 수열로 만듭니다. 대신, 각 수가 몇 번째 수열에 속하는지 함께 관리합니다.

그리고, 각 수열에서 숫자가 몇 개 선택되었는지 관리합니다.

초기화(initialization)는 모든 수열에서 1개 이상이 선택될 때까지 오른쪽 포인터(그림에서는 빨간색 포인터)를 오른쪽으로 밉니다.

그리고 반복되는 과정으로, 최소값인 왼쪽 포인터(그림에서는 파란색 포인터)가 속한 수열을 확인하고 한 칸 오른쪽으로 밉니다. 밀기 전 수열에서 선택된 개수를 하나 줄이고, 그 수열에서 선택된 개수가 1 이상이 될 때까지 오른쪽 포인터를 오른쪽으로 밉니다.

더이상 반복할 수 없을 때까지 위 과정을 반복합니다.

이렇게 구현할 경우 시간 복잡도는 \( O(NM\lg{(NM)}) \)이 됩니다.