출시·고도화 중
복잡도 분석 안내서 · 2/6
복잡도를 구하는 과정은 생각보다 기계적입니다. 기본 연산을 정하고, 코드의 구조(순차, 반복, 중첩, 재귀)를 따라 횟수를 n의 식으로 쓴 다음, 가장 큰 항만 남기면 됩니다. 이 장에서는 작은 예제로 그 과정을 한 단계씩 따라갑니다.
기본 연산은 입력 크기와 상관없이 일정한 시간이 드는 동작입니다. 비교, 덧셈, 배열 인덱싱, 변수 대입 등이 여기에 들어갑니다. 정렬이라면 "비교 횟수", 탐색이라면 "원소를 확인한 횟수"처럼 알고리즘의 핵심 동작 하나를 골라 세면 충분합니다.
주의할 점은 한 줄짜리 코드가 기본 연산이 아닐 수 있다는 것입니다. Python의 x in some_list는 리스트를 처음부터 훑으므로 O(n)이고, sorted(items)는 O(n log n), 문자열 s + t는 길이만큼 복사합니다.
| 코드 구조 | 규칙 | 예 |
|---|---|---|
| 차례로 이어진 블록 | 더한다 | O(n) + O(n²) = O(n²) |
| n번 도는 반복문 | 본문 비용에 n을 곱한다 | n × O(1) = O(n) |
| 중첩 반복문 | 곱한다 | n × n = O(n²) |
| 매번 절반으로 줄어드는 반복 | 줄어드는 횟수를 센다 | O(log n) |
| 조건 분기 | 더 비싼 쪽을 택한다(최악 기준) | max(O(1), O(n)) |
아래 함수의 비교 횟수를 직접 세어 봅니다.
def count_pairs_with_sum(items, target):
count = 0
n = len(items)
for i in range(n): # i = 0 .. n-1
for j in range(i + 1, n): # n-1-i 번
if items[i] + items[j] == target:
count += 1
return count안쪽 반복은 i가 0일 때 n-1번, 1일 때 n-2번, 마지막에는 0번 돕니다. 합은 (n-1) + (n-2) + ... + 0 = n(n-1)/2입니다. 이를 펼치면 n²/2 - n/2이고, 상수와 낮은 차수 항을 버리면 Θ(n²)입니다. 작은 n에서 실제로 세어 보면 공식과 맞습니다.
| n | 비교 횟수 | n(n-1)/2 |
|---|---|---|
| 1 | 0 | 0 |
| 2 | 1 | 1 |
| 4 | 6 | 6 |
| 8 | 28 | 28 |
| 16 | 120 | 120 |
n이 두 배가 될 때 횟수가 약 네 배가 되는 것이 n²의 특징입니다.
이진 탐색은 정렬된 리스트에서 범위를 매번 절반으로 줄입니다. 범위 크기가 16 → 8 → 4 → 2 → 1로 줄어드는 데 4번이면 충분하고, 원소가 1,024개여도 약 10번이면 됩니다. "n을 몇 번 반으로 나눠야 1이 되는가"가 바로 log₂ n입니다.
def halving_steps(n):
steps = 0
while n > 1:
n //= 2
steps += 1
return steps
for n in (16, 1_024, 1_000_000):
print(n, halving_steps(n))
# 16 4
# 1024 10
# 1000000 19로그의 밑(2, 3, 10)은 상수배 차이(log₂ n = log₁₀ n / log₁₀ 2)일 뿐이라 점근 표기에서는 밑을 쓰지 않고 O(log n)이라고 적습니다. 바깥 반복이 n번이고 안쪽이 절반씩 줄어드는 반복이라면 O(n log n)이 됩니다.
재귀 함수는 "자기 자신을 몇 번, 얼마나 작은 입력으로 부르는가"와 "그 밖에 하는 일"로 점화식을 세웁니다.
def merge_sort(items):
if len(items) <= 1: # 바닥: T(1) = O(1)
return items
mid = len(items) // 2
left = merge_sort(items[:mid]) # T(n/2)
right = merge_sort(items[mid:]) # T(n/2)
merged, i, j = [], 0, 0 # 병합: O(n)
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
return merged + left[i:] + right[j:]점화식은 T(n) = 2T(n/2) + O(n)입니다. 재귀 트리로 보면 각 층에서 병합하는 원소의 합이 n이고, 층의 수가 log n이므로 전체는 O(n log n)입니다.
T(n) = a·T(n/b) + f(n) 꼴, 즉 "크기 n/b인 하위 문제 a개를 풀고, 나누고 합치는 데 f(n)이 드는" 재귀에는 마스터 정리를 쓸 수 있습니다. 핵심은 잎(가장 작은 하위 문제)들이 하는 일의 양 n^(log_b a)와 맨 위에서 하는 일 f(n)을 비교하는 것입니다.
T(n) = Θ(n^(log_b a))T(n) = Θ(n^(log_b a) · log n)T(n) = Θ(f(n))| 알고리즘 | 점화식 | n^(log_b a) | 결과 |
|---|---|---|---|
| 이진 탐색 | T(n) = T(n/2) + O(1) | n⁰ = 1 | Θ(log n) |
| 병합 정렬 | T(n) = 2T(n/2) + O(n) | n | Θ(n log n) |
| 이진 트리 순회 | T(n) = 2T(n/2) + O(1) | n | Θ(n) |
| 카라추바 곱셈 | T(n) = 3T(n/2) + O(n) | n^1.585 | Θ(n^1.585) |
T(n) = T(n-1) + T(n-2) + O(1)인 단순 재귀 피보나치처럼 크기가 비율이 아니라 뺄셈으로 줄어드는 재귀에는 마스터 정리를 쓸 수 없습니다. 이 경우 호출 수가 약 1.618^n으로 늘어나는 지수 시간입니다.
in list, 정렬, 문자열 결합)의 비용도 세어야 합니다.aT(n/b) + f(n) 꼴이면 잎과 맨 위의 일을 비교하는 마스터 정리로 풉니다.
댓글 0개
로그인 · 로그인하면 댓글을 남길 수 있습니다.
첫 댓글을 남겨 보세요.