선택 심화: 세 파일 polyphase 병합의 분배와 역할 교대
길이 헤더·가상 런·순차 위치를 사용해 실제 파일의 다단계 병합과 읽기·쓰기 수를 추적합니다.
앞 외부 정렬 예제는 k개 런 파일을 한 번에 엽니다. 런 수가 커져도 병합 입력 파일을 두 개만 사용하려면 런을 여러 개씩 담은 세 병합 작업 파일의 역할을 바꾸어야 합니다. 목표는 런 수의 분배·가상 런·소진 파일 교대를 실제 파일 실행과 연결하는 것입니다. 병합 정렬, 앞값 비교, 파일 스트림·생성기를 먼저 읽으세요.
개념과 약속
이 예제는 F0,F1,F2라는 임시 병합 파일 세 개를 사용합니다. 원본 입력 파일과 마지막 일반 출력 파일은 별도이므로 모든 파일이 총 세 개라는 뜻은 아닙니다. 한 단계에서는 런이 남은 두 파일을 입력으로 읽고, 논리 런 수가 0인 나머지 파일을 새 출력으로 씁니다. 입력 중 하나의 런이 모두 소진되면 단계가 끝나며, 그 소진 파일이 다음 단계의 출력이 됩니다.
세 파일에서 적절한 런 수는 두 입력을 연속 피보나치 수로 배치하면 만들 수 있습니다. 5와 3에서 시작하면 5,3,0 → 2,0,3 → 0,2,1 → 1,1,0 → 0,0,1입니다. 매 단계에서 입력 런 수의 작은 쪽만큼 쌍을 병합합니다. 첫 단계는 3쌍, 이후 2쌍·1쌍·1쌍입니다. 각 0은 물리 파일이 디스크에서 삭제됐다는 뜻이 아니라 아직 소비하지 않은 논리 런이 없다는 뜻입니다.
같은 세 파일에서 polyphase 병합 따라가기
초기 분배와 네 번의 병합 뒤 상태를 한 장면씩 봅니다. F0·F1·F2의 위치는 그대로 두고, 남은 런·읽기 위치·다음 역할을 바꿉니다.
자료구조·알고리즘 · 단계 전체보기
세 파일의 논리 런 수가 바뀌는 전체 순서
초기 분배와 네 번의 병합 뒤 상태를 한 장면씩 봅니다. F0·F1·F2의 위치는 그대로 두고, 남은 런·읽기 위치·다음 역할을 바꿉니다.
각 숫자는 F0/F1/F2에 아직 남은 논리 런 수입니다. 0은 파일 삭제가 아닙니다. 화살표에는 그 단계에서 병합한 런 쌍 수를 표시합니다.
- 입력 [8,1,6,2,9,3,7] · 청크 크기 1. 실런 7개에 dummy 1런을 앞쪽에 배치합니다.
- 그림은 초기 분배와 각 병합이 끝난 시점의 미소비 런을 보여 줍니다. 화살표는 다음 병합의 입력→출력입니다. 강조한 파일은 초기에는 첫 병합의 출력, 이후에는 방금 쓴 출력입니다.
- ∅는 길이 헤더 0인 dummy 한 런입니다. 값 [0]은 헤더 1과 값 0을 가진 실런이며, []는 남은 런이 없는 파일입니다.
- 읽기 위치는 다음 미소비 런의 헤더 앞입니다. 입력 파일에 남은 런은 같은 파일에 보존하고, 소진된 파일의 reader를 닫은 뒤 다음 출력으로 비웁니다.
- 원본 입력·최종 일반 출력은 세 임시 병합 파일과 별도입니다. 마지막 런을 일반 출력으로 복사하는 작업은 네 병합 단계 뒤에 수행합니다.
- 읽기·쓰기는 값 정수 레코드 수입니다. 길이 헤더·바이트·파일 버퍼 페이지·OS 호출을 제외하며, 읽기 위치 표시는 바이트 수가 아닙니다.
- 정본의 런 배열은 상태 설명용입니다. 본문의 Python 구현은 청크와 앞값 및 파일 읽기 위치를 유지하며 모든 런을 메모리에 모으지 않습니다.
| 시점 | F0의 아직 남은 런 | F1의 아직 남은 런 | F2의 아직 남은 런 | 다음 입력→출력 |
|---|---|---|---|---|
| 초기 5,3,0 | ∅ / [8] / [1] / [6] / [2] | [9] / [3] / [7] | [] | F0,F1→F2 |
| 1단계 뒤 2,0,3 | [6] / [2] | [] | [9] / [3,8] / [1,7] | F0,F2→F1 |
| 2단계 뒤 0,2,1 | [] | [6,9] / [2,3,8] | [1,7] | F1,F2→F0 |
| 3단계 뒤 1,1,0 | [1,6,7,9] | [2,3,8] | [] | F0,F1→F2 |
| 4단계 뒤 0,0,1 | [] | [] | [1,2,3,6,7,8,9] | 최종 런을 일반 출력으로 복사 |
| 병합 단계 | 병합 슬롯 쌍 | 읽은 값 | 쓴 값 |
|---|---|---|---|
| 1 | 3 | 5 | 5 |
| 2 | 2 | 5 | 5 |
| 3 | 1 | 4 | 4 |
| 4 | 1 | 7 | 7 |
| 병합 합계 | 7 | 21 | 21 |
이 예의 조건
- 입력 [8,1,6,2,9,3,7] · 청크 크기 1. 실런 7개에 dummy 1런을 앞쪽에 배치합니다.
- 그림은 초기 분배와 각 병합이 끝난 시점의 미소비 런을 보여 줍니다. 화살표는 다음 병합의 입력→출력입니다. 강조한 파일은 초기에는 첫 병합의 출력, 이후에는 방금 쓴 출력입니다.
- ∅는 길이 헤더 0인 dummy 한 런입니다. 값 [0]은 헤더 1과 값 0을 가진 실런이며, []는 남은 런이 없는 파일입니다.
- 읽기 위치는 다음 미소비 런의 헤더 앞입니다. 입력 파일에 남은 런은 같은 파일에 보존하고, 소진된 파일의 reader를 닫은 뒤 다음 출력으로 비웁니다.
- 원본 입력·최종 일반 출력은 세 임시 병합 파일과 별도입니다. 마지막 런을 일반 출력으로 복사하는 작업은 네 병합 단계 뒤에 수행합니다.
- 읽기·쓰기는 값 정수 레코드 수입니다. 길이 헤더·바이트·파일 버퍼 페이지·OS 호출을 제외하며, 읽기 위치 표시는 바이트 수가 아닙니다.
- 정본의 런 배열은 상태 설명용입니다. 본문의 Python 구현은 청크와 앞값 및 파일 읽기 위치를 유지하며 모든 런을 메모리에 모으지 않습니다.
장면별 값과 변화
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 초기 분배 F0에 5런, F1에 3런을 배치하고 F2를 첫 출력으로 둡니다. ∅도 한 런 슬롯으로 참여합니다. |
|
|
| 1단계 병합 뒤 F0·F1의 런 3쌍을 F2에 병합했습니다. 다음 출력은 F1입니다. |
|
|
| 2단계 병합 뒤 F0·F2의 런 2쌍을 F1에 병합했습니다. 다음 출력은 F0입니다. |
|
|
| 3단계 병합 뒤 F1·F2의 런 1쌍을 F0에 병합했습니다. 다음 출력은 F2입니다. |
|
|
| 4단계 병합 뒤 F0·F1의 런 1쌍을 F2에 병합했습니다. 한 런만 남아 병합을 마칩니다. |
|
|
같은 세 파일에서 polyphase 병합 따라가기
초기 분배와 네 번의 병합 뒤 상태를 한 장면씩 봅니다. F0·F1·F2의 위치는 그대로 두고, 남은 런·읽기 위치·다음 역할을 바꿉니다.
자료구조·알고리즘 · 단계 전체보기
세 파일의 논리 런 수가 바뀌는 전체 순서
초기 분배와 네 번의 병합 뒤 상태를 한 장면씩 봅니다. F0·F1·F2의 위치는 그대로 두고, 남은 런·읽기 위치·다음 역할을 바꿉니다.
각 숫자는 F0/F1/F2에 아직 남은 논리 런 수입니다. 0은 파일 삭제가 아닙니다. 화살표에는 그 단계에서 병합한 런 쌍 수를 표시합니다.
- 입력 [8,1,6,2,9,3,7] · 청크 크기 1. 실런 7개에 dummy 1런을 앞쪽에 배치합니다.
- 그림은 초기 분배와 각 병합이 끝난 시점의 미소비 런을 보여 줍니다. 화살표는 다음 병합의 입력→출력입니다. 강조한 파일은 초기에는 첫 병합의 출력, 이후에는 방금 쓴 출력입니다.
- ∅는 길이 헤더 0인 dummy 한 런입니다. 값 [0]은 헤더 1과 값 0을 가진 실런이며, []는 남은 런이 없는 파일입니다.
- 읽기 위치는 다음 미소비 런의 헤더 앞입니다. 입력 파일에 남은 런은 같은 파일에 보존하고, 소진된 파일의 reader를 닫은 뒤 다음 출력으로 비웁니다.
- 원본 입력·최종 일반 출력은 세 임시 병합 파일과 별도입니다. 마지막 런을 일반 출력으로 복사하는 작업은 네 병합 단계 뒤에 수행합니다.
- 읽기·쓰기는 값 정수 레코드 수입니다. 길이 헤더·바이트·파일 버퍼 페이지·OS 호출을 제외하며, 읽기 위치 표시는 바이트 수가 아닙니다.
- 정본의 런 배열은 상태 설명용입니다. 본문의 Python 구현은 청크와 앞값 및 파일 읽기 위치를 유지하며 모든 런을 메모리에 모으지 않습니다.
| 시점 | F0의 아직 남은 런 | F1의 아직 남은 런 | F2의 아직 남은 런 | 다음 입력→출력 |
|---|---|---|---|---|
| 초기 5,3,0 | ∅ / [8] / [1] / [6] / [2] | [9] / [3] / [7] | [] | F0,F1→F2 |
| 1단계 뒤 2,0,3 | [6] / [2] | [] | [9] / [3,8] / [1,7] | F0,F2→F1 |
| 2단계 뒤 0,2,1 | [] | [6,9] / [2,3,8] | [1,7] | F1,F2→F0 |
| 3단계 뒤 1,1,0 | [1,6,7,9] | [2,3,8] | [] | F0,F1→F2 |
| 4단계 뒤 0,0,1 | [] | [] | [1,2,3,6,7,8,9] | 최종 런을 일반 출력으로 복사 |
| 병합 단계 | 병합 슬롯 쌍 | 읽은 값 | 쓴 값 |
|---|---|---|---|
| 1 | 3 | 5 | 5 |
| 2 | 2 | 5 | 5 |
| 3 | 1 | 4 | 4 |
| 4 | 1 | 7 | 7 |
| 병합 합계 | 7 | 21 | 21 |
이 예의 조건
- 입력 [8,1,6,2,9,3,7] · 청크 크기 1. 실런 7개에 dummy 1런을 앞쪽에 배치합니다.
- 그림은 초기 분배와 각 병합이 끝난 시점의 미소비 런을 보여 줍니다. 화살표는 다음 병합의 입력→출력입니다. 강조한 파일은 초기에는 첫 병합의 출력, 이후에는 방금 쓴 출력입니다.
- ∅는 길이 헤더 0인 dummy 한 런입니다. 값 [0]은 헤더 1과 값 0을 가진 실런이며, []는 남은 런이 없는 파일입니다.
- 읽기 위치는 다음 미소비 런의 헤더 앞입니다. 입력 파일에 남은 런은 같은 파일에 보존하고, 소진된 파일의 reader를 닫은 뒤 다음 출력으로 비웁니다.
- 원본 입력·최종 일반 출력은 세 임시 병합 파일과 별도입니다. 마지막 런을 일반 출력으로 복사하는 작업은 네 병합 단계 뒤에 수행합니다.
- 읽기·쓰기는 값 정수 레코드 수입니다. 길이 헤더·바이트·파일 버퍼 페이지·OS 호출을 제외하며, 읽기 위치 표시는 바이트 수가 아닙니다.
- 정본의 런 배열은 상태 설명용입니다. 본문의 Python 구현은 청크와 앞값 및 파일 읽기 위치를 유지하며 모든 런을 메모리에 모으지 않습니다.
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 초기 분배 F0에 5런, F1에 3런을 배치하고 F2를 첫 출력으로 둡니다. ∅도 한 런 슬롯으로 참여합니다. |
|
|
| 1단계 병합 뒤 F0·F1의 런 3쌍을 F2에 병합했습니다. 다음 출력은 F1입니다. |
|
|
| 2단계 병합 뒤 F0·F2의 런 2쌍을 F1에 병합했습니다. 다음 출력은 F0입니다. |
|
|
| 3단계 병합 뒤 F1·F2의 런 1쌍을 F0에 병합했습니다. 다음 출력은 F2입니다. |
|
|
| 4단계 병합 뒤 F0·F1의 런 1쌍을 F2에 병합했습니다. 한 런만 남아 병합을 마칩니다. |
|
|
실런 수 R이 7이면 5+3의 8슬롯을 맞추기 위해 가상 런(dummy run) 하나를 더합니다. 가상 런은 데이터 값이 없는 길이 0의 런이며, 런 수 계산과 소비에는 한 슬롯으로 참여합니다. dummy+실런은 실런의 값을 그대로 출력하고, dummy+dummy는 길이 0의 출력 런 한 개를 만듭니다. 값이 0인 실런과 가상 런은 다릅니다.
실제 파일에서 런 경계를 보존하는 형식
입력·최종 출력은 한 줄에 정수 하나입니다. 임시 파일의 각 런은 길이 헤더 한 줄 다음에 그 길이만큼의 값 줄을 둡니다. 예를 들어 정렬 런 [3,8]은 헤더 2, 값 3, 값 8의 세 줄입니다. 가상 런은 헤더 0 한 줄만 쓰고, 실제 값 [0]은 헤더 1과 값 0 두 줄로 씁니다. 런 사이를 값의 감소만으로 구분하지 않으므로 같은 값·이미 정렬된 이웃 런도 별도로 소비할 수 있습니다.
파일마다 다음 미소비 런을 읽을 위치를 tell()로 저장하고, 다음 입력 역할에서 seek()로 그 위치부터 읽습니다. 여기 위치는 Python 텍스트 스트림의 위치 표식이며 일반적인 바이트 수나 디스크 I/O 호출 수라고 계산하지 않습니다. 남은 런을 메모리 목록이나 새 파일로 미리 복사하지 않습니다. 출력으로 재사용하는 파일의 이전 reader는 먼저 닫고, 논리 런 수가 0인 파일만 open("w")로 비워 처음부터 씁니다.
chunk_size는 양의 Python 정수이며 bool·float·NaN을 거부합니다. 초기 청크 하나만 메모리에서 정렬하고 순서대로 파일에 씁니다. 원본은 런 수를 세는 첫 순회와 청크를 만드는 둘째 순회 동안 변하지 않아야 합니다. 입력과 출력은 서로 다른 실파일이어야 하고 같은 경로·이미 존재하는 하드링크 별칭은 거부합니다. 출력은 덮어써도 되는 전용 학습 결과 경로만 사용하세요.
손으로 상태 추적하기
입력은 [8,1,6,2,9,3,7], 청크 크기는 1입니다. 실제 런은 일곱 개이고, F0의 앞쪽에 dummy를 넣은 뒤 입력 순서대로 남은 슬롯을 채웁니다. 그림과 전체 표에서 ∅는 dummy 한 런, []는 남은 런이 없는 파일입니다. /는 파일 안의 런 경계입니다. 파일 이름 F0,F1,F2의 순서는 끝까지 고정합니다.
위 단계 개념도의 전체보기에서 다섯 상태의 미소비 런과 다음 역할 표를 확인할 수 있습니다. 그림과 표는 같은 상태 자료에서 만들어집니다.
첫 단계의 세 쌍을 풀어 보겠습니다. ∅+[9]→[9], [8]+[3]→[3,8], [1]+[7]→[1,7]입니다. F0에는 [6]과 [2]가 아직 읽히지 않은 상태로 남고, F1의 reader는 끝에 닿습니다. 따라서 F0의 저장 위치는 [6]의 헤더 앞이며 다음 단계에도 거기서 읽습니다. F1은 모두 소진됐으므로 닫은 뒤 다음 출력으로 비웁니다.
둘째 단계는 [6]+[9]→[6,9], [2]+[3,8]→[2,3,8]입니다. F2의 [1,7]은 아직 읽지 않으므로 셋째 단계 입력으로 남습니다. 셋째 단계는 [6,9]+[1,7]→[1,6,7,9], 넷째 단계는 그 런과 [2,3,8]을 병합합니다. 가상 런은 슬롯 수를 맞추지만 숫자 자료를 추가하거나 삭제하지 않습니다.
읽기·쓰기를 어떤 단위로 세는가
전체보기 표의 수치는 값 정수 레코드의 읽기·쓰기이며 길이 헤더·바이트·파일 버퍼 페이지·OS 호출은 제외합니다.
위 단계 개념도의 전체보기에서 병합 단계별 값 레코드 읽기·쓰기 표를 확인할 수 있습니다. 표의 수치는 각 단계에서 실제 병합한 런의 값 수로 계산합니다.
이 구현에는 별도로 처음 값·런 수를 세는 값 7개 읽기, 초기 분배의 값 7개 읽기+값 7개 쓰기, 마지막 결과 복사의 값 7개 읽기+값 7개 쓰기가 있습니다. 모두 합치면 값 레코드 읽기 42·쓰기 35입니다. 매 병합 쌍에는 두 길이 헤더 읽기와 한 길이 헤더 쓰기도 더 있습니다. 이 수치를 그대로 물리 디스크 접근 횟수나 실측 성능이라고 부르지 않습니다.
직접 실행하기
아래를 lesson.py로 저장해 Python으로 실행하세요. 표준 라이브러리만 사용하며 코드 파일 옆 private-temp 아래 전용 폴더에만 예제 원본·출력·세 임시 병합 파일을 만듭니다. 함수의 첫 순회는 int(line)으로 각 입력 줄도 검사합니다. 전체 N개 숫자를 한 목록으로 읽는 코드가 아닙니다.
긴 함수는 세 역할로 나누어 읽으세요. _chunks는 최대 M개씩 정렬해 내보내는 생성기, _write_run과 _read_integer는 헤더/값 줄을 쓰고 읽는 도구, _merge_one_run은 헤더에 적힌 두 런의 길이만큼만 소비하는 병합입니다. polyphase_sort_file은 세 파일의 런 수와 다음 읽기 위치를 관리합니다. 각 phase의 inputs·output은 이름이 고정된 파일 번호이며, before·after는 F0,F1,F2 순서의 논리 런 수입니다.
"""Three merge-work-file polyphase sorter; educational integer-line file format.
Original and destination files are additional files. Source is read twice;
only one sorted chunk, two merge front values and three stream offsets are kept.
Run header is an integer length; length zero is one dummy run slot.
"""
from pathlib import Path
from tempfile import TemporaryDirectory
def _chunks(source, size):
with source.open(encoding="utf-8") as stream:
chunk = []
for line in stream:
chunk.append(int(line))
if len(chunk) == size:
chunk.sort()
yield chunk
chunk = []
if chunk:
chunk.sort()
yield chunk
def _write_run(stream, values):
stream.write(f"{len(values)}\n")
for value in values:
stream.write(f"{value}\n")
def _read_integer(stream):
line = stream.readline()
if not line:
raise ValueError("truncated run file")
return int(line)
def _merge_one_run(left, right, output):
left_count, right_count = _read_integer(left), _read_integer(right)
if left_count < 0 or right_count < 0:
raise ValueError("negative run length")
length = left_count + right_count
output.write(f"{length}\n")
first = _read_integer(left) if left_count else None
second = _read_integer(right) if right_count else None
while first is not None or second is not None:
if second is None or (first is not None and first <= second):
output.write(f"{first}\n")
left_count -= 1
first = _read_integer(left) if left_count else None
else:
output.write(f"{second}\n")
right_count -= 1
second = _read_integer(right) if right_count else None
return length
def polyphase_sort_file(source, destination, chunk_size=3, *, work_dir=None):
"""Sort integer lines with three temporary merge files; return phase counters.
Source must remain unchanged during both streaming passes. Destination is a
disposable result path. Exact int chunk size >= 1; bool/float are rejected.
Header/offset counts refer to logical runs, including length-zero dummies.
"""
source, destination = Path(source), Path(destination)
if type(chunk_size) is not int or chunk_size < 1:
raise ValueError("positive integer chunk size required")
if source.resolve() == destination.resolve() or (source.exists() and destination.exists() and source.samefile(destination)):
raise ValueError("distinct paths required")
value_count = 0
with source.open(encoding="utf-8") as stream:
for line in stream:
int(line)
value_count += 1
real_runs = (value_count + chunk_size - 1) // chunk_size
if real_runs <= 1:
counts = [real_runs, 0, 0]
else:
large, small = 1, 1
while large + small < real_runs:
large, small = large + small, large
counts = [large, small, 0]
dummy_runs = sum(counts) - real_runs
initial = counts.copy()
io = {"scanReadValues": value_count,
"distributionReadValues": value_count,
"distributionWrittenValues": value_count,
"mergeReadValues": 0, "mergeWrittenValues": 0,
"finalReadValues": 0, "finalWrittenValues": 0}
phases = []
with TemporaryDirectory(prefix="polyphase-", dir=work_dir) as temporary:
paths = [Path(temporary) / f"file-{index}.txt" for index in range(3)]
for path in paths:
path.write_text("", encoding="utf-8")
runs = _chunks(source, chunk_size)
for index in (0, 1):
with paths[index].open("w", encoding="utf-8") as stream:
for slot in range(counts[index]):
if index == 0 and slot < dummy_runs:
_write_run(stream, [])
else:
_write_run(stream, next(runs))
# Finish the generator so its source stream closes and excess is noticed.
try:
next(runs)
except StopIteration:
pass
else:
raise ValueError("source changed between counting and run generation")
offsets = [0, 0, 0]
while sum(count > 0 for count in counts) > 1:
inputs = [index for index, count in enumerate(counts) if count]
output_index = next(index for index, count in enumerate(counts) if not count)
first, second = inputs
pairs = min(counts[first], counts[second])
before = counts.copy()
phase_values = 0
# Readers close before a consumed file is reused as the next output.
with paths[first].open(encoding="utf-8") as left, paths[second].open(encoding="utf-8") as right, paths[output_index].open("w", encoding="utf-8") as output:
left.seek(offsets[first])
right.seek(offsets[second])
for _ in range(pairs):
phase_values += _merge_one_run(left, right, output)
offsets[first], offsets[second] = left.tell(), right.tell()
offsets[output_index] = 0
counts[first] -= pairs
counts[second] -= pairs
counts[output_index] = pairs
io["mergeReadValues"] += phase_values
io["mergeWrittenValues"] += phase_values
phases.append({"before": before, "inputs": inputs,
"output": output_index, "mergedSlots": pairs,
"after": counts.copy(), "readValues": phase_values,
"writtenValues": phase_values})
final_index = next((index for index, count in enumerate(counts) if count), None)
with destination.open("w", encoding="utf-8") as output:
if final_index is not None:
with paths[final_index].open(encoding="utf-8") as stream:
stream.seek(offsets[final_index])
length = _read_integer(stream)
if length != value_count:
raise ValueError("final run length does not preserve input count")
for _ in range(length):
output.write(f"{_read_integer(stream)}\n")
io["finalReadValues"] = io["finalWrittenValues"] = length
return {"valueCount": value_count, "realRuns": real_runs,
"dummyRuns": dummy_runs, "initial": initial, "phases": phases,
"finalFile": final_index, "io": io}
if __name__ == "__main__":
private_root = Path(__file__).resolve().parent / "private-temp"
private_root.mkdir(exist_ok=True)
with TemporaryDirectory(prefix="polyphase-example-", dir=private_root) as temporary:
folder = Path(temporary)
source, output = folder / "input.txt", folder / "output.txt"
source.write_text("8\n1\n6\n2\n9\n3\n7\n", encoding="utf-8")
before = source.read_text(encoding="utf-8")
report = polyphase_sort_file(source, output, 1, work_dir=folder)
print(report["initial"])
for phase in report["phases"]:
print(phase["after"], phase["readValues"], phase["writtenValues"])
print([int(line) for line in output.read_text(encoding="utf-8").splitlines()])
print(source.read_text(encoding="utf-8") == before)실행 결과:
[5, 3, 0]
[2, 0, 3] 5 5
[0, 2, 1] 5 5
[1, 1, 0] 4 4
[0, 0, 1] 7 7
[1, 2, 3, 6, 7, 8, 9]
True빈 입력은 빈 최종 파일이며 phase가 없습니다. 한 실런만 있으면 정렬한 청크를 바로 결과로 복사하고 병합하지 않습니다. 일반 입력에서 마지막 런이 놓이는 파일은 R에 따라 달라지므로 F2라고 고정하지 않고 finalFile로 찾습니다. 원본 값이 같은 경우와 음수도 정수 비교로 정렬합니다.
왜 맞고 언제 끝나는가
초기 각 실런은 청크 정렬로 오름차순이며 dummy는 원소가 없는 정렬 런으로 볼 수 있습니다. _merge_one_run은 두 런의 남은 앞값 중 작은 값을 선택합니다. 각 입력 런이 이미 정렬돼 있으므로 그 값은 두 런 전체의 남은 최소입니다. 입력 길이의 합을 출력 헤더에 쓰고 값도 정확히 그 합만큼 쓰므로 정렬과 원소의 다중집합을 보존합니다.
한 phase 뒤 부분 소비된 파일의 위치와 남은 런 수를 유지하고, 이미 소진된 파일만 다음 출력으로 비웁니다. 따라서 아직 읽지 않은 값은 덮어쓰지 않습니다. dummy도 슬롯으로 소비하므로 매 phase의 총 논리 런 수는 min(두 입력 런 수)만큼 엄격히 줄어듭니다. 연속 피보나치 수의 차가 이전 수이므로 두 입력·한 빈 출력 구조가 이어지고, 결국 한 런만 남습니다. 그 길이는 입력 N과 같아야 합니다.
비용과 실패 한계
작은 정수·제한된 줄 길이와 고정 파일 버퍼 모델에서 초기 청크 정렬은 O(N log(M+1))입니다. R=ceil(N/M)일 때 피보나치 분포의 phase 수는 O(log(R+1))이고, 한 phase가 읽고 쓰는 실제 값은 N 이하라 값 전송의 상한은 O(N log(R+1))입니다. 세 파일의 런 수와 위치만 저장하며 모든 런의 목록을 RAM에 만들지 않습니다. 청크·정렬 보조 메모리 O(M), 고정 개수의 앞값·파일 버퍼에 더해 반환하는 phase 기록 O(log(R+1))를 보관합니다. 큰 정수·긴 줄은 바이트와 산술 비용을 별도로 봐야 합니다.
이 예제는 교육용 길이 헤더 형식과 세 병합 작업 파일만 구현합니다. 다중 입력 일반화·replacement selection·압축·비동기 I/O·디스크 장애 복구는 없습니다. 숫자만 저장하므로 같은 키의 서로 다른 레코드가 원래 순서를 지킨다는 안정성을 시험하거나 보장하지 않습니다. 디스크 부족·쓰기 오류·중단이 생기면 결과 파일이 일부만 쓰일 수 있어 원자적 교체를 보장하지 않습니다. 원본이 실행 중 변경되지 않는다는 계약을 벗어난 모든 상황을 탐지하는 구현도 아닙니다. 비교·값 전송 상한과 실제 대용량 성능은 구분하며, p-way보다 항상 빠르다고 주장하지 않습니다.
교재 안에서 확인하기
- 첫 단계에서 dummy와
[9]를 합쳐 읽고 쓰는 값은 몇 개인가요? 왜 논리 런 하나로 세나요? - 둘째 단계 뒤 F2 전체를 지워도 될까요? 남은 런과 다음 입력 역할을 근거로 설명하세요.
힌트
- 헤더의 길이와 슬롯 수를 구분합니다.
- F2에는 아직 소비하지 않은
[1,7]이 있습니다.
해설
- 읽고 쓴 값은 각각 1개이며 출력 헤더는 1입니다. dummy는 값 0개지만 배분·병합 계획에서는 한 슬롯이라 두 입력 런을 한 출력 런으로 줄입니다.
- 지우면
[1,7]을 잃습니다. F0가 소진되어 다음 출력이 되고, F1과 F2는 입력으로 남습니다. 논리 런 수가 0인 파일만 출력으로 비울 수 있습니다.
복습과 다음 연결
분배·가상 런의 이론적 배경은 Gordon College CS321 외부 정렬 강의에서 확인할 수 있습니다. 버퍼와 파일 I/O 비용 모델은 UC Berkeley CS186 정렬 노트와 연결해 읽으세요. 원 논문의 모든 증명·현대 대용량 성능을 이 작은 실행으로 검증한 것은 아닙니다. 다음은 종합 지도 문제입니다.