복잡도·자료구조·메모리·재귀를 한 권으로 — 마지막은 시험 단골 손코딩 10제
이 노트는 프로그래밍 기말 시험범위(4개 강의 — 복잡도 ①②, 자료구조, 메모리·재귀·메모이제이션)를 처음부터 끝까지 교과서처럼 읽도록 구성했습니다. 큰 흐름은 셋입니다 — 복잡도(코드가 얼마나 잘 확장되는가), 자료구조(데이터를 어떻게 담아야 빠른가), 메모리·재귀(함수 호출이 실제로 어떻게 동작하는가). 마지막 §9는 사용자가 가장 중요하다고 한 손코딩입니다.
📅 6/11(목) 9:00–11:00, E7-233 · 범위: ~6/02 강의 전체 + P-set #7 · Office Hour 6/09.
증가 속도 순서(좋음→나쁨): O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(2ⁿ). n=1,000일 때 대략 1 · 10 · 1,000 · 10,000 · 1,000,000 · ∞. 이 한 줄이 객관식·T/F의 절반을 커버한다.
코드가 빠른지 느린지 재는 방법은 둘이다. ① wall-clock time(스톱워치로 실제 시간 측정) — 컴퓨터·다른 프로그램·입력에 따라 들쭉날쭉. ② counting operations(입력이 커질 때 연산 수가 어떻게 느는지 셈) — 기계 독립적이고 수학적. 우리는 ②를 쓰고, 그 추세를 Big-O로 표기한다. 핵심은 정확한 횟수가 아니라 추세(trend), 즉 입력 n이 커질 때의 상한(upper bound)이다.
# 연산 수는?
def sum_list(numbers):
total = 0 # 1 operation
for num in numbers: # n operations
total += num # n operations
return total # 1 operation
# 합 = 2n + 2 → 추세는 n에 비례 → O(n)
n=10이면 22번, n=1,000이면 2,002번 → n에 선형으로 증가. 상수 2와 +2는 버린다.
① 상수를 버린다: O(3n)→O(n), O(n²/2)→O(n²), O(100)→O(1). ② 낮은 차수 항을 버린다: O(n²+n)→O(n²), O(n+log n)→O(n), O(2ⁿ+n³)→O(2ⁿ). ③ n이 아주 커지면 가장 큰 항(dominant term)만 의미가 있다. ("O"는 "Order of"의 약자 — 증가의 상한.)
| 식 | Big-O | 식 | Big-O |
|---|---|---|---|
3n + 5 | O(n) | n² + n log n | O(n²) |
n²/2 + 100n | O(n²) | 2ⁿ + n³ | O(2ⁿ) |
100 | O(1) | log₂n + log₁₀n | O(log n) (밑 무시) |
2n³ + 100n² + 5n + 10 | O(n³) | n + 1000 | O(n) |
| 패턴 | 복잡도 | 비유 / 예 |
|---|---|---|
lst[i], dict[k], len(x), n%2 | O(1) | 주소로 바로 접근 — "성배" |
| for loop 1개 (n번) | O(n) | 섞인 카드에서 한 장 찾기 |
| 중첩 for loop (n×n) | O(n²) | 모든 쌍 비교 (30명→900번) |
| 매번 절반씩 줄임 | O(log n) | 전화번호부 펼치기(binary search) |
| 분할정복 + 합치기 | O(n log n) | merge sort, quick sort(평균) |
| 매번 2번 재귀 호출 | O(2ⁿ) | naive Fibonacci |
n(n−1)/2 ≈ n²/2 → O(n²).def has_duplicate(lst): # O(n²)
for i in range(len(lst)):
for j in range(i+1, len(lst)):
if lst[i] == lst[j]:
return True
return False
# set을 쓰면 O(n)으로 개선 가능 → §3, §5에서 다시
다음 두 코드의 시간복잡도는?
(a) for i in range(n): for j in range(n): print(i,j)
(b) while n > 1: n = n // 2
(a) 중첩 loop, 각 n번 → O(n²). (b) 매번 절반으로 줄임 → O(log n).
시간이 "얼마나 오래?"라면 공간은 "메모리를 얼마나 쓰나?"이다. 비유: 요리에서 시간=조리 시간, 공간=필요한 냄비·팬 수. 핵심 규칙: 입력 자체는 세지 않는다 — 알고리즘이 추가로 할당하는(extra) 메모리만 센다.
| 할당하는 것 | 공간 |
|---|---|
| 단일 변수 (int/float/bool) | O(1) |
| 길이 n 새 list/array | O(n) |
| n×n 2D matrix | O(n²) |
| 재귀 (깊이 n) | O(n) ← 자주 출제 |
리스트 뒤집기를 두 가지로. 둘 다 시간은 O(n)이지만 공간이 다르다.
# O(n) space — 새 list 생성 # O(1) space — 제자리 교환(in-place)
def rev1(nums): def rev2(nums):
result = [] l, r = 0, len(nums)-1
for i in range(len(nums)-1,-1,-1): while l < r:
result.append(nums[i]) nums[l], nums[r] = nums[r], nums[l]
return result l += 1; r -= 1
"이 단어가 전에 나왔나?"를 풀 때 — ⓐ 추가 메모리 없이 매번 전체를 훑으면 Time O(n²), Space O(1). ⓑ set에 본 단어를 기억하면 Time O(n), Space O(n). ⓑ가 훨씬 빠르고 메모리를 더 쓴다. 실무 원칙: 메모리는 대체로 싸다 → 속도를 택하라.
def reverse(nums): return nums[::-1] 의 Time / Space는?
슬라이싱은 새 리스트를 만든다 → Time O(n), Space O(n). (제자리가 아님에 주의.)
| Linear Search | Binary Search | |
|---|---|---|
| 시간 | O(n) | O(log n) |
| 조건 | 아무 list나 OK | 정렬된 list 필수 |
| 방식 | 하나씩 전부 확인 | 매번 절반을 버림 |
| n=10⁶ 최악 | 최대 1,000,000번 | 약 20번 (log₂10⁶≈20) |
| 정렬 | Best | Average | Worst | Space | 특징 |
|---|---|---|---|---|---|
| Bubble Sort | O(n) | O(n²) | O(n²) | O(1) | 인접 비교·swap, "큰 값이 위로 떠오름" |
| Selection Sort | O(n²) | O(n²) | O(n²) | O(1) | 최솟값 찾아 앞으로, 정렬돼 있어도 O(n²) |
| Merge Sort | O(n log n) | O(n log n) | O(n log n) | O(n) | 분할정복, 항상 일정, merge에 추가메모리 |
| Quick Sort | O(n log n) | O(n log n) | O(n²) | O(log n) | pivot 분할, in-place, 실무 최速 |
① Bubble best O(n)(이미 정렬 시 한 번 훑고 끝) vs Selection 항상 O(n²)(정렬돼 있어도 최솟값 탐색은 함). ② Quick worst O(n²)(나쁜 pivot, 예: 이미 정렬된 입력에 첫 원소 pivot). ③ 공간: Merge O(n)(병합 버퍼) vs Quick O(log n)(재귀 깊이, in-place). Merge는 "항상 O(n log n)이지만 메모리를 더 쓴다".
# Merge Sort: [5,3,8,1] # Selection Sort: [5,3,8,1]
[5,3,8,1] → [5,3] [8,1] min=1 → [1,3,8,5]
[5][3] [8][1] (base) min=3 → [1,3,8,5]
[3,5] [1,8] (merge) min=5 → [1,3,5,8] ✓
[1,3,5,8] (merge) ✓(a) Quick Sort의 worst-case 시간은? (b) 이미 정렬된 배열에 Selection Sort를 쓰면?
(a) O(n²) — pivot이 매번 최소/최대로 뽑힐 때. (b) 여전히 O(n²) — Selection은 입력 상태와 무관. (Bubble이라면 best O(n).)
자료구조는 데이터를 조직·저장하는 방식이다. 구조마다 잘하는 연산과 못하는 연산이 다르다. 옷장 비유: 바닥에 쌓기(추가 쉽지만 찾기 어려움) vs 색깔별 정렬(찾기 빠름) vs 라벨 서랍(즉시 접근). 가장 자주 하는 연산을 가장 빠르게 만드는 구조를 고르는 것이 핵심 — 잘못 고르면 O(n) 해법이 O(n²)가 된다.
index 0부터 시작, 연속 메모리(contiguous memory)에 저장. 그래서 위치 접근이 빠르다.
| 연산 | 복잡도 | 이유 |
|---|---|---|
lst[i] (index 접근) | O(1) | 주소 직접 계산 |
x in lst (검색) | O(n) | 전수 확인 |
lst.append(x) | O(1) | 뒤에 추가 |
lst.insert(0, x) | O(n) | ⚠️ 뒤 원소 전부 shift! |
lst.pop() | O(1) | 뒤에서 제거 |
lst.pop(0) | O(n) | ⚠️ 전부 shift! |
접시 더미 비유. 맨 위에서만 넣고 뺀다 — 중간 접근 불가. 세 연산 모두 O(1): push(x)=append, pop()=top 제거+반환, peek()=stack[-1](제거 없이 확인).
# 문자열 뒤집기 — stack의 대표 용도
stack = []
for ch in "HELLO":
stack.append(ch)
result = ""
while stack:
result += stack.pop()
# "OLLEH"
pop() → IndexError. 항상 if stack:로 확인. 실생활: Undo/Redo(Ctrl+Z), 브라우저 뒤로가기, 함수 호출 스택(§6).카페 줄 비유. 뒤로 들어와 앞으로 나간다. enqueue=append, dequeue=popleft, peek=q[0] — 모두 O(1).
from collections import deque
q = deque()
q.append("A"); q.append("B") # enqueue
front = q.popleft() # dequeue → "A", O(1)
list.pop(0)는 O(n)(앞에서 빼면 전부 shift) vs deque.popleft()는 O(1). 큐는 반드시 deque를 써라. 실생활: 프린트 큐, OS 작업 스케줄링, 메시지 큐.
둘 다 hash function으로 메모리 위치를 직접 계산 → 평균 O(1). Dict는 key→value, Set은 unique values.
| Dictionary (Hash Map) | Set | |
|---|---|---|
| Lookup / Insert / Delete | O(1) 평균 | O(1) 평균 |
| 저장 | key→value 쌍 | unique 값만 |
| 중복 | key 중복 X | 값 중복 X |
| index 접근 | 없음 (key로) | 없음 (무순서) |
x in ...: List는 O(n)(전수) vs Set은 O(1)(해싱). 이 차이가 아래 case study의 핵심이다.| 필요한 것 | 고를 구조 |
|---|---|
| 위치(index)로 접근? | List |
| LIFO (undo/뒤집기)? | Stack |
| FIFO (선착순)? | Queue (deque) |
| key로 빠른 lookup? | Dictionary |
| unique만, 빠른 멤버십? | Set |
# O(n²) — nested loop # O(n) — set
for i in range(len(nums)): seen = set()
for j in range(i+1,len(nums)): for x in nums:
if nums[i]==nums[j]: if x in seen: return True
return True seen.add(x)
같은 문제, set으로 O(n²)→O(n)(n=10,000이면 약 100배 빠름). "올바른 자료구조가 복잡도를 바꾼다"의 대표 예.
"O(n) 공간"이 실제로 어디에 사는가? 메모리는 거대한 바이트 배열이다 — 각 바이트에 주소(index)와 값(0–255). 변수 x = 5는 "이름 x → 주소 0x1000 → 값 5"의 연결이다. 타입은 몇 바이트를 읽을지를 정한다(bool 1, int 보통 4, long/float/double 8, 객체 참조 보통 8 — 데이터가 아니라 주소를 담음).
sum이 끝나면 그 a,b는 즉시 회수된다 — 그래서 지역변수가 안 남는다. (개념: 강의 0526 Memory Has Regions · The Call Stack)| Stack | Heap | |
|---|---|---|
| 크기 | 작음 (1–8MB/thread) | 큼 (RAM까지) |
| 속도 | 매우 빠름 (포인터만 이동) | 느림 (빈 chunk 탐색) |
| 관리 | 자동 (LIFO, frame pop) | GC(Java/Python) 또는 수동(C malloc/free) |
| 저장 | 지역변수, 매개변수, return 주소 | 객체, list, dict, string |
| 수명 | 함수 끝나면 즉시 사라짐 | 참조가 남아있는 한 살아있음 |
def f():
n = 5 # int 객체는 HEAP, 슬롯 n(참조)은 STACK
items = [1,2,3,4,5] # list 객체는 HEAP, 슬롯 items(주소)는 STACK
return items[0]
함수가 호출되면 frame이 stack에 push된다. frame 안에는 ① 매개변수(인자 복사본) ② 지역변수 ③ return 주소(끝나면 돌아갈 곳). 함수가 return하면 frame이 즉시 pop되고 지역변수는 사라진다.
def sum(a,b): return a+b
def average(a,b): total = sum(a,b); return total/2
def main(): result = average(4,6)
# sum(4,6) 실행 중 stack 상태:
sum: a=4, b=6 ← top
average: a=4, b=6, total=?
main: result=?
# sum이 10 반환 → sum frame pop → average의 total=10
지역변수가 안 남는 이유: sum의 a,b는 sum이 실행되는 동안만 존재. return하면 그 바이트는 다음에 push될 frame 차지가 된다. 그게 stack의 본질 — 안 남아도 되니까 빠르다.
sys.getrecursionlimit()). 넘으면 stack overflow → RecursionError."이 폴더의 모든 파일을 세라(하위·하위의 하위 폴더 포함)" — 반복문으로는 worklist+stack을 손수 흉내내야 하지만, 재귀로는 두 줄이다. 재귀 함수는 자기 자신을 호출하는 함수이고, tree·자기유사(self-similar) 구조에 자연스럽다.
모든 재귀 함수 = ① Base case(멈춤 조건) — 가장 작은 입력, 답이 자명, 재귀 호출 없음. ② Recursive case(줄이기) — 더 작은 같은 문제로 표현하고 재귀 호출. 설계 3질문: 가장 단순한 입력의 답?(base) · 한 step 줄이는 법?(reduce) · 작은 답으로 큰 답 만드는 법?(combine).
def factorial(n):
if n == 0: # ← base case
return 1
return n * factorial(n-1) # ← recursive case
# 내려가며 쌓임: 되돌아오며 곱함:
# factorial(4)→4*factorial(3) factorial(0)=1
# factorial(3)→3*factorial(2) factorial(1)=1*1=1
# factorial(2)→2*factorial(1) factorial(2)=2*1=2
# factorial(1)→1*factorial(0) factorial(3)=3*2=6
# factorial(0)→1 (base) factorial(4)=4*6=24
가장 깊을 때 5개 frame(factorial 4,3,2,1,0)이 동시에 stack에 존재 → 하나씩 pop. 이게 §6의 call stack이 그대로 일하는 모습이다. 재귀는 마법이 아니라 함수가 함수를 부르는 것이고, bookkeeping은 stack이 한다.
factorial(-1)→factorial(-2)→… 무한 → frame이 안 멈추고 쌓여 RecursionError. Traceback이 곧 stack(top=가장 최근 호출).def fib(n):
if n == 0: return 0 # base 1
if n == 1: return 1 # base 2
return fib(n-1) + fib(n-2) # ← 두 번 재귀 → trace가 tree
# 0,1,1,2,3,5,8,13,21,34,55,...
Time: O(2ⁿ) — 각 호출이 2갈래로 번져 중복 subproblem 폭증(fib(5)≈15노드, fib(30)≈260만). Space: O(n) — call tree는 O(2ⁿ) 노드지만, 동시에 stack에 있는 frame은 한 경로(root→leaf)뿐이라 최대 깊이 n. "일반 규칙: 재귀의 space ≈ 최대 재귀 깊이".
| Recursion 적합 | Iteration 적합 |
|---|---|
| tree/graph 구조 (파일시스템, JSON) | 단순 linear 순회 (합·카운트) |
| 분할정복 (merge/quick sort, binary search) | 깊이가 매우 큼(10,000+) → overflow 위험 |
| 정의 자체가 재귀 (factorial, 하노이탑) | 성능 critical, frame overhead 회피 |
f(n) 대신 f(n-1)) ④return 빼먹음→None 반환 ⑤mutable state 공유→예측불가.naive fib가 느린 이유는 같은 subproblem을 반복 계산하기 때문(fib(3)을 여러 번). 고치는 법: 처음 계산한 답을 기억(cache)해 두고, 같은 입력이 또 오면 다시 계산하지 않고 꺼낸다. 이게 memoization — 전형적인 "공간을 써서 시간을 산다(space-for-time)".
memo = {}
def fib(n):
if n in memo: return memo[n] # cache hit → 즉시 반환
if n < 2: return n
memo[n] = fib(n-1) + fib(n-2)
return memo[n]
같은 재귀, 다른 복잡도. Time O(2ⁿ) → O(n)(각 fib(k)를 단 한 번 계산 — 2갈래 tree가 길이 n의 일직선으로 붕괴). Space는 stack O(n) + memo O(n). 안전 조건: 함수가 순수(pure)해야(같은 입력→같은 출력, 부작용 없음) 하고, 입력이 hashable(dict key로 쓰일 수 있어야)해야 한다. 이 기법을 일반화하면 Dynamic Programming(DP)이다.
(a) naive Fibonacci의 Time/Space는? (b) memoization 적용 후 Time은? (c) 재귀 함수의 space complexity는 대략 무엇에 비례?
(a) Time O(2ⁿ), Space O(n). (b) O(n). (c) 최대 재귀 깊이(max recursion depth).
기말 코딩 10문항 대비. 핵심 패턴은 — 현실적인 상황(사례)이 주어지고, 거기서 수업에 배운 개념이 무엇인지 알아채 코드로 구현하는 것이다. 그래서 아래 문제는 모두 실생활 시나리오 → 떠올릴 개념 → 구현 순서다. 각 문제는 먼저 직접 손으로 짜 본 뒤 "Show Solution"을 펼쳐 모범답안·복잡도를 확인하세요.
사용자가 페이지를 차례로 방문하고("visit") 가끔 뒤로가기("back")를 누른다. 동작 목록을 받아 현재 보고 있는 페이지를 반환하라. 예: [("visit","A"),("visit","B"),("back",None),("visit","C")] → "C".
def current_page(actions):
history = [] # stack
for kind, page in actions:
if kind == "visit":
history.append(page) # push
elif kind == "back" and len(history) > 1:
history.pop() # pop
return history[-1] if history else None
"가장 최근 것부터 되돌린다" = LIFO = Stack. push/pop 모두 O(1). ⚠️ 빈 history pop 방지(len>1).
카페에서 주문이 들어온 순서대로 음료를 만들어 내보낸다. 주문 리스트를 받아 처리되는 순서대로 출력하되, 앞에서 빼는 연산이 O(1)이 되게 하라.
from collections import deque
def serve(orders):
q = deque(orders)
while q:
drink = q.popleft() # dequeue, O(1)
print(f"made {drink}")
"선착순 = FIFO = Queue". ⚠️ list.pop(0)는 O(n)(전부 shift)이라 함정 — 반드시 deque.popleft() O(1).
가입 신청 이메일이 순서대로 들어온다. 이미 가입된 이메일이면 거절해야 한다. 중복 없이 가입에 성공한 이메일 목록을 반환하라. (가입자가 10만 명이어도 빠르게.)
def register(emails):
seen = set() # O(1) 포함검사
accepted = []
for e in emails:
if e not in seen:
seen.add(e)
accepted.append(e)
return accepted
"중복 막기 + 빠른 포함검사" = Set. e in seen이 O(1) → 전체 O(n). 리스트로 in 검사하면 O(n)×n = O(n²)(함정).
서버 로그(이벤트 이름 리스트)를 받아 가장 많이 발생한 이벤트를 반환하라. 예: ["login","view","login","buy","login"] → "login".
def top_event(logs):
freq = {} # dict: 이벤트 → 횟수
for ev in logs:
freq[ev] = freq.get(ev, 0) + 1 # O(1) 갱신
return max(freq, key=freq.get)
"세기/매핑" = Dict(hash map). 한 번 훑어 O(n). freq.get(ev, 0)로 없는 key 안전 처리(KeyError 회피).
이름이 사전순으로 정렬된 거대한 사용자 명단(100만 명)에서 특정 이름이 몇 번째에 있는지 찾아라(없으면 −1). 전수조사(O(n))는 너무 느리다 — O(log n)으로.
def find_user(names, target):
lo, hi = 0, len(names) - 1
while lo <= hi:
mid = (lo + hi) // 2
if names[mid] == target: return mid
elif names[mid] < target: lo = mid + 1
else: hi = mid - 1
return -1
"정렬된 데서 빠르게 찾기" = Binary Search, O(log n)(100만 → 약 20번). ⚠️ 정렬이 전제 — 안 돼 있으면 못 쓴다. 문자열도 <로 사전순 비교 가능.
폴더가 {"files": int, "subfolders": [폴더, ...]}로 표현된다. 하위 폴더·하위의 하위까지 포함한 총 파일 수를 구하라. (폴더 안에 폴더 — 자기 자신과 같은 모양!)
def count_files(folder):
total = folder["files"] # 직속 파일
for sub in folder["subfolders"]:
total += count_files(sub) # 같은 문제, 더 작은 입력
return total
"하위 폴더를 세는 것 = 더 작은 같은 문제" → 재귀. base case는 subfolders가 빈 폴더(loop가 안 돌고 직속 파일만 반환). 반복문으로 짜면 worklist+stack을 손수 만들어야 한다.
회사 조직도에서 각 사람이 {"name": str, "reports": [부하직원, ...]}로 표현된다. 어떤 매니저 아래 직·간접 포함 전체 인원(본인 제외)을 구하라.
def team_size(person):
total = len(person["reports"]) # 직속 부하
for r in person["reports"]:
total += team_size(r) # 그 부하의 팀까지
return total
트리 구조(사람 안에 사람) → 재귀. §6의 call stack이 깊이만큼 frame을 쌓는다. 파일 폴더(⑥)와 정확히 같은 골격.
fetch_rate(currency)는 외부 서버를 호출해 느리다(1초). 같은 통화를 여러 번 물어봐도 한 번만 실제 조회하고 이후엔 즉시 답하도록 캐싱하라.
cache = {}
def get_rate(currency):
if currency in cache: # cache hit → 즉시
return cache[currency]
rate = fetch_rate(currency) # 느린 실제 조회 (1회만)
cache[currency] = rate
return rate
"같은 입력의 반복 계산을 기억" = 메모이제이션(space-for-time). §8 fib와 똑같은 패턴. 안전 조건: 같은 통화→같은 환율(순수), 입력이 hashable.
흑백 사진이 2차원 리스트(각 행이 픽셀 값 리스트)로 주어진다. 거울처럼 좌우 반전하되 새 이미지를 만들지 말고 제자리(in-place)로 바꿔라. (각 행을 뒤집는 문제.)
def flip_horizontal(img):
for row in img:
l, r = 0, len(row) - 1
while l < r: # two-pointer 제자리 교환
row[l], row[r] = row[r], row[l]
l += 1; r -= 1
각 행을 in-place reverse(§3) → 추가 메모리 O(1)(새 격자 안 만듦), 시간 O(행×열). "이미지=격자=2D 리스트"를 알아채는 게 핵심.
A반·B반 성적이 각각 오름차순 정렬되어 있다. 둘을 합쳐 전체 정렬된 하나의 리스트로 만들어라(다시 정렬하지 말고 한 번 훑어서).
def merge_scores(a, b):
res, i, j = [], 0, 0
while i < len(a) and j < len(b):
if a[i] <= b[j]: res.append(a[i]); i += 1
else: res.append(b[j]); j += 1
res.extend(a[i:]); res.extend(b[j:]) # 남은 꼬리
return res
두 포인터로 한 번씩 → O(n+m). 합친 뒤 sorted()로 다시 정렬하면 O(N log N)으로 낭비 — merge sort의 핵심 단계가 바로 이것.
NLP에서 텍스트를 모델에 넣으려면 각 고유 단어에 정수 ID를 부여한다(tokenizer의 vocabulary). 단어 리스트를 받아 처음 등장한 순서대로 0,1,2…를 매긴 {단어: id} 사전을 만들어라.
def build_vocab(words):
vocab = {}
for w in words:
if w not in vocab: # 새 단어 → 다음 ID
vocab[w] = len(vocab)
return vocab
"고유 항목에 번호" = Dict + O(1) 멤버십. 전체 O(n). len(vocab)가 자연스럽게 다음 ID가 된다.
가격이 스트림으로 들어온다. 매 시점 최근 k개의 평균을 출력하라. 가장 오래된 값은 자동으로 빠져야 한다(window 유지).
from collections import deque
def moving_average(prices, k=3):
window = deque(maxlen=k) # 꽉 차면 가장 오래된 게 자동 pop
out = []
for p in prices:
window.append(p)
out.append(sum(window) / len(window))
return out
"오래된 것부터 밀어냄 = FIFO = Queue". deque(maxlen=k)가 sliding window를 O(1)로 관리. 슬라이딩 윈도우는 스트리밍 지표의 단골.
전체 영화 목록 all_movies와 사용자가 이미 본 목록 watched가 주어진다. 안 본 영화만 추천 리스트로 반환하라(영화가 수만 개여도 빠르게).
def to_recommend(all_movies, watched):
seen = set(watched) # O(1) 포함검사
return [m for m in all_movies if m not in seen]
watched를 Set으로 바꾸면 포함검사 O(1) → 전체 O(n). 리스트로 not in 하면 O(n×m)(함정). "차집합" 발상.
동전 종류 coins(예: [1,5,10,25], 무한 사용 가능)로 금액 amount를 만드는 경우의 수를 구하라(순서 무시). 예: 15 → 6가지. (Berkeley CS61A의 대표 트리 재귀 문제.)
발상 — 각 동전마다 "이 동전을 (한 번 더) 쓴다" vs "이 동전은 더 안 쓰고 다음 종류로" 두 갈래. 이 2갈래 분기가 §7의 트리 재귀다.
def count_change(amount, coins):
memo = {}
def helper(amt, i):
if amt == 0: return 1 # 딱 맞춤 → 1가지
if amt < 0 or i == len(coins): return 0
if (amt, i) in memo: return memo[(amt, i)]
memo[(amt, i)] = (helper(amt - coins[i], i) # i동전 쓰기
+ helper(amt, i + 1)) # i동전 그만
return memo[(amt, i)]
return helper(amount, 0)
메모 없이는 같은 (금액, 동전위치)를 반복 계산해 지수적. memo로 각 상태를 1번만 → 사실상 DP. §7(트리 재귀)+§8(메모이제이션)을 한 문제로 묶은 전형이다.
가격이 오름차순 정렬된 상품 목록에서 합이 정확히 budget인 두 상품의 인덱스 쌍을 반환하라(없으면 None). 모든 쌍 검사(O(n²)) 말고 O(n)으로.
def find_pair(prices, budget):
lo, hi = 0, len(prices) - 1
while lo < hi:
s = prices[lo] + prices[hi]
if s == budget: return (lo, hi)
elif s < budget: lo += 1 # 합 부족 → 왼쪽을 키움
else: hi -= 1 # 합 초과 → 오른쪽을 줄임
return None
양 끝에서 좁혀오는 two-pointer: 정렬돼 있으니 합이 크면 hi--, 작으면 lo++. Time O(n), Space O(1). "정렬됨"을 이용하는 점이 binary search와 통한다.
gcd(a,b)=gcd(b,a%b)(재귀), 중첩 리스트 flatten(트리 재귀), 빠른 거듭제곱 x^n=(x^(n/2))²(분할정복 O(log n)). 모두 "배운 base case+재귀 / 자료구조"로 분해하면 풀린다.아래는 A4 양면(영어 손글씨) 치트시트 압축본. 시험장 반입용으로 그대로 옮겨 적으세요.
Order: O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(2ⁿ) · n=1000: 1·10·1000·10⁴·10⁶·∞
Simplify: drop constants, drop lower terms, keep dominant.
Patterns: 1 loop=O(n), nested=O(n²), halving=O(log n), divide&merge=O(n log n), 2-way recursion=O(2ⁿ).
Space: count EXTRA only. var=O(1), len-n list=O(n), n×n=O(n²), recursion≈max depth=O(n). in-place=O(1).
| 구조 | Access | Search | Insert | Delete |
|---|---|---|---|---|
| List | O(1) by index | O(n) | O(1) end / O(n) front | O(1) end / O(n) front |
| Stack (LIFO) | O(1) top | O(n) | O(1) push | O(1) pop |
| Queue (deque, FIFO) | O(1) front | O(n) | O(1) enq | O(1) deq |
| Dict / Set | O(1) by key | O(1) | O(1) | O(1) |
list.pop(0)=O(n) vs deque.popleft()=O(1) · x in list=O(n) vs x in set=O(1).
| 정렬 | Best | Avg | Worst | Space |
|---|---|---|---|---|
| Bubble | O(n) | O(n²) | O(n²) | O(1) |
| Selection | O(n²) | O(n²) | O(n²) | O(1) |
| Merge | O(n log n) | O(n log n) | O(n log n) | O(n) |
| Quick | O(n log n) | O(n log n) | O(n²) | O(log n) |
Stack: small, fast, auto-LIFO, locals/params/return-addr, dies on return. Heap: large, slow, GC, objects/lists.
Call stack: frame push on call, pop on return. Python limit ≈1000.
Recursion = base case + recursive case (write base FIRST). factorial O(n)/O(n).
naive fib: Time O(2ⁿ), Space O(n) (tree=2ⁿ nodes, stack=n depth). memoization→Time O(n) (space-for-time, needs pure+hashable).
Errors: empty stack pop→IndexError · no/bad base case→RecursionError · missing key→KeyError.
list.pop(0) vs deque.popleft() 함정? ⑤ fib Time O(2ⁿ)·Space O(n) 안 헷갈리나? ⑥ Stack(자료구조) ≠ Stack(메모리영역) 구분? ⑦ T/F는 극단 단어(always/never)=의심, 확실한 것만! → 다음은 실전 모의고사 2회분으로.