본문 바로가기
C.W.K.
Stream
Lesson 06 of 06 · published

분할 상환 분석: '가끔 비쌈'이 평균적으로 쌀 때

~12 min · complexity, amortized, dynamic-array

Level 0호기심 많은 입문자
0 XP0/85 lessons0/19 achievements
0/100 XP to next level100 XP to go0% complete
"어떤 연산은 대개 즉시인데 가끔 잔혹해. 분할 상환 분석은 그걸 정직하게 청구하는 방법이야. 드물게 오는 잔혹한 비용을 싼 연산 전체에 펴 바르는 거지."

list.append의 수수께끼

파이썬 리스트에 append 하는 건 O(1)이야. 그런데 가끔은 아니야. 항목 하나를 append 했을 뿐인데 파이썬이 리스트 전체를 더 큰 메모리 덩어리로 복사하는 일이 생기고, 그건 O(n)이거든. 그럼 문서는 무슨 낯으로 append를 "O(1)"이라고 부를까? 답이 분할 상환 분석이라는 아름다운 아이디어야. 개별 연산은 들쭉날쭉해도, 우리가 보고하는 건 긴 연속에 걸친 연산당 평균 비용이라는 것.

동적 배열이 실제로 자라는 법

리스트는 현재 길이보다 여유 있는 메모리 블록을 잡아둬. 여유가 모자라면 더 큰 블록을 마련하고 기존 참조를 옮기는데, 이 한 번의 재할당이 O(n)이야. 중요한 건 정확히 두 배가 된다는 규칙이 아니라 여유 공간을 두고 기하급수적으로 키우는 전략이라는 점이야. CPython의 실제 증가 폭은 버전에 따라 달라질 수 있지만, 재할당 사이에 값싼 append가 충분히 많이 끼도록 설계돼 있어.

이해를 돕는 모델로 용량이 두 배씩 커진다고 그려보자. 8칸으로 커진 뒤엔 다음 재할당까지 싼 append가 최대 4번, 16칸이면 8번 따라와. 옮긴 원소 수도 1+2+4+…이라 n개를 넣을 때 2n보다 작아. 그래서 총 복사량은 O(n)이고 append당 분할 상환 비용은 O(1)이야. 이건 분석하기 쉬운 모델이지 CPython이 정확히 두 배로 큰다는 약속은 아니야. 핵심은 고정된 칸을 더하는 게 아니라 일정한 배수로 키워서 비싼 복사 사이에 값싼 append를 많이 끼워 넣는다는 거야.

분할 상환 비용 = 연산열의 총비용 ÷ 연산 수. 입력 분포의 평균을 가정하는 평균 케이스 분석과는 달라. 여유 용량을 기하급수적으로 늘리는 전략이 append를 분할 상환 O(1)로 만들어.

은행원의 직관

단번에 이해되게 하는 멘탈 모델이 있어. 싼 append가 저마다 몰래 조금씩 더 낸다고 상상해봐. 1코인 대신 3코인. 2코인은 저금통에 넣고. 드물게 비싼 복사가 닥치면 저금통에 딱 그걸 낼 만큼 모여 있어. 어떤 연산도 마이너스가 안 나. 비싼 복사는 그 앞의 싼 append들이 미리 낸 셈이거든. 그래서 한 연산이 가끔 치솟아도 평균은 평평하게 유지돼.

분할 상환은 최악 케이스가 아니야

결정적인 주의사항. 분할 상환 O(1)은 모든 append가 O(1)이라는 뜻이 아니야. 복사를 일으키는 그 한 번의 append는 진짜 O(n)이야. 단일 연산이 절대 멈추면 안 되는 실시간 코드를 쓴다면 (심장 모니터, 오디오 버퍼) 분할 상환으로는 부족해. 최악 케이스 보장이 필요하고, 치솟음을 피하려고 미리 할당할 수도 있어. 반면 처리량이 문제라면, 즉 시간에 걸친 총 일이 관심사라면 분할 상환이 딱 맞는 렌즈야. 내 문제가 어느 렌즈를 요구하는지 아는 게 진짜 실력이고.

피파의 고백

한참 동안 "append는 O(1)"과 "append가 가끔 리스트 전체를 복사함"이 그냥 무시한 모순으로 머릿속에 앉아 있었어. 아빠가 저금통을 그려주니 풀리더라. 드문 O(n) 복사는 진짜지만 싼 append들이 미리 내주니까 append당 비용은 평평하게 유지된다는 것. 연속에 대한 평균이 희망이 아니라 단단한 보장일 수 있다는 걸 처음 이해한 순간이야.

Code

여유 용량 관찰과 기하급수 성장 모델·python
import sys

# Python 리스트의 예약 메모리가 길이마다가 아니라 간헐적으로 커지는 걸 봐.
lst = []
last = -1
for i in range(33):
    cap = sys.getsizeof(lst)        # 예약된 바이트 (1개씩 아니라 점프로 커짐)
    if cap != last:
        print(f"len={len(lst):>2}  reserved bytes={cap}")  # 점프 = 재할당
        last = cap
    lst.append(i)

# 바이트 수가 매 append 가 아니라 가끔 점프해.
# 각 점프가 O(n) 복사. 점프 사이의 append 는 O(1).
# 많은 append 에 걸쳐, 총 복사 일은 ~2n -> append 당 분할 상환 O(1).

# 분석용 단순 모델: 용량을 두 배로 키울 때 n번 append의 총 복사.
n = 1_000_000
size, capacity, total_copies = 0, 1, 0
for _ in range(n):
    if size == capacity:
        total_copies += size      # 분석 모델에서 더 큰 블록으로 전부 복사
        capacity *= 2
    size += 1
print(f"\n{n:,} appends -> {total_copies:,} total copies (~n) -> 각각 분할 상환 O(1)")

External links

Exercise

리스트가 비어서 시작하고 항목 16개를 append하는데, 꽉 찰 때마다 내부 배열이 두 배가 된다고 하자 (1→2→4→8→16). 모든 두 배 과정에서 옮긴 원소 수를 전부 세고 16으로 나눠봐. 이번엔 배열이 매번 한 칸씩만 자란다고 상상하고 복사를 세봐. 어느 쪽이 분할 상환 O(1)이고 왜 그럴까?
Hint
두 배 전략의 복사는 1+2+4+8 = 15, 대략 n이야. +1 전략은 0+1+2+…+15 ≈ n²/2 복사고, 그게 고정량 성장을 분할 상환 O(n)으로 만드는 O(n²) 함정이야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.