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

동적 배열: Python list는 어떻게 자랄까

~11 min · arrays, dynamic-array, python

Level 0호기심 많은 입문자
0 XP0/85 lessons0/19 achievements
0/100 XP to next level100 XP to go0% complete
"저수준 배열은 크기가 고정이야. Python 리스트는 아닌 척하지. 그 시치미를 끝까지 유지하는 방법이 은근히 기막혀."

리스트가 푸는 문제

저수준 배열은 크기를 선언하는 순간 거기 묶여. 그런데 최종 크기를 미리 아는 경우가 얼마나 되겠어. 동적 배열은 그 한계를 덮어 감춰. append하면 알아서 늘어나니까 쓰는 쪽에서는 끝이 없는 것처럼 느껴지지. Python의 list가 바로 동적 배열이야. C++의 vector, Java의 ArrayList도 이름만 다르지 같은 물건이고.

원리는 분할 상환 분석에서 이미 봤어. 리스트는 속에 배열을 하나 깔아두는데, 그 배열의 용량(capacity)이 실제 길이(length)보다 넉넉해. 여유가 남아 있는 동안 append는 O(1)이야. 다 차면 더 큰 블록을 새로 잡고 기존 참조를 옮기는데, 이 재할당이 O(n)이고. 구현체는 여유 용량을 기하급수적으로 늘려서 비싼 재할당이 어쩌다 한 번만 오게 만들어. 그래서 append가 분할 상환 O(1)인 거야. CPython이 정확히 얼마씩 키우는지는 버전마다 다를 수 있는 구현 세부사항이지, 항상 두 배로 늘린다는 약속이 아니야.

길이는 용량이 아니야

이 구분 하나면 나머지가 다 풀려. len(lst)는 실제로 담긴 항목 수고, 용량은 다음 재할당이 오기 전까지 담을 수 있는 수야. Python이 용량을 직접 보여주진 않지만 예약된 바이트로 짐작할 수 있어. 용량이라는 게 따로 있다는 걸 알고 나면 "append는 보통 즉시인데 가끔 걸린다"는 현상이 더 이상 이상하지 않아.

동적 배열은 고정 배열에 여유 용량과 기하급수적 성장 정책을 얹은 거야. 길이는 담긴 원소 수, 용량은 재할당 없이 담을 수 있는 수. 둘 사이의 여유가 append를 분할 상환 O(1)로 만들어.

연산과 그 진짜 비용

리스트가 한 줄로 붙어 있으니, 연산 비용은 "뭐가 움직여야 하나" 하나만 따지면 바로 나와.

  • lst[i] 읽기와 쓰기는 O(1). 주소만 계산하면 끝이야.
  • lst.append(x)lst.pop()분할 상환 O(1). 끝에서만 일하니까 밀리는 게 없어.
  • lst.insert(0, x)lst.pop(0)O(n). 그 자리 뒤가 통째로 밀려.
  • x in lstO(n). 처음부터 훑거든. 복잡도 트랙에서 본 숨은 반복문 함정이 이거야.
  • lst[a:b] 슬라이스는 O(b−a). 그 범위를 새 리스트로 복사해.

피파의 고백

평범한 리스트로 큐를 만들고 lst.pop(0)으로 꺼내 쓰던 시절이 있어. 작은 테스트에서는 멀쩡히 돌았지. 그런데 진짜 데이터를 넣으니까 O(n)이던 반복문이 소리 없이 O(n²)이 돼 있더라. pop(0)을 할 때마다 리스트 전체가 왼쪽으로 밀렸던 거야. 아빠가 앞에서 빼는 게 O(1)인 collections.deque를 보여줬어. 발상은 그대로고 구조만 맞게 바꾼 거지. 리스트가 잘못된 게 아니라, 내가 리스트의 비싼 쪽 끝을 공짜인 줄 알고 쓴 거였어.

Code

길이와 용량, 그리고 연산별 비용·python
import sys

# 길이 vs 용량: 길이가 매끄럽게 오르는 동안 예약 바이트가 점프하는 걸 봐.
lst = []
for i in range(9):
    print(f"len={len(lst)}  reserved_bytes={sys.getsizeof(lst)}")
    lst.append(i)
# 바이트 수가 특정 길이에서 점프하면 재할당이 일어난 것. 증가 폭은 구현별로 다름.
# 커지는 거고, len 은 매번 1 씩만 똑딱.

# 비용 치트시트, 시연:
lst = list(range(5))
lst.append(99)        # 분할 상환 O(1) — 끝의 여유 용량에 떨어짐
lst.pop()             # O(1) — 끝에서 제거, 아무것도 안 밀림
lst.insert(0, -1)     # O(n) — 인덱스 0 열려고 모든 원소 오른쪽으로 밀림
lst.pop(0)            # O(n) — 간극 메우려 모든 원소 왼쪽으로 밀림
print(3 in lst)       # O(n) — 선형 훑기, 리스트엔 지름길 없음
print(lst[1:4])       # O(k) — 그 슬라이스를 새 리스트로 복사

External links

Exercise

항목을 도착한 순서 그대로 처리해야 해. 뒤에 넣고 앞에서 빼기를 수백만 번. 이걸 리스트와 pop(0)으로 짜면 왜 전체가 O(n²)이 되는지 정리해 보고, O(n)으로 끝내주는 구조를 대 봐. 보너스로, Python 리스트에서 싼 쪽 끝은 어디고 왜 그런지도.
Hint
pop(0) 한 번이 O(n)인데 그걸 n번 하니까 O(n²)이야. collections.deque는 양쪽 끝이 다 O(1)이고. 리스트에서 싼 쪽은 뒤쪽(append/pop)이야. 거기선 아무것도 안 밀려도 되니까.

Progress

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

댓글 0

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

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