코딩 · 알고리즘
빅오 표기법 예제로 쉽게 이해하기
핵심 답변
빅오 표기법은 입력 크기 n이 커질 때 알고리즘의 실행 시간이나 메모리 사용량이 얼마나 빠르게 늘어나는지 나타냅니다. 가장 빠르게 증가하는 항만 남기고 상수는 버리므로, 3n² + 5n + 2단계는 O(n²)로 씁니다. 빠른 것부터 느린 것 순으로 대표적인 종류는 O(1), O(log n), O(n), O(n log n), O(n²), O(2ⁿ)입니다. 특정 컴퓨터에서 실행해 보지 않고도 알고리즘끼리 비교할 수 있습니다.
빅오가 해결하는 문제
프로그램 실행 시간을 초 단위로 재면 한 대의 컴퓨터, 한 가지 언어, 한 가지 입력에 대해서만 알 수 있습니다. 빅오는 더 유용한 질문에 답합니다. 입력이 10배 커지면 알고리즘이 하는 일은 얼마나 늘어날까요? 일이 10배 늘어나는 알고리즘과 100배 늘어나는 알고리즘은 규모가 커질수록 크게 차이가 납니다.
형식적으로는 모든 n ≥ n₀에 대해 f(n) ≤ c · g(n)을 만족하는 상수 c와 n₀가 있으면 f(n)은 O(g(n))입니다. 쉽게 말해, n이 충분히 크면 상수 배를 무시했을 때 g가 f 증가 속도의 상한이 된다는 뜻입니다.
- n은 입력의 크기입니다. 리스트의 길이, 문자열의 글자 수, 그래프의 노드 수 등이 해당합니다.
- 시간 복잡도는 기본 연산(비교, 대입, 산술 연산)의 횟수를 셉니다.
- 공간 복잡도는 입력 자체를 제외하고 추가로 사용하는 메모리를 셉니다.
식을 단순화하는 방법
단계 수를 n의 함수로 구한 다음 두 가지 규칙을 적용합니다. 상수 계수를 버리고, 가장 지배적인 항만 남깁니다.
| 정확한 단계 수 | 적용한 규칙 | 빅오 |
|---|---|---|
5 | 상수 | O(1) |
3n + 10 | 3과 10을 버림 | O(n) |
n²/2 + n/2 | n²만 남기고 1/2을 버림 | O(n²) |
2n log n + 100n | n log n이 n보다 빠르게 증가 | O(n log n) |
n³ + 2ⁿ | 2ⁿ은 n의 어떤 거듭제곱보다 빠르게 증가 | O(2ⁿ) |
독립된 입력이 두 개인 알고리즘은 둘 다 남깁니다. 길이 n인 리스트를 돌고 나서 길이 m인 리스트를 도는 것은 O(n + m)이고, 반복문 안의 반복문은 O(n · m)입니다. 빅오에서는 로그의 밑이 중요하지 않습니다. log₂ n과 log₁₀ n은 상수 배만 차이 나기 때문입니다.
주요 복잡도 종류
| 종류 | 이름 | n = 1,000일 때 단계 수 | 대표 예 |
|---|---|---|---|
| O(1) | 상수 | 1 | items[i] 읽기, dict에서 키 조회(평균) |
| O(log n) | 로그 | 약 10 | 정렬된 리스트에서 이진 탐색 |
| O(n) | 선형 | 1,000 | 리스트의 최댓값 찾기 |
| O(n log n) | 선형 로그 | 약 10,000 | 병합 정렬, 파이썬 sorted() |
| O(n²) | 이차 | 1,000,000 | 모든 쌍 비교, 버블 정렬 |
| O(2ⁿ) | 지수 | 302자리 수 | 모든 부분집합 시도 |
| O(n!) | 팩토리얼 | 훨씬 더 큼 | 모든 순열 시도 |
대략적인 기준은 다음과 같습니다. 요즘 컴퓨터에서 단순한 반복문은 파이썬에서 초당 수천만 번, 컴파일 언어에서는 그보다 훨씬 많이 기본 연산을 실행할 수 있습니다. 따라서 O(n log n)은 수백만 개 항목에도 충분하고, O(n²)은 수만 개 근처부터 느려지며, O(2ⁿ)은 40~60개만 넘어도 사실상 불가능합니다.
파이썬 코드에서 복잡도 읽기
반복문을 보세요. 입력에 대한 반복문 하나는 O(n)입니다. 같은 입력에 대해 반복문 안에 반복문이 있으면 O(n²)입니다. 매번 남은 범위를 절반으로 줄이는 반복문은 O(log n)입니다.
def first(items): # O(1): one step, whatever the size
return items[0]
def total(items): # O(n): touches each item once
s = 0
for x in items:
s += x
return s
def has_duplicate(items): # O(n^2): compares every pair
for i in range(len(items)):
for j in range(i + 1, len(items)):
if items[i] == items[j]:
return True
return False
def halvings(n): # O(log n): n is halved each step
steps = 0
while n > 1:
n //= 2
steps += 1
return steps
print(first([7, 8]), total([1, 2, 3]), has_duplicate([1, 2, 1]), halvings(1024))
# 7 6 True 10라이브러리 호출에도 비용이 있습니다. x in some_list는 O(n), x in some_set은 평균 O(1), list.insert(0, x)는 모든 요소가 밀리므로 O(n), sorted()는 O(n log n)입니다. O(n) 연산을 n번 호출하는 반복문은 겉보기에는 반복문 하나여도 O(n²)입니다.
def has_duplicate_fast(items): # O(n) time, O(n) extra space
seen = set()
for x in items:
if x in seen: # average O(1) set lookup
return True
seen.add(x)
return False
print(has_duplicate_fast([3, 1, 4, 1, 5])) # True
print(has_duplicate_fast(list(range(10)))) # False최선, 평균, 최악의 경우
같은 크기의 입력이라도 입력에 따라 알고리즘이 하는 일의 양이 달라질 수 있으므로, 복잡도는 보통 세 가지 경우로 나누어 말합니다.
| 알고리즘 | 최선 | 평균 | 최악 |
|---|---|---|---|
| 선형 탐색 | O(1), 찾는 값이 맨 앞 | O(n) | O(n), 찾는 값이 없음 |
| 삽입 정렬 | O(n), 이미 정렬됨 | O(n²) | O(n²), 역순 |
| 퀵 정렬 | O(n log n) | O(n log n) | O(n²), 피벗이 나쁜 경우 |
| 해시 테이블 조회 | O(1) | O(1) | O(n), 충돌이 많은 경우 |
그냥 "X의 복잡도"라고만 말하면 보통 최악의 경우를 뜻하며, 해시 테이블과 퀵 정렬은 평균적인 경우를 뜻합니다. 분할 상환 비용이라는 네 번째 개념도 있습니다. 파이썬 리스트에 요소를 추가하는 일은 저장 공간을 늘릴 때 가끔 O(n)이지만, 여러 번 추가한 평균으로는 O(1)입니다.
빅오, 빅오메가, 빅세타
| 표기 | 의미 | 예 |
|---|---|---|
| O(g) | 상한: g보다 빠르게 증가하지 않음 | 선형 탐색은 O(n) |
| Ω(g) | 하한: 적어도 g만큼 빠르게 증가 | 모든 비교 정렬은 최악의 경우 Ω(n log n) |
| Θ(g) | 정확한 한계: O(g)이면서 Ω(g) | 병합 정렬은 Θ(n log n) |
엄밀히 말하면 O는 상한일 뿐이므로 O(n) 알고리즘은 O(n²)이기도 합니다. 일상적으로는 빅오라고 쓰면서도 자신이 아는 가장 정확한 한계를 뜻하는 경우가 많으며, 그것을 나타내는 것이 Θ입니다.
흔한 실수
- 리스트에
in을 쓰거나items[1:]로 슬라이싱하는 등 내장 연산의 비용을 잊는 경우입니다. 슬라이싱은 복사하므로 복사한 요소 k개에 대해 O(k)입니다. - 재귀 호출을 잘못 세는 경우입니다. n − 1에 대해 자신을 두 번 호출하는 함수는 O(n)이 아니라 O(2ⁿ)입니다.
- 공간을 무시하는 경우입니다. 크기 n인 새 리스트를 만들거나 깊이 n의 재귀를 쓰면 메모리가 O(n) 듭니다.
- O(2n)이나 O(n + 5)처럼 쓰는 경우입니다. 둘 다 O(n)으로 단순화됩니다.
자주 묻는 질문
O(1)은 무슨 뜻인가요?
O(1)은 상수 시간을 뜻합니다. 입력 크기가 커져도 작업량이 늘지 않습니다. 인덱스로 리스트 요소를 읽는 일은 리스트에 10개가 있든 1,000만 개가 있든 O(1)입니다.
O(log n)이 O(n)보다 빠른가요?
네, n이 클 때는 그렇습니다. 입력이 두 배가 되면 O(log n) 알고리즘은 한 단계만 늘어나지만 O(n) 알고리즘은 작업량이 두 배가 됩니다. 항목이 100만 개일 때 log₂ n은 약 20입니다.
파이썬 sorted()의 시간 복잡도는 얼마인가요?
파이썬의 sorted()와 list.sort()는 최악의 경우 O(n log n)이고, 이미 정렬된 입력에서는 O(n)이며, 추가 메모리는 O(n)을 사용합니다.
빅오는 초 단위를 재나요?
아닙니다. 입력 크기에 따라 작업량이 어떻게 늘어나는지를 나타냅니다. 실제 시간(초)은 컴퓨터, 언어, 그리고 빅오가 무시하는 상수 계수에 따라 달라집니다.
시간 복잡도와 공간 복잡도의 차이는 무엇인가요?
시간 복잡도는 알고리즘이 거치는 단계 수를, 공간 복잡도는 추가로 필요한 메모리를 셉니다. 둘 다 빅오로 쓰며, 시간을 아끼려고 집합(set)을 쓰는 것처럼 한쪽을 위해 다른 쪽을 희생하는 경우가 많습니다.