D-1: 정렬 ① 비교 기반 정렬 — O(n²)를 O(n log n)으로 끌어내린다
목차 31
안녕하세요, 홍순구 튜터입니다. 지난 시간까지 우리는 데이터를 담는 그릇을 다 만들었습니다. 카테고리 B에서 선형 자료구조(스택·큐·연결 리스트·해시)를, 카테고리 C에서 비선형 자료구조(트리·힙·그래프)를 손으로 짰죠. 이제 그릇을 넘어, 그 위에서 데이터를 다루는 알고리즘으로 넘어옵니다. 카테고리 D의 첫 주제는 정렬(sort)입니다.
정렬은 코딩테스트에서 가장 기본이 되는 무기입니다. 그 자체로 문제가 되기도 하지만, 더 중요한 건 정렬이 다른 알고리즘의 발판이 된다는 거예요. 정렬된 배열이라야 절반씩 좁혀 찾는 이진 탐색(D-3)이 돌고, 양 끝에서 조여 오는 투 포인터(D-4)가 통합니다. "일단 정렬해 놓으면 풀리는" 문제가 정말 많습니다.
그런데 "정렬 좀 해 봐"라고 하면 보통 버블 정렬 하나만 떠오릅니다. 이게 왜 느린지, 그리고 어떻게 병합·퀵 정렬로 훨씬 빠르게 끌어내리는지를 오늘 직접 짜 보며 익힙니다. 정렬 종류가 많아 두 시간에 나눠 배우는데, 오늘은 그중 비교 기반 정렬 여섯 가지입니다. 다음 시간엔 비교를 아예 안 하는 정렬과 실전 내장 정렬을 봅니다.
오늘의 여정 — 정렬 ①, 비교로 줄 세우는 여섯 가지
느린 정렬 (O(n²)) 빠른 정렬 (O(n log n)) 고르는 눈
버블·선택·삽입 병합 (분할정복) 입력 크기로 판단
비교하고 자리 바꾸기 퀵 (피벗 분할) 세 잣대로 견주기
힙 (C-2 회수)
💡 오늘 수업의 핵심 — "소박한 O(n²) 정렬 셋을 이해하고, 분할정복으로 O(n log n)까지 끌어내린 뒤, 병합·퀵·힙을 세 잣대로 견줘 고른다"
🎯 학습 목표
- 버블·선택·삽입 정렬이 왜 O(n²)인지 이해하고, 안정성·제자리·비교라는 세 잣대로 정렬을 본다.
- 병합·퀵 정렬을 분할정복으로 직접 구현하고, O(n log n)이 나오는 이유를 설명한다.
- 힙 정렬로 C-2의 힙을 회수하고, 입력 크기를 보고 어떤 정렬을 쓸지 빅오로 고른다.
Step 1: "왜 줄을 세우나 — 정렬을 보는 세 잣대"
정렬이 뭔지는 다 압니다. 뒤죽박죽인 데이터를 크기순으로 줄 세우는 거죠. [5, 2, 9, 1]을 [1, 2, 5, 9]로 만드는 것. 그런데 왜 이걸 이렇게 열심히 배울까요?
정렬은 그 자체보다 "정렬한 다음에 뭘 할 수 있느냐"가 진짜 힘입니다. 뒤섞인 배열에서 특정 값을 찾으려면 처음부터 끝까지 훑어야 해서 O(n)이지만, 정렬해 두면 절반씩 좁혀 O(log n)에 찾습니다(이진 탐색, 다음 시간에 배웁니다). 중복 제거, 두 번째로 큰 값 찾기, 겹치는 구간 병합 같은 문제도 정렬 한 번이면 훨씬 쉬워져요. 그래서 "막히면 일단 정렬해 보라"는 말이 코테의 격언입니다.
정렬 알고리즘은 종류가 많은데, 앞으로 하나하나 볼 때마다 늘 세 가지 잣대로 견주겠습니다. 이 세 잣대가 오늘 내내 비교표의 기둥이 됩니다.
- 안정성(stable): 값이 같은 원소들의 원래 순서가 정렬 뒤에도 지켜지는가.
- 제자리(in-place): 입력 배열 말고 추가로 쓰는 공간이 O(1)로 적은가.
- 비교(comparison): 원소끼리 크기를 직접 비교해서 순서를 정하는가.
안정성이 조금 낯설 텐데, 그림으로 보면 금방입니다. 이름과 점수 묶음을 점수순으로 정렬한다고 해 봅시다.
안정 정렬 — 점수가 같은 (민지 80)·(수현 80)의 원래 순서가 유지된다
입력: (민지,80) (지훈,95) (수현,80) (예린,70)
점수순 정렬 ↓
안정: (예린,70) (민지,80) (수현,80) (지훈,95)
└ 80 동점: 민지 → 수현 (입력 순서 그대로)
불안정: (예린,70) (수현,80) (민지,80) (지훈,95)
└ 80 동점: 수현 → 민지 (순서가 뒤바뀜)
동점일 때 원래 순서가 지켜지면 안정 정렬입니다. "점수순으로 정렬하되 같은 점수는 먼저 등록한 사람이 위로" 같은 요구가 흔해서, 안정성은 실전에서 꽤 중요합니다.
오늘 배울 여섯 정렬은 전부 세 번째 잣대, 비교 기반입니다. 원소끼리 크고 작음을 따져 순서를 정해요. 비교를 아예 하지 않고 O(n)까지 내려가는 계수·기수 정렬은 다음 시간에 만납니다.
🎯 코테에서는 정렬 자체를 직접 짜라는 문제보다, "정렬한 뒤에 무엇을 하느냐"가 핵심인 경우가 훨씬 많습니다. 좌표를 정렬해 겹침을 확인하거나, 정렬 후 이진 탐색·투 포인터로 O(n²)를 O(n log n)으로 줄이는 식이죠. 그래서 정렬은 "풀이의 1단계"로 깔고 갑니다.
💡 한 줄 정리
정렬은 탐색·투 포인터 같은 뒷 알고리즘의 발판이고, 모든 정렬은 안정성·제자리·비교라는 세 잣대로 견준다.
🙋 학생 질문 — "정렬은 그냥 sorted() 한 줄이면 되는데 왜 직접 짜나요?"
맞습니다. 실전 코테에서는 대부분 파이썬 내장 sorted() 한 줄을 씁니다(오늘 마지막 Step에서 다뤄요). 그런데도 직접 짜 보는 이유는 두 가지예요.
첫째, 원리를 알아야 판단이 섭니다. "이 입력에 이 정렬을 쓰면 시간 안에 드나", "안정성이 필요한 상황인가"를 가늠하려면 각 정렬이 속으로 어떻게 도는지를 알아야 합니다. 둘째, 면접에서 자주 물어요. "퀵 정렬이 왜 평균 O(n log n)인데 최악은 O(n²)인가요?" 같은 질문에 답하려면 손으로 짜 본 경험이 필요합니다.
원리는 직접 짜서 보고, 실전에선 내장 정렬을 쓴다 — 이 두 트랙이 이 과목 내내 이어집니다.
Step 2: "O(n²) 정렬 삼형제 — 버블·선택·삽입"
가장 소박한 정렬 셋부터 봅시다. 버블, 선택, 삽입. 셋 다 "비교하고 자리를 바꾼다"는 발상이 비슷하고, 셋 다 시간 복잡도가 O(n²)입니다. 왜 셋이 다 O(n²)인지, 그런데도 미묘하게 어떻게 다른지를 보는 게 이 Step의 목표예요.
버블 정렬 — 큰 값이 거품처럼 뒤로
인접한 두 원소를 비교해서, 왼쪽이 더 크면 자리를 바꿉니다. 이걸 왼쪽부터 끝까지 반복하면 한 바퀴에 가장 큰 값이 물거품처럼 맨 뒤로 떠올라요.
버블 정렬 한 바퀴 — [5, 2, 9, 1] 에서 가장 큰 9가 뒤로
[5, 2, 9, 1] → 5 > 2, 교환 → [2, 5, 9, 1]
[2, 5, 9, 1] → 5 < 9, 유지 → [2, 5, 9, 1]
[2, 5, 9, 1] → 9 > 1, 교환 → [2, 5, 1, 9]
9가 맨 뒤에 안착 ┘
한 바퀴에 제일 큰 값 하나가 뒤로 갑니다. 다음 바퀴는 이미 자리 잡은 맨 뒤 하나를 빼고 돌면 돼요. n개를 정렬하려면 이런 바퀴를 약 n번 돌고, 각 바퀴가 약 n번 비교하니 O(n²)입니다.
# algorithms/sorting.py
def bubble_sort(arr):
a = list(arr)
n = len(a)
for i in range(n - 1):
swapped = False
for j in range(n - 1 - i): # 이미 뒤로 간 i개는 건드리지 않는다
if a[j] > a[j + 1]: # 왼쪽이 더 크면 자리를 바꿔 뒤로 민다
a[j], a[j + 1] = a[j + 1], a[j]
swapped = True
if not swapped: # 한 바퀴 동안 교환 0회 → 이미 정렬됨
break
return a
swapped 플래그 한 줄이 작은 최적화입니다. 한 바퀴를 다 돌았는데 교환이 한 번도 없었다면, 이미 정렬이 끝난 거라 곧장 멈춥니다. 덕분에 이미 정렬된 입력은 한 바퀴만 돌고 끝나 O(n)이 돼요. 그리고 버블 정렬은 값이 같을 때는 교환하지 않으니(>로만 바꿈) 안정 정렬입니다.
선택 정렬 — 최솟값을 골라 앞으로
이번엔 남은 부분에서 가장 작은 값을 골라 맨 앞으로 보냅니다. 맨 앞에 놓을 최솟값을 전부 훑어 찾고, 그다음에 놓을 최솟값을 또 찾고, 이렇게 반복해요.
def selection_sort(arr):
a = list(arr)
n = len(a)
for i in range(n - 1):
min_idx = i
for j in range(i + 1, n): # i 뒤에서 가장 작은 값의 위치를 찾는다
if a[j] < a[min_idx]:
min_idx = j
a[i], a[min_idx] = a[min_idx], a[i] # 찾은 최솟값을 i 자리로 (한 번만 교환)
return a
선택 정렬의 특징은 교환이 한 바퀴에 딱 한 번뿐이라는 겁니다. 최댓값을 찾는 동안 위치만 기억했다가, 마지막에 한 번만 맞바꿔요. 그래서 "쓰기(교환)가 비싼" 환경에 유리합니다. 대신 최솟값을 찾으려면 남은 전부를 매번 훑어야 해서, 입력이 이미 정렬돼 있어도 비교는 늘 n²/2번 — 최선도 O(n²)입니다. 그리고 멀리 있는 값과 통째로 자리를 바꾸다 보니 같은 값의 순서가 뒤집힐 수 있어 불안정합니다.
삽입 정렬 — 정렬된 앞부분에 끼워 넣기
세 번째는 삽입 정렬입니다. 카드를 손에 들고 정렬하는 방식과 똑같아요. 왼쪽은 늘 정렬된 상태로 두고, 새 카드(key)를 그 정렬된 부분의 알맞은 곳에 밀어 넣습니다.
def insertion_sort(arr):
a = list(arr)
for i in range(1, len(a)):
key = a[i]
j = i - 1
while j >= 0 and a[j] > key: # key보다 큰 값들을 오른쪽으로 한 칸씩 민다
a[j + 1] = a[j]
j -= 1
a[j + 1] = key # 큰 값들이 비켜준 자리에 key를 놓는다
return a
삽입 정렬에는 남다른 강점이 하나 있습니다. key보다 큰 값들만 오른쪽으로 밀면 되니까, 입력이 거의 정렬돼 있으면 밀 일이 거의 없어 O(n)에 가까워요. 그래서 파이썬 내장 정렬(Timsort)도 작은 조각이나 거의 정렬된 부분은 삽입 정렬로 처리합니다. 이 이야기는 마지막 Step에서 다시 만나요. 같은 값은 앞뒤가 안 바뀌니 삽입 정렬도 안정 정렬입니다.
셋을 나란히 놓고 보면
셋을 표로 견주면 차이가 또렷해집니다.
| 정렬 | 평균 | 최선 | 공간 | 안정성 | 한 줄 특징 |
|---|---|---|---|---|---|
| 버블 | O(n²) | O(n) | O(1) | 안정 | 인접 교환, 이미 정렬되면 빨리 멈춤 |
| 선택 | O(n²) | O(n²) | O(1) | 불안정 | 교환 최소(n-1회), 비교는 늘 최대 |
| 삽입 | O(n²) | O(n) | O(1) | 안정 | 거의 정렬된 입력에 강함(Timsort 부품) |
셋 다 추가 공간 O(1)인 제자리 정렬입니다. 그리고 세 함수 모두 같은 답을 냅니다.
정렬([5, 2, 9, 1, 5, 6]) → [1, 2, 5, 5, 6, 9] (세 정렬 모두 동일)
🎯 코테에서는 이 세 정렬을 실전에서 직접 쓰는 일은 드뭅니다(내장 정렬이 더 빠르니까요). 다만 "정렬을 직접 구현하라"거나 삽입 정렬의 동작을 묻는 문제가 나오고, 무엇보다 O(n²)가 왜 느린지를 몸으로 알아야 O(n log n)의 고마움을 압니다. 데이터가 아주 작거나(수십 개) 거의 정렬돼 있으면 삽입 정렬이 오히려 실용적일 때도 있어요.
💡 한 줄 정리
버블·선택·삽입은 모두 O(n²) 제자리 정렬이지만, 삽입 정렬만은 거의 정렬된 입력에서 O(n)으로 빨라지고 안정적이다.
🙋 학생 질문 — "선택 정렬은 교환이 n번뿐인데 왜 버블·삽입보다 안 빠른가요?"
좋은 관찰입니다. 교환 횟수만 보면 선택 정렬이 가장 적어요(최대 n-1번). 하지만 시간 복잡도를 정하는 건 교환이 아니라 비교입니다. 선택 정렬은 매 바퀴 "남은 것 중 최솟값"을 찾으려고 남은 전부를 훑어야 해서, 입력이 어떻든 비교를 늘 n²/2번 합니다. 이미 정렬된 입력이 들어와도 마찬가지예요.
반면 버블·삽입은 "이미 정렬됐다"는 신호를 감지하면 일을 덜 합니다(버블은 교환 0회면 멈추고, 삽입은 밀 값이 없으면 곧장 넘어가요). 그래서 거의 정렬된 데이터에선 버블·삽입이 O(n)까지 빨라지는데, 선택 정렬은 그 이득을 못 봅니다. "교환이 적다"와 "빠르다"는 다른 이야기예요.
Step 3: "나눠서 합친다 — 병합 정렬"
이제 O(n²)의 벽을 넘습니다. 첫 주자는 병합 정렬(merge sort)이에요. 발상이 아주 강력한데, "혼자 다 정렬하려니 O(n²)니까, 반으로 쪼개서 각각 정렬한 뒤 합치자"입니다.
이 발상에 이름이 있습니다. 분할정복(divide and conquer)이에요. 문제를 더 못 쪼갤 때까지 반씩 나누고(divide), 작은 답들을 합치며 되돌아온다(conquer)는 사고법입니다. 이 사고법 자체는 나중에 E-3에서 본격적으로 다루는데, 오늘은 정렬로 그 맛을 먼저 봅니다.
병합 정렬 — [5, 2, 9, 1] 을 반씩 쪼갰다 다시 합친다
분할 (내려가며 쪼갬) 병합 (올라오며 합침)
[5, 2, 9, 1] [1, 2, 5, 9]
├ [5, 2] → [2, 5]
│ ├ [5] [5]
│ └ [2] [2]
└ [9, 1] → [1, 9]
├ [9] [9]
└ [1] [1]
원소가 하나가 될 때까지 쪼개면, 하나짜리는 그 자체로 이미 정렬된 상태입니다. 거기서부터 두 조각씩 합치며 올라와요.
def merge_sort(arr):
a = list(arr)
if len(a) <= 1: # 원소 0~1개는 이미 정렬된 상태 (종료 조건)
return a
mid = len(a) // 2
left = merge_sort(a[:mid]) # 왼쪽 절반을 정렬
right = merge_sort(a[mid:]) # 오른쪽 절반을 정렬
return merge(left, right) # 정렬된 두 조각을 하나로 합친다
핵심은 "정렬된 두 조각을 하나로 합치는" merge입니다. 양쪽 맨 앞을 비교해서 작은 쪽을 먼저 담고, 담은 쪽 포인터만 한 칸 옮겨요. 두 조각이 이미 정렬돼 있으니 맨 앞만 봐도 충분합니다.
def merge(left, right):
merged = []
i = j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]: # 같을 때 왼쪽 먼저 → 안정성 보장
merged.append(left[i])
i += 1
else:
merged.append(right[j])
j += 1
merged.extend(left[i:]) # 한쪽이 남으면 통째로 이어 붙인다
merged.extend(right[j:])
return merged
left[i] <= right[j]에서 등호가 안정성의 비결입니다. 값이 같을 때 왼쪽(원래 앞에 있던) 조각을 먼저 담으니 순서가 지켜져요.
시간 복잡도가 왜 O(n log n)일까요? 쪼개는 깊이를 보면 됩니다. n개를 반씩 쪼개면 1이 될 때까지 약 log n번 쪼개집니다. 그리고 각 깊이에서 합치는 데 원소를 한 번씩 훑으니 O(n)이에요. 깊이(log n) × 각 깊이의 일(n) = O(n log n)입니다. 중요한 건 이게 최악에도 흔들리지 않는다는 점이에요. 어떤 입력이 와도 늘 반씩 쪼개지니까요. 대신 합칠 때 임시 리스트가 필요해 추가 공간을 O(n) 씁니다.
🎯 코테에서는 병합 정렬의
merge단계는 그 자체로 자주 쓰입니다. "정렬된 두 배열을 합쳐라", "여러 정렬된 리스트를 병합하라" 같은 문제가 대표적이에요. 그리고 안정성이 필요하면서 최악의 성능도 보장돼야 할 때(O(n log n) 확정) 병합 정렬을 씁니다.
💡 한 줄 정리
병합 정렬은 반씩 쪼개 각각 정렬한 뒤 합치는 분할정복으로, 최악에도 O(n log n)을 보장하는 안정 정렬이다(공간 O(n)).
🙋 학생 질문 — "쪼개기만 하면 정렬이 저절로 되나요? 정렬은 대체 언제 일어나죠?"
핵심을 짚으셨어요. 쪼개는 단계에서는 아무 정렬도 일어나지 않습니다. 그냥 반씩 나누기만 해요. 정렬은 전부 합치는(merge) 단계에서 일어납니다.
원소가 하나가 되면 "정렬된 조각"으로 봅니다(하나짜리는 정렬할 게 없으니까요). 그 다음부터 두 개짜리를 합칠 때 비교해서 순서대로 담고, 그렇게 만든 정렬된 조각 둘을 또 합치고... 올라올 때마다 조금씩 정렬된 크기가 커집니다. 그래서 "쪼개는 건 준비, 합치는 게 정렬"이라고 기억하면 됩니다. 분할정복의 conquer(합치며 정복)가 바로 이 대목이에요.
Step 4: "제자리에서 가른다 — 퀵 정렬"
병합 정렬은 O(n log n)을 보장하지만 임시 배열이 필요했죠. 퀵 정렬(quick sort)은 임시 배열 없이 배열 안에서 자리를 바꿔 가며 정렬합니다. 그래서 평균적으로 병합 정렬보다 빠르고, 실무 라이브러리들이 오래 애용해 온 정렬이에요.
발상은 이렇습니다. 피벗(pivot)이라는 기준값을 하나 정하고, 그보다 작은 값은 왼쪽, 큰 값은 오른쪽으로 몰아요. 그러면 피벗은 정렬된 자기 자리에 딱 놓입니다. 그 좌우를 같은 방식으로 또 정렬하면 끝이에요.
partition — 피벗 3을 기준으로 작은 것/큰 것 가르기 [5, 2, 9, 1, 6, 3]
피벗 = 3 (맨 끝 원소)
3 이하는 왼쪽으로, 3 초과는 오른쪽으로 몰면:
[ 2, 1 ] [ 3 ] [ 5, 6, 9 ]
작은 값 피벗 큰 값
3이 정렬된 제자리에 안착 ┘
→ 왼쪽 [2, 1] 과 오른쪽 [5, 6, 9] 를 각각 다시 퀵 정렬
이 "가르는" 일을 하는 게 partition입니다. 맨 끝 원소를 피벗으로 삼고, 피벗보다 작은 값들을 왼쪽으로 당겨 모은 뒤 피벗을 그 경계로 옮겨요.
def quick_sort(arr):
a = list(arr)
_quick_sort(a, 0, len(a) - 1)
return a
def _quick_sort(a, lo, hi):
if lo < hi:
p = partition(a, lo, hi) # 피벗을 제자리에 놓고 그 위치를 받는다
_quick_sort(a, lo, p - 1) # 피벗 왼쪽(작은 값들)
_quick_sort(a, p + 1, hi) # 피벗 오른쪽(큰 값들)
def partition(a, lo, hi):
pivot = a[hi]
i = lo - 1
for j in range(lo, hi):
if a[j] <= pivot:
i += 1
a[i], a[j] = a[j], a[i]
a[i + 1], a[hi] = a[hi], a[i + 1] # 피벗을 작은/큰 무리의 경계로 옮긴다
return i + 1
i는 "피벗보다 작은 값들의 마지막 위치"를 가리킵니다. j로 훑다가 피벗 이하인 값을 만나면 i를 한 칸 늘려 그 자리로 당겨 와요. 다 훑으면 피벗을 i+1로 보내 작은 무리와 큰 무리의 경계에 놓습니다.
퀵 정렬의 시간 복잡도는 두 얼굴입니다. 피벗이 매번 딱 중간값이면 병합 정렬처럼 반씩 쪼개져 평균 O(n log n)이에요. 그런데 피벗이 매번 한쪽 끝값이면(예를 들어 이미 정렬된 입력에 맨 끝을 피벗으로 잡으면) 분할이 1과 나머지로 치우쳐 O(n²)로 무너집니다. 이게 퀵 정렬의 유명한 최악의 경우예요. 추가 공간은 재귀 호출 스택으로 평균 O(log n)입니다.
병합과 퀵을 나란히 두면 성격이 잘 드러납니다.
| 정렬 | 평균 | 최악 | 공간 | 안정성 | 특징 |
|---|---|---|---|---|---|
| 병합 | O(n log n) | O(n log n) | O(n) | 안정 | 최악에도 흔들림 없음, 임시 배열 필요 |
| 퀵 | O(n log n) | O(n²) | O(log n) | 불안정 | 평균 최고속·제자리, 피벗 잘못 잡으면 무너짐 |
🎯 코테에서는 퀵 정렬을 직접 짜기보다, "왜 최악이 O(n²)인가"와 "어떻게 피하나(피벗을 무작위나 중앙값으로)"를 면접에서 자주 묻습니다. 파이썬 내장 정렬은 퀵이 아니라 병합 계열(Timsort)이라 이 최악을 피한다는 점도 함께 기억해 두세요.
💡 한 줄 정리
퀵 정렬은 피벗으로 좌우를 갈라 제자리에서 정렬하는 분할정복으로, 평균 O(n log n)이지만 피벗이 치우치면 최악 O(n²)가 된다.
🙋 학생 질문 — "이미 정렬된 배열이 들어오면 퀵 정렬이 오히려 최악이라니, 이상하지 않나요?"
직관에 어긋나서 헷갈리는 부분이에요. 보통 "이미 정렬됐으면 편하겠지" 싶은데, 우리 코드처럼 맨 끝 원소를 피벗으로 잡으면 정반대가 됩니다.
이미 정렬된 [1, 2, 3, 4, 5]에서 맨 끝 5를 피벗으로 잡으면, 5보다 작은 게 왼쪽 전부라 분할이 [1,2,3,4]와 []로 쪼개집니다. 반씩이 아니라 1과 나머지로 갈려요. 그 다음도 4를 피벗으로 또 1:나머지... 이렇게 n번 재귀하며 각 단계가 O(n)이라 O(n²)가 됩니다.
그래서 실무 퀵 정렬은 피벗을 맨 끝이 아니라 무작위로 고르거나, 처음·중간·끝 셋 중 중앙값을 씁니다(median-of-three). 이러면 특정 입력에 최악이 걸릴 확률이 확 낮아져요. 이 최악 회피는 오늘 응용 과제로 직접 손봅니다.
Step 5: "다 넣고 하나씩 빼면? — 힙 정렬과 O(n log n) 삼총사"
O(n log n) 정렬이 하나 더 있습니다. 그런데 이건 새로 배우는 게 아니라, 지난 시간에 이미 절반을 만들어 뒀어요. C-2에서 힙을 배우며 이런 말을 했던 걸 기억하시나요? "최소 힙은 가장 작은 값이 늘 맨 위에 있으니, 전부 넣고 하나씩 빼면 작은 순서대로 나온다." 그게 바로 힙 정렬(heap sort)입니다.
원리를 다시 짚으면, 최소 힙은 넣을 때(push)나 뺄 때(pop)나 트리 높이만큼만 손보면 되니 O(log n)이었죠. 그러니 n개를 다 넣고 하나씩 빼면 오름차순으로 정렬돼서 나옵니다. 여기서는 C-2에서 직접 짠 MinHeap 대신, 실전에서 쓰는 표준 라이브러리 heapq를 그대로 씁니다.
def heap_sort(arr):
import heapq
a = list(arr)
heapq.heapify(a) # 리스트를 최소 힙으로 O(n)
return [heapq.heappop(a) for _ in range(len(a))] # 하나씩 빼면 오름차순 O(n log n)
heapify는 리스트를 통째로 최소 힙으로 만드는데 O(n)이고, 그 뒤 heappop을 n번 하면 각 pop이 O(log n)이라 전체가 O(n log n)입니다. 힙은 완전 이진트리라 높이가 늘 log n으로 균형이 보장돼요. 그래서 퀵 정렬과 달리 최악에도 O(n log n)이 무너지지 않습니다. 다만 정렬 결과를 담을 새 리스트가 필요해 공간은 O(n)이고, 힙에 넣고 빼는 과정에서 같은 값의 순서가 흐트러질 수 있어 불안정합니다.
이제 O(n log n) 세 정렬이 다 모였습니다. 병합·퀵·힙을 세 잣대로 견줘 보면 각자 언제 빛나는지 보여요.
| 정렬 | 평균 | 최악 | 공간 | 안정성 | 언제 쓰나 |
|---|---|---|---|---|---|
| 병합 | O(n log n) | O(n log n) | O(n) | 안정 | 안정성이 필요하고 최악도 보장돼야 할 때 |
| 퀵 | O(n log n) | O(n²) | O(log n) | 불안정 | 평균 속도가 가장 중요하고 메모리가 빠듯할 때 |
| 힙 | O(n log n) | O(n log n) | O(n) | 불안정 | 최악을 보장하면서, 상위 K개만 필요할 때 |
세 정렬 다 O(n log n)이지만 결이 다릅니다. 병합은 안정적이고 최악이 없지만 메모리를 더 쓰고, 퀵은 평균 최고속이지만 최악이 있고, 힙은 최악이 없지만 불안정합니다. "공짜 점심은 없다"는 말이 여기서도 통해요.
🎯 코테에서는 힙 정렬을 통째로 쓰기보다, 힙의 "가장 급한 것부터 꺼낸다"는 성질을 K번째 큰 수, 우선순위 큐, 다익스트라(F-1) 같은 데서 씁니다. "전체를 정렬할 필요 없이 상위 K개만" 필요할 때 힙이 특히 강해요(전부 정렬하는 O(n log n) 대신 O(n log K)).
💡 한 줄 정리
힙 정렬은 C-2의 힙으로 "다 넣고 하나씩 빼면 정렬"을 실현하며, 병합·퀵·힙 세 O(n log n) 정렬은 안정성·공간·최악 보장에서 서로 다르게 절충한다.
🙋 학생 질문 — "셋 다 O(n log n)이면 그냥 아무거나 써도 되는 거 아닌가요?"
빅오만 보면 그렇게 보이지만, 빅오는 상수와 세부 성질을 버린 큰 그림일 뿐이에요. 같은 O(n log n) 안에서도 실제 속도와 성질이 다릅니다.
예를 들어 "동점은 먼저 온 순서를 유지해 달라"는 요구가 있으면 안정 정렬인 병합만 후보가 됩니다. 메모리가 빠듯한 임베디드 환경이면 추가 공간이 적은 쪽을 골라야 하고요. 최악의 순간에도 시간을 보장해야 하는 시스템이면 퀵은 위험합니다(최악 O(n²)). 실제 속도로는 퀵이 평균적으로 가장 빠른 편이라 많은 라이브러리가 퀵 계열을 기본으로 삼았어요.
그래서 "O(n log n)이니 아무거나"가 아니라, 안정성·공간·최악 보장 중 무엇이 중요한지를 보고 고릅니다. 다음 Step에서 이 고르는 눈을 입력 크기까지 넣어 정리해요.
Step 6: "빅오로 고르는 정렬 — 입력 크기별 판단"
정렬을 여섯 개 배웠으니, 이제 "언제 무엇을 쓰나"를 정리할 차례입니다. 코딩테스트의 첫 판단은 늘 똑같아요. 입력 크기 n을 보고 "어떤 복잡도까지 시간 안에 드나"를 가늠하는 거죠. A-1에서 배운 "1초에 약 1억 번 연산" 잣대를 정렬에 대 봅시다.
| n (입력 크기) | O(n²) 연산 수 | O(n log n) 연산 수 | 판정 |
|---|---|---|---|
| 1,000 | 약 100만 | 약 1만 | 둘 다 순식간 |
| 100,000 | 약 100억 | 약 170만 | O(n²) 시간 초과, O(n log n) OK |
| 1,000,000 | 약 1조 | 약 2,000만 | O(n²) 절대 불가, O(n log n) OK |
표가 말해 주는 게 분명합니다. n이 수만을 넘어가면 O(n²) 정렬은 시간 초과예요. n=100,000만 돼도 O(n²)는 100억 번이라 100초쯤 걸리는데, O(n log n)은 170만 번이라 눈 깜짝할 새죠. 그래서 코테에서 "정렬해야겠다" 싶으면 거의 항상 O(n log n)을 씁니다.
그럼 O(n²) 정렬은 배울 필요도 없었을까요? 그렇진 않습니다. 두 가지 경우가 있어요. 첫째, 입력이 아주 작을 때(수십 개)는 O(n²)든 O(n log n)이든 차이가 없고, 오히려 간단한 삽입 정렬이 상수가 작아 더 빠를 수도 있습니다. 둘째, 거의 정렬된 데이터라면 삽입 정렬이 O(n)까지 빨라져 O(n log n)보다도 유리해요. 실제로 파이썬 내장 정렬이 이 성질을 씁니다(다음 Step에서 봅니다).
정리하면 이렇게 고릅니다.
정렬 고르는 눈 — 흐름으로 보기
입력이 수십 개로 아주 작다 → 아무거나 (삽입이 간단·빠름)
거의 정렬돼 있다 → 삽입 정렬 (O(n)에 근접)
일반적인 큰 입력 → O(n log n) (실전은 내장 정렬)
├ 안정성이 필요하다 → 병합 (또는 내장 정렬)
├ 메모리가 빠듯하다 → 퀵 (제자리)
└ 상위 K개만 필요하다 → 힙
🎯 코테에서는 문제를 받으면 제일 먼저 입력 크기 제한(n의 범위)을 봅니다. n이 100만이면 O(n²)는 애초에 후보에서 빠지고, "정렬 후 O(n) 또는 O(log n)으로 푸는" 방향을 잡아야 해요. 입력 크기가 알고리즘을 고르는 나침반입니다.
💡 한 줄 정리
n이 수만을 넘으면 O(n²) 정렬은 시간 초과라 O(n log n)을 쓰되, 아주 작거나 거의 정렬된 입력에서는 삽입 정렬이 오히려 유리하다.
🙋 학생 질문 — "그럼 실전에선 무조건 O(n log n)만 기억하면 되나요?"
거의 그렇습니다. "큰 입력을 정렬하려면 O(n log n)"을 기본값으로 두면 대부분 맞아요. 다만 두 예외를 알면 실력이 한 뼘 더 올라갑니다.
하나는 거의 정렬된 데이터에서의 삽입 정렬입니다. 로그가 실시간으로 조금씩 들어와 거의 정렬된 상태를 유지하는 상황 같은 데서 유용해요. 다른 하나는 다음 시간에 배울 계수 정렬인데, 값의 범위가 좁은 정수라면 비교를 아예 안 하고 O(n)에 정렬합니다. O(n log n)이 비교 정렬의 한계일 뿐, 조건이 맞으면 그 아래로도 내려갈 수 있다는 거죠. 그 이야기가 바로 다음 시간의 문을 엽니다.
Step 7: "실전에선 결국 내장 정렬 — 그리고 비교의 벽"
오늘 여섯 정렬을 손으로 짜 봤습니다. 그런데 솔직히 말하면, 실전 코딩테스트에서 이걸 직접 짜는 일은 거의 없습니다. 파이썬이 이미 훌륭한 정렬을 내장하고 있거든요.
sorted([5, 2, 9, 1, 5, 6]) # → [1, 2, 5, 5, 6, 9] (새 리스트 반환)
nums.sort() # 리스트를 제자리에서 정렬 (반환값 None)
sorted()와 list.sort()는 C로 구현돼 있어 우리가 파이썬으로 짠 것보다 훨씬 빠르고, 오랜 세월 검증돼 버그도 없습니다. 그래서 두 트랙으로 기억하세요. 원리는 오늘처럼 직접 짜서 이해하고, 실전에선 내장 정렬을 씁니다. 직접 짜는 건 면접에서 원리를 묻거나, 표준에 없는 특별한 변형이 필요할 때예요.
파이썬 내장 정렬의 정체가 재밌습니다. Timsort라는 정렬인데, 오늘 배운 병합 정렬과 삽입 정렬을 섞은 하이브리드예요. 큰 흐름은 병합 정렬로 O(n log n)을 보장하되, 작은 조각이나 이미 정렬된 부분은 삽입 정렬로 빠르게 처리합니다. Step 2에서 "삽입 정렬은 Timsort의 부품"이라 했던 게 이 대목이에요. 그리고 Timsort는 안정 정렬이라, 파이썬 sorted()는 늘 안정적입니다.
정렬로 무엇을 커스텀할 수 있는지(정렬 기준을 바꾸는 key, 튜플로 여러 기준을 한 번에 거는 법)는 다음 시간에 본격적으로 다룹니다. 오늘은 "실전은 내장 정렬"이라는 큰 그림만 잡아 두세요.
마지막으로 한 가지 벽을 짚고 넘어갑니다. 오늘 배운 정렬은 전부 원소끼리 비교했죠. 그런데 비교만으로 정렬하면 아무리 잘해도 O(n log n)이 한계입니다. 이걸 비교 정렬의 하한이라고 불러요. 그렇다면 이 벽을 넘을 방법은 없을까요? 있습니다. 비교를 아예 하지 않으면 됩니다. 값을 세거나 자릿수로 나누면 비교 없이 O(n)에 정렬할 수 있어요. 그게 다음 시간의 주인공, 계수 정렬과 기수 정렬입니다.
🎯 코테에서는 정렬은
sorted()한 줄로 끝내는 게 정석입니다. 시간을 아껴 진짜 문제(정렬 후의 로직)에 집중하세요. 다만 "정렬이 안정적인가"를 물으면 파이썬은 안정 정렬이라고 답할 수 있어야 하고, 커스텀 정렬 기준(key)은 다음 시간에 확실히 익힙니다.
💡 한 줄 정리
실전에선 C로 구현된 안정 정렬 Timsort(sorted/list.sort)를 쓰며, 비교 정렬의 한계인 O(n log n)을 넘으려면 비교를 버린 계수·기수 정렬(다음 시간)이 필요하다.
🙋 학생 질문 — "비교만 하면 왜 O(n log n)이 한계인가요?"
직관적으로만 짚어 볼게요(엄밀한 증명은 면접 답변 영역이라 여기선 감만 잡습니다). n개를 정렬한다는 건, 가능한 모든 순서(n개를 늘어놓는 경우의 수 = n!) 중에서 맞는 하나를 골라내는 일이에요.
비교 한 번은 "A가 B보다 큰가?"라는 예/아니오 질문 하나입니다. 예/아니오 질문 하나로는 후보를 최대 절반으로 줄일 수 있어요. n!개의 후보를 절반씩 줄여 하나로 좁히려면 질문이 약 log₂(n!)번 필요한데, 이 값이 수학적으로 약 n log n입니다. 그래서 "비교만으로는" O(n log n) 아래로 못 내려가요.
이 벽을 넘는 방법은 질문 방식을 바꾸는 겁니다. "A가 B보다 큰가"를 묻는 대신 "이 값이 3이야"처럼 값 자체를 열쇠로 쓰면(계수 정렬), 비교라는 틀을 벗어나 O(n)이 가능해집니다. 다음 시간에 직접 보게 돼요.
마무리
오늘은 카테고리 D의 문을 열며 비교 기반 정렬 여섯 가지를 손으로 짰습니다. 뒤죽박죽인 데이터를 O(n²)의 소박한 방법으로 줄 세워 보고, 분할정복으로 O(n log n)까지 끌어내린 뒤, 세 잣대로 골라 쓰는 눈까지 길렀어요.
오늘 배운 핵심 세 가지
- 💡 하나 — 소박한 정렬 셋은 모두 O(n²)다. 버블·선택·삽입은 비교하고 자리를 바꾸는 발상이지만 O(n²)라 큰 입력엔 느리다. 단 삽입 정렬은 거의 정렬된 입력에서 O(n)으로 빨라지고 안정적이다.
- 💡 둘 — 분할정복으로 O(n log n)까지 내려간다. 병합 정렬은 반씩 쪼개 합쳐 최악에도 O(n log n)을 보장하고, 퀵 정렬은 피벗으로 갈라 평균 O(n log n)이지만 치우치면 최악 O(n²)가 된다.
- 💡 셋 — 힙 정렬로 C-2를 회수하고, 세 잣대로 고른다. "다 넣고 하나씩 빼면 정렬"이 힙 정렬이다. 병합·퀵·힙 세 O(n log n) 정렬은 안정성·공간·최악 보장에서 다르게 절충하니, 입력 크기와 요구를 보고 고른다.
다음 시간 예고
오늘 마지막에 "비교만으로는 O(n log n)이 한계"라는 벽을 만났습니다. 다음 시간(D-2)엔 그 벽을 넘습니다. 비교를 아예 하지 않고 값을 세어 O(n)에 정렬하는 계수 정렬과, 자릿수로 나눠 정렬하는 기수 정렬을 배워요. 그리고 오늘 잠깐 스친 실전 내장 정렬을 제대로 다룹니다. 정렬 기준을 바꾸는 key, 튜플로 여러 기준을 한 번에 거는 법, 그리고 "언제 직접 짜고 언제 내장을 쓰나"의 판단까지. 오늘 짠 여섯 정렬이 그 판단의 바탕이 됩니다.
과제
오늘 배운 여섯 정렬과 빅오 감각을 다지는 문제들입니다. 코드베이스 algorithms/sorting.py의 함수들을 그대로 쓰거나, 직접 변형해 풀어 보세요.
[기초] K번째로 작은 수 찾기
정수 리스트 nums와 정수 k가 주어질 때, nums에서 k번째로 작은 값을 반환하는 kth_smallest(nums, k)를 작성하세요(k는 1부터 시작). 예를 들어 kth_smallest([5, 2, 9, 1, 6], 2)는 두 번째로 작은 값인 2를 반환합니다.
- 정렬을 이용하면 아주 간단해집니다. "정렬해 두면 순위 문제가 쉬워진다"를 몸으로 느껴 보세요.
- 시간·공간 복잡도를 빅오로 적고, 왜 그런지 한 줄로 설명하세요.
[응용] 퀵 정렬의 최악을 피하기 (중앙값 피벗)
Step 4에서 본 퀵 정렬은 이미 정렬된 입력에서 최악 O(n²)로 무너졌습니다. 피벗을 맨 끝값 대신 "처음·중간·끝 세 값의 중앙값"으로 고르도록 partition을 손봐, 이미 정렬된 입력에서도 치우치지 않게 만드세요(median-of-three).
- 이미 정렬된
list(range(1000))같은 입력에서, 원래 퀵 정렬과 개선한 퀵 정렬이 재귀 깊이가 어떻게 달라지는지 생각해 보세요. - 중앙값을 피벗으로 옮긴 뒤에는 기존
partition로직을 그대로 쓸 수 있습니다.
[심화] 역순 쌍(inversion) 개수 세기
리스트에서 i < j인데 nums[i] > nums[j]인 쌍을 역순 쌍이라 합니다. "얼마나 뒤섞여 있나"를 나타내는 값이에요. [3, 1, 2]에는 (3,1), (3,2) 두 개의 역순 쌍이 있습니다. 역순 쌍의 개수를 O(n log n)에 세는 count_inversions(nums)를 작성하세요.
- 하나하나 다 세면 O(n²)입니다. 병합 정렬의
merge단계를 활용하면 O(n log n)에 셀 수 있어요. 오른쪽 조각의 값이 왼쪽 조각의 값보다 먼저 담길 때, 그 값보다 뒤에 남은 왼쪽 원소들이 전부 역순 쌍입니다. - 병합 정렬을 회수하는 대표적인 코테 심화 유형입니다.
생각해볼 주제
1. 버블·선택·삽입 다 O(n²)인데, 왜 셋을 다 배울까?
세 정렬 모두 O(n²)라 실전에선 큰 입력에 못 씁니다. 그런데도 교과서가 셋을 다 다루는 이유가 있어요. 특히 삽입 정렬은 다른 둘과 다른 강점이 있습니다. 셋의 차이가 어디서 오는지, 삽입 정렬만의 강점(거의 정렬된 입력·안정성·데이터가 실시간으로 들어오는 상황)이 실무에서 왜 쓸모 있는지 정리해 보세요.
2. 퀵 정렬은 왜 최악이 O(n²)인데도 실무 표준일까?
병합·힙은 최악에도 O(n log n)인데, 퀵은 최악이 O(n²)입니다. 그런데도 오랫동안 많은 언어의 기본 정렬이 퀵 계열이었어요. 왜 "최악이 더 나쁜" 정렬이 실무에서 사랑받았을까요? 평균 성능과 최악 성능 중 무엇을 봐야 하는지, 그리고 최악을 어떻게 낮추는지(피벗 무작위화·중앙값)를 함께 생각해 보세요.
3. 같은 O(n log n)인데 병합·퀵·힙을 무엇으로 고를까?
셋 다 O(n log n)이라 빅오만으로는 우열을 못 가립니다. 안정성이 필요한 상황, 메모리가 빠듯한 상황, 최악의 시간을 반드시 보장해야 하는 상황을 각각 떠올리고, 그때 셋 중 무엇을 고를지 근거를 세워 보세요. "빅오가 같아도 세부 성질로 고른다"는 감각이 목표입니다.
✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] K번째로 작은 수 찾기
채점 포인트
| 항목 | 확인 |
|---|---|
| 정렬 활용 | 순위 문제를 정렬 한 번으로 환원 (정렬하면 k번째는 인덱스 k-1) |
| 인덱스 처리 | k는 1부터 시작하므로 sorted(nums)[k - 1] (off-by-one 주의) |
| 빅오 | 정렬 O(n log n) + 인덱싱 O(1) → 전체 O(n log n)·공간 O(n) |
| 실전 감각 | 직접 짠 정렬 대신 내장 sorted()를 쓰는 판단 |
풀이 예시
"K번째로 작은 값"은 순위(순서) 문제입니다. 뒤섞인 채로 k번째 작은 값을 찾으려면 복잡하지만, 정렬해 두면 이야기가 단번에 끝나요. 정렬된 배열에서 가장 작은 값은 0번, 그다음은 1번... 그러니 k번째로 작은 값은 그냥 인덱스 k-1입니다.
def kth_smallest(nums, k):
return sorted(nums)[k - 1]
k가 1부터 시작한다는 조건 때문에 인덱스는 k-1이에요. 여기서 놓치기 쉬운 게 이 off-by-one 하나입니다. kth_smallest([5, 2, 9, 1, 6], 2)를 따라가 봅시다.
정렬한 뒤 인덱스로 순위를 읽는다
입력: [5, 2, 9, 1, 6]
sorted → [1, 2, 5, 6, 9]
인덱스: 0 1 2 3 4
└ k=2 → 인덱스 1 → 답 2
두 번째로 작은 값인 2가 나옵니다. 빅오는 정렬이 O(n log n)이라 전체도 O(n log n)이에요. 내장 sorted()를 그대로 썼는데, 오늘 배운 merge_sort로 바꿔도 결과는 같습니다. 다만 실전에선 더 빠르고 검증된 내장 정렬을 쓰는 게 정석이에요.
💡 튜터의 한마디: "K번째", "상위 K개", "중앙값" 같은 순위 표현이 보이면 "정렬해 볼까?"가 첫 반응이면 됩니다. 참고로 배열 전체가 아니라 K번째 하나만 필요할 땐, 전부 정렬하지 않고 힙이나 퀵 정렬의 분할만 이용해 평균 O(n)에 뽑는 방법(quickselect)도 있어요. 지금은 "정렬로 순위 문제를 푼다"는 감각이면 충분합니다.
🎯 [과제 2 예시답안] 퀵 정렬의 최악을 피하기 (중앙값 피벗)
채점 포인트
| 항목 | 확인 |
|---|---|
| 중앙값 선택 | 처음·중간·끝 세 값 중 중앙값을 피벗으로 |
| 기존 로직 재사용 | 중앙값을 맨 끝으로 옮긴 뒤 sorting.py의 partition 그대로 |
| 최악 회피 | 이미 정렬된 입력에서도 분할이 반씩 갈려 O(n log n) 유지 |
| 정확성 | 피벗 선택만 바뀌고 정렬 결과는 기본 퀵 정렬과 동일 |
풀이 예시
Step 4에서 봤듯, 맨 끝값을 피벗으로 잡으면 이미 정렬된 입력에서 분할이 1:나머지로 치우쳐 O(n²)가 됩니다. 원인은 "피벗이 하필 최솟값이나 최댓값"이라는 데 있어요. 그러니 피벗을 조금만 똑똑하게 골라 극단값을 피하면 됩니다. 처음·중간·끝 세 값의 중앙값을 쓰는 게 흔한 방법이에요.
# algorithms/exercises_d1.py
from algorithms.sorting import partition
def quick_sort_mo3(arr):
a = list(arr)
_quick_sort_mo3(a, 0, len(a) - 1)
return a
def _quick_sort_mo3(a, lo, hi):
if lo < hi:
p = partition_mo3(a, lo, hi)
_quick_sort_mo3(a, lo, p - 1)
_quick_sort_mo3(a, p + 1, hi)
def partition_mo3(a, lo, hi):
mid = (lo + hi) // 2
# 세 인덱스를 그 값 기준으로 정렬했을 때 가운데가 중앙값의 위치
median_idx = sorted((lo, mid, hi), key=lambda idx: a[idx])[1]
a[median_idx], a[hi] = a[hi], a[median_idx] # 중앙값을 맨 끝(피벗 자리)으로
return partition(a, lo, hi)
핵심은 partition_mo3 하나입니다. 세 인덱스 lo·mid·hi를 그 값 기준으로 정렬해 가운데 것을 고르면 중앙값의 위치예요. 그 중앙값을 맨 끝으로 옮긴 다음에는, Step 4에서 짠 partition을 그대로 씁니다. 피벗이 이미 맨 끝에 와 있으니 나머지 로직은 손댈 필요가 없어요.
이미 정렬된 [1, 2, 3, 4, 5]에서 어떻게 달라지는지 봅시다.
기본 퀵 피벗 = 맨 끝(5) → 5보다 작은 게 전부 왼쪽
[1,2,3,4,5] → [1,2,3,4] | 5 | [] (1 : 4 로 치우침 → 최악)
중앙값 피벗 처음1·중간3·끝5 의 중앙값 = 3 을 피벗으로
[1,2,3,4,5] → [1,2] | 3 | [4,5] (2 : 2 로 균형 → O(n log n))
기본 퀵은 한쪽으로 쏠려 재귀가 깊어지지만, 중앙값 피벗은 반씩 갈려요. 정렬 결과 자체는 두 방식이 똑같습니다. 피벗을 어떻게 고르느냐만 바뀌었을 뿐이니까요.
💡 튜터의 한마디: 실무의 퀵 정렬은 거의 다 이런 피벗 전략을 씁니다(중앙값 또는 무작위). 무작위 피벗은 "악의적으로 최악 입력을 넣는" 공격까지 막아 줘서 더 선호되기도 해요. 완벽히 O(n²)를 없애진 못하지만(운 나쁘면 여전히 치우칠 수 있음), 특정 입력에 늘 최악이 걸리는 함정은 확실히 없앱니다.
🎯 [과제 3 예시답안] 역순 쌍(inversion) 개수 세기
채점 포인트
| 항목 | 확인 |
|---|---|
| 완전탐색의 한계 | 모든 쌍을 확인하면 O(n²) — 큰 입력에 시간 초과 |
| 병합 정렬 회수 | merge 단계에서 역순 쌍을 한꺼번에 셈 |
| 세는 규칙 | 오른쪽 값이 먼저 담길 때 왼쪽에 남은 원소 수만큼 더함 |
| 빅오 | 병합 정렬과 같은 O(n log n)·공간 O(n) |
풀이 예시
역순 쌍은 "앞에 있는데 더 큰" 쌍이에요. i < j인데 nums[i] > nums[j]인 경우죠. 하나하나 다 확인하면 모든 쌍을 봐야 해서 O(n²)입니다. n이 크면 시간 초과예요. 여기서 병합 정렬이 등장합니다.
병합 정렬로 정렬하는 과정에서 역순 쌍을 공짜로 셀 수 있어요. 두 정렬된 조각을 합칠 때, 오른쪽 조각의 값이 왼쪽 조각의 값보다 먼저 담기는 순간을 잡는 겁니다. 그 오른쪽 값은 왼쪽에 아직 남은 원소들보다 작다는 뜻이고(둘 다 정렬돼 있으니까), 남은 왼쪽 원소들은 인덱스가 더 앞이죠. 그러니 그 남은 개수만큼이 전부 역순 쌍입니다.
# algorithms/exercises_d1.py
def count_inversions(nums):
_, count = _sort_and_count(list(nums))
return count
def _sort_and_count(a):
if len(a) <= 1:
return a, 0
mid = len(a) // 2
left, left_inv = _sort_and_count(a[:mid])
right, right_inv = _sort_and_count(a[mid:])
merged, split_inv = _merge_and_count(left, right)
return merged, left_inv + right_inv + split_inv
def _merge_and_count(left, right):
merged = []
i = j = 0
count = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
merged.append(left[i])
i += 1
else:
merged.append(right[j])
j += 1
count += len(left) - i # 남은 왼쪽 원소들이 전부 역순 쌍
merged.extend(left[i:])
merged.extend(right[j:])
return merged, count
병합 정렬과 뼈대가 똑같고, _merge_and_count에 count += len(left) - i 한 줄이 더해진 게 전부입니다. 오른쪽 값(right[j])이 먼저 담길 때, 왼쪽에 남은 원소 수 len(left) - i를 한꺼번에 더해요. [2, 4]와 [1, 3]을 합치는 순간을 보면 이해가 쉽습니다.
[2, 4] 와 [1, 3] 을 합치며 역순 쌍 세기 (i·j 는 각 조각의 포인터)
left=[2,4] right=[1,3]
1 < 2 → 1 담음, 왼쪽에 [2,4] 남음 → 역순 2개 (2>1, 4>1)
2 < 3 → 2 담음
3 < 4 → 3 담음, 왼쪽에 [4] 남음 → 역순 1개 (4>3)
4 담음
합계 3개
전체 [2, 4, 1, 3]의 역순 쌍은 (2,1)·(4,1)·(4,3) 세 개, 정확히 맞습니다. 빅오는 병합 정렬 그대로 O(n log n)이에요. count_inversions([3, 1, 2])는 (3,1)·(3,2) 두 개라 2를 반환합니다.
💡 튜터의 한마디: "정렬하면서 무언가를 함께 센다"는 발상은 코테 심화의 단골이에요. 역순 쌍은 "얼마나 뒤섞였나"를 재는 값이라, 버블 정렬이 교환하는 횟수와 정확히 같습니다. 완전탐색 O(n²)가 시간 초과일 때, 이미 배운 정렬 골격에 계산 한 줄을 얹어 O(n log n)으로 끌어내리는 연습을 해 두세요.
🤔 [생각해볼 주제 1] 버블·선택·삽입 다 O(n²)인데, 왜 셋을 다 배울까
문제 상황 요약
세 정렬 모두 O(n²)라 큰 입력엔 못 씁니다. 그런데도 교과서는 셋을 다 다뤄요. 셋의 차이는 어디서 오고, 특히 삽입 정렬만의 강점은 무엇일까요?
튜터의 가이드 및 해설
빅오가 같다고 셋이 같은 정렬은 아닙니다. O(n²)는 "최악의 큰 그림"일 뿐이고, 세부 성질은 셋이 다 달라요.
버블 정렬은 교육적 가치가 큽니다. "인접한 걸 비교해 바꾼다"가 직관적이라 정렬의 첫 그림으로 좋아요. 선택 정렬은 교환 횟수가 최소(최대 n-1번)라, 값을 옮기는 비용이 아주 큰 특수 상황에서 의미가 있습니다. 하지만 진짜 실전 가치가 있는 건 삽입 정렬이에요.
삽입 정렬의 강점은 세 가지입니다. 첫째, 거의 정렬된 입력에서 O(n)에 가까워요. 밀어낼 값이 거의 없으니까요. 둘째, 안정 정렬이라 같은 값의 순서가 지켜집니다. 셋째, 데이터가 실시간으로 하나씩 들어오는 상황(온라인)에서도 이미 정렬된 부분에 새 값을 끼워 넣으며 정렬 상태를 유지할 수 있어요. 이 세 성질 덕분에 파이썬의 Timsort가 작은 조각과 거의 정렬된 부분을 삽입 정렬로 처리합니다.
🎯 코테·면접에선 이렇게 나온다
면접에서 "세 정렬의 차이"를 물으면, 빅오가 같다는 걸 인정한 뒤 "삽입 정렬은 거의 정렬된 입력에서 O(n)이고 안정적이라 Timsort의 부품으로 쓰인다"까지 말하면 깊이가 드러납니다. "선택 정렬은 교환이 최소" 같은 디테일도 한 스푼 얹으면 좋아요. 코테에선 이 셋을 직접 쓰기보다, "작은 입력이나 거의 정렬된 데이터엔 삽입 정렬이 실용적"이라는 판단이 가끔 유효합니다.
💡 실무에선
실무 정렬 라이브러리는 거의 다 하이브리드입니다. 파이썬 Timsort, 자바의 정렬도 큰 배열은 O(n log n) 정렬로, 작은 조각은 삽입 정렬로 처리해요. "느린 O(n²) 정렬"이 실무 최고 성능 정렬의 부품으로 살아 있다는 게 재밌는 지점입니다.
🤔 [생각해볼 주제 2] 퀵 정렬은 최악이 O(n²)인데 왜 실무 표준일까
문제 상황 요약
병합·힙은 최악에도 O(n log n)인데 퀵은 최악이 O(n²)입니다. 그런데도 오랫동안 많은 언어의 기본 정렬이 퀵 계열이었어요. "최악이 더 나쁜" 정렬이 왜 사랑받았을까요?
튜터의 가이드 및 해설
핵심은 "평균과 최악 중 무엇을 보느냐"입니다. 퀵 정렬은 평균적으로 O(n log n) 정렬 중에서도 가장 빠른 편이에요. 제자리 정렬이라 병합 정렬처럼 임시 배열을 O(n)이나 쓰지 않고, 메모리 접근이 연속적이라 실제 CPU에서 캐시 효율도 좋습니다. 상수 계수가 작다는 뜻이에요. 같은 O(n log n)이라도 실측 속도는 퀵이 병합·힙보다 빠른 경우가 많습니다.
최악 O(n²)는 "피벗이 매번 극단값"일 때만 일어나는데, 피벗을 무작위나 중앙값으로 고르면 그 확률이 극도로 낮아집니다(과제 2에서 직접 해 봤죠). 그래서 실무는 "최악은 사실상 안 걸리게 막고, 평균 최고속을 취한다"는 선택을 한 거예요. 다만 최악이 절대 일어나면 안 되는 시스템(실시간성이 중요한)에선 최악을 보장하는 병합·힙을 씁니다.
🎯 코테·면접에선 이렇게 나온다
"퀵 정렬의 최악은 언제, 왜 O(n²)인가?"는 정렬 단골 질문입니다. "이미 정렬된 입력에 극단값을 피벗으로 잡으면 분할이 1:나머지로 치우쳐서"라고 답하고, "무작위·중앙값 피벗으로 회피한다"까지 이어 가면 됩니다. "그런데도 평균 최고속·제자리라서 실무 표준"이라는 균형 잡힌 시각을 보여 주면 면접관이 좋아해요.
💡 실무에선
많은 표준 라이브러리가 퀵의 장점과 최악 보장을 함께 취하려고 introsort 같은 하이브리드를 씁니다. 평소엔 퀵으로 돌다가, 재귀가 너무 깊어져 최악이 의심되면 힙 정렬로 전환하는 식이에요. "평균은 퀵, 최악은 힙으로 방어"라는 절충입니다.
🤔 [생각해볼 주제 3] 같은 O(n log n)인데 병합·퀵·힙을 무엇으로 고를까
문제 상황 요약
병합·퀵·힙 셋 다 평균 O(n log n)이라 빅오만으로는 우열을 못 가립니다. 그렇다면 실제로는 무엇을 보고 고를까요?
튜터의 가이드 및 해설
빅오가 같을 땐 세부 성질로 고릅니다. 세 가지 축을 보면 돼요.
안정성이 필요한가. 같은 값의 원래 순서를 지켜야 한다면(예: 여러 기준으로 이어서 정렬) 안정 정렬인 병합을 고릅니다. 퀵·힙은 불안정해요. 메모리가 빠듯한가. 추가 공간을 아껴야 하면 제자리 정렬인 퀵이 유리합니다(병합은 O(n) 추가 공간). 최악의 시간을 반드시 보장해야 하는가. 실시간 시스템처럼 "느려지면 안 되는" 곳이면 최악에도 O(n log n)인 병합·힙을 고르고 퀵은 피해요.
그래서 대략 이렇게 정리됩니다. 안정성·최악 보장이 둘 다 필요하면 병합, 평균 속도와 메모리가 중요하면 퀵, 최악 보장을 하면서 상위 K개만 필요하면 힙. "공짜 점심은 없다"는 말처럼, 셋 다 무언가를 얻고 무언가를 내줍니다.
🎯 코테·면접에선 이렇게 나온다
"셋 다 O(n log n)인데 뭘 쓰겠냐"는 트레이드오프를 보는 질문이에요. "안정성이 필요하면 병합, 메모리가 중요하면 퀵, 최악 보장이면 병합·힙"처럼 조건별로 답하면 좋습니다. 여기에 "파이썬 내장 sorted는 안정 정렬(Timsort)이라 안정성이 공짜"라는 한 줄을 얹으면 실전 감각까지 보여 줄 수 있어요.
💡 실무에선
실무에선 대부분 언어 내장 정렬을 씁니다. 파이썬은 안정성이 필요한 경우가 많아 안정 정렬(Timsort)을 기본으로 삼았고, 안정성이 필요 없는 언어는 퀵 계열 하이브리드를 기본으로 두기도 해요. "직접 고를 일은 드물지만, 내장 정렬이 어떤 선택을 했는지 아는 것"이 실력입니다. 정렬 기준을 바꾸는 법은 다음 시간에 이어 갑니다.