Merge k Sorted Lists
lists의 행으로 정수 리스트 k개가 주어집니다. 각 행은 비내림차순으로 정렬되어 있고, 행마다 길이가 다를 수 있으며, 비어 있는 행은 없습니다.
모든 행의 모든 값을 포함하는 하나의 리스트로 병합하고, 비내림차순으로 정렬된 결과를 반환하세요. 한 행 또는 여러 행에서 여러 번 나타나는 값은 결과에도 그 횟수만큼 나타납니다.
함수
- listsinteger-2d-array
- 길이가 서로 다를 수 있는 정렬된 목록을 행마다 하나씩
- 반환값integer-array
- 각 행의 모든 값을 하나의 정렬된 목록으로
제약 조건
1 ≤ lists.length ≤ 1041 ≤ lists[i].length이며, 모든 행을 합쳐 최대104개의 값을 포함합니다-104 ≤ lists[i][j] ≤ 104- 각 행은 비내림차순으로 정렬되어 있습니다.
예제
- 입력
- lists = [[2, 6, 9], [1, 4, 10], [3, 5]]
- 출력
- [1, 2, 3, 4, 5, 6, 9, 10]
- 설명
- 전체에서 가장 작은 값은 두 번째 행의 첫 번째 값인 1입니다. 그다음 각 행은 2, 4, 3으로 시작하므로 다음은 2이고, 이런 식으로 이어집니다. 세 번째 행은 5 다음에 끝나므로, 마지막에는 6, 9, 10이 남습니다.
- 입력
- lists = [[5], [-2, 5, 7], [0, 5]]
- 출력
- [-2, 0, 5, 5, 5, 7]
- 설명
- 세 개의 5는 서로 다른 세 행에서 나오며 세 개 모두 남습니다. 음수
-2는0보다 먼저 정렬됩니다.
- 입력
- lists = [[4, 8]]
- 출력
- [4, 8]
- 설명
- 행이 하나뿐이면 병합할 것이 없습니다. 행이 이미 정렬되어 있으므로 이것이 정답입니다.
제출 시 숨은 테스트 +14개
후속 질문
모든 행에서 적어도 하나의 값을 포함하는 가장 작은 구간 [a, b]을 찾으세요. 지금까지의 가장 큰 헤드와 각 행의 헤드로 이루어진 같은 힙을 사용해 O(N log k) 시간에 이 구간을 찾을 수 있을까요?
힌트
하나씩 열어 보세요. 열 때마다 조금 더 알려 줍니다.
각 행은 정렬되어 있습니다. 전체에서 가장 작은 값이 될 수 있는 값은 무엇일까요?
answer의 다음 값은 항상 각 행에서 아직 사용되지 않은 첫 번째 값들 중 가장 작은 값입니다. 그 값을 가져간 후에는 그 값들 중 하나만 바뀝니다.
각 행에서 아직 사용하지 않은 첫 번째 값을 행과 함께 표시해 최소 힙에 보관합니다. 가장 작은 값을 꺼내 추가하고, 같은 행에 다음 값이 있으면 힙에 넣습니다.
풀이
모든 행은 정렬되어 있으므로, 아직 사용되지 않은 가장 작은 값은 항상 어떤 행에서든 사용되지 않은 첫 번째 값입니다. 문제의 핵심은 k개의 행의 맨 앞 값 중 가장 작은 것을 N번 찾는 것입니다. 여기서 N은 값의 개수입니다. 맨 앞 값들을 모두 확인하면 값 하나당 k단계가 걸립니다. 최소 힙은 맨 앞 값들을 정렬된 상태로 유지하고 O(log k)에 가장 작은 값을 꺼내므로, 전체 시간 복잡도를 O(N·k)에서 O(N log k)로 줄여 줍니다. 전형적인 형태에서는 각 리스트가 연결 리스트이지만, 여기서는 각 행이 배열이며 행마다 인덱스를 하나씩 두어 노드 포인터의 역할을 대신합니다.
모든 값에 대해 모든 k개의 헤드를 비교합니다
정답이지만 가장 큰 테스트에서는 끝나지 않음
핵심 아이디어
각 행마다 pos[r] 인덱스 하나를 두어, 아직 사용하지 않은 해당 행 r의 첫 번째 값, 즉 행의 맨 앞 원소를 가리키게 합니다. 사용하지 않은 값 중 가장 작은 값은 반드시 이 맨 앞 원소 중 하나입니다. 행 r에서 사용하지 않은 모든 값은 pos[r] 위치나 그 뒤에 있고, 행은 정렬되어 있으므로 그중 맨 앞 원소보다 작은 값은 없습니다.
따라서 아직 값이 남아 있는 모든 행을 살펴 가장 작은 맨 앞 원소를 찾고, 그 값을 추가한 다음 해당 행의 인덱스를 한 칸 앞으로 옮깁니다. N개의 값을 모두 꺼낼 때까지 반복합니다. 이것은 병합 정렬의 병합 단계이며, 두 목록을 k개로 확장한 것입니다.
첫 번째 예에서 맨 앞 원소는 처음에 2, 1, 3이므로 1이 먼저 나오고 두 번째 행의 맨 앞 원소는 4가 됩니다. 그런 다음 2(맨 앞 원소 2, 4, 3), 3(맨 앞 원소 6, 4, 3), 4, 그리고 세 번째 행을 비우는 5가 나옵니다. 마지막 세 차례에는 6과 10만 비교하고, 다음에는 9와 10을 비교한 뒤, 마지막으로 10만 남습니다.
비용은 N개의 각 값에 대해 k번 비교하는 것입니다. 값이 하나씩 들어 있는 행이 10^4개라면 비교 횟수는 10^8번입니다. C, Java 또는 JavaScript는 1초 이내에 처리하지만, Python은 10초 넘게 걸립니다. 또한 N과 k를 모두 두 배로 늘리면 어떤 언어든 네 배 느려집니다. 추적 결과에서 낭비가 드러납니다. 값을 하나 고른 뒤 바뀐 맨 앞 원소는 하나뿐인데, 다음 차례에 다시 k개를 모두 읽습니다.
알고리즘
- 모든 행에 대해
pos[r] = 0으로 설정하고 값의 개수인N을 셉니다. N번 반복합니다.pos[r]이 아직 해당 행 안에 있는 모든 행을 살펴보고, 첫 번째 값이 가장 작은 행을 기억합니다.- 그 첫 번째 값을 결과에 추가하고 해당 행의
pos에 1을 더합니다. - 결과를 반환합니다.
def mergeKLists(lists):
pos = [0] * len(lists) # pos[r]: index of the first unused value in row r
total = sum(len(row) for row in lists)
merged = []
for _ in range(total):
best = -1 # the row whose head is the smallest so far
for r in range(len(lists)):
if pos[r] < len(lists[r]) and (best == -1 or lists[r][pos[r]] < lists[best][pos[best]]):
best = r
merged.append(lists[best][pos[best]])
pos[best] += 1
return mergedk개 헤드의 최소 힙
핵심 아이디어
스캔은 가장 작은 값을 찾기 위해 k개의 머리 값을 다시 확인합니다. 하지만 지난 라운드 이후 바뀐 머리 값은 하나뿐입니다. 최소 힙은 바로 이런 경우에 사용합니다. 숫자 집합을 저장하고 가장 작은 값을 맨 위에 두며, 맨 위 값을 꺼내거나 숫자를 추가하는 데 각각 O(log size)의 비용이 듭니다.
각 행의 첫 번째 값을 행 번호와 함께 힙에 넣습니다. 그런 다음 다음을 반복합니다. 가장 작은 쌍 (value, row)을 꺼내 value를 추가하고, 해당 행에 다음 값이 있으면 같은 태그를 붙여 힙에 넣습니다. 힙에는 값이 남아 있는 각 행의 머리 값이 정확히 하나씩 들어 있으므로, 맨 위 값은 전체에서 아직 사용하지 않은 값 중 가장 작습니다. 스캔의 규칙을 더 빠르게 적용하는 방법입니다.
행 번호를 0부터 매겨 첫 번째 예제를 따라가 봅시다. 힙은 2 (행 0), 1 (행 1), 3 (행 2)으로 시작합니다. 1을 꺼내고 행 1의 다음 값인 4를 넣습니다. 2를 꺼내고 행 0의 다음 값인 6을 넣습니다. 3을 꺼내고 행 2의 다음 값인 5를 넣습니다. 4를 꺼내고 10을 넣습니다. 5를 꺼냅니다. 행 2의 값이 모두 사용되었으므로 아무것도 넣지 않고 힙에는 6과 10만 남습니다. 6을 꺼내고 9를 넣습니다. 9를 꺼낸 다음 10을 꺼냅니다. 결과는 [1, 2, 3, 4, 5, 6, 9, 10]입니다.
각 값은 힙에 한 번 들어가고 한 번 나옵니다. 힙에는 최대 k개의 항목만 있으므로, 총 2N번의 각 연산에 O(log k)의 비용이 듭니다. N = k = 10^4이면 약 2 × 10^4 × 14, 즉 3 × 10^5회 미만의 연산으로, 스캔의 10^8회와 비교됩니다. 힙은 O(k) 메모리를 사용하며 O(N)은 사용하지 않습니다. 각 행의 머리 값 하나만 보관하고 그 뒤의 값은 보관하지 않기 때문입니다.
여러 구현에서는 각 행의 머리 값에 따라 정렬된 행 번호 배열로 힙을 직접 만듭니다. 슬롯 i의 자식은 2i+1과 2i+2에 있습니다(1부터 세는 Lua와 R에서는 2i와 2i+1에 있습니다). 이렇게 하면 작업도 줄어듭니다. 맨 위 행의 머리 값을 꺼낸 뒤에도 그 행의 다음 값은 더 작지 않으므로, 해당 행은 맨 위에 남아 한 번만 아래로 내려가면 됩니다. 꺼내기 후 넣기를 하는 것보다 효율적입니다.
알고리즘
- 모든 행
r에 대해(lists[r][0], r)을 값 기준 최소 힙에 넣습니다. - 힙이 비어 있지 않은 동안 가장 작은 쌍
(value, r)을 꺼내고value를 결과에 추가합니다. - 행
r에 다음 값이 있으면 해당 값과r을 함께 넣습니다. - 힙이 비면 결과를 반환합니다.
import heapq
def mergeKLists(lists):
# The heap holds one (value, row) pair per row that still has values: that row's head.
heap = [(row[0], r) for r, row in enumerate(lists)]
heapq.heapify(heap)
nxt = [1] * len(lists) # nxt[r]: index of row r's next value, not yet in the heap
merged = []
while heap:
value, r = heapq.heappop(heap) # the smallest head of all rows
merged.append(value)
if nxt[r] < len(lists[r]):
heapq.heappush(heap, (lists[r][nxt[r]], r)) # row r's new head takes its place
nxt[r] += 1
return merged
함정과 경계 사례
힙 로직은 짧습니다. 대부분의 버그는 힙에 무엇을 넣는지, 그리고 어떤 순서로 정렬되는지에서 발생합니다.
- 값의 출처를 잊는 경우입니다. 힙에 값만 넣으면 pop 후 어느 행을 다음으로 진행해야 하는지 알 수 없습니다. 값과 함께 행을 저장하세요.
- 실수로 최대 힙을 사용하는 경우입니다. C++의
priority_queue와 Rust의BinaryHeap은 가장 큰 값을 맨 위에 둡니다.greater<>또는Reverse를 사용하세요. Java의PriorityQueue와 Python의heapq는 이미 가장 작은 값을 반환합니다. - Python의
heapq에서 동점이 발생하는 경우입니다. 두 값이 같으면 튜플 비교는 두 번째 항목으로 넘어갑니다. 행 번호는 문제없이 비교되지만 연결 리스트 노드는 그렇지 않으며, 기본 구현은 값이 같을 때 오류가 발생합니다. 두 번째 항목으로 행 번호나 카운터를 넣으세요. - 처음에 모든 값을 넣는 경우입니다. 여전히 올바르게 정렬되지만, 힙의 크기가
N개 항목까지 커지고 작업량이O(N log N)이 됩니다. 각 행의 맨 앞 항목 하나만 유지하세요. - 길이가 짧은 행의 끝을 넘어 읽는 경우입니다. 행마다 길이가 다르므로, 다음 값을 넣기 전에 해당 행에 다음 값이 있는지 확인하세요.
- 중복 값을 제거하는 경우입니다. 서로 다른 행에서 나온 같은 값은 각각 별개의 값이므로 모두 결과에 포함해야 합니다.
자주 묻는 질문4
정렬된 k개의 연결 리스트 병합의 시간 복잡도는 얼마인가요?
최소 힙을 사용하면 O(N log k)이며, 여기서 N은 전체 값의 개수이고 k는 리스트의 개수입니다. 각 값은 한 번씩 삽입되고 제거되며, 힙에는 최대 k개의 항목이 들어 있으므로 각 연산의 비용은 O(log k)입니다. 출력에 필요한 메모리 외에 추가 메모리는 O(k)입니다.
모든 값을 한데 모아 정렬하면 어떨까요?
맞습니다. O(N log N) 시간이 걸리며, 작은 입력에는 괜찮습니다. 이 방법은 목록이 이미 정렬되어 있다는 사실을 무시하므로, 힙이 log k를 사용하는 반면 값마다 log N을 사용하고, 모든 값을 한 번에 메모리에 올려야 합니다. 힙은 정렬과 달리 스트림으로 들어오는 목록도 병합할 수 있습니다.
힙 없이 정렬된 k개의 리스트를 병합할 수 있나요?
네, 분할 정복 방식으로 가능합니다. 두 개의 리스트를 병합하는 방식으로 리스트를 쌍으로 병합한 다음, 그 결과를 쌍으로 병합하는 식으로 계속합니다. log k번의 라운드가 진행되고 각 라운드에서 모든 값을 한 번씩 다루므로, 시간 복잡도는 역시 O(N log k)입니다. 리스트를 하나씩 늘어나는 결과에 차례로 병합하면 더 느립니다. 초반의 값들은 병합할 때마다 다시 복사되므로, 총 비용이 O(N·k)에 이릅니다.
힙에는 왜 각 목록의 맨 앞 항목만 있으면 되나요?
각 리스트는 정렬되어 있으므로, 아직 사용하지 않은 첫 번째 값이 남아 있는 값 중 가장 작습니다. 따라서 모든 리스트에서 가장 작은 값은 각 리스트의 맨 앞 값 중 가장 작은 값이며, 리스트 안쪽에 있는 어떤 값도 이보다 작을 수 없습니다. 맨 앞 값이 빠져나가면, 같은 리스트의 다음 값이 그 리스트의 맨 앞 값이 되어 힙에서 그 자리를 차지합니다.
비슷한 문제
같은 아이디어를 쓰는 문제입니다. 두세 개를 풀면 패턴이 몸에 익습니다.
Python
def mergeKLists(lists):
# 여기에 코드를 작성하세요케이스 1
케이스 2
케이스 3
입력
lists = [[2, 6, 9], [1, 4, 10], [3, 5]]
기대값
[1, 2, 3, 4, 5, 6, 9, 10]