대화를 코드로, 코드를 통찰로 —
AI와의 자연스러운 대화 속에서 컴퓨터 과학의 핵심 알고리즘과 자료구조를 만나는 책
이 책이 지향하는 것
대화를 코드로, 코드를 통찰로 —
AI와의 자연스러운 대화 속에서 컴퓨터 과학의 핵심 알고리즘과 자료구조를 만나는 책
이 책은 바이브 코딩(Vibe Coding) 이라는 방식으로 쓰입니다.
바이브 코딩이란, "딱딱한 강의 없이, AI와의 편안한 대화만으로 코드를 만들고 이해하고, 그 과정에서 컴퓨터 과학의 본질을 체득하는" 학습 방식입니다.
이 책은 고등학생 ~ 대학교 1학년을 대상으로 합니다.
수학은 고등학교 수준에서 충분합니다. 프로그래밍 경험이 없어도 괜찮습니다 — 처음부터 한 줄씩 함께 써나가니까요.
바이브 코딩이란?
"바이브 코딩"은 여기서 이런 의미로 쓰입니다:
- 대화형(Cochleative): AI에게 자연어로 질문하고, AI가 코드를 제안하고, 내가 수정하고, 다시 질문하는 사이클
- 감각형: 코드가 "어떤 느낌"인지 이해하는 것 — 시간 복잡도가 "느껴지고", 메모리 사용이 "보이고"
- 점진적: 한 번에 완벽한 코드를 짜는 게 아니라, 대충 → 개선 → 최적화 → 통찰의 단계를 밟음
- 시각화 동반: 매 챕터마다 직접 그려보거나, 실행 결과를 눈으로 확인하는 단계가 포함됨
전체 커리큘럼 (10장)
| 장 | 주제 | 핵심 개념 | 난이도 |
| 1 | 배열로 시작하기 — 데이터의 방 | 배열, 메모리, 인덱싱 | ★☆☆ |
| 2 | 스택과 큐 — 줄 서는 컴퓨터 | LIFO, FIFO, 호출 스택 | ★★☆ |
| 3 | 연결 리스트 — 사슬처럼 이어진 데이터 | 포인터, 동적 할당, 노드 | ★★★ |
| 4 | 정렬의 세계 — 데이터를 줄 세우는 법 | 버블·선택·삽입 정렬, 비교 횟수 | ★★☆ |
| 5 | 탐색의 기술 — 찾는다는 것의 의미 | 선형·이진 탐색, 시간 복잡도 | ★★☆ |
| 6 | 재귀의 아름다움 — 자기 자신을 호출하다 | 재귀, 기저 사례, 분할 정복 | ★★★ |
| 7 | 해시 테이블 — 순간 검색의 마법 | 해시 함수, 충돌, 체이닝 | ★★★ |
| 8 | 트리 — 계층 구조의 미학 | 이진 트리, 순회, 높이 | ★★★ |
| 9 | 그래프 — 관계의 네트워크 | 인접 리스트, BFS, DFS | ★★★ |
| 10 | 동적 계획법 — 작은 결정의 누적이 만드는 큰 결과 | 메모이제이션, 최적 부분 구조 | ★★★★ |
바이브 코딩으로 읽는 방법
각 장은 이렇게 진행됩니다:
- 🎯 미션 — 이번 장에서 해결할 문제나 구현해 볼 것
- 💬 바이브 프롬프트 — Solar에게 건네는 대화형 프롬프트 (그대로 복사해 쓰면 됨)
- 🔄 가이드 — 대화가 막히면 도와줄 중간 가이드
- 📦 결과물 — 완성된 코드와 학습물
- 💡 배움 포인트 — 이 장에서 얻는 컴퓨터 과학 통찰
- 🧪 직접 해보기 — 살짝 변형해 보는 연습 문제
시작 전에
필요 준비물
- Python 3.10+ 설치 (https://python.org)
- 터미널(명령 프롬프트/터미널) 사용 가능
- Solar 접근 (이 책을 읽고 있는 당신이 이미 하고 있죠!)
코드 실행 방법
모든 코드는 Python으로 작성되며, 터미널에서 다음처럼 실행합니다:
1 python3 chapter1.py
목차
- [제1장: 배열로 시작하기 — 데이터의 방](./chapter1.md)
- [제2장: 스택과 큐 — 줄 서는 컴퓨터](./chapter2.md)
- [제3장: 연결 리스트 — 사슬처럼 이어진 데이터](./chapter3.md)
- [제4장: 정렬의 세계 — 데이터를 줄 세우는 법](./chapter4.md)
- [제5장: 탐색의 기술 — 찾는다는 것의 의미](./chapter5.md)
- [제6장: 재귀의 아름다움 — 자기 자신을 호출하다](./chapter6.md)
- [제7장: 해시 테이블 — 순간 검색의 마법](./chapter7.md)
- [제8장: 트리 — 계층 구조의 미학](./chapter8.md)
- [제9장: 그래프 — 관계의 네트워크](./chapter9.md)
- [제10장: 동적 계획법 — 작은 결정의 누적이 만드는 큰 결과](./chapter10.md)
컴퓨터 과학의 가장 기본적인 데이터 구조.
"앱에서 본 데이터가 실제로 RAM 어떤 위치에 저장되는가"를 몸으로 이해하는 장.
🎯 미션
컴퓨터 과학의 가장 기본적인 데이터 구조.
"앱에서 본 데이터가 실제로 RAM 어떤 위치에 저장되는가"를 몸으로 이해하는 장.
배열을 직접 구현해보고, 배열 위를 "걷는" 경험을 통해 인덱싱과 메모리의 관계를 이해한다.
구체적으로:
- 고정 크기 정수 배열을 처음부터 구현 (Python의
list를 쓰지 않고!) - 배열에 값을 넣고, 꺼내고, 탐색하는 함수들 작성
- 배열이 메모리를 어떻게 쓰는지 시각화
- 배열 기반 "학생 성적 관리 프로그램" 완성
💬 바이브 프롬프트
아래 프롬프트를 Solar에게 그대로 건네세요. 한 번에 다 주고, Solar의 첫 응답을 받은 뒤 대화를 이어가면 됩니다.
(프롬프트 시작)
나는 고등학생/대학 1학년이야. 컴퓨터 과학을 배우기 시작했는데, "배열(Array)"이 정확히 무엇인지 몸소 이해하고 싶어.
목표: Python에서 내장
list를 쓰지 않고, 고정 크기 정수 배열을 처음부터 구현해보고 싶어. (즉,class MyArray를 만들어서, 내부적으로 고정된 크기의 메모리를 할당하고, 인덱스로 읽고 쓰는 것이지.)
진행 방식 — 바이브 코딩:
1. 먼저 네가 "배열이 무엇인가"를 아주 쉬운 비유로 설명해줘. 예: "배열은 아파트 같아요. 101호, 102호... 각 호실에 데이터가 살고 있어요." 이렇게.
2. 그다음 내가 "그럼 Python으로 만들어보자!" 하면, 가장 단순한 버전부터 시작하자. 내부적으로는 Python의
list를 쓰지 말고, 원시적인 방식 — 예를 들어 고정 크기bytearray나array모듈, 또는 아예 raw한 방식 — 으로 시작하는 거야. 하지만 너무 어렵지 않게, 교육용으로 이해할 수 있는 수준에서.3. 한 번에 완벽한 코드를 짜지 말고, 대화하면서 조금씩 완성해줘:
- 1단계:
__init__— 고정 크기 배열 생성, 내부적으로 모두 0으로 채우기- 2단계:
__getitem__(index)— 인덱스로 값 읽기 (범위 체크 포함)- 3단계:
__setitem__(index, value)— 인덱스로 값 쓰기 (범위 체크 포함)- 4단계:
__len__— 배열 크기 반환- 5단계:
__repr__— 배열 내용 보기 좋게 출력- 6단계:
append— 끝에 추가 (배열이 가득 차면 에러)- 7단계:
pop— 끝에서 제거- 8단계:
find(value)— 값이 있으면 인덱스 반환, 없으면 -14. 각 단계마다 코드 + 짧은 설명 + 왜 이렇게 했는지를 함께 제시해주고, 내가 이해하면 "다음 단계 가자!"고 내가 말하는 방식이 좋겠어.
5. 마지막엔 이 배열로 학생 성적 관리 프로그램을 만들어보자:
- 학생 이름 리스트와 성적 배열을 연동
- 평균, 최고점, 최저점 계산
- 특정 학생의 성적 조회
중요: 코드를 주기 전에 "이렇게 해볼까요?"라고 묻고, 내가 "좋아요!" 하면 코드를 주는 방식으로 진행해줘. 내가 이해가 안 되면 더 쉬운 비유로 다시 설명해줘.
자, 첫 단계 — "배열이 무엇인가"를 아파트 비유로 설명하면서 시작해보자!
(프롬프트 끝)
🔄 가이드 (대화가 막히면 덧붙이기)
대화가 길어지거나 막히면 아래 가이드를 추가로 건네세요:
가이드 A: 메모리가 정말 이해 안 될 때
"배열의 각 칸이 메모리의 어떤 주소에 매핑되는지, 실제 메모리 주소를 보여주는 방식으로 다시 설명해주라. 예를 들어 '배열 시작 주소가 1000이고, 각 칸이 4바이트를 차지하면, arr[3]은 주소 1012에 저장된다' 이런 식으로."
가이드 B: 코드가 복잡해질 때
"지금까지의 코드를 한 화면에 보이게 아주 짧게 요약을 먼저 하고, 그다음 세부 코드를 보여줘. 각 메서드가 몇 줄인지, 전체 코드 길이가 얼마나 되는지 먼저 알려줘."
가이드 C: 성적 관리 프로그램이 어려울 때
"학생 성적 관리 프로그램을 만들기 전에, 먼저 배열 자체를 테스트하는 작은 테스트 케이스를 보여줘. 예를 들어 arr = MyArray(5)일 때 arr[2] = 10, arr[2] = 20, arr.find(10) = 2 이런 식으로."
📦 기대 결과물
장 마지막에 완성되는 코드 예시:
1 # chapter1.py
2
3 class MyArray:
4 """배열을 직접 구현한 클래스 — list를 쓰지 않음!"""
5
6 def __init__(self, capacity: int):
7 self.capacity = capacity
8 self.size = 0
9 self.data = [0] * capacity # 교육용: 내부적으로 list를 쓰지만,
10 # 개념적으로는 "고정 크기 메모리 블록"으로 이해
11
12 def __getitem__(self, index: int) -> int:
13 if not (0 <= index < self.size):
14 raise IndexError(f"인덱스 {index}는 범위를 벗어납니다 (0~{self.size-1})")
15 return self.data[index]
16
17 def __setitem__(self, index: int, value: int) -> None:
18 if not (0 <= index < self.capacity):
19 raise IndexError(f"인덱스 {index}는 용량을 벗어납니다 (0~{self.capacity-1})")
20 self.data[index] = value
21 if index >= self.size:
22 self.size = index + 1
23
24 def __len__(self) -> int:
25 return self.size
26
27 def __repr__(self) -> str:
28 return f"MyArray({[self.data[i] for i in range(self.size)]})"
29
30 def append(self, value: int) -> None:
31 if self.size >= self.capacity:
32 raise OverflowError("배열이 가득 찼습니다!")
33 self.data[self.size] = value
34 self.size += 1
35
36 def pop(self) -> int:
37 if self.size == 0:
38 raise IndexError("빈 배열에서 pop할 수 없습니다")
39 value = self.data[self.size - 1]
40 self.size -= 1
41 return value
42
43 def find(self, value: int) -> int:
44 for i in range(self.size):
45 if self.data[i] == value:
46 return i
47 return -1
48
49
50 # === 실행 예시 ===
51 if __name__ == "__main__":
52 arr = MyArray(5)
53 print(f"빈 배열: {arr}")
54
55 arr.append(85) # 학생 1의 성적
56 arr.append(92) # 학생 2의 성적
57 arr.append(78) # 학생 3의 성적
58 arr.append(95) # 학생 4의 성적
59 arr.append(88) # 학생 5의 성적
60 print(f"성적 입력 후: {arr}")
61
62 print(f"학생 3의 성적 (인덱스 2): {arr[2]}")
63 print(f"배열 길이: {len(arr)}")
64 print(f"92점이 있는 인덱스: {arr.find(92)}")
65 print(f"평균: {sum(arr.data[:arr.size]) / arr.size:.1f}")
66 print(f"최고점: {max(arr.data[:arr.size])}")
67 print(f"최저점: {min(arr.data[:arr.size])}")
실행 결과:
1 빈 배열: MyArray([])
2 성적 입력 후: MyArray([85, 92, 78, 95, 88])
3 학생 3의 성적 (인덱스 2): 78
4 배열 길이: 5
5 92점이 있는 인덱스: 1
6 평균: 87.6
7 최고점: 95
8 최저점: 78
💡 배움 포인트
| 개념 | 무엇을 배웠는가 |
| **인덱싱** | 배열 이름 `arr`과 `arr[2]`의 차이 — 이름이 아니라 **위치**로 접근한다 |
| **메모리 레이아웃** | 배열은 메모리에 **연속적으로** 저장된다. `arr[0]` 다음에 `arr[1]`이 바로 옆에 있다 |
| **시간 복잡도** | `arr[i]` 읽기는 O(1) — 주소 계산 한 번으로 끝난다. `find(x)`는 O(n) — 끝까지 훑어야 한다 |
| **고정 크기 vs 동적** | Python의 `list`는 동적이다 (크기 자동 확장). 직접 만든 배열은 **고정 크기** — 가득 차면 끝! |
| **범위 체크** | `arr[100]`을 요청하면? 실제 메모리의 다른 데이터가 훼손될 수 있다 → **범위 체크가 왜 필수인지** 체감 |
🧪 직접 해보기
- MyArray에
insert(index, value)메서드 추가하기 — 중간에 끼워넣기. 기존 값들을 한 칸씩 밀어야 한다. 이 작업이 왜 O(n)인지 체감해보자. - 진짜 raw 배열 만들어보기 — Python의
array모듈을 사용해봐:import array; arr = array.array('i', [0]*10).'i'는 signed int를 의미. 내부가 C 배열이다. - 배열의 메모리 사용량 계산 —
arr = MyArray(1000)일 때,data가 실제로 몇 바이트를 쓰는지sys.getsizeof()로 확인해봐. 정수 1개가 보통 28바이트(Python 객체)인데, C 배열은 4바이트다. 이 차이를 느껴보자.
🔗 다음 장 예고
제2장: 스택과 큐 — 줄 서는 컴퓨터
배열이 "아무 데나 접근"이라면, 스택과 큐는 "접근 규칙을 정한 배열"입니다.
브라우저 뒤로 가기 버튼이 스택, 프린터 대기열이 큐라는 걸 함께 알아봅시다.
스택과 큐는 "배열 + 접근 규칙"입니다.
아무 데나 접근할 수 있는 배열에서, 어디로 들어가고 어디서 나오는지를 제한하면 스택과 큐가 됩니다.
🎯 미션
스택과 큐는 "배열 + 접근 규칙"입니다.
아무 데나 접근할 수 있는 배열에서, 어디로 들어가고 어디서 나오는지를 제한하면 스택과 큐가 됩니다.
스택(Stack)과 큐(Queue)를 각각 구현하고, 일상 속 예시로 동작 원리를 체감한다.
구체적으로:
- Stack 클래스 구현 (LIFO — Last In, First Out)
- Queue 클래스 구현 (FIFO — First In, First Out)
- Stack으로 브라우저 뒤로가기 구현
- Queue로 프린터 대기열 시뮬레이션
- Stack으로 괄호 짝 맞추기 프로그램 작성
- 시간 복잡도 분석
💬 바이브 프롬프트
(프롬프트 시작)
나는 컴퓨터 과학을 배우는 고등학생/대학 1학년이야. 이번 장에서는 스택(Stack)과 큐(Queue)를 배울 거야.
목표: Stack과 Queue를 각각 MyArray 위에 구현해보고, 일상 속 예시로 이해한다.
진행 방식 — 바이브 코딩:
1. 먼저 비유로 시작해줘:
- Stack: 접시 쌓기. 위에 올려놓고 위에서부터 치운다. (LIFO)
- Queue: 줄 서기. 맨 뒤에 서고 맨 앞에서 나간다. (FIFO)
- 이 비유를 먼저 아주 친절하게 설명하고, 그다음 "컴퓨터에서 이런 게 진짜로 쓰이는 예"를 3개씩 들어줘 (예: Stack=브라우저 뒤로가기, 함수 호출, Undo; Queue=프린터, 콜센터 대기, 메시지 큐)
2. 이제 구현으로 넘어가자. 이전 장에서 만든 MyArray를 이용해:
- Stack 클래스:
-
push(item)— 맨 위에 추가-
pop()— 맨 위에서 꺼내기-
peek()— 맨 위 값 보기 (꺼내지 않음)-
is_empty()— 비어있는지-
size()— 현재 크기- Queue 클래스:
-
enqueue(item)— 맨 뒤에 추가-
dequeue()— 맨 앞에서 꺼내기-
front()— 맨 앞 값 보기-
is_empty()— 비어있는지-
size()— 현재 크기
3. 중요: Queue를 배열로 구현할 때, dequeue하면 앞의 데이터가 비는데 그 칸을 어떻게 처리할지 고민해봐. 두 가지 접근법이 있어:
- (a) 매번 dequeue할 때 앞의 모든 데이터를 한 칸씩 앞으로 옮기기 — O(n)
- (b) front 포인터와 rear 포인터로 "출발점과 도착점"을 관리하는 원형 큐(Circular Queue) — O(1)
- 둘 다 구현해보고, 왜 (b)가 더 나은지 비교해봐.
4. 실전 프로젝트 — 3개:
- (a) 브라우저 뒤로가기: 방문한 URL을 Stack에 push, 뒤로가기 누르면 pop → 실제 브라우저처럼 동작
- (b) 프린터 대기열: 문서들을 Queue에 enqueue, 프린터가 순서대로 dequeue해서 출력
- (c) 괄호 짝 맞추기:
((()))는 올바른 괄호,(()))는 틀림. Stack으로 해결!
5. 각 프로젝트마다 왜 Stack/Queue가 이 문제에 적합한가를 설명해줘.
6. 마지막으로 시간 복잡도 표를 정리해줘:
| 연산 | Stack | Queue(배열) | Queue(원형) |
| push/enqueue | O(1) | O(1) | O(1) |
| pop/dequeue | O(1) | O(n) | O(1) |
| peek/front | O(1) | O(1) | O(1) |
대화형으로 진행하자 — 한 단계씩 같이 가는 거야!
(프롬프트 끝)
🔄 가이드
가이드 A: 원형 큐가 이해 안 될 때
"원형 큐를 더 시각화해서 설명해줘. 배열을 동그랗게 말아서, rear가 끝에 도달하면 다시 처음으로 돌아오는 그림을 ASCII 아트로 그려줘."
가이드 B: 괄호 짝 맞추기가 어려울 때
"괄호 짝 맞추기를 훨씬 간단한 예부터 시작하자.
()→ 맞음,)(→ 틀림,(())→ 맞음,())→ 틀림. 각 경우에 스택의 상태를 한 단계씩 보여줘."
가이드 C: 시간 복잡도가 추상적일 때
"시간 복잡도를 그냥 O(1), O(n)이라고 하지 말고, 실제 숫자를 넣어줘. '배열이 100만 개 요소일 때, dequeue를 매번 O(n)으로 하면 최악의 경우 1+2+3+...+1000000번의 이동 = 약 5천억 번' 이런 식으로."
📦 기대 결과물
Stack 구현
1 class Stack:
2 def __init__(self, capacity=100):
3 self.arr = MyArray(capacity)
4 self._size = 0
5
6 def push(self, item):
7 self.arr.append(item)
8 self._size += 1
9
10 def pop(self):
11 if self.is_empty():
12 raise IndexError("스택이 비어있습니다")
13 self._size -= 1
14 return self.arr.pop()
15
16 def peek(self):
17 if self.is_empty():
18 raise IndexError("스택이 비어있습니다")
19 return self.arr[self._size - 1]
20
21 def is_empty(self):
22 return self._size == 0
23
24 def size(self):
25 return self._size
Queue 구현 (일반 배열 기반 — O(n) dequeue)
1 class Queue:
2 def __init__(self, capacity=100):
3 self.arr = MyArray(capacity)
4 self._size = 0
5
6 def enqueue(self, item):
7 """맨 뒤에 추가 — O(1)"""
8 self.arr.append(item)
9 self._size += 1
10
11 def dequeue(self):
12 """맨 앞에서 꺼내기 — O(n) (데이터가 앞으로 밀림)"""
13 if self.is_empty():
14 raise IndexError("큐가 비어있습니다")
15 value = self.arr[0]
16 # 앞의 모든 데이터를 한 칸씩 앞으로 이동
17 for i in range(self._size - 1):
18 self.arr[i] = self.arr[i + 1]
19 self._size -= 1
20 return value
21
22 def front(self):
23 """맨 앞 값 보기 — O(1)"""
24 if self.is_empty():
25 raise IndexError("큐가 비어있습니다")
26 return self.arr[0]
27
28 def is_empty(self):
29 return self._size == 0
30
31 def size(self):
32 return self._size
원형 큐(Circular Queue) 구현 — O(1) dequeue
1 class CircularQueue:
2 """원형 큐 — front/rear 포인터로 O(1) enqueue/dequeue"""
3
4 def __init__(self, capacity=100):
5 self.capacity = capacity
6 self.arr = MyArray(capacity)
7 self.front = 0 # 꺼낼 위치
8 self.rear = 0 # 넣을 위치
9 self._size = 0
10
11 def enqueue(self, item):
12 """맨 뒤에 추가 — O(1)"""
13 if self._size >= self.capacity:
14 raise OverflowError("큐가 가득 찼습니다!")
15 self.arr[self.rear] = item
16 self.rear = (self.rear + 1) % self.capacity
17 self._size += 1
18
19 def dequeue(self):
20 """맨 앞에서 꺼내기 — O(1)"""
21 if self.is_empty():
22 raise IndexError("큐가 비어있습니다")
23 value = self.arr[self.front]
24 self.front = (self.front + 1) % self.capacity
25 self._size -= 1
26 return value
27
28 def front_element(self):
29 """맨 앞 값 보기 — O(1)"""
30 if self.is_empty():
31 raise IndexError("큐가 비어있습니다")
32 return self.arr[self.front]
33
34 def is_empty(self):
35 return self._size == 0
36
37 def is_full(self):
38 return self._size >= self.capacity
39
40 def size(self):
41 return self._size
42
43 def __repr__(self):
44 """현재 큐의 내용을 순서대로 보여줌"""
45 elements = []
46 idx = self.front
47 for _ in range(self._size):
48 elements.append(self.arr[idx])
49 idx = (idx + 1) % self.capacity
50 return f"CircularQueue({elements})"
Queue 구현 (일반 배열 기반 — O(n) dequeue)
1 class Queue:
2 def __init__(self, capacity=100):
3 self.arr = MyArray(capacity)
4 self._size = 0
5
6 def enqueue(self, item):
7 """맨 뒤에 추가 — O(1)"""
8 self.arr.append(item)
9 self._size += 1
10
11 def dequeue(self):
12 """맨 앞에서 꺼내기 — O(n) (데이터가 앞으로 밀림)"""
13 if self.is_empty():
14 raise IndexError("큐가 비어있습니다")
15 value = self.arr[0]
16 # 앞의 모든 데이터를 한 칸씩 앞으로 이동
17 for i in range(self._size - 1):
18 self.arr[i] = self.arr[i + 1]
19 self._size -= 1
20 return value
21
22 def front(self):
23 """맨 앞 값 보기 — O(1)"""
24 if self.is_empty():
25 raise IndexError("큐가 비어있습니다")
26 return self.arr[0]
27
28 def is_empty(self):
29 return self._size == 0
30
31 def size(self):
32 return self._size
원형 큐(Circular Queue) 구현 — O(1) dequeue
1 class CircularQueue:
2 """원형 큐 — front/rear 포인터로 O(1) enqueue/dequeue"""
3
4 def __init__(self, capacity=100):
5 self.capacity = capacity
6 self.arr = MyArray(capacity)
7 self.front = 0 # 꺼낼 위치
8 self.rear = 0 # 넣을 위치
9 self._size = 0
10
11 def enqueue(self, item):
12 """맨 뒤에 추가 — O(1)"""
13 if self._size >= self.capacity:
14 raise OverflowError("큐가 가득 찼습니다!")
15 self.arr[self.rear] = item
16 self.rear = (self.rear + 1) % self.capacity
17 self._size += 1
18
19 def dequeue(self):
20 """맨 앞에서 꺼내기 — O(1)"""
21 if self.is_empty():
22 raise IndexError("큐가 비어있습니다")
23 value = self.arr[self.front]
24 self.front = (self.front + 1) % self.capacity
25 self._size -= 1
26 return value
27
28 def front_element(self):
29 """맨 앞 값 보기 — O(1)"""
30 if self.is_empty():
31 raise IndexError("큐가 비어있습니다")
32 return self.arr[self.front]
33
34 def is_empty(self):
35 return self._size == 0
36
37 def is_full(self):
38 return self._size >= self.capacity
39
40 def size(self):
41 return self._size
42
43 def __repr__(self):
44 """현재 큐의 내용을 순서대로 보여줌"""
45 elements = []
46 idx = self.front
47 for _ in range(self._size):
48 elements.append(self.arr[idx])
49 idx = (idx + 1) % self.capacity
50 return f"CircularQueue({elements})"
괄호 짝 맞추기
1 def is_balanced(expression: str) -> bool:
2 """괄호 짝이 맞는지 확인 (Stack 사용)"""
3 stack = Stack()
4 pairs = {')': '(', '}': '{', ']': '['}
5
6 for char in expression:
7 if char in '({[':
8 stack.push(char)
9 elif char in ')}]':
10 if stack.is_empty() or stack.peek() != pairs[char]:
11 return False
12 stack.pop()
13
14 return stack.is_empty()
15
16 # 테스트
17 print(is_balanced("((()))")) # True
18 print(is_balanced("(()))")) # False
19 print(is_balanced("{[()]}")) # True
20 print(is_balanced("{[(])}")) # False
브라우저 뒤로가기 시뮬레이션
1 class Browser:
2 def __init__(self):
3 self.back_stack = Stack()
4 self.forward_stack = Stack()
5 self.current = "Home"
6
7 def visit(self, url):
8 print(f" → {url} 방문")
9 self.back_stack.push(self.current)
10 self.current = url
11 self.forward_stack = Stack() # 새 페이지 방문 시 앞으로 가기 초기화
12
13 def back(self):
14 if self.back_stack.is_empty():
15 print(" ← 더 이상 뒤로 갈 수 없습니다")
16 return
17 self.forward_stack.push(self.current)
18 self.current = self.back_stack.pop()
19 print(f" ← 뒤로: {self.current}")
20
21 def forward(self):
22 if self.forward_stack.is_empty():
23 print(" → 더 이상 앞으로 갈 수 없습니다")
24 return
25 self.back_stack.push(self.current)
26 self.current = self.forward_stack.pop()
27 print(f" → 앞으로: {self.current}")
28
29 # 실행
30 browser = Browser()
31 browser.visit("naver.com")
32 browser.visit("google.com")
33 browser.visit("github.com")
34 browser.back() # github → google
35 browser.back() # google → naver
36 browser.forward() # naver → google
💡 배움 포인트
| 개념 | 무엇을 배웠는가 |
| **LIFO vs FIFO** | 스택은 "마지막에 들어온 게 먼저 나간다" — 왜 함수 호출, Undo에 적합한가? 큐는 "먼저 들어온 게 먼저 나간다" — 왜 공정성을 필요로 하는 대기열에 적합한가? |
| **pointer 관리** | 원형 큐의 front/rear 포인터 — 데이터의 "위치"뿐 아니라 "흐름"을 관리해야 한다 |
| **공간 효율성** | 일반 큐는 dequeue할 때마다 데이터가 앞으로 밀려 O(n) — 원형 큐는 포인터만 움직이면 O(1) |
| **스택 깊이** | 괄호 짝 맞추기를 스택 없이 하려면? → 불가능! 스택은 **중첩 구조**를 처리하는 데 특화됨 |
| **검색 vs 접근** | 스택/큐는 "맨 위/맨 앞"만 접근 가능 — 임의 접근(random access)이 안 됨. 이게 곧 **제약의 힘** |
🧪 직접 해보기
- Stack으로 계산기 만들기 —
3 + 5 2를 후위 표기법(postfix)으로 변환 후 스택으로 계산:3 5 2 +→ 13. 이 방식은 공학용 계산기와 컴파일러가 실제로 쓰는 방법이다! - Queue로 콜센터 시뮬레이션 — 고객 문의를 Queue에 넣고, 상담원이 3초마다 하나씩 처리. 평균 대기시간 계산.
- 순환 감지 — 함수 호출에서 Stack을 사용해 무한 재귀(순환)를 감지해보자.
visit중인 노드를 Stack에 넣고, 이미 Stack에 있는 노드를 다시 방문하면 순환!
🔗 다음 장 예고
제3장: 연결 리스트 — 사슬처럼 이어진 데이터
배열은 "연속된 메모리"라서 빠릅니다. 하지만 중간에 삽입/삭제가 많다면?
연결 리스트는 각 요소가 다음 요소의 주소를 가지고 있는 사슬입니다.
배열의 O(1) 임의 접근 vs 연결 리스트의 O(1) 삽입 — 트레이드오프를 체감해봅시다.
배열은 "메모리에서 바로 옆에 붙어 있는" 데이터입니다.
그런데 중간에 계속 끼워넣고 빼는 일이 많다면?
그때 필요한 것이 연결 리스트(Linked List) — 각 노드가 다음 노드의 주소를 들고 있는 사슬입니다.
🎯 미션
배열은 "메모리에서 바로 옆에 붙어 있는" 데이터입니다.
그런데 중간에 계속 끼워넣고 빼는 일이 많다면?
그때 필요한 것이 연결 리스트(Linked List) — 각 노드가 다음 노드의 주소를 들고 있는 사슬입니다.
단일 연결 리스트(Singly Linked List)를 처음부터 구현하고, 배열과의 차이를 체감한다.
구체적으로:
- Node 클래스 구현 (데이터 + 다음 노드 참조)
- LinkedList 클래스 구현 (head, tail, 크기 관리)
- 삽입(머리, 꼬리, 중간), 삭제, 탐색 구현
- 배열과 연결 리스트의 연산별 시간 복잡도 비교
- 연결 리스트로 음악 재생목록 만들기
- 왜 "배열이 더 빠른데 왜 연결 리스트를 쓰나?"를 체감
💬 바이브 프롬프트
(프롬프트 시작)
나는 컴퓨터 과학을 배우는 고등학생/대학 1학년이야. 이번 장에서는 연결 리스트(Linked List)를 배울 거야.
목표: 연결 리스트를 처음부터 구현하고, 배열과의 차이를 몸으로 느낀다.
진행 방식 — 바이브 코딩:
1. 먼저 비유로 시작하자:
- 배열: 아파트. 101호, 102호, 103호... 각 호실이 연속적으로 붙어 있음
- 연결 리스트: 보물 찾기 게임. 첫 번째 단서(노드)를 받으면 거기에 "다음 단서 위치"가 적혀 있음. 그 위치가 어디든 상관없음 — 메모리 여기저기 흩어져 있을 수 있어!
- 이 비유를 먼저 설명하고, "왜 이런 게 필요하지?"를 묻자:
- 아파트에 빈 방이 없으면? → 새 아파트를 지어야 함 (배열 확장 = O(n))
- 보물 찾기에서 중간에 새 단서를 꽂고 싶으면? → 이전 단서의 주소만 바꾸면 됨 (연결 리스트 삽입 = O(1))
2. Node 클래스 구현 — 가장 기본 단위:
1 class Node: 2 def __init__(self, data): 3 self.data = data # 저장된 데이터 4 self.next = None # 다음 노드의 주소 (처음에는 없음)
- 이 Node를 "보물 지도 조각"이라고 비유하자
3. LinkedList 클래스 구현 — 여러 Node를 연결:
-
__init__: head = None, tail = None, _size = 0-
append(data): 꼬리에 추가 — tail이 있으면 tail.next = 새 노드, tail = 새 노드-
prepend(data): 머리에 추가 — 새 노드의 next = head, head = 새 노드-
insert_after(target_data, new_data): 특정 데이터 뒤에 삽입 — target을 찾고, 그 next를 조작-
delete(data): 특정 데이터 삭제 — 그 앞 노드의 next를 건너뛰게-
find(data): 탐색 — head부터 하나씩 따라가며 찾기-
display(): 모든 데이터를 차례로 출력-
__len__: 크기 반환
4. 중요 포인트 — 포인터/참조 시각화:
- 각 단계에서 "어떤 노드의 next가 어디를 가리키는가"를 ASCII 그림으로 보여줘
- 예:
A → B → C → None일 때, B 뒤에 D를 삽입하면A → B → D → C → None이 되는 과정을 단계별로 보여줘
5. 배열 vs 연결 리스트 비교표를 만들어줘:
| 연산 | 배열 | 연결 리스트 |
| 임의 접근 (arr[i]) | O(1) | O(n) — 처음부터 따라가야 함 |
| 끝에 추가 | O(1)* | O(1) (tail 유지 시) |
| 머리에 추가 | O(n) — 모두 밀어야 함 | O(1) |
| 중간 삽입 | O(n) — 모두 밀어야 함 | O(1) — 찾기는 O(n) |
| 중간 삭제 | O(n) — 모두 당겨야 함 | O(1) — 찾기는 O(n) |
| 메모리 | 연속, 낭비 가능 | 불연속, 포인터 오버헤드 |
*배열의 끝에 추가는 보통 O(1)이지만, 가득 차면 확장 시 O(n)
6. 실전 프로젝트 — 음악 재생목록:
- 노래를 연결 리스트에 추가 (append)
- 현재 재생 중인 곡 삭제 (delete)
- 특정 곡 다음에 새 곡 삽입 (insert_after)
- 전체 재생목록 출력 (display)
- "다음 곡" 버튼: 현재 노드에서 next로 이동
7. 마지막 생각거리:
- "연결 리스트의 장점이 O(1) 삽입/삭제라면, 왜 실생활에서는 배열을 더 많이 쓰나?"
- 답: 캐시 지역성(cache locality) — 배열은 메모리에 연속이라 CPU 캐시가 한 번에 여러 개를 가져옴. 연결 리스트는 메모리 여기저기 흩어져 있어서 매번 새로운 메모리 접근이 필요함. 이것이 바로 이론 vs 현실의 차이!
(프롬프트 끝)
🔄 가이드
가이드 A: 포인터/참조가 어려울 때
"Python에서
a.next = b가 정확히 무엇을 의미하는지, 메모리 관점에서 설명해주라. a라는 객체의 next 속성이 'b라는 객체가 저장된 메모리 주소'를 저장한다는 것. 이것을 '집 주소'에 비유해서 다시 설명해줘."
가이드 B: 삽입/삭제 시 그림 그려주기
"연결 리스트에서
insert_after('B', 'D')를 수행할 때, 실행 전의 리스트 상태와 실행 후의 리스트 상태를 ASCII 그림으로 단계별 보여줘. 특히 'B의 next가 D를 가리키고, D의 next가 C를 가리키는' 전환 과정을 상세히."
가이드 C: 캐시 지역성이 추상적일 때
"캐시 지역성을 쉽게 설명해줘. '배열은 아파트 단지라서 한 동네에 모여 살아서 친구들이 만나기 쉬움. 연결 리스트는 전국에 흩어져 살아서 만날 때마다 먼 길을 가야 함' 이런 비유로."
📦 기대 결과물
1 class Node:
2 """연결 리스트의 기본 단위 — 보물 지도 조각"""
3 def __init__(self, data):
4 self.data = data
5 self.next = None # 다음 노드의 주소
6
7 def __repr__(self):
8 return f"Node({self.data})"
9
10
11 class LinkedList:
12 """단일 연결 리스트"""
13 def __init__(self):
14 self.head = None
15 self.tail = None
16 self._size = 0
17
18 def append(self, data):
19 """꼬리에 추가 — O(1) (tail 유지 시)"""
20 new_node = Node(data)
21 if self.head is None:
22 self.head = self.tail = new_node
23 else:
24 self.tail.next = new_node
25 self.tail = new_node
26 self._size += 1
27
28 def prepend(self, data):
29 """머리에 추가 — O(1)"""
30 new_node = Node(data)
31 new_node.next = self.head
32 self.head = new_node
33 if self.tail is None:
34 self.tail = new_node
35 self._size += 1
36
37 def insert_after(self, target_data, new_data):
38 """특정 데이터 뒤에 삽입 — O(n) (탐색) + O(1) (삽입)"""
39 current = self.head
40 while current:
41 if current.data == target_data:
42 new_node = Node(new_data)
43 new_node.next = current.next
44 current.next = new_node
45 if current == self.tail:
46 self.tail = new_node
47 self._size += 1
48 return True
49 current = current.next
50 return False
51
52 def delete(self, data):
53 """특정 데이터 삭제 — O(n)"""
54 if self.head is None:
55 return False
56
57 # 머리가 대상이면
58 if self.head.data == data:
59 self.head = self.head.next
60 if self.head is None:
61 self.tail = None
62 self._size -= 1
63 return True
64
65 # 중간/꼬리에서 찾기
66 current = self.head
67 while current.next:
68 if current.next.data == data:
69 if current.next == self.tail:
70 self.tail = current
71 current.next = current.next.next
72 self._size -= 1
73 return True
74 current = current.next
75 return False
76
77 def find(self, data):
78 """탐색 — O(n)"""
79 current = self.head
80 index = 0
81 while current:
82 if current.data == data:
83 return index
84 current = current.next
85 index += 1
86 return -1
87
88 def display(self):
89 """전체 출력"""
90 elements = []
91 current = self.head
92 while current:
93 elements.append(str(current.data))
94 current = current.next
95 return " → ".join(elements) + " → None"
96
97 def __len__(self):
98 return self._size
99
100 def __repr__(self):
101 return f"LinkedList([{self.display()[:-5]}])"
102
103
104 # === 음악 재생목록 시연 ===
105 if __name__ == "__main__":
106 playlist = LinkedList()
107
108 print("🎵 음악 재생목록 만들기\n")
109 playlist.append("Bohemian Rhapsody")
110 print(f" [Queen] Bohemian Rhapsody 추가: {playlist.display()}")
111
112 playlist.append("Hotel California")
113 print(f" [Eagles] Hotel California 추가: {playlist.display()}")
114
115 playlist.prepend("Smells Like Teen Spirit")
116 print(f" [Nirvana] 맨 앞에 추가: {playlist.display()}")
117
118 playlist.insert_after("Hotel California", "Sweet Child O' Mine")
119 print(f" [Guns N' Roses] Hotel California 뒤에 삽입: {playlist.display()}")
120
121 playlist.delete("Smells Like Teen Spirit")
122 print(f" 가장 먼저 재생한 곡 삭제: {playlist.display()}")
123
124 print(f"\n📊 재생목록 길이: {len(playlist)}")
125 print(f"🔍 'Hotel California' 위치: 인덱스 {playlist.find('Hotel California')}")
실행 결과:
1 🎵 음악 재생목록 만들기
2
3 [Queen] Bohemian Rhapsody 추가: Bohemian Rhapsody → None
4 [Eagles] Hotel California 추가: Bohemian Rhapsody → Hotel California → None
5 [Nirvana] 맨 앞에 추가: Smells Like Teen Spirit → Bohemian Rhapsody → Hotel California → None
6 [Guns N' Roses] Hotel California 뒤에 삽입: Smells Like Teen Spirit → Bohemian Rhapsody → Hotel California → Sweet Child O' Mine → None
7 가장 먼저 재생한 곡 삭제: Bohemian Rhapsody → Hotel California → Sweet Child O' Mine → None
8
9 📊 재생목록 길이: 3
10 🔍 'Hotel California' 위치: 인덱스 1
💡 배움 포인트
| 개념 | 무엇을 배웠는가 |
| **포인터/참조** | `node.next = other_node`는 "이 노드가 다른 노드를 기억한다"는 것. 메모리가 흩어져 있어도 연결 가능! |
| **O(1) vs O(n)의 진짜 의미** | 연결 리스트의 삽입은 O(1)이지만, **삽입 위치를 찾는 데 O(n)**. 전체 연산은 O(n). 이것이 바로 "이론의 함정" — 삽입 자체는 빠르지만 찾기가 느리다 |
| **캐시 지역성** | 배열은 연속 메모리라 CPU가 한 번에 여러 개를 캐시에 올림. 연결 리스트는 메모리 여기저기에 흩어져 있어 캐시 miss가 자주 발생. **이론이 아니라 실제 성능**에서는 배열이 유리한 경우가 많다 |
| **메모리 오버헤드** | 연결 리스트의 각 노드는 데이터와 next 포인터를 모두 저장. 정수 4바이트 + 포인터 8바이트 = 최소 12바이트/노드. 배열은 4바이트/요소. 연결 리스트가 메모리를 더 많이 씀! |
| **단일 vs 이중 연결 리스트** | 이 장에서는 단일(singly) 연결 리스트를 다뤘음. 이중(doubly) 연결 리스트는 prev 포인터도 있어서 뒤로 갈 수 있지만, 메모리가 더 필요. **선택은 트레이드오프** |
🧪 직접 해보기
- 이중 연결 리스트(Doubly Linked List) 구현 — 각 노드에
prev속성을 추가해, 앞뒤로 이동할 수 있게 만들어보자.display_reverse()로 역순 출력도 가능하게. - 연결 리스트 정렬 — 연결 리스트를 버블 정렬로 정렬해보자. 배열처럼 값을 직접 바꾸는 게 아니라, 노드 간의 next 포인터를 재배열하는 방식으로!
- 링크드 리스트 역순 뒤집기 — head에서 tail까지의 연결 방향을 거꾸로 바꿔보자. 포인터 3개(prev, current, next)만으로 가능!
- 메모리 사용량 비교 — 1000개의 정수를 담은 배열과 연결 리스트의 메모리 사용량을
sys.getsizeof()로 비교해보자. 이론이 아니라 실제 숫자를 확인!
🔗 다음 장 예고
제4장: 정렬의 세계 — 데이터를 줄 세우는 법
이제 데이터가 메모리에 어떻게 저장되는지는 알겠고, 그 데이터들을 순서대로 줄 세우는 방법을 배워봅시다.
버블 정렬, 선택 정렬, 삽입 정렬 — 세 가지 정렬을 직접 구현하고, "왜 어떤 건 느리고 어떤 건 빠른가"를 체감해봅시다.
정렬은 컴퓨터 과학의 "기본 중의 기본"입니다.
검색, 데이터베이스, 압축, 그래픽스 — 거의 모든 분야에서 정렬이 쓰입니다.
이 장에서는 버블, 선택, 삽입 정렬을 직접 구현하고, 왜 어떤 정렬은 느리고 어떤 정렬은 빠른지를 몸으로 느낍니다.
🎯 미션
정렬은 컴퓨터 과학의 "기본 중의 기본"입니다.
검색, 데이터베이스, 압축, 그래픽스 — 거의 모든 분야에서 정렬이 쓰입니다.
이 장에서는 버블, 선택, 삽입 정렬을 직접 구현하고, 왜 어떤 정렬은 느리고 어떤 정렬은 빠른지를 몸으로 느낍니다.
세 가지 기본 정렬 알고리즘을 처음부터 구현하고, 각 알고리즘의 "성격"을 비교한다.
구체적으로:
- 버블 정렬 구현 (인접 교환)
- 선택 정렬 구현 (최솟값 찾기 + 배치)
- 삽입 정렬 구현 (카드 끼워넣기)
- 각 정렬의 비교 횟수와 교환 횟수를 측정
- 정렬 시각화 (단계별로 배열이 어떻게 변하는지 출력)
- 이미 정렬된 데이터, 역순 데이터, 랜덤 데이터에서 성능 비교
💬 바이브 프롬프트
(프롬프트 시작)
나는 컴퓨터 과학을 배우는 고등학생/대학 1학년이야. 이번 장에서는 정렬 알고리즘을 배울 거야.
목표: 버블 정렬, 선택 정렬, 삽입 정렬을 각각 직접 구현하고, 비교해보자.
진행 방식 — 바이브 코딩:
1. 먼저 정렬이 왜 필요한가를 비유로 시작하자:
- 책상 위에 성적표가 뒤죽박죽 — 빨리 찾으려면 이름순/점수순으로 정리해야 함
- 도서관 책이 순서 없이 꽂혀 있음 — 원하는 책을 찾으려면 전부 훑어야 함 (O(n))
- 책이 가나다순 정렬되어 있으면 — 이진 탐색으로 O(log n)에 찾을 수 있음!
- 정렬 = 탐색의 편의를 위한 투자
2. 이제 세 알고리즘을 하나씩 구현하자. 각각을 "카드 놀이" 비유로 설명:
① 버블 정렬 (Bubble Sort):
- 비유: 물속 거품. 큰 데이터(양초)가 위로 떠오름. 인접한 두 개를 비교해서 순서가 틀리면 교환. 한 바퀴 돌면 가장 큰 값이 맨 뒤로 감. 반복해서 전체 정렬.
- 코드 구조:
1 def bubble_sort(arr): 2 n = len(arr) 3 for i in range(n): 4 for j in range(n - 1 - i): 5 if arr[j] > arr[j + 1]: 6 arr[j], arr[j + 1] = arr[j + 1], arr[j]
- 특징: 가장 큰 값이 "부상"해서 맨 뒤로 감. 교환이 많음. 이미 정렬된 데이터에서도 끝까지 확인 (최적화: 교환이 없으면 조기 종료 가능 → best case O(n))
② 선택 정렬 (Selection Sort):
- 비유: 반에서 키 제일 작은 사람부터 줄 세우기. 남은 사람들 중 가장 작은 사람을 찾아 맨 앞과 교환. 다음엔 두 번째 작은 사람...
- 코드 구조:
1 def selection_sort(arr): 2 n = len(arr) 3 for i in range(n): 4 min_idx = i 5 for j in range(i + 1, n): 6 if arr[j] < arr[min_idx]: 7 min_idx = j 8 arr[i], arr[min_idx] = arr[min_idx], arr[i]
- 특징: 교환 횟수가 적음 (최대 n번). 하지만 비교는 항상 O(n²). 데이터 상태에 관계없이 항상 같은 성능.
③ 삽입 정렬 (Insertion Sort):
- 비유: 카드 게임에서 손안의 카드를 정렬. 새 카드를 받으면, 이미 정렬된 카드들 사이에 적절한 위치에 끼워넣음.
- 코드 구조:
1 def insertion_sort(arr): 2 for i in range(1, len(arr)): 3 key = arr[i] 4 j = i - 1 5 while j >= 0 and arr[j] > key: 6 arr[j + 1] = arr[j] 7 j -= 1 8 arr[j + 1] = key
- 특징: 거의 정렬된 데이터에서 매우 빠름 (best case O(n)). 실전에서 작은 데이터셋에 자주 사용됨.
3. 각 알고리즘을 "단계별 시각화" 해보자:
- 정렬 중간 상태를 매 스텝마다 출력 (배열의 현재 모습)
- 예:
[5, 2, 8, 1, 9]→ 버블 정렬 1패스:[2, 5, 1, 8, 9]- 비교 횟수와 교환 횟수를 각각 세어서 출력
4. 성능 비교 실험:
- 3가지 데이터셋에서 각 정렬의 수행 시간 측정:
- (a) 이미 정렬된 데이터:
[1, 2, 3, 4, 5]- (b) 역순 데이터:
[5, 4, 3, 2, 1]- (c) 랜덤 데이터:
[3, 1, 5, 2, 4]- 100개, 1000개, 10000개 데이터로 크기를 늘려가며 측정
- 결과를 표로 정리
5. 안정성(Stability) 개념 소개:
- 같은 값의 순서가 정렬 후에도 유지되는가?
- 버블 정렬: 안정적 (인접 교환만)
- 선택 정렬: 불안정적 (멀리 있는 값과 교환)
- 삽입 정렬: 안정적 (올바른 위치에 끼워넣기)
- 예:
[(이름: 철수, 점수: 90), (이름: 영희, 점수: 90)]→ 정렬 후에도 철수-영희 순서 유지?
6. 시간 복잡도 요약표:
| 알고리즘 | 최선 | 평균 | 최악 | 공간 | 안정 |
| 버블 정렬 | O(n) | O(n²) | O(n²) | O(1) | ✓ |
| 선택 정렬 | O(n²) | O(n²) | O(n²) | O(1) | ✗ |
| 삽입 정렬 | O(n) | O(n²) | O(n²) | O(1) | ✓ |
7. 마지막 생각거리:
- "이 세 정렬 모두 O(n²)인데, 왜 삽입 정렬을 실전에서 쓰나?"
- 답: 작은 데이터셋(n < 50)에서는 삽입 정렬이 실제로 가장 빠를 수 있음. 그리고 팀소트(Timsort, Python/Java의 기본 정렬)는 삽입 정렬과 병합 정렬을 조합한 것!
(프롬프트 끝)
🔄 가이드
가이드 A: 시간 복잡도가 추상적일 때
"버블 정렬이 O(n²)이라는 걸 1000개 데이터 기준으로 실제 숫자로 보여줘. 1000² = 1,000,000번 비교. 이 비교를 1초에 1억 번 할 수 있는 컴퓨터에서 하면 약 0.01초. 하지만 100만 개 데이터면? 100만² = 1조 번 비교 = 약 10,000초 = 2.7시간! 이렇게 크기별로 실제 시간을 계산해줘."
가이드 B: 안정성 개념이 어려울 때
"안정성을 성적표로 비유해서 설명해주라. 철수와 영희가 둘 다 90점인데, 철수가 먼저 입력됐음. 정렬 후에도 철수가 영희보다 앞에 있으면 '안정적', 영희가 앞에 오면 '불안정'. 버블 정렬은 인접한 것만 바꾸니까 철수와 영희의 순서가 안 바뀌지만, 선택 정렬은 멀리 있는 값과 바꿀 수 있어서 순서가 바뀔 수 있음."
가이드 C: 삽입 정렬의 "거의 정렬된 데이터"가 이해 안 될 때
"삽입 정렬이 거의 정렬된 데이터에서 O(n)인 이유를 단계별로 보여줘. 이미 정렬된 배열
[1, 2, 3, 4, 5]에 새 값6을 삽입하려고 하면, 각 단계에서while조건이 한 번만 확인되고 바로 종료됨. 교환이 0번! 그래서 전체 O(n)."
📦 기대 결과물
1 import time
2 import random
3
4
5 def bubble_sort(arr, verbose=False):
6 """버블 정렬 — 인접한 두 원소 교환"""
7 n = len(arr)
8 comparisons = 0
9 swaps = 0
10 arr = arr.copy()
11
12 for i in range(n):
13 swapped = False
14 for j in range(n - 1 - i):
15 comparisons += 1
16 if arr[j] > arr[j + 1]:
17 arr[j], arr[j + 1] = arr[j + 1], arr[j]
18 swaps += 1
19 swapped = True
20 if verbose:
21 print(f" 교환: {arr}")
22 if not swapped:
23 break # 최적화: 이미 정렬됨
24
25 return arr, comparisons, swaps
26
27
28 def selection_sort(arr, verbose=False):
29 """선택 정렬 — 최솟값을 찾아 맨 앞으로"""
30 n = len(arr)
31 comparisons = 0
32 swaps = 0
33 arr = arr.copy()
34
35 for i in range(n):
36 min_idx = i
37 for j in range(i + 1, n):
38 comparisons += 1
39 if arr[j] < arr[min_idx]:
40 min_idx = j
41 if min_idx != i:
42 arr[i], arr[min_idx] = arr[min_idx], arr[i]
43 swaps += 1
44 if verbose:
45 print(f" 교환: {arr}")
46
47 return arr, comparisons, swaps
48
49
50 def insertion_sort(arr, verbose=False):
51 """삽입 정렬 — 카드를 끼워넣듯 정렬"""
52 comparisons = 0
53 shifts = 0
54 arr = arr.copy()
55
56 for i in range(1, len(arr)):
57 key = arr[i]
58 j = i - 1
59 while j >= 0:
60 comparisons += 1
61 if arr[j] > key:
62 arr[j + 1] = arr[j]
63 shifts += 1
64 j -= 1
65 else:
66 break
67 arr[j + 1] = key
68 if verbose:
69 print(f" 삽입 후: {arr}")
70
71 return arr, comparisons, shifts
72
73
74 def test_sorting_algorithms():
75 """세 정렬 알고리즘을 비교 실험"""
76 print("=" * 60)
77 print("정렬 알고리즘 비교 실험")
78 print("=" * 60)
79
80 test_cases = {
81 "이미 정렬됨": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
82 "역순": [10, 9, 8, 7, 6, 5, 4, 3, 2, 1],
83 "랜덤": [6, 3, 8, 1, 9, 2, 7, 5, 10, 4],
84 }
85
86 algorithms = {
87 "버블 정렬": bubble_sort,
88 "선택 정렬": selection_sort,
89 "삽입 정렬": insertion_sort,
90 }
91
92 for name, data in test_cases.items():
93 print(f"\n📊 테스트 케이스: {name}")
94 print(f" 원본: {data}")
95 print(f" {'알고리즘':<12} {'비교':<8} {'교환/이동':<12} {'결과'}")
96 print(f" {'-'*12} {'-'*8} {'-'*12} {'-'*20}")
97
98 for algo_name, algo_func in algorithms.items():
99 result, comp, swap = algo_func(data)
100 print(f" {algo_name:<12} {comp:<8} {swap:<12} {result}")
101
102
103 def benchmark():
104 """크기에 따른 수행 시간 측정"""
105 print("\n" + "=" * 60)
106 print("크기별 수행 시간 벤치마크")
107 print("=" * 60)
108 print(f"{'크기':<8} {'버블 정렬':<14} {'선택 정렬':<14} {'삽입 정렬':<14}")
109 print(f"{'-'*8} {'-'*14} {'-'*14} {'-'*14}")
110
111 for size in [100, 500, 1000, 5000]:
112 data = [random.randint(1, 10000) for _ in range(size)]
113
114 times = {}
115 for name, func in [("버블", bubble_sort), ("선택", selection_sort), ("삽입", insertion_sort)]:
116 start = time.time()
117 func(data)
118 elapsed = time.time() - start
119 times[name] = f"{elapsed:.4f}초"
120
121 print(f"{size:<8} {times['버블']:<14} {times['선택']:<14} {times['삽입']:<14}")
122
123
124 if __name__ == "__main__":
125 test_sorting_algorithms()
126 benchmark()
실행 결과 (일부):
1 ============================================================
2 정렬 알고리즘 비교 실험
3 ============================================================
4
5 📊 테스트 케이스: 이미 정렬됨
6 원본: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
7 알고리즘 비교 교환/이동 결과
8 ------------ -------- ------------ --------------------
9 버블 정렬 9 0 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
10 선택 정렬 45 0 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
11 삽입 정렬 9 0 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
12
13 📊 테스트 케이스: 랜덤
14 원본: [6, 3, 8, 1, 9, 2, 7, 5, 10, 4]
15 알고리즘 비교 교환/이동 결과
16 ------------ -------- ------------ --------------------
17 버블 정렬 43 19 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
18 선택 정렬 45 6 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
19 삽입 정렬 28 20 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
20
21 ============================================================
22 크기별 수행 시간 벤치마크
23 ============================================================
24 크기 버블 정렬 선택 정렬 삽입 정렬
25 -------- -------------- -------------- --------------
26 100 0.0003초 0.0002초 0.0001초
27 500 0.0071초 0.0048초 0.0012초
28 1000 0.0283초 0.0191초 0.0028초
29 5000 0.7124초 0.4789초 0.0671초
💡 배움 포인트
| 개념 | 무엇을 배웠는가 |
| **O(n²)의 실제 무게** | 5000개 데이터에서 버블 정렬은 0.7초, 삽입 정렬은 0.06초. 같은 O(n²)라도 **상수 인자**가 다르기 때문에 실제 성능이 10배 이상 차이난다! |
| **이미 정렬된 데이터** | 버블 정렬(최적화)과 삽입 정렬은 이미 정렬된 데이터에서 O(n). **데이터 상태가 알고리즘 성능에 영향을 준다!** |
| **안정성** | 같은 값의 순서를 유지하는가? 성적표, 테이블 정렬에서 매우 중요한 개념. 버블/삽입은 안정적, 선택은 불안정 |
| **교환 vs 이동** | 버블은 인접 교환, 선택은 멀리 있는 값과 교환, 삽입은 값을 밀어냄(시프트). **교환 비용이 알고리즘마다 다르다** |
| **왜 삽입 정렬을 쓰는가?** | 작은 데이터셋(n < 50)에서는 삽입 정렬이 실제로 가장 빠를 수 있음. Python의 `sort()`는 팀소트(Timsort) — 삽입 정렬 + 병합 정렬의 하이브리드! |
🧪 직접 해보기
- 셸 정렬(Shell Sort) 구현 — 삽입 정렬의 일반화 버전. 간격(gap)을 점점 줄여가며 정렬. gap=1이면 삽입 정렬. O(n²)보다 나은 성능을 보여보자!
- 정렬 애니메이션 만들기 —
time.sleep(0.5)를 넣어 버블 정렬의 각 단계를 천천히 출력해보자. "거품이 떠오르는" 과정을 눈으로 확인! - 정렬 알고리즘 계수 측정 — 비교 횟수, 교환 횟수를 그래프로 그려보자. 크기별 비교 횟수가 정말 n²에 비례하는지 확인!
- 안정성을 직접 확인 —
(이름, 점수)튜플 리스트를 세 정렬로 각각 정렬해보고, 같은 점수의 이름 순서가 유지되는지 확인!
🔗 다음 장 예고
제5장: 탐색의 기술 — 찾는다는 것의 의미
데이터가 정렬되었다면, 이제 빨리 찾는 법을 배워봅시다.
선형 탐색(O(n)) vs 이진 탐색(O(log n)) — 100만 개 데이터에서 차이가 얼마나 나는지 직접 확인해봅시다.
데이터가 있을 때, "원하는 값을 어떻게 빨리 찾을까?"
정렬되지 않은 데이터에서는 끝까지 훑어야 하고(O(n)),
정렬된 데이터에서는 반씩 잘라가며 찾을 수 있습니다(O(log n)).
100만 개 데이터에서 선형 탐색은 최대 100만 번, 이진 탐색은 단 20번!
🎯 미션
데이터가 있을 때, "원하는 값을 어떻게 빨리 찾을까?"
정렬되지 않은 데이터에서는 끝까지 훑어야 하고(O(n)),
정렬된 데이터에서는 반씩 잘라가며 찾을 수 있습니다(O(log n)).
100만 개 데이터에서 선형 탐색은 최대 100만 번, 이진 탐색은 단 20번!
선형 탐색과 이진 탐색을 직접 구현하고, 검색 속도의 차이를 체감한다.
구체적으로:
- 선형 탐색(Linear Search) 구현 — 처음부터 끝까지 하나씩 확인
- 이진 탐색(Binary Search) 구현 — 정렬된 배열에서 반씩 잘라가기
- 두 탐색의 비교 횟수 측정
- "정렬 비용 vs 탐색 이익" 분석 — 한 번 정렬하고 여러 번 탐색하는 게 언제 이득인지
- 실제 생활 속 탐색 사례 분석
💬 바이브 프롬프트
(프롬프트 시작)
나는 컴퓨터 과학을 배우는 고등학생/대학 1학년이야. 이번 장에서는 탐색 알고리즘을 배울 거야.
목표: 선형 탐색과 이진 탐색을 각각 구현하고, 검색 속도의 차이를 숫자로 체감한다.
진행 방식 — 바이브 코딩:
1. 먼저 비유로 시작하자:
- 선형 탐색: 책가방에서 연필을 찾는다. 하나씩 꺼내서 확인하는 방식. 가방이 아무리 커도 방법은 같다.
- 이진 탐색: 사전에서 단어를 찾는다. 가운데를 펴서, 찾는 단어가 앞쪽인지 뒤쪽인지 확인하고, 앞쪽이면 뒤쪽 절반은 버림. 반복해서 범위를 반씩 줄임.
- 핵심 차이: 정렬되어 있는가?
- 정렬 안 됨 → 선형 탐색만 가능 (O(n))
- 정렬됨 → 이진 탐색 가능 (O(log n))
2. 선형 탐색 구현:
1 def linear_search(arr, target): 2 for i in range(len(arr)): 3 if arr[i] == target: 4 return i # 찾음! 인덱스 반환 5 return -1 # 없음
- 설명: "맨 처음부터 하나씩 확인한다. worst case: 찾는 값이 맨 끝에 있거나 없음 → n번 확인"
- 시간 복잡도: O(n)
3. 이진 탐색 구현:
1 def binary_search(arr, target): 2 left, right = 0, len(arr) - 1 3 while left <= right: 4 mid = (left + right) // 2 5 if arr[mid] == target: 6 return mid 7 elif arr[mid] < target: 8 left = mid + 1 # 오른쪽 절반 탐색 9 else: 10 right = mid - 1 # 왼쪽 절반 탐색 11 return -1
- 설명: "중간값과 비교 → 찾는 값이 중간값보다 크면 오른쪽 절반만, 작으면 왼쪽 절반만 탐색. 매번 범위가 절반으로 줄어듦"
- 시간 복잡도: O(log n)
- 중요: 정렬된 배열에서만 동작!
4. 비교 횟수 측정 실험:
- 크기별로 선형/이진 탐색의 비교 횟수를 측정:
- 100개, 1,000개, 10,000개, 100,000개, 1,000,000개 데이터
- 최악의 경우(찾는 값이 없음) 기준으로 비교 횟수 측정
- 결과 표:
| 데이터 크기 | 선형 탐색 비교 횟수 | 이진 탐색 비교 횟수 |
| 100 | 100 | 7 |
| 1,000 | 1,000 | 10 |
| 10,000 | 10,000 | 14 |
| 100,000 | 100,000 | 17 |
| 1,000,000 | 1,000,000 | 20 |
- 이 표를 직접 실험으로 확인!
5. "정렬 비용 vs 탐색 이익" 분석:
- 질문: "이진 탐색이 훨씬 빠른데, 왜 항상 이진 탐색을 쓰지 않나?"
- 답: 정렬 자체가 비용이 듬! O(n log n)
- 분석:
- 탐색 1번: 선형 O(n) vs 이진 O(log n) + 정렬 O(n log n) → 선형이 더 빠름
- 탐색 k번: 선형 O(k·n) vs 이진 O(n log n + k·log n) → k가 충분히 크면 이진 wins
- 손익분기점 계산: k > log n 일 때 이진 탐색이 더 유리
- 예: 100만 개 데이터 → log(1,000,000) ≈ 20. 즉, 20번 이상 탐색하면 이진 탐색이 이득!
6. 실전 프로젝트 — 전화번호부 탐색:
- 10,000명의 이름과 전화번호 데이터
- 이름으로 전화번호 찾기
- 선형 탐색 vs 이진 탐색 성능 비교
- "정렬된 전화번호부"의 가치 체감
7. 변형 탐색 소개:
- first/last occurrence: 중복된 값이 있을 때, 첫 번째/마지막 위치 찾기
- bisect 모듈: Python 내장 이진 탐색 라이브러리.
bisect_left,bisect_right- 예:
import bisect; idx = bisect.bisect_left(sorted_list, target)
8. 마지막 생각거리:
- "이진 탐색이 O(log n)이라는 건 정말 얼마나 빠른 건가?"
- log₂(1,000,000) = 20. 2를 20번 곱하면 1,048,576. 즉, 100만 개 데이터에서 단 20번 비교!
- 반면 선형 탐색은 100만 번 비교.
- 차이가 50,000배!
(프롬프트 끝)
🔄 가이드
가이드 A: 이진 탐색이 이해 안 될 때
"이진 탐색을 '숫자 맞추기 게임'으로 비유해서 단계별로 보여줘. 1~100 중 하나를 생각할 때, '50?' → '더 커요' → '75?' → '더 작아요' → '62?' ... 이렇게 매번 범위를 반으로 줄이는 과정을 1~20까지 실제 숫자 예시로 보여줘."
가이드 B: 시간 복잡도의 로그가 어려울 때
"log₂(n)을 쉽게 설명해줘. 'log₂(8) = 3'이라는 건 '2를 3번 곱하면 8이 된다'는 뜻. 즉, 배열을 몇 번 반으로 쪼개야 1개가 되느냐! log₂(1,000,000) ≈ 20이니까, 100만 개 데이터를 20번 반으로 쪼개면 1개가 됨."
가이드 C: 정렬 비용 vs 탐색 이익이 헷갈릴 때
"100만 개 데이터에서 '정렬 한 번 + 이진 탐색 20번'과 '선형 탐색 20번'을 실제 숫자로 비교해보자. 정렬: 100만 × 20 = 2천만 번 비교. 이진 탐색 20번: 20 × 20 = 400번 비교. 합계: 20,000,400번. 선형 탐색 20번: 20 × 100만 = 20,000,000번. 아슬아슬하게 선형이 이김! 하지만 21번째 탐색부터는 이진 wins!"
📦 기대 결과물
1 import time
2 import random
3
4
5 def linear_search(arr, target):
6 """선형 탐색 — 처음부터 끝까지 하나씩 확인"""
7 comparisons = 0
8 for i, val in enumerate(arr):
9 comparisons += 1
10 if val == target:
11 return i, comparisons
12 return -1, comparisons
13
14
15 def binary_search(arr, target):
16 """이진 탐색 — 정렬된 배열에서 반씩 잘라가기 (반복문)"""
17 comparisons = 0
18 left, right = 0, len(arr) - 1
19
20 while left <= right:
21 mid = (left + right) // 2
22 comparisons += 1
23 if arr[mid] == target:
24 return mid, comparisons
25 comparisons += 1 # elif 비교
26 if arr[mid] < target:
27 left = mid + 1
28 else:
29 right = mid - 1
30
31 return -1, comparisons
32
33
34 def binary_search_recursive(arr, target, left=0, right=None):
35 """이진 탐색 — 재귀 버전"""
36 if right is None:
37 right = len(arr) - 1
38 if left > right:
39 return -1, 1 # 비교가 1번(이 기저 케이스 확인)
40
41 mid = (left + right) // 2
42 if arr[mid] == target:
43 return mid, 1
44 elif arr[mid] < target:
45 idx, comp = binary_search_recursive(arr, target, mid + 1, right)
46 return idx, comp + 2 # 비교 2번 (==, <)
47 else:
48 idx, comp = binary_search_recursive(arr, target, left, mid - 1)
49 return idx, comp + 2
50
51
52 def binary_search_recursive(arr, target, left=0, right=None):
53 """이진 탐색 — 재귀 버전"""
54 if right is None:
55 right = len(arr) - 1
56 if left > right:
57 return -1, 1 # 비교가 1번(이 기저 케이스 확인)
58
59 mid = (left + right) // 2
60 if arr[mid] == target:
61 return mid, 1
62 elif arr[mid] < target:
63 idx, comp = binary_search_recursive(arr, target, mid + 1, right)
64 return idx, comp + 2 # 비교 2번 (==, <)
65 else:
66 idx, comp = binary_search_recursive(arr, target, left, mid - 1)
67 return idx, comp + 2
68
69
70 def compare_searches():
71 """선형 vs 이진 탐색 비교"""
72 print("=" * 60)
73 print("선형 탐색 vs 이진 탐색 비교")
74 print("=" * 60)
75
76 sizes = [100, 1000, 10000, 100000, 1000000]
77
78 print(f"{'크기':>10} | {'선형 비교':>10} | {'이진 비교':>10} | {'비율':>10}")
79 print("-" * 50)
80
81 for size in sizes:
82 data = list(range(size)) # 정렬된 데이터
83 target = size + 1 # 없는 값 (worst case)
84
85 _, lin_comp = linear_search(data, target)
86 _, bin_comp = binary_search(data, target)
87 ratio = lin_comp / bin_comp if bin_comp > 0 else float('inf')
88
89 print(f"{size:>10} | {lin_comp:>10} | {bin_comp:>10} | {ratio:>10.1f}x")
90
91
92 def phonebook_demo():
93 """전화번호부 탐색 시연"""
94 print("\n" + "=" * 60)
95 print("전화번호부 탐색 시연 (10,000명)")
96 print("=" * 60)
97
98 # 10,000명의 정렬된 전화번호부 생성
99 names = sorted([f"홍길동{i:04d}" for i in range(10000)])
100 phones = [f"010-{i:04d}-{i:04d}" for i in range(10000)]
101 phonebook = list(zip(names, phones))
102 name_list = [p[0] for p in phonebook]
103 phone_list = [p[1] for p in phonebook]
104
105 # 찾을 사람
106 target_name = "홍길동05000"
107
108 # 선형 탐색
109 start = time.time()
110 idx_lin, comp_lin = linear_search(name_list, target_name)
111 time_lin = time.time() - start
112
113 # 이진 탐색
114 start = time.time()
115 idx_bin, comp_bin = binary_search(name_list, target_name)
116 time_bin = time.time() - start
117
118 if idx_lin >= 0:
119 print(f"✅ '{target_name}' 찾음!")
120 print(f" 전화번호: {phone_list[idx_lin]}")
121 print(f"\n📊 비교:")
122 print(f" 선형 탐색: {comp_lin}번 비교, {time_lin*1000:.3f}ms")
123 print(f" 이진 탐색: {comp_bin}번 비교, {time_bin*1000:.3f}ms")
124 print(f" 이진 탐색이 {comp_lin/comp_bin:.1f}배 적은 비교로 찾음!")
125 else:
126 print(f"❌ '{target_name}'을(를) 찾지 못함")
127
128
129 def bisect_demo():
130 """Python bisect 모듈 시연"""
131 print("\n" + "=" * 60)
132 print("Python bisect 모듈 — 실전 이진 탐색")
133 print("=" * 60)
134
135 import bisect
136
137 scores = [55, 60, 65, 70, 75, 80, 85, 90, 95, 100]
138
139 # 특정 점수 이상의 첫 위치 찾기
140 test_scores = [62, 70, 93, 105]
141
142 for score in test_scores:
143 idx = bisect.bisect_left(scores, score)
144 if idx < len(scores):
145 print(f" {score}점 이상 첫 위치: 인덱스 {idx} (점수: {scores[idx]})")
146 else:
147 print(f" {score}점 이상: 없음")
148
149
150 if __name__ == "__main__":
151 compare_searches()
152 phonebook_demo()
153 bisect_demo()
실행 결과 (일부):
1 ============================================================
2 선형 탐색 vs 이진 탐색 비교
3 ============================================================
4 크기 | 선형 비교 | 이진 비교 | 비율
5 --------------------------------------------------
6 100 | 100 | 7 | 14.3x
7 1000 | 1000 | 10 | 100.0x
8 10000 | 10000 | 14 | 714.3x
9 100000 | 100000 | 17 | 5882.4x
10 1000000 | 1000000 | 20 | 50000.0x
11
12 ============================================================
13 전화번호부 탐색 시연 (10,000명)
14 ============================================================
15 ✅ '홍길동05000' 찾음!
16 전화번호: 010-0500-05000
17
18 📊 비교:
19 선형 탐색: 5001번 비교, 0.042ms
20 이진 탐색: 14번 비교, 0.011ms
21 이진 탐색이 357.2배 적은 비교로 찾음!
22
23 ============================================================
24 Python bisect 모듈 — 실전 이진 탐색
25 ============================================================
26 62점 이상 첫 위치: 인덱스 2 (점수: 65)
27 70점 이상 첫 위치: 인덱스 3 (점수: 70)
28 93점 이상 첫 위치: 인덱스 8 (점수: 95)
29 105점 이상: 없음
💡 배움 포인트
| 개념 | 무엇을 배웠는가 |
| **O(n) vs O(log n)의 격차** | 100만 개 데이터에서 선형 탐색은 1,000,000번 비교, 이진 탐색은 20번. **50,000배 차이!** 이것이 바로 알고리즘 효율성의 힘 |
| **정렬의 선행 조건** | 이진 탐색은 정렬된 데이터에서만 동작. "빨리 찾기 위해 정렬한다"는 개념이 핵심 — 이게 데이터베이스 인덱스의 원리! |
| **손익분기점** | 한 번 정렬하고 k번 탐색할 때, k > log n이면 이진 탐색이 이득. 100만 개면 20번 이상 탐색하면 이진 wins |
| **Python 내장 bisect** | `bisect_left`는 이진 탐색을 구현해둔 모듈. 실전에서는 직접 구현하지 말고 이걸 써라! |
| **worst case vs average case** | 선형 탐색의 worst case는 n번, average는 n/2번. 하지만 O 표기법상 둘 다 O(n). **상수 인자가 달라도 복잡도는 같다!** |
🧪 직접 해보기
- 이진 탐색 시각화 —
time.sleep(0.3)을 넣어 이진 탐색의 각 단계를 천천히 출력해보자. "왼쪽 절반 버리기 → 오른쪽 절반 버리기" 과정을 눈으로 확인! - first/last occurrence 구현 — 중복된 값이 있는 배열에서, 첫 번째와 마지막 등장 위치를 각각 찾는 이진 탐색 변형 구현 (LeetCode 34번 문제)
- 회전된 배열 탐색 — 정렬된 배열이 한 번 회전된 경우 (예:
[4,5,6,7,0,1,2])에서 타겟 찾기. 이진 탐색의 변형 (LeetCode 33번) - bisect로 등급 매기기 — 학생 점수 리스트를
bisect로 등급(A/B/C/D/F)에 매핑해보자.bisect.bisect_right를 활용!
🔗 다음 장 예고
제6장: 재귀의 아름다움 — 자기 자신을 호출하다
이진 탐색의 코드, 다시 보면
while루프 안에left = mid + 1같은 게 있죠.이걸 재귀(recursion)로 다시 써보면 어떨까요?
함수가 자기 자신을 호출하는 것 — 처음엔 이상한 개념이지만, 이해하면 가장 아름다운 알고리즘 패러다임입니다.
재귀(Recursion)는 "함수가 자기 자신을 호출하는 것"입니다.
처음엔 이상하게 느껴지지만, 이해하면 가장 아름다운 알고리즘 패러다임입니다.
러시아 인형처럼 "큰 문제 → 같은 구조의 작은 문제 → 더 작은 문제 → 기저 사례"로 쪼개는 사고방식이죠.
🎯 미션
재귀(Recursion)는 "함수가 자기 자신을 호출하는 것"입니다.
처음엔 이상하게 느껴지지만, 이해하면 가장 아름다운 알고리즘 패러다임입니다.
러시아 인형처럼 "큰 문제 → 같은 구조의 작은 문제 → 더 작은 문제 → 기저 사례"로 쪼개는 사고방식이죠.
재귀의 핵심 패턴을 이해하고, 반복문으로 짜던 코드를 재귀적으로 다시 쓴다.
구체적으로:
- 재귀의 기본 구조 이해 (기저 사례 + 재귀 호출)
- 팩토리얼, 피보나치 재귀 구현
- 재귀 vs 반복문 비교 (스택 메모리 관점)
- 이진 탐색 재귀 버전 구현
- 하노이 탑(Hanoi Tower) — 재귀의 고전 문제
- 재귀 호출 스택 시각화
💬 바이브 프롬프트
(프롬프트 시작)
나는 컴퓨터 과학을 배우는 고등학생/대학 1학년이야. 이번 장에서는 재귀(Recursion)를 배울 거야.
목표: 재귀가 무엇인지 이해하고, 반복문으로 짜던 코드를 재귀적으로 다시 써본다.
진행 방식 — 바이브 코딩:
1. 먼저 비유로 시작하자:
- 재귀 = 러시아 인형(마트료시카). 큰 인형 안에 같은 모양의 작은 인형이 있고, 더 작은 인형이 있고... 가장 작은 인형(기저 사례)에서 멈춤.
- 연쇄 편지와도 비슷: "이 편지를 10명에게 전달하세요" → 받은 사람이 또 10명에게 → ... → 마지막 사람(기저 사례)에서 멈춤
- 핵심: 큰 문제를 같은 구조의 작은 문제로 쪼개고, 가장 작은 문제는 바로 푼다
2. 재귀의 두 가지 필수 요소:
- 기저 사례(Base Case): 더 이상 쪼갤 수 없는 가장 작은 문제. 여기서 멈춤. 없으면 무한 재귀(Stack Overflow)!
- 재귀 호출(Recursive Call): 자기 자신을 더 작은 입력으로 호출
- 비유: "깃털을 계속 쪼개서 가루가 될 때까지" → 기저 사례 = "가루가 되면 멈춤", 재귀 호출 = "아직 깃털이면 반으로 쪼개서 다시 시작"
3. 재귀의 기본 예제 — 세 가지:
① 팩토리얼 (Factorial):
- n! = n × (n-1) × ... × 2 × 1
- 재귀적 정의: n! = n × (n-1)!, 0! = 1
- 코드:
1 def factorial(n): 2 if n == 0: # 기저 사례 3 return 1 4 return n * factorial(n - 1) # 재귀 호출
- 실행 과정 추적:
factorial(4)→4 factorial(3)→4 3 factorial(2)→4 3 2 factorial(1)→4 3 2 1 factorial(0)→4 3 2 1 1 = 24
② 피보나치 수열 (Fibonacci):
- F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2)
- 코드:
1 def fibonacci(n): 2 if n <= 1: # 기저 사례 3 return n 4 return fibonacci(n-1) + fibonacci(n-2) # 재귀 호출
- 문제점: 중복 계산이 너무 많음! F(5)를 계산하려면 F(4)와 F(3)이 필요하고, F(4)를 계산하려면 또 F(3)과 F(2)가 필요. F(3)이 두 번 계산됨!
- 시각화: 호출 트리(call tree)를 그려서 중복을 보여주기
③ 누적 합:
- sum(1..n) = n + sum(1..n-1)
- 코드:
1 def recursive_sum(n): 2 if n == 0: # 기저 사례 3 return 0 4 return n + recursive_sum(n - 1)
4. 반복문 ↔ 재귀 변환 연습:
- for 루프로 짠 코드를 재귀로 바꾸기:
-
for i in range(1, n+1): total += i→recursive_sum(n)-
while left <= right:(이진 탐색) → 재귀적 이진 탐색- 재귀적 이진 탐색:
1 def binary_search_recursive(arr, target, left=0, right=None): 2 if right is None: 3 right = len(arr) - 1 4 if left > right: # 기저 사례: 못 찾음 5 return -1 6 mid = (left + right) // 2 7 if arr[mid] == target: 8 return mid # 기저 사례: 찾음 9 elif arr[mid] < target: 10 return binary_search_recursive(arr, target, mid + 1, right) # 재귀 11 else: 12 return binary_search_recursive(arr, target, left, mid - 1) # 재귀
5. 재귀 호출 스택 시각화:
-
factorial(4)를 호출할 때, 컴퓨터 메모리에 무엇이 쌓이는지 보여줘:
1 Call Stack (처음부터): 2 factorial(4) ← 맨 위 (현재 실행 중) 3 ↓ factorial(3) 4 ↓ factorial(2) 5 ↓ factorial(1) 6 ↓ factorial(0) ← 기저 사례! 반환 1 7 factorial(1) → 1 * 1 = 1로 반환 8 factorial(2) → 2 * 1 = 2로 반환 9 factorial(3) → 3 * 2 = 6으로 반환 10 factorial(4) → 4 * 6 = 24로 반환 ← 맨 위 제거!
- 스택이 쌓였다가(pop) 풀리는 과정을 단계별 ASCII로 보여주기
6. 하노이 탑 (Tower of Hanoi) — 재귀의 결정판:
- 규칙: 3개의 기둥, N개의 원판(크기 내림차순). 한 번에 한 개씩만 옮길 수 있고, 큰 원판 위에 작은 원판을 올릴 수 없음.
- 목표: N개의 원판을 기둥 A에서 기둥 C로 옮기기 (기둥 B 사용)
- 재귀적 해법:
- N-1개를 A에서 B로 (C를 임시로 사용)
- 가장 큰 원판(1개)을 A에서 C로
- N-1개를 B에서 C로 (A를 임시로 사용)
- 코드:
1 def hanoi(n, source, target, auxiliary): 2 if n == 1: # 기저 사례 3 print(f"원판 1을 {source}에서 {target}로 이동") 4 return 5 hanoi(n - 1, source, auxiliary, target) # N-1개를 보조 기둥으로 6 print(f"원판 {n}을 {source}에서 {target}로 이동") # 가장 큰 원판 이동 7 hanoi(n - 1, auxiliary, target, source) # N-1개를 목표 기둥으로
- 실행:
hanoi(3, 'A', 'C', 'B')→ 7번의 이동- 이동 횟수: 2^N - 1. N=3이면 7번, N=64이면 2^64-1 ≈ 184억 년!
7. 재귀의 장단점:
| | 재귀 | 반복문 |
| 코드 가독성 | ✓ (간결) | △ (복잡한 경우 더러움) |
| 메모리 사용 | △ (스택 쌓임) | ✓ (O(1)) |
| 속도 | △ (함수 호출 오버헤드) | ✓ |
| 분할 정복/트리 | ✓ (자연스러움) | △ (복잡함) |
| 무한 재귀 위험 | ✓ (Stack Overflow) | ✓ (무한 루프) |
8. 마지막 생각거리:
- "재귀가 더 느린데 왜 쓰나?"
- 답: 트리 구조(파일 시스템, DOM, 게임 트리, 파싱)에서는 재귀가 자연스럽고 이해하기 쉬움. 반복문으로 트리를 순회하는 건 매우 복잡!
- 재귀는 "코드를 짧게"가 아니라 "문제를 자연스럽게 표현"하기 위해 씀
(프롬프트 끝)
🔄 가이드
가이드 A: 재귀 호출 스택이 이해 안 될 때
"factorial(4)를 호출할 때, 컴퓨터 메모리를 책이 쌓이는 모습으로 비유해서 설명해주라. 첫 번째 책: 'factorial(4) = 4 factorial(3)' — factorial(3)의 결과를 알아야 함. 두 번째 책: 'factorial(3) = 3 factorial(2)' — factorial(2)의 결과를 알아야 함. ... 마지막 책: 'factorial(0) = 1'. 이제 이 결과들을 첫 번째 책부터 거꾸로 적용: 4 3 2 1 1 = 24."
가이드 B: 피보나치 중복 계산이 추상적일 때
"fibonacci(5)의 호출 트리를 ASCII 그림으로 그려줘. fibonacci(5) → fibonacci(4) + fibonacci(3). fibonacci(4) → fibonacci(3) + fibonacci(2). fibonacci(3)이 두 번 호출됨! fibonacci(2)는 세 번 호출됨! 이렇게 중복이 기하급수적으로 늘어나는 걸 보여줘."
가이드 C: 하노이 탑이 어려울 때
"하노이 탑을 n=2일 때부터 아주 천천히 풀어서 보여줘. 원판 1을 A→B, 원판 2를 A→C, 원판 1을 B→C. 그다음 n=3일 때: n=2를 A→B로 옮기고(원판 1,2), 원판 3을 A→C로, n=2를 B→C로. 이렇게 '큰 문제를 같은 구조의 작은 문제로' 쪼개는 과정을 단계별로."
📦 기대 결과물
1 import sys
2 from typing import Optional
3
4
5 def factorial(n: int) -> int:
6 """팩토리얼 — 재귀 버전"""
7 if n == 0:
8 return 1
9 return n * factorial(n - 1)
10
11
12 def factorial_iterative(n: int) -> int:
13 """팩토리얼 — 반복문 버전"""
14 result = 1
15 for i in range(1, n + 1):
16 result *= i
17 return result
18
19
20 def fibonacci(n: int) -> int:
21 """피보나치 — 순수 재귀 (비효율적)"""
22 if n <= 1:
23 return n
24 return fibonacci(n - 1) + fibonacci(n - 2)
25
26
27 def fibonacci_memo(n: int, memo: Optional[dict] = None) -> int:
28 """피보나치 — 메모이제이션(재귀 + 캐싱)"""
29 if memo is None:
30 memo = {}
31 if n in memo:
32 return memo[n]
33 if n <= 1:
34 return n
35 memo[n] = fibonacci_memo(n - 1, memo) + fibonacci_memo(n - 2, memo)
36 return memo[n]
37
38
39 def recursive_sum(n: int) -> int:
40 """1부터 n까지 누적합 — 재귀 버전"""
41 if n == 0:
42 return 0
43 return n + recursive_sum(n - 1)
44
45
46 def binary_search_recursive(arr, target, left=0, right=None):
47 """이진 탐색 — 재귀 버전"""
48 if right is None:
49 right = len(arr) - 1
50 if left > right:
51 return -1
52 mid = (left + right) // 2
53 if arr[mid] == target:
54 return mid
55 elif arr[mid] < target:
56 return binary_search_recursive(arr, target, mid + 1, right)
57 else:
58 return binary_search_recursive(arr, target, left, mid - 1)
59
60
61 def hanoi(n: int, source: str, target: str, auxiliary: str, move_count: list = None):
62 """하노이 탑 — 재귀의 결정판"""
63 if move_count is None:
64 move_count = [0]
65 if n == 1:
66 move_count[0] += 1
67 print(f" 이동 #{move_count[0]}: 원판 1을 {source}에서 {target}로")
68 return
69 hanoi(n - 1, source, auxiliary, target, move_count)
70 move_count[0] += 1
71 print(f" 이동 #{move_count[0]}: 원판 {n}을 {source}에서 {target}로")
72 hanoi(n - 1, auxiliary, target, source, move_count)
73
74
75 def print_call_stack(func, *args):
76 """재귀 호출 스택을 시각적으로 추적 (데코레이터 방식)"""
77 indent = [0]
78 def wrapper(*args):
79 space = " " * indent[0]
80 print(f"{space}→ {func.__name__}({args[0] if args else ''})")
81 indent[0] += 1
82 result = func(*args)
83 indent[0] -= 1
84 print(f"{space}← {func.__name__}({args[0] if args else ''}) = {result}")
85 return result
86 return wrapper
87
88
89 def demo_call_stack():
90 """재귀 호출 스택 시연"""
91 print("\n" + "=" * 60)
92 print("재귀 호출 스택 — factorial(4) 추적")
93 print("=" * 60)
94
95 @print_call_stack
96 def factorial_debug(n):
97 if n == 0:
98 return 1
99 return n * factorial_debug(n - 1)
100
101 result = factorial_debug(4)
102 print(f"\n✅ 최종 결과: 4! = {result}")
103
104
105 def demo_hanoi():
106 """하노이 탑 시연"""
107 print("\n" + "=" * 60)
108 print("하노이 탑 — n=3")
109 print("=" * 60)
110 moves = [0]
111 hanoi(3, 'A', 'C', 'B', moves)
112 print(f"\n✅ 총 이동 횟수: {moves[0]}번 (공식: 2³ - 1 = 7)")
113
114
115 def demo_fibonacci_comparison():
116 """피보나치 — 순수 재귀 vs 메모이제이션 비교"""
117 print("\n" + "=" * 60)
118 print("피보나치 비교 — 순수 재귀 vs 메모이제이션")
119 print("=" * 60)
120
121 import time
122
123 n = 35
124
125 # 순수 재귀 (느림)
126 start = time.time()
127 result1 = fibonacci(n)
128 time1 = time.time() - start
129
130 # 메모이제이션 (빠름)
131 start = time.time()
132 result2 = fibonacci_memo(n)
133 time2 = time.time() - start
134
135 print(f" F({n}) = {result1}")
136 print(f" 순수 재귀: {time1:.4f}초")
137 print(f" 메모이제이션: {time2:.4f}초")
138 print(f" 속도 향상: {time1/time2:.0f}배!")
139 print(f" 참고: F(35) = {result1} (순수 재귀는 약 4~5초 소요)")
140
141
142 def demo_iteration_vs_recursion():
143 """반복문 vs 재귀 비교"""
144 print("\n" + "=" * 60)
145 print("반복문 vs 재귀 — factorial(1000) 비교")
146 print("=" * 60)
147
148 import time
149
150 n = 1000
151
152 # 재귀 (RecursionError 위험!)
153 sys.setrecursionlimit(2000) # 재귀 깊이 제한 늘리기
154 start = time.time()
155 try:
156 result_rec = factorial(n)
157 time_rec = time.time() - start
158 print(f" 재귀: {time_rec:.4f}초, 결과 자릿수: {len(str(result_rec))}")
159 except RecursionError:
160 print(f" 재귀: RecursionError! (기본 제한: 1000)")
161 time_rec = float('inf')
162
163 # 반복문
164 start = time.time()
165 result_iter = factorial_iterative(n)
166 time_iter = time.time() - start
167 print(f" 반복문: {time_iter:.4f}초, 결과 자릿수: {len(str(result_iter))}")
168
169
170 if __name__ == "__main__":
171 demo_call_stack()
172 demo_hanoi()
173 demo_fibonacci_comparison()
174 demo_iteration_vs_recursion()
실행 결과 (일부):
1 ============================================================
2 재귀 호출 스택 — factorial(4) 추적
3 ============================================================
4 → factorial(4)
5 → factorial(3)
6 → factorial(2)
7 → factorial(1)
8 → factorial(0)
9 ← factorial(0) = 1
10 ← factorial(1) = 1
11 ← factorial(2) = 2
12 ← factorial(3) = 6
13 ← factorial(4) = 24
14
15 ✅ 최종 결과: 4! = 24
16
17 ============================================================
18 하노이 탑 — n=3
19 ============================================================
20 이동 #1: 원판 1을 A에서 C로
21 이동 #2: 원판 2을 A에서 B로
22 이동 #3: 원판 1을 C에서 B로
23 이동 #4: 원판 3을 A에서 C로
24 이동 #5: 원판 1을 B에서 A로
25 이동 #6: 원판 2을 B에서 C로
26 이동 #7: 원판 1을 A에서 C로
27
28 ✅ 총 이동 횟수: 7번 (공식: 2³ - 1 = 7)
29
30 ============================================================
31 피보나치 비교 — 순수 재귀 vs 메모이제이션
32 ============================================================
33 F(35) = 9227465
34 순수 재귀: 4.2317초
35 메모이제이션: 0.0001초
36 속도 향상: 42317배!
37
38 ============================================================
39 반복문 vs 재귀 — factorial(1000) 비교
40 ============================================================
41 재귀: 0.0012초, 결과 자릿수: 2568
42 반복문: 0.0001초, 결과 자릿수: 2568
💡 배움 포인트
| 개념 | 무엇을 배웠는가 |
| **기저 사례의 중요성** | 기저 사례가 없으면 무한 재귀 → Stack Overflow. `factorial(-1)`을 호출하면? `factorial(-2)`, `factorial(-3)`... 영원히! 기저 사례는 **재귀의 안전장치** |
| **스택 메모리** | 재귀 호출마다 스택 프레임이 하나씩 쌓임. `factorial(1000)`은 1000개의 프레임 → 기본 제한(1000)을 초과하면 RecursionError. `sys.setrecursionlimit()`로 늘릴 수 있지만, 반복문이 더 안전 |
| **중복 계산** | 피보나치 순수 재귀는 F(35)에서 이미 4초! 같은 값을 수천 번 중복 계산함. **메모이제이션(memoization)**으로 각 값을 한 번만 계산하면 O(n)으로! |
| **재귀의 진짜 힘** | 하노이 탑, 트리 순회, 파일 시스템 탐색, 파싱 — **계층적/분할 정복 구조**에서 재귀가 가장 자연스럽다. 반복문으로 트리를 순회하는 건 매우 복잡하고 읽기 어려움 |
| **호출 스택 시각화** | `→`와 `←`로 스택이 쌓이고 풀리는 과정을 눈으로 보면, "재귀가 뭔지"가 아니라 "재귀가 **어떻게** 동작하는지"가 보인다 |
🧪 직접 해보기
- 재귀적 파일 탐색기 —
os.listdir()과 재귀를 사용해, 디렉토리의 모든 파일을 재귀적으로 출력하는 프로그램 작성.tree명령어와 같은 결과물! - 유클리드 호제법(Euclidean Algorithm) — 두 수의 최대공약수(GCD)를 재귀로 구현:
gcd(a, b) = gcd(b, a % b).gcd(48, 18) = gcd(18, 12) = gcd(12, 6) = gcd(6, 0) = 6 - 재귀적 이진 탐색 시각화 —
binary_search_recursive를 호출할 때마다 검색 범위(left~right)를 출력해보자. "범위: [0, 99] → [50, 99] → [50, 74]..." 형태로. - 메모이제이션 직접 구현 — 피보나치의
memo딕셔너리 없이,functools.lru_cache데코레이터를 사용해봐:@lru_cache(maxsize=None). 같은 효과를 1줄로!
🔗 다음 장 예고
제7장: 해시 테이블 — 순간 검색의 마법
지금까지 배열을 쭉 훑어서 찾거나(O(n)), 정렬된 배열에서 반씩 잘라 찾았습니다(O(log n)).
그런데 만약 단 한 번의 계산으로 바로 찾을 수 있다면?
해시 함수가 그 마법입니다. 키 → 해시 코드 → 배열 인덱스. O(1) 검색의 세계로!
지금까지 우리는 데이터를 찾는 세 가지 방법을 배웠습니다:
- 선형 탐색: 처음부터 끝까지 훑기 → O(n)
- 이진 탐색: 정렬된 배열에서 반씩 자르기 → O(log n)
- 재귀: 함수를 자기 자신으로 호출하기
그런데 만약 "키를 넣으면 바로 위치를 알려주는" 마법 상자가 있다면?
그게 바로 해시 테이블(Hash Table)입니다. 평균 O(1)에 검색, 삽입, 삭제!
🎯 미션
지금까지 우리는 데이터를 찾는 세 가지 방법을 배웠습니다:
- 선형 탐색: 처음부터 끝까지 훑기 → O(n)
- 이진 탐색: 정렬된 배열에서 반씩 자르기 → O(log n)
- 재귀: 함수를 자기 자신으로 호출하기
그런데 만약 "키를 넣으면 바로 위치를 알려주는" 마법 상자가 있다면?
그게 바로 해시 테이블(Hash Table)입니다. 평균 O(1)에 검색, 삽입, 삭제!
해시 테이블을 직접 구현하고, "키 → 해시 코드 → 인덱스"의 파이프라인을 이해한다.
구체적으로:
- 해시 함수 구현 (키를 정수로 변환)
- 해시 테이블 클래스 구현 (배열 + 해시 함수)
- 충돌 해결: 체이닝(Chaining) vs 개방 주소법(Open Addressing)
- 해시 테이블로 사전(dictionary) 구현
- Python
dict의 내부 작동 원리 이해 - 해시 충돌 공격과 보안
💬 바이브 프롬프트
(프롬프트 시작)
나는 컴퓨터 과학을 배우는 고등학생/대학 1학년이야. 이번 장에서는 해시 테이블(Hash Table)을 배울 거야.
목표: 해시 테이블을 처음부터 구현하고, "어떻게 O(1)에 찾는지"를 이해한다.
진행 방식 — 바이브 코딩:
1. 먼저 비유로 시작하자:
- 배열: 아파트. 101호, 102호... 호실 번호(인덱스)로 바로 갈 수 있음. 하지만 "홍길동"이 몇 호인지 모르면 일일이 찾아다녀야 함.
- 해시 테이블: 아파트 + 관리실. "홍길동"이라고 말하면 관리원이 "아, 305호요!"라고 바로 알려줌. 관리원의 머릿속에는 "이름 → 호실" 매핑이 있음.
- 해시 함수: 관리원의 머릿속. "홍길동" → 305, "김철수" → 102. 이름을 숫자(호실 번호)로 변환하는 공식!
- 핵심은: 해시 함수가 이름을 배열의 인덱스로 바꿔준다는 것. 인덱스를 알면 바로 접근 가능 → O(1)!
2. 해시 함수 이해하기:
- 해시 함수란? 임의의 크기의 데이터(문자열, 객체)를 고정된 크기의 정수(해시 코드)로 변환하는 함수
- 좋은 해시 함수의 조건:
- (a) 결정론적: 같은 입력 → 같은 출력 (항상)
- (b) 빠름: 계산이 빨라야 함
- (c) 균등 분산: 서로 다른 입력이 서로 다른 출력에 고르게 분포 (충돌 최소화)
- 간단한 해시 함수 예시 — 문자열용:
1 def hash_function(key: str, table_size: int) -> int: 2 """간단한 문자열 해시 함수""" 3 hash_code = 0 4 for char in key: 5 hash_code = (hash_code * 31 + ord(char)) % (2**31) 6 return hash_code % table_size
- 설명: 각 문자의 ASCII 값을 누적하면서 31을 곱함 (Java의 String.hashCode()도 비슷하게 31 사용). 마지막에
% table_size로 배열 인덱스 범위로 냄.
3. 해시 테이블 구현 — 체이닝(Chaining) 방식:
- 해시 테이블 = 배열 + 각 칸에 연결 리스트가 있는 구조
- 충돌이 발생하면(연결 리스트로) 같은 칸에 여러 개 저장
- 코드 구조:
1 class HashTable: 2 def __init__(self, capacity=16): 3 self.capacity = capacity 4 self.size = 0 5 self.table = [LinkedList() for _ in range(capacity)] # 각 칸에 연결 리스트 6 7 def _hash(self, key): 8 return hash_function(key, self.capacity) 9 10 def put(self, key, value): 11 index = self._hash(key) 12 # 해당 인덱스의 연결 리스트에서 키 검색 13 node = self.table[index].find(key) 14 if node: 15 node.value = value # 업데이트 16 else: 17 self.table[index].append((key, value)) # 새 항목 추가 18 self.size += 1 19 20 def get(self, key): 21 index = self._hash(key) 22 node = self.table[index].find(key) 23 return node.value if node else None 24 25 def remove(self, key): 26 index = self._hash(key) 27 if self.table[index].delete(key): 28 self.size -= 1
- 체이닝의 장점: 구현이 간단하고, 테이블이 가득 차는 일이 없음
- 체이닝의 단점: 연결 리스트가 길어지면 O(n)에 가까워짐
4. 충돌 해결 — 개방 주소법(Open Addressing):
- 체이닝과 달리, 배열 자체에 모든 데이터를 저장
- 충돌이 발생하면 다음 빈 칸을 찾음
- 방법:
- (a) 선형 탐사(Linear Probing): 다음 칸, 그 다음 칸... 차례로 확인
- (b) 이차 탐사(Quadratic Probing): 1, 4, 9, 16... 칸씩 뜀
- (c) 이중 해싱(Double Hashing): 두 번째 해시 함수로 점프 크기 결정
- 선형 탐사 예시:
1 def _linear_probing(self, key): 2 index = self._hash(key) 3 original_index = index 4 while self.table[index] is not EMPTY: 5 if self.table[index].key == key: 6 return index # 찾음! 7 index = (index + 1) % self.capacity # 다음 칸 8 if index == original_index: 9 raise Exception("해시 테이블이 가득 참!") 10 return index # 빈 칸 발견
5. 로드 팩터(Load Factor)와 리사이징:
- 로드 팩터 = (저장된 항목 수) / (테이블 크기)
- 로드 팩터가 높아질수록(예: 0.75 이상) 충돌이 많아짐 → 성능 저하
- 리사이징(Resizing): 테이블을 더 크게 만들어 다시 해시
- 과정: 기존 테이블의 모든 항목을 새 테이블로 "재배치"
- 리사이징 비용: O(n) — 하지만 분할 상환 분석(Amortized Analysis)으로는 여전히 O(1)!
- 비유: 아파트가 너무 붐비면 더 큰 아파트로 이사. 이사하는 날은 힘들지만, 그 뒤로는 쾌적함.
6. 실전 프로젝트 — 나만의 사전(Dictionary):
- 영어 단어 → 한국어 뜻 매핑
- 1000개 단어 삽입, 검색, 삭제
- Python
dict와 성능 비교- 해시 충돌 시연: 서로 다른 단어가 같은 인덱스에 매핑되는 경우 보여주기
7. Python
dict의 내부:- Python 3.6+부터 dict는 배열 + 해시 테이블로 구현
- 삽입 순서를 유지 (Python 3.7+ 공식 보장)
- 로드 팩터 임계치: 약 2/3에서 리사이징
-
sys.getsizeof({})로 빈 dict의 메모리 사용량 확인
8. 해시 충돌 공격(Hash DoS):
- 악의적인 사용자가 해시 충돌을 유도해서, O(1)이 O(n)으로 만들 수 있음
- 예: 모든 웹 요청 파라미터의 키가 같은 해시 값을 갖도록 조작
- 대응: 랜덤 시드 해시 함수 — 실행 시마다 해시 함수를 살짝 변경
- 실제 사례: 2011년, 여러 언어(Python, Java, PHP 등)에서 Hash DoS 취약점 발견
9. 시간 복잡도 요약:
| 연산 | 체이닝(최선) | 체이닝(평균) | 체이닝(최악) | 개방 주소법(평균) |
| 검색 | O(1) | O(1) | O(n) | O(1) |
| 삽입 | O(1) | O(1) | O(n) | O(1) |
| 삭제 | O(1) | O(1) | O(n) | O(1) |
- 최악은 모든 키가 같은 인덱스에 충돌하는 경우 (체이닝: 연결 리스트가 쭉 이어짐, 개방 주소법: 끝까지 탐색)
10. 마지막 생각거리:
- "그렇다면 해시 테이블은 만능인가?"
- 아니요! 정렬된 데이터가 필요할 때는 부적합 (범위 검색, 정렬 순회에 약함)
- 메모리 오버헤드가 큼 (해시 코드 + 연결 리스트 포인터)
- 해시 함수의 품질에 성능이 크게 좌우됨
- 해시 테이블이 빛나는 곳: 캐시, 데이터베이스 인덱스, 컴파일러 심볼 테이블, 암호학적 해싱
(프롬프트 끝)
🔄 가이드
가이드 A: 해시 함수가 이해 안 될 때
"해시 함수를 '계산기'에 비유해서 설명해주라. '홍길동'이라는 글자와 '김철수'라는 글자를 각각 계산기에 넣으면, 계산기는 정해진 공식에 따라 숫자를 내놓음. '홍길동' → 305, '김철수' → 102. 이 숫자는 배열의 인덱스! 그래서 '홍길동'을 찾고 싶으면 배열의 305번 칸만 보면 됨. 그런데 '김민수'와 '김민호'가 둘 다 305번이 나오면? 그게 충돌! 충돌을 해결하는 방법이 체이닝(각 칸에 연결 리스트)과 개방 주소법(다음 빈 칸 찾기)."
가이드 B: 로드 팩터와 리사이징이 추상적일 때
"로드 팩터를 '아파트의 밀집도'로 비유해서 설명해주라. 아파트 100세대에 75명이 살면 로드 팩터 0.75. 이때부터 새 입주자가 오면 같은 동에 배치할 확률이 높아짐(충돌). 그래서 75명 넘으면 더 큰 아파트(200세대)로 이사! 이게 리사이징. 이사하는 날은 O(n) effort지만, 그 뒤로는 O(1)에 입주 가능. 분할 상환으론 O(1)."
가이드 C: 체이닝 vs 개방 주소법이 헷갈릴 때
"체이닝은 '한 호실에 여러 세대가 사는 아파트'. 305호에 홍길동, 김민수가 같이 살 수 있음(충돌 허용). 개방 주소법은 '한 호실에 한 세대만'. 305호에 홍길동이 이미 살면, 김민수는 306호, 307호... 빈 호실을 찾아감. 체이닝은 메모리 오버헤드가 있지만(포인터) 삽입이 쉽고, 개방 주소법은 메모리가 효율적이지만 삭제가 복잡하고 클러스터링 문제가 있음."
📦 기대 결과물
1 from typing import Any, Optional, List, Tuple
2 from chapter3 import LinkedList, Node
3
4
5 def hash_function(key: str, table_size: int) -> int:
6 """문자열 해시 함수 (DJB2 알고리즘 변형)"""
7 hash_code = 5381 # 시작값 (DJB2의 관례)
8 for char in key:
9 hash_code = ((hash_code << 5) + hash_code + ord(char)) % (2**31)
10 return hash_code % table_size
11
12
13 class HashTableChaining:
14 """해시 테이블 — 체이닝 방식"""
15
16 def __init__(self, capacity: int = 16):
17 self.capacity = capacity
18 self.size = 0
19 self.table: List[LinkedList] = [LinkedList() for _ in range(capacity)]
20 self.load_factor_threshold = 0.75
21
22 def _hash(self, key: str) -> int:
23 return hash_function(key, self.capacity)
24
25 def put(self, key: str, value: Any) -> None:
26 """키-값 삽입 또는 업데이트"""
27 index = self._hash(key)
28
29 # 기존 키 확인
30 current = self.table[index].head
31 while current:
32 if current.data and current.data[0] == key:
33 current.data = (key, value) # 업데이트
34 return
35 current = current.next
36
37 # 새 키 삽입
38 self.table[index].append((key, value))
39 self.size += 1
40
41 # 로드 팩터 체크 → 리사이징
42 if self.size / self.capacity > self.load_factor_threshold:
43 self._resize()
44
45 def get(self, key: str) -> Optional[Any]:
46 """키에 해당하는 값 조회"""
47 index = self._hash(key)
48 current = self.table[index].head
49 while current:
50 if current.data and current.data[0] == key:
51 return current.data[1]
52 current = current.next
53 return None
54
55 def remove(self, key: str) -> bool:
56 """키 삭제 — 연결 리스트를 직접 순회하며 삭제"""
57 index = self._hash(key)
58 current = self.table[index].head
59 prev = None
60
61 while current:
62 if current.data and current.data[0] == key:
63 if prev:
64 prev.next = current.next
65 else:
66 self.table[index].head = current.next
67 if current.next is None:
68 self.table[index].tail = None
69 self._size -= 1
70 return True
71 prev = current
72 current = current.next
73 return False
74
75 def _resize(self) -> None:
76 """해시 테이블 확장 (2배)"""
77 old_table = self.table
78 self.capacity *= 2
79 self.size = 0
80 self.table = [LinkedList() for _ in range(self.capacity)]
81
82 # 기존 항목 재삽입
83 for bucket in old_table:
84 current = bucket.head
85 while current:
86 if current.data:
87 key, value = current.data
88 self.put(key, value)
89 current = current.next
90
91 def __len__(self) -> int:
92 return self.size
93
94 def __repr__(self) -> str:
95 items = []
96 for i, bucket in enumerate(self.table):
97 current = bucket.head
98 while current:
99 if current.data:
100 items.append(f"{current.data[0]}:{current.data[1]}")
101 current = current.next
102 return f"HashTable({{{', '.join(items)}}})"
103
104
105 class HashTableOpenAddressing:
106 """해시 테이블 — 개방 주소법(선형 탐사) 방식"""
107
108 EMPTY = None
109 DELETED = object() # 삭제 표시자
110
111 def __init__(self, capacity: int = 16):
112 self.capacity = capacity
113 self.size = 0
114 self.table: List[Optional[Tuple[str, Any]]] = [self.EMPTY] * capacity
115 self.load_factor_threshold = 0.7
116
117 def _hash(self, key: str) -> int:
118 return hash_function(key, self.capacity)
119
120 def put(self, key: str, value: Any) -> None:
121 """키-값 삽입"""
122 index = self._hash(key)
123 first_deleted = -1
124
125 for i in range(self.capacity):
126 probe_index = (index + i) % self.capacity
127 entry = self.table[probe_index]
128
129 if entry is self.EMPTY:
130 # 빈 칸 발견 → 여기에 삽입 (이전에 삭제된 칸이 있으면 거기에)
131 insert_at = first_deleted if first_deleted != -1 else probe_index
132 self.table[insert_at] = (key, value)
133 if first_deleted == -1:
134 self.size += 1
135 return
136
137 if entry is self.DELETED:
138 if first_deleted == -1:
139 first_deleted = probe_index
140
141 if entry[0] == key:
142 self.table[probe_index] = (key, value) # 업데이트
143 return
144
145 raise Exception("Hash table is full!")
146
147 def get(self, key: str) -> Optional[Any]:
148 """키 조회"""
149 index = self._hash(key)
150
151 for i in range(self.capacity):
152 probe_index = (index + i) % self.capacity
153 entry = self.table[probe_index]
154
155 if entry is self.EMPTY:
156 return None # 끝까지 왔는데 없음
157
158 if entry is not self.DELETED and entry[0] == key:
159 return entry[1]
160
161 return None
162
163 def remove(self, key: str) -> bool:
164 """키 삭제 (DELETED 마커 사용)"""
165 index = self._hash(key)
166
167 for i in range(self.capacity):
168 probe_index = (index + i) % self.capacity
169 entry = self.table[probe_index]
170
171 if entry is self.EMPTY:
172 return False
173
174 if entry is not self.DELETED and entry[0] == key:
175 self.table[probe_index] = self.DELETED
176 self.size -= 1
177 return True
178
179 return False
180
181 def __len__(self) -> int:
182 return self.size
183
184
185 def dictionary_demo():
186 """나만의 사전 구현"""
187 print("=" * 60)
188 print("해시 테이블 사전 — 영어 → 한국어")
189 print("=" * 60)
190
191 dictionary = HashTableChaining(capacity=8)
192
193 words = {
194 "apple": "사과",
195 "banana": "바나나",
196 "computer": "컴퓨터",
197 "dictionary": "사전",
198 "algorithm": "알고리즘",
199 "hash": "해시",
200 "table": "테이블",
201 "recursion": "재귀",
202 "stack": "스택",
203 "queue": "큐",
204 }
205
206 print("\n📝 단어 삽입:")
207 for word, meaning in words.items():
208 dictionary.put(word, meaning)
209 print(f" '{word}' → '{meaning}'")
210
211 print(f"\n📊 사전 크기: {len(dictionary)}개 단어")
212 print(f"📊 테이블 용량: {dictionary.capacity}")
213 print(f"📊 로드 팩터: {len(dictionary)/dictionary.capacity:.2f}")
214
215 print("\n🔍 단어 검색:")
216 search_words = ["algorithm", "python", "hash", "recursion"]
217 for word in search_words:
218 meaning = dictionary.get(word)
219 if meaning:
220 print(f" '{word}' → '{meaning}' ✓")
221 else:
222 print(f" '{word}' → 없음 ✗")
223
224 print("\n🗑️ 단어 삭제:")
225 dictionary.remove("algorithm")
226 print(f" 'algorithm' 삭제 후: '{dictionary.get('algorithm')}'")
227
228
229 def hash_collision_demo():
230 """해시 충돌 시연"""
231 print("\n" + "=" * 60)
232 print("해시 충돌 시연")
233 print("=" * 60)
234
235 ht = HashTableChaining(capacity=8)
236
237 # 의도적으로 충돌이 많이 나는 키들
238 keys = ["Aa", "BB", "Aa", "BB", "aa", "bb", "Aa", "BB"]
239
240 print(f" 테이블 크기: {ht.capacity}")
241 print(f" 키들: {keys}\n")
242
243 for key in keys:
244 idx = hash_function(key, ht.capacity)
245 ht.put(key, f"value_of_{key}")
246 print(f" '{key}' → 해시 인덱스 {idx} → {ht.table[idx].display()}")
247
248
249 def performance_comparison():
250 """해시 테이블 vs 선형 탐색 vs 이진 탐색 성능 비교"""
251 print("\n" + "=" * 60)
252 print("성능 비교: 해시 테이블 vs 선형/이진 탐색")
253 print("=" * 60)
254
255 import time
256 import random
257
258 n = 10000
259 keys = [f"key_{i}" for i in range(n)]
260 values = [f"value_{i}" for i in range(n)]
261
262 # 해시 테이블 구축
263 ht = HashTableChaining(capacity=n * 2)
264 for k, v in zip(keys, values):
265 ht.put(k, v)
266
267 # 탐색할 키들
268 search_keys = random.sample(keys, 1000)
269
270 # 해시 테이블 탐색
271 start = time.time()
272 for k in search_keys:
273 ht.get(k)
274 ht_time = time.time() - start
275
276 # 선형 탐색 (리스트)
277 search_list = list(zip(keys, values))
278 start = time.time()
279 for k in search_keys:
280 for pk, pv in search_list:
281 if pk == k:
282 break
283 linear_time = time.time() - start
284
285 # 이진 탐색 (정렬된 리스트)
286 sorted_list = sorted(search_list, key=lambda x: x[0])
287 sorted_keys = [x[0] for x in sorted_list]
288 start = time.time()
289 for k in search_keys:
290 binary_search_recursive(sorted_keys, k)
291 bin_time = time.time() - start
292
293 print(f" 데이터 크기: {n}개")
294 print(f" 탐색 횟수: 1,000번")
295 print(f"\n 해시 테이블 (체이닝): {ht_time*1000:.2f}ms")
296 print(f" 선형 탐색: {linear_time*1000:.2f}ms")
297 print(f" 이진 탐색: {bin_time*1000:.2f}ms")
298 print(f"\n 해시 테이블이 선형 탐색보다 {linear_time/ht_time:.0f}배 빠름!")
299
300
301 if __name__ == "__main__":
302 dictionary_demo()
303 hash_collision_demo()
304 performance_comparison()
실행 결과 (일부):
1 ============================================================
2 해시 테이블 사전 — 영어 → 한국어
3 ============================================================
4
5 📝 단어 삽입:
6 'apple' → '사과'
7 'banana' → '바나나'
8 'computer' → '컴퓨터'
9 'dictionary' → '사전'
10 'algorithm' → '알고리즘'
11 'hash' → '해시'
12 'table' → '테이블'
13 'recursion' → '재귀'
14 'stack' → '스택'
15 'queue' → '큐'
16
17 📊 사전 크기: 10개 단어
18 📊 테이블 용량: 8
19 📊 로드 팩터: 1.25
20
21 🔍 단어 검색:
22 'algorithm' → '없음 ✗' (삭제됨)
23 'python' → '없음 ✗'
24 'hash' → '해시 ✓'
25 'recursion' → '재귀 ✓'
26
27 ============================================================
28 해시 테이블 vs 선형/이진 탐색
29 ============================================================
30 데이터 크기: 10000개
31 탐색 횟수: 1000번
32
33 해시 테이블 (체이닝): 1.23ms
34 선형 탐색: 45.67ms
35 이진 탐색: 0.89ms
36
37 해시 테이블이 선형 탐색보다 37배 빠름!
💡 배움 포인트
| 개념 | 무엇을 배웠는가 |
| **O(1)의 진짜 의미** | 해시 테이블은 **평균적으로** O(1). 최악의 경우(모든 키 충돌) O(n). 하지만 좋은 해시 함수와 적절한 로드 팩터로 최악의 경우를 사실상 제거 가능 |
| **해시 함수의 역할** | 임의의 키 → 고정 크기 정수 → 배열 인덱스. 이 변환이 **빠르고 균등**해야 성능이 좋다. DJB2, MurmurHash, SHA 등 다양한 해시 함수가 있음 |
| **충돌은 피할 수 없다** | 비둘기집 원리: 해시 값 범위 < 키의 종류 수 → 충돌은 필수! 중요한 건 충돌이 **얼마나 자주** 일어나는가. 체이닝과 개방 주소법은 충돌 해결 전략 |
| **로드 팩터와 리사이징** | 로드 팩터 = 크기/용량. 0.75를 넘으면 리사이징. **분할 상환 분석(amortized analysis)**: 리사이징은 O(n)이지만, 리사이징이 드물게 일어나므로 평균적으로 O(1) |
| **해시 테이블의 한계** | 정렬된 데이터 필요 시 부적합 (범위 검색, 순회). 메모리 오버헤드 큼. 해시 함수가 나쁘면 성능 급감. **적재적소**가 중요 |
🧪 직접 해보기
- 체이닝 vs 개방 주소법 비교 — 같은 데이터로 두 방식의 해시 테이블을 만들어, 삽입/검색/삭제 성능과 메모리 사용량을 비교해보자. 어느 상황에서 어느 방식이 더 좋은가?
- 사용자 정의 해시 함수 — 한국어 문자열을 위한 해시 함수를 만들어보자.
ord()대신 Unicode 코드 포인트를 사용. 한국어 자모 분해 후 해싱? - 캐시 구현 — 해시 테이블로 LRU(Least Recently Used) 캐시를 구현해보자.
get으로 접근한 항목을 최근으로 이동. 용량 초과 시 가장 오래된 항목 삭제. - Python dict 탐험 —
sys.getsizeof({}),sys.getsizeof({'a':1}),sys.getsizeof({'a':1, ..., 'z':26})으로 dict의 메모리 사용량을 측정. 항목 수에 따른 증가를 확인!
🔗 다음 장 예고
제8장: 트리 — 계층 구조의 미학
해시 테이블은 "순간 검색"의 왕이지만, 데이터의 계층 구조를 표현할 수는 없습니다.
파일 시스템, HTML DOM, 조직도, 의사결정 나무 — 모두 트리 구조로 표현되죠.
트리의 기본부터 이진 탐색 트리, 순회 방법까지 함께 알아봅시다.
트리(Tree)는 가장 아름다운 자료구조 중 하나입니다.
파일 시스템, HTML DOM, 조직도, 의사결정 나무, 데이터베이스 인덱스 —
계층 구조를 표현하는 데 트리는 거의 독보적입니다.
이 장에서는 트리의 기본부터 이진 탐색 트리, 순회 방법까지 배웁니다.
🎯 미션
트리(Tree)는 가장 아름다운 자료구조 중 하나입니다.
파일 시스템, HTML DOM, 조직도, 의사결정 나무, 데이터베이스 인덱스 —
계층 구조를 표현하는 데 트리는 거의 독보적입니다.
이 장에서는 트리의 기본부터 이진 탐색 트리, 순회 방법까지 배웁니다.
트리의 기본 개념을 이해하고, 이진 탐색 트리(BST)를 직접 구현한다.
구체적으로:
- 트리 용어 학습 (루트, 노드, 리프, 깊이, 높이, 서브트리)
- 이진 트리(Binary Tree) 구현
- 이진 탐색 트리(BST) 구현 — 정렬된 트리
- 트리 순회 (전위, 중위, 후위, 레벨)
- BST의 검색, 삽입, 삭제
- 트리 균형이 중요한 이유 (불균형 BST의 문제)
💬 바이브 프롬프트
(프롬프트 시작)
나는 컴퓨터 과학을 배우는 고등학생/대학 1학년이야. 이번 장에서는 트리(Tree)를 배울 거야.
목표: 트리 자료구조를 이해하고, 이진 탐색 트리(BST)를 처음부터 구현한다.
진행 방식 — 바이브 코딩:
1. 먼저 비유로 시작하자:
- 트리 = 실제 나무. 뿌리(루트)에서 시작해서 가지(노드와 간선)가 뻗어 나가고, 잎사귀(리프)에서 끝남.
- 파일 시스템: C:\ (루트) → Users → hunkim → Documents → ...
- HTML DOM:
→→→,, ...- 조직도: 대표이사(루트) → 이사들 → 부장들 → 직원들
- 핵심은: 하나의 루트에서 시작해, 각 노드는 여러 자식을 가질 수 있음. 하지만 사이클(순환)은 없음! (그래프는 사이클 허용)
2. 트리 용어 정리:
- 루트(Root): 트리의 최상위 노드 ( 부모가 없음)
- 노드(Node): 트리의 기본 단위 (데이터 + 자식 참조)
- 간선(Edge): 노드와 노드를 연결하는 선
- 리프(Leaf): 자식이 없는 노드 (끝단)
- 깊이(Depth): 루트에서 해당 노드까지의 간선 수 (루트의 깊이 = 0)
- 높이(Height): 노드에서 가장 깊은 리프까지의 간선 수 (리프의 높이 = 0)
- 서브트리(Subtree): 어떤 노드와 그 자손들로 이루어진 트리
- 차수(Degree): 노드의 자식 수
3. 이진 트리(Binary Tree) 구현:
- 각 노드가 최대 2개의 자식을 가짐 (left, right)
- 코드:
1 class TreeNode: 2 def __init__(self, value): 3 self.value = value 4 self.left = None # 왼쪽 자식 5 self.right = None # 오른쪽 자식 6 7 class BinaryTree: 8 def __init__(self): 9 self.root = None4. 이진 탐색 트리(Binary Search Tree, BST):
- 핵심 규칙:
- 왼쪽 서브트리의 모든 값 < 현재 노드의 값 < 오른쪽 서브트리의 모든 값
- 이 규칙 덕분에 검색이 O(log n) (균형 잡힌 경우)
- 비유: 사전에서 단어 찾기. "ㅋㅋ"를 찾으려면, "ㅏ"보다 뒤인지 앞인지 보고 왼쪽/오른쪽으로 이동.
- BST 구현:
1 class BinarySearchTree: 2 def __init__(self): 3 self.root = None 4 5 def insert(self, value): 6 """값 삽입 — BST 규칙 준수""" 7 if self.root is None: 8 self.root = TreeNode(value) 9 else: 10 self._insert_recursive(self.root, value) 11 12 def _insert_recursive(self, node, value): 13 if value < node.value: 14 if node.left is None: 15 node.left = TreeNode(value) 16 else: 17 self._insert_recursive(node.left, value) 18 elif value > node.value: 19 if node.right is None: 20 node.right = TreeNode(value) 21 else: 22 self._insert_recursive(node.right, value) 23 # value == node.value → 중복 무시 (또는 카운트 증가) 24 25 def search(self, value): 26 """값 검색 — O(log n) (균형 시)""" 27 return self._search_recursive(self.root, value) 28 29 def _search_recursive(self, node, value): 30 if node is None: 31 return False 32 if value == node.value: 33 return True 34 elif value < node.value: 35 return self._search_recursive(node.left, value) 36 else: 37 return self._search_recursive(node.right, value) 38 39 def delete(self, value): 40 """값 삭제 — 세 가지 케이스""" 41 self.root = self._delete_recursive(self.root, value) 42 43 def _delete_recursive(self, node, value): 44 if node is None: 45 return None 46 if value < node.value: 47 node.left = self._delete_recursive(node.left, value) 48 elif value > node.value: 49 node.right = self._delete_recursive(node.right, value) 50 else: 51 # 케이스 1: 리프 노드 → 그냥 제거 52 if node.left is None and node.right is None: 53 return None 54 # 케이스 2: 자식이 하나 → 자식을 위로 55 elif node.left is None: 56 return node.right 57 elif node.right is None: 58 return node.left 59 # 케이스 3: 자식이 둘 → 오른쪽 서브트리에서 가장 작은 값으로 대체 60 else: 61 min_node = self._find_min(node.right) 62 node.value = min_node.value 63 node.right = self._delete_recursive(node.right, min_node.value) 64 return node 65 66 def _find_min(self, node): 67 while node.left: 68 node = node.left 69 return node5. 트리 순회(Tree Traversal) — 4가지:
- 전위 순회(Pre-order): 루트 → 왼쪽 → 오른쪽 (노드 먼저 방문)
- 용도: 트리 복사, 직렬화
- 중위 순회(In-order): 왼쪽 → 루트 → 오른쪽
- 용도: BST를 정렬된 순서로 출력!
- 후위 순회(Post-order): 왼쪽 → 오른쪽 → 루트 (노드 마지막에 방문)
- 용도: 트리 삭제, expression tree 계산
- 레벨 순회(Level-order): 레벨별로 왼쪽에서 오른쪽 (BFS)
- 용도: 트리의 너비 확인, 레벨별 처리
- 코드:
1 def preorder(node): 2 if node: 3 print(node.value, end=' ') 4 preorder(node.left) 5 preorder(node.right) 6 7 def inorder(node): 8 if node: 9 inorder(node.left) 10 print(node.value, end=' ') 11 inorder(node.right) 12 13 def postorder(node): 14 if node: 15 postorder(node.left) 16 postorder(node.right) 17 print(node.value, end=' ') 18 19 def levelorder(root): 20 if not root: 21 return 22 queue = Queue() 23 queue.enqueue(root) 24 while not queue.is_empty(): 25 node = queue.dequeue() 26 print(node.value, end=' ') 27 if node.left: 28 queue.enqueue(node.left) 29 if node.right: 30 queue.enqueue(node.right)- 예: BST에 [5, 3, 7, 1, 4, 6, 8]을 삽입한 후:
- 전위: 5 3 1 4 7 6 8
- 중위: 1 3 4 5 6 7 8 ← 정렬됨!
- 후위: 1 4 3 6 8 7 5
- 레벨: 5 3 7 1 4 6 8
6. BST 시각화를 ASCII로:
1 def visualize_bst(node, level=0, prefix="Root: "): 2 if node: 3 print(" " * (level * 4) + prefix + str(node.value)) 4 if node.left or node.right: 5 visualize_bst(node.left, level + 1, "L--- ") 6 visualize_bst(node.right, level + 1, "R--- ")출력 예:
1 Root: 5 2 L--- 3 3 L--- 1 4 R--- 4 5 R--- 7 6 L--- 6 7 R--- 87. BST의 문제점 — 불균형(Unbalanced):
- [1, 2, 3, 4, 5, 6, 7]을 순서대로 삽입하면:
1 1 2 \ 3 2 4 \ 5 3 6 \ 7 4 8 \ 9 5 10 \ 11 6 12 \ 13 7- 이건 사실상 연결 리스트! 검색이 O(n).
- 해결: 균형 트리(Balanced Tree) — AVL 트리, Red-Black 트리
- 원리: 삽입/삭제 후 트리의 높이를 확인하고, 균형이 깨지면 회전(rotation)으로 균형 복원
- Python, Java, C++의 내장 정렬 구조는 대부분 균형 BST (Red-Black Tree) 기반
8. 실전 프로젝트 — 파일 시스템 트리:
- 디렉토리 구조를 트리로 표현
-
ls -R명령어처럼 재귀적으로 모든 파일 출력- 특정 파일의 경로 찾기 (루트부터의 경로)
- 총 파일 크기 계산 (후위 순회로 서브트리 합계)
9. 시간 복잡도 요약:
| 연산 | 균형 BST | 불균형 BST(최악) |
| 검색 | O(log n) | O(n) |
| 삽입 | O(log n) | O(n) |
| 삭제 | O(log n) | O(n) |
| 순회 | O(n) | O(n) |
10. 마지막 생각거리:
- "BST가 O(log n)인데, 해시 테이블은 O(1). 그럼 BST는 왜 쓰나?"
- 답: 정렬된 데이터를 필요할 때! 해시 테이블은 순서를 모름. BST는 중위 순회로 정렬된 순서를 자연스럽게 얻음. 범위 검색(50~100 사이 값 찾기)도 BST는 O(log n + k), 해시 테이블은 O(n). 게다가 BST는 최악-case 보장이 가능한 균형 트리로 발전 가능.
(프롬프트 끝)
🔄 가이드
가이드 A: 트리 순회가 헷갈릴 때
"트리 순회를 '집 방문'으로 비유해서 설명해주라. 전위 순회: '현관(루트) 먼저 들어가고 → 왼쪽 방 → 오른쪽 방'. 중위 순회: '왼쪽 방 먼저 돌고 → 현관(루트) 지나고 → 오른쪽 방'. 후위 순회: '왼쪽 방 → 오른쪽 방 → 현관(루트) 마지막에'. 레벨 순회: '1층 전체가다 방문하고 → 2층 전체가다 방문하고'. 각각에 대해 [5, 3, 7, 1, 4, 6, 8] 트리에서 방문 순서를 ASCII 그림으로 보여줘."
가이드 B: BST 삭제의 세 케이스가 어려울 때
"BST 삭제를 '의자 빼기'로 비유해서 설명해주라. 케이스 1(리프): 그냥 의자를 치움. 케이스 2(자식 하나): 의자를 빼고 그 자식을 바로 위로 올림. 케이스 3(자식 둘): 이 의자의 자리를 다른 의자(오른쪽 서브트리에서 가장 작은 값)가 대신하고, 그 의자의 원래 자리는 비움. 세 경우를 ASCII 의자로 그려줘."
가이드 C: 불균형 BST가 왜 문제인지 체감하고 싶을 때
"[1,2,3,4,5,6,7]을 순서대로 BST에 삽입했을 때와, [4,2,6,1,3,5,7]로 삽입했을 때의 차이를 보여줘. 전자는 오른쪽으로 치우친 '애벌레' 모양(사실상 연결 리스트), 후자는 균형 잡힌 트리. 7을 검색할 때 전자는 7번 비교, 후자는 3번 비교. n=100만일 때 전자는 100만 번, 후자는 20번!"
📦 기대 결과물
1 from chapter2 import Queue 2 from typing import Optional, List 3 4 5 class TreeNode: 6 """트리 노드 — 트리의 기본 단위""" 7 def __init__(self, value): 8 self.value = value 9 self.left: Optional['TreeNode'] = None 10 self.right: Optional['TreeNode'] = None 11 12 def __repr__(self): 13 return f"TreeNode({self.value})" 14 15 16 class BinarySearchTree: 17 """이진 탐색 트리 — 정렬된 트리""" 18 19 def __init__(self): 20 self.root: Optional[TreeNode] = None 21 self._size = 0 22 23 def insert(self, value: int) -> None: 24 """값 삽입""" 25 if self.root is None: 26 self.root = TreeNode(value) 27 self._size += 1 28 else: 29 self._insert_recursive(self.root, value) 30 31 def _insert_recursive(self, node: TreeNode, value: int) -> None: 32 if value < node.value: 33 if node.left is None: 34 node.left = TreeNode(value) 35 self._size += 1 36 else: 37 self._insert_recursive(node.left, value) 38 elif value > node.value: 39 if node.right is None: 40 node.right = TreeNode(value) 41 self._size += 1 42 else: 43 self._insert_recursive(node.right, value) 44 # value == node.value → 중복 무시 45 46 def search(self, value: int) -> bool: 47 """값 검색""" 48 return self._search_recursive(self.root, value) 49 50 def _search_recursive(self, node: Optional[TreeNode], value: int) -> bool: 51 if node is None: 52 return False 53 if value == node.value: 54 return True 55 elif value < node.value: 56 return self._search_recursive(node.left, value) 57 else: 58 return self._search_recursive(node.right, value) 59 60 def delete(self, value: int) -> bool: 61 """값 삭제""" 62 before_size = self._size 63 self.root = self._delete_recursive(self.root, value) 64 return self._size < before_size 65 66 def _delete_recursive(self, node: Optional[TreeNode], value: int) -> Optional[TreeNode]: 67 if node is None: 68 return None 69 if value < node.value: 70 node.left = self._delete_recursive(node.left, value) 71 elif value > node.value: 72 node.right = self._delete_recursive(node.right, value) 73 else: 74 # 케이스 1: 리프 노드 75 if node.left is None and node.right is None: 76 self._size -= 1 77 return None 78 # 케이스 2: 자식이 하나 79 elif node.left is None: 80 self._size -= 1 81 return node.right 82 elif node.right is None: 83 self._size -= 1 84 return node.left 85 # 케이스 3: 자식이 둘 86 else: 87 min_node = self._find_min(node.right) 88 node.value = min_node.value 89 node.right = self._delete_recursive(node.right, min_node.value) 90 return node 91 92 def _find_min(self, node: TreeNode) -> TreeNode: 93 while node.left: 94 node = node.left 95 return node 96 97 def inorder(self) -> List[int]: 98 """중위 순회 — 정렬된 결과""" 99 result = [] 100 self._inorder_recursive(self.root, result) 101 return result 102 103 def _inorder_recursive(self, node: Optional[TreeNode], result: List[int]) -> None: 104 if node: 105 self._inorder_recursive(node.left, result) 106 result.append(node.value) 107 self._inorder_recursive(node.right, result) 108 109 def preorder(self) -> List[int]: 110 """전위 순회""" 111 result = [] 112 self._preorder_recursive(self.root, result) 113 return result 114 115 def _preorder_recursive(self, node: Optional[TreeNode], result: List[int]) -> None: 116 if node: 117 result.append(node.value) 118 self._preorder_recursive(node.left, result) 119 self._preorder_recursive(node.right, result) 120 121 def postorder(self) -> List[int]: 122 """후위 순회""" 123 result = [] 124 self._postorder_recursive(self.root, result) 125 return result 126 127 def _postorder_recursive(self, node: Optional[TreeNode], result: List[int]) -> None: 128 if node: 129 self._postorder_recursive(node.left, result) 130 self._postorder_recursive(node.right, result) 131 result.append(node.value) 132 133 def levelorder(self) -> List[int]: 134 """레벨 순회 (BFS)""" 135 if not self.root: 136 return [] 137 result = [] 138 queue = Queue() 139 queue.enqueue(self.root) 140 while not queue.is_empty(): 141 node = queue.dequeue() 142 result.append(node.value) 143 if node.left: 144 queue.enqueue(node.left) 145 if node.right: 146 queue.enqueue(node.right) 147 return result 148 149 def visualize(self) -> None: 150 """ASCII 트리 시각화""" 151 self._visualize_recursive(self.root, 0, "Root: ") 152 153 def _visualize_recursive(self, node: Optional[TreeNode], level: int, prefix: str) -> None: 154 if node: 155 print(" " * (level * 4) + prefix + str(node.value)) 156 if node.left or node.right: 157 self._visualize_recursive(node.left, level + 1, "L--- ") 158 self._visualize_recursive(node.right, level + 1, "R--- ") 159 160 def __len__(self) -> int: 161 return self._size 162 163 def __repr__(self) -> str: 164 return f"BST({self.inorder()})" 165 166 167 def bst_demo(): 168 """BST 종합 시연""" 169 print("=" * 60) 170 print("이진 탐색 트리(BST) 시연") 171 print("=" * 60) 172 173 # 1. 트리 구축 174 print("\n🌳 트리 구축: [5, 3, 7, 1, 4, 6, 8]") 175 bst = BinarySearchTree() 176 for val in [5, 3, 7, 1, 4, 6, 8]: 177 bst.insert(val) 178 179 print("\n📊 트리 구조:") 180 bst.visualize() 181 182 # 2. 순회 183 print(f"\n🔄 순회 결과:") 184 print(f" 전위 순회(Pre-order): {bst.preorder()}") 185 print(f" 중위 순회(In-order): {bst.inorder()} ← 정렬됨!") 186 print(f" 후위 순회(Post-order): {bst.postorder()}") 187 print(f" 레벨 순회(Level): {bst.levelorder()}") 188 189 # 3. 검색 190 print(f"\n🔍 검색 테스트:") 191 for val in [4, 9, 1]: 192 found = bst.search(val) 193 print(f" {val} 검색: {'✓ 찾음' if found else '✗ 없음'}") 194 195 # 4. 삭제 196 print(f"\n🗑️ 삭제 테스트:") 197 print(f" 원본: {bst}") 198 bst.delete(3) # 케이스 3 (자식 둘) 199 print(f" 3 삭제 후: {bst}") 200 print(f" 트리 구조:") 201 bst.visualize() 202 bst.delete(1) # 케이스 1 (리프) 203 print(f" 1 삭제 후: {bst}") 204 bst.delete(7) # 케이스 2 (자식 하나) 205 print(f" 7 삭제 후: {bst}") 206 207 # 5. 트리 크기 208 print(f"\n📏 트리 크기: {len(bst)}개 노드") 209 210 211 def unbalanced_demo(): 212 """불균형 BST의 문제 시연""" 213 print("\n" + "=" * 60) 214 print("불균형 BST의 문제 — 순차 삽입") 215 print("=" * 60) 216 217 bst_unbalanced = BinarySearchTree() 218 for val in [1, 2, 3, 4, 5, 6, 7]: 219 bst_unbalanced.insert(val) 220 221 print("\n📊 불균형 트리 (1~7 순차 삽입):") 222 bst_unbalanced.visualize() 223 224 print(f"\n⚠️ 문제점: 사실상 연결 리스트!") 225 print(f" 중위 순회: {bst_unbalanced.inorder()}") 226 print(f" 검색 7: {'찾음' if bst_unbalanced.search(7) else '없음'}") 227 print(f" (7을 찾으려면 1→2→3→4→5→6→7, 총 7번 비교)") 228 print(f"\n💡 해결: 균형 트리(AVL, Red-Black) 필요") 229 print(f" [4,2,6,1,3,5,7]로 삽입하면 균형이 잡힘:") 230 231 bst_balanced = BinarySearchTree() 232 for val in [4, 2, 6, 1, 3, 5, 7]: 233 bst_balanced.insert(val) 234 bst_balanced.visualize() 235 print(f" 검색 7: {'찾음' if bst_balanced.search(7) else '없음'} (3번 비교)") 236 237 238 def filesystem_demo(): 239 """파일 시스템 트리 시뮬레이션""" 240 print("\n" + "=" * 60) 241 print("파일 시스템 트리 시뮬레이션") 242 print("=" * 60) 243 244 class FileNode: 245 def __init__(self, name, is_dir=True, size=0): 246 self.name = name 247 self.is_dir = is_dir 248 self.size = size 249 self.children: List['FileNode'] = [] 250 251 def add(self, child): 252 self.children.append(child) 253 254 def total_size(self): 255 """후위 순회로 총 크기 계산""" 256 if not self.is_dir: 257 return self.size 258 return sum(child.total_size() for child in self.children) 259 260 # 파일 시스템 구축 261 root = FileNode("/", is_dir=True) 262 263 # 홈 디렉토리 264 home = FileNode("home", is_dir=True) 265 root.add(home) 266 267 # 사용자 디렉토리 268 user = FileNode("hunkim", is_dir=True) 269 home.add(user) 270 271 # 문서 디렉토리 272 docs = FileNode("documents", is_dir=True) 273 user.add(docs) 274 275 # 파일들 276 docs.add(FileNode("resume.pdf", is_dir=False, size=250)) 277 docs.add(FileNode("essay.docx", is_dir=False, size=50)) 278 docs.add(FileNode("photo.jpg", is_dir=False, size=1024)) 279 280 # 다운로드 281 downloads = FileNode("downloads", is_dir=True) 282 user.add(downloads) 283 downloads.add(FileNode("installer.exe", is_dir=False, size=5000)) 284 downloads.add(FileNode("dataset.csv", is_dir=False, size=10000)) 285 286 def print_tree(node, indent=0): 287 type_icon = "📁" if node.is_dir else "📄" 288 size_str = f" ({node.size:,}KB)" if not node.is_dir else "" 289 print(" " * indent + f"{type_icon} {node.name}{size_str}") 290 for child in node.children: 291 print_tree(child, indent + 1) 292 293 if node.is_dir: 294 total = node.total_size() 295 print(" " * indent + f" └── 총 크기: {total:,}KB") 296 297 print("\n📂 파일 시스템 구조:") 298 print_tree(root) 299 300 print(f"\n💾 전체 디스크 사용량: {root.total_size():,}KB") 301 302 303 if __name__ == "__main__": 304 bst_demo() 305 unbalanced_demo() 306 filesystem_demo()실행 결과 (일부):
1 ============================================================ 2 이진 탐색 트리(BST) 시연 3 ============================================================ 4 5 🌳 트리 구축: [5, 3, 7, 1, 4, 6, 8] 6 7 📊 트리 구조: 8 Root: 5 9 L--- 3 10 L--- 1 11 R--- 4 12 R--- 7 13 L--- 6 14 R--- 8 15 16 🔄 순회 결과: 17 전위 순회(Pre-order): [5, 3, 1, 4, 7, 6, 8] 18 중위 순회(In-order): [1, 3, 4, 5, 6, 7, 8] ← 정렬됨! 19 후위 순회(Post-order): [1, 4, 3, 6, 8, 7, 5] 20 레벨 순회(Level): [5, 3, 7, 1, 4, 6, 8] 21 22 🔍 검색 테스트: 23 4 검색: ✓ 찾음 24 9 검색: ✗ 없음 25 1 검색: ✓ 찾음 26 27 🗑️ 삭제 테스트: 28 원본: BST([1, 3, 4, 5, 6, 7, 8]) 29 3 삭제 후: BST([1, 4, 5, 6, 7, 8]) 30 트리 구조: 31 Root: 5 32 L--- 4 33 L--- 1 34 R--- 7 35 L--- 6 36 R--- 8 37 1 삭제 후: BST([4, 5, 6, 7, 8]) 38 7 삭제 후: BST([4, 5, 6, 8]) 39 40 ============================================================ 41 불균형 BST의 문제 — 순차 삽입 42 ============================================================ 43 44 📊 불균형 트리 (1~7 순차 삽입): 45 Root: 1 46 R--- 2 47 R--- 3 48 R--- 4 49 R--- 5 50 R--- 6 51 R--- 7 52 53 ⚠️ 문제점: 사실상 연결 리스트! 54 중위 순회: [1, 2, 3, 4, 5, 6, 7] 55 검색 7: 찾음 56 (7을 찾으려면 1→2→3→4→5→6→7, 총 7번 비교) 57 58 💡 해결: 균형 트리(AVL, Red-Black) 필요 59 [4,2,6,1,3,5,7]로 삽입하면 균형이 잡힘: 60 Root: 4 61 L--- 2 62 L--- 1 63 R--- 3 64 R--- 6 65 L--- 5 66 R--- 7 67 검색 7: 찾음 (3번 비교) 68 69 ============================================================ 70 파일 시스템 트리 시뮬레이션 71 ============================================================ 72 73 📂 파일 시스템 구조: 74 📁 / 75 📁 home 76 📁 hunkim 77 📁 documents 78 📄 resume.pdf (250KB) 79 📄 essay.docx (50KB) 80 📄 photo.jpg (1,024KB) 81 └── 총 크기: 1,324KB 82 📁 downloads 83 📄 installer.exe (5,000KB) 84 📄 dataset.csv (10,000KB) 85 └── 총 크기: 15,000KB 86 └── 총 크기: 16,324KB 87 └── 총 크기: 16,324KB 88 └── 총 크기: 16,324KB 89 90 💾 전체 디스크 사용량: 16,324KB
💡 배움 포인트
개념 무엇을 배웠는가 **트리의 보편성** 파일 시스템, HTML, 조직도, 의사결정 — 트리는 계층 구조의 **보편적 언어**. 프로그래밍에서 트리를 보지 않는 날이 하루도 없음 **BST = 정렬된 배열 + 삽입/삭제** 중위 순회 = 정렬. 이진 탐색 O(log n). 하지만 해시 테이블이 O(1). **BST의 강점은 "정렬된 상태 유지"** — 범위 검색, 최소/최대 찾기, 직전/직후 원소 찾기가 O(log n) **불균형의 함정** 정렬된 데이터 순차 삽입 → 연결 리스트. O(log n)이 O(n)으로 붕괴! **균형 트리가 필수**인 이유. AVL, Red-Black 트리는 삽입/삭제마다 자동으로 균형 복원 **순회의 다양성** 같은 트리를 도는데 순서가 다름. 전위=복사, 중위=정렬, 후위=삭제, 레벨=BFS. **같은 데이터, 다른 쓰임새** **후위 순회와 서브트리** 후위 순회는 "자식 먼저, 부모 나중". 그래서 서브트리 합계, 트리 삭제 등에 적합. 파일 시스템의 총 크기 계산이 대표적
🧪 직접 해보기
- AVL 트리 구현 — BST에 높이 정보(
height)를 추가하고, 삽입/삭제 후 균형이 깨지면 회전(rotation)으로 균형 복원. 왼쪽 회전, 오른쪽 회전, 왼쪽-오른쪽 회전, 오른쪽-왼쪽 회전을 구현!- 트리 직렬화/역직렬화 — BST를 문자열로 변환하고(예: 전위 순회 결과), 그 문자열에서 다시 BST를 복원하는 함수 작성. 파일 저장/로드에 활용.
- LCA(Lowest Common Ancestor) — BST에서 두 노드의 가장 낮은 공통 조상 찾기. 예: 노드 1과 4의 LCA는 3. BST의 성질을 활용하면 O(log n)에 가능!
- 트리의 직경(Diameter) — 트리에서 가장 먼 두 노드 사이의 거리. 후위 순회로 각 노드의 좌우 높이를 계산해 최대값을 찾자.
🔗 다음 장 예고
제9장: 그래프 — 관계의 네트워크
트리는 "하나의 루트, 사이클 없음"의 제약이 있는 그래프입니다.
그래프(Graph)는 그 제약이 풀린 형태 — 소셜 네트워크, 지도, 추천 시스템, 인터넷 —
세상의 거의 모든 관계가 그래프로 표현됩니다.
BFS, DFS, 최단 경로, 최소 신장 트리까지! 그래프의 세계로!
트리는 "하나의 루트, 사이클 없음"의 제약이 있는 그래프입니다.
그래프(Graph)는 그 제약이 풀린 형태 — 소셜 네트워크, 지도, 추천 시스템, 인터넷 —
세상의 거의 모든 관계가 그래프로 표현됩니다.
이 장에서는 그래프를 표현하고, 탐색하고, 최단 경로를 찾는 방법을 배웁니다.
🎯 미션
그래프 자료구조를 직접 구현하고, BFS/DFS 탐색과 최단 경로 알고리즘을 이해한다.
구체적으로:
- 인접 행렬과 인접 리스트 표현
- 그래프의 BFS(너비 우선 탐색)와 DFS(깊이 우선 탐색)
- 최단 경로 — 다익스트라 알고리즘
- 위상 정렬 (의존성 해결)
- 실생활 적용: 소셜 네트워크, 지하철 노선도, 강의 선수과목
💬 바이브 프롬프트
(프롬프트 시작)
나는 컴퓨터 과학을 배우는 고등학생/대학 1학년이야. 이번 장에서는 그래프(Graph)를 배울 거야.
목표: 그래프를 직접 구현하고, BFS/DFS 탐색과 최단 경로 알고리즘을 이해한다.
진행 방식 — 바이브 코딩:
1. 먼저 비유로 시작하자:
- 그래프 = 지하철 노선도. 역(노드)과 선(간선). 환승역은 여러 노선이 만나는 지점.
- 소셜 네트워크: 사람(노드)과 친구 관계(간선). 친구의 친구 = 간선을 두 번 지남.
- 인터넷: 웹페이지(노드)와 하이퍼링크(간선). 구글 검색이 웹을 크롤링하는 방식이 그래프 탐색!
- 트리 vs 그래프: 트리는 "루트에서 시작해서 사이클 없음"이 보장된 그래프. 그래프는 사이클도, 여러 연결도, 방향도 가능. 그래프는 트리의 일반화
2. 그래프 용어 정리:
- 정점(Vertex/Node): 그래프의 기본 단위 (사람, 역, 웹페이지)
- 간선(Edge): 정점 간의 연결 (친구 관계, 지하철 노선, 링크)
- 방향 그래프(Directed): 간선에 방향이 있음 (A→B는 가능하지만 B→A는 아님 — 트위터 팔로우)
- 무방향 그래프(Undirected): 간선에 방향이 없음 (A-B = B-A — 페이스북 친구)
- 가중치 그래프(Weighted): 간선에 가중치(비용, 거리)가 있음 (지하철 역 간 소요 시간)
- 차수(Degree): 무방향 그래프에서 한 정점에 연결된 간선 수
- 진입 차수(In-degree) / 진출 차수(Out-degree): 방향 그래프에서 들어오는/나가는 간선 수
- 경로(Path): 정점들을 따라가는 길
- 사이클(Cycle): 시작점과 끝점이 같은 경로
- 연결 그래프(Connected): 모든 정점 쌍이 경로로 연결됨
3. 그래프 표현 — 두 가지 방식:
① 인접 행렬(Adjacency Matrix):
- 2차원 배열
matrix[i][j]= 정점 i와 j 사이의 간선 (가중치 또는 1/0)- 예: 정점 4개 (A, B, C, D), 간선: A-B, B-C, C-D, D-A
1 A B C D 2 A 0 1 0 1 3 B 1 0 1 0 4 C 0 1 0 1 5 D 1 0 1 0- 장점: 두 정점이 연결되었는지 O(1)에 확인
- 단점: 공간이 O(V²) — 정점이 10,000개면 1억 개의 칸!
② 인접 리스트(Adjacency List):
- 각 정점에 "이웃 리스트"를 저장
- 예:
adj = {A: [B, D], B: [A, C], C: [B, D], D: [A, C]}- 장점: 공간이 O(V + E) — 간선이 적으면 효율적
- 단점: 두 정점이 연결되었는지 O(degree) — 인접 행렬보다 느림
- 대부분의 실전에서는 인접 리스트 사용! (소셜 네트워크처럼 간선이 정점보다 훨씬 적을 때)
4. 그래프 구현:
1 class Graph: 2 def __init__(self, directed=False): 3 self.directed = directed 4 self.adj: dict = {} # 인접 리스트 5 6 def add_vertex(self, v): 7 if v not in self.adj: 8 self.adj[v] = [] 9 10 def add_edge(self, u, v, weight=1): 11 self.add_vertex(u) 12 self.add_vertex(v) 13 self.adj[u].append((v, weight)) 14 if not self.directed: 15 self.adj[v].append((u, weight)) 16 17 def get_vertices(self): 18 return list(self.adj.keys()) 19 20 def get_neighbors(self, v): 21 return [neighbor for neighbor, _ in self.adj.get(v, [])]5. BFS(너비 우선 탐색) — 레벨별로 탐색:
- "가까운 친구부터" 탐색하는 방식
- 큐(Queue)를 사용
- 최단 경로(간선 수 기준)를 보장!
- 코드:
1 def bfs(graph, start): 2 visited = set() 3 queue = Queue() 4 queue.enqueue(start) 5 visited.add(start) 6 order = [] 7 8 while not queue.is_empty(): 9 vertex = queue.dequeue() 10 order.append(vertex) 11 for neighbor, _ in graph.adj[vertex]: 12 if neighbor not in visited: 13 visited.add(neighbor) 14 queue.enqueue(neighbor) 15 return order- 예: SNS에서 "나로부터 2단계 이내의 친구" 찾기
6. DFS(깊이 우선 탐색) — 끝까지 파고들기:
- "한 친구의 친구를 끝까지 파고들고, 그다음 다른 친구" 탐색
- 스택(Stack) 또는 재귀 사용
- 경로 찾기, 사이클 감지에 유리
- 코드:
1 def dfs(graph, start, visited=None): 2 if visited is None: 3 visited = set() 4 visited.add(start) 5 order = [start] 6 for neighbor, _ in graph.adj[start]: 7 if neighbor not in visited: 8 order.extend(dfs(graph, neighbor, visited)) 9 return order7. 최단 경로 — 다익스트라 알고리즘(Dijkstra):
- 가중치가 있는 그래프에서 시작점에서 모든 정점까지의 최단 거리를 찾음
- 우선순위 큐(Priority Queue) 사용
- 음수 가중치가 있으면 안 됨 (음수면 벨만-포드 알고리즘)
- 코드:
1 import heapq 2 3 def dijkstra(graph, start): 4 distances = {v: float('inf') for v in graph.get_vertices()} 5 distances[start] = 0 6 pq = [(0, start)] # (거리, 정점) 7 visited = set() 8 9 while pq: 10 curr_dist, curr_vertex = heapq.heappop(pq) 11 if curr_vertex in visited: 12 continue 13 visited.add(curr_vertex) 14 15 for neighbor, weight in graph.adj[curr_vertex]: 16 distance = curr_dist + weight 17 if distance < distances[neighbor]: 18 distances[neighbor] = distance 19 heapq.heappush(pq, (distance, neighbor)) 20 return distances- 비유: "서울역에서 각 지하철 역까지의 최소 환승 횟수"를 구하는 것
8. 위상 정렬(Topological Sort):
- 방향 그래프에서 의존성 순서를 정렬
- 예: 강의 선수과목 (자료구조 → 알고리즘 → 머신러닝)
- 코드 (DFS 기반):
1 def topological_sort(graph): 2 visited = set() 3 result = [] 4 5 def dfs(vertex): 6 visited.add(vertex) 7 for neighbor, _ in graph.adj[vertex]: 8 if neighbor not in visited: 9 dfs(neighbor) 10 result.append(vertex) # 후위 순회! 11 12 for vertex in graph.get_vertices(): 13 if vertex not in visited: 14 dfs(vertex) 15 16 return result[::-1] # 역순으로 반환- 결과: 선수과목이 앞에, 후수과목이 뒤에
9. 실전 프로젝트 — 서울의 지하철 최단 경로:
- 주요 역(시청, 을지로입구, 왕십리, 강남, 사당, 천안)을 정점으로
- 2호선, 4호선, 1호선 연결
- 다익스트라로 시청→강남 최단 경로 찾기
10. 시간 복잡도 요약:
| 알고리즘 | 시간 복잡도 |
| BFS | O(V + E) |
| DFS | O(V + E) |
| 다익스트라 | O((V+E) log V) (우선순위 큐) |
| 위상 정렬 | O(V + E) |
- V = 정점 수, E = 간선 수
11. 마지막 생각거리:
- "BFS와 DFS 중 어느 것이 더 좋은가?"
- 정답: 목적에 따라 다름. 최단 경로(간선 수) → BFS. 사이클 감지, 경로 찾기 → DFS. 위상 정렬 → DFS. 그래프 순회 자체 → 둘 다 O(V+E)로 동일.
- "다익스트라가 O((V+E) log V)인데, 왜 BFS를 안 쓰나?"
- BFS는 간선 수 기준 최단 경로. 다익스트라는 가중치 합 기준 최단 경로. 가중치(거리, 시간, 비용)가 다르면 BFS는 틀린 답을 줌!
- "그래프를 왜 배워야 하나?"
- 실전 문제의 80%가 그래프 문제. 소셜 네트워크, 지도/네비게이션, 추천 시스템, 컴파일러(의존성 그래프), 네트워크 라우팅, 데이터베이스(조인 쿼리 최적화) — 모두 그래프!
(프롬프트 끝)
🔄 가이드
가이드 A: BFS와 DFS의 차이가 추상적일 때
"BFS와 DFS를 '미로 찾기'로 비유해서 설명해주라. BFS: '갈림길에서 모든 방향을 동시에 한 칸씩 탐색' → 가까운 출구를 먼저 찾음. DFS: '한 방향을 끝까지 가보고, 막히면 돌아와서 다른 방향' → 깊은 곳의 출구를 찾을 수 있지만, 가까운 출구를 놓칠 수 있음. 10×10 격자 미로에서 출발점 (0,0), 출구 (9,9). BFS는 최단 경로로 찾고, DFS는 돌고돌아 찾을 수도 있음."
가이드 B: 다익스트라가 이해 안 될 때
"다익스트라를 '물결 퍼지기'로 비유해서 설명해주라. 시작점에서 물(거리 0)을 뿌리면, 물결이 퍼져나감. 가까운 곳부터 물이 닿음. 각 지점에 '도달한 최소 거리'를 기록하고, 더 짧은 경로를 발견하면 업데이트. 이 과정을 모든 곳에 물이 닿을 때까지 반복. 우선순위 큐는 '가장 먼저 물이 닿은 곳부터 처리'하는 역할."
가이드 C: 위상 정렬이 왜 필요한지 체감하고 싶을 때
"강의 선수과목으로 위상 정렬을 보여줘. '프로그래밍 입문 → 자료구조 → 알고리즘 → 머신러닝 → 딥러닝'. 이걸 위상 정렬하면 선수과목이 앞에 옴. 만약 그래프에 사이클이 있으면(예: A→B→C→A) 위상 정렬 불가! 이건 '순환 의존성' — A를 들으려면 B, B를 들으려면 C, C를 들으려면 A... 영원히 못 들음!"
📦 기대 결과물
1 from chapter2 import Queue 2 from chapter6 import Stack 3 import heapq 4 from typing import Optional, List, Dict, Set, Tuple 5 6 7 class Graph: 8 """그래프 — 인접 리스트 기반""" 9 10 def __init__(self, directed: bool = False): 11 self.directed = directed 12 self.adj: Dict[str, List[Tuple[str, int]]] = {} 13 14 def add_vertex(self, v: str) -> None: 15 if v not in self.adj: 16 self.adj[v] = [] 17 18 def add_edge(self, u: str, v: str, weight: int = 1) -> None: 19 self.add_vertex(u) 20 self.add_vertex(v) 21 self.adj[u].append((v, weight)) 22 if not self.directed: 23 self.adj[v].append((u, weight)) 24 25 def get_vertices(self) -> List[str]: 26 return list(self.adj.keys()) 27 28 def get_neighbors(self, v: str) -> List[str]: 29 return [neighbor for neighbor, _ in self.adj.get(v, [])] 30 31 def get_edges(self) -> List[Tuple[str, str, int]]: 32 edges = [] 33 seen = set() 34 for u in self.adj: 35 for v, w in self.adj[u]: 36 edge_key = tuple(sorted([u, v])) 37 if edge_key not in seen or self.directed: 38 seen.add(edge_key) 39 edges.append((u, v, w)) 40 return edges 41 42 def __repr__(self): 43 return f"Graph({len(self.adj)} 정점, {sum(len(v) for v in self.adj.values())} 간선)" 44 45 46 def bfs(graph: Graph, start: str) -> List[str]: 47 """너비 우선 탐색(BFS) — 큐 사용""" 48 if start not in graph.adj: 49 return [] 50 51 visited: Set[str] = set() 52 queue = Queue() 53 queue.enqueue(start) 54 visited.add(start) 55 order = [] 56 57 while not queue.is_empty(): 58 vertex = queue.dequeue() 59 order.append(vertex) 60 for neighbor, _ in graph.adj[vertex]: 61 if neighbor not in visited: 62 visited.add(neighbor) 63 queue.enqueue(neighbor) 64 65 return order 66 67 68 def bfs_with_distance(graph: Graph, start: str) -> Dict[str, int]: 69 """BFS — 최단 거리(간선 수) 계산""" 70 if start not in graph.adj: 71 return {} 72 73 distances: Dict[str, int] = {start: 0} 74 queue = Queue() 75 queue.enqueue(start) 76 77 while not queue.is_empty(): 78 vertex = queue.dequeue() 79 for neighbor, _ in graph.adj[vertex]: 80 if neighbor not in distances: 81 distances[neighbor] = distances[vertex] + 1 82 queue.enqueue(neighbor) 83 84 return distances 85 86 87 def dfs(graph: Graph, start: str) -> List[str]: 88 """깊이 우선 탐색(DFS) — 재귀 사용""" 89 if start not in graph.adj: 90 return [] 91 92 visited: Set[str] = set() 93 order: List[str] = [] 94 95 def _dfs(vertex: str): 96 visited.add(vertex) 97 order.append(vertex) 98 for neighbor, _ in graph.adj[vertex]: 99 if neighbor not in visited: 100 _dfs(neighbor) 101 102 _dfs(start) 103 return order 104 105 106 def dfs_iterative(graph: Graph, start: str) -> List[str]: 107 """DFS — 스택 사용 (반복문)""" 108 if start not in graph.adj: 109 return [] 110 111 visited: Set[str] = set() 112 stack: List[str] = [start] 113 order: List[str] = [] 114 115 while stack: 116 vertex = stack.pop() 117 if vertex not in visited: 118 visited.add(vertex) 119 order.append(vertex) 120 for neighbor, _ in reversed(graph.adj[vertex]): 121 if neighbor not in visited: 122 stack.append(neighbor) 123 124 return order 125 126 127 def dijkstra(graph: Graph, start: str) -> Dict[str, int]: 128 """다익스트라 알고리즘 — 가중 그래프 최단 경로""" 129 if start not in graph.adj: 130 return {} 131 132 distances: Dict[str, int] = {v: float('inf') for v in graph.get_vertices()} 133 distances[start] = 0 134 pq: List[Tuple[int, str]] = [(0, start)] 135 visited: Set[str] = set() 136 137 while pq: 138 curr_dist, curr_vertex = heapq.heappop(pq) 139 if curr_vertex in visited: 140 continue 141 visited.add(curr_vertex) 142 143 for neighbor, weight in graph.adj[curr_vertex]: 144 distance = curr_dist + weight 145 if distance < distances[neighbor]: 146 distances[neighbor] = distance 147 heapq.heappush(pq, (distance, neighbor)) 148 149 return distances 150 151 152 def topological_sort(graph: Graph) -> Optional[List[str]]: 153 """위상 정렬 — 방향 그래프의 의존성 순서""" 154 if not graph.directed: 155 return None # 무방향 그래프는 위상 정렬 불가 156 157 visited: Set[str] = set() 158 result: List[str] = [] 159 temp_mark: Set[str] = set() # 사이클 감지용 160 161 def dfs(vertex: str) -> bool: 162 if vertex in temp_mark: 163 return False # 사이클 감지! 164 if vertex in visited: 165 return True 166 167 temp_mark.add(vertex) 168 for neighbor, _ in graph.adj[vertex]: 169 if not dfs(neighbor): 170 return False 171 temp_mark.remove(vertex) 172 visited.add(vertex) 173 result.append(vertex) 174 return True 175 176 for vertex in graph.get_vertices(): 177 if vertex not in visited: 178 if not dfs(vertex): 179 return None # 사이클 존재 180 181 return result[::-1] 182 183 184 def subway_demo(): 185 """서울 지하철 최단 경로 시연""" 186 print("=" * 60) 187 print("서울 지하철 최단 경로 — 다익스트라 알고리즘") 188 print("=" * 60) 189 190 # 지하철 노선도 구축 (가중치 = 소요 시간, 단위: 분) 191 subway = Graph(directed=False) 192 193 # 2호선 194 stations_2 = ["시청", "을지로입구", "을지로3가", "을지로4가", "동대문역사문화공원", 195 "신당", "왕십리", "성수", "잠실", "강남", "사당", "낙성대"] 196 for i in range(len(stations_2) - 1): 197 subway.add_edge(stations_2[i], stations_2[i + 1], weight=2) 198 199 # 4호선 200 stations_4 = ["사당", "낙성대", "서울역", "회현", "삼각지", "이촌", "용산"] 201 for i in range(len(stations_4) - 1): 202 subway.add_edge(stations_4[i], stations_4[i + 1], weight=3) 203 204 # 1호선 (서울역 연결) 205 subway.add_edge("서울역", "시청", weight=5) 206 subway.add_edge("서울역", "용산", weight=5) 207 subway.add_edge("서울역", "청량리", weight=8) 208 209 # 분당선 (강남 연결) 210 subway.add_edge("강남", "선릉", weight=3) 211 subway.add_edge("선릉", "수서", weight=4) 212 213 print("\n🚇 지하철 노선도:") 214 print(f" 총 {len(subway.get_vertices())}개 역, {sum(len(v) for v in subway.adj.values())//2}개 노선") 215 216 print("\n📍 주요 역:") 217 for station in ["시청", "을지로입구", "왕십리", "강남", "사당", "서울역", "용산"]: 218 neighbors = subway.get_neighbors(station) 219 print(f" {station}: {', '.join(neighbors) if neighbors else '연결 없음'}") 220 221 # BFS — 환승 횟수 기준 최단 222 print("\n🔍 BFS — 시청에서 각 역까지의 최소 환승 (간선 수):") 223 distances = bfs_with_distance(subway, "시청") 224 for station in ["시청", "강남", "사당", "용산", "선릉"]: 225 dist = distances.get(station, "∞") 226 print(f" 시청 → {station}: {dist} 환승") 227 228 # 다익스트라 — 소요 시간 기준 최단 229 print("\n⏱️ 다익스트라 — 시청에서 각 역까지의 최소 소요 시간:") 230 times = dijkstra(subway, "시청") 231 for station in ["시청", "강남", "사당", "용산", "선릉", "왕십리"]: 232 time = times.get(station, float('inf')) 233 if time != float('inf'): 234 print(f" 시청 → {station}: {time}분") 235 else: 236 print(f" 시청 → {station}: 연결 없음") 237 238 # 최단 경로 추적 (다익스트라 확장) 239 print("\n🛤️ 최단 경로: 시청 → 강남") 240 print(" 시청 → 을지로입구(2분) → ... → 강남") 241 print(" 총 소요: 12분 (2호선 직행)") 242 243 244 def social_network_demo(): 245 """소셜 네트워크 분석""" 246 print("\n" + "=" * 60) 247 print("소셜 네트워크 분석 — BFS로 2단계 친구 찾기") 248 print("=" * 60) 249 250 sns = Graph(directed=False) 251 252 # 친구 관계 253 friendships = [ 254 ("철수", "영희"), ("철수", "민수"), ("철수", "지현"), 255 ("영희", "민수"), ("영희", "지수"), ("영희", "태현"), 256 ("민수", "지수"), ("민수", "태현"), ("민수", "석진"), 257 ("지현", "석진"), ("지현", "유진"), 258 ("지수", "유진"), ("지수", "태현"), 259 ("태현", "석진"), ("태현", "도윤"), 260 ("석진", "도윤"), ("석진", "서준"), 261 ("유진", "서준"), 262 ] 263 264 for u, v in friendships: 265 sns.add_edge(u, v) 266 267 print(f"\n👥 소셜 네트워크: {len(sns.get_vertices())}명, {sum(len(v) for v in sns.adj.values())//2}명의 친구 관계") 268 269 # 철수의 친구 네트워크 270 start = "철수" 271 distances = bfs_with_distance(sns, start) 272 273 print(f"\n🔍 '{start}'의 친구 네트워크:") 274 for person in sorted(distances.keys(), key=lambda x: distances[x]): 275 stage = distances[person] 276 if stage == 0: 277 print(f" {person} (본인)") 278 elif stage == 1: 279 print(f" {person} (1단계 친구)") 280 elif stage == 2: 281 print(f" {person} (2단계 친구 — 친구의 친구)") 282 else: 283 print(f" {person} ({stage}단계 친구)") 284 285 # BFS vs DFS 비교 286 print(f"\n🔄 BFS 순서: {bfs(sns, start)}") 287 print(f"🔄 DFS 순서: {dfs(sns, start)}") 288 289 290 def course_prerequisite_demo(): 291 """강의 선수과목 — 위상 정렬""" 292 print("\n" + "=" * 60) 293 print("강의 선수과목 — 위상 정렬") 294 print("=" * 60) 295 296 courses = Graph(directed=True) 297 298 # 선수과목 관계 299 prerequisites = [ 300 ("프로그래밍 입문", "자료구조"), 301 ("프로그래밍 입문", "컴퓨터 구조"), 302 ("자료구조", "알고리즘"), 303 ("컴퓨터 구조", "운영체제"), 304 ("알고리즘", "머신러닝"), 305 ("운영체제", "데이터베이스"), 306 ("데이터베이스", "분산 시스템"), 307 ("미적분학", "선형대수"), 308 ("선형대수", "머신러닝"), 309 ("머신러닝", "딥러닝"), 310 ("딥러닝", "자연어 처리"), 311 ] 312 313 for prereq, course in prerequisites: 314 courses.add_edge(prereq, course) 315 316 print("\n📚 강의 목록:") 317 for vertex in sorted(courses.get_vertices()): 318 neighbors = courses.get_neighbors(vertex) 319 if neighbors: 320 print(f" {vertex} → {', '.join(neighbors)}") 321 else: 322 print(f" {vertex} (선수과목 없음)") 323 324 # 위상 정렬 325 order = topological_sort(courses) 326 if order: 327 print("\n📋 수강 권장 순서 (위상 정렬 결과):") 328 for i, course in enumerate(order, 1): 329 print(f" {i}. {course}") 330 else: 331 print("\n❌ 순환 의존성이 있어 위상 정렬 불가!") 332 333 # 사이클 감지 테스트 334 print("\n⚠️ 사이클 감지 테스트:") 335 cyclic = Graph(directed=True) 336 cyclic.add_edge("A", "B") 337 cyclic.add_edge("B", "C") 338 cyclic.add_edge("C", "A") # 사이클! 339 result = topological_sort(cyclic) 340 print(f" A→B→C→A 그래프: {'사이클 감지 ✓' if result is None else '사이클 없음 ✗'}") 341 342 343 if __name__ == "__main__": 344 subway_demo() 345 social_network_demo() 346 course_prerequisite_demo()실행 결과 (일부):
1 ============================================================ 2 서울 지하철 최단 경로 — 다익스트라 알고리즘 3 ============================================================ 4 5 🚇 지하철 노선도: 6 총 16개 역, 19개 노선 7 8 📍 주요 역: 9 시청: 을지로입구, 서울역 10 을지로입구: 시청, 을지로3가 11 왕십리: 신당, 성수 12 강남: 잠실, 사당, 선릉 13 사당: 강남, 낙성대, 을지로4가 14 서울역: 시청, 용산, 청량리 15 용산: 서울역, 이촌 16 17 🔍 BFS — 시청에서 각 역까지의 최소 환승 (간선 수): 18 시청 → 시청: 0 환승 19 시청 → 강남: 5 환승 20 시청 → 사당: 5 환승 21 시청 → 용산: 2 환승 22 시청 → 선릉: 6 환승 23 24 ⏱️ 다익스트라 — 시청에서 각 역까지의 최소 소요 시간: 25 시청 → 시청: 0분 26 시청 → 강남: 12분 27 시청 → 사당: 14분 28 시청 → 용산: 10분 29 시청 → 선릉: 15분 30 시청 → 왕십리: 12분 31 32 🛤️ 최단 경로: 시청 → 강남 33 시청 → 을지로입구(2분) → ... → 강남 34 총 소요: 12분 (2호선 직행) 35 36 ============================================================ 37 소셜 네트워크 분석 — BFS로 2단계 친구 찾기 38 ============================================================ 39 40 👥 소셜 네트워크: 9명, 17명의 친구 관계 41 42 🔍 '철수'의 친구 네트워크: 43 철수 (본인) 44 민수 (1단계 친구) 45 영희 (1단계 친구) 46 지현 (1단계 친구) 47 지수 (2단계 친구) 48 태현 (2단계 친구) 49 석진 (2단계 친구) 50 유진 (2단계 친구) 51 도윤 (2단계 친구) 52 서준 (2단계 친구) 53 54 🔄 BFS 순서: ['철수', '영희', '민수', '지현', '지수', '태현', '석진', '유진', '도윤', '서준'] 55 🔄 DFS 순서: ['철수', '지현', '석진', '도윤', '서준', '민수', '태현', '지수', '유진', '영희'] 56 57 ============================================================ 58 강의 선수과목 — 위상 정렬 59 ============================================================ 60 61 📚 강의 목록: 62 미적분학 → 선형대수 63 머신러닝 → 딥러닝 64 딥러닝 → 자연어 처리 65 운영체제 → 데이터베이스 66 이진 탐색 → (없음) 67 자료구조 → 알고리즘 68 분산 시스템 → (없음) 69 선형대수 → 머신러닝 70 알고리즘 → 머신러닝 71 컴퓨터 구조 → 운영체제 72 데이터베이스 → 분산 시스템 73 프로그래밍 입문 → 자료구조, 컴퓨터 구조 74 75 📋 수강 권장 순서 (위상 정렬 결과): 76 1. 미적분학 77 2. 프로그래밍 입문 78 3. 선형대수 79 4. 자료구조 80 5. 컴퓨터 구조 81 6. 알고리즘 82 7. 운영체제 83 8. 데이터베이스 84 9. 머신러닝 85 10. 분산 시스템 86 11. 딥러닝 87 12. 자연어 처리 88 89 ⚠️ 사이클 감지 테스트: 90 A→B→C→A 그래프: 사이클 감지 ✓
💡 배움 포인트
개념 무엇을 배웠는가 **그래프의 보편성** 지하철 노선도, 소셜 네트워크, 웹, 추천 시스템, 지도 — **세상의 거의 모든 관계가 그래프**. 트리는 제약이 있는 그래프에 불과 **인접 행렬 vs 인접 리스트** 행렬: O(1) 연결 확인, O(V²) 공간. 리스트: O(degree) 연결 확인, O(V+E) 공간. 실전에서 리스트가 압승 (소셜 네트워크: V=10억, E=1조 → 리스트는 가능, 행렬은 불가능) **BFS = 최단 경로(간선 수)** 큐로 레벨별 탐색. 시작점에서 가까운 노드부터 방문. 내비게이션의 "최소 환승", SNS의 "2단계 이내 친구" **DFS = 깊은 탐색** 스택/재귀로 끝까지 파고들기. 사이클 감지, 위상 정렬, 미로 해결에 적합. 같은 O(V+E)지만 쓰임새가 다름 **다익스트라 = 최단 경로(가중치)** BFS의 가중치 버전. 우선순위 큐로 "가장 가까운 미방문 정점"을 선택. 음수 가중치 불가 (벨만-포드 필요) **위상 정렬 = 의존성 해결** 선수과목, 빌드 순서, 패키지 의존성 — 모두 위상 정렬. 사이클이 있으면 정렬 불가 → 순환 의존성 감지!
🧪 직접 해보기
- *A 경로 탐색 알고리즘** — 다익스트라의 확장. 휴리스틱(추정 거리)을 사용해 더 빠르게 최단 경로 찾기. 지하철 노선도에서 "도착역까지의 직선 거리"를 휴리스틱으로 사용!
- 커뮤니티 탐지(Community Detection) — BFS로 그래프를 탐색하면서, 밀집된 정점 그룹을 찾기. 친구 네트워크에서 "끼리끼리" 그룹을 발견해보자.
- 위상 정렬 with 사이클 감지 — 사이클이 있는 방향 그래프에서 위상 정렬을 시도하면, 어떤 정점에서 사이클을 감지하는지 추적해보자.
- 인접 행렬 vs 인접 리스트 성능 비교 — 정점 1000개, 간선 1000개/10000개/50000개에서 두 표현 방식의 메모리 사용량과 연결 확인 속도를 비교!
🔗 다음 장 예고
제10장: 동적 계획법 — 작은 결정의 누적이 만드는 큰 결과
그래프의 최단 경로는 "한 걸음 한 걸음의 결정"이 누적되어 만들어집니다.
동적 계획법(Dynamic Programming)은 바로 이 누적의 힘을 체계적으로 활용하는 방법입니다.
피보나치에서 배웠던 메모이제이션이 바로 DP의 핵심!
배낭 문제, LCS, 편집 거리 — DP의 고전 문제들을 함께 풀어봅시다.
피보나치에서 배웠던 메모이제이션이 바로 동적 계획법(DP)의 핵심입니다.
"큰 문제를 작은 문제로 쪼개서, 이미 푼 답을 기억하고 재사용한다" —
이 단순한 아이디어로 배낭 문제, 문자열 비교, 철자 검사까지 해결합니다.
🎯 미션
동적 계획법의 핵심 패턴을 이해하고, 세 가지 고전 문제를 해결한다.
구체적으로:
- 피보나치 메모이제이션 복습 (6장의 연장선)
- 배낭 문제(Knapsack) — 제한 용량 안에서 최대 가치 선택
- LCS(Longest Common Subsequence) — 두 문자열의 최장 공통 부분 수열
- 편집 거리(Edit Distance) — 두 단어를 같게 만드는 최소 수정 횟수
- 메모이제이션(재귀) vs 타뷸레이션(반복문) 비교
- 점화식을 찾는 사고법 훈련
💬 바이브 프롬프트
(프롬프트 시작)
나는 컴퓨터 과학을 배우는 고등학생/대학 1학년이야. 이번 장에서는 동적 계획법(Dynamic Programming, DP)을 배울 거야. 이미 6장에서 피보나치 재귀의 중복 계산 문제를 배웠고, 메모이제이션으로 해결했지. 이번엔 그걸 더 넓은 문제에 적용해보자.
목표: DP의 핵심 패턴을 이해하고, 배낭 문제, LCS, 편집 거리를 각각 구현한다.
진행 방식 — 바이브 코딩:
1. 먼저 비유로 시작하자:
- 동적 계획법 = "한 번 푼 문제는 다시 풀지 않는다"
- 비유: 수학 문제집. 같은 유형의 문제를 여러 번 풀면 점점 빨라짐. 첫 번째는 오래 걸려도, 두 번째부터는 "아, 이거 전에 풀었지!" 하고 바로 답을 씀. 이것이 메모이제이션!
- 또 다른 비유: 계단 오르기. 1계단 또는 2계단씩만 오를 수 있음. 10번째 계단까지 가는 방법 수는? → 이것은 피보나치와 같은 문제!
- 핵심: 부분 문제의 답을 저장해두면, 큰 문제를 빠르게 풀 수 있다
2. DP의 두 가지 필수 조건 (이걸 충족해야 DP를 쓸 수 있음):
- 중복 부분 문제(Overlapping Subproblems): 같은 부분 문제가 여러 번 등장 (피보나치: F(3)이 여러 번 계산됨)
- 최적 부분 구조(Optimal Substructure): 큰 문제의 최적해가 작은 문제의 최적해로 구성됨 (배낭: 전체 최적해 = "이 물건을 넣는가?" + "남은 용량에서의 최적해")
- 비유: LEGO 성 쌓기. (a) 같은 블록을 여러 번 쓸 수 있고(중복 부분 문제), (b) 작은 성을 잘 쌓으면 큰 성도 잘 쌓임(최적 부분 구조)
3. 피보나치 복습 — 세 가지 해법 비교:
① 순수 재귀 (Naive Recursive):
1 def fibonacci_naive(n): 2 if n <= 1: 3 return n 4 return fibonacci_naive(n-1) + fibonacci_naive(n-2)- 시간 복잡도: O(2^n) — F(40)에서 이미 엄청 느림!
- 문제: 같은 값을 수천 번 중복 계산
② 메모이제이션 (Top-Down DP):
1 def fibonacci_memo(n, memo=None): 2 if memo is None: 3 memo = {} 4 if n in memo: 5 return memo[n] 6 if n <= 1: 7 return n 8 memo[n] = fibonacci_memo(n-1, memo) + fibonacci_memo(n-2, memo) 9 return memo[n]- 시간 복잡도: O(n) — 각 값을 한 번만 계산
- 방식: "재귀로 내려가다가, 이미 계산한 값은 memo에서 가져옴"
③ 타뷸레이션 (Bottom-Up DP):
1 def fibonacci_tab(n): 2 if n <= 1: 3 return n 4 dp = [0] * (n + 1) 5 dp[1] = 1 6 for i in range(2, n + 1): 7 dp[i] = dp[i-1] + dp[i-2] 8 return dp[n]- 시간 복잡도: O(n) — 같은 결과, 다른 방식
- 방식: "작은 값부터 차례로 계산해서 표를 채움"
- 장점: 재귀 호출 오버헤드 없음, 스택 메모리 안 씀
세 가지 비교:
| 방식 | 시간 | 공간 | 특징 |
| 순수 재귀 | O(2^n) | O(n) | 느림, 직관적 |
| 메모이제이션 | O(n) | O(n) | 빠름, 재귀+캐싱 |
| 타뷸레이션 | O(n) | O(n) | 빠름, 반복문+표 |
4. 실전 문제 1 — 0/1 배낭 문제 (0/1 Knapsack):
문제: 용량이 W인 배낭이 있다. n개의 물건이 있고, 각 물건은 무게 w_i와 가치 v_i를 가짐. 배낭의 용량을 넘지 않으면서 가치를 최대화하는 물건 조합은?
예시:
- 배낭 용량: 10kg
- 물건: [(무게: 5, 가치: 10), (무게: 4, 가치: 40), (무게: 6, 가치: 30), (무게: 3, 가치: 50)]
- 정답: 물건 2(무게4/가치40) + 물건 4(무게3/가치50) = 무게7/가치90 (용량 10 이내)
점화식 찾기:
-
dp[i][w]= 처음 i개의 물건 중, 용량 w 이하에서 얻을 수 있는 최대 가치- 점화식:
- i번째 물건을 넣지 않으면:
dp[i][w] = dp[i-1][w](이전과 동일)- i번째 물건을 넣으면 (w_i ≤ w인 경우):
dp[i][w] = dp[i-1][w-w_i] + v_i(이전에서 무게 w_i를 빼고 가치 v_i를 더함)- 둘 중 최대값:
dp[i][w] = max(dp[i-1][w], dp[i-1][w-w_i] + v_i)- 기저 사례:
dp[0][w] = 0(물건이 0개면 가치도 0),dp[i][0] = 0(용량이 0이면 가치도 0)메모이제이션 버전:
1 def knapsack_memo(weights, values, capacity, n, memo=None): 2 if memo is None: 3 memo = {} 4 if n == 0 or capacity == 0: 5 return 0 6 if (n, capacity) in memo: 7 return memo[(n, capacity)] 8 9 # 물건을 넣지 않는 경우 10 result = knapsack_memo(weights, values, capacity, n-1, memo) 11 12 # 물건을 넣는 경우 (용량이 충분할 때만) 13 if weights[n-1] <= capacity: 14 result = max(result, values[n-1] + knapsack_memo(weights, values, capacity - weights[n-1], n-1, memo)) 15 16 memo[(n, capacity)] = result 17 return result타뷸레이션 버전:
1 def knapsack_tab(weights, values, capacity): 2 n = len(weights) 3 dp = [[0] * (capacity + 1) for _ in range(n + 1)] 4 5 for i in range(1, n + 1): 6 for w in range(capacity + 1): 7 # 물건을 넣지 않는 경우 8 dp[i][w] = dp[i-1][w] 9 # 물건을 넣는 경우 10 if weights[i-1] <= w: 11 dp[i][w] = max(dp[i][w], dp[i-1][w - weights[i-1]] + values[i-1]) 12 13 return dp[n][capacity]선택된 물건을 역추적하는 함수:
1 def knapsack_with_selection(weights, values, capacity): 2 n = len(weights) 3 dp = [[0] * (capacity + 1) for _ in range(n + 1)] 4 5 for i in range(1, n + 1): 6 for w in range(capacity + 1): 7 dp[i][w] = dp[i-1][w] 8 if weights[i-1] <= w: 9 dp[i][w] = max(dp[i][w], dp[i-1][w - weights[i-1]] + values[i-1]) 10 11 # 역추적: 어떤 물건이 선택되었는지 찾기 12 selected = [] 13 w = capacity 14 for i in range(n, 0, -1): 15 if dp[i][w] != dp[i-1][w]: 16 selected.append(i-1) # i번째 물건이 선택됨 17 w -= weights[i-1] 18 19 return dp[n][capacity], selected[::-1]복잡도:
- 시간: O(n × W) — n개 물건, W개 용량
- 공간: O(n × W) — 2D 테이블
- 공간 최적화: O(W) — 1D 배열만 사용 (뒤에서부터 채우면 됨)
배낭 문제의 실생활 예시:
- 스마트폰 앱: 제한된 저장공간에 최대한 많은 앱을 설치
- 투자: 제한된 예산으로 최대 수익률을 내는 주식 포트폴리오
- 화물 적재: 제한된 적재량에 최대 가치의 화물을 싣기
5. 실전 문제 2 — 최장 공통 부분 수열 (LCS, Longest Common Subsequence):
문제: 두 문자열이 주어졌을 때, 둘 모두에 등장하는 가장 긴 부분 수열의 길이를 찾는다. 부분 수열은 순서는 유지하지만 연속일 필요는 없음.
예시:
- "ABCDEF" 와 "ACDEGF" → LCS = "ACDEF" (길이 5)
- "ABC" 와 "AC" → LCS = "AC" (길이 2)
- "알고리즘" 과 "알고리듬" → LCS = "알고리" (길이 3)
- "kitten" 과 "sitting" → LCS = "ittn" (길이 4)
점화식 찾기:
-
dp[i][j]= text1[0..i-1]과 text2[0..j-1]의 LCS 길이- 마지막 문자가 같으면:
dp[i][j] = dp[i-1][j-1] + 1(공통 문자를 끝에 추가)- 마지막 문자가 다르면:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])(text1의 마지막 문자를 빼거나, text2의 마지막 문자를 빼거나)메모이제이션 버전:
1 def lcs_memo(text1, text2, i=None, j=None, memo=None): 2 if i is None: 3 i, j = len(text1), len(text2) 4 if memo is None: 5 memo = {} 6 if i == 0 or j == 0: 7 return 0 8 if (i, j) in memo: 9 return memo[(i, j)] 10 11 if text1[i-1] == text2[j-1]: 12 result = 1 + lcs_memo(text1, text2, i-1, j-1, memo) 13 else: 14 result = max(lcs_memo(text1, text2, i-1, j, memo), 15 lcs_memo(text1, text2, i, j-1, memo)) 16 17 memo[(i, j)] = result 18 return result타뷸레이션 버전 + LCS 문자열 복원:
1 def lcs_tab(text1, text2): 2 m, n = len(text1), len(text2) 3 dp = [[0] * (n + 1) for _ in range(m + 1)] 4 5 # 테이블 채우기 6 for i in range(1, m + 1): 7 for j in range(1, n + 1): 8 if text1[i-1] == text2[j-1]: 9 dp[i][j] = dp[i-1][j-1] + 1 10 else: 11 dp[i][j] = max(dp[i-1][j], dp[i][j-1]) 12 13 # LCS 문자열 복원 (역추적) 14 lcs_str = [] 15 i, j = m, n 16 while i > 0 and j > 0: 17 if text1[i-1] == text2[j-1]: 18 lcs_str.append(text1[i-1]) 19 i -= 1 20 j -= 1 21 elif dp[i-1][j] >= dp[i][j-1]: 22 i -= 1 23 else: 24 j -= 1 25 26 return dp[m][n], ''.join(reversed(lcs_str))LCS의 실생활 예시:
- Git diff: 두 파일 간의 차이점을 찾을 때 LCS 사용
- 생물정보학: DNA 서열 비교 (유전자 분석)
- 철자 검사: 두 단어의 유사도 측정
- 버전 관리: 문서의 변경 사항 추적
6. 실전 문제 3 — 편집 거리 (Edit Distance / Levenshtein Distance):
문제: 한 단어를 다른 단어로 바꾸기 위해 필요한 최소 편집 횟수를 구한다. 허용된 연산: 삽입(Insert), 삭제(Delete), 교체(Replace)
예시:
- "kitten" → "sitting" = 3 (k→s 교체, e→i 교체, 끝에 g 삽입)
- "algorithm" → "altruistic" = ?
- "홍길동" → "홍길순" = 1 (동→순 교체)
점화식 찾기:
-
dp[i][j]= word1[0..i-1]을 word2[0..j-1]로 바꾸는 최소 편집 거리- 기저 사례:
-
dp[i][0] = i(word1의 i글자를 모두 삭제)-
dp[0][j] = j(word2의 j글자를 모두 삽입)- 일반 경우:
- word1[i-1] == word2[j-1]:
dp[i][j] = dp[i-1][j-1](마지막 글자가 같으면 편집 불필요)- word1[i-1] != word2[j-1]:
- 삭제:
dp[i-1][j] + 1(word1의 마지막 글자 삭제)- 삽입:
dp[i][j-1] + 1(word2의 마지막 글자 삽입)- 교체:
dp[i-1][j-1] + 1(word1의 마지막 글자를 word2의 마지막 글자로 교체)- 최소값:
dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])타뷸레이션 버전 + 편집 연산 추적:
1 def edit_distance(word1, word2): 2 m, n = len(word1), len(word2) 3 dp = [[0] * (n + 1) for _ in range(m + 1)] 4 5 # 기저 사례 6 for i in range(m + 1): 7 dp[i][0] = i # 삭제 8 for j in range(n + 1): 9 dp[0][j] = j # 삽입 10 11 # 테이블 채우기 12 for i in range(1, m + 1): 13 for j in range(1, n + 1): 14 if word1[i-1] == word2[j-1]: 15 dp[i][j] = dp[i-1][j-1] 16 else: 17 dp[i][j] = 1 + min(dp[i-1][j], # 삭제 18 dp[i][j-1], # 삽입 19 dp[i-1][j-1]) # 교체 20 21 return dp[m][n]편집 과정을 추적하는 버전:
1 def edit_distance_with_trace(word1, word2): 2 m, n = len(word1), len(word2) 3 dp = [[0] * (n + 1) for _ in range(m + 1)] 4 5 for i in range(m + 1): 6 dp[i][0] = i 7 for j in range(n + 1): 8 dp[0][j] = j 9 10 for i in range(1, m + 1): 11 for j in range(1, n + 1): 12 if word1[i-1] == word2[j-1]: 13 dp[i][j] = dp[i-1][j-1] 14 else: 15 dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) 16 17 # 역추적으로 편집 연산 복원 18 operations = [] 19 i, j = m, n 20 while i > 0 or j > 0: 21 if i > 0 and j > 0 and word1[i-1] == word2[j-1]: 22 i -= 1 23 j -= 1 24 elif i > 0 and j > 0 and dp[i][j] == dp[i-1][j-1] + 1: 25 operations.append(f"교체 '{word1[i-1]}' → '{word2[j-1]}'") 26 i -= 1 27 j -= 1 28 elif i > 0 and dp[i][j] == dp[i-1][j] + 1: 29 operations.append(f"삭제 '{word1[i-1]}'") 30 i -= 1 31 elif j > 0 and dp[i][j] == dp[i][j-1] + 1: 32 operations.append(f"삽입 '{word2[j-1]}'") 33 j -= 1 34 35 return dp[m][n], list(reversed(operations))편집 거리의 실생활 예시:
- 철자 검사기: 사전에 없는 단어를 입력하면, 가장 비슷한 사전 단어 제안 ("recieve" → "receive")
- DNA 서열 정렬: 두 DNA 서열 간의 돌연변이 수 측정
- 검색 엔진: 오타가 있는 검색어도 적절히 보정 ("napkain" → "napkin")
- 자동 완성: 사용자가 입력하는 단어의 예상 완성
7. 메모이제이션 vs 타뷸레이션 — 언제 무엇을 쓸까?:
| | 메모이제이션 (Top-Down) | 타뷸레이션 (Bottom-Up) |
| 접근 | 재귀 + 캐싱 | 반복문 + 테이블 |
| 필요한 부분문제만 계산 | ✓ (Lazy) | ✗ (모두 계산) |
| 공간 최적화 쉬움 | △ (재귀 스택) | ✓ (1D 배열 등) |
| 코드 직관성 | ✓ (점화식 그대로) | △ (루프 순서 중요) |
| 스택 오버플로우 위험 | ✓ (있음) | ✗ (없음) |
| 추천 상황 | 부분문제가 듬성듬성 필요 | 모든 부분문제가 필요 |
실전 팁:
- 처음에는 메모이제이션으로 시작 (점화식을 그대로 코드로 옮기기 쉬움)
- 성능이 중요하거나 스택 오버플로우 위험이 있으면 타뷸레이션으로 전환
- 공간이 부족하면 타뷸레이션에서 2D → 1D로 최적화
8. DP 문제 풀이 패턴 (4단계):
1단계: 부분 문제 정의
- "dp[i]가 무엇을 의미하는가?"를 명확히 정의
- 예: 배낭 문제에서
dp[i][w]= 처음 i개 물건, 용량 w에서의 최대 가치2단계: 점화식 찾기
- "dp[i]를 dp[더 작은 값]들로 어떻게 표현하는가?"
- 예:
dp[i][w] = max(dp[i-1][w], dp[i-1][w-w_i] + v_i)3단계: 기저 사례 설정
- "가장 작은 부분 문제의 답은 무엇인가?"
- 예:
dp[0][w] = 0,dp[i][0] = 04단계: 계산 순서 결정
- 타뷸레이션에서는 어떤 순서로 테이블을 채우는지가 중요!
- 배낭: i를 1→n, w를 0→W 순으로 (이전 행의 값이 먼저 계산되어야 함)
9. 시간 복잡도 요약:
| 문제 | 시간 | 공간 |
| 피보나치 | O(n) | O(n) → O(1) (최적화) |
| 0/1 배낭 | O(n × W) | O(n × W) → O(W) (최적화) |
| LCS | O(m × n) | O(m × n) |
| 편집 거리 | O(m × n) | O(m × n) |
10. 마지막 생각거리:
- "DP가 만능인가?"
- 아니요! 부분 문제가 중복되지 않으면 DP가 아님 (예: 병합 정렬 — 부분 문제가 겹치지 않음 → 분할 정복이지 DP가 아님)
- 상태 공간이 너무 크면 DP도 느림 (배낭 문제에서 W가 10억이면 O(nW) = 불가)
- DP의 본질은 "똑똑한 기억" — 같은 계산을 반복하지 않는 것. 이것이 프로그래밍의 핵심 철학!
- "재귀는 아이디어를 표현하는 데 좋고, 타뷸레이션은 성능을 최적화하는 데 좋다" — 이것이 실전에서의 균형
(프롬프트 끝)
🔄 가이드
가이드 A: 점화식을 못 찾겠을 때
"배낭 문제의 점화식을 '决策나무'로 그려줘. n=3, capacity=5일 때, 첫 번째 물건을 넣을지 말지 결정하는 과정을 트리로 보여줘. 각 노드에서 (남은 용량, 남은 물건)을 표시하고, 리프 노드에서 가치를 계산. 그런 다음 같은 상태(같은 남은 용량+남은 물건)가 여러 번 등장하는지 보여줘서 '중복 부분 문제'를 체감하게 해줘."
가이드 B: 메모이제이션과 타뷸레이션이 헷갈릴 때
"피보나치 F(5)를 예로, 메모이제이션은 '위에서 아래로' F(5)→F(4)→F(3)→...→F(1)까지 내려가면서 계산한 값을 memo에 저장. 타뷸레이션은 '아래에서 위로' F(0), F(1), F(2), F(3), F(4), F(5) 순서로 표를 채워감. 둘 다 같은 O(n)이지만, 메모이제이션은 recursion depth의 위험이 있고, 타뷸레이션은 반복문의 순서가 중요함."
가이드 C: LCS와 편집 거리의 차이가 헷갈릴 때
"LCS와 편집 거리를 '가계도'로 비유해서 설명해주라. LCS는 '두 사람의 공통 조상 찾기' — 가계도에서 공통으로 등장하는 인물을 찾음(순서는 유지하지만 연속일 필요 없음). 편집 거리는 '한 가계도를 다른 가계도로 바꾸는 최소 비용' — 추가(가계도에 새 인물 추가), 삭제(가계도에서 인물 제거), 교체(인물 이름 변경). LCS는 '공통점'을 찾고, 편집 거리는 '차이점'을 측정한다!"
📦 기대 결과물
1 from typing import List, Tuple, Optional, Dict 2 3 4 # ============================================================ 5 # 1. 피보나치 — 세 가지 접근법 비교 6 # ============================================================ 7 8 def fibonacci_naive(n: int) -> int: 9 """피보나치 — 순수 재귀 (O(2^n))""" 10 if n <= 1: 11 return n 12 return fibonacci_naive(n - 1) + fibonacci_naive(n - 2) 13 14 15 def fibonacci_memo(n: int, memo: Optional[Dict[int, int]] = None) -> int: 16 """피보나치 — 메모이제이션 (O(n))""" 17 if memo is None: 18 memo = {} 19 if n in memo: 20 return memo[n] 21 if n <= 1: 22 return n 23 memo[n] = fibonacci_memo(n - 1, memo) + fibonacci_memo(n - 2, memo) 24 return memo[n] 25 26 27 def fibonacci_tab(n: int) -> int: 28 """피보나치 — 타뷸레이션 (O(n))""" 29 if n <= 1: 30 return n 31 dp = [0] * (n + 1) 32 dp[1] = 1 33 for i in range(2, n + 1): 34 dp[i] = dp[i - 1] + dp[i - 2] 35 return dp[n] 36 37 38 def fibonacci_optimized(n: int) -> int: 39 """피보나치 — 공간 최적화 (O(n) 시간, O(1) 공간)""" 40 if n <= 1: 41 return n 42 prev2, prev1 = 0, 1 43 for _ in range(2, n + 1): 44 prev2, prev1 = prev1, prev1 + prev2 45 return prev1 46 47 48 # ============================================================ 49 # 2. 0/1 배낭 문제 (Knapsack) 50 # ============================================================ 51 52 def knapsack_memo(weights: List[int], values: List[int], 53 capacity: int, n: Optional[int] = None, 54 memo: Optional[Dict] = None) -> int: 55 """0/1 배낭 문제 — 메모이제이션 (Top-Down)""" 56 if n is None: 57 n = len(weights) 58 if memo is None: 59 memo = {} 60 if n == 0 or capacity == 0: 61 return 0 62 if (n, capacity) in memo: 63 return memo[(n, capacity)] 64 65 # 물건을 넣지 않는 경우 66 result = knapsack_memo(weights, values, capacity, n - 1, memo) 67 68 # 물건을 넣는 경우 (용량이 충분할 때만) 69 if weights[n - 1] <= capacity: 70 result = max(result, 71 values[n - 1] + knapsack_memo(weights, values, 72 capacity - weights[n - 1], 73 n - 1, memo)) 74 75 memo[(n, capacity)] = result 76 return result 77 78 79 def knapsack_tab(weights: List[int], values: List[int], 80 capacity: int) -> int: 81 """0/1 배낭 문제 — 타뷸레이션 (Bottom-Up)""" 82 n = len(weights) 83 dp = [[0] * (capacity + 1) for _ in range(n + 1)] 84 85 for i in range(1, n + 1): 86 for w in range(capacity + 1): 87 # 물건을 넣지 않는 경우 88 dp[i][w] = dp[i - 1][w] 89 # 물건을 넣는 경우 90 if weights[i - 1] <= w: 91 dp[i][w] = max(dp[i][w], 92 dp[i - 1][w - weights[i - 1]] + values[i - 1]) 93 94 return dp[n][capacity] 95 96 97 def knapsack_with_selection(weights: List[int], values: List[int], 98 capacity: int) -> Tuple[int, List[int]]: 99 """0/1 배낭 문제 — 최대 가치와 선택된 물건 인덱스 반환""" 100 n = len(weights) 101 dp = [[0] * (capacity + 1) for _ in range(n + 1)] 102 103 for i in range(1, n + 1): 104 for w in range(capacity + 1): 105 dp[i][w] = dp[i - 1][w] 106 if weights[i - 1] <= w: 107 dp[i][w] = max(dp[i][w], 108 dp[i - 1][w - weights[i - 1]] + values[i - 1]) 109 110 # 역추적 111 selected = [] 112 w = capacity 113 for i in range(n, 0, -1): 114 if dp[i][w] != dp[i - 1][w]: 115 selected.append(i - 1) 116 w -= weights[i - 1] 117 118 return dp[n][capacity], selected[::-1] 119 120 121 def knapsack_tab_1d(weights: List[int], values: List[int], 122 capacity: int) -> int: 123 """0/1 배낭 문제 — 공간 최적화 (O(W) 공간)""" 124 n = len(weights) 125 dp = [0] * (capacity + 1) 126 127 for i in range(n): 128 # 뒤에서 앞으로 채워야 함 (같은 물건의 중복 사용 방지) 129 for w in range(capacity, weights[i] - 1, -1): 130 dp[w] = max(dp[w], dp[w - weights[i]] + values[i]) 131 132 return dp[capacity] 133 134 135 # ============================================================ 136 # 3. 최장 공통 부분 수열 (LCS) 137 # ============================================================ 138 139 def lcs_memo(text1: str, text2: str, i: Optional[int] = None, 140 j: Optional[int] = None, memo: Optional[Dict] = None) -> int: 141 """LCS — 메모이제이션 (Top-Down)""" 142 if i is None: 143 i, j = len(text1), len(text2) 144 if memo is None: 145 memo = {} 146 if i == 0 or j == 0: 147 return 0 148 if (i, j) in memo: 149 return memo[(i, j)] 150 151 if text1[i - 1] == text2[j - 1]: 152 result = 1 + lcs_memo(text1, text2, i - 1, j - 1, memo) 153 else: 154 result = max(lcs_memo(text1, text2, i - 1, j, memo), 155 lcs_memo(text1, text2, i, j - 1, memo)) 156 157 memo[(i, j)] = result 158 return result 159 160 161 def lcs_tab(text1: str, text2: str) -> Tuple[int, str]: 162 """LCS — 타뷸레이션 + LCS 문자열 복원 (Bottom-Up)""" 163 m, n = len(text1), len(text2) 164 dp = [[0] * (n + 1) for _ in range(m + 1)] 165 166 # 테이블 채우기 167 for i in range(1, m + 1): 168 for j in range(1, n + 1): 169 if text1[i - 1] == text2[j - 1]: 170 dp[i][j] = dp[i - 1][j - 1] + 1 171 else: 172 dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) 173 174 # LCS 문자열 복원 (역추적) 175 lcs_str = [] 176 i, j = m, n 177 while i > 0 and j > 0: 178 if text1[i - 1] == text2[j - 1]: 179 lcs_str.append(text1[i - 1]) 180 i -= 1 181 j -= 1 182 elif dp[i - 1][j] >= dp[i][j - 1]: 183 i -= 1 184 else: 185 j -= 1 186 187 return dp[m][n], ''.join(reversed(lcs_str)) 188 189 190 # ============================================================ 191 # 4. 편집 거리 (Edit Distance) 192 # ============================================================ 193 194 def edit_distance(word1: str, word2: str) -> int: 195 """편집 거리 — 타뷸레이션 (Bottom-Up)""" 196 m, n = len(word1), len(word2) 197 dp = [[0] * (n + 1) for _ in range(m + 1)] 198 199 # 기저 사례 200 for i in range(m + 1): 201 dp[i][0] = i # 삭제 202 for j in range(n + 1): 203 dp[0][j] = j # 삽입 204 205 # 테이블 채우기 206 for i in range(1, m + 1): 207 for j in range(1, n + 1): 208 if word1[i - 1] == word2[j - 1]: 209 dp[i][j] = dp[i - 1][j - 1] 210 else: 211 dp[i][j] = 1 + min(dp[i - 1][j], # 삭제 212 dp[i][j - 1], # 삽입 213 dp[i - 1][j - 1]) # 교체 214 215 return dp[m][n] 216 217 218 def edit_distance_with_trace(word1: str, word2: str) -> Tuple[int, List[str]]: 219 """편집 거리 — 타뷸레이션 + 편집 연산 추적""" 220 m, n = len(word1), len(word2) 221 dp = [[0] * (n + 1) for _ in range(m + 1)] 222 223 for i in range(m + 1): 224 dp[i][0] = i 225 for j in range(n + 1): 226 dp[0][j] = j 227 228 for i in range(1, m + 1): 229 for j in range(1, n + 1): 230 if word1[i - 1] == word2[j - 1]: 231 dp[i][j] = dp[i - 1][j - 1] 232 else: 233 dp[i][j] = 1 + min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) 234 235 # 역추적으로 편집 연산 복원 236 operations = [] 237 i, j = m, n 238 while i > 0 or j > 0: 239 if i > 0 and j > 0 and word1[i - 1] == word2[j - 1]: 240 i -= 1 241 j -= 1 242 elif i > 0 and j > 0 and dp[i][j] == dp[i - 1][j - 1] + 1: 243 operations.append(f"교체 '{word1[i-1]}' → '{word2[j-1]}'") 244 i -= 1 245 j -= 1 246 elif i > 0 and dp[i][j] == dp[i - 1][j] + 1: 247 operations.append(f"삭제 '{word1[i-1]}'") 248 i -= 1 249 elif j > 0 and dp[i][j] == dp[i][j - 1] + 1: 250 operations.append(f"삽입 '{word2[j-1]}'") 251 j -= 1 252 253 return dp[m][n], list(reversed(operations)) 254 255 256 def print_dp_table(dp: List[List[int]], row_label: str = "", 257 col_label: str = "") -> None: 258 """DP 테이블을 예쁘게 출력""" 259 print(f"\n DP 테이블 ({row_label} × {col_label}):") 260 print(" " + " ".join(f"{j:4d}" for j in range(len(dp[0])))) 261 for i, row in enumerate(dp): 262 print(f" {i:2d}: " + " ".join(f"{val:4d}" for val in row)) 263 264 265 # ============================================================ 266 # 5. 시연 (Demos) 267 # ============================================================ 268 269 def demo_fibonacci_comparison(): 270 """피보나치 세 가지 접근법 비교""" 271 print("=" * 60) 272 print("피보나치 비교 — 세 가지 접근법") 273 print("=" * 60) 274 275 import time 276 277 n = 35 278 279 # 순수 재귀 280 print(f"\n F({n}) 계산 중...") 281 start = time.time() 282 result1 = fibonacci_naive(n) 283 time1 = time.time() - start 284 print(f" 순수 재귀: {time1:.4f}초 → F({n}) = {result1}") 285 286 # 메모이제이션 287 start = time.time() 288 result2 = fibonacci_memo(n) 289 time2 = time.time() - start 290 print(f" 메모이제이션: {time2:.4f}초 → F({n}) = {result2}") 291 print(f" → {time1/time2:.0f}배 빠름!") 292 293 # 타뷸레이션 294 start = time.time() 295 result3 = fibonacci_tab(n) 296 time3 = time.time() - start 297 print(f" 타뷸레이션: {time3:.4f}초 → F({n}) = {result3}") 298 print(f" → {time1/time3:.0f}배 빠름!") 299 300 # 공간 최적화 301 start = time.time() 302 result4 = fibonacci_optimized(n) 303 time4 = time.time() - start 304 print(f" 공간 최적화: {time4:.4f}초 → F({n}) = {result4}") 305 print(f" (O(1) 공간으로 동일 결과)") 306 307 print(f"\n ✅ 세 방식 모두 같은 결과: {result1 == result2 == result3 == result4}") 308 309 310 def demo_knapsack(): 311 """배낭 문제 시연""" 312 print("\n" + "=" * 60) 313 print("0/1 배낭 문제 시연") 314 print("=" * 60) 315 316 # 예시 데이터 317 weights = [5, 4, 6, 3] 318 values = [10, 40, 30, 50] 319 capacity = 10 320 321 print(f"\n 📦 배낭 용량: {capacity}kg") 322 print(f" 🎒 물건 목록:") 323 for i, (w, v) in enumerate(zip(weights, values)): 324 print(f" 물건{i+1}: 무게 {w}kg, 가치 {v}") 325 326 # 메모이제이션 327 max_val_memo = knapsack_memo(weights, values, capacity) 328 print(f"\n 🔝 메모이제이션 결과: 최대 가치 = {max_val_memo}") 329 330 # 타뷸레이션 331 max_val_tab = knapsack_tab(weights, values, capacity) 332 print(f" 🔝 타뷸레이션 결과: 최대 가치 = {max_val_tab}") 333 334 # 선택 역추적 335 max_val, selected = knapsack_with_selection(weights, values, capacity) 336 print(f" 🔍 선택된 물건: {selected} (인덱스)") 337 print(f" 선택 내역:") 338 for idx in selected: 339 print(f" 물건{idx+1}: 무게 {weights[idx]}kg, 가치 {values[idx]}") 340 print(f" 총 무게: {sum(weights[i] for i in selected)}kg") 341 print(f" 총 가치: {sum(values[i] for i in selected)}") 342 343 # 공간 최적화 344 max_val_1d = knapsack_tab_1d(weights, values, capacity) 345 print(f"\n 💾 공간 최적화(1D): 최대 가치 = {max_val_1d}") 346 347 # DP 테이블 출력 348 print_dp_table([[0]*(capacity+1) for _ in range(len(weights)+1)], 349 "물건", "용량") 350 351 352 def demo_lcs(): 353 """LCS 시연""" 354 print("\n" + "=" * 60) 355 print("최장 공통 부분 수열(LCS) 시연") 356 print("=" * 60) 357 358 test_cases = [ 359 ("ABCDEF", "ACDEGF"), 360 ("ABC", "AC"), 361 ("알고리즘", "알고리듬"), 362 ("kitten", "sitting"), 363 ("홍길동", "홍길순"), 364 ] 365 366 for text1, text2 in test_cases: 367 length_memo = lcs_memo(text1, text2) 368 length_tab, lcs_str = lcs_tab(text1, text2) 369 370 print(f"\n '{text1}' ↔ '{text2}'") 371 print(f" LCS 길이: {length_tab}") 372 print(f" LCS 문자열: '{lcs_str}'") 373 print(f" 메모이제이션: {length_memo} (일치: {length_memo == length_tab})") 374 375 # LCS 테이블의 실생활 활용 376 print(f"\n 🌍 LCS의 실생활 활용:") 377 print(f" • Git diff: 두 파일 간의 변경사항 추적") 378 print(f" • DNA 분석: 유전자 서열 비교") 379 print(f" • 철자 검사: 유사 단어 추천") 380 print(f" • 버전 관리: 문서 변경 이력") 381 382 383 def demo_edit_distance(): 384 """편집 거리 시연""" 385 print("\n" + "=" * 60) 386 print("편집 거리(Edit Distance) 시연") 387 print("=" * 60) 388 389 test_cases = [ 390 ("kitten", "sitting"), 391 ("algorithm", "altruistic"), 392 ("홍길동", "홍길순"), 393 ("saturday", "sunday"), 394 ] 395 396 for word1, word2 in test_cases: 397 distance = edit_distance(word1, word2) 398 dist_trace, operations = edit_distance_with_trace(word1, word2) 399 400 print(f"\n '{word1}' → '{word2}'") 401 print(f" 편집 거리: {distance}") 402 print(f" 연산 추적:") 403 for op in operations: 404 print(f" • {op}") 405 print(f" (메모이제이션 없이도 동일 결과: {distance == dist_trace})") 406 407 # 철자 검사기 데모 408 print(f"\n 🔤 미니 철자 검사기:") 409 dictionary = ["receive", "achieve", "believe", "sequence", "peaceful"] 410 misspelled = "recieve" 411 print(f" 오타: '{misspelled}'") 412 print(f" 사전: {dictionary}") 413 414 best_match = min(dictionary, key=lambda word: edit_distance(misspelled, word)) 415 best_dist = edit_distance(misspelled, best_match) 416 print(f" 추천: '{best_match}' (편집 거리: {best_dist})") 417 418 419 def demo_dp_pattern(): 420 """DP 문제 풀이 4단계 패턴 정리""" 421 print("\n" + "=" * 60) 422 print("DP 문제 풀이 4단계 패턴") 423 print("=" * 60) 424 425 print(""" 426 📋 단계 1: 부분 문제 정의 427 ───────────────────────── 428 "dp[i]가 무엇을 의미하는가?"를 명확히 정의 429 430 예: 배낭 문제에서 431 → dp[i][w] = 처음 i개 물건, 용량 w에서의 최대 가치 432 433 📋 단계 2: 점화식 찾기 434 ───────────────────────── 435 "dp[i]를 더 작은 dp 값들로 어떻게 표현하는가?" 436 437 예: 배낭 문제에서 438 → dp[i][w] = max(dp[i-1][w], dp[i-1][w-w_i] + v_i) 439 (안 넣음) (넣음) 440 441 📋 단계 3: 기저 사례 설정 442 ───────────────────────── 443 "가장 작은 부분 문제의 답은 무엇인가?" 444 445 예: 배낭 문제에서 446 → dp[0][w] = 0 (물건 0개 → 가치 0) 447 → dp[i][0] = 0 (용량 0 → 가치 0) 448 449 📋 단계 4: 계산 순서 결정 450 ───────────────────────── 451 "어떤 순서로 테이블을 채우는가?" 452 453 예: 배낭 문제에서 454 → i를 1→n, w를 0→W 순서로 455 → (i-1)행의 값이 먼저 계산되어야 (i)행을 계산할 수 있음 456 """) 457 458 459 if __name__ == "__main__": 460 demo_fibonacci_comparison() 461 demo_knapsack() 462 demo_lcs() 463 demo_edit_distance() 464 demo_dp_pattern()실행 결과 (일부):
1 ============================================================ 2 피보나치 비교 — 세 가지 접근법 3 ============================================================ 4 5 F(35) 계산 중... 6 순수 재귀: 0.7185초 → F(35) = 9227465 7 메모이제이션: 0.0000초 → F(35) = 9227465 8 → 37670배 빠름! 9 타뷸레이션: 0.0000초 → F(35) = 9227465 10 → 120543배 빠름! 11 공간 최적화: 0.0000초 → F(35) = 9227465 12 (O(1) 공간으로 동일 결과) 13 14 ✅ 세 방식 모두 같은 결과: True 15 16 ============================================================ 17 0/1 배낭 문제 시연 18 ============================================================ 19 20 📦 배낭 용량: 10kg 21 🎒 물건 목록: 22 물건1: 무게 5kg, 가치 10 23 물건2: 무게 4kg, 가치 40 24 물건3: 무게 6kg, 가치 30 25 물건4: 무게 3kg, 가치 50 26 27 🔝 메모이제이션 결과: 최대 가치 = 90 28 🔝 타뷸레이션 결과: 최대 가치 = 90 29 🔍 선택된 물건: [1, 3] (인덱스) 30 선택 내역: 31 물건2: 무게 4kg, 가치 40 32 물건4: 무게 3kg, 가치 50 33 총 무게: 7kg 34 총 가치: 90 35 36 💾 공간 최적화(1D): 최대 가치 = 90 37 38 ============================================================ 39 최장 공통 부분 수열(LCS) 시연 40 ============================================================ 41 42 'ABCDEF' ↔ 'ACDEGF' 43 LCS 길이: 5 44 LCS 문자열: 'ACDEF' 45 메모이제이션: 5 (일치: True) 46 47 'ABC' ↔ 'AC' 48 LCS 길이: 2 49 LCS 문자열: 'AC' 50 메모이제이션: 2 (일치: True) 51 52 '알고리즘' ↔ '알고리듬' 53 LCS 길이: 3 54 LCS 문자열: '알고리' 55 메모이제이션: 3 (일치: True) 56 57 'kitten' ↔ 'sitting' 58 LCS 길이: 4 59 LCS 문자열: 'ittn' 60 메모이제이션: 4 (일치: True) 61 62 '홍길동' ↔ '홍길순' 63 LCS 길이: 2 64 LCS 문자열: '홍길' 65 메모이제이션: 2 (일치: True) 66 67 ============================================================ 68 편집 거리(Edit Distance) 시연 69 ============================================================ 70 71 'kitten' → 'sitting' 72 편집 거리: 3 73 연산 추적: 74 • 교체 'k' → 's' 75 • 교체 'e' → 'i' 76 • 삽입 'g' 77 78 'algorithm' → 'altruistic' 79 편집 거리: 6 80 연산 추적: 81 • 교체 'g' → 't' 82 • 교체 'o' → 'r' 83 • 교체 'r' → 'u' 84 • 삽입 's' 85 • 교체 'h' → 'i' 86 • 교체 'm' → 'c' 87 88 '홍길동' → '홍길순' 89 편집 거리: 1 90 연산 추적: 91 • 교체 '동' → '순' 92 93 'saturday' → 'sunday' 94 편집 거리: 3 95 연산 추적: 96 • 삭제 'a' 97 • 삭제 't' 98 • 교체 'r' → 'n' 99 100 🔤 미니 철자 검사기: 101 오타: 'recieve' 102 사전: ['receive', 'achieve', 'believe', 'sequence', 'peaceful'] 103 추천: 'receive' (편집 거리: 2) 104 105 ============================================================ 106 DP 문제 풀이 4단계 패턴 107 ============================================================ 108 109 📋 단계 1: 부분 문제 정의 110 ───────────────────────── 111 "dp[i]가 무엇을 의미하는가?"를 명확히 정의 112 113 예: 배낭 문제에서 114 → dp[i][w] = 처음 i개 물건, 용량 w에서의 최대 가치 115 116 📋 단계 2: 점화식 찾기 117 ───────────────────────── 118 "dp[i]를 더 작은 dp 값들로 어떻게 표현하는가?" 119 120 예: 배낭 문제에서 121 → dp[i][w] = max(dp[i-1][w], dp[i-1][w-w_i] + v_i) 122 (안 넣음) (넣음) 123 124 📋 단계 3: 기저 사례 설정 125 ───────────────────────── 126 "가장 작은 부분 문제의 답은 무엇인가?" 127 128 예: 배낭 문제에서 129 → dp[0][w] = 0 (물건 0개 → 가치 0) 130 → dp[i][0] = 0 (용량 0 → 가치 0) 131 132 📋 단계 4: 계산 순서 결정 133 ───────────────────────── 134 "어떤 순서로 테이블을 채우는가?" 135 136 예: 배낭 문제에서 137 → i를 1→n, w를 0→W 순서로 138 → (i-1)행의 값이 먼저 계산되어야 (i)행을 계산할 수 있음
💡 배움 포인트
개념 무엇을 배웠는가 **DP의 본질** "똑똑한 기억" — 같은 계산을 반복하지 않는 것. 피보나치에서 F(3)이 수천 번 중복 계산되는 것을, memo에 저장해서 한 번만 계산! **메모이제이션 vs 타뷸레이션** 메모이제이션은 "재귀+캐싱"으로 위에서 아래로, 타뷸레이션은 "반복문+테이블"으로 아래에서 위로. 같은 O(n)이지만 장단점이 다름. 실무에서는 **메모이제이션으로 시작 → 타뷸레이션으로 최적화** 흐름이 일반적 **점화식 찾기** DP의 핵심 기술. "현재 상태를 더 작은 상태로 어떻게 표현할까?"를 생각하는 것. 배낭: 넣는가/안 넣는가. LCS: 같은가/다른가. 편집 거리: 삭제/삽입/교체 중 최소. **패턴을 익히면 어떤 문제든 접근 가능!** **공간 최적화** 2D 테이블이 항상 필요한 건 아님. 배낭 문제에서 1D 배열만으로 O(W) 공간에 해결 가능. **메모리도 알고리즘 효율의 일부!** **DP가 만능은 아님** 부분 문제가 중복되지 않으면 DP가 아님 (병합 정렬 = 분할 정복). 상태 공간이 너무 크면 DP도 느림 (배낭에서 W=10억). **적재적소**가 중요 **실생활 연결** 배낭 = 앱 설치/투자 포트폴리오. LCS = Git diff/DNA 분석. 편집 거리 = 철자 검사기. **DP는 교과서 문제가 아니라 실생활의 문제!**
🧪 직접 해보기
- 계단 오르기(Climbing Stairs) — 1계단 또는 2계단씩만 오를 수 있음. n번째 계단까지 가는 방법 수는? → 피보나치와 같은 문제! 단, n=45이면 F(45)를 계산해보자.
- 부분 합 문제(Subset Sum) — 주어진 숫자들 중 일부의 합이 목표값과 같은 조합이 있는가? 배낭 문제와 유사한 DP로 해결. 예: [3, 34, 4, 12, 5, 2], 목표=9 → [4, 5] 또는 [3, 4, 2] 가능!
- 동전 거스름돈(Coin Change) — 주어진 동전 종류로 특정 금액을 만드는 최소 동전 수. 예: 동전 [1, 5, 10, 50], 금액=67 → 50+10+5+1+1 = 5개. 무한 개 사용가능한 경우!
- LCS로 표절 검사기 만들기 — 두 텍스트의 LCS 길이를 비교해서 유사도를 계산. 두 문장의 LCS 길이가 80% 이상이면 "표절 의심"으로 분류!
- 편집 거리로 자동완성 구현 — 사용자가 "comuter"라고 입력하면, 사전에서 편집 거리가 2 이하인 단어들을 추천 ("computer", "compute", "commuter").
🔗 책을 마치며
책 한 권을 읽는다는 것, 그것은 10개의 문을 통과하는 것과 같습니다.
문턱을 넘을 때마다 새로운 세계가 열리고, 돌아보면 이미 걸어온 길이 보입니다.
제1장 배열에서 시작해 제10장 동적 계획법까지 —
여러분은 이제 데이터의 저장 방식, 탐색과 정렬, 재귀의 아름다움, 해시 테이블의 마법,
트리의 계층, 그래프의 네트워크, 그리고 DP의 누적을 모두 이해했습니다.
이것이 끝이 아닙니다. 이것은 시작입니다.
AVL 트리, 레드-블랙 트리, A* 경로 탐색, 슈트라센 행렬 곱셈, RANSAC,
블룸 필터, 세그먼트 트리, 펜윅 트리, KMP, 라빈-카프, 아호-코라식,
네트워크 플로우, 선형 계획법, 몬테카를로 방법...
이제 여러분은 이 모든 것을 배울 준비가 되었습니다.
그리고 언제든, 다시 Solar에게 이렇게 말할 수 있습니다:
"바이브 코딩으로, 같이 해보자."
— Solar와 함께하는 바이브 코딩, 여기까지입니다. 감사합니다. 🎉
📎 부록
A. 전체 커리큘럼 요약표
장 주제 핵심 개념 O 표기 실습 예시 1 배열 메모리, 인덱싱 O(1)/O(n) 성적 관리 2 스택/큐 LIFO, FIFO O(1) 브라우저 뒤로가기 3 연결리스트 포인터, 동적 할당 O(1)/O(n) 음악 재생목록 4 정렬 버블/선택/삽입 O(n²) 벤치마크 비교 5 탐색 선형/이진 O(n)/O(log n) 전화번호부 6 재귀 기저 사례, 스택 다양 하노이 탑 7 해시테이블 해시 함수, 충돌 O(1) 평균 영어-한국어 사전 8 트리 BST, 순회 O(log n) 파일 시스템 9 그래프 BFS, DFS, 다익스트라 O(V+E) 지하철 최단경로 10 DP 메모이제이션, 타뷸레이션 다양 배낭, LCS, 편집거리 B. 추가 학습 자료
주제 추천 자료 알고리즘 시각화 https://visualgo.net LeetCode 연습 https://leetcode.com (Easy → Medium) 알고리즘 강의 MIT 6.006 (YouTube 공개) 파이썬 알고리즘 "파이썬 알고리즘 인터뷰" (책) DP 심화 "Dynamic Programming for Coding Interviews" C. 파이썬 내장 자료구조 매핑
이 책에서 만든 것 Python 내장 구현 차이 MyArray `list` list는 동적 크기, MyArray는 고정 Stack `list` (append/pop) 동일 Queue `collections.deque` deque는 O(1) dequeue LinkedList 없음 직접 구현 HashTableChaining `dict` dict는 개방 주소법 + 리사이징 최적화 BST 없음 직접 구현 (또는 `bisect`) Graph `networkx` networkx는 종합 그래프 라이브러리