cv.cm

코딩 · 알고리즘

빅오 표기법 예제로 쉽게 이해하기

7분 분량

핵심 답변

빅오 표기법은 입력 크기 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 + 103과 10을 버림O(n)
n²/2 + n/2n²만 남기고 1/2을 버림O(n²)
2n log n + 100nn 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)상수1items[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)을 쓰는 것처럼 한쪽을 위해 다른 쪽을 희생하는 경우가 많습니다.

코딩 튜토리얼 전체