이 원고는 제시된 12개 챕터·75개 절의 목차에 맞추어 설명·예제·문제·코드를 새로 작성한 독학용 원안입니다. 파이썬 기초 문법을 접해 본 학습자가 자료구조의 동작을 직접 설명하고 구현하는 것을 목표로 합니다. 표준 알고리즘의 원리는 공통 지식이지만 특정 출판 교재의 문장·문제·코드를 옮기지 않았습니다.
읽기용 통합 HTML은 인터넷 연결 없이 브라우저에서 열 수 있습니다. 챕터별 Markdown은 편집 원고이며, 코드 폴더에는 실행용 예제와 재사용 모듈, 검증 스크립트를 함께 제공합니다. 통합 문서는 01부터 12까지 같은 순서로 연결됩니다. 연습문제의 정답은 문제 묶음 뒤에, 실습 정답은 모든 실습 문제 뒤에 배치했습니다.
먼저 정한 공통 규약
항목
이 원고의 규약
언어
Python 3, 외부 패키지 없이 실행
검증 환경
Python 3.12.14
함수·변수 이름
snake_case, 클래스는 PascalCase
들여쓰기
공백 4칸
자료구조 길이
__len__을 구현하여 len(structure) 사용
내부 상태
_data, _head, _size처럼 밑줄 관례로 구분
공개 API
사용자가 맡길 행동만 제공하고 내부 배열·노드 수정은 피함
빈 스택·큐·덱·힙 제거
IndexError
잘못된 위치
IndexError, 직접 구현은 음수 인덱스 미지원
고정 용량 초과
OverflowError, 자동 확장하지 않음
잘못된 값·형식
예제 명세에 따라 ValueError
없는 맵 키
KeyError; 정상 값 None과 구분
맵 중복 키
새 노드를 늘리지 않고 값 갱신
입력 수정
절마다 명시, 정렬 비교는 원본 복사본에 각각 적용
복잡도
입력 크기 정의→반복·이동·호출 수→시간·공간 순서
인덱스·용량·횟수는 정수, 그래프 이름은 해시 가능하고 None이 아닌 값, 비교 정렬·BST의 키는 일관된 대소 비교가 가능하다고 가정합니다. 추가로 제한하는 입력은 각 절에 명시합니다. 모든 잘못된 Python 객체까지 방어하는 범용 라이브러리를 목표로 하지는 않습니다. 자료구조가 지켜야 할 핵심 경계·실패 상태는 검증합니다.
용어를 읽는 기준
한글
영어·기호
의미와 주의점
추상 자료형
ADT
저장 구현과 분리한 데이터·연산의 약속
재귀
Recursion
목차의 ‘순환 알고리즘’과 같은 의미, 반복문과 구분
시간 복잡도
Time Complexity
입력에 따라 증가하는 작업량
보조 공간
Auxiliary Space
입력 자체를 제외한 추가 공간, 결과·스택 포함 여부 명시
분할 상환
Amortized
연속 연산의 총비용을 나누어 보는 분석, 확률 평균과 다름
연산자 중복
Operator Overloading
__add__ 등으로 연산자 의미 정의
메서드 재정의
Overriding
상속한 행동을 자식 클래스에서 바꿈
루트 레벨
0
루트에서 해당 노드까지 간선 수
노드 높이
리프 0, 빈 트리 -1
가장 먼 리프까지 간선 수
완전이진트리
Complete Binary Tree
마지막 레벨은 왼쪽부터 채움
포화이진트리
Perfect Binary Tree
모든 레벨이 가득 참
안정 정렬
Stable Sort
같은 키의 상대 순서를 유지
최소 우선순위
작은 숫자 우선
동점은 가능하면 삽입 순번으로 처리
n은 보통 원소 수, C는 확보 용량, h는 트리 높이, V와 E는 그래프 정점·간선 수입니다. k는 절에 따라 결과 수·키 범위 등으로 달라질 수 있으므로 각 분석에서 다시 정의합니다. 정수·참조·일반 원소 비교는 기본 단위 비용 모형을 사용하며, 매우 큰 정수·긴 문자열·출력 길이 비용이 중요한 곳에는 별도로 적습니다.
Big-O는 상한입니다. 표의 목적상 가능한 한 유용한 증가 차수를 제시하지만, 최선·평균·최악과 Big-O 자체를 동일시하지 않습니다. 특히 Python 동적 배열의 확장, 재귀 스택, 결과 목록, 해시 분포를 비용에서 숨기지 않습니다.
고정 배열은 Python list를 슬롯 저장 재료로 사용하지만, 배우려는 삽입·삭제 이동을 내장 insert·pop으로 대신하지 않습니다. 원형 큐의 슬롯은 모두 사용하고 size로 빈 상태·가득 찬 상태를 구별합니다. 연결 구조는 Node를 직접 만들며 필요할 때 노드를 추가합니다. 생성자에 용량이 필요한 배열 구현과 필요 없는 연결 구현의 차이는 실제 저장 방식의 차이입니다.
표준 deque, heapq, dict, set을 쓰는 응용은 앞서 직접 구현한 원리와 연결하여 설명합니다. 자료구조 그 자체를 구현하는 절과 이미 배운 구조를 도구로 사용하는 절을 구별합니다. MinHeap은 비교 가능한 원소를 다루는 원시 힙 인터페이스이며, 우선순위·순번·항목 튜플을 넣으면 우선순위 큐로 사용할 수 있습니다.
챕터 간 선수 개념과 연결
챕터
핵심 주제
필요한 앞 개념
다음으로 이어지는 것
01
ADT·복잡도·재귀
기본 반복문 감각
모든 구현·성능 분석
02
참조·함수·클래스
기초 Python 경험
노드·배열 상태 관리
03
리스트·집합
참조·인덱스·클래스
스택·큐 저장 기반
04
스택·괄호·수식·DFS
배열·LIFO
호출 스택·그래프 DFS
05
큐·덱·BFS·우선순위
배열·방문 표시
그래프 탐색·힙
06
연결 구조
객체 참조·스택·큐
트리 노드와 링크 변경
07
정렬·탐색·해시 맵
목록·복잡도
BST·고급 정렬
08
트리·순회·힙·허프만
재귀·큐·우선순위
BST·힙 정렬·그래프
09
BST·맵·AVL 원리
이진트리·탐색
순서와 높이의 성능 영향
10
그래프·성분·위상 정렬
DFS·BFS·큐·스택
가중치 그래프
11
MST·Dijkstra
그래프·힙·탐색
비용 선택과 정확성 조건
12
고급 정렬 종합
재귀·분할정복·힙·빈도
상황별 알고리즘 선택
공부하는 방법
코드보다 먼저 문제 상황과 작은 예제를 읽습니다. 왜 그 자료구조가 필요한지 한 문장으로 말해 봅니다.
상태표를 가리고 명령 하나씩 실행한 뒤 내부 상태를 직접 적습니다. 반환값과 저장 상태를 별개로 표시합니다.
코드를 실행하기 전에 출력과 실패 조건을 예측합니다. 다른 결과가 나오면 최초로 예측과 달라진 줄을 찾습니다.
핵심 불변식을 적습니다. 예를 들어 배열 리스트는 유효 구간, BST는 조상의 키 범위, BFS는 거리층 순서입니다.
‘몇 줄인가’ 대신 ‘각 원소가 몇 번 처리되는가’를 셉니다. 입력·출력·보조 공간과 재귀 깊이도 함께 봅니다.
연습문제 10개를 먼저 풀고 뒤의 해설을 읽습니다. 기초 4개, 응용 4개, 도전 2개로 구성했습니다.
CHAPTER 02 이후 실습 3개를 정답 없이 구현한 뒤, 예시 외의 빈 입력·중복·실패 입력을 추가로 생각합니다.
장별 권장 공부 시간은 학습 경험과 실습 속도에 따라 크게 달라집니다. 페이지 수보다 ‘코드가 왜 맞는지 설명할 수 있는가’를 다음 장으로 넘어가는 기준으로 삼으세요.
장별 파일은 그 장의 예제와 실습 정답을 학습 순서대로 실행하는 파일입니다. 따라서 여러 예제의 출력이 순서대로 나타납니다. core01.py부터 core12.py는 시연 없이 함수·클래스를 가져오는 재사용 모듈입니다. 같은 code 폴더에 새 실습 파일을 만들면 다음처럼 가져올 수 있습니다.
from core04 import Stack
pending = Stack(10)
pending.push("첫 작업")
print(pending.pop())
본문 코드 블록은 같은 장에서 앞서 정의한 이름을 이어서 사용합니다. 독립적으로 일부 블록만 실행하려면 필요한 클래스·도우미 함수도 가져와야 합니다. 재사용 모듈은 동일 정의를 정리한 파일이며, 주석이 포함된 학습용 원본은 장별 실행 파일과 본문에 있습니다.
이 원안은 완성된 출판 조판본이나 모든 입력을 방어하는 배포용 라이브러리는 아닙니다. 편집 가능한 교재 원고, 실행 가능한 학습 예제, 독학용 문제·해설을 제공합니다. 실무 사용 전에는 구체적인 입력 계약·자료 크기·성능 요구에 맞추어 API와 예외 처리를 확장할 수 있습니다.
CHAPTER 01 자료구조와 알고리즘
자료구조를 처음 공부할 때 가장 흔한 오해는 “리스트나 딕셔너리의 사용법을 더 배우는 과목”이라고 생각하는 것입니다. 사용법도 필요하지만, 더 중요한 질문은 따로 있습니다. 같은 일을 하는 두 프로그램이 왜 서로 다른 속도로 실행될까요? 한 프로그램은 적은 데이터를 처리할 때 빠른데, 데이터가 늘면 왜 다른 프로그램보다 느려질까요?
이 장에서는 아직 복잡한 자료구조를 만들지 않습니다. 작은 목록에서 값을 찾는 문제를 출발점으로 삼아, 데이터를 조직하는 방식과 처리하는 절차를 구분합니다. 이어서 구현을 바꾸어도 유지해야 할 약속을 배우고, 실행 시간을 측정하는 일과 증가율을 분석하는 일이 어떻게 다른지 살펴봅니다. 마지막에는 재귀호출을 “함수가 자신을 부른다”는 문장에서 한 걸음 더 나아가 호출의 수와 깊이로 분석합니다.
이 장에서 입력 크기 n은 특별한 설명이 없으면 데이터의 개수입니다. 정수 덧셈과 비교를 일정한 비용으로 보는 기본 모형을 사용합니다. 실제 Python의 매우 큰 정수 연산에서는 자릿수에 따른 비용이 추가된다는 점은 뒤에서 구분합니다.
1.1 자료구조와 알고리즘
학습 목표
자료구조와 알고리즘이 맡는 역할을 구분한다.
입력·출력·종료 조건을 이용해 문제를 명확히 기술한다.
순차 탐색의 과정을 데이터와 비교 횟수로 설명한다.
문제에서 개념으로
도서관에서 책 한 권을 찾는다고 생각해 봅시다. 책이 아무렇게나 쌓여 있다면 한 권씩 제목을 확인해야 합니다. 반면 분류 번호 순서로 정리되어 있고 서가 위치를 알 수 있다면 훨씬 적은 확인으로 찾을 수 있습니다. 찾으려는 책은 같지만, 책을 배치한 방식과 찾아가는 절차가 달라진 것입니다.
**자료구조(Data Structure)**는 데이터를 저장하고 서로의 관계를 표현하는 방식입니다. 순서를 가진 목록, 먼저 들어온 일을 먼저 처리하는 대기열, 위아래 관계가 있는 조직도는 서로 다른 관계를 표현합니다. **알고리즘(Algorithm)**은 주어진 입력에서 원하는 출력을 얻기 위해 수행하는 명확하고 유한한 절차입니다. “적당히 찾아본다”는 지시에는 어떤 것을 먼저 보고 언제 멈출지가 없으므로 구현할 수 있는 절차가 되지 못합니다.
두 개념은 함께 설계해야 합니다. 정렬되지 않은 목록에 이진 탐색을 그대로 적용하면 빠른 정답이 아니라 잘못된 결과를 얻을 수 있습니다. 반대로 이름을 거의 검색하지 않고 끝에 추가만 하는 목록을 위해 복잡한 탐색 구조를 만드는 것은 불필요할 수 있습니다. 좋은 선택은 데이터의 모양뿐 아니라 실제로 자주 수행할 연산에 따라 달라집니다.
이 책에서는 먼저 **정확성(Correctness)**을 확보하고 그다음 효율성을 분석합니다. 빠르게 틀린 결과를 내는 프로그램은 문제를 해결하지 못합니다. 정확성을 판단하려면 입력 범위와 출력의 의미를 먼저 정해야 합니다. “값을 찾는다” 대신 “값이 처음 나타나는 인덱스를 반환하고, 없으면 -1을 반환한다”처럼 약속을 구체화합니다.
구조와 작은 예제
목록 [18, 7, 25, 7]에서 7을 찾습니다. 인덱스는 0부터 시작하며, 중복된 값이 있어도 첫 번째 위치를 정답으로 삼습니다.
단계
확인할 인덱스
확인한 값
판단
다음 행동
1
0
18
7과 다름
다음 원소 확인
2
1
7
7과 같음
1을 반환하고 종료
없는 값 9를 찾는다면 네 원소를 모두 확인한 뒤 -1을 반환합니다. 빈 목록은 확인할 원소가 없으므로 즉시 -1을 반환합니다. 이처럼 정상 입력 외에 빈 입력, 중복값, 찾지 못한 경우를 미리 생각하면 코드에서 놓칠 부분이 줄어듭니다.
의사코드로 표현하면 ‘앞에서부터 원소를 하나씩 확인한다 → 목표와 같으면 위치를 반환한다 → 끝까지 없으면 실패값을 반환한다’입니다. 순차 탐색(Sequential Search)이라는 이름은 이 확인 순서에서 나옵니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def linear_search(items, target):
"""target이 처음 등장한 위치를 반환한다. 없으면 -1이다."""
for index, value in enumerate(items):
# 앞쪽에서 발견하는 즉시 종료하므로 첫 위치가 보장된다.
if value == target:
return index
# 이 줄에 도달했다면 모든 원소를 확인한 상태다.
return -1
numbers = [18, 7, 25, 7]
print(linear_search(numbers, 7))
print(linear_search(numbers, 9))
print(linear_search([], 7))
코드 해설
이 코드의 목적은 목록을 수정하지 않고 목표값의 첫 위치를 찾는 것입니다. items는 조사 대상, target은 찾을 값, index와 value는 현재 위치와 값입니다. enumerate는 각 원소에 0부터 시작하는 번호를 붙여 줍니다.
반복문이 시작될 때마다 “현재 위치보다 앞에는 목표값이 없다”는 사실이 유지됩니다. 처음에는 앞쪽 원소가 없으므로 참이고, 현재 값이 다르면 이 사실을 한 칸 확장할 수 있습니다. 같다면 앞에는 목표값이 없으므로 현재 인덱스가 첫 위치입니다. 이런 식으로 반복 과정에서 계속 유지되는 성질을 **반복 불변식(Loop Invariant)**이라고 합니다. 아직 형식적으로 증명할 필요는 없지만, 코드가 왜 맞는지 설명하는 좋은 도구입니다.
마지막 return -1은 반복문 안에 두지 않습니다. 안에 두면 첫 원소가 다르다는 이유만으로 탐색 전체를 실패 처리하게 됩니다. return은 반복만 멈추는 break와 달리 함수 자체를 종료한다는 점도 확인하세요.
실행 결과
1
-1
-1
시간·공간 복잡도
첫 원소가 목표값이면 비교 한 번이므로 최선 시간은 O(1)입니다. 목표값이 없거나 맨 끝에 있으면 n개를 비교하므로 최악 시간은 O(n)입니다. 목표값이 각 위치에 같은 확률로 있다고 가정하면 평균 비교 횟수는 (1+2+…+n)/n=(n+1)/2이므로 평균도 O(n)입니다. 평균 분석에는 이처럼 입력에 대한 가정이 필요합니다.
입력 목록 자체를 제외하면 인덱스와 현재 값 등 일정한 수의 변수만 추가하므로 보조 공간은 O(1)입니다. 이 함수는 목록을 복사하지 않습니다. 반환값 하나도 입력 크기에 비례해 커지는 구조가 아닙니다.
생각해보기와 핵심 정리
마지막 return -1을 반복문 안으로 옮기면 어떤 입력에서 틀릴까요?
중복값의 마지막 위치가 필요하다면 무엇을 바꾸어야 할까요?
핵심은 ‘저장 방식’과 ‘처리 절차’를 함께 보되 구분하는 것입니다. 결과의 의미를 먼저 정하면 구현과 검증의 기준이 생깁니다. 첫 질문은 [18, 7]에서 7을 찾는 예로 확인할 수 있습니다. 두 번째 질문에서는 발견 즉시 종료하지 않고 마지막으로 발견한 인덱스를 기억해야 합니다.
1.2 추상 자료형
학습 목표
추상 자료형과 구체적인 구현의 차이를 설명한다.
연산의 사전조건·사후조건을 읽고 정의한다.
같은 인터페이스를 유지하면서 내부 저장 방식을 바꿀 수 있는 이유를 이해한다.
문제에서 개념으로
자동차를 운전할 때 페달을 밟으면 어떤 동작이 일어나는지는 알아야 하지만, 엔진 내부의 모든 부품을 알아야 출발할 수 있는 것은 아닙니다. 프로그램에서도 사용자가 알아야 하는 약속과 제작자가 결정할 내부 구조를 분리하면 복잡성을 줄일 수 있습니다.
**추상 자료형(Abstract Data Type, ADT)**은 데이터와 그 데이터에 허용되는 연산을 구현 방식과 분리하여 정의한 것입니다. 여기서 ‘추상’은 불명확하다는 뜻이 아닙니다. 오히려 사용자가 기대할 행동을 정확하게 명시하고, 그 행동을 실현하는 저장 장치나 세부 코드는 나중에 결정한다는 뜻입니다.
예를 들어 작은 보관함을 생각해 봅시다. 보관함은 물건을 하나 보관하고, 물건을 꺼내며, 비어 있는지 알려 줍니다. 물건을 Python 리스트에 넣을지 별도 속성에 저장할지는 사용자의 관심사가 아닙니다. 사용자는 이미 차 있는 보관함에 넣으면 어떻게 되는지, 빈 보관함에서 꺼내면 어떻게 되는지 알아야 합니다.
연산 실행 전에 만족해야 하는 조건을 사전조건(Precondition), 정상 종료 후 보장되는 상태를 **사후조건(Postcondition)**이라고 합니다. 이 책은 잘못된 연산을 조용히 무시하지 않고 예외로 알리는 방식을 주로 사용합니다. 실패와 정상 데이터가 섞이지 않게 하기 위해서입니다. None도 정상적으로 저장할 수 있게 만들려면 ‘비어 있음’을 단순히 None으로 판정해서는 안 됩니다.
구조와 작은 예제
단일 보관함 ADT의 연산을 정해 봅시다.
연산
사전조건
정상 종료 후 보장
조건 위반
put(item)
비어 있음
item을 보관하고 가득 참
OverflowError
take()
비어 있지 않음
기존 물건을 반환하고 비움
IndexError
is_empty()
없음
빈 상태를 참/거짓으로 반환
없음
상태 변화는 ‘빈 보관함 → put(42) → 42를 가진 보관함 → take() → 빈 보관함’입니다. take()가 반환하는 42와 보관함에 남는 상태를 따로 보세요. 반환값은 호출자에게 전달되는 데이터이고, 상태 변화는 객체 내부에 남는 변화입니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
class SingleBox:
def __init__(self):
self._occupied = False
self._item = None
def is_empty(self):
return not self._occupied
def put(self, item):
if self._occupied:
raise OverflowError("보관함이 가득 찼습니다.")
self._item = item
self._occupied = True
def take(self):
if self.is_empty():
raise IndexError("보관함이 비었습니다.")
item = self._item
# 반환할 값은 지역 변수에 보존하고 내부 참조는 제거한다.
self._item = None
self._occupied = False
return item
box = SingleBox()
box.put(None)
print(box.is_empty())
print(box.take())
print(box.is_empty())
코드 해설
SingleBox는 ADT 자체가 아니라 그 약속을 실현한 하나의 클래스입니다. 클래스 문법은 2장에서 자세히 다루므로 지금은 점 뒤의 이름이 해당 보관함의 상태나 행동을 가리킨다고 읽으면 됩니다. self는 현재 보관함 객체입니다.
_occupied는 물건의 존재 여부를 별도로 기록합니다. 따라서 _item에 None이 있어도 ‘None이라는 값을 보관한 상태’와 ‘아무것도 없는 상태’를 구분할 수 있습니다. 밑줄로 시작하는 이름은 외부에서 직접 수정하지 말자는 관례입니다. Python이 접근을 완전히 금지해 주는 장치는 아닙니다.
take는 우선 실패 조건을 검사한 다음 물건을 임시로 기억합니다. 내부를 비우기 전에 값을 보존하지 않으면 반환할 물건을 잃어버립니다. 향후 스택과 큐에서도 ‘꺼낼 값 보존 → 내부 상태 수정 → 값 반환’이라는 흐름이 반복됩니다.
외부 코드는 put, take, is_empty만 사용하면 됩니다. 내부를 길이 0 또는 1인 리스트로 바꾸어도 이 세 연산의 행동을 지키면 사용 코드를 바꿀 필요가 없습니다. 이것이 인터페이스를 안정적으로 정하는 이유입니다.
실행 결과
False
None
True
시간·공간 복잡도
모든 연산이 입력 데이터의 개수에 따라 반복하지 않으므로 기본 모형에서 시간 O(1), 보조 공간 O(1)입니다. 보관하는 객체가 큰 리스트여도 이 구현은 그 리스트 전체를 복사하지 않고 참조를 저장합니다. 보관함이 가리키는 물건의 전체 크기와 보관함 구현 자체의 추가 공간을 구분해야 합니다.
ADT가 연산을 정의한다고 해서 모든 구현의 비용이 같아지는 것은 아닙니다. 나중에 같은 리스트 ADT를 배열과 연결 구조로 구현하면 인덱스 접근과 삽입의 비용이 달라집니다.
생각해보기와 핵심 정리
반환값 None만 보고 빈 보관함이었다고 결론 내릴 수 있을까요?
put이라는 이름은 같지만 기존 물건을 덮어쓰는 구현도 위 ADT를 만족할까요?
첫 답은 ‘아니요’입니다. None도 저장 가능한 값입니다. 두 번째 답도 ‘아니요’입니다. 가득 찼을 때 예외를 발생시킨다는 약속을 어기기 때문입니다. ADT를 지킨다는 것은 메서드 이름뿐 아니라 실패 동작과 상태 변화까지 지킨다는 뜻입니다.
1.3 알고리즘의 성능 분석
학습 목표
실행 시간 측정과 연산 횟수 분석을 구분한다.
Big-O를 증가율의 상한으로 해석한다.
반복문의 구조에서 상수·선형·로그·제곱 증가를 찾아낸다.
보조 공간과 입력·출력 공간을 구분한다.
문제에서 개념으로
같은 코드를 다른 컴퓨터에서 실행하면 걸리는 시간이 달라집니다. 같은 컴퓨터에서도 다른 작업, 인터프리터의 준비 상태, 입력 구성에 따라 달라질 수 있습니다. 시간을 재는 실험은 실제 환경에서 유용하지만 “데이터가 열 배가 되면 얼마나 더 힘들어지는가”라는 구조적인 질문에는 연산 횟수 분석이 더 적합합니다.
**시간 복잡도(Time Complexity)**는 입력 크기에 따라 필요한 작업량이 어떻게 증가하는지 나타냅니다. **공간 복잡도(Space Complexity)**는 필요한 기억 공간의 증가를 나타냅니다. 이 책의 ‘보조 공간’은 주어진 입력 자체를 제외하고 알고리즘이 추가로 사용하는 공간을 뜻합니다. 결과 목록을 새로 만든다면 그것을 포함하는지 따로 적습니다.
Big-O 표기법O(g(n))은 충분히 큰 n에서 실제 비용이 어떤 상수배의 g(n)을 넘지 않는다는 점근적 상한입니다. 예를 들어 3n+5는 n이 커질수록 선형 항의 영향이 지배적이므로 O(n)입니다. 상수 3과 5를 없앤다고 해서 실행 시간이 실제로 같다는 뜻은 아닙니다. 작은 입력에서는 상수 차이가 오히려 중요할 수 있습니다.
엄밀히 말하면 선형 알고리즘은 O(n²)이기도 합니다. 선형 함수도 충분히 큰 제곱 함수 아래에 있기 때문입니다. 다만 이 책의 표에는 유용한 비교를 위해 가능한 한 타이트한 증가 차수를 씁니다. 위아래가 같은 차수로 묶이는 것을 Θ(세타), 하한을 Ω(오메가)라고 부릅니다. Big-O 자체가 ‘최악’이라는 뜻은 아닙니다. 최선·평균·최악 중 어느 비용 함수에 적용했는지를 별도로 말해야 합니다.
우선 비교·대입·일반적인 크기의 정수 연산을 각각 일정한 비용으로 가정합니다. 문자열 두 개의 비교나 매우 큰 정수의 곱셈은 실제로 데이터 길이에 의존합니다. 이런 연산이 핵심인 문제에서는 원소 개수 외에 문자열 길이나 정수 비트 수도 고려해야 합니다.
구조와 작은 예제
다음은 같은 n에 대해 서로 다른 방식으로 작업 횟수를 늘리는 예입니다.
형태
n=8
n=16
n=32
증가 이유
한 번 접근
1
1
1
n과 무관
1부터 배가하며 n 미만까지
3
4
5
두 배 늘어도 한 번 추가
모든 원소 한 번
8
16
32
원소 수와 비례
각 원소마다 로그 단계
24
64
160
두 비용이 곱해짐
모든 순서쌍
64
256
1024
n개마다 다시 n개 처리
반복문이 두 개 보인다고 무조건 O(n²)은 아닙니다. 순서대로 이어지면 n+n=2n이므로 O(n)이고, 중첩되어 각 바깥 반복마다 안쪽 반복이 n번 실행되면 n×n이 됩니다. 안쪽 반복 길이가 바뀌면 실제 합을 세어야 합니다. 예를 들어 for j in range(i)는 총 0+1+…+(n-1)=n(n-1)/2번입니다.
로그는 ‘몇 번 나누거나 곱해야 목표 크기에 도달하는가’로 읽으면 쉽습니다. 1을 k번 두 배로 만들면 2ᵏ입니다. 이것이 n에 도달하는 k가 log₂n입니다. 로그의 밑은 고정된 값이면 상수배 차이이므로 Big-O에서는 보통 생략합니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def count_work(n):
linear = 0
for _ in range(n):
linear += 1
triangular = 0
for i in range(n):
for _ in range(i):
triangular += 1
logarithmic = 0
size = 1
while size < n:
size *= 2
logarithmic += 1
return linear, triangular, logarithmic
for n in (4, 8, 16):
print(n, count_work(n))
코드 해설
이 코드는 시간을 재는 대신 세 가지 반복 구조가 핵심 작업을 몇 번 수행하는지 셉니다. linear는 각 원소를 한 번 처리한 횟수, triangular는 서로 다른 앞쪽 원소와 짝을 만든 횟수, logarithmic은 크기를 두 배로 확장한 횟수입니다.
n=4이면 안쪽 반복 횟수는 i=0에서 0회, i=1에서 1회, i=2에서 2회, i=3에서 3회로 합계 6회입니다. 반면 배가 과정은 1→2→4이므로 2회입니다. 반환된 세 수를 보면 n이 두 배가 될 때 선형 횟수는 두 배가 되고, 삼각형 횟수는 큰 n에서 약 네 배가 되며, 로그 횟수는 한 번만 증가합니다.
이 함수 전체의 시간은 세 부분의 합입니다. 그중 제곱 항이 가장 빨리 증가하므로 전체는 O(n²)입니다. 다만 이 코드는 증가율을 관찰하기 위한 예제이지 여러 작업을 함께 수행해야 하는 실제 문제의 권장 구현은 아닙니다.
실행 시간 실험에서는 time.perf_counter()로 시작과 끝을 재고 차이를 구할 수 있습니다. 입력 생성과 출력 시간을 알고리즘 시간에 섞지 않으며, 같은 입력을 복사해 여러 번 비교해야 합니다. 단 한 번의 시간으로 보편적인 우열을 주장해서는 안 됩니다.
실행 결과
4 (4, 6, 2)
8 (8, 28, 3)
16 (16, 120, 4)
시간·공간 복잡도
첫 부분은 O(n), 두 번째 부분은 O(n²), 세 번째 부분은 O(log n)입니다. 전체는 O(n²)이고, 카운터 몇 개만 사용하므로 기본 모형에서 보조 공간 O(1)입니다. range(n)은 n개의 정수를 모두 담은 리스트를 만들지 않습니다.
Python 리스트의 끝 추가 append는 보통 O(1)로 소개하지만 내부 배열을 키우는 특정 호출은 O(n)이 될 수 있습니다. 여러 번의 추가 비용을 합쳐 평균내면 한 번당 O(1)인 **분할 상환 분석(Amortized Analysis)**을 적용한 것입니다. 이는 입력의 확률을 가정하는 평균 분석과 다릅니다. 3장에서 다시 다룹니다.
생각해보기와 핵심 정리
O(n)인 반복문 두 개가 차례로 실행되면 왜 O(n²)이 아닐까요?
결과 목록에 n개의 값을 복사하면 보조 공간을 O(1)이라고 할 수 있을까요?
O(1)은 반드시 한 줄, 한 번의 CPU 명령이라는 뜻일까요?
순차 실행의 비용은 더하고, 중첩 실행은 실제 반복 횟수를 합산합니다. 새 목록은 n개의 참조를 보관하므로 결과 공간 O(n)입니다. O(1)은 입력 크기가 증가해도 작업량이 일정한 범위에 머문다는 의미이며, 실제로 몇 번의 명령을 수행하는지는 말하지 않습니다.
1.4 시간 복잡도 분석: 순환 알고리즘
학습 목표
순환을 재귀호출과 같은 의미로 이해하고 반복문과 구분한다.
기저 사례와 문제 크기의 감소를 확인한다.
호출 횟수와 최대 호출 깊이를 각각 분석한다.
같은 재귀 형태라도 추가 작업에 따라 비용이 달라짐을 설명한다.
문제에서 개념으로
여기서 **순환 알고리즘(Recursive Algorithm)**은 재귀 알고리즘을 뜻합니다. for나 while로 되풀이하는 반복과 혼동하지 않도록 이후에는 ‘재귀’라는 표현을 주로 씁니다. 재귀는 현재 문제를 같은 종류의 더 작은 문제로 바꾸어 해결하는 방법입니다.
1부터 n까지의 합을 구할 때, 1부터 n-1까지의 합을 이미 안다면 거기에 n만 더하면 됩니다. 즉 sum(n)=n+sum(n-1)입니다. 그러나 작은 문제를 끝없이 만들면 답이 나오지 않습니다. 더 이상 호출하지 않고 직접 답하는 **기저 사례(Base Case)**가 있어야 하며, 호출할 때마다 그 사례에 가까워져야 합니다. 이 예에서는 n=0일 때 합을 0으로 정합니다.
함수가 다른 함수를 호출하면 현재 함수의 지역 변수와 돌아올 위치를 기억해야 합니다. 재귀도 예외가 아닙니다. sum(4)는 sum(3)의 답을 기다리고, sum(3)은 sum(2)의 답을 기다립니다. 이러한 미완료 호출 정보가 쌓이는 구조를 **호출 스택(Call Stack)**이라고 합니다. 스택 자체는 4장에서 구현합니다.
재귀를 분석할 때는 두 질문을 분리해야 합니다. ‘전체 호출이 몇 번 발생하는가’는 시간에 관련되고, ‘동시에 미완료 상태로 몇 번까지 쌓이는가’는 스택 공간에 관련됩니다. 호출이 두 갈래로 퍼지는 알고리즘에서는 전체 호출 수가 많아도 최대 깊이는 비교적 작을 수 있습니다.
구조와 작은 예제
recursive_sum(3)의 흐름입니다.
방향
현재 호출
아직 해야 하는 일 또는 반환값
내려감
sum(3)
sum(2)를 구한 뒤 3 더하기
내려감
sum(2)
sum(1)을 구한 뒤 2 더하기
내려감
sum(1)
sum(0)을 구한 뒤 1 더하기
기저
sum(0)
0 반환
돌아옴
sum(1)
1+0=1 반환
돌아옴
sum(2)
2+1=3 반환
돌아옴
sum(3)
3+3=6 반환
이 표에서 함수 호출은 4번이고, 반환은 각각 한 번 일어납니다. 호출할 때와 반환할 때 별개의 문제가 생기는 것은 아닙니다. 하나의 함수 실행이 잠시 멈췄다가 이어지는 것입니다.
한편 n을 매번 절반으로 줄이는 함수는 16→8→4→2→1의 다섯 단계만 거칩니다. 감소 폭을 바꾸면 깊이의 증가율도 바뀝니다. 단, 두 절반을 모두 재귀로 처리한다면 한 갈래만 보는 것과 전체 호출 수가 달라집니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def recursive_sum(n):
if n < 0:
raise ValueError("n은 0 이상이어야 합니다.")
if n == 0:
return 0
# 작은 문제의 반환값을 받은 뒤 현재 n을 더한다.
return n + recursive_sum(n - 1)
def halving_depth(n):
if n < 1:
raise ValueError("n은 1 이상이어야 합니다.")
if n == 1:
return 0
return 1 + halving_depth(n // 2)
def fibonacci(n):
if n < 0:
raise ValueError("n은 0 이상이어야 합니다.")
if n < 2:
return n
# 같은 작은 문제가 여러 경로에서 반복 계산된다.
return fibonacci(n - 1) + fibonacci(n - 2)
print(recursive_sum(3))
print(halving_depth(16))
print(fibonacci(6))
코드 해설
recursive_sum의 핵심 변수 n은 남아 있는 문제의 크기입니다. n-1로 반드시 감소하므로 허용된 정수 입력에서는 0에 도달합니다. 음수 입력을 허용하면 0에서 멀어지기 때문에 먼저 거부합니다. 정수 입력이라는 사전조건을 가정하며 자료형 검사를 반복 호출마다 수행하지는 않습니다.
halving_depth는 n을 정수 나눗셈으로 줄입니다. n=16일 때 반환값은 4입니다. 호출 수는 기저 사례를 포함해 5번이지만, 이 함수가 세는 것은 ‘절반으로 줄인 횟수’라서 한 번 적습니다. n이 2의 거듭제곱이 아니어도 1에 도달하며 반환값은 ⌊log₂n⌋입니다.
fibonacci(6)은 fibonacci(5)와 fibonacci(4)를 부릅니다. 그런데 fibonacci(5) 역시 fibonacci(4)를 호출합니다. 이후에도 같은 문제가 반복되어 많은 계산이 중복됩니다. 재귀라는 문법이 느린 것이 아니라, 이 문제 분할 방식이 중복 작업을 만들고 있는 것입니다. 계산한 답을 기억하는 메모이제이션이나 앞에서부터 두 값을 갱신하는 반복 구현으로 개선할 수 있습니다.
Python은 일반적인 재귀호출을 반복문으로 자동 바꾸어 호출 스택을 없애 주지 않습니다. 입력 n이 크면 재귀 깊이 제한에 도달할 수 있습니다. 제한을 무작정 늘리기보다, 합산처럼 반복으로 쉽게 표현되는 작업은 반복 구현을 고려합니다. 트리처럼 문제 자체가 하위 문제로 나뉘는 경우에는 재귀가 구조를 매우 자연스럽게 표현합니다.
실행 결과
6
4
8
시간·공간 복잡도
재귀 합의 시간은 T(n)=T(n-1)+O(1)입니다. n번 작은 문제로 내려가므로 O(n), 동시에 n+1개 호출이 쌓이므로 보조 공간 O(n)입니다. 같은 합을 반복문으로 구하면 시간 O(n), 보조 공간 O(1)로 만들 수 있습니다.
절반 깊이는 T(n)=T(⌊n/2⌋)+O(1)이므로 시간·스택 공간 모두 O(log n)입니다. 반면 병합 정렬처럼 두 절반을 모두 처리하고 매 단계 합치는 데 n번 일하는 경우에는 T(n)=2T(n/2)+O(n)이 됩니다. 깊이가 log n이고 각 깊이의 작업 합이 O(n)이므로 전체 O(n log n)입니다. 이 분석은 12장에서 실제 코드와 연결합니다.
단순 피보나치는 T(n)=T(n-1)+T(n-2)+O(1)입니다. O(2ⁿ)은 유효한 상한이고, 더 타이트한 시간 차수는 Θ(φⁿ), φ≈1.618입니다. 최대 호출 깊이는 n에 비례하므로 스택 공간은 O(n)입니다. 두 재귀호출은 순서대로 실행되므로 모든 호출이 동시에 메모리에 쌓이는 것은 아닙니다. 위 시간 분석은 정수 덧셈을 일정 비용으로 보는 모형입니다.
주의: f(items[1:])처럼 슬라이싱하면 매 호출마다 남은 원소를 복사합니다. 겉보기에는 한 번씩만 재귀호출해도 복사 비용의 합이 n+(n-1)+…+1이 되어 O(n²)이 될 수 있습니다. 인덱스 범위를 전달하면 이런 복사를 피할 수 있습니다.
생각해보기와 핵심 정리
기저 사례가 있어도 n이 거기에 가까워지지 않으면 어떻게 될까요?
재귀 호출 수가 지수적으로 많으면 스택 공간도 반드시 지수적일까요?
기저 사례와 감소 규칙은 함께 필요합니다. 전체 호출 수와 동시에 살아 있는 호출 수는 다릅니다. 재귀를 분석할 때는 ‘하위 문제 수, 하위 문제 크기, 호출 밖의 작업, 최대 깊이’를 차례로 적어 보세요.
CHAPTER 핵심 정리
개념
핵심 내용
분석할 질문
자료구조
저장 방식과 데이터 관계
어떤 연산이 자주 필요한가?
알고리즘
입력을 출력으로 바꾸는 유한 절차
모든 허용 입력에서 맞는가?
ADT
구현과 분리한 연산의 약속
실패 조건까지 정의했는가?
시간 복잡도
입력에 따른 작업량 증가
핵심 작업을 몇 번 하는가?
보조 공간
입력 외에 추가되는 기억 공간
복사와 호출 스택을 세었는가?
재귀
더 작은 같은 문제로 환원
어디서 끝나며 얼마나 깊어지는가?
순차 탐색의 최악 시간은 O(n), 추가 공간은 O(1)입니다. 재귀 합은 같은 O(n) 시간이지만 스택 O(n)이 필요합니다. 따라서 시간만 같다고 구현의 특성이 모두 같은 것은 아닙니다.
연습문제
[기초] 문제 1
자료구조와 알고리즘을 각각 한 문장으로 설명하고, 도서관 예에서 각 개념에 해당하는 것을 적으세요.
[기초] 문제 2
O/X: ADT를 만족하는 두 구현은 내부 저장 방식과 실행 시간이 반드시 같아야 한다.
[기초] 문제 3
빈 목록에서 1.1의 linear_search를 실행하면 무엇을 반환하며 비교는 몇 번 하나요?
[기초] 문제 4
재귀가 올바르게 종료하려면 필요한 두 요소를 적으세요.
[응용] 문제 5
길이 n인 목록을 한 번 순회한 다음 같은 목록을 다시 한 번 순회합니다. 전체 시간 차수와 근거를 적으세요.
[응용] 문제 6
for i in range(n) 안에서 for j in range(i+1)이 실행됩니다. 안쪽 본문의 총 실행 횟수와 차수는?
[응용] 문제 7
recursive_sum(4)의 호출 인수를 순서대로 적고, 최대 동시 호출 수와 최종 반환값을 구하세요.
[응용] 문제 8
SingleBox에 None을 넣은 뒤 is_empty()가 False인 이유를 설명하세요.
[도전] 문제 9
길이 n인 목록에서 매번 첫 원소를 더하고 나머지 슬라이스 items[1:]로 재귀호출합니다. 슬라이스 복사를 포함한 시간과, 복사본들이 반환까지 유지될 때 최대 추가 공간을 분석하세요.
[도전] 문제 10
A는 T(n)=T(n/2)+1, B는 T(n)=2T(n/2)+n의 비용을 가집니다. n은 2의 거듭제곱입니다. 두 알고리즘의 시간 차수를 호출 깊이와 각 깊이의 작업량으로 설명하세요.
연습문제 정답 및 해설
문제 1
정답: 자료구조는 데이터를 저장하고 관계를 표현하는 방식, 알고리즘은 정답을 얻는 절차입니다.
해설: 책을 번호순으로 배치한 것은 저장 구조, 번호 범위를 좁히며 서가를 찾아가는 것은 절차입니다. 정렬된 배치만으로 찾기가 실행되는 것은 아닙니다.
문제 2
정답: X.
해설: 연산의 의미와 실패 행동을 지켜야 하지만 내부 구조는 달라도 됩니다. 비용 제약이 ADT의 명세에 별도로 포함되었다면 그 제약은 지켜야 합니다.
문제 3
정답: -1, 0번.
해설: 반복할 원소가 없으므로 반복문 본문에 진입하지 않고 마지막 반환문으로 갑니다. 실패를 알리기 위해 임의의 원소를 확인할 필요가 없습니다.
문제 4
정답: 직접 답을 반환하는 기저 사례와, 그 사례를 향해 문제 크기를 줄이는 규칙.
해설: n=0이 있어도 n+1로 호출하면 양수 입력에서 0에 도달하지 못합니다. 허용 입력 범위를 함께 정의해야 합니다.
문제 5
정답: 각 원소 처리가 O(1)이면 O(n).
해설: 총 처리 횟수는 n+n=2n입니다. 두 반복문이 중첩되지 않았으므로 n²을 곱하지 않습니다.
문제 6
정답: n(n+1)/2, O(n²).
해설: 각 행의 길이는 1, 2, …, n입니다. 첫 항과 끝 항을 짝지으면 합은 n+1이고 그런 짝이 n/2개입니다. 최고차항은 n²/2입니다.
문제 7
정답: 4, 3, 2, 1, 0 / 5개 / 10.
해설: 기저 호출도 호출 수에 포함합니다. 반환값은 0→1→3→6→10으로 올라옵니다. 전체 호출 수와 덧셈 횟수는 각각 5와 4로 다릅니다.
문제 8
정답: 저장된 값과 점유 상태를 별개 속성으로 관리하기 때문입니다.
해설: _item이 None이라는 사실만으로는 빈 상태를 판단할 수 없습니다. put은 _occupied를 True로 바꾸므로 None도 정상 데이터로 저장됩니다.
문제 9
정답: 둘 다 O(n²).
해설: 복사 길이는 n-1, n-2, …, 0입니다. 합은 n(n-1)/2입니다. 호출이 끝나기 전 각 프레임이 자신의 입력 목록을 참조하므로 복사된 목록들이 동시에 남아 같은 제곱 크기의 참조 공간을 차지할 수 있습니다. 인덱스로 재귀호출하면 복사 비용은 없어지지만 스택 O(n)은 남습니다.
문제 10
정답: A는 O(log n), B는 O(n log n).
해설: A는 각 깊이에 호출 하나와 상수 작업만 있고 깊이는 log₂n입니다. B는 깊이가 내려가면 호출 수는 두 배, 각 문제 크기는 절반이므로 한 깊이에서 합산한 작업은 n입니다. 이런 깊이가 log₂n개이며, 기저 호출 O(n)을 더해도 전체 차수는 O(n log n)입니다.
다음 학습으로
2장에서는 이 장에서 사용한 변수·함수·클래스를 객체 참조의 관점으로 다시 확인합니다. 재귀와 복잡도 분석은 이후 모든 장의 공통 도구입니다.
실행 파일은 code/ch01.py, 시연 출력 없이 가져오는 재사용 모듈은 code/core01.py입니다. 실습 정답은 실행 파일의 뒤쪽에 배치했습니다.
이 장은 문법을 외우기 위한 목록이 아닙니다. 이후 자료구조를 구현할 때 실제로 필요한 Python의 동작을 확인하는 장입니다. 특히 변수에 무엇이 저장되는지, 함수가 목록을 바꾸면 호출한 쪽에도 왜 변화가 보이는지, 클래스의 인스턴스마다 상태를 어떻게 분리하는지에 집중합니다. 코드는 위에서 아래로 실행하며, 각 예제를 읽은 뒤 출력부터 예측해 보세요.
2.1 파이썬이란?
학습 목표
소스 코드와 실행 환경을 구분한다.
예제를 파일로 저장하고 실행하는 흐름을 이해한다.
문제에서 개념으로
Python은 읽기 쉬운 문법과 다양한 기본 자료형을 제공하는 프로그래밍 언어입니다. 자료구조 학습에서는 메모리 할당의 세부 사항보다 구조와 알고리즘에 집중할 수 있다는 장점이 있습니다. 그러나 짧게 쓸 수 있다는 이유만으로 연산이 빠르다고 판단해서는 안 됩니다. 목록 복사는 한 줄이어도 원소 수만큼의 작업이 필요합니다.
이 책은 Python 3의 일반적인 문법을 사용하며 외부 패키지를 요구하지 않습니다. .py 파일은 프로그램을 적은 텍스트이고, Python 인터프리터는 그 파일을 읽어 실행하는 환경입니다. 대화형 창에서 한 줄씩 실행하면 이전 변수 상태가 남아 실수를 가릴 수 있으므로, 장별 예제는 새 프로세스에서 파일 전체를 실행해 확인하는 것이 좋습니다.
들여쓰기는 단순 장식이 아니라 블록의 범위입니다. 함수나 조건문 아래를 공백 네 칸으로 들여쓰고 탭과 섞지 않습니다. 주석은 # 뒤에 쓰며 ‘왜 이런 상태 변화가 필요한지’를 설명하는 데 사용합니다.
구조와 작은 예제
파일 이름을 hello.py로 저장한 뒤 터미널에서 python hello.py로 실행합니다. 환경에 따라 실행 명령이 python3일 수 있습니다. 파일명은 list.py, collections.py처럼 가져올 모듈과 겹치지 않게 정합니다. 오류 메시지가 있으면 마지막 줄의 예외 종류와 그 위의 파일·줄 번호를 함께 읽습니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
message = "자료구조 공부를 시작합니다."
print(message)
print(3 + 4)
코드 해설
첫 줄은 문자열 객체에 message라는 이름을 연결합니다. 다음 줄은 그 내용을 화면에 표시합니다. 마지막 줄은 식을 계산한 결과 7을 출력합니다. .py 파일에서는 식만 적는다고 자동 출력되지 않으므로 관찰할 값에는 print를 사용합니다. 대화형 환경과 파일 실행의 이 차이를 기억하세요.
실행 결과
자료구조 공부를 시작합니다.
7
시간·공간 복잡도
이 예제는 고정된 길이의 문장과 상수 계산만 수행합니다. 일반적으로 길이 m인 문자열 전체를 출력하면 출력량 자체가 m이므로 O(m)입니다. 입출력 비용을 항상 O(1)로 취급하지 않습니다.
생각해보기와 핵심 정리
생각해보기: 파일에서 3 + 4만 쓰면 7이 표시될까요? 표시되지 않습니다. 값 계산과 출력은 서로 다른 동작입니다.
2.2 자료형, 리터럴과 변수
학습 목표
변수 이름과 객체의 관계를 설명한다.
가변 객체 공유와 새 객체 재대입을 구분한다.
문제에서 개념으로
자료형(Type)은 값이 어떤 성질을 가지며 어떤 연산을 허용하는지 정합니다. 12, 2.5, "tree", True, None처럼 코드에 직접 적은 값의 표현을 리터럴(Literal)이라고 합니다. 정수는 int, 실수는 float, 문자열은 str, 논리값은 bool입니다. None은 값의 부재 등을 나타낼 때 사용하는 별도의 객체입니다.
Python의 변수는 ‘값이 담긴 고정 상자’보다 객체에 붙인 이름으로 이해하는 것이 정확합니다. second = first는 객체 전체를 복사하는 명령이 아닙니다. 두 이름이 같은 객체를 가리킬 수 있습니다. 리스트처럼 내용을 바꿀 수 있는 가변(Mutable) 객체에서는 한 이름을 통해 수정한 내용이 다른 이름에도 보입니다. 정수와 문자열처럼 불변(Immutable)인 객체는 기존 내용을 바꾸지 못하므로 연산 결과인 새 객체를 이름에 연결합니다.
구조와 작은 예제
명령
first가 가리키는 목록
second가 가리키는 목록
관계
first=[10,20]
[10,20]
없음
목록 생성
second=first
[10,20]
[10,20]
같은 목록
second.append(30)
[10,20,30]
[10,20,30]
같은 객체 수정
second=[99]
[10,20,30]
[99]
이름을 새 객체에 연결
참조 그림을 그릴 때는 두 이름을 두 개의 복사본으로 그리지 말고, 같은 목록을 가리키는 관계로 표시해야 합니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
first = [10, 20]
second = first
second.append(30)
print(first)
second = [99]
print(first, second)
value = 10
other = value
other += 1
print(value, other)
코드 해설
append는 공유 중인 리스트 자체를 바꿉니다. 반면 second = [99]는 second가 가리킬 대상을 바꾸며 first의 연결은 그대로 둡니다. 정수의 other += 1은 10이라는 객체 내부를 수정하는 것이 아니라 결과 11을 other에 다시 연결합니다. 가변성은 이름이 바뀔 수 있는지와 다른 개념입니다.
실행 결과
[10, 20, 30]
[10, 20, 30] [99]
10 11
시간·공간 복잡도
참조를 대입하는 것은 기본 모형에서 O(1)입니다. 원소 n개를 복사해 별도 목록을 만들면 시간·공간 O(n)입니다. 매우 큰 정수는 비트 수에 따라 덧셈·곱셈 비용이 증가할 수 있습니다.
생각해보기와 핵심 정리
생각해보기: a = b만 실행해서 목록 복사본을 얻을 수 있을까요? 없습니다. 두 이름은 같은 객체를 참조할 수 있습니다. 핵심은 객체 수정과 이름 재대입의 구분입니다.
2.3 파이썬의 연산
학습 목표
나눗셈·나머지의 의미를 이해한다.
동등성과 동일성, 단락 평가를 구분한다.
문제에서 개념으로
자료구조 구현에서 자주 쓰는 연산은 사칙연산만이 아닙니다. 원형 큐에서는 나머지 %로 배열 끝에서 처음으로 돌아가고, 힙에서는 정수 나눗셈 //로 부모 위치를 계산합니다. /는 실수 나눗셈이며 //는 음의 무한대 방향으로 내림한 몫입니다. 따라서 -7 // 3은 -2가 아니라 -3입니다. 나머지는 a == (a // b) * b + a % b의 관계를 만족합니다.
==는 두 값이 같은지 비교하고, is는 동일한 객체인지를 확인합니다. 값 비교에 is를 쓰면 안 됩니다. 다만 None이라는 특별한 객체를 확인할 때 is None을 사용합니다. 비교 연산은 참 또는 거짓을 만들지만 and, or는 피연산자 자체를 반환할 수 있습니다. 조건식에서 주로 쓰더라도 이 차이는 알아둘 필요가 있습니다.
and는 앞 조건이 거짓이면 뒤를 평가하지 않는 단락 평가(Short-circuit Evaluation)를 합니다. 인덱스가 유효한지 먼저 확인하고 그다음 목록에 접근하면 범위 오류를 피할 수 있습니다. 조건 순서도 프로그램의 의미입니다.
구조와 작은 예제
빈 목록에서 items[0]은 실패합니다. 하지만 items and items[0] > 0은 items가 비어 있으면 두 번째 식을 평가하지 않습니다. 숫자 0, 빈 컬렉션, None은 조건식에서 거짓으로 해석됩니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
print(7 / 3, 7 // 3, 7 % 3)
print(-7 // 3, -7 % 3)
left = [1, 2]
right = [1, 2]
print(left == right, left is right)
items = []
print(bool(items and items[0] > 0))
print(0 or 5)
코드 해설
서로 별도로 만든 두 리스트는 값이 같으므로 ==는 True지만 객체는 다르므로 is는 False입니다. 마지막 출력이 True가 아니라 5인 이유는 or가 첫 피연산자가 거짓일 때 두 번째 피연산자를 반환하기 때문입니다. 복잡한 조건은 괄호로 의도를 분명히 하세요.
실행 결과
2.3333333333333335 2 1
-3 2
True False
False
5
시간·공간 복잡도
일반 크기의 숫자 비교는 O(1)로 보지만, 리스트 동등성 비교는 원소를 순서대로 비교하므로 길이 n에서 최악 O(n)입니다. 원소의 비교도 비싸면 그 비용을 추가해야 합니다.
생각해보기와 핵심 정리
생각해보기: 양수 capacity에 대해 (index + 1) % capacity는 어떤 범위에 있나요? 0부터 capacity-1입니다. 이 성질을 5장 원형 큐에 사용합니다.
2.4 함수 호출과 입출력 함수
학습 목표
인수와 반환값의 흐름을 설명한다.
입력 문자열을 변환하고 변환 실패를 처리한다.
문제에서 개념으로
함수 호출은 이름 뒤 괄호에 필요한 인수(Argument)를 전달하고, 실행이 끝나면 반환값(Return Value)을 받는 과정입니다. print는 화면에 표시하는 함수이지 계산 결과를 다른 코드에 전달하는 수단이 아닙니다. 계산 함수에서는 return으로 값을 돌려주고, 표시 여부는 호출자가 결정하도록 하면 재사용과 검증이 쉬워집니다.
input은 사용자가 숫자를 입력하더라도 문자열을 반환합니다. 정수가 필요하면 int로 변환해야 합니다. int("12")는 가능하지만 int("열둘")은 ValueError를 발생시킵니다. 예상 가능한 사용자 입력 오류는 try와 except ValueError로 처리할 수 있습니다. 모든 예외를 한꺼번에 숨기면 실제 코드 오류까지 가려지므로 필요한 종류만 처리합니다.
구조와 작은 예제
터미널에서 age = int(input("나이: "))를 실행하면 입력→문자열→정수 변환의 순서를 거칩니다. 아래에서는 자동 실행이 가능하도록 입력 문자열을 함수 인수로 전달합니다. 실제 입력에서도 같은 변환 함수를 사용할 수 있습니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def parse_count(text):
try:
count = int(text)
except ValueError:
return None
# 변환 성공과 값의 허용 범위는 별도로 검사한다.
return count if count >= 0 else None
print(parse_count("12"))
print(parse_count("열둘"))
print(parse_count("-3"))
코드 해설
text는 함수 정의에서 인수를 받는 매개변수(Parameter)입니다. "12"는 호출할 때 전달한 실제 인수입니다. 변환 실패와 음수 입력은 모두 None으로 나타냅니다. 여기서는 정상 결과가 음이 아닌 정수이므로 None과 혼동되지 않습니다. 반환값을 검사한 호출자는 오류 메시지를 출력하거나 다시 입력받을 수 있습니다.
실행 결과
12
None
None
시간·공간 복잡도
길이 m인 입력을 읽고 해석하는 비용은 입력 길이에 의존합니다. 짧은 입력을 다루는 예제에서는 핵심 자료구조 연산과 구분하며, 무조건 O(1)이라고 일반화하지 않습니다.
생각해보기와 핵심 정리
생각해보기: 0을 정상 결과로 허용할 때 if not result로 실패를 판정해도 될까요? 안 됩니다. 0도 거짓이므로 result is None으로 구분합니다.
2.5 제어 구조와 반복
학습 목표
for와 while을 목적에 맞게 선택한다.
break·continue·종료 조건을 설명한다.
문제에서 개념으로
if는 조건에 따라 실행할 블록을 선택합니다. for는 컬렉션이나 range에서 원소를 하나씩 받아 반복하고, while은 조건이 참인 동안 반복합니다. 정해진 원소를 모두 처리할 때는 for가 자연스럽고, 목표를 찾거나 작업 대기열이 빌 때까지 처리할 때는 while이 자연스럽습니다.
break는 가장 가까운 반복문을 종료합니다. continue는 현재 반복의 나머지를 건너뛰고 다음 반복으로 진행합니다. 특히 while에서 continue 앞에 상태 갱신을 빠뜨리면 같은 상태가 계속 남아 무한 반복이 될 수 있습니다. 1장의 재귀와 마찬가지로 반복에서도 ‘무엇이 변하여 종료되는가’를 확인해야 합니다.
구조와 작은 예제
음수는 건너뛰고, 0이 나오면 입력 목록 처리를 멈추며, 양수만 더해 봅시다. [3,-2,5,0,9]에서 9는 0 뒤에 있으므로 합산하지 않습니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def positive_sum_until_zero(values):
total = 0
for value in values:
if value == 0:
break
if value < 0:
continue
total += value
return total
print(positive_sum_until_zero([3, -2, 5, 0, 9]))
size = 16
while size > 1:
print(size, end=" ")
size //= 2
print(size)
코드 해설
total에는 지금까지 확인한 양수의 합만 남습니다. 0 검사는 continue와 독립적으로 먼저 수행합니다. while 예제는 size가 정수 나눗셈으로 감소하므로 1에 도달합니다. 반복 횟수가 데이터 값에 따라 달라질 수 있다는 점을 확인하세요.
실행 결과
8
16 8 4 2 1
시간·공간 복잡도
첫 함수는 최대 n개를 확인하므로 O(n) 시간, O(1) 보조 공간입니다. 두 번째 반복은 초기 크기 n을 절반씩 줄이므로 O(log n)회입니다. 출력 문자가 차지하는 비용은 별도입니다.
생각해보기와 핵심 정리
생각해보기: break 대신 return을 쓰면 어디까지 끝날까요? 함수 전체가 끝납니다. 반복 종료와 함수 종료는 범위가 다릅니다.
2.6 컬렉션 자료형
학습 목표
list·tuple·set·dict의 역할을 구분한다.
얕은 복사에서 내부 객체가 공유될 수 있음을 설명한다.
문제에서 개념으로
컬렉션(Collection)은 여러 값을 함께 관리하는 자료형입니다. 리스트(list)는 순서가 있고 중복을 허용하며 내용을 바꿀 수 있습니다. 튜플(tuple)은 순서가 있지만 원소 참조를 바꿀 수 없습니다. 집합(set)은 중복 없는 원소의 소속 여부를 다루며 위치 기반 순서를 제공하지 않습니다. 딕셔너리(dict)는 키에 값을 연결하는 맵입니다.
집합 원소와 딕셔너리 키에는 해시 가능한 객체가 필요합니다. 일반적인 정수·문자열은 가능하지만 리스트는 불가능합니다. 튜플이라도 내부에 리스트처럼 해시 불가능한 원소가 있으면 키로 쓸 수 없습니다. ‘불변이면 무조건 무엇이든 키로 쓸 수 있다’고 외우지 마세요.
리스트의 copy()나 전체 슬라이스는 바깥 목록만 복사하는 얕은 복사(Shallow Copy)입니다. 내부 원소가 또 다른 리스트이면 그 내부 객체는 공유됩니다. 행렬을 [[0] * 3] * 2로 만들면 같은 행 두 개를 가리키는 문제가 생깁니다. 행마다 새 목록을 만드는 컴프리헨션을 사용해야 합니다.
구조와 작은 예제
자료형
주요 목적
중복·순서
주의점
list
순서 있는 작업 목록
중복 가능, 위치 있음
중간 삽입은 이동 필요
tuple
바뀌지 않는 묶음
중복 가능, 위치 있음
내부 가변 객체까지 얼리지는 않음
set
소속·중복 제거
중복 없음, 인덱스 없음
출력 순서에 의존하지 않음
dict
키→값 조회
키 중복 없음, 삽입 순서 유지
같은 키 대입은 값 갱신
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
리스트 인덱스 접근 O(1), 소속 검사 O(n), 얕은 복사 O(n)입니다. dict와 set의 조회는 해시 분포와 부하율 등의 가정 아래 평균 O(1), 충돌이 심하면 최악 O(n)입니다. n×m 행렬을 만들면 시간·공간 O(nm)입니다.
생각해보기와 핵심 정리
생각해보기: 튜플 ( [1], [2] ) 내부의 리스트에 append할 수 있을까요? 가능합니다. 튜플이 고정하는 것은 원소를 가리키는 참조입니다.
2.7 사용자 정의 함수
학습 목표
수정하는 함수와 새 결과를 만드는 함수를 구분한다.
가변 기본 인수의 공유 문제를 피한다.
문제에서 개념으로
def로 함수를 정의하면 작업에 이름을 붙이고 입력과 출력을 명확히 할 수 있습니다. 기본 인수는 인수를 생략했을 때 사용하는 값인데, 함수가 호출될 때마다 만들어지는 것이 아니라 함수 정의가 실행될 때 한 번 만들어집니다. 기본값을 빈 리스트로 두고 수정하면 여러 호출이 같은 리스트를 공유합니다.
자료구조 함수에서는 입력을 직접 바꾸는지, 새 결과를 반환하는지 반드시 약속합니다. 정렬 함수는 제자리 수정과 새 목록 반환을 혼용하기 쉬운 예입니다. 이 책에서는 함수 이름만으로 판단하지 않도록 절마다 수정 여부를 설명합니다.
구조와 작은 예제
collect(1)과 collect(2)를 서로 독립된 새 수집으로 만들려면 기본값을 None으로 받고 함수 안에서 새 리스트를 생성합니다. 사용자가 기존 리스트를 넘기면 그 리스트를 수정한다는 약속을 따로 둡니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def collect(item, bucket=None):
if bucket is None:
bucket = [] # 호출마다 독립적인 목록을 만든다.
bucket.append(item)
return bucket
print(collect(1))
print(collect(2))
shared = [10]
print(collect(20, shared))
print(shared)
코드 해설
bucket은 함수의 지역 이름이지만 기존 객체를 전달받았다면 그 객체는 호출자와 공유됩니다. 지역 변수라는 이유로 객체 수정까지 지역에 한정되는 것은 아닙니다. bucket = []는 지역 이름 재연결, bucket.append는 객체 수정입니다.
실행 결과
[1]
[2]
[10, 20]
[10, 20]
시간·공간 복잡도
append는 분할 상환 O(1)입니다. 새 빈 목록 생성도 O(1)이며, 이미 받은 목록 전체를 복사하지 않습니다. 입력을 보존하려고 bucket.copy()를 추가하면 길이 n에 대해 O(n) 비용이 생깁니다.
생각해보기와 핵심 정리
생각해보기: 입력 목록을 보존해야 한다면 어디에서 복사할까요? 함수 시작에 새 목록을 만들면 됩니다. 그때 복사 깊이와 비용까지 명세해야 합니다.
2.8 변수의 범위
학습 목표
지역 이름과 바깥 이름을 구분한다.
재대입과 객체 수정이 범위 규칙과 어떻게 연결되는지 설명한다.
문제에서 개념으로
이름을 찾을 때 Python은 일반적으로 지역(Local), 감싸는 함수(Enclosing), 전역(Global), 내장(Built-in) 범위를 살펴봅니다. 이를 LEGB라고 줄여 부릅니다. 함수 안에서 이름에 대입하면 보통 그 이름은 함수의 지역 이름으로 취급됩니다. 바깥에 같은 이름이 있어도 자동으로 그것을 수정하지 않습니다.
global은 모듈 전역 이름에 재대입할 때, nonlocal은 감싸는 함수의 이름에 재대입할 때 사용할 수 있습니다. 하지만 자료구조의 상태를 여러 전역 변수로 나누면 서로 다른 자료구조가 상태를 공유하는 오류가 생기기 쉽습니다. 이후에는 각 객체의 속성으로 상태를 묶겠습니다.
구조와 작은 예제
바깥 count=100과 함수 안 count=3은 별개의 이름입니다. 반면 함수 매개변수로 받은 리스트에 append하면 같은 리스트 객체가 수정됩니다. ‘범위’는 이름을 찾는 규칙이고 ‘공유’는 객체 관계입니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
count = 100
def local_example():
count = 3
return count
def make_counter():
value = 0
def increase():
nonlocal value
value += 1
return value
return increase
print(local_example(), count)
counter = make_counter()
print(counter(), counter())
코드 해설
make_counter가 종료되어도 내부 함수는 자신이 사용하는 바깥 value와의 관계를 유지합니다. 이를 클로저(Closure)라고 합니다. 이 책에서 클로저를 능숙하게 구현할 필요는 없지만, 함수 호출이 끝나면 모든 관련 데이터가 무조건 사라지는 것은 아니라는 점을 보여 줍니다.
실행 결과
3 100
1 2
시간·공간 복잡도
예제의 각 증가 연산은 일반 크기 정수 모형에서 O(1)입니다. 카운터 하나가 보유하는 상태 수도 일정합니다. 범위가 넓다고 시간 복잡도가 자동으로 O(n)이 되는 것은 아닙니다.
생각해보기와 핵심 정리
생각해보기: 함수 안에서 바깥 count를 읽은 뒤 같은 이름에 대입하면 어떤 문제가 생길 수 있나요? 지역 이름으로 판정되어 대입 전 참조 시 UnboundLocalError가 날 수 있습니다. 매개변수와 반환값으로 전달하면 의도를 명확히 할 수 있습니다.
2.9 모듈과 이름 공간(namespace)
학습 목표
import와 이름 공간의 목적을 설명한다.
모듈 재사용 시 예제 실행을 분리한다.
문제에서 개념으로
모듈(Module)은 함수·클래스 등의 정의를 모아 둔 파일 단위입니다. 이름 공간(Namespace)은 이름을 객체에 대응시키는 영역입니다. 여러 모듈에서 같은 이름을 사용해도 module.name처럼 출처를 붙이면 충돌을 줄일 수 있습니다.
import math 후 math.sqrt를 쓰면 어디서 온 기능인지 드러납니다. from math import sqrt는 현재 이름 공간에 sqrt를 직접 가져옵니다. from module import *는 어떤 이름이 들어오는지 모호해지므로 사용하지 않습니다. 나중에 장별 구현을 모듈로 나눌 때도 필요한 이름만 가져옵니다.
구조와 작은 예제
독립적인 모듈을 만들 때는 함수 정의 아래의 시연 코드를 if __name__ == "__main__": 안에 둡니다. 직접 실행할 때만 시연하고, import할 때는 함수·클래스 정의만 준비할 수 있습니다. 배포 원고의 장별 파일은 학습 순서대로 출력을 확인하는 실행용 파일입니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
import math
from collections import deque
print(math.isqrt(81))
pending = deque(["A", "B"])
print(pending.popleft())
코드 해설
math.isqrt는 정수 제곱근의 내림값을 반환합니다. deque는 표준 라이브러리의 덱입니다. 여기서는 가져오는 문법만 확인하고, 양끝 연산이 필요한 이유와 직접 구현은 5장에서 다룹니다. 표준 기능을 쓰는 것과 내부 구현을 배우는 것은 서로 다른 학습 목적입니다.
실행 결과
9
A
시간·공간 복잡도
모듈을 처음 가져오는 초기화 비용과 이후 자료구조 연산 비용을 분리합니다. deque의 양끝 추가·제거는 O(1)입니다. 이 절의 isqrt를 임의 크기 정수에 대해 무조건 O(1)로 일반화하지 않습니다.
생각해보기와 핵심 정리
생각해보기: 자신의 파일을 math.py로 만들면 왜 곤란할까요? 표준 모듈 대신 같은 이름의 로컬 파일을 가져와 예상하지 못한 동작을 만들 수 있습니다.
2.10 클래스
학습 목표
인스턴스마다 상태를 독립적으로 관리한다.
생성자와 메서드의 역할을 설명한다.
문제에서 개념으로
리스트 구현에 저장 배열, 원소 수, 용량이 함께 필요하다고 가정해 봅시다. 이를 별개 전역 변수로 두면 두 개의 리스트를 동시에 만들기 어렵습니다. 클래스(Class)는 관련 데이터와 연산을 묶어 같은 구조의 객체를 여러 개 만들도록 합니다. 만들어진 개별 객체를 인스턴스(Instance)라고 합니다.
__init__은 생성된 인스턴스의 초기 상태를 설정합니다. self는 메서드를 호출한 인스턴스를 가리킵니다. 각 인스턴스마다 달라야 하는 목록은 self.items = []처럼 초기화해야 합니다. 클래스 본문에 공용 리스트를 두면 모든 인스턴스가 그것을 공유할 수 있습니다.
구조와 작은 예제
첫 수첩에만 "stack"을 기록하면 두 번째 수첩은 비어 있어야 합니다. 이 독립성이 이후 두 개의 큐나 두 개의 탐색트리를 동시에 사용할 때도 필요합니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
Notebook("민")은 새 객체를 만들고 초기화합니다. first_book.add를 호출하면 self가 first_book인 상태로 메서드를 실행합니다. __len__은 len(first_book)과 연결되는 특별한 메서드입니다. 길이를 따로 세는 구조에서도 같은 인터페이스를 제공할 수 있습니다.
실행 결과
1 0
시간·공간 복잡도
add는 리스트 append에 의해 분할 상환 O(1), len은 저장된 길이를 읽으므로 O(1)입니다. 기록 n개를 보관하는 참조 공간은 O(n)이며 문자열 객체 자체의 크기는 별도입니다.
생각해보기와 핵심 정리
생각해보기: _notes = []를 클래스 본문으로 옮기면 어떤 위험이 생길까요? 인스턴스가 공용 리스트를 수정할 수 있습니다. 각 객체의 상태는 초기화 메서드에서 준비합니다.
2.11 연산자 중복
학습 목표
연산자 중복을 operator overloading으로 이해한다.
지원하지 않는 피연산자에 NotImplemented를 반환한다.
문제에서 개념으로
이 목차의 ‘연산자 중복’은 같은 연산자 기호에 사용자 정의 객체의 의미를 부여하는 **연산자 오버로딩(Operator Overloading)**입니다. 같은 이름의 함수를 인수 개수별로 여러 번 정의한다는 뜻이 아닙니다. Python에서 같은 범위에 같은 함수 이름을 다시 정의하면 보통 이전 이름 연결을 덮어씁니다.
숫자 +는 덧셈, 문자열 +는 연결입니다. 좌표 객체에도 +를 정의하면 두 좌표의 성분별 덧셈을 표현할 수 있습니다. __add__는 +, __eq__는 ==, __repr__는 개발자가 읽을 표현과 연결됩니다. 편리하더라도 예상하기 어려운 의미를 부여하면 코드가 오히려 혼란스러워집니다.
구조와 작은 예제
좌표 (2,3)과 (4,-1)을 더하면 (6,2)입니다. 원래 두 객체는 보존하고 새 좌표 객체를 반환하는 약속을 사용합니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __add__(self, other):
if not isinstance(other, Point):
return NotImplemented
return Point(self.x + other.x, self.y + other.y)
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return self.x == other.x and self.y == other.y
def __repr__(self):
return f"Point({self.x}, {self.y})"
print(Point(2, 3) + Point(4, -1))
print(Point(2, 3) == Point(2, 3))
코드 해설
NotImplemented는 예외를 발생시키는 코드가 아니라 특별한 반환값입니다. Python에게 이 피연산자 조합을 현재 메서드가 지원하지 않는다고 알려 다른 처리 경로를 시도하게 합니다. NotImplementedError라는 예외와 구분합니다. 좌표를 새로 생성하므로 왼쪽 피연산자를 몰래 바꾸지 않습니다.
실행 결과
Point(6, 2)
True
시간·공간 복잡도
고정된 두 성분에 연산하므로 기본 숫자 모형에서 시간·추가 공간 O(1)입니다. n차원 벡터라면 성분 n개를 처리하고 저장해야 하므로 O(n)으로 달라집니다.
생각해보기와 핵심 정리
생각해보기: 좌표 + 문자열을 임의로 허용하는 것이 좋을까요? 의미가 분명하지 않으면 지원하지 않는 편이 좋습니다. 연산자의 친숙한 의미를 유지하는 것이 중요합니다.
2.12 상속
학습 목표
상속과 메서드 재정의를 이해한다.
상속과 포함 관계를 구분한다.
문제에서 개념으로
상속(Inheritance)은 기존 클래스의 행동을 이어받아 특수한 경우를 표현하는 방법입니다. 자식 클래스가 같은 이름의 메서드를 정의하면 호출할 때 자식의 구현을 사용할 수 있는데 이를 재정의(Overriding)라고 합니다. 앞 절의 연산자 오버로딩과 구분하세요.
상속은 코드 복사를 줄일 수 있지만 모든 재사용에 적합하지는 않습니다. ‘수첩은 기록을 갖는다’는 포함 관계이고, ‘글자 수 제한 수첩은 수첩의 한 종류다’는 상속을 고려할 수 있는 관계입니다. 스택이 리스트를 내부에 사용한다는 이유만으로 리스트의 모든 연산을 상속하면, 중간 삭제처럼 스택에 허용하지 않을 연산까지 노출될 수 있습니다. 이 책의 자료구조는 대부분 내부 객체를 포함하는 방식으로 구현합니다.
구조와 작은 예제
제한 수첩은 최대 글자 수를 검사한 뒤 부모의 add를 호출합니다. 검사 실패 시 기록 수가 늘어나면 안 됩니다. 먼저 검증하고 다음에 상태를 바꾸는 순서가 중요합니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
class ShortNotebook(Notebook):
def __init__(self, owner, limit):
super().__init__(owner)
self.limit = limit
def add(self, text):
if len(text) > self.limit:
raise ValueError("기록이 너무 깁니다.")
super().add(text)
book = ShortNotebook("민", 5)
book.add("queue")
print(len(book))
try:
book.add("algorithm")
except ValueError as error:
print(error)
print(len(book))
코드 해설
super().__init__은 부모가 준비하는 owner와 _notes를 초기화합니다. 이를 빠뜨리면 부모 add가 기대하는 상태가 없을 수 있습니다. add 재정의는 길이를 검사하고, 통과하면 부모 구현을 재사용합니다. 실패 이후 길이가 1로 유지되는 결과로 상태 보존을 확인합니다.
실행 결과
1
기록이 너무 깁니다.
1
시간·공간 복잡도
Python 문자열의 len은 저장된 길이를 읽으므로 O(1)이며, 추가는 분할 상환 O(1)입니다. 문자열을 글자별로 검사하는 조건을 추가하면 검사 길이에 비례한 비용이 생깁니다.
생각해보기와 핵심 정리
생각해보기: 스택을 리스트의 자식으로 만들면 어떤 연산을 숨겨야 할까요? 중간 삽입·삭제 등 LIFO를 깨는 연산입니다. 필요한 인터페이스만 제공하는 포함 방식이 더 단순할 수 있습니다.
CHAPTER 핵심 정리
개념
반드시 구분할 것
이후 연결
참조
객체 수정 / 이름 재대입
연결 노드와 공유
컬렉션
얕은 복사 / 독립된 내부 행
행렬·그래프
함수
출력 / 반환, 기본값 공유
연산 인터페이스
클래스
인스턴스 상태 / 클래스 공유 상태
모든 직접 구현
오버로딩·재정의
연산자 의미 / 상속 메서드 교체
비교와 자료형 설계
연습문제
[기초] 문제 1
input이 반환하는 자료형은 무엇인가요?
[기초] 문제 2
a=[1]; b=a; b.append(2) 뒤 a는?
[기초] 문제 3
7//3, -7//3의 값을 구하세요.
[기초] 문제 4
==와 is의 차이를 설명하세요.
[응용] 문제 5
a=[[0]*2]*3; a[0][0]=9 후 a는?
[응용] 문제 6
기본 매개변수 bucket=[]를 수정하는 함수의 위험은?
[응용] 문제 7
함수 내부의 지역 이름이 가리키는 리스트를 수정하면 외부에도 보일 수 있나요?
[응용] 문제 8
Point + 정수를 지원하지 않을 때 __add__에서 반환하는 특별한 값은?
[도전] 문제 9
스택에 리스트를 상속하면 ADT 설계에 어떤 문제가 생길 수 있나요?
[도전] 문제 10
얕은 복사로 충분한 경우와 부족한 경우를 각각 예로 설명하세요.
연습문제 정답 및 해설
문제 1
정답: str.
해설: 숫자를 타이핑해도 문자열입니다. int나 float 변환과 범위 검사를 별도로 수행합니다.
문제 2
정답: [1, 2].
해설: 대입은 목록 복사가 아니라 참조 공유입니다.
문제 3
정답: 2, -3.
해설: //는 0 방향이 아니라 음의 무한대 방향으로 내립니다.
문제 4
정답: 값의 동등성 / 객체의 동일성.
해설: 별개 리스트도 내용이 같으면 ==가 참일 수 있습니다. 값 비교에는 ==를 씁니다.
문제 5
정답: [[9,0],[9,0],[9,0]].
해설: 같은 내부 행의 참조를 세 번 반복했습니다. 행별 컴프리헨션으로 새 목록을 만들어야 합니다.
문제 6
정답: 호출 간 기본 리스트 공유.
해설: 기본 객체는 정의 시 한 번 만들어집니다. None을 기본값으로 두고 호출 안에서 새 목록을 만듭니다.
문제 7
정답: 예.
해설: 이름의 범위와 객체 공유는 다릅니다. 외부도 같은 객체를 참조하면 수정된 내용을 관찰합니다.
문제 8
정답: NotImplemented.
해설: NotImplementedError를 발생시키는 것과 다릅니다. 다른 피연산자 처리 경로를 Python에게 맡깁니다.
문제 9
정답: 중간 삽입 등 스택 규칙과 맞지 않는 인터페이스가 노출될 수 있습니다.
해설: 구현 재사용보다 사용자에게 허용할 행동을 먼저 설계해야 합니다. 내부 리스트를 포함하고 push·pop만 노출할 수 있습니다.
문제 10
정답: 정수 목록에서 독립적으로 원소를 재대입할 때는 충분하고, 내부 리스트를 서로 독립적으로 수정하려면 부족합니다.
해설: 바깥 목록은 분리되지만 각 원소 참조는 복사됩니다. 깊은 복사가 항상 정답인 것도 아니며 공유해야 할 객체나 순환 참조까지 고려해야 합니다.
실습문제
실습문제 1: 점수 요약
문제
점수 문자열 목록에서 0~100의 유효한 정수만 골라 개수와 평균을 구하세요.
요구사항
잘못된 문자열·범위 밖 값은 건너뜁니다. 유효값이 없으면 평균은 None입니다.
실행 예시
(3, 80.0)
(0, None)
힌트
변환 실패와 범위 검사를 나누고 합과 개수를 유지하세요.
실습문제 2: 독립 좌석표
문제
행과 열 수를 받아 독립된 행을 가진 좌석표를 만드세요.
요구사항
양의 행·열만 허용합니다. 0은 빈 좌석, 1은 예약 좌석입니다. 한 행 수정이 다른 행에 영향을 주면 안 됩니다.
실행 예시
[[1, 0, 0], [0, 0, 0]]
힌트
목록 곱셈으로 바깥 행까지 반복하지 마세요.
실습문제 3: 재고 품목
문제
품목 이름과 수량을 관리하는 클래스를 만드세요.
요구사항
입고는 양수만 허용합니다. 출고는 양수이고 현재 수량 이하일 때만 허용합니다. 실패하면 수량을 보존하세요.
실행 예시
연필 7
재고 부족
7
힌트
검증을 모두 통과한 뒤 수량을 바꾸세요.
실습문제 정답 및 해설
실습문제 1 정답
def summarize_scores(texts):
total = count = 0
for text in texts:
try:
score = int(text)
except ValueError:
continue
if 0 <= score <= 100:
total += score
count += 1
# 0개일 때 나누기 전에 분기한다.
return count, total / count if count else None
print(summarize_scores(["70", "x", "80", "101", "90"]))
print(summarize_scores(["x"]))
코드 해설
각 문자열의 변환을 시도하고, 성공한 값만 범위를 검사합니다. 유효하지 않은 값이 합이나 개수에 섞이지 않게 합니다.
실행 과정
70→합70·개수1, x 건너뜀, 80→150·2, 101 건너뜀, 90→240·3. 평균은 80입니다.
핵심 아이디어와 복잡도
입력을 한 번 순회합니다. 짧은 문자열 기준 시간 O(n), 결과 목록을 만들지 않아 보조 공간 O(1)입니다.
실습문제 2 정답
def make_seats(rows, columns):
if rows <= 0 or columns <= 0:
raise ValueError("행과 열은 양수여야 합니다.")
return [[0] * columns for _ in range(rows)]
seats = make_seats(2, 3)
seats[0][0] = 1
print(seats)
코드 해설
바깥 반복이 실행될 때마다 [0]*columns가 새로 만들어집니다. 정수 0을 참조하는 것은 안전하지만 행 리스트 자체를 공유하면 안 됩니다.
실행 과정
첫 행과 둘째 행이 별도로 만들어집니다. 첫 행의 0번 칸을 바꾼 뒤 둘째 행은 그대로 유지됩니다.
핵심 아이디어와 복잡도
객체 공유를 구분하는 문제입니다. r×c개 칸을 초기화하므로 시간·결과 공간 O(rc)입니다.
2장에서 사용한 Python 리스트를 이번에는 직접 만드는 관점에서 살펴봅니다. 목록의 순서를 유지하려면 삽입과 삭제 때 어떤 원소가 이동해야 하는지 추적합니다. 뒤쪽에서는 순서보다 소속 여부가 중요한 집합을 같은 저장 재료로 구현하여, ADT와 구현을 구분합니다.
3.1 리스트란?
학습 목표
리스트 ADT의 위치와 값의 차이를 구분한다.
삽입 위치와 접근 위치의 허용 범위를 설명한다.
문제에서 개념으로
음악 재생 목록에는 같은 곡을 두 번 넣을 수 있고, 앞뒤 순서도 중요합니다. **리스트(List)**는 이처럼 순서 있는 원소들의 모음입니다. 여기서 리스트는 추상적인 개념이고 Python의 list는 그것을 지원하는 구체적인 자료형입니다. 연결 리스트도 같은 목록을 다른 방식으로 표현할 수 있습니다.
길이가 n이면 기존 원소에 접근할 위치는 0부터 n-1입니다. 하지만 삽입은 마지막 뒤에도 가능하므로 0부터 n까지 허용합니다. 이 차이를 놓치면 빈 목록에 첫 원소를 넣지 못하거나 끝에 추가하지 못합니다. 이 책의 직접 구현은 위치 의미를 단순하게 하기 위해 음수 인덱스를 허용하지 않습니다.
구조와 작은 예제
연산
[10,20,30]에 적용
결과
get(1)
위치 1 읽기
20 반환, 목록 유지
insert(1,15)
기존 위치 1 앞에 삽입
[10,15,20,30]
delete(1)
위치 1 원소 제거
20 반환, [10,30]
각 행은 원래 목록에서 독립적으로 시작합니다. 값 20을 삭제하는 일과 위치 20을 삭제하는 일은 다릅니다. 이후 메서드 이름과 인수를 보고 어느 의미인지 확인하세요.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
Python list로 ADT의 동작을 먼저 관찰합니다. insert는 기존 원소 앞에 새 값을 넣고, pop(index)는 삭제한 값을 반환합니다. 중복된 "달"은 서로 다른 위치에 있는 별도 원소로 취급됩니다. Python insert는 범위를 벗어난 위치를 조정하는 등 이 책의 엄격한 직접 구현과 일부 행동이 다릅니다.
실행 결과
바람
['달', '별', '바람', '달']
바람
시간·공간 복잡도
순서라는 ADT만으로 비용은 결정되지 않습니다. 배열 기반에서는 인덱스 접근 O(1), 중간 삽입·삭제 최악 O(n)입니다. 연결 구조에서는 해당 위치까지 찾아가는 비용이 추가됩니다.
생각해보기와 핵심 정리
생각해보기: 길이 0인 목록의 유효한 삽입 위치는? 0 하나입니다. 유효한 접근 위치는 없습니다. 위치 범위는 연산마다 다릅니다.
3.2 파이썬의 리스트
학습 목표
동적 배열의 용량과 길이를 구분한다.
append의 분할 상환 비용을 설명한다.
문제에서 개념으로
Python 리스트는 원소 객체에 대한 참조들을 담는 동적 배열(Dynamic Array)로 이해할 수 있습니다. 정수 객체들이 반드시 한 덩어리로 붙어 있다는 뜻이 아니라, 그것을 가리키는 참조 슬롯이 배열 형태로 관리된다는 뜻입니다. 인덱스를 알면 해당 슬롯을 바로 찾을 수 있습니다.
길이는 현재 원소 수이고 용량은 확보한 슬롯 수입니다. 내부 배열이 가득 차면 더 큰 공간을 마련하고 기존 참조를 옮겨야 합니다. 실제 Python의 확장 비율을 고정된 두 배라고 외우지 않습니다. 교육용으로 기하급수적 확장을 생각하면 n번 추가 동안 옮기는 총량이 n에 비례함을 이해할 수 있습니다. 그래서 한 번의 확장이 O(n)이어도 연속 append의 한 번당 분할 상환 시간은 O(1)입니다.
구조와 작은 예제
길이 3인 목록에 앞쪽으로 원소를 넣으면 기존 3개를 뒤로 밀어야 합니다. 끝 추가는 여유 슬롯이 있으면 이동하지 않습니다. 이 차이가 스택은 리스트 끝을 쓰고, 큐는 단순 pop(0)을 피하는 이유입니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
끝 append는 분할 상환 O(1), 앞 insert는 O(n), 전체 슬라이스는 시간·추가 공간 O(n)입니다. 메서드 호출이 한 줄이라는 사실은 비용과 관계없습니다.
생각해보기와 핵심 정리
생각해보기: append가 언제나 최악 O(1)인가요? 아닙니다. 확장 호출은 O(n)일 수 있습니다. 평균 입력과 분할 상환은 다른 분석입니다.
3.3 배열로 구현한 리스트
학습 목표
고정 용량 배열에서 유효 구간을 관리한다.
삽입·삭제의 이동 방향을 설명한다.
문제에서 개념으로
이제 Python list를 완성된 목록 연산으로 사용하지 않고, 고정 개수의 슬롯을 확보하는 저장 재료로만 사용하겠습니다. insert와 pop을 대신 호출하면 배우려는 이동 과정이 가려지므로 직접 대입하여 원소를 이동합니다.
항상 유지할 불변식은 ‘0 이상 size 미만의 슬롯이 현재 원소이며, 나머지는 빈 여유 공간’입니다. 빈 표시 None은 저장 데이터로도 허용합니다. 실제 원소인지 여부는 값이 아니라 size로 판단합니다. 잘못된 인덱스나 용량 초과를 먼저 검사하면 실패한 연산이 구조를 망가뜨리지 않습니다.
구조와 작은 예제
[10,20,30,None,None], size=3에 위치 1로 15를 삽입합니다.
단계
배열
이유
30 이동
[10,20,30,30,None]
오른쪽부터 시작
20 이동
[10,20,20,30,None]
아직 안 옮긴 값 보존
15 저장
[10,15,20,30,None]
빈 위치 채우기
왼쪽부터 이동하면 20을 30 자리로 복사한 뒤 원래 30을 잃습니다. 삭제는 반대로 빈칸을 메우도록 오른쪽 원소를 왼쪽으로 당깁니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
class ArrayList:
def __init__(self, capacity=10):
if capacity <= 0:
raise ValueError("용량은 양수여야 합니다.")
self._data = [None] * capacity
self._size = 0
def __len__(self):
return self._size
def _check(self, index, insertion=False):
upper = self._size if insertion else self._size - 1
if not 0 <= index <= upper:
raise IndexError("유효하지 않은 위치입니다.")
def get(self, index):
self._check(index)
return self._data[index]
def set(self, index, item):
self._check(index)
self._data[index] = item
def insert(self, index, item):
self._check(index, insertion=True)
if self._size == len(self._data):
raise OverflowError("리스트가 가득 찼습니다.")
for position in range(self._size, index, -1):
self._data[position] = self._data[position - 1]
self._data[index] = item
self._size += 1
def delete(self, index):
self._check(index)
item = self._data[index]
for position in range(index, self._size - 1):
self._data[position] = self._data[position + 1]
self._size -= 1
self._data[self._size] = None # 불필요한 참조를 남기지 않는다.
return item
def to_list(self):
return self._data[:self._size]
line = ArrayList(5)
for item in (10, 20, 30):
line.insert(len(line), item)
line.insert(1, 15)
print(line.to_list())
print(line.delete(2), line.to_list())
코드 해설
_data는 물리적 슬롯, _size는 논리적 길이입니다. _check는 삽입과 기존 원소 접근의 상한을 구분합니다. 삽입 반복은 오른쪽 끝부터 덮어쓰기를 피하고, 삭제 반복은 왼쪽 빈칸부터 채웁니다. 마지막 슬롯을 None으로 바꾸는 것은 데이터가 사라졌음을 size로 판단하는 것과 별개로, 더 이상 필요 없는 객체 참조를 제거하기 위함입니다. to_list는 관찰용 복사본이며 내부 배열 자체를 노출하지 않습니다.
실행 결과
[10, 15, 20, 30]
20 [10, 15, 30]
시간·공간 복잡도
get·set·len은 O(1). 위치 i 삽입은 n-i개 이동하므로 O(n-i+1), 삭제도 뒤쪽 원소 수에 비례하며 최악 O(n)입니다. 끝 삽입은 용량이 고정되어 확장이 없으므로 최악 O(1)입니다. 구조의 저장 공간 O(C), 연산 보조 공간 O(1), to_list는 O(n) 시간·결과 공간입니다.
생각해보기와 핵심 정리
생각해보기: 여유 공간에 None이 있으면 None 저장을 금지해야 할까요? 아닙니다. 유효 구간은 size가 정합니다. 삽입은 뒤에서 앞으로, 삭제는 앞에서 뒤로 이동한다는 이유를 설명해 보세요.
3.4 리스트의 응용: 라인 편집기
학습 목표
문서의 행을 리스트 연산으로 모델링한다.
사용자 행 번호와 내부 인덱스를 구분한다.
문제에서 개념으로
간단한 라인 편집기는 문서를 문자열들의 목록으로 저장합니다. 새 문장을 특정 행 앞에 넣고, 잘못된 행을 삭제하고, 한 행을 새 내용으로 바꾸면 됩니다. 문자 단위 편집기를 만들기 전에 데이터 단위를 ‘한 행’으로 정하면 문제를 작게 만들 수 있습니다.
사용자는 보통 첫 행을 1번이라고 생각하지만 내부 인덱스는 0부터 시작합니다. 이 변환을 인터페이스 경계에서 한 번만 처리하면 코드 곳곳에 보정값이 흩어지는 일을 피할 수 있습니다. 이 예제의 명령은 이미 0 기반 위치를 사용합니다. 대화형 UI를 붙인다면 입력 직후 1을 빼고, 표시할 때 1을 더하면 됩니다.
구조와 작은 예제
초기 두 행 사이에 한 행을 넣고 첫 행을 수정합니다. 편집기는 목록 구현의 세부 배열을 직접 건드리지 않고 insert·set·get만 사용합니다. 저장 구조를 나중에 바꾸어도 이 명령 계층은 유지할 수 있습니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
document = ArrayList(10)
document.insert(0, "자료구조를 배웁니다.")
document.insert(1, "코드를 실행합니다.")
document.insert(1, "동작을 추적합니다.")
document.set(0, "자료구조의 원리를 배웁니다.")
for index in range(len(document)):
print(f"{index + 1}: {document.get(index)}")
코드 해설
문서의 각 원소가 한 줄이므로 문자열 내부를 편집하지 않습니다. 두 번째 insert의 위치 1과 세 번째 insert의 위치 1은 그 시점의 목록 상태에 적용됩니다. 자료구조를 수정하는 연속 명령은 항상 최신 상태를 기준으로 해석해야 합니다.
실행 결과
1: 자료구조의 원리를 배웁니다.
2: 동작을 추적합니다.
3: 코드를 실행합니다.
시간·공간 복잡도
n행 문서의 중간 삽입·삭제는 행 참조를 최대 n개 이동하여 O(n)입니다. 행 교체는 참조 대입 O(1)이지만 새 문자열 생성 비용은 길이에 따릅니다. 전체 표시는 출력할 총 문자 수만큼의 비용이 필요합니다.
생각해보기와 핵심 정리
생각해보기: 긴 문서를 맨 앞에서 계속 편집하면 어떤 비용이 반복될까요? 매번 많은 행 참조가 이동합니다. 6장에서는 연결 구조와 비교합니다.
3.5 집합이란?
학습 목표
순서·중복보다 소속 여부가 중요한 문제를 구분한다.
합집합·교집합·차집합을 설명한다.
문제에서 개념으로
참여자 명단에서 ‘누가 참여했는가’만 필요하다면 같은 이름을 두 번 기록할 이유가 없습니다. **집합(Set)**은 중복 없는 원소의 모음이며 원소의 소속 여부가 핵심입니다. 목록에서 두 번째 사람을 묻는 것은 자연스럽지만 집합에서 두 번째 사람을 묻는 것은 ADT의 기본 의미에 맞지 않습니다.
합집합은 둘 중 적어도 한쪽에 속하는 원소, 교집합은 양쪽에 모두 속하는 원소, 차집합 A-B는 A에만 속하는 원소입니다. A-B와 B-A는 보통 다릅니다. ‘중복 제거’라는 기능만 기억하지 말고 어떤 소속 조건을 표현하는지 문장으로 읽어 보세요.
구조와 작은 예제
A={민,준}, B={준,서}이면 합집합은 {민,준,서}, 교집합은 {준}, A-B는 {민}입니다. 표시 순서는 학습을 위한 정렬이며 집합의 의미와 무관합니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
a = {"민", "준"}
b = {"준", "서"}
print(sorted(a | b))
print(sorted(a & b))
print(sorted(a - b))
코드 해설
|, &, -는 각각 합집합·교집합·차집합입니다. 동일 기호라도 숫자에서의 의미와 구분해야 합니다. sorted는 집합을 바꾸지 않고 표시할 정렬 목록을 새로 만듭니다.
실행 결과
['민', '서', '준']
['준']
['민']
시간·공간 복잡도
Python set은 해시 기반입니다. 정상적인 해시 분포에서 소속 조회는 평균 O(1), 최악 O(n)입니다. 표시용 정렬은 결과 크기 k에 대해 O(k log k)이므로 집합 연산 자체와 분리합니다.
내장 set 없이도 리스트에 중복 원소를 넣지 않으면 집합을 구현할 수 있습니다. 핵심은 add 전에 기존 소속 여부를 확인하는 것입니다. 내부에 순서가 생기더라도 그 순서를 집합의 외부 약속으로 제공할 필요는 없습니다.
이 구현은 단순하지만 조회할 때 전체를 확인합니다. 같은 집합 ADT라도 내장 해시 집합과 시간 비용이 다릅니다. 성능 표에서 자료형 이름만 보고 복잡도를 단정하면 안 되는 이유입니다. 여기서는 같은 값을 다시 추가하면 변화 없이 False, 새로 추가하면 True를 반환합니다.
구조와 작은 예제
빈 집합에 3,1,3을 넣으면 내부 목록은 [3,1]입니다. 마지막 3은 이미 있으므로 저장하지 않습니다. 구현의 핵심 검사와 실제 삽입은 서로 다른 단계입니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
class ListSet:
def __init__(self, values=()):
self._items = []
for item in values:
self.add(item)
def __len__(self):
return len(self._items)
def contains(self, item):
for value in self._items:
if value == item:
return True
return False
def add(self, item):
if self.contains(item):
return False
self._items.append(item)
return True
def discard(self, item):
for index, value in enumerate(self._items):
if value == item:
self._items.pop(index)
return True
return False
def union(self, other):
result = ListSet(self._items)
for item in other._items:
result.add(item)
return result
def intersection(self, other):
return ListSet(item for item in self._items if other.contains(item))
def difference(self, other):
return ListSet(item for item in self._items if not other.contains(item))
def to_list(self):
return self._items.copy()
a = ListSet([3, 1, 3])
b = ListSet([1, 2])
print(a.to_list(), a.add(3))
print(a.union(b).to_list())
print(a.intersection(b).to_list())
print(a.difference(b).to_list())
코드 해설
contains가 모든 연산의 중복 판정 기준입니다. union은 결과 집합에 양쪽 원소를 차례로 추가하고, intersection은 상대에 존재하는 원소만 모읍니다. 결과는 새 객체라 원래 집합을 수정하지 않습니다. 교집합 생성에 사용한 괄호 식은 값을 하나씩 내는 제너레이터 표현식이며, 생성자가 순회하면서 받아 넣습니다.
실행 결과
[3, 1] False
[3, 1, 2]
[1]
[3]
시간·공간 복잡도
원소 비교 O(1) 가정에서 contains·add·discard는 최악 O(n)입니다. 현재 생성자는 add를 반복하므로 k개 고유 원소 생성이 O(k²)입니다. 따라서 union은 O((n+m)²), intersection·difference는 상대 소속 검사 O(nm)에 결과 중복 검사 O(k²)가 추가됩니다. 결과가 고유함을 이미 알 때 내부에 직접 추가하도록 최적화하면 이 불필요한 중복 검사를 줄일 수 있습니다. 결과 저장 공간 O(n+m) 또는 O(n)입니다.
생각해보기와 핵심 정리
생각해보기: 교집합 결과가 이미 중복 없다는 사실을 활용할 수 있을까요? 가능합니다. 하지만 내부 자료를 직접 다루는 최적화에서도 불변식을 지켜야 합니다. 7장의 정렬과 해싱이 다른 해결책을 제공합니다.
CHAPTER 핵심 정리
구조
접근·조회
삽입·삭제
추가 전제
고정 배열 리스트
위치 O(1)
중간 O(n), 끝 O(1)
용량 여유
Python list
위치 O(1)
끝 추가 분할 상환 O(1)
확장 가능
리스트 집합
소속 O(n)
O(n)
중복 검사
연습문제
[기초] 문제 1
길이 4인 직접 구현 리스트의 삽입 위치 범위는?
[기초] 문제 2
리스트와 집합의 중복 처리 차이는?
[기초] 문제 3
ArrayList에서 실제 원소 수를 나타내는 속성은?
[기초] 문제 4
A={1,2}, B={2,3}의 A-B는?
[응용] 문제 5
[10,20,30]의 위치 0에 5를 넣을 때 이동 순서는?
[응용] 문제 6
길이 n에서 위치 i 삭제 시 몇 개를 이동하나요?
[응용] 문제 7
빈 슬롯 표시가 None인데 None 저장이 가능한 이유는?
[응용] 문제 8
용량 초과를 이동 후 검사하면 어떤 위험이 있나요?
[도전] 문제 9
리스트 집합에 서로 다른 n개를 하나씩 넣는 전체 비용은?
[도전] 문제 10
라인 편집기의 첫 행에 n번 삽입하면 전체 이동은?
연습문제 정답 및 해설
문제 1
정답: 0~4.
해설: 마지막 원소 뒤 위치 4도 삽입할 수 있습니다. 읽기는 0~3입니다.
문제 2
정답: 리스트는 허용, 집합은 허용하지 않습니다.
해설: 집합은 값의 소속 여부를 표현합니다.
문제 3
정답: _size.
해설: len(_data)는 용량이므로 현재 길이와 다를 수 있습니다.
문제 4
정답: {1}.
해설: A에 속하고 B에는 없는 원소만 선택합니다.
문제 5
정답: 30,20,10 순서로 오른쪽 이동.
해설: 뒤에서부터 움직여 아직 옮기지 않은 원소를 덮어쓰지 않습니다.
문제 6
정답: n-i-1개.
해설: i 뒤에 있는 모든 원소가 한 칸 왼쪽으로 이동합니다.
문제 7
정답: 유효 여부를 값이 아닌 size와 위치로 판단합니다.
해설: 0≤i<size이면 None도 실제 원소입니다.
문제 8
정답: 실패 전에 상태를 일부 바꾸거나 범위 오류를 일으킬 수 있습니다.
해설: 조건 검사를 먼저 수행해야 실패 시 불변식을 유지하기 쉽습니다.
문제 9
정답: O(n²).
해설: 중복 검사 길이가 0,1,…,n-1로 늘어 합이 n(n-1)/2입니다. append의 O(1)만 세면 안 됩니다.
문제 10
정답: 초기 빈 문서 기준 n(n-1)/2, O(n²).
해설: 각 삽입 때 기존 모든 행을 이동합니다. 충분한 용량을 가정하며, 끝 삽입 n번은 이동 없이 O(n)입니다.
실습문제
실습문제 1: 구간 삭제
문제
배열 리스트에서 start 이상 stop 미만 구간을 삭제하세요.
요구사항
0≤start≤stop≤길이. 삭제된 원소를 순서대로 반환하세요. 빈 구간도 허용합니다.
실행 예시
[20, 30]
[10, 40]
힌트
같은 start 위치에서 반복 삭제하면 다음 원소가 당겨집니다.
실습문제 2: 참여 차이
문제
두 활동 중 정확히 하나에만 참여한 사람의 집합을 구하세요.
요구사항
내장 set 없이 ListSet을 사용합니다. 원래 집합은 보존하세요.
실행 예시
['민', '서']
힌트
A-B와 B-A를 합치세요.
실습문제 3: 첫 등장 순서 유지
문제
중복된 값을 제거하되 처음 나타난 순서를 유지하세요.
요구사항
내장 set·dict 사용 없이 구현합니다. 빈 입력은 빈 목록을 반환합니다.
실행 예시
[4, 2, 1]
힌트
결과에 이미 있는지 확인한 뒤 추가하세요.
실습문제 정답 및 해설
실습문제 1 정답
def delete_range(items, start, stop):
if not 0 <= start <= stop <= len(items):
raise IndexError("잘못된 구간")
removed = []
for _ in range(stop - start):
removed.append(items.delete(start))
return removed
items = ArrayList(6)
for value in (10, 20, 30, 40):
items.insert(len(items), value)
print(delete_range(items, 1, 3))
print(items.to_list())
코드 해설
인덱스를 증가시키지 않는 이유는 삭제 후 다음 원소가 같은 위치로 이동하기 때문입니다. 범위 전체를 먼저 검사합니다.
실행 과정
위치1의 20 삭제→[10,30,40], 다시 위치1의 30 삭제→[10,40].
핵심 아이디어와 복잡도
기존 ADT를 조합한 구현은 k개 삭제에 최악 O(kn), 결과 공간 O(k)입니다. 배열 내부에서 뒤쪽을 한 번에 이동하는 전용 구현은 O(n)으로 개선할 수 있습니다.
양쪽에 동시에 있는 준은 두 차집합 모두에서 제외됩니다. 새 집합을 반환하므로 원래 참여자 집합은 보존됩니다.
실행 과정
A-B={민}, B-A={서}, 두 결과의 합={민,서}.
핵심 아이디어와 복잡도
소속 조건을 집합 연산으로 분해합니다. 이 리스트 기반 구현의 전체 최악 시간 O((n+m)²), 결과·중간 공간 O(n+m)입니다.
실습문제 3 정답
def unique_in_order(values):
seen = ListSet()
result = []
for value in values:
if seen.add(value):
result.append(value)
return result
print(unique_in_order([4, 2, 4, 1, 2]))
코드 해설
add의 반환값을 사용하면 소속 검사를 두 번 하지 않아도 됩니다. 순서는 별도의 결과 목록에 기록하므로 집합의 순서 약속에 기대지 않습니다.
3장의 리스트는 어느 위치든 읽고 수정할 수 있었습니다. 이번 장에서는 일부 연산을 의도적으로 제한합니다. 마지막에 넣은 것부터 꺼내는 규칙만으로 괄호, 계산, 되돌아가는 탐색을 처리할 수 있다는 점이 스택의 가치입니다.
4.1 스택이란?
학습 목표
LIFO와 top의 의미를 설명한다.
리스트 전체 기능 대신 제한된 연산이 필요한 이유를 이해한다.
문제에서 개념으로
접시를 쌓아 두면 마지막에 올린 접시를 먼저 꺼내는 것이 자연스럽습니다. **스택(Stack)**은 마지막에 들어간 원소가 먼저 나오는 후입선출(Last-In First-Out, LIFO) 구조입니다. 넣는 연산은 push, 꺼내는 연산은 pop, 꺼내지 않고 맨 위를 보는 연산은 peek입니다. 맨 위 위치를 top이라고 합니다.
단순히 ‘리스트와 같은데 이름만 다른 구조’는 아닙니다. 리스트는 중간 값을 직접 삭제할 수 있지만 스택은 맨 위에만 접근하도록 약속합니다. 이 제한 덕분에 가장 최근에 시작한 미완료 작업을 먼저 끝내는 규칙이 코드에 드러납니다. 함수 호출, 괄호 닫기, 실행 취소가 같은 순서를 가집니다.
구조와 작은 예제
명령
바닥→위 상태
반환값
push(10)
[10]
없음
push(20)
[10,20]
없음
peek()
[10,20]
20
pop()
[10]
20
peek는 상태를 바꾸지 않습니다. 검색이 잦고 중간 원소를 바로 찾아야 한다면 스택의 제한이 오히려 불편하므로 다른 구조를 선택합니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
Python list의 끝을 top으로 삼았습니다. append와 인수 없는 pop을 사용하면 중간 이동을 피합니다. pop(0)을 사용하면 먼저 넣은 원소가 나오므로 같은 스택 동작이 아닙니다.
실행 결과
20
20
[10]
시간·공간 복잡도
내장 리스트 끝 추가는 분할 상환 O(1), 끝 제거는 통상 분할 상환 O(1)입니다. 저장 공간 O(n). 스택에서 특정 값을 찾으려고 모든 원소를 조사하면 O(n)이지만 그것은 기본 ADT 연산이 아닙니다.
생각해보기와 핵심 정리
생각해보기: 10,20,30을 순서대로 push한 후 두 번 pop하면? 30,20입니다. 기억할 성질은 마지막에 시작된 일이 먼저 완료된다는 점입니다.
4.2 스택의 구현
학습 목표
고정 용량 슬롯과 size로 스택을 구현한다.
빈 상태·가득 찬 상태에서 불변식을 보존한다.
문제에서 개념으로
리스트를 감싸 push를 append에 연결할 수도 있지만, 내부 상태 변화를 배우기 위해 고정 배열을 사용합니다. 원소 수 size가 다음 삽입 위치이고, 현재 top 인덱스는 size-1입니다. 비었을 때 size=0이며 top 원소에 접근하면 안 됩니다.
실패 검사를 먼저 하고, pop에서는 값을 보존한 다음 참조를 제거합니다. 인터페이스는 1장의 보관함과 마찬가지로 빈 구조는 IndexError, 용량 초과는 OverflowError를 사용합니다. 스택마다 독립된 배열과 크기를 갖도록 생성자에서 초기화합니다.
구조와 작은 예제
용량 3, size=2라면 슬롯0·1이 유효하며 슬롯2가 다음 저장 위치입니다. pop은 size를 1로 줄여 옛 top인 슬롯1의 값을 반환합니다. 논리적으로 빠진 슬롯의 참조도 None으로 정리합니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
class Stack:
def __init__(self, capacity=20):
if capacity <= 0:
raise ValueError("용량은 양수여야 합니다.")
self._data = [None] * capacity
self._size = 0
def __len__(self):
return self._size
def is_empty(self):
return self._size == 0
def push(self, item):
if self._size == len(self._data):
raise OverflowError("스택이 가득 찼습니다.")
self._data[self._size] = item
self._size += 1
def peek(self):
if self.is_empty():
raise IndexError("스택이 비었습니다.")
return self._data[self._size - 1]
def pop(self):
item = self.peek() # 빈 상태 검사를 함께 수행한다.
self._size -= 1
self._data[self._size] = None
return item
stack = Stack(3)
stack.push(10)
stack.push(20)
print(stack.peek(), len(stack))
print(stack.pop(), stack.pop(), stack.is_empty())
코드 해설
_size는 원소 수이자 다음 push 위치입니다. push와 pop이 각각 1만큼 크기를 바꾸므로 유효 구간이 연속적으로 유지됩니다. peek를 pop에서 재사용해 실패 조건을 한 곳에 둡니다. 내부 배열을 외부에서 수정하면 규칙을 깨뜨릴 수 있으므로 직접 노출하지 않습니다.
실행 결과
20 2
20 10 True
시간·공간 복잡도
생성은 용량 C개 슬롯을 채워 시간·공간 O(C)입니다. 이후 push·pop·peek·len은 이동이나 반복이 없어 최악 O(1), 연산 보조 공간 O(1)입니다. 고정 용량이므로 자동 확장은 없습니다.
생각해보기와 핵심 정리
생각해보기: pop에서 먼저 size를 줄인 다음 빈 상태를 검사하면? 실패한 호출이 size를 음수로 만들 수 있습니다. 검사→상태 변경 순서를 지킵니다.
4.3 스택의 응용: 괄호 검사
학습 목표
최근 열린 괄호와 닫는 괄호를 대응한다.
남은 여는 괄호와 이른 닫는 괄호를 모두 검출한다.
문제에서 개념으로
괄호 개수가 같다고 올바른 식은 아닙니다. )(는 여는 것과 닫는 것이 하나씩이지만 순서가 잘못되었고, ([)]는 종류가 서로 교차합니다. 닫는 괄호가 나오면 가장 최근에 열려 아직 닫히지 않은 괄호와 짝을 이루어야 합니다. 이것이 정확히 LIFO입니다.
여는 괄호는 스택에 넣고, 닫는 괄호는 스택에서 하나 꺼내 종류를 비교합니다. 닫을 대상이 없으면 즉시 실패입니다. 전체 문자를 다 읽었어도 스택에 여는 괄호가 남으면 실패입니다. 다음 구현은 텍스트에 등장하는 모든 괄호를 검사하며, 문자열 리터럴·주석 안의 괄호를 제외하는 Python 구문 분석기는 아닙니다.
구조와 작은 예제
([2+3])의 괄호만 추적합니다.
문자
처리 후 스택
의미
(
[(]
바깥 괄호 시작
[
[(,[]
안쪽 괄호 시작
]
[(]
가장 최근 [ 닫음
)
[]
바깥 ( 닫음
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def brackets_ok(text):
expected = {")": "(", "]": "[", "}": "{"}
pending = Stack(max(1, len(text)))
for char in text:
if char in "([{":
pending.push(char)
elif char in expected:
if pending.is_empty() or pending.pop() != expected[char]:
return False
return pending.is_empty()
for text in ("([2+3])", "([)]", "(()", ""):
print(repr(text), brackets_ok(text))
코드 해설
expected는 닫는 괄호에서 필요한 여는 괄호로 가는 작은 맵입니다. 단락 평가 덕분에 비어 있을 때 pop을 실행하지 않습니다. 중간에 종류가 틀리면 이후 문자가 아무리 좋아도 앞 오류를 고칠 수 없으므로 즉시 반환합니다. 빈 문자열은 짝을 어긴 괄호가 없어 참으로 정합니다.
실행 결과
'([2+3])' True
'([)]' False
'(()' False
'' True
시간·공간 복잡도
문자 n개를 한 번 읽고 각 괄호를 최대 한 번 넣고 빼므로 O(n)입니다. 최악에 여는 괄호만 n개가 쌓여 O(n) 공간입니다. 이 고정 배열 구현은 실제 중첩이 얕아도 길이 n만큼 미리 할당합니다.
생각해보기와 핵심 정리
생각해보기: 마지막 스택 검사를 빼면 어떤 입력을 잘못 허용할까요? "(("입니다. 닫는 괄호 오류와 닫히지 않은 여는 괄호는 별개입니다.
4.4 스택의 응용: 수식의 계산
학습 목표
후위 표기의 계산 순서를 추적한다.
중위 표기 변환에서 연산자 우선순위를 처리한다.
문제에서 개념으로
보통 쓰는 3 + 4 * 2는 연산자가 피연산자 사이에 있는 중위 표기(Infix)입니다. 곱셈을 먼저 해야 하므로 왼쪽부터 무조건 계산할 수 없습니다. 후위 표기(Postfix)는 3 4 2 * +처럼 연산자를 뒤에 두어 계산 순서가 토큰 순서에 드러나게 합니다.
후위식을 읽을 때 숫자는 스택에 쌓고, 이항 연산자를 만나면 최근 숫자 두 개를 꺼내 계산합니다. 먼저 꺼낸 것이 오른쪽 피연산자입니다. 뺄셈과 나눗셈에서는 이 순서를 바꾸면 결과가 달라집니다. 연산 결과를 다시 넣으면 그것을 더 큰 식의 피연산자로 사용할 수 있습니다.
중위식을 후위식으로 바꿀 때는 연산자 스택을 별도로 둡니다. 더 높은 우선순위 연산자가 위에 있으면 먼저 출력합니다. 여기서 지원하는 +,-,*,/는 왼쪽 결합이므로 같은 우선순위도 먼저 출력합니다. 괄호는 이 규칙을 잠시 가두는 경계입니다. 예제 문법은 공백으로 분리된 정수 토큰과 이항 연산자, 괄호이며 -3은 정수 토큰으로 허용합니다. -(...), 지수, 함수 호출은 지원하지 않습니다.
구조와 작은 예제
후위 토큰
값 스택(아래→위)
3
[3]
4
[3,4]
2
[3,4,2]
*
[3,8]
+
[11]
최종 값이 하나여야 완성된 식입니다. 값이 남으면 연산자가 부족하고, 연산 전에 값이 두 개 미만이면 피연산자가 부족합니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def is_integer(token):
return token.isdigit() or (token.startswith("-") and token[1:].isdigit())
def to_postfix(expression):
tokens = expression.split()
operators = Stack(max(1, len(tokens)))
priority = {"+": 1, "-": 1, "*": 2, "/": 2}
output = []
need_value = True
for token in tokens:
if is_integer(token):
if not need_value:
raise ValueError("연산자가 필요합니다.")
output.append(token)
need_value = False
elif token == "(":
if not need_value:
raise ValueError("괄호 앞 연산자가 필요합니다.")
operators.push(token)
elif token == ")":
if need_value:
raise ValueError("빈 괄호 또는 부족한 값")
while not operators.is_empty() and operators.peek() != "(":
output.append(operators.pop())
if operators.is_empty():
raise ValueError("괄호 불일치")
operators.pop()
elif token in priority:
if need_value:
raise ValueError("피연산자가 필요합니다.")
while (not operators.is_empty() and operators.peek() != "("
and priority[operators.peek()] >= priority[token]):
output.append(operators.pop())
operators.push(token)
need_value = True
else:
raise ValueError("지원하지 않는 토큰")
if need_value:
raise ValueError("완성되지 않은 식")
while not operators.is_empty():
token = operators.pop()
if token == "(":
raise ValueError("괄호 불일치")
output.append(token)
return output
def evaluate_postfix(tokens):
values = Stack(max(1, len(tokens)))
for token in tokens:
if is_integer(token):
values.push(int(token))
continue
if token not in ("+", "-", "*", "/") or len(values) < 2:
raise ValueError("잘못된 후위식")
right, left = values.pop(), values.pop()
if token == "+": result = left + right
elif token == "-": result = left - right
elif token == "*": result = left * right
else: result = left / right # 0 나눗셈은 ZeroDivisionError로 알린다.
values.push(result)
if len(values) != 1:
raise ValueError("결과는 하나여야 합니다.")
return values.pop()
postfix = to_postfix("3 + 4 * 2")
print(" ".join(postfix))
print(evaluate_postfix(postfix))
print(evaluate_postfix(to_postfix("( 9 - 3 ) / 2")))
코드 해설
need_value는 지금 숫자나 여는 괄호가 필요한 위치인지를 기록합니다. 이 상태를 검사하지 않으면 3 4 + 같은 후위식을 중위식으로 잘못 받아들일 수 있습니다. operators는 아직 출력하지 않은 연산자, values는 계산 중인 부분식 결과를 보관합니다. 두 스택은 역할이 다릅니다. 실수 나눗셈은 반올림 오차가 있을 수 있으며, 예제의 작은 정수 결과와 일반적인 수치 계산을 구분합니다.
실행 결과
3 4 2 * +
11
3.0
시간·공간 복잡도
토큰 수 n에 대해 각 연산자는 한 번 push되고 한 번 pop됩니다. 안쪽 while이 있어도 전체 pop 수가 n을 넘지 않으므로 변환 O(n), 평가 O(n)입니다. 토큰 길이와 큰 정수 계산을 제외한 모형이며 스택·출력 공간 O(n)입니다.
생각해보기와 핵심 정리
생각해보기: 8 3 -에서 먼저 pop한 3을 왼쪽에 두면? -5가 되어 오답입니다. 후위 평가에서는 오른쪽을 먼저 꺼낸다는 순서를 기억하세요.
4.5 스택의 응용: 미로 탐색
학습 목표
DFS의 탐색 대기를 스택으로 관리한다.
방문 표시와 경로 복원을 구분한다.
문제에서 개념으로
미로에서 갈림길을 만나면 한 길을 깊게 진행해 보고 막히면 남겨 둔 갈림길로 돌아갈 수 있습니다. 이를 **깊이 우선 탐색(Depth-First Search, DFS)**으로 표현합니다. 다음에 조사할 칸을 스택에 넣으면 최근에 발견한 칸부터 조사하므로 깊이 들어가는 성향이 생깁니다.
이미 발견한 칸을 다시 넣으면 순환 통로에서 끝없이 반복할 수 있습니다. 여기서는 스택에 넣을 때 방문 표시를 합니다. 또 방문 여부만으로는 실제 도착 경로를 알 수 없으므로 각 칸을 처음 발견한 이전 칸을 parent에 기록합니다. 도착 후 이 관계를 거꾸로 따라가면 경로를 복원할 수 있습니다. DFS는 경로 하나를 찾지만 일반적으로 가장 짧은 경로를 보장하지 않습니다.
구조와 작은 예제
0은 통로, 1은 벽입니다. 좌표는 (행,열), 이동은 상하좌우 한 칸입니다. 시작 (0,0)에서 (2,2)로 가며, 격자 밖으로 나가거나 벽인 칸은 후보에서 제외합니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def restore_path(parent, goal):
path = []
current = goal
while current is not None:
path.append(current)
current = parent[current]
path.reverse()
return path
def maze_dfs(grid, start, goal):
if not grid or not grid[0] or any(len(row) != len(grid[0]) for row in grid):
raise ValueError("직사각형 미로가 필요합니다.")
rows, columns = len(grid), len(grid[0])
def valid(cell):
row, column = cell
return 0 <= row < rows and 0 <= column < columns and grid[row][column] == 0
if not valid(start) or not valid(goal):
return None
pending = Stack(rows * columns)
pending.push(start)
parent = {start: None}
while not pending.is_empty():
current = pending.pop()
if current == goal:
return restore_path(parent, goal)
row, column = current
for dr, dc in ((0, 1), (1, 0), (0, -1), (-1, 0)):
neighbor = (row + dr, column + dc)
if valid(neighbor) and neighbor not in parent:
parent[neighbor] = current # 넣는 순간 발견 처리한다.
pending.push(neighbor)
return None
grid = [[0, 1, 0], [0, 0, 0], [1, 1, 0]]
print(maze_dfs(grid, (0, 0), (2, 2)))
코드 해설
parent의 키 존재 여부가 방문 표시 역할을 겸합니다. 각 칸은 최대 한 번 스택에 들어가므로 격자 칸 수만큼의 용량이면 충분합니다. 이웃을 오른쪽부터 넣어도 마지막에 넣은 이웃이 먼저 나오므로, 나열 순서가 그대로 방문 순서가 되지는 않습니다. 경로 복원은 도착점→부모→시작점으로 읽은 뒤 뒤집습니다.
실행 결과
[(0, 0), (1, 0), (1, 1), (1, 2), (2, 2)]
시간·공간 복잡도
R×C 격자에서 각 통로 칸은 최대 한 번 처리되고 이웃 네 개만 확인하므로 기대 시간 O(RC), 공간 O(RC)입니다. dict 소속 조회의 평균 O(1)을 가정합니다. 일반 그래프 분석은 10장에서 O(V+E)로 확장합니다.
생각해보기와 핵심 정리
생각해보기: 방문 표시를 pop할 때만 하면 무엇이 달라질까요? 같은 칸이 여러 번 대기열에 들어갈 수 있어 중복과 용량 문제가 생깁니다. 발견 시점과 처리 시점을 구분하세요.
이전 문자열 객체의 참조를 쌓습니다. push가 성공한 뒤 현재 내용을 바꾸므로 용량 초과 시 현재 문서를 보존합니다.
실행 과정
기록 [빈 문서,첫 번째]에서 첫 번째, 빈 문서 순서로 복원합니다.
핵심 아이디어와 복잡도
변경 이전 상태를 LIFO로 관리합니다. 참조 기반 교체·취소 O(1), 보존한 문자열 전체 저장량은 내용 크기의 합에 따릅니다.
실습문제 2 정답
def maximum_depth(text):
pending = Stack(max(1, len(text)))
maximum = 0
for char in text:
if char == "(":
pending.push(char)
maximum = max(maximum, len(pending))
elif char == ")":
if pending.is_empty():
raise ValueError("잘못된 괄호")
pending.pop()
if not pending.is_empty():
raise ValueError("잘못된 괄호")
return maximum
print(maximum_depth("(a(b(c)))()"))
코드 해설
최대 깊이는 동시에 닫히지 않은 괄호 수입니다. 최종 길이가 0이어도 중간 최대는 3일 수 있습니다.
실행 과정
깊이는 1→2→3→2→1→0→1→0으로 바뀝니다.
핵심 아이디어와 복잡도
현재 크기와 최대 크기를 구분합니다. 시간 O(n), 이 고정 배열 구현의 공간 O(n)입니다. 괄호가 한 종류뿐이면 카운터만으로 O(1) 공간도 가능합니다.
실습문제 3 정답
def evaluate_square(tokens):
values = Stack(max(1, len(tokens)))
for token in tokens:
if is_integer(token):
values.push(int(token))
elif token == "sq" and len(values) >= 1:
value = values.pop()
values.push(value * value)
elif token == "+" and len(values) >= 2:
right, left = values.pop(), values.pop()
values.push(left + right)
else:
raise ValueError("잘못된 식")
if len(values) != 1:
raise ValueError("결과 개수 오류")
return values.pop()
print(evaluate_square("3 sq 4 sq +".split()))
코드 해설
sq는 한 값만 소비하고 +는 두 값을 소비합니다. 임의로 두 개씩 pop하는 공통 처리로 묶으면 단항 연산을 처리할 수 없습니다.
실행 과정
3→9,4→16, 두 결과를 더해25입니다.
핵심 아이디어와 복잡도
연산자 차수와 피연산자 수를 연결합니다. 기본 숫자 모형에서 토큰 n개 시간 O(n), 공간 O(n)입니다.
다음 학습으로
5장에서는 스택의 LIFO를 FIFO와 우선순위 규칙으로 바꿉니다. 미로의 탐색 대기 구조가 달라지면 최단 경로 보장도 달라집니다.
실행 파일은 code/ch04.py, 시연 출력 없이 가져오는 재사용 모듈은 code/core04.py입니다. 실습 정답은 실행 파일의 뒤쪽에 배치했습니다.
스택은 최근 작업을 먼저 처리했습니다. 이번에는 먼저 도착한 작업을 먼저 처리하거나, 양끝에서 작업을 넣고 빼거나, 우선순위에 따라 선택합니다. 저장할 값이 같아도 꺼내는 규칙이 바뀌면 탐색 알고리즘의 성질까지 달라집니다.
5.1 큐란?
학습 목표
FIFO와 스택의 처리 순서를 비교한다.
enqueue·dequeue·peek의 역할을 설명한다.
문제에서 개념으로
매표소 대기 줄에서는 먼저 온 사람이 먼저 서비스를 받습니다. **큐(Queue)**는 이런 선입선출(First-In First-Out, FIFO) 규칙을 구현합니다. 뒤쪽에 넣는 연산은 enqueue, 앞쪽에서 꺼내는 연산은 dequeue입니다. 스택과 달리 삽입과 삭제가 서로 다른 끝에서 일어납니다.
프린터 대기 작업이나 순서대로 수신한 메시지를 처리할 때 유용합니다. 다만 시간이 오래 걸리는 첫 작업 때문에 뒤 작업이 기다릴 수 있고, 긴급도를 반영하지 못합니다. ‘공정한 도착 순서’가 필요한지 ‘더 중요한 것 먼저’가 필요한지 문제에서 구분해야 합니다.
구조와 작은 예제
명령
앞→뒤 상태
반환
enqueue(A)
[A]
없음
enqueue(B)
[A,B]
없음
dequeue()
[B]
A
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
표준 deque의 append와 popleft를 사용했습니다. 스택에서는 같은 끝의 pop을 썼지만 여기서는 반대 끝을 꺼냅니다. 다음 절에서는 표준 구조에 맡기지 않고 고정 배열로 같은 규칙을 만듭니다.
실행 결과
A
['B']
시간·공간 복잡도
deque의 양끝 연산 O(1). Python list의 pop(0)은 남은 참조를 모두 당겨 O(n)이므로 길이가 긴 대기열에 반복 사용하면 전체 비용이 커집니다.
생각해보기와 핵심 정리
생각해보기: A,B,C 도착 후 하나 처리하고 D가 도착하면 다음 처리 대상은? B입니다. 새 도착이 기존 대기 순서를 바꾸지 않습니다.
5.2 큐의 구현
학습 목표
원형 인덱스와 논리적 순서를 구분한다.
size로 빈 상태와 가득 찬 상태를 구별한다.
문제에서 개념으로
배열의 앞 원소를 꺼낼 때마다 모두 이동시키지 않고, ‘앞’이라고 기억하는 인덱스만 옮길 수 있습니다. 다만 뒤 인덱스가 배열 끝에 도달했을 때 앞쪽 빈칸을 다시 사용해야 합니다. 이를 **원형 큐(Circular Queue)**라고 합니다. 실제 메모리가 원형인 것이 아니라 인덱스를 나머지 연산으로 순환시킵니다.
여기서는 front가 다음에 꺼낼 위치, size가 원소 수입니다. 다음 삽입 위치는 (front+size)%capacity입니다. front와 rear만 쓰며 한 칸을 비우는 다른 구현도 있지만, 이 책은 size를 저장하여 모든 슬롯을 사용합니다. 두 방식을 섞으면 가득 찬 상태 판정이 틀어집니다.
구조와 작은 예제
용량3에서 A,B,C를 넣고 A를 꺼내면 front=1,size=2입니다. D의 삽입 위치는 (1+2)%3=0입니다. 물리 배열은 [D,B,C]여도 논리 순서는 B,C,D입니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
dequeue는 다른 원소를 이동하지 않습니다. front만 다음 칸으로 넘기고 size를 줄입니다. 빈 큐에서 front가 0이 아니어도 정상입니다. 다음 enqueue는 같은 공식으로 현재 빈칸을 사용합니다. 물리 배열 출력만 보고 FIFO 순서가 깨졌다고 판단하지 마세요.
실행 결과
A
['B', 'C', 'D']
시간·공간 복잡도
생성 O(C) 시간·공간. enqueue·dequeue·peek는 최악 O(1), 보조 공간 O(1)입니다. 고정 용량이므로 외부에서 적절한 상한을 정해야 합니다.
생각해보기와 핵심 정리
생각해보기: 용량1에서도 동작할까요? size를 별도로 관리하므로 가능합니다. 삽입·삭제 위치가 같아도 원소 수로 빈 상태를 구별합니다.
5.3 큐의 응용: 너비우선탐색
학습 목표
BFS가 가까운 거리 순서로 확장하는 이유를 설명한다.
무가중치 최단 경로를 복원한다.
문제에서 개념으로
시작점에서 한 걸음에 갈 수 있는 곳을 모두 확인하고, 그다음 두 걸음 거리를 확인하면 처음 목표를 발견한 거리가 최소입니다. 이것이 **너비 우선 탐색(Breadth-First Search, BFS)**입니다. 큐에 새 후보를 뒤에 넣으면 앞에 있던 더 가까운 후보가 먼저 처리됩니다.
이 최단 경로 성질은 모든 간선의 비용이 같을 때 적용됩니다. 도로마다 시간이 다르면 간선 수가 적다고 빠른 길이 아닙니다. 가중치가 있는 경우는 11장에서 다룹니다. DFS와 마찬가지로 발견할 때 표시하고 부모를 기록합니다. 이웃 순서에 따라 같은 길이의 다른 경로가 선택될 수 있지만 최소 간선 수는 같습니다.
구조와 작은 예제
A:[B,C], B:[D], C:[E], D:[E], E:[]를 생각합니다.
꺼낸 정점
처리 후 큐
새 거리
A
[B,C]
B=1,C=1
B
[C,D]
D=2
C
[D,E]
E=2
D
[E]
E는 이미 발견
E
[]
종료
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def bfs_path(adjacency, start, goal):
if start not in adjacency or goal not in adjacency:
return None
pending = CircularQueue(max(1, len(adjacency)))
pending.enqueue(start)
parent = {start: None}
while not pending.is_empty():
vertex = pending.dequeue()
if vertex == goal:
path = []
while vertex is not None:
path.append(vertex)
vertex = parent[vertex]
return path[::-1]
for neighbor in adjacency[vertex]:
if neighbor not in parent:
parent[neighbor] = vertex
pending.enqueue(neighbor)
return None
graph = {"A": ["B", "C"], "B": ["D"], "C": ["E"], "D": ["E"], "E": []}
print(bfs_path(graph, "A", "E"))
코드 해설
adjacency는 정점에서 이웃 목록으로 가는 맵이며, 모든 이웃도 키로 존재한다는 사전조건을 둡니다. 아직 그래프 전체 이론을 몰라도 ‘다음에 갈 수 있는 곳의 목록’으로 읽으면 됩니다. E는 C에서 먼저 발견되어 부모가 C가 됩니다. D에서 다시 발견해도 덮어쓰지 않습니다.
실행 결과
['A', 'C', 'E']
시간·공간 복잡도
정점 V개, 저장된 이웃 항목 E개에서 각 정점은 한 번 큐에 들어가고 각 이웃 항목은 한 번 확인하여 기대 O(V+E) 시간입니다. 부모·큐 공간 O(V). dict 조회 평균 O(1)을 가정합니다.
생각해보기와 핵심 정리
생각해보기: 가중치가 서로 다른 길에서도 BFS 첫 경로가 최소 비용일까요? 아닙니다. 최소 간선 수와 최소 비용을 구분합니다.
5.4 덱이란?
학습 목표
양끝 연산의 대칭성을 이해한다.
덱으로 스택과 큐를 표현한다.
문제에서 개념으로
**덱(Deque, Double-Ended Queue)**은 양끝에서 삽입과 삭제를 모두 허용합니다. 한쪽만 사용하면 스택, 한쪽 삽입·다른 쪽 삭제를 사용하면 큐처럼 쓸 수 있습니다. 하지만 임의의 중간 위치를 빠르게 읽는 자료구조라는 뜻은 아닙니다.
문자열의 앞뒤 문자를 비교하는 회문 검사나, 최근 기록을 양방향으로 관리하는 작업에 어울립니다. 연산 이름에서 방향을 분명히 해야 합니다. 이 책의 직접 구현은 add_front·add_rear·remove_front·remove_rear를 사용합니다. 표준 deque는 appendleft·append·popleft·pop이라는 이름을 사용하므로 대응을 확인합니다.
구조와 작은 예제
앞에 A, 뒤에 B, 앞에 C를 넣으면 C,A,B입니다. 앞에서 꺼내면 C, 뒤에서 꺼내면 B입니다. 어느 끝이 ‘최근’인지는 삽입한 방향에 따라 다릅니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
both = deque()
both.appendleft("A")
both.append("B")
both.appendleft("C")
print(list(both))
print(both.popleft(), both.pop())
코드 해설
양끝이 모두 열려 있다는 점이 큐와 다릅니다. 프로그램이 어떤 연산 조합을 허용하는지에 따라 FIFO나 LIFO의 성질을 유지하거나 벗어날 수 있습니다.
실행 결과
['C', 'A', 'B']
C B
시간·공간 복잡도
표준 deque의 양끝 연산 O(1). 중간 인덱스 접근은 최악 O(n)으로 리스트의 O(1) 접근과 다릅니다. 필요한 연산에 따라 자료구조를 고릅니다.
생각해보기와 핵심 정리
생각해보기: 뒤에 넣고 뒤에서 꺼내면 어떤 구조인가요? 스택입니다. 뒤에 넣고 앞에서 꺼내면 큐입니다.
5.5 덱의 구현
학습 목표
원형 배열의 앞쪽 확장과 뒤쪽 삭제를 구현한다.
한 원소·빈 상태 경계를 처리한다.
문제에서 개념으로
원형 큐의 규칙을 확장하면 배열 덱을 만들 수 있습니다. 뒤 삽입 위치는 그대로 (front+size)%C입니다. 앞 삽입은 front를 한 칸 뒤로, 즉 (front-1)%C로 옮긴 뒤 값을 넣습니다. Python에서 양수 C에 대한 나머지는 0부터 C-1 범위이므로 front=0에서도 C-1로 돌아갑니다.
뒤 삭제 위치는 (front+size-1)%C입니다. size가 0일 때 이 식을 먼저 사용하면 여유 슬롯을 읽게 되므로 빈 상태 검사가 선행되어야 합니다. 대칭적으로 보이는 연산도 ‘언제 인덱스를 갱신하는가’가 다릅니다.
구조와 작은 예제
용량4, front=1,size=2의 논리 구간이 슬롯1·2라면 앞 추가는 슬롯0, 뒤 추가는 슬롯3을 사용합니다. 뒤 삭제는 슬롯2를 비우고 size만 줄입니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
네 연산 모두 논리적 유효 구간의 양끝만 바꿉니다. 원소가 하나면 앞과 뒤 위치가 같으므로 어느 쪽에서 지워도 size=0이 됩니다. peek 연산을 추가하려면 같은 위치 공식에서 삭제와 크기 감소만 제외하면 됩니다.
실행 결과
B C A
시간·공간 복잡도
네 양끝 연산 최악 O(1), 생성 시간·공간 O(C). 원소 이동이 없다는 점이 배열 리스트의 앞 삽입·삭제와 다릅니다.
생각해보기와 핵심 정리
생각해보기: front를 줄일 때 나머지를 빼면 어떻게 될까요? Python의 음수 인덱스가 잠시 동작을 가려도 올바른 순환 불변식이 깨집니다. 항상 0≤front<C로 유지합니다.
5.6 우선순위 큐
학습 목표
도착 순서와 우선순위를 구분한다.
동점 처리 규칙을 명세한다.
문제에서 개념으로
긴급 작업이 일반 작업보다 먼저 처리되어야 한다면 FIFO로 충분하지 않습니다. **우선순위 큐(Priority Queue)**는 각 항목에 우선순위를 붙이고 가장 중요한 항목을 꺼냅니다. 이 책에서는 작은 숫자가 높은 우선순위인 최소 우선순위 큐를 기본으로 합니다.
자료구조 이름은 같아도 구현은 다양합니다. 정렬하지 않은 목록에 넣으면 삽입은 빠르지만 꺼낼 때 전체 최솟값을 찾아야 합니다. 정렬된 목록은 반대 부담이 생깁니다. 8장에서 힙을 배우면 둘 다 로그 시간으로 균형 있게 처리할 수 있습니다. 같은 우선순위에서는 먼저 넣은 것을 먼저 꺼내도록 순번을 기록하겠습니다.
구조와 작은 예제
보통 작업(5), 긴급 작업(1), 또 다른 긴급 작업(1)을 넣으면 긴급 작업 두 개가 도착 순서대로 먼저 나옵니다. 항목 자체가 비교 불가능한 객체여도 우선순위·순번만 비교하면 됩니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
class PriorityQueue:
def __init__(self):
self._items = []
self._sequence = 0
def __len__(self): return len(self._items)
def is_empty(self): return not self._items
def enqueue(self, item, priority):
self._items.append((priority, self._sequence, item))
self._sequence += 1
def dequeue(self):
if self.is_empty(): raise IndexError("우선순위 큐가 비었습니다.")
best = 0
for index in range(1, len(self._items)):
if self._items[index][:2] < self._items[best][:2]:
best = index
return self._items.pop(best)[2]
urgent = PriorityQueue()
urgent.enqueue("일반", 5)
urgent.enqueue("긴급1", 1)
urgent.enqueue("긴급2", 1)
print([urgent.dequeue() for _ in range(len(urgent))])
코드 해설
튜플 앞 두 필드를 비교하면 우선순위가 같을 때 순번으로 결정됩니다. [:2]는 길이2의 고정 크기 복사이므로 이 예제의 n에 따라 커지지 않습니다. pop(best)는 뒤 원소를 이동시키지만 최솟값 탐색 자체가 선형이어서 전체 차수는 O(n)입니다.
실행 결과
['긴급1', '긴급2', '일반']
시간·공간 복잡도
enqueue 분할 상환 O(1), dequeue O(n), 저장 공간 O(n). n개를 모두 꺼내면 n+(n-1)+…+1에 비례해 O(n²)입니다. 힙 구현과 비교할 기준입니다.
생각해보기와 핵심 정리
생각해보기: 동점에서 항목 이름으로 정렬하면 도착 순서를 보장할까요? 아닙니다. 도착 순서를 명시하려면 순번이 필요합니다.
5.7 우선순위 큐의 응용: 전략적인 미로 탐색
학습 목표
휴리스틱을 이용한 탐색 순서를 설명한다.
탐욕적 최선 우선 탐색의 한계를 구분한다.
문제에서 개념으로
목표까지 직선상으로 가까워 보이는 칸을 먼저 조사하면 탐색량을 줄일 수 있을까요? 목표까지의 실제 거리를 모르더라도 추정치인 **휴리스틱(Heuristic)**을 사용할 수 있습니다. 격자에서는 행 차이와 열 차이의 절댓값 합인 맨해튼 거리가 간단한 추정입니다.
여기서는 추정 거리 h만 우선순위로 쓰는 **탐욕적 최선 우선 탐색(Greedy Best-First Search)*을 구현합니다. 벽 때문에 가까워 보이는 길이 크게 돌아갈 수 있어 최단 경로를 보장하지 않습니다. 지금까지의 비용 g까지 더하는 A와 구분해야 합니다. 추정이 있어도 방문 표시와 유효 칸 검사는 여전히 필요합니다.
구조와 작은 예제
목표(2,2)에서 (0,0)의 추정 거리는4, (1,0)은3, (1,1)은2입니다. 단, 이 숫자는 벽을 무시합니다. 우선순위는 발견된 칸 중 어느 것을 먼저 처리할지 결정할 뿐 벽을 통과하게 만들지 않습니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def greedy_maze(grid, start, goal):
if not grid or not grid[0] or any(len(row) != len(grid[0]) for row in grid):
raise ValueError("직사각형 미로가 필요합니다.")
rows, columns = len(grid), len(grid[0])
def valid(cell):
r, c = cell
return 0 <= r < rows and 0 <= c < columns and grid[r][c] == 0
def estimate(cell):
return abs(cell[0] - goal[0]) + abs(cell[1] - goal[1])
if not valid(start) or not valid(goal): return None
pending = PriorityQueue()
pending.enqueue(start, estimate(start))
parent = {start: None}
while not pending.is_empty():
current = pending.dequeue()
if current == goal:
result = []
while current is not None:
result.append(current)
current = parent[current]
return result[::-1]
r, c = current
for dr, dc in ((0, 1), (1, 0), (0, -1), (-1, 0)):
neighbor = (r + dr, c + dc)
if valid(neighbor) and neighbor not in parent:
parent[neighbor] = current
pending.enqueue(neighbor, estimate(neighbor))
return None
print(greedy_maze([[0,1,0],[0,0,0],[1,1,0]], (0,0), (2,2)))
코드 해설
BFS와 전체 흐름은 비슷하지만 pending에서 꺼내는 기준이 다릅니다. parent를 최초 발견 시 고정하는 것은 이 탐욕 탐색의 경로 하나를 복원하기 위한 선택이며, 일반적인 최단 경로 알고리즘의 거리 갱신을 대신하지 않습니다.
실행 결과
[(0, 0), (1, 0), (1, 1), (1, 2), (2, 2)]
시간·공간 복잡도
칸 수 N=RC. 이 절의 비정렬 우선순위 큐는 한 번 제거 O(N)이므로 전체 최악 O(N²), 공간 O(N)입니다. 힙으로 교체하면 큐 비용이 O(N log N)으로 줄지만 최단 경로 보장 여부는 바뀌지 않습니다.
생각해보기와 핵심 정리
생각해보기: 자료구조를 빠른 힙으로 바꾸면 탐욕 탐색이 최단 경로를 보장할까요? 아닙니다. 실행 비용과 알고리즘의 정답 보장은 다른 문제입니다.
CHAPTER 핵심 정리
구조
다음 항목 선정
기본 비용
원형 큐
가장 먼저 도착
양끝 O(1)
배열 덱
지정한 끝
네 연산 O(1)
비정렬 우선순위 큐
최소 우선순위
삽입 분할 상환 O(1), 제거 O(n)
BFS
거리층 순서
무가중치 최단 경로
탐욕 탐색
h 최소
최단 보장 없음
연습문제
[기초] 문제 1
FIFO의 뜻은?
[기초] 문제 2
원형 큐의 다음 삽입 위치 공식은?
[기초] 문제 3
덱의 영어 풀이는?
[기초] 문제 4
이 책의 최소 우선순위 큐에서 1과5 중 먼저 나오는 우선순위는?
[응용] 문제 5
C=5,front=4,size=3일 때 다음 삽입 위치는?
[응용] 문제 6
BFS에서 큐에 넣을 때 방문 표시하는 이유는?
[응용] 문제 7
front=0인 덱의 앞 삽입 후 front는?
[응용] 문제 8
비정렬 우선순위 큐에서 n개를 모두 제거하는 시간은?
[도전] 문제 9
BFS의 최소 거리 보장에 필요한 간선 비용 조건은?
[도전] 문제 10
휴리스틱 h만 쓰는 탐색과 실제 비용 g를 고려하는 탐색을 구분해야 하는 이유는?
연습문제 정답 및 해설
문제 1
정답: 먼저 들어온 항목이 먼저 나옵니다.
해설: 삽입과 제거가 서로 다른 끝입니다.
문제 2
정답: (front+size)%capacity.
해설: 현재 논리 구간 바로 뒤를 순환 인덱스로 바꿉니다.
문제 3
정답: Double-Ended Queue.
해설: 양끝에서 삽입·삭제합니다.
문제 4
정답: 1.
해설: 작은 숫자를 높은 우선순위로 명세했습니다.
문제 5
정답: 2.
해설: 유효 위치는4,0,1이고 다음은(4+3)%5=2입니다.
문제 6
정답: 같은 정점의 중복 삽입을 막습니다.
해설: 아직 꺼내지 않았어도 발견한 정점은 다시 넣을 필요가 없습니다.
문제 7
정답: capacity-1.
해설: (-1)%capacity가 마지막 슬롯입니다.
문제 8
정답: O(n²).
해설: 매번 남은 전체에서 최솟값을 찾습니다.
문제 9
정답: 모든 간선 비용이 동일해야 합니다.
해설: BFS는 간선 수 순서로 탐색하므로 비용이 다르면 더 적은 간선의 길이 더 비쌀 수 있습니다.
문제 10
정답: 가까워 보인다는 추정만으로 지금까지 돌아온 비용을 평가하지 못하기 때문입니다.
해설: 좋은 추정치를 써도 탐욕 최선 우선 탐색은 일반적으로 최단 보장을 하지 않습니다.
실습문제
실습문제 1: 라운드 로빈
문제
한 번에 2단위씩 작업을 처리하고 남은 작업은 뒤로 보내세요.
요구사항
작업량은 양수 정수입니다. 완료된 작업 이름을 순서대로 반환합니다.
실행 예시
['B', 'A', 'C']
힌트
꺼내서 감소시킨 후 남았을 때만 다시 넣습니다.
실습문제 2: 회문 검사
문제
영문 문자열의 앞뒤가 같은지 덱으로 확인하세요.
요구사항
대소문자는 구별합니다. 빈 문자열은 회문입니다.
실행 예시
True
False
힌트
원소가 둘 이상일 때 앞뒤를 하나씩 제거합니다.
실습문제 3: 도달 거리
문제
시작점에서 모든 도달 가능한 정점까지 최소 간선 수를 구하세요.
요구사항
모든 정점은 맵의 키에 존재합니다. 도달 불가능 정점은 결과에 포함하지 않습니다.
실행 예시
{'A': 0, 'B': 1, 'C': 1, 'D': 2, 'E': 2}
힌트
부모 대신 거리를 저장하고 처음 발견할 때 부모 거리+1을 기록하세요.
실습문제 정답 및 해설
실습문제 1 정답
def round_robin(jobs, quantum=2):
if quantum <= 0 or any(amount <= 0 for _, amount in jobs):
raise ValueError("양수 작업량과 할당량 필요")
pending = CircularQueue(max(1, len(jobs)))
for job in jobs: pending.enqueue(job)
completed = []
while not pending.is_empty():
name, remaining = pending.dequeue()
remaining -= quantum
if remaining > 0: pending.enqueue((name, remaining))
else: completed.append(name)
return completed
print(round_robin([("A",3),("B",1),("C",4)]))
코드 해설
꺼낸 뒤 다시 넣으므로 원래 작업 수보다 큐가 길어지지 않습니다. 완료된 항목은 재삽입하지 않습니다.
실행 과정
A는1 남아 뒤로, B완료, C는2 남아 뒤로, A완료, C완료입니다.
핵심 아이디어와 복잡도
각 작업 처리 차례 수의 합을 K라 하면 O(K+n) 시간, O(n) 공간입니다. n만으로 작업량에 따른 반복을 숨기지 않습니다.
실습문제 2 정답
def is_palindrome(text):
letters = ArrayDeque(max(1, len(text)))
for char in text: letters.add_rear(char)
while len(letters) > 1:
if letters.remove_front() != letters.remove_rear(): return False
return True
print(is_palindrome("level"))
print(is_palindrome("queue"))
코드 해설
짝이 되는 양끝을 제거하면 남은 부분도 같은 문제입니다. 홀수 길이의 가운데 문자 하나는 비교할 상대가 없으므로 그대로 성공합니다.
실행 과정
level에서 l/l, e/e를 비교하고 v만 남습니다.
핵심 아이디어와 복잡도
양끝 대칭을 덱 연산에 대응합니다. 시간 O(n), 공간 O(n). 문자열 양끝 인덱스로 구현하면 보조 공간 O(1)도 가능합니다.
실습문제 3 정답
def bfs_distances(adjacency, start):
if start not in adjacency: raise KeyError(start)
distance = {start: 0}
pending = CircularQueue(max(1, len(adjacency)))
pending.enqueue(start)
while not pending.is_empty():
vertex = pending.dequeue()
for neighbor in adjacency[vertex]:
if neighbor not in distance:
distance[neighbor] = distance[vertex] + 1
pending.enqueue(neighbor)
return distance
print(bfs_distances(graph, "A"))
코드 해설
거리 맵의 키가 발견 표시 역할을 합니다. 처음 기록한 거리를 다시 덮어쓰지 않습니다.
실행 과정
A=0에서 B,C=1, 이어 D,E=2가 됩니다. D에서 E를 보아도 이미 거리2가 있으므로 유지합니다.
핵심 아이디어와 복잡도
FIFO의 거리층 불변식을 활용합니다. 기대 시간 O(V+E), 보조·결과 공간 O(V)입니다.
다음 학습으로
6장에서는 같은 스택·큐·덱 인터페이스를 노드 참조로 다시 구현합니다. 원형 인덱스와 원형 링크는 서로 다른 저장 방식입니다.
실행 파일은 code/ch05.py, 시연 출력 없이 가져오는 재사용 모듈은 code/core05.py입니다. 실습 정답은 실행 파일의 뒤쪽에 배치했습니다.
배열에서는 원소가 들어갈 슬롯을 먼저 확보하고 위치로 접근했습니다. 연결 구조에서는 각 원소를 노드 객체에 넣고 다음 객체를 가리키는 참조로 순서를 표현합니다. 삽입 때 전체를 이동하지 않는 대신, 원하는 위치를 찾아가야 하는 비용을 지불합니다.
6.1 연결된 구조란?
학습 목표
노드의 값과 링크를 구분한다.
물리적 배치와 논리적 순서의 차이를 설명한다.
문제에서 개념으로
보물찾기의 쪽지에 다음 쪽지의 위치가 적혀 있다고 생각해 봅시다. 쪽지들이 바닥에 차례대로 놓일 필요는 없습니다. 한 쪽지에서 다음 쪽지로 따라가면 순서를 알 수 있습니다. **연결 리스트(Linked List)**도 각 **노드(Node)**가 데이터와 다음 노드 참조를 보관합니다.
첫 노드를 가리키는 head만 알면 전체를 순회할 수 있습니다. 마지막 노드의 next는 None으로 끝을 나타냅니다. Python에서는 실제 숫자 메모리 주소를 계산하지 않고 객체 참조를 저장합니다. 노드들이 메모리에 인접하다고 가정하지 않습니다. 배열의 인덱스처럼 k번째 노드로 바로 점프할 수 없다는 점이 중요한 차이입니다.
구조와 작은 예제
단순 연결 리스트의 참조
화살표는 next 참조입니다. 값 10이라는 숫자가 20을 가리키는 것이 아니라, 10을 저장한 노드 객체의 next 속성이 다음 노드를 가리킵니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
class Node:
def __init__(self, item, next_node=None):
self.item = item
self.next = next_node
head = Node(10, Node(20))
current = head
while current is not None:
print(current.item)
current = current.next
코드 해설
current는 순회하는 동안 이동하는 지역 이름입니다. current를 next에 재대입해도 head는 그대로 첫 노드를 가리킵니다. 반대로 current.next = ...는 실제 연결 구조를 수정합니다. 2장에서 배운 이름 재대입과 객체 수정의 차이가 여기서 직접 드러납니다.
실행 결과
10
20
시간·공간 복잡도
노드 하나 생성 O(1), n개 순회 O(n), 순회 보조 공간 O(1)입니다. 노드 저장 공간은 데이터 참조와 링크를 포함하여 O(n)이며 배열보다 원소당 부가 비용이 클 수 있습니다.
생각해보기와 핵심 정리
생각해보기: head 대신 head=head.next로 순회하면 무엇을 잃을까요? 첫 노드의 시작 참조를 잃을 수 있습니다. 관찰용 current를 따로 사용합니다.
6.2 단순연결리스트 응용: 연결된 스택
학습 목표
head에서 push·pop을 구현한다.
링크 변경 전 보존해야 할 참조를 찾는다.
문제에서 개념으로
스택은 한쪽 끝에서만 넣고 빼므로 head를 top으로 사용하면 됩니다. 새 노드의 next를 현재 head로 연결한 다음 head를 새 노드로 바꿉니다. 순서를 반대로 하면 기존 목록의 시작을 잃을 수 있습니다.
배열 스택과 달리 고정 용량을 미리 정하지 않습니다. 필요할 때 노드를 생성하므로 메모리가 허용하는 한 늘어납니다. 그렇다고 무한 용량이라는 뜻은 아닙니다. 사용자가 받는 push·pop·peek·len 인터페이스는 4장과 같고 내부 저장 방식만 바뀝니다.
구조와 작은 예제
head가20→10인 상태에30을 넣습니다. 새 노드30.next=head를 설정한 뒤 head=30으로 바꾸면30→20→10입니다. pop은30의 값을 보존하고 head를20으로 바꿉니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
새 Node를 만드는 식이 먼저 평가되므로 기존 head를 next로 전달한 뒤 대입됩니다. pop 이후 빠진 노드를 가리키는 참조가 더 없다면 Python의 메모리 관리 대상이 됩니다. 직접 메모리를 free하는 코드는 필요하지 않습니다.
실행 결과
[30, 20, 10]
시간·공간 복잡도
push·pop·peek·len은 O(1), 전체 저장 공간 O(n)입니다. 일반적인 객체 할당을 상수 비용으로 보는 분석입니다. 임의 위치 접근은 제공하지 않습니다.
생각해보기와 핵심 정리
생각해보기: top을 단순 연결의 꼬리에 두고 pop하려면? 이전 노드를 찾아야 하므로 O(n)이 됩니다. 연산에 맞는 끝을 고릅니다.
6.3 단순연결리스트 응용: 연결된 리스트
학습 목표
위치 탐색과 링크 교체 비용을 분리한다.
head 변경이 필요한 경계 사례를 처리한다.
문제에서 개념으로
중간 삽입은 새 노드가 이전 노드와 다음 노드 사이에 들어가도록 링크 두 개를 설정합니다. 이전 노드를 이미 알고 있다면 상수 시간입니다. 하지만 인덱스 i만 주어지면 head부터 i-1번째 노드를 찾아가야 합니다. ‘연결 리스트 삽입 O(1)’에는 삽입 지점의 이전 노드를 알고 있다는 전제가 필요합니다.
삭제도 단순 연결에서는 이전 노드의 next를 수정해야 합니다. 삭제할 노드만 알고 있다고 일반적으로 O(1) 삭제가 되는 것은 아닙니다. 첫 위치는 이전 노드가 없으므로 head를 바꾸는 별도 처리가 필요합니다. 더미 노드를 두어 경계를 통일할 수도 있지만 여기서는 구조를 명확히 보이기 위해 분기합니다.
구조와 작은 예제
10→30 사이에20을 넣으려면 먼저20.next=30, 다음10.next=20입니다. 삭제는10.next를20.next인30으로 바꾸면 됩니다. 먼저10.next를 덮어써서30으로 가는 참조를 잃지 않도록 순서를 확인합니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
class LinkedList:
def __init__(self):
self._head = None
self._size = 0
def __len__(self): return self._size
def _node_at(self, index):
if not 0 <= index < self._size: raise IndexError("잘못된 위치")
current = self._head
for _ in range(index): current = current.next
return current
def get(self, index): return self._node_at(index).item
def set(self, index, item): self._node_at(index).item = item
def insert(self, index, item):
if not 0 <= index <= self._size: raise IndexError("잘못된 위치")
if index == 0:
self._head = Node(item, self._head)
else:
previous = self._node_at(index - 1)
previous.next = Node(item, previous.next)
self._size += 1
def delete(self, index):
if not 0 <= index < self._size: raise IndexError("잘못된 위치")
if index == 0:
removed = self._head
self._head = removed.next
else:
previous = self._node_at(index - 1)
removed = previous.next
previous.next = removed.next
self._size -= 1
return removed.item
def to_list(self):
result, current = [], self._head
while current is not None:
result.append(current.item)
current = current.next
return result
linked = LinkedList()
linked.insert(0,10)
linked.insert(1,30)
linked.insert(1,20)
print(linked.to_list())
print(linked.delete(0), linked.to_list())
코드 해설
_node_at은 위치를 노드 참조로 바꾸는 내부 도우미입니다. get·set도 탐색이 필요하므로 배열처럼 상수 시간이 아닙니다. insert(len, item)는 이전 마지막 노드를 찾은 뒤 끝에 연결합니다. size는 성공한 변경 후 한 번만 갱신합니다.
실행 결과
[10, 20, 30]
10 [20, 30]
시간·공간 복잡도
인덱스 i 접근·수정·삽입·삭제는 O(i+1), 최악 O(n)입니다. head 삽입·삭제는 O(1). 저장 공간 O(n), 각 연산 보조 공간 O(1), to_list 결과 공간 O(n)입니다. 반복 get으로 전체를 읽으면 O(n²)이므로 한 번의 링크 순회를 사용합니다.
생각해보기와 핵심 정리
생각해보기: 모든 원소를 for i ... get(i)로 읽으면 왜 느릴까요? 매번 head부터 다시 걷기 때문입니다. 위치 탐색 비용을 중복하지 마세요.
6.4 원형연결리스트의 응용: 연결된 큐
학습 목표
tail 하나로 앞·뒤 위치를 찾는다.
마지막 원소 삭제에서 원형 연결을 정리한다.
문제에서 개념으로
큐는 앞에서 꺼내고 뒤에 넣어야 하므로 두 끝이 모두 필요합니다. 단순 연결 큐에서 head와 tail을 각각 저장할 수도 있습니다. **원형 연결 리스트(Circular Linked List)**는 마지막 노드가 첫 노드를 가리키게 하여 tail 하나만 저장해도 front=tail.next를 얻도록 합니다.
원형 구조는 None을 만날 때까지 순회하는 방식으로는 끝나지 않습니다. 첫 노드로 돌아왔는지 또는 size번 처리했는지로 종료해야 합니다. 원소가 하나일 때는 그 노드의 next가 자기 자신입니다. 이 마지막 원소를 꺼내면 tail을 None으로 바꿔 빈 상태를 명시합니다.
구조와 작은 예제
tail에서 첫 노드로 이어지는 원형 연결 큐
뒤에 D를 넣을 때 D.next를 A에 연결하고 C.next를 D로 바꾼 뒤 tail을 D로 갱신합니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
class LinkedQueue:
def __init__(self):
self._tail = None
self._size = 0
def __len__(self): return self._size
def is_empty(self): return self._size == 0
def enqueue(self, item):
node = Node(item)
if self._tail is None:
node.next = node
else:
node.next = self._tail.next
self._tail.next = node
self._tail = node
self._size += 1
def peek(self):
if self.is_empty(): raise IndexError("큐가 비었습니다.")
return self._tail.next.item
def dequeue(self):
item = self.peek()
front = self._tail.next
if self._size == 1:
self._tail = None
else:
self._tail.next = front.next
front.next = None # 분리된 노드의 원형 참조를 끊는다.
self._size -= 1
return item
queue = LinkedQueue()
for item in "ABC": queue.enqueue(item)
print([queue.dequeue() for _ in range(len(queue))])
queue.enqueue("D")
print(queue.dequeue())
코드 해설
tail.next를 앞 위치로 사용하는 불변식이 핵심입니다. 빈 상태에서 node.next=node를 설정하면 하나의 노드도 원형 구조가 됩니다. 마지막 삭제 후 재삽입 예제로 빈 상태 전환이 올바른지 확인합니다.
실행 결과
['A', 'B', 'C']
D
시간·공간 복잡도
enqueue·dequeue·peek O(1), 저장 공간 O(n). 원형으로 연결했다는 사실이 순회를 빠르게 하지는 않으며 n개 순회는 O(n)입니다.
생각해보기와 핵심 정리
생각해보기: 마지막 삭제에서 tail을 그대로 두면? 논리적으로 빈 큐가 삭제된 노드를 계속 가리켜 다음 연산이 잘못될 수 있습니다.
6.5 이중연결리스트의 응용: 연결된 덱
학습 목표
prev와 next를 동시에 일관되게 갱신한다.
센티널 노드로 경계 처리를 단순화한다.
문제에서 개념으로
단순 연결은 뒤에서 앞으로 한 칸 이동할 수 없어 꼬리 삭제가 느립니다. **이중 연결 리스트(Doubly Linked List)**는 각 노드에 이전 노드를 가리키는 prev도 저장합니다. 양끝에서 추가·삭제하는 덱에 적합하지만 링크 공간과 갱신해야 할 관계가 늘어납니다.
여기서는 실제 데이터를 담지 않는 센티널(Sentinel) 노드 하나를 두고 원형 이중 연결을 만듭니다. 빈 구조에서는 센티널의 prev와 next가 자기 자신입니다. 그러면 첫 노드와 마지막 노드도 같은 삽입·삭제 도우미로 처리할 수 있습니다. 센티널은 원소 수에 포함하지 않습니다.
구조와 작은 예제
두 인접 노드 left,right 사이에 node를 넣을 때 node.prev=left, node.next=right, left.next=node, right.prev=node의 네 관계가 필요합니다. 한 방향만 바꾸면 앞 순회는 맞아도 뒤 순회가 깨질 수 있습니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
센티널인지 판정할 때는 값 비교가 아니라 is로 객체 동일성을 검사합니다. None도 정상 데이터일 수 있어 item으로 센티널을 구별하지 않습니다. _remove는 양쪽 이웃이 서로를 직접 가리키게 만든 뒤 빠진 노드의 링크를 끊습니다.
실행 결과
3 1 2
시간·공간 복잡도
양끝 추가·삭제 O(1), 저장 공간 O(n), 연산 보조 공간 O(1). 특정 인덱스 위치를 찾는 비용은 여전히 O(n)입니다. prev가 있다고 임의 위치 접근이 상수 시간이 되는 것은 아닙니다.
생각해보기와 핵심 정리
생각해보기: 센티널도 len에 포함하면? 사용자가 넣지 않은 원소까지 길이에 포함됩니다. 관리용 노드와 실제 데이터를 구분합니다.
CHAPTER 핵심 정리
구현
앞 삽입·삭제
위치 접근
뒤 삭제
배열 리스트
O(n)
O(1)
O(1)
단순 연결 리스트
O(1)
O(n)
O(n)
이중 연결+양끝 참조
O(1)
O(n)
O(1)
연결 구조의 O(1) 중간 변경은 필요한 노드 참조를 이미 확보했다는 전제가 있습니다. 배열은 공간 지역성이 좋을 수 있고 연결 구조는 원소별 객체·링크 비용이 듭니다. Big-O가 같아도 실제 실행 특성은 다릅니다.
연습문제
[기초] 문제 1
노드의 두 기본 필드는?
[기초] 문제 2
단순 연결 마지막 노드의 next는?
[기초] 문제 3
연결 스택에서 top으로 사용하는 끝은?
[기초] 문제 4
원형 큐에서 front를 얻는 식은?
[응용] 문제 5
head=head.next와 head.next=head.next.next의 차이는?
[응용] 문제 6
단순 연결에서 인덱스 i 삽입이 항상 O(1)이 아닌 이유는?
[응용] 문제 7
원형 목록을 None까지 순회하면?
[응용] 문제 8
이중 연결 삭제에서 두 이웃에 적용할 관계는?
[도전] 문제 9
단순 연결에서 모든 값을 get(i)로 읽는 전체 비용은?
[도전] 문제 10
배열보다 연결 구조가 언제나 빠르다는 주장에 반박하세요.
연습문제 정답 및 해설
문제 1
정답: 데이터와 다음 노드 참조.
해설: 단순 연결 노드는 item·next를 가집니다.
문제 2
정답: None.
해설: 원형 연결에서는 첫 노드로 돌아가므로 다릅니다.
문제 3
정답: head.
해설: 앞 추가와 제거가 상수 시간입니다.
문제 4
정답: tail.next.
해설: 비어 있지 않다는 전제가 필요합니다.
문제 5
정답: 첫째는 시작 참조를 바꾸고, 둘째는 첫 노드의 연결을 수정합니다.
해설: 각각 앞 노드 제거와 두 번째 노드 건너뛰기를 나타낼 수 있습니다.
문제 6
정답: 이전 위치를 찾는 순회 때문입니다.
해설: 링크 교체만 상수이고 탐색은 i에 비례합니다.
문제 7
정답: 정상 원형 구조에서는 종료되지 않습니다.
해설: 첫 노드 재방문 또는 원소 수로 종료를 정합니다.
문제 8
정답: 이전.next=다음, 다음.prev=이전.
해설: 양방향 연결을 모두 복구합니다.
문제 9
정답: O(n²).
해설: 순회 길이0+1+…+(n-1)이 반복됩니다. current를 한 번 이동시키면 O(n)입니다.
문제 10
정답: 위치 접근이 느리고 노드별 메모리·할당 비용이 추가됩니다.
해설: 장점인 링크 변경도 해당 위치 확보가 전제입니다. 필요한 연산과 실제 입력 패턴으로 선택해야 합니다.
실습문제
실습문제 1: 연결 역순
문제
단순 연결 리스트를 새 노드 없이 뒤집으세요.
요구사항
원소 수와 값은 유지합니다. 빈 목록도 처리합니다.
실행 예시
[3, 2, 1]
힌트
이전·현재·다음 참조 세 개를 사용합니다.
실습문제 2: 원형 순서 회전
문제
연결 큐의 앞 k명을 차례로 맨 뒤에 보내세요.
요구사항
k는 0 이상입니다. 빈 큐는 그대로 둡니다.
실행 예시
['C', 'D', 'A', 'B']
힌트
길이만큼 회전하면 원래 상태입니다.
실습문제 3: 양끝 번갈아 배출
문제
덱에서 앞·뒤를 번갈아 꺼내 새 목록으로 만드세요.
요구사항
앞부터 시작합니다. 홀수 길이의 가운데 원소는 한 번만 처리합니다.
실행 예시
[1, 5, 2, 4, 3]
힌트
한 번 꺼낼 때마다 비었는지 반복 조건에서 검사합니다.
실습문제 정답 및 해설
실습문제 1 정답
def reverse_linked(items):
previous, current = None, items._head
while current is not None:
following = current.next # 링크를 덮기 전에 나머지를 보존한다.
current.next = previous
previous, current = current, following
items._head = previous
items = LinkedList()
for value in (1,2,3): items.insert(len(items), value)
reverse_linked(items)
print(items.to_list())
코드 해설
자료구조 내부를 다루는 확장 함수입니다. 일반 사용 코드에서는 private 속성을 건드리지 않고 클래스 메서드로 넣는 것이 좋습니다. following을 저장하지 않으면 나머지 목록을 잃습니다.
실행 과정
1.next=None,2.next=1,3.next=2로 바뀌고 마지막 previous인3이 새 head입니다.
핵심 아이디어와 복잡도
링크 방향을 한 번씩 뒤집어 O(n) 시간·O(1) 보조 공간입니다.
실습문제 2 정답
def rotate_queue(queue, k):
if k < 0: raise ValueError("k는 0 이상")
if queue.is_empty(): return
for _ in range(k % len(queue)):
queue.enqueue(queue.dequeue())
queue = LinkedQueue()
for name in "ABCD": queue.enqueue(name)
rotate_queue(queue, 6)
print([queue.dequeue() for _ in range(len(queue))])
한 번 회전 O(1), 전체 O(k mod n), 보조 공간 O(1)입니다. 내부 tail을 next로 움직이는 전용 메서드는 노드 재생성도 피할 수 있습니다.
실습문제 3 정답
def alternating_drain(items):
result = []
front_turn = True
while not items.is_empty():
result.append(items.remove_front() if front_turn else items.remove_rear())
front_turn = not front_turn
return result
items = LinkedDeque()
for value in range(1,6): items.add_rear(value)
print(alternating_drain(items))
코드 해설
매 반복에서 정확히 하나만 제거하므로 마지막 하나를 두 번 꺼내는 문제가 없습니다. 원래 덱을 소진하는 함수라는 점을 명세합니다.
실행 과정
앞1,뒤5,앞2,뒤4,앞3 순서입니다.
핵심 아이디어와 복잡도
양끝 연산이 모두 O(1)이어서 전체 O(n), 결과 공간 O(n), 그 외 보조 공간 O(1)입니다.
다음 학습으로
7장에서는 저장 순서를 정렬하여 탐색을 개선하고, 해시 함수를 이용한 맵을 구현합니다. 위치까지 걸어가는 연결 구조의 비용과 배열 접근의 차이를 이어서 비교합니다.
실행 파일은 code/ch06.py, 시연 출력 없이 가져오는 재사용 모듈은 code/core06.py입니다. 실습 정답은 실행 파일의 뒤쪽에 배치했습니다.
목록을 정리하는 데 비용을 들이면 이후 찾기가 빨라질 수 있습니다. 이 장에서는 간단한 정렬을 직접 구현하고, 정렬된 데이터가 집합 연산과 탐색을 어떻게 바꾸는지 확인합니다. 마지막에는 정렬 없이 키의 위치를 계산하는 해싱을 배웁니다.
7.1 정렬이란?
학습 목표
정렬 기준과 안정성을 구분한다.
제자리 수정 여부를 확인한다.
문제에서 개념으로
**정렬(Sorting)**은 정해진 기준에 따라 원소를 재배치하는 일입니다. 숫자 오름차순뿐 아니라 이름 가나다순, 마감일순도 정렬입니다. 여러 필드가 있는 자료에서는 비교에 사용할 키(Key)를 정해야 합니다. 학생 정보를 점수순으로 정렬한다면 학생 전체가 아니라 점수가 키입니다.
같은 키의 원소들이 입력에서의 상대 순서를 유지하면 **안정 정렬(Stable Sort)**입니다. 모든 숫자가 서로 다르면 안정성 차이를 관찰할 수 없습니다. 또 입력 배열을 직접 수정하는지, 새 배열을 반환하는지 구분해야 합니다. 이 장의 세 정렬 함수는 주어진 리스트를 수정하고 같은 리스트를 반환합니다. 비교하려면 같은 원본의 복사본을 각각 전달합니다.
구조와 작은 예제
[(민,80),(준,70),(서,80)]을 점수순으로 안정 정렬하면 준,민,서입니다. 80점 둘의 민→서 순서가 유지됩니다. 이는 점수 외에 이름까지 정렬했다는 뜻이 아닙니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
비교 정렬은 비교 횟수로 분석하며 비교 한 번의 비용을 보통 O(1)로 가정합니다. 문자열·복합 객체 비교는 길이에 따른 비용이 추가될 수 있습니다. 내장 sorted는 결과 목록 O(n) 공간이 필요합니다.
생각해보기와 핵심 정리
생각해보기: 같은 점수끼리 이름순으로 바뀌었다면 안정 정렬일까요? 입력 상대 순서를 유지하지 않았다면 점수만을 기준으로는 안정적이지 않습니다.
7.2 간단한 정렬 알고리즘
학습 목표
선택·삽입·버블 정렬의 불변식을 비교한다.
입력 순서에 따라 작업량이 달라지는 이유를 설명한다.
문제에서 개념으로
선택 정렬(Selection Sort)은 남은 구간에서 최솟값을 골라 앞에 놓습니다. 매 단계 앞쪽 한 자리가 확정됩니다. 삽입 정렬(Insertion Sort)은 앞쪽을 정렬된 구간으로 유지하고 새 원소를 그 안의 알맞은 위치에 끼워 넣습니다. 카드패를 정리하는 방식과 비슷합니다. 버블 정렬(Bubble Sort)은 이웃한 두 값의 순서가 틀리면 교환하여 큰 값을 뒤쪽으로 보냅니다.
세 방법 모두 단순하지만 하는 일이 다릅니다. 선택은 정렬 여부와 관계없이 남은 구간을 모두 조사합니다. 삽입은 현재 값보다 큰 앞쪽 원소만 이동하므로 거의 정렬된 데이터에 유리합니다. 버블은 한 회전에서 교환이 없으면 이미 정렬되었다고 판단하여 종료할 수 있습니다. 종료 최적화가 없는 버전과 최선 시간을 혼동하지 않습니다.
구조와 작은 예제
입력 [4,2,3,1]을 추적합니다.
단계
선택: 앞 확정
삽입: 새 값 끼우기
버블: 뒤 확정
1
[1,2,3,4]
[2,4,3,1]
[2,3,1,4]
2
[1,2,3,4]
[2,3,4,1]
[2,1,3,4]
3
[1,2,3,4]
[1,2,3,4]
[1,2,3,4]
선택 정렬은 첫 단계에서 우연히 전체가 정렬되어도 남은 비교를 수행합니다. 결과가 빨리 정리된 것과 알고리즘이 그것을 알고 멈추는 것은 다릅니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def selection_sort(items):
for start in range(len(items) - 1):
smallest = start
for index in range(start + 1, len(items)):
if items[index] < items[smallest]: smallest = index
items[start], items[smallest] = items[smallest], items[start]
return items
def insertion_sort(items):
for index in range(1, len(items)):
value = items[index]
position = index
# value보다 큰 값만 이동하여 같은 값의 상대 순서를 지킨다.
while position > 0 and items[position - 1] > value:
items[position] = items[position - 1]
position -= 1
items[position] = value
return items
def bubble_sort(items):
for end in range(len(items) - 1, 0, -1):
swapped = False
for index in range(end):
if items[index] > items[index + 1]:
items[index], items[index + 1] = items[index + 1], items[index]
swapped = True
if not swapped: break
return items
for algorithm in (selection_sort, insertion_sort, bubble_sort):
print(algorithm.__name__, algorithm([4,2,3,1]))
코드 해설
selection의 smallest는 현재 미정렬 구간 최솟값의 위치입니다. insertion은 value를 따로 보존한 뒤 큰 원소들을 한 칸씩 옮겨 빈자리를 만듭니다. bubble의 end 뒤는 이미 확정되어 다음 회전에서 제외합니다. 삽입과 버블은 같은 값일 때 이동·교환하지 않아 안정적입니다. 선택의 먼 거리 교환은 동일 키의 상대 순서를 깨뜨릴 수 있습니다.
선택은 n(n-1)/2회 비교로 최선·평균·최악 O(n²). 삽입·최적화 버블은 정렬된 입력에서 O(n), 평균·최악 O(n²)입니다. 세 구현 모두 보조 공간 O(1). 삽입에서 역순 쌍의 수가 적으면 이동도 적습니다.
생각해보기와 핵심 정리
생각해보기: 선택 정렬은 왜 교환이 적어도 느릴 수 있나요? 비교를 계속 수행하기 때문입니다. 비용을 비교와 이동으로 나누어 보면 특성이 더 분명합니다.
7.3 정렬 응용: 집합 다시보기
학습 목표
정렬된 두 집합을 두 포인터로 합친다.
사전 정렬 비용과 결합 비용을 분리한다.
문제에서 개념으로
3장의 리스트 집합은 원소마다 상대 집합 전체를 조사했습니다. 두 집합을 정렬된 중복 없는 배열로 유지하면 앞에서부터 두 포인터를 움직여 결합할 수 있습니다. 작은 값을 결과에 넣고 그쪽 포인터만 이동하며, 같으면 한 번만 넣고 둘 다 이동합니다.
현재 비교하는 두 값 중 작은 값은 상대 배열의 아직 남은 어떤 값보다도 작거나 같습니다. 따라서 그 값을 지금 확정해도 나중에 더 작은 값이 끼어들지 않습니다. 정렬이라는 사전조건이 반복 탐색을 없애 줍니다. 정렬되지 않은 입력에 같은 코드를 쓰면 보장은 사라집니다.
구조와 작은 예제
A=[1,4,7], B=[2,4,6]. 1 선택→2 선택→4는한번→6 선택→7 남은값 추가. 결과는 [1,2,4,6,7]입니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def sorted_union(first, second):
result = []
left = right = 0
while left < len(first) and right < len(second):
if first[left] < second[right]:
result.append(first[left]); left += 1
elif second[right] < first[left]:
result.append(second[right]); right += 1
else:
result.append(first[left]); left += 1; right += 1
result.extend(first[left:])
result.extend(second[right:])
return result
print(sorted_union([1,4,7], [2,4,6]))
코드 해설
각 포인터는 뒤로 가지 않습니다. 한쪽이 끝나면 상대의 나머지는 이미 정렬되어 있으므로 그대로 붙입니다. 입력 자체에 중복이 없다는 사전조건이 있어야 남은 구간을 그대로 붙여도 집합이 됩니다.
실행 결과
[1, 2, 4, 6, 7]
시간·공간 복잡도
이미 정렬된 중복 없는 길이 n,m 입력에서 시간 O(n+m), 결과·슬라이스 공간 O(n+m)입니다. 정렬부터 해야 한다면 사용 정렬의 비용을 추가합니다. 7.2의 제곱 정렬을 썼다면 전체가 자동으로 선형이 되지 않습니다.
생각해보기와 핵심 정리
생각해보기: 교집합은 같을 때만 추가하면 되지만 다른 경우에도 포인터를 움직여야 합니다. 어느 쪽일까요? 더 작은 쪽입니다. 그 값은 상대의 남은 값에 없기 때문입니다.
7.4 탐색과 맵 구조
학습 목표
키와 값의 역할을 구분한다.
없는 키와 값 None을 구별한다.
문제에서 개념으로
단어를 찾은 뒤 뜻을 얻고 싶다면 소속 여부만 알려 주는 집합으로 부족합니다. **맵(Map)**은 고유한 키에 값을 연결합니다. 같은 키를 다시 저장하면 별도 항목을 늘리는 대신 값을 갱신하는 규칙을 사용합니다. 키는 탐색 기준, 값은 얻고 싶은 정보입니다.
이 책의 직접 맵은 put(key,value), get(key), delete(key)를 사용합니다. 없는 키의 get·delete는 KeyError입니다. 정상 값으로 None을 저장할 수 있으므로 없는 키를 None 반환으로만 표현하지 않습니다. 내부 구현은 목록·해시·탐색트리로 바꾸어도 이 약속을 유지할 수 있습니다.
구조와 작은 예제
word→뜻과 student→점수는 같은 맵 관계입니다. 뜻이 우연히 같아도 키가 다르면 다른 항목이고, 키가 같으면 갱신합니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
인덱스 연산자는 dict에서 키 조회입니다. 목록의 정수 위치 접근과 구분하세요. 키가 없을 때 예외를 처리하는 코드와 정상 None을 출력하는 코드의 결과가 분리됩니다.
실행 결과
계층 구조 2
None
없는 키
시간·공간 복잡도
Python dict의 키 조회·갱신은 평균 O(1), 최악 O(n). 비교·해시 계산 비용은 키의 크기에 따라 추가될 수 있습니다. 맵 ADT 자체가 이 비용을 정하는 것은 아닙니다.
생각해보기와 핵심 정리
생각해보기: 맵의 값도 모두 달라야 할까요? 아닙니다. 고유성은 키에 적용됩니다.
7.5 간단한 탐색 알고리즘
학습 목표
순차·이진 탐색의 사전조건을 비교한다.
탐색 구간의 불변식과 경계를 유지한다.
문제에서 개념으로
순차 탐색은 앞에서부터 확인하므로 정렬이 필요하지 않습니다. **이진 탐색(Binary Search)**은 정렬된 배열의 중간값과 비교하여 가능한 구간을 절반씩 줄입니다. 찾는 값이 중간값보다 크면 중간 이하를 버리고, 작으면 중간 이상을 버립니다.
이 책은 양끝을 포함하는 구간 [low,high]를 사용합니다. 탐색을 계속할 조건은 low≤high입니다. 범위를 줄일 때 mid 자체는 이미 비교했으므로 mid+1 또는 mid-1로 넘어갑니다. low=mid처럼 그대로 남기면 길이2 같은 작은 구간에서 진전하지 못할 수 있습니다. 중복값이 있으면 이 기본 구현은 발견한 위치 하나를 반환하며 첫 위치를 보장하지 않습니다.
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def sequential_search(items, target):
for index, value in enumerate(items):
if value == target: return index
return -1
def binary_search(items, target):
low, high = 0, len(items) - 1
while low <= high:
middle = (low + high) // 2
if items[middle] == target: return middle
if items[middle] < target: low = middle + 1
else: high = middle - 1
return -1
print(binary_search([2,5,8,12,16,23,38],23))
print(binary_search([],23))
코드 해설
불변식은 ‘목표가 존재한다면 현재 구간 안에 있다’입니다. 버리는 구간에는 정렬 조건상 목표가 없으므로 유지됩니다. low>high가 되면 후보가 없어 실패합니다. 빈 배열은 high=-1로 시작해 바로 종료합니다.
실행 결과
5
-1
시간·공간 복잡도
순차 최악 O(n), 이진 최악 O(log n), 두 구현 보조 공간 O(1). 이진 탐색은 중간 접근 O(1)인 배열을 전제로 합니다. 연결 리스트에서 중간을 매번 걸어 찾으면 같은 로그 비용을 얻지 못합니다.
생각해보기와 핵심 정리
생각해보기: 한 번만 찾기 위해 O(n log n) 정렬 후 이진 탐색하는 것이 항상 유리할까요? 아닙니다. 한 번의 순차 탐색 O(n)이 더 적은 총비용일 수 있습니다.
7.6 고급 탐색 구조: 해싱
학습 목표
해시 함수·충돌·부하율을 설명한다.
체이닝으로 충돌을 처리한다.
문제에서 개념으로
**해싱(Hashing)**은 키에서 배열 위치를 계산하는 방식입니다. 해시 함수(Hash Function)가 만든 정수를 버킷 수로 나눈 나머지를 위치로 사용합니다. 서로 다른 키가 같은 위치를 얻는 **충돌(Collision)**은 피할 수 없으므로 반드시 해결 규칙이 필요합니다. 충돌했다고 키가 같아지는 것은 아닙니다.
체이닝(Chaining)은 버킷마다 항목 목록을 두어 같은 위치의 키들을 보관합니다. 찾을 때는 해당 버킷 안에서 실제 키를 비교합니다. 선형 조사법은 대신 배열의 다음 빈 슬롯을 찾는 개방 주소 방식이며, 삭제 시 탐색 경로를 끊지 않도록 삭제 표시가 필요합니다. 여기서는 삭제가 직관적인 체이닝을 직접 구현합니다.
항목 수 n을 버킷 수 m으로 나눈 부하율(Load Factor) α=n/m이 커지면 평균 버킷 길이도 커집니다. 잘 분산된 해시와 제한된 부하율이 평균 상수 시간의 전제입니다. 고정 버킷 수를 계속 유지하면 n이 커질 때 평균 상수 시간을 주장할 수 없습니다.
구조와 작은 예제
버킷5개에서 정수 키1과6은 모두 나머지1입니다. 버킷1에 [(1,가),(6,나)]를 두고 실제 키 비교로 구별합니다. 아래 구현은 내장 hash를 위치 계산에만 쓰며 맵 자체를 dict로 대신하지 않습니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
class HashMap:
def __init__(self, bucket_count=8):
if bucket_count <= 0: raise ValueError("양의 버킷 수 필요")
self._buckets = [[] for _ in range(bucket_count)]
self._size = 0
def __len__(self): return self._size
def _bucket(self, key):
return self._buckets[hash(key) % len(self._buckets)]
def put(self, key, value):
bucket = self._bucket(key)
for index, (stored_key, _) in enumerate(bucket):
if stored_key == key:
bucket[index] = (key, value)
return
bucket.append((key, value))
self._size += 1
if self._size > 2 * len(self._buckets):
self._resize(2 * len(self._buckets))
def get(self, key):
for stored_key, value in self._bucket(key):
if stored_key == key: return value
raise KeyError(key)
def delete(self, key):
bucket = self._bucket(key)
for index, (stored_key, value) in enumerate(bucket):
if stored_key == key:
bucket.pop(index)
self._size -= 1
return value
raise KeyError(key)
def _resize(self, count):
old = self._buckets
self._buckets = [[] for _ in range(count)]
for bucket in old:
for key, value in bucket:
# 키는 이미 고유하므로 중복 검사를 반복하지 않는다.
self._bucket(key).append((key, value))
mapping = HashMap(5)
mapping.put(1,"가")
mapping.put(6,"나")
mapping.put(1,"수정")
print(mapping.get(1), mapping.get(6), len(mapping))
print(mapping.delete(1), mapping.get(6))
코드 해설
갱신 경로에서는 size를 증가시키지 않습니다. 버킷 수가 바뀌면 나머지 값도 바뀌므로 모든 키를 다시 배치해야 합니다. 예전 버킷 배열만 새 배열 앞쪽에 붙이면 잘못된 위치에서 찾게 됩니다. 사용자 정의 키는 동등한 키의 hash가 같고 저장 중 해시·동등성이 바뀌지 않는 계약을 지켜야 합니다.
실행 결과
수정 나 2
수정 나
시간·공간 복잡도
좋은 분포에서 조회·삭제 기대 O(1+α). 확장으로 α를 제한하면 삽입은 기대 분할 상환 O(1), 확장하는 특정 삽입은 O(n+m)입니다. 모든 키가 한 버킷에 몰리면 조회·삽입·삭제 최악 O(n). 저장 공간 O(n+m). 해시 함수 자체 비용은 별도입니다.
생각해보기와 핵심 정리
생각해보기: 버킷 수를 두 배로 늘려도 모든 키의 해시가 같다면? 충돌이 해소되지 않습니다. 확장과 좋은 해시 분포는 서로 다른 조건입니다.
7.7 맵의 응용: 나의 단어장
학습 목표
조회·갱신·삭제를 사용자 작업에 대응한다.
저장 구조와 표시 계층을 분리한다.
문제에서 개념으로
단어장은 단어를 키, 뜻을 값으로 사용합니다. 단어를 새로 등록하는 것과 뜻을 고치는 것은 동일한 put 연산으로 표현할 수 있습니다. 조회 실패는 ‘없는 단어’ 메시지로 바꾸되, 내부 맵은 계속 KeyError라는 일관된 규칙을 유지합니다.
단어를 알파벳순으로 모두 출력하는 기능까지 필요하다면 해시 맵만으로는 정렬 순서가 나오지 않습니다. 키를 모아 정렬하거나 순서를 유지하는 탐색트리를 고려해야 합니다. 빠른 정확 키 조회와 정렬 순회는 별개의 요구사항입니다.
구조와 작은 예제
tree 등록→뜻 갱신→조회, graph 조회 실패의 순서입니다. 실패 메시지는 사용자 계층에서 처리하므로 HashMap 코드를 특정 응용에 맞춰 바꾸지 않습니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
words = HashMap()
words.put("tree", "나무")
words.put("tree", "계층적 자료구조")
print(words.get("tree"))
try:
print(words.get("graph"))
except KeyError:
print("등록되지 않은 단어입니다.")
코드 해설
HashMap의 메서드가 한국어 안내 문구를 출력하지 않는 이유는 다른 응용에서도 재사용하기 위해서입니다. 뜻이 빈 문자열인 경우와 키가 없는 경우도 구분됩니다. 실제 영구 저장은 별도 파일 입출력 계층이 담당합니다.
실행 결과
계층적 자료구조
등록되지 않은 단어입니다.
시간·공간 복잡도
단어 길이 L의 해시 계산과 충돌 비교 비용을 고려합니다. 짧은 키와 좋은 분포 가정에서 평균 조회 O(1). 전체 사전 정렬 출력은 항목 수 n 기준 O(n log n) 정렬 비용이 추가됩니다.
생각해보기와 핵심 정리
생각해보기: 접두사로 시작하는 모든 단어를 찾는 기능도 정확 키 조회와 같은 비용일까요? 아닙니다. 해시 맵의 단일 키 조회 장점이 모든 검색 형태에 적용되지는 않습니다.
CHAPTER 핵심 정리
방법
필요한 조건
최악 시간
순차 탐색
없음
O(n)
이진 탐색
정렬+빠른 중간 접근
O(log n)
체이닝 해시
해시 가능한 키
O(n), 정상 분포 평균 O(1)
정렬 비용은 조회 비용과 별도로 합산합니다. 안정성은 동일 키의 상대 순서, 제자리 여부는 추가 저장 방식에 대한 성질입니다.
연습문제
[기초] 문제 1
안정 정렬의 의미는?
[기초] 문제 2
이진 탐색의 필수 입력 조건은?
[기초] 문제 3
맵에서 같은 키를 put하면?
[기초] 문제 4
서로 다른 키의 버킷 위치가 같으면 무엇인가요?
[응용] 문제 5
[3,1,2] 삽입 정렬의 두 단계는?
[응용] 문제 6
선택 정렬이 불안정함을 같은 키에 꼬리표를 붙여 설명하세요.
[응용] 문제 7
원소12개, 버킷8개의 부하율은?
[응용] 문제 8
이진 탐색에서 low=mid 대신 mid+1로 옮기는 이유는?
[도전] 문제 9
q번 조회를 순차 탐색할 때와 한 번 정렬 후 이진 탐색할 때 비용을 비교하세요.
[도전] 문제 10
해시 버킷을 확장할 때 재배치가 필요한 이유는?
연습문제 정답 및 해설
문제 1
정답: 동일 키 원소의 입력 상대 순서를 유지합니다.
해설: 모든 키가 다르면 안정성 차이가 드러나지 않습니다.
문제 2
정답: 같은 비교 기준으로 정렬된 데이터.
해설: 절반을 버려도 된다는 근거입니다.
문제 3
정답: 값을 갱신합니다.
해설: 키 항목 수는 늘지 않습니다.
문제 4
정답: 충돌.
해설: 실제 키 비교로 항목을 구별해야 합니다.
문제 5
정답: [1,3,2]→[1,2,3].
해설: 매번 새 값을 이미 정렬된 앞부분에 끼웁니다.
문제 6
정답: [2a,2b,1]에서1과2a를 교환하면 [1,2b,2a].
해설: 같은 키2의 상대 순서가 뒤집힙니다.
문제 7
정답: 1.5.
해설: α=n/m=12/8입니다. 체이닝은1을 넘을 수 있습니다.
문제 8
정답: 이미 비교한 원소를 후보에서 제외하고 구간을 줄입니다.
해설: mid를 남기면 작은 구간에서 무한 반복할 수 있습니다.
문제 9
정답: O(qn) 대 O(n log n+q log n), O(n log n) 정렬 가정.
해설: 조회가 많으면 사전 정렬 비용을 나눠 부담할 수 있습니다. 갱신 비용은 별도입니다.
발견 위치를 answer에 보존하고 더 왼쪽의 같은 값 가능성을 확인합니다. target보다 큰 경우도 왼쪽으로 갑니다.
실행 과정
중간2에서 후보2, 왼쪽 구간에서 위치1의2를 찾아 갱신합니다.
핵심 아이디어와 복잡도
답 후보와 탐색 구간을 별도로 유지합니다. 시간 O(log n), 공간 O(1).
실습문제 2 정답
def sorted_intersection(first, second):
left = right = 0
result = []
while left < len(first) and right < len(second):
if first[left] < second[right]: left += 1
elif second[right] < first[left]: right += 1
else:
result.append(first[left]); left += 1; right += 1
return result
print(sorted_intersection([1,4,7],[2,4,6,7]))
코드 해설
한쪽이 끝나면 공통 원소를 더 찾을 수 없으므로 종료합니다. 합집합처럼 나머지를 붙이지 않습니다.
실행 과정
1과2 비교 후1 제거,4와2 후2 제거,4 일치,7과6 후6 제거,7 일치입니다.
핵심 아이디어와 복잡도
각 포인터는 한 방향으로만 이동합니다. O(n+m) 시간, 결과 O(min(n,m)) 공간.
실습문제 3 정답
def word_counts(text):
counts = HashMap()
for word in text.split():
try: previous = counts.get(word)
except KeyError: previous = 0
counts.put(word, previous + 1)
return counts
counts = word_counts("tree graph tree tree")
print(counts.get("tree"), counts.get("graph"))
코드 해설
키 고유성 때문에 같은 단어가 새 항목이 아니라 횟수 갱신으로 처리됩니다. 예외 범위를 조회 한 곳에 제한합니다.
실행 과정
tree=1,graph=1,tree=2,tree=3 순으로 변합니다.
핵심 아이디어와 복잡도
전체 문자 수 처리와 해시 비용을 포함하며, 짧은 단어 n개 기준 기대 O(n), 고유 단어 u개 저장 O(u)입니다.
다음 학습으로
8장에서는 선형 순서를 넘어 계층 구조인 트리를 배웁니다. 비정렬 우선순위 큐의 느린 제거는 힙으로 개선하고, 재귀는 서브트리 연산에 재사용합니다.
실행 파일은 code/ch07.py, 시연 출력 없이 가져오는 재사용 모듈은 code/core07.py입니다. 실습 정답은 실행 파일의 뒤쪽에 배치했습니다.
연결 리스트는 다음 노드 하나로 이어졌습니다. 트리는 한 노드 아래 여러 갈래를 두어 계층을 표현합니다. 재귀는 하위 트리를 같은 문제로 다룰 때 자연스러워지고, 힙은 완전이진트리의 모양을 배열 위치 공식으로 바꿉니다.
8.1 트리란?
학습 목표
루트·부모·자식·형제·리프를 그림에서 찾는다.
레벨·높이의 기준을 통일한다.
문제에서 개념으로
폴더 안에 하위 폴더가 있고 그 안에 다시 폴더가 있듯 계층적인 관계를 표현할 때 **트리(Tree)**를 사용합니다. 이 책에서 루트가 있는 트리는 맨 위의 **루트(Root)**를 하나 정하며, 루트가 아닌 각 노드는 부모가 정확히 하나입니다. 부모와 자식을 연결하는 관계가 간선(Edge)입니다. 일반 그래프와 달리 같은 아래 노드를 두 부모가 공유하거나 순환하면 이 장의 트리 구조가 아닙니다.
부모(Parent) 아래의 노드는 자식(Child), 같은 부모의 자식들은 형제(Sibling)입니다. 자식이 없는 노드는 리프(Leaf), 어떤 노드와 그 아래 자손 전체를 묶으면 서브트리(Subtree)입니다. 레벨(Level)은 루트에서 내려간 간선 수로 정하여 루트는0입니다. 노드 높이(Height)는 그 노드에서 가장 먼 리프까지의 간선 수입니다. 리프 높이는0, 빈 트리 높이는-1로 통일합니다. 다른 책의 ‘노드 수 기준 높이’와1 차이가 날 수 있습니다.
구조와 작은 예제
트리의 루트·레벨·리프
용어
그림의 예
노드
A,B,C,D,E 각각
간선
A-B,A-C,B-D,B-E
부모·자식
B는D의 부모, D는B의 자식
형제
B와C, D와E
리프
C,D,E
B의 서브트리
B,D,E와 그 연결
전체 높이
2
해설
노드5개에 간선4개입니다. 일반적으로 비어 있지 않은 트리는 n개 노드에 n-1개 간선을 가집니다. 새 노드 하나를 기존 트리에 부모 간선 하나로 붙이는 과정을 생각하면 이해할 수 있습니다. 깊은 위치의 노드라고 항상 높이가 큰 것은 아닙니다. D는 레벨2이지만 리프이므로 높이0입니다.
시간·공간 복잡도
트리의 전체 순회는 보통 n개 노드를 확인하므로 O(n)입니다. 특정 경로만 내려가는 연산은 높이 h에 의존합니다. 높이가 항상 log n이라는 가정은 아직 없습니다.
생각해보기와 핵심 정리
생각해보기: 위 그림에서 C와D는 형제인가요? 아닙니다. 부모가 다릅니다. 레벨과 높이는 서로 반대 방향의 거리 개념입니다.
8.2 이진트리
학습 목표
이진트리의 왼쪽·오른쪽 구별을 이해한다.
완전·포화·편향 모양을 비교한다.
문제에서 개념으로
**이진트리(Binary Tree)**는 각 노드가 최대 두 자식을 갖고 왼쪽과 오른쪽을 구별하는 트리입니다. 자식이 하나일 때도 왼쪽 자식인지 오른쪽 자식인지 구조의 일부입니다. 값의 크기에 대한 규칙은 없습니다. 작은 값이 왼쪽이라는 규칙은 다음 장의 이진탐색트리에 추가됩니다.
모든 레벨이 가득 찬 모양을 이 책에서는 포화이진트리(Perfect Binary Tree)라고 부릅니다. 마지막 레벨을 제외하면 가득 차고 마지막은 왼쪽부터 채운 모양은 완전이진트리(Complete Binary Tree)입니다. 모든 내부 노드가 정확히 두 자식을 갖는 full binary tree와 perfect를 혼동하지 않도록 영어를 병기합니다. 한쪽 자식만 이어지면 편향되어 높이가 n-1까지 커질 수 있습니다.
구조와 작은 예제
높이 h인 포화이진트리는 레벨별1,2,4,…,2ʰ개 노드여서 총2^(h+1)-1개입니다. 완전이진트리의 높이는 ⌊log₂n⌋입니다. 임의 이진트리는 이보다 훨씬 길쭉할 수 있습니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
class BinaryNode:
def __init__(self, item, left=None, right=None):
self.item, self.left, self.right = item, left, right
tree = BinaryNode("A",
BinaryNode("B", BinaryNode("D"), BinaryNode("E")),
BinaryNode("C"))
print(tree.item, tree.left.item, tree.right.item)
코드 해설
left와 right에는 값이 아니라 서브트리의 루트 노드 참조가 들어갑니다. None은 해당 자식이 없다는 뜻입니다. 이 트리는8.1의 그림을 그대로 표현합니다. 노드를 생성하는 중첩 코드도 안쪽 하위 노드부터 만들어 부모에 연결됩니다.
실행 결과
A B C
시간·공간 복잡도
노드 하나 생성 O(1), n개 저장 O(n). 노드 참조에서 왼쪽·오른쪽 자식 접근 O(1)이지만 특정 값을 찾으려면 일반적으로 전체를 조사해야 합니다.
생각해보기와 핵심 정리
생각해보기: 이진트리에서 오른쪽 값이 왼쪽보다 작으면 오류인가요? 아닙니다. 아직 탐색 순서 규칙이 없습니다.
8.3 이진트리의 연산
학습 목표
네 가지 순회 결과를 직접 추적한다.
노드 수와 높이를 하위 문제로 계산한다.
문제에서 개념으로
트리 전체를 방문하는 순회(Traversal)는 현재 노드를 언제 처리하느냐에 따라 나뉩니다. 전위(Preorder)는 현재→왼쪽→오른쪽, 중위(Inorder)는 왼쪽→현재→오른쪽, 후위(Postorder)는 왼쪽→오른쪽→현재입니다. 레벨 순회(Level-order)는 큐로 가까운 레벨부터 방문합니다.
‘왼쪽’은 왼쪽 자식 하나만 처리한다는 뜻이 아니라 왼쪽 서브트리 전체를 같은 규칙으로 순회한다는 뜻입니다. 후위 순회는 두 자식 결과를 받아 부모 결과를 만드는 계산에 적합합니다. 트리 노드 수는1+왼쪽수+오른쪽수, 높이는1+두 자식 높이의 최댓값입니다. 빈 트리의 값이 기저 사례가 됩니다.
구조와 작은 예제
순회
A-B(D,E)-C 그림의 결과
전위
A B D E C
중위
D B E A C
후위
D E B C A
레벨
A B C D E
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
from collections import deque
def traverse(root, order):
if order not in ("pre", "in", "post"):
raise ValueError("지원하지 않는 순회")
result = []
def visit(node):
if node is None: return
if order == "pre": result.append(node.item)
visit(node.left)
if order == "in": result.append(node.item)
visit(node.right)
if order == "post": result.append(node.item)
visit(root)
return result
def level_order(root):
if root is None: return []
pending, result = deque([root]), []
while pending:
node = pending.popleft()
result.append(node.item)
if node.left is not None: pending.append(node.left)
if node.right is not None: pending.append(node.right)
return result
def node_count(root):
if root is None: return 0
return 1 + node_count(root.left) + node_count(root.right)
def tree_height(root):
if root is None: return -1
return 1 + max(tree_height(root.left), tree_height(root.right))
for order in ("pre", "in", "post"):
print(order, traverse(tree, order))
print("level", level_order(tree))
print(node_count(tree), tree_height(tree))
코드 해설
traverse는 결과 목록 하나에 append합니다. 재귀마다 왼쪽목록+현재+오른쪽목록으로 연결하면 편향 트리에서 반복 복사가 커질 수 있습니다. visit은 결과를 공유하지만 노드별 처리 위치만 다릅니다. 높이 함수는 리프에서1+max(-1,-1)=0이 되어 정의와 일치합니다.
실행 결과
pre ['A', 'B', 'D', 'E', 'C']
in ['D', 'B', 'E', 'A', 'C']
post ['D', 'E', 'B', 'C', 'A']
level ['A', 'B', 'C', 'D', 'E']
5 2
시간·공간 복잡도
각 노드를 한 번 처리하여 모두 O(n) 시간. 재귀 스택 O(h+1), 순회 결과 O(n). 레벨 순회 큐는 최대 너비 w에 비례하여 O(w), 최악 O(n)입니다. 편향 트리에서는 재귀 깊이 제한에 주의하고 반복 스택으로 바꿀 수 있습니다.
생각해보기와 핵심 정리
생각해보기: 일반 이진트리의 중위 결과가 정렬되어 있나요? 보장되지 않습니다.9장에서 값의 순서 규칙을 추가할 때 정렬 순회가 됩니다.
8.4 이진트리의 응용: 모르스 코드 결정트리
학습 목표
신호 하나를 한 간선 선택으로 해석한다.
없는 경로와 미등록 문자를 구별한다.
문제에서 개념으로
모르스 부호는 점과 선의 조합으로 문자를 표현합니다. 점을 왼쪽, 선을 오른쪽 간선으로 두면 신호열을 읽는 일이 루트에서 경로를 따라가는 일이 됩니다. 이처럼 입력의 선택에 따라 아래로 내려가는 구조를 결정트리(Decision Tree)로 사용할 수 있습니다.
여기서는 원리를 확인하기 위한 E,T,I,A,N,M 여섯 문자만 등록합니다. 전체 국제 모르스 문자표를 제공하는 예제가 아닙니다. 모르스는 문자 사이의 간격이 문자 경계를 알려 주므로, 단순히 모든 점과 선을 이어 붙인 문자열을 이 트리만으로 유일하게 해독할 수 있다고 가정하면 안 됩니다.
구조와 작은 예제
루트에서 점(.)은E, 선(-)은T입니다. E에서 점은I(..), 선은A(.-), T에서 점은N(-.), 선은M(--)입니다. 각 문자 신호를 공백으로 분리해 전달합니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def build_morse(entries):
root = BinaryNode(None)
for letter, signals in entries:
current = root
for signal in signals:
if signal not in ".-": raise ValueError("잘못된 신호")
direction = "left" if signal == "." else "right"
if getattr(current, direction) is None:
setattr(current, direction, BinaryNode(None))
current = getattr(current, direction)
current.item = letter
return root
def decode_morse(root, signals):
current = root
for signal in signals:
if signal not in ".-": raise ValueError("잘못된 신호")
current = current.left if signal == "." else current.right
if current is None: raise ValueError("등록되지 않은 경로")
if current.item is None: raise ValueError("등록되지 않은 문자")
return current.item
morse = build_morse([("E","."),("T","-"),("I",".."),
("A",".-"),("N","-."),("M","--")])
print("".join(decode_morse(morse, code) for code in ".- -- .".split()))
코드 해설
getattr·setattr는 문자열로 지정한 속성을 읽고 씁니다. 현재 방향에 노드가 없으면 새로 만들고, 공통 접두 경로는 공유합니다. None값 노드는 아직 문자 자체가 등록되지 않은 중간 분기일 수 있으므로 경로 존재와 문자 등록을 따로 검사합니다.
실행 결과
AME
시간·공간 복잡도
신호 길이 L 하나 해독은 O(L), 결과 문자 외 보조 공간 O(1). 트리 구축은 등록된 신호 총길이 S에 대해 O(S), 저장 노드 수 O(S) 상한입니다.
생각해보기와 핵심 정리
생각해보기: E(.)가 I(..)의 접두어인데 문자들을 간격 없이 붙이면? 경계를 알기 어렵습니다. 다음 허프만의 접두어 없는 코드와 비교하세요.
8.5 힙 트리
학습 목표
완전이진트리와 힙 순서 조건을 함께 유지한다.
삽입의 상향 이동과 삭제의 하향 이동을 추적한다.
문제에서 개념으로
**힙(Heap)**은 우선순위 큐를 효율적으로 구현하는 구조입니다. 최소 힙(Min-Heap)은 완전이진트리 모양을 유지하며 부모 값이 자식 값 이하입니다. 따라서 루트가 전체 최솟값입니다. 하지만 형제 사이의 순서나 왼쪽 서브트리 전체와 오른쪽 전체의 크기 순서는 정하지 않습니다. 힙 배열이 완전히 정렬된 배열은 아닙니다.
완전이진트리를 레벨 순서로 배열에 저장하면0기반 인덱스 i의 부모는(i-1)//2, 왼쪽 자식은2i+1, 오른쪽은2i+2입니다. 새 원소는 마지막에 넣어 모양을 지키고 부모보다 작으면 위로 올립니다. 최솟값 제거 후에는 마지막 원소를 루트로 옮기고 작은 자식과 교환하며 내려갑니다. 작은 자식을 고르지 않으면 반대 자식과의 순서가 깨질 수 있습니다.
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
class MinHeap:
def __init__(self): self._data = []
def __len__(self): return len(self._data)
def is_empty(self): return not self._data
def peek(self):
if self.is_empty(): raise IndexError("힙이 비었습니다.")
return self._data[0]
def push(self, item):
self._data.append(item)
index = len(self._data) - 1
while index > 0:
parent = (index - 1)//2
if self._data[parent] <= self._data[index]: break
self._data[parent], self._data[index] = self._data[index], self._data[parent]
index = parent
def pop(self):
smallest = self.peek()
last = self._data.pop()
if self._data:
self._data[0] = last
index = 0
while 2*index + 1 < len(self._data):
child = 2*index + 1
right = child + 1
if right < len(self._data) and self._data[right] < self._data[child]:
child = right
if self._data[index] <= self._data[child]: break
self._data[index], self._data[child] = self._data[child], self._data[index]
index = child
return smallest
heap = MinHeap()
for value in (2,5,8,1): heap.push(value)
print(heap._data)
print([heap.pop() for _ in range(len(heap))])
코드 해설
push는 루트 방향의 한 경로만 수정합니다. pop도 아래 방향 한 경로만 수정하며 완전 모양은 마지막 원소 제거로 유지합니다. 마지막 원소 하나를 제거한 경우에는 루트 대입을 생략해야 합니다. 실무의 heapq도 최소 힙 인터페이스를 제공하지만 여기서는 원리를 직접 구현했습니다.
실행 결과
[1, 2, 8, 5]
[1, 2, 5, 8]
시간·공간 복잡도
힙 높이 O(log n)이므로 비교·교환은 삽입·삭제 O(log n), peek O(1), 저장 O(n). Python 동적 리스트 확장의 특정 호출은 O(n)일 수 있어 저장 확장까지 포함한 삽입 보장은 분할 상환 O(log n)입니다. 교과서 힙 연산 표는 확보된 배열 용량의 구조적 작업을 기준으로 합니다.
생각해보기와 핵심 정리
생각해보기: 힙에서 임의 값 탐색도 O(log n)인가요? 아닙니다. BST처럼 한쪽 전체를 버릴 순서 규칙이 없어 최악 O(n)입니다.
8.6 힙의 응용: 허프만 코드
학습 목표
빈도가 낮은 두 묶음을 반복 결합한다.
접두어 없는 코드와 디코딩 경계를 설명한다.
문제에서 개념으로
자주 나오는 문자에 짧은 비트열을 주면 전체 길이를 줄일 수 있습니다. **허프만 코드(Huffman Code)**는 빈도가 가장 낮은 두 묶음을 반복해서 합쳐 이진트리를 만듭니다. 두 묶음이 부모 아래로 내려가며 각각 한 비트가 늘어나므로, 자주 나오지 않는 묶음을 먼저 깊게 보내는 선택이 가중 경로 길이를 줄이는 방향입니다. 주어진 빈도에 대한 이진 접두 코드 중 최소 평균 길이를 얻습니다.
문자는 리프에만 놓고 왼쪽0·오른쪽1로 경로를 기록합니다. 어떤 문자 코드도 다른 문자 코드의 접두어가 되지 않으므로 비트를 읽다가 리프에 도달하면 한 문자가 완성됩니다. 빈도가 같으면 여러 최적 코드가 가능하므로 특정 비트열 자체가 유일한 정답은 아닙니다. 아래는 순번으로 동점을 처리하여 재현 가능한 결과를 만듭니다.
구조와 작은 예제
빈도 A:4,B:2,C:1,D:1이면 C+D=2, B+CD=4, A+BCD=8 순서로 합칩니다. 코드 A=0,B=10,C=110,D=111의 총길이는4×1+2×2+1×3+1×3=14비트입니다. 고정2비트라면16비트지만 코드표 저장 비용은 별도로 필요합니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def huffman_codes(frequencies):
heap = MinHeap()
sequence = 0
for symbol, frequency in sorted(frequencies.items()):
if frequency <= 0: raise ValueError("빈도는 양수여야 합니다.")
heap.push((frequency, sequence, BinaryNode(symbol)))
sequence += 1
if heap.is_empty(): return {}
while len(heap) > 1:
first, _, left = heap.pop()
second, _, right = heap.pop()
parent = BinaryNode(None, left, right)
heap.push((first + second, sequence, parent))
sequence += 1
root = heap.pop()[2]
codes = {}
def visit(node, path):
if node.left is None and node.right is None:
codes[node.item] = path or "0" # 문자 종류가 하나인 경우의 규약
return
visit(node.left, path + "0")
visit(node.right, path + "1")
visit(root, "")
return codes
frequencies = {"A":4,"B":2,"C":1,"D":1}
codes = huffman_codes(frequencies)
print(codes)
print(sum(frequencies[symbol]*len(code) for symbol,code in codes.items()))
코드 해설
힙 항목은 빈도·고유 순번·노드입니다. 빈도가 같아도 순번이 다르므로 비교 불가능한 BinaryNode까지 튜플 비교가 내려가지 않습니다. 문자는 정렬 가능한 문자열이라는 사전조건을 둡니다. 빈 입력은 빈 코드표, 문자 하나는0으로 정해 인코딩 길이를 잃지 않도록 했습니다.
실행 결과
{'A': '0', 'B': '10', 'C': '110', 'D': '111'}
14
시간·공간 복잡도
문자 종류 k에서 트리 구축은 O(k log k), 노드 O(k). 코드 문자열을 실제 생성하는 추가 비용은 전체 코드 길이 합 L에 비례하는 O(L) 상한으로 함께 계산합니다. 최악 L=O(k²)일 수 있으므로 코드표 생성까지 무조건 O(k log k)라고 하지 않습니다. 재귀 스택은 트리 높이만큼 필요합니다.
생각해보기와 핵심 정리
생각해보기: 압축된 본문 비트만 짧으면 파일 전체도 반드시 작을까요? 아닙니다. 코드표·원문 길이 등 해독에 필요한 부가 정보 비용을 포함해야 합니다.
CHAPTER 핵심 정리
구조·연산
핵심 성질
시간·공간
일반 이진트리 순회
모든 노드 방문
시간 O(n), 스택 O(h)
완전이진트리
마지막 레벨 왼쪽 채움
높이 O(log n)
최소 힙
부모≤자식
구조적 삽입·삭제 O(log n)
허프만
낮은 빈도 두 묶음 결합
트리 O(k log k), 코드 길이 비용 별도
연습문제
[기초] 문제 1
루트 레벨과 리프 높이는?
[기초] 문제 2
형제 노드의 조건은?
[기초] 문제 3
최소 힙의 최솟값 위치는?
[기초] 문제 4
0기반 힙의 인덱스3의 자식 위치는?
[응용] 문제 5
A의 왼쪽B,오른쪽C인 트리의 후위 순회는?
[응용] 문제 6
일렬로5개가 연결된 이진트리 높이는?
[응용] 문제 7
힙[2,5,8]에1을 넣는 경로는?
[응용] 문제 8
허프만에서 빈도 동점에 순번을 넣는 이유는?
[도전] 문제 9
재귀 순회에서 매번 결과 목록을 +로 합치면 편향 트리에서 어떤 비용이 생기나요?
[도전] 문제 10
허프만 코드가 문자 경계를 알 수 있는 이유는?
연습문제 정답 및 해설
문제 1
정답: 둘 다0.
해설: 이 책은 간선 수를 기준으로 합니다.
문제 2
정답: 부모가 같습니다.
해설: 같은 레벨이라는 것만으로 형제가 되지는 않습니다.
문제 3
정답: 루트, 배열 인덱스0.
해설: 부모≤자식 관계를 위로 따라가면 루트가 가장 작습니다.
문제 4
정답: 7,8.
해설: 2i+1,2i+2를 사용하며 배열 범위에 있는지 확인합니다.
문제 5
정답: B,C,A.
해설: 양쪽 하위 트리를 처리한 뒤 현재 노드를 처리합니다.
문제 6
정답: 4.
해설: 루트에서 마지막 리프까지 간선4개입니다.
문제 7
정답: 마지막위치3→부모1→루트0.
해설: 5,2와 순서대로 교환하여 [1,2,8,5]가 됩니다.
문제 8
정답: 결정적 순서와 노드 객체 직접 비교 회피.
해설: 순번이 고유하면 튜플 비교가 노드 필드까지 가지 않습니다.
문제 9
정답: 반복 복사로 O(n²)이 될 수 있습니다.
해설: 길이1,2,…,n 목록을 계속 복사합니다. 공유 결과 목록에 append하면 전체 O(n)입니다.
문제 10
정답: 문자가 리프에만 있어 어떤 코드도 다른 코드의 접두어가 아니기 때문입니다.
해설: 리프에 도달할 때 한 문자를 확정하고 루트로 돌아갑니다. 임의의 짧은 코드를 배정하기만 해서는 이 성질이 보장되지 않습니다.
실습문제
실습문제 1: 리프 개수
문제
이진트리의 리프 개수를 재귀로 구하세요.
요구사항
빈 트리는0, 자식이 모두 없는 노드는1입니다.
실행 예시
3
힌트
현재 노드가 리프인지 먼저 확인합니다.
실습문제 2: k개 최소값
문제
직접 MinHeap으로 목록의 작은 값 k개를 꺼내세요.
요구사항
0≤k≤n, 중복값은 유지, 원본 보존입니다.
실행 예시
[1, 1, 3]
힌트
전체를 힙에 넣고 k번 pop합니다.
실습문제 3: 허프만 왕복
문제
주어진 접두 코드표로 문자열을 인코딩하고 다시 해독하세요.
요구사항
코드표는 유효한 비어 있지 않은 이진 접두 코드입니다. 남은 불완전 비트나 잘못된 비트는 ValueError입니다.
실행 예시
010110111
ABCD
힌트
코드표를 경로 트리로 만든 뒤 리프마다 문자 하나를 확정하세요.
실습문제 정답 및 해설
실습문제 1 정답
def leaf_count(root):
if root is None: return 0
if root.left is None and root.right is None: return 1
return leaf_count(root.left) + leaf_count(root.right)
print(leaf_count(tree))
코드 해설
노드 수와 달리 모든 노드에1을 더하지 않습니다. 내부 노드는 양쪽 리프 수만 합합니다.
실행 과정
D,E,C가 각각1, B는2, A는3입니다.
핵심 아이디어와 복잡도
기저 사례를 계산 의미에 맞춥니다. 시간 O(n), 호출 공간 O(h+1).
실습문제 2 정답
def smallest_k(values, k):
if not 0 <= k <= len(values): raise ValueError("잘못된 k")
pending = MinHeap()
for value in values: pending.push(value)
return [pending.pop() for _ in range(k)]
print(smallest_k([5,1,3,1,8],3))
코드 해설
같은 값도 각각 힙 항목이므로 두 개의1이 모두 나옵니다. 별도 힙을 만들기 때문에 입력을 수정하지 않습니다.
def decode_bits(bits, codes):
root = BinaryNode(None)
for symbol, code in codes.items():
current = root
for bit in code:
direction = "left" if bit == "0" else "right"
if getattr(current, direction) is None:
setattr(current, direction, BinaryNode(None))
current = getattr(current, direction)
current.item = symbol
result, current = [], root
for bit in bits:
if bit not in "01": raise ValueError("이진 비트가 아닙니다.")
current = current.left if bit == "0" else current.right
if current is None: raise ValueError("없는 경로")
if current.item is not None:
result.append(current.item)
current = root
if current is not root: raise ValueError("불완전한 마지막 코드")
return "".join(result)
encoded = "".join(codes[symbol] for symbol in "ABCD")
print(encoded)
print(decode_bits(encoded,codes))
코드 해설
입력 코드표의 접두 유효성 검사는 사전조건으로 둡니다. 디코딩은 매 비트 한 간선씩 이동하고 문자에 도착하면 루트로 되돌아갑니다.
실행 과정
0→A,10→B,110→C,111→D로 경계가 자동 결정됩니다.
핵심 아이디어와 복잡도
총 코드표 길이 L, 본문 비트 수 B에서 O(L+B) 시간, 코드트리 O(L)와 결과 공간이 필요합니다.
다음 학습으로
9장에서는 일반 이진트리에 키의 대소 순서를 추가하여 탐색트리를 만듭니다. 힙의 부모·자식 순서와 BST의 서브트리 전체 순서는 서로 다릅니다.
실행 파일은 code/ch08.py, 시연 출력 없이 가져오는 재사용 모듈은 code/core08.py입니다. 실습 정답은 실행 파일의 뒤쪽에 배치했습니다.
일반 이진트리는 계층을 표현하지만 값의 위치를 예측할 수 없었습니다. 이번에는 왼쪽에는 작은 키, 오른쪽에는 큰 키라는 규칙을 추가합니다. 이 규칙 덕분에 한 경로만 따라 찾아갈 수 있지만, 트리 모양이 길어지면 효율이 사라집니다.
9.1 탐색트리란?
학습 목표
BST 규칙을 서브트리 전체에 적용한다.
키 중복 정책과 탐색 경로를 설명한다.
문제에서 개념으로
**이진탐색트리(Binary Search Tree, BST)**는 모든 노드에서 왼쪽 서브트리의 키는 현재 키보다 작고, 오른쪽 서브트리의 키는 현재 키보다 크도록 유지합니다. 자식 두 개만 비교하는 규칙이 아닙니다. 왼쪽 자식의 오른쪽 자손도 현재 루트보다 작아야 합니다.
탐색할 키가 현재보다 작으면 오른쪽 전체를 볼 필요가 없습니다. 이진 탐색과 비슷하게 후보를 줄이지만, 트리가 균형 잡혀 있다는 보장이 없으므로 항상 절반이 사라지는 것은 아닙니다. 같은 키는 한 노드만 두고 값만 갱신하는 맵 정책을 사용합니다. 키들은 서로 일관되게 비교 가능해야 합니다.
구조와 작은 예제
이진탐색트리의 서브트리 순서 규칙
6 탐색은8→3→6입니다. 6은3보다 크지만 여전히8의 왼쪽 서브트리에 있으므로8보다 작습니다. 7 탐색은8→3→6→빈 오른쪽으로 끝납니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
class SearchNode:
def __init__(self, key, value):
self.key, self.value = key, value
self.left = self.right = None
self.height = 0 # 균형 삽입 절에서 사용할 보조 정보
def find_node(root, key):
current = root
while current is not None:
if key == current.key: return current
current = current.left if key < current.key else current.right
return None
root = SearchNode(8,"여덟")
root.left = SearchNode(3,"셋")
root.right = SearchNode(10,"열")
root.left.right = SearchNode(6,"여섯")
print(find_node(root,6).value)
print(find_node(root,7))
코드 해설
find_node는 내부 도우미로 발견 노드 또는 None을 반환합니다. 외부 맵의 get은 이를 이용해 없는 키에 KeyError를 발생시키도록 만들겠습니다. 값을 None으로 저장할 수 있어 노드 존재와 저장값을 구별해야 합니다.
실행 결과
여섯
None
시간·공간 복잡도
높이 h에서 최대 h+1개 노드를 비교하여 O(h+1), 반복 구현 보조 공간 O(1). 균형이면 O(log n), 편향이면 O(n)입니다. n=0 같은 경계에서는 상수 작업만 수행합니다.
생각해보기와 핵심 정리
생각해보기: 루트8의 왼쪽3 아래 오른쪽에9를 두면? 부모3과의 관계는 맞아도 루트8의 규칙을 깨뜨립니다. 모든 조상에서 허용된 범위를 지켜야 합니다.
9.2 이진탐색트리의 연산
학습 목표
삽입 위치를 탐색 실패 지점에서 결정한다.
삭제의 세 경우와 후계자 대체를 추적한다.
문제에서 개념으로
삽입은 탐색과 같은 경로로 내려가 빈 자리에 새 노드를 붙입니다. 이미 있는 키이면 값만 갱신합니다. 삭제는 더 신중합니다. 리프이면 부모의 해당 링크를 None으로 바꾸고, 자식이 하나이면 그 자식이 자리를 이어받습니다. 자식이 둘이면 아무 자식이나 올리는 것으로는 순서 규칙을 유지하기 어렵습니다.
두 자식 삭제에서는 오른쪽 서브트리의 최솟값인 중위 후계자(Inorder Successor)를 사용합니다. 후계자는 현재보다 큰 키 중 가장 작으므로 현재 자리로 옮겨도 왼쪽·오른쪽의 순서가 유지됩니다. 키뿐 아니라 연결된 값도 함께 복사한 뒤, 오른쪽에서 원래 후계자 노드를 삭제해야 중복이 남지 않습니다.
구조와 작은 예제
8의 왼쪽3,오른쪽10(오른쪽14)에서8을 삭제하면 후계자는10입니다. 루트의 키·값을10으로 바꾸고 오른쪽의 기존10을 삭제하면14가 오른쪽 자리를 이어받습니다. 최종 중위 순서는1,3,6,10,14입니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def insert_node(root, key, value):
if root is None: return SearchNode(key, value)
if key < root.key: root.left = insert_node(root.left, key, value)
elif key > root.key: root.right = insert_node(root.right, key, value)
else: root.value = value
return root
def delete_node(root, key):
if root is None: raise KeyError(key)
if key < root.key: root.left = delete_node(root.left, key)
elif key > root.key: root.right = delete_node(root.right, key)
else:
if root.left is None: return root.right
if root.right is None: return root.left
successor = root.right
while successor.left is not None: successor = successor.left
root.key, root.value = successor.key, successor.value
root.right = delete_node(root.right, successor.key)
return root
def inorder_items(root):
result = []
def visit(node):
if node is None: return
visit(node.left)
result.append((node.key,node.value))
visit(node.right)
visit(root)
return result
root = None
for key in (8,3,10,1,6,14): root = insert_node(root,key,str(key))
print([key for key,_ in inorder_items(root)])
root = delete_node(root,8)
print([key for key,_ in inorder_items(root)])
코드 해설
재귀 함수는 변경된 서브트리의 루트를 반환합니다. 따라서 호출자는 root.left = ... 또는 전체 root = ...로 반환값을 다시 연결해야 합니다. 특히 루트 삭제에서 이 대입을 빼면 새 루트를 잃습니다. 삭제할 키가 없는 경우에는 도달한 None에서 예외가 발생하며 성공하지 않은 경로의 대입은 실행되지 않습니다.
실행 결과
[1, 3, 6, 8, 10, 14]
[1, 3, 6, 10, 14]
시간·공간 복잡도
삽입·삭제는 O(h+1) 시간, 재귀 스택 O(h+1). 후계자 탐색과 그 삭제도 오른쪽의 한 경로이므로 높이 차수를 넘지 않습니다. 편향에서는 O(n)이고 Python 재귀 제한이 실제 실행 한계가 될 수 있습니다. 중위 순회는 O(n) 시간·결과 공간입니다.
생각해보기와 핵심 정리
생각해보기: 후계자의 키만 복사하고 값은 그대로 두면? 키와 정보의 연결이 바뀌어 맵이 잘못됩니다. 한 항목의 키·값을 함께 이동합니다.
9.3 이진탐색트리를 이용한 맵
학습 목표
해시 맵과 같은 외부 인터페이스를 제공한다.
정렬 순회와 정확 키 조회의 차이를 비교한다.
문제에서 개념으로
7장의 단어장 코드는 put·get·delete만 사용했습니다. 같은 인터페이스를 BST로 제공하면 저장 방식이 바뀌어도 호출 코드는 거의 유지됩니다. 해시 맵은 정상 조건에서 정확 키 조회가 빠르고, BST는 키 순서대로 순회하거나 범위 검색을 하기 자연스럽습니다.
길이를 매번 트리 전체를 세어 구하면 len이 O(n)이 됩니다. 별도 size를 유지하되 새로운 키를 넣을 때만 늘려야 합니다. 아래는 이해를 위해 put 전에 존재 여부를 탐색합니다. 한 경로를 두 번 걸을 수 있지만 여전히 O(h)입니다. 삽입 함수가 새 노드 생성 여부를 함께 반환하도록 만들면 반복 탐색을 줄일 수 있습니다.
구조와 작은 예제
map.put(6,"six") 후 같은 키에"여섯"을 저장해도 길이는1입니다. get이 반환하는 것은 노드 객체가 아니라 값입니다. 내부 노드를 외부에 주면 사용자가 키를 바꾸어 순서 규칙을 깨뜨릴 수 있습니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
class BSTMap:
def __init__(self):
self._root = None
self._size = 0
def __len__(self): return self._size
def put(self, key, value):
fresh = find_node(self._root,key) is None
self._root = insert_node(self._root,key,value)
if fresh: self._size += 1
def get(self, key):
node = find_node(self._root,key)
if node is None: raise KeyError(key)
return node.value
def delete(self, key):
value = self.get(key)
self._root = delete_node(self._root,key)
self._size -= 1
return value
def items(self): return inorder_items(self._root)
mapping = BSTMap()
for key in (6,2,9): mapping.put(key,str(key))
mapping.put(6,"여섯")
print(mapping.get(6), len(mapping))
print(mapping.items())
코드 해설
get을 먼저 호출하므로 없는 키 삭제는 길이를 바꾸지 않습니다. items는 내부 노드 대신 새 튜플 목록을 정렬 순서로 반환합니다. 값이 가변 객체이면 그 객체 참조는 여전히 공유된다는 점은 얕은 복사와 같습니다.
실행 결과
여섯 3
[(2, '2'), (6, '여섯'), (9, '9')]
시간·공간 복잡도
len O(1), get·put·delete O(h+1), items O(n) 시간·결과 공간. 균형이 없으면 핵심 조회도 최악 O(n)입니다. 문자열 키라면 비교에 드는 길이 비용을 추가합니다.
생각해보기와 핵심 정리
생각해보기: 해시 맵과 BST 맵 중 하나가 모든 용도에서 우월할까요? 아닙니다. 정확 키 조회, 정렬 순회, 범위 조회, 최악 시간 보장 요구가 다릅니다.
9.4 심화 학습: 균형이진탐색트리
학습 목표
편향이 탐색 비용을 높이는 원인을 설명한다.
AVL 균형 인수와 회전의 역할을 이해한다.
문제에서 개념으로
1,2,3,4를 차례대로 일반 BST에 넣으면 오른쪽으로만 이어집니다. 비교할 때 후보가 거의 줄지 않아 연결 리스트와 비슷해집니다. **균형이진탐색트리(Balanced BST)**는 삽입·삭제 후 모양을 조정해 높이를 로그 수준으로 유지합니다.
AVL 트리는 각 노드에서 왼쪽 높이-오른쪽 높이인 균형 인수(Balance Factor)의 절댓값을1 이하로 유지합니다. 한쪽으로2만큼 기울면 회전(Rotation)으로 재배치합니다. 오른쪽 회전은 왼쪽 자식을 새 루트로 올리고, 그 자식의 오른쪽 서브트리를 옛 루트의 왼쪽으로 옮깁니다. 중위 순서는 그대로라 탐색 규칙이 유지됩니다. LL·RR은 단일 회전, LR·RL은 두 번 회전합니다.
아래는 원리를 확인할 수 있는 삽입 전용 AVL 구현입니다. 일반 BST의 delete를 이 트리에 그대로 적용하면 높이 갱신·재균형이 빠지므로 AVL 보장이 사라집니다. 완전한 AVL 삭제 구현까지 확대하지 않고 균형의 이유와 회전의 순서에 집중합니다.
구조와 작은 예제
입력 유형
예
복구
LL
3,2,1
3에서 오른쪽 회전
RR
1,2,3
1에서 왼쪽 회전
LR
3,1,2
왼쪽 자식 왼쪽 회전 후 오른쪽 회전
RL
1,3,2
오른쪽 자식 오른쪽 회전 후 왼쪽 회전
네 경우 모두 최종 중위 순서는1,2,3이며 루트는2가 됩니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def avl_height(node): return -1 if node is None else node.height
def update_height(node):
node.height = 1 + max(avl_height(node.left), avl_height(node.right))
def rotate_right(root):
pivot = root.left
root.left = pivot.right
pivot.right = root
update_height(root)
update_height(pivot) # 새 자식 높이가 확정된 뒤 새 루트를 계산한다.
return pivot
def rotate_left(root):
pivot = root.right
root.right = pivot.left
pivot.left = root
update_height(root)
update_height(pivot)
return pivot
def avl_insert(root, key, value):
if root is None: return SearchNode(key,value)
if key < root.key: root.left = avl_insert(root.left,key,value)
elif key > root.key: root.right = avl_insert(root.right,key,value)
else:
root.value = value
return root
update_height(root)
balance = avl_height(root.left) - avl_height(root.right)
if balance > 1:
if key > root.left.key: root.left = rotate_left(root.left)
return rotate_right(root)
if balance < -1:
if key < root.right.key: root.right = rotate_right(root.right)
return rotate_left(root)
return root
balanced = None
for key in range(1,8): balanced = avl_insert(balanced,key,str(key))
print(balanced.key, balanced.height)
print([key for key,_ in inorder_items(balanced)])
코드 해설
각 재귀가 돌아오며 높이를 갱신하므로 아래쪽 변화가 위로 전달됩니다. 회전 후에는 옛 루트가 아래로 내려가기 때문에 옛 루트 높이를 먼저 갱신해야 합니다. 높이를 매번 전체 서브트리 순회로 다시 구하면 회전 자체는 빨라도 삽입 비용이 커지므로 속성으로 보관합니다.
실행 결과
4 2
[1, 2, 3, 4, 5, 6, 7]
시간·공간 복잡도
AVL 높이는 O(log n)입니다. 한 경로의 높이 갱신과 상수 시간 회전을 수행하므로 삽입 O(log n), 재귀 공간 O(log n), 노드 저장 O(n). 균형 조건이 유지되는 AVL 루트에 avl_insert를 사용한다는 전제입니다.
생각해보기와 핵심 정리
생각해보기: 회전이 값을 정렬해서 다시 복사하는 작업인가요? 아닙니다. 몇 개의 참조만 바꾸며 중위 순서를 보존합니다. 모양과 순서 규칙을 동시에 유지하는 것이 핵심입니다.
해설: 균형 자체가 좋아도 높이 조회를 O(1)로 보지 못합니다. 국소 갱신이 로그 삽입 비용의 전제입니다.
실습문제
실습문제 1: 범위 조회
문제
BST에서 low 이상 high 이하인 키를 정렬 순서로 반환하세요.
요구사항
범위 밖임이 확실한 서브트리는 방문하지 않습니다. low>high는 빈 결과입니다.
실행 예시
[3, 6, 8]
힌트
현재 키와 범위를 비교하여 왼쪽·오른쪽 방문을 결정합니다.
실습문제 2: BST 검증
문제
모든 조상 범위를 고려해 BST인지 판정하세요.
요구사항
중복 키를 허용하지 않습니다. 키는 정수입니다.
실행 예시
False
힌트
각 재귀에 허용 하한·상한을 전달하세요.
실습문제 3: 정렬 배열로 균형 트리
문제
정렬된 고유 정수 목록에서 높이가 작은 BST를 만드세요.
요구사항
슬라이스 복사를 사용하지 않고 인덱스 구간을 전달합니다.
실행 예시
4
[1, 2, 3, 4, 5, 6, 7]
힌트
중간값을 루트로 두고 양쪽 구간을 재귀로 처리하세요.
실습문제 정답 및 해설
실습문제 1 정답
def range_keys(root, low, high):
result = []
if low > high: return result
def visit(node):
if node is None: return
if low < node.key: visit(node.left)
if low <= node.key <= high: result.append(node.key)
if node.key < high: visit(node.right)
visit(root)
return result
sample = None
for key in (8,3,10,1,6,14): sample = insert_node(sample,key,str(key))
print(range_keys(sample,3,8))
코드 해설
low가 현재 키 이상이면 왼쪽은 전부 너무 작으므로 생략합니다. 정렬 출력은 중위 처리 순서를 유지해 얻습니다.
실행 과정
1쪽 생략,3 추가,6 추가,8 추가,10쪽 생략입니다.
핵심 아이디어와 복잡도
결과 k개에서 O(h+k) 시간, 스택 O(h), 결과 O(k). 편향 트리는 h=O(n)입니다.
실습문제 2 정답
def valid_bst(root, lower=float("-inf"), upper=float("inf")):
if root is None: return True
if not lower < root.key < upper: return False
return (valid_bst(root.left,lower,root.key)
and valid_bst(root.right,root.key,upper))
bad = SearchNode(8,"8")
bad.left = SearchNode(3,"3")
bad.left.right = SearchNode(9,"9")
print(valid_bst(bad))
코드 해설
현재 부모와만 비교하지 않고 누적 범위를 전달합니다. 이 정수 키 예제는 양·음의 무한대를 초기 경계로 사용합니다.
트리는 계층적 관계를 다뤘지만 친구 관계, 도로망, 선수 과목 관계에는 여러 경로와 순환이 있습니다. 그래프는 이런 연결을 표현합니다.5장의 BFS·DFS를 일반적인 정점·간선 언어로 다시 정리하고, 연결 성분·신장 트리·위상 정렬로 확장합니다.
10.1 그래프란?
학습 목표
정점·간선·경로·사이클을 구분한다.
방향·무방향 그래프를 비교한다.
문제에서 개념으로
**그래프(Graph)**는 정점(Vertex)의 집합과 정점 사이의 간선(Edge)으로 구성합니다. 정점은 사람·도시·작업처럼 대상이고 간선은 친구 관계·도로·선후 관계입니다. **무방향 그래프(Undirected Graph)**의 연결은 양방향 관계이고, **방향 그래프(Directed Graph)**의 간선 u→v는 반대 방향을 자동 포함하지 않습니다.
경로(Path)는 간선을 따라 이동하는 정점열입니다. 출발점으로 돌아오는 순환 관계를 사이클(Cycle)이라고 합니다. 이 장의 예제는 자기 루프와 중복 간선이 없는 단순 그래프를 주로 사용합니다. 트리는 연결되어 있고 사이클이 없는 무방향 그래프로도 볼 수 있으며, 루트를 정하면 앞 장의 부모·자식 관계가 생깁니다.
구조와 작은 예제
구분
무방향
방향
예
상호 친구
일방 통행·선수 과목
간선
{u,v}
u→v
차수
연결 간선 수
진입 차수·진출 차수
행렬
대칭
보통 비대칭
이 장의 공통 무방향 그래프는 A-B,A-C,B-D,C-D,D-E입니다. A-B-D-C-A는 사이클이며 A에서 E까지 여러 경로가 존재합니다.
DFS·BFS 공통 무방향 그래프
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
같은 간선을 B-A로 또 적지 않습니다. 무방향 간선 하나를 저장 표현에서 두 항목으로 만들 수는 있지만 수학적인 간선 수 E는 하나로 셉니다. 이후 복잡도 O(V+E)의 E 의미와 저장 항목 수2E를 구분합니다.
실행 결과
5 5
시간·공간 복잡도
정점·간선 목록 저장은 O(V+E). 완전 단순 무방향 그래프는 V(V-1)/2개 간선이 가능하므로 E가 V² 수준까지 늘 수 있습니다.
생각해보기와 핵심 정리
생각해보기: 방향 그래프에서 A→B가 있으면 B에서 A로 갈 수 있나요? 다른 역방향 경로가 주어지지 않았다면 보장되지 않습니다.
10.2 그래프의 표현
학습 목표
인접 행렬과 인접 리스트를 구성한다.
간선 조회와 이웃 순회의 비용을 비교한다.
문제에서 개념으로
**인접 행렬(Adjacency Matrix)**은 정점 쌍마다 간선 유무를 표에 저장합니다. 정점 번호를 알면 한 간선이 있는지 바로 확인하지만, 실제 간선이 적어도 V²칸이 필요합니다. **인접 리스트(Adjacency List)**는 각 정점에서 실제 이웃만 저장합니다. 연결이 적은 희소 그래프에서 공간을 절약하고 이웃만 순회하기 좋습니다.
무방향 간선 u-v는 행렬의 두 칸과 인접 리스트 양쪽에 반영합니다. 방향 간선이라면 u 쪽에 v만 기록합니다. 입력의 모든 정점을 먼저 생성해야 고립 정점도 사라지지 않습니다. 사전 키에 등장하지 않는 이웃을 허용하지 않는 것이 이후 탐색의 사전조건입니다.
구조와 작은 예제
공통 그래프의 인접 행렬입니다.
A
B
C
D
E
A
0
1
1
0
0
B
1
0
0
1
0
C
1
0
0
1
0
D
0
1
1
0
1
E
0
0
0
1
0
작업
행렬
리스트에 이웃 저장
공간
O(V²)
O(V+E)
간선 존재
O(1)
O(deg(u))
모든 이웃
O(V)
O(deg(u))
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def graph_representations(vertices, edges, directed=False):
index = {vertex: position for position,vertex in enumerate(vertices)}
if len(index) != len(vertices): raise ValueError("중복 정점")
adjacency = {vertex: [] for vertex in vertices}
matrix = [[0]*len(vertices) for _ in vertices]
for first,second in edges:
if first not in index or second not in index: raise KeyError("없는 정점")
i,j = index[first],index[second]
if not matrix[i][j]:
matrix[i][j] = 1
adjacency[first].append(second)
if not directed:
matrix[j][i] = 1
adjacency[second].append(first)
return adjacency,matrix
adjacency,matrix = graph_representations(vertices,edges)
print(adjacency)
print(matrix[0])
코드 해설
행렬을 행별로 새로 만들어2장의 공유 행 오류를 피합니다. index는 이름을 행·열 번호로 바꿉니다. 중복 간선은 이미 행렬에 표시되어 있으면 재추가하지 않습니다. 이 도우미는 비교 학습을 위해 두 표현을 동시에 만듭니다.
두 표현 동시 생성은 행렬 초기화 때문에 O(V²+E) 시간·공간입니다. 인접 리스트만 필요하면 행렬을 생성하지 않는 별도 구성으로 O(V+E)에 만들 수 있습니다. 표현 자체의 장점과 이 학습용 변환 함수 비용을 구분합니다.
생각해보기와 핵심 정리
생각해보기: 정점이 많고 간선이 매우 적으면 어느 표현이 유리할까요? 보통 인접 리스트입니다. 모든 정점 쌍의 직접 조회가 핵심이면 행렬도 고려합니다.
10.3 그래프의 탐색
학습 목표
DFS·BFS를 같은 그래프에서 추적한다.
방문 순서가 이웃 순서에 의존함을 설명한다.
문제에서 개념으로
탐색은 시작점에서 연결된 정점을 빠짐없이 확인하는 과정입니다. DFS는 한 경로를 깊게, BFS는 시작점과 가까운 간선 거리 순서로 진행합니다. 둘 다 방문 표시가 없으면 사이클에서 반복할 수 있습니다. 방문 순서는 그래프만으로 하나로 정해지지 않으며 이웃을 확인하는 순서에도 영향을 받습니다.
여기서는 인접 리스트에 저장된 순서를 사용합니다. DFS는 재귀 호출 스택으로, BFS는 명시적인 큐로 구현합니다. 반복 DFS에서 이웃을 뒤집어 넣으면 재귀 DFS와 비슷한 우선순위를 줄 수 있지만, 발견 시점 처리 방식까지 고려해야 정확한 순서가 일치합니다.
구조와 작은 예제
DFS 진행
방문 순서 누적
선택 이유
A→B
A,B
A의 첫 이웃
B→D
A,B,D
A는 방문했으므로 D
D→C
A,B,D,C
B는 방문했으므로 C
C에서 돌아와 D→E
A,B,D,C,E
남은 이웃 E
BFS 꺼낸 정점
처리 후 큐
A
B,C
B
C,D
C
D
D
E
E
빈 큐
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
from collections import deque
def dfs_order(adjacency,start):
if start not in adjacency: raise KeyError(start)
visited, result = set(), []
def visit(vertex):
visited.add(vertex)
result.append(vertex)
for neighbor in adjacency[vertex]:
if neighbor not in visited: visit(neighbor)
visit(start)
return result
def bfs_order(adjacency,start):
if start not in adjacency: raise KeyError(start)
visited, result = {start}, []
pending = deque([start])
while pending:
vertex = pending.popleft()
result.append(vertex)
for neighbor in adjacency[vertex]:
if neighbor not in visited:
visited.add(neighbor)
pending.append(neighbor)
return result
print(dfs_order(adjacency,"A"))
print(bfs_order(adjacency,"A"))
코드 해설
DFS는 방문 표시 후 이웃으로 들어가므로 부모로 돌아오는 간선을 무시합니다. BFS는 큐에 넣을 때 표시해 D가 B와 C에서 두 번 들어가지 않도록 합니다. result는 관찰용 방문 순서이고, 최단 경로 복원용 parent와는 다릅니다.
인접 리스트에서 둘 다 기대 O(V+E), 추가 공간 O(V). DFS 호출 깊이는 최악 O(V), BFS 큐는 동시에 대기하는 정점 수만큼입니다. 인접 행렬에서 각 방문 정점마다 행 전체를 확인하면 O(V²)입니다. 긴 그래프의 재귀 DFS는 반복 스택으로 바꾸어 깊이 제한을 피할 수 있습니다.
생각해보기와 핵심 정리
생각해보기: DFS 결과 목록이 시작에서 끝까지의 한 경로인가요? 아닙니다. 되돌아간 후 다른 가지를 방문한 순서까지 섞이므로 연속 두 원소 사이에 간선이 없을 수도 있습니다.
10.4 연결 성분 검사
학습 목표
한 번의 탐색으로 그래프 전체를 못 볼 수 있음을 이해한다.
미방문 정점마다 새 성분 탐색을 시작한다.
문제에서 개념으로
무방향 그래프에서 서로 경로로 연결된 최대 정점 묶음을 **연결 성분(Connected Component)**이라고 합니다. 시작점 하나에서 탐색하면 그 성분만 방문합니다. 고립 정점도 크기1의 성분입니다. 그래프 전체를 처리하려면 모든 정점을 확인하며 아직 방문하지 않은 정점에서 새 탐색을 시작합니다.
방향 그래프에는 서로 오갈 수 있는 강한 연결과 방향을 무시하는 약한 연결 등 별도 개념이 있습니다. 아래 알고리즘은 대칭 인접 리스트의 무방향 연결 성분 검사입니다. 방향 그래프에 그대로 적용한 결과를 강한 연결 성분이라고 부르면 안 됩니다.
구조와 작은 예제
A-B-C와 D-E, 고립F가 있으면 성분은 {A,B,C},{D,E},{F} 세 개입니다. 첫 성분 탐색을 끝낸 후 바깥 반복이 D를 만나 두 번째 탐색을 시작합니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def connected_components(adjacency):
visited, components = set(), []
for start in adjacency:
if start in visited: continue
group, pending = [], [start]
visited.add(start)
while pending:
vertex = pending.pop()
group.append(vertex)
for neighbor in adjacency[vertex]:
if neighbor not in visited:
visited.add(neighbor)
pending.append(neighbor)
components.append(group)
return components
disconnected = {"A":["B"],"B":["A","C"],"C":["B"],
"D":["E"],"E":["D"],"F":[]}
print(connected_components(disconnected))
코드 해설
visited를 성분마다 새로 만들지 않고 전체 탐색 동안 공유합니다. 따라서 각 정점은 정확히 한 성분에 들어갑니다. 바깥 for와 안쪽 while이 있어도 각 정점을 성분 수만큼 반복 처리하지 않습니다.
실행 결과
[['A', 'B', 'C'], ['D', 'E'], ['F']]
시간·공간 복잡도
전체 기대 O(V+E), 방문 집합·대기 스택·결과 합 O(V). 모든 성분에 걸쳐 간선을 합산하면 원래 그래프의 E입니다.
생각해보기와 핵심 정리
생각해보기: 고립 정점을 인접 맵의 키에서 빼면? 성분 검사에서 존재 자체를 알 수 없습니다. 간선이 없는 정점도 명시합니다.
10.5 신장 트리
학습 목표
탐색 중 처음 발견한 간선만 선택한다.
연결되지 않은 경우 신장 숲이 됨을 설명한다.
문제에서 개념으로
연결된 무방향 그래프의 모든 정점을 포함하면서 사이클 없이 연결하는 부분 그래프를 **신장 트리(Spanning Tree)**라고 합니다. 원래 그래프에는 여러 길이 있어도 일부 간선만 남기면 됩니다. n개 정점을 포함하는 신장 트리는 n-1개 간선을 가집니다.
탐색에서 새 정점을 처음 발견할 때 사용한 간선만 모으면 신장 트리를 얻습니다. 이미 방문한 정점으로 가는 간선은 추가하지 않으므로 사이클을 만들지 않습니다. 연결되지 않은 그래프에서 성분마다 이렇게 하면 신장 숲(Spanning Forest)입니다. 이 장은 간선의 가격을 고려하지 않으며 최소비용은11장의 별도 문제입니다.
구조와 작은 예제
공통 그래프 BFS에서 A-B,A-C,B-D,D-E를 선택합니다. C-D는 D가 이미 발견되어 제외합니다. 정점5개에 선택 간선4개입니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def spanning_forest(adjacency):
visited, selected = set(), []
for start in adjacency:
if start in visited: continue
visited.add(start)
pending = deque([start])
while pending:
vertex = pending.popleft()
for neighbor in adjacency[vertex]:
if neighbor not in visited:
visited.add(neighbor)
selected.append((vertex,neighbor))
pending.append(neighbor)
return selected
print(spanning_forest(adjacency))
코드 해설
각 새 간선은 기존 방문 집합과 미방문 정점 하나를 연결합니다. 양끝이 모두 방문된 간선을 추가하지 않으므로 사이클이 생기지 않습니다. 어느 BFS·DFS를 사용하느냐에 따라 다른 유효 신장 트리가 나올 수 있습니다.
실행 결과
[('A', 'B'), ('A', 'C'), ('B', 'D'), ('D', 'E')]
시간·공간 복잡도
인접 리스트에서 O(V+E) 기대 시간, O(V) 보조·결과 공간. 성분 수 c라면 선택 간선 수는 V-c입니다. 고립 정점 성분은 간선을0개 제공합니다.
생각해보기와 핵심 정리
생각해보기: BFS 신장 트리의 간선 가중치 합이 최소인가요? 가중치를 보지 않았으므로 보장할 수 없습니다. 신장 트리와 최소비용 신장 트리를 구분합니다.
10.6 위상 정렬
학습 목표
DAG의 선후 제약을 선형 순서로 바꾼다.
진입 차수0 처리와 사이클 검출을 설명한다.
문제에서 개념으로
‘기초 학습 후 실습, 실습 후 프로젝트’ 같은 선후 관계는 방향 간선으로 표현합니다. **위상 정렬(Topological Sort)**은 모든 간선 u→v에 대해 u가 v보다 먼저 오도록 정점을 나열합니다. 값의 크기로 정렬하는7장의 정렬과 목적이 다릅니다.
사이클이 없는 방향 그래프인 **DAG(Directed Acyclic Graph)**에서 가능합니다. A가B보다 먼저이고 B가A보다 먼저라면 어느 것도 시작할 수 없습니다. Kahn 알고리즘은 진입 차수가0인 정점부터 처리하고, 그 정점의 출발 간선을 제거한 효과로 이웃 진입 차수를 줄입니다. 새로0이 된 정점을 큐에 넣습니다. 처리한 정점이 V개보다 적으면 남은 부분에 사이클이 있습니다.
구조와 작은 예제
A→C,B→C,C→D에서 처음 A,B의 진입 차수는0, C는2,D는1입니다. A 처리 후 C=1, B 처리 후 C=0, C 처리 후 D=0입니다. A,B,C,D와 B,A,C,D 모두 유효합니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def topological_sort(adjacency):
indegree = {vertex:0 for vertex in adjacency}
for neighbors in adjacency.values():
for neighbor in neighbors: indegree[neighbor] += 1
pending = deque(vertex for vertex in adjacency if indegree[vertex] == 0)
result = []
while pending:
vertex = pending.popleft()
result.append(vertex)
for neighbor in adjacency[vertex]:
indegree[neighbor] -= 1
if indegree[neighbor] == 0: pending.append(neighbor)
if len(result) != len(adjacency):
raise ValueError("사이클이 있어 위상 정렬할 수 없습니다.")
return result
tasks = {"A":["C"],"B":["C"],"C":["D"],"D":[]}
print(topological_sort(tasks))
코드 해설
실제 인접 리스트에서 간선을 삭제하지 않고 indegree만 줄여 입력을 보존합니다.0이 되는 순간에만 enqueue하므로 중복 처리를 피합니다. 결과가 일부 나왔다고 전체 성공은 아니며 길이를 반드시 검사합니다.
실행 결과
['A', 'B', 'C', 'D']
시간·공간 복잡도
진입 차수 계산과 각 간선 감소를 합해 O(V+E), 큐·차수·결과 공간 O(V). 진입 차수0 후보를 사전순으로 선택하려면 힙을 사용할 수 있고 그 선택 비용이 추가됩니다.
생각해보기와 핵심 정리
생각해보기: 위상 정렬 결과가 하나뿐인가요? 아닙니다. 독립 작업의 순서는 달라도 모든 간선 제약을 만족하면 됩니다.
CHAPTER 핵심 정리
알고리즘
적용 대상
산출물
DFS·BFS
경로로 도달 가능한 부분
방문 순서·부모
연결 성분
무방향
최대 연결 묶음
신장 숲
무방향
V-c개 간선
위상 정렬
DAG
선후 조건을 만족하는 순서
인접 리스트 기준 탐색 알고리즘은 정점과 간선을 한 번씩 다루어 O(V+E)입니다. 행렬 표현에서는 이웃을 찾기 위해 각 행 전체를 읽는 비용이 생깁니다.
연습문제
[기초] 문제 1
무방향 간선 u-v는 이동을 어떻게 허용하나요?
[기초] 문제 2
고립 정점의 연결 성분 크기는?
[기초] 문제 3
행렬의 공간 차수는?
[기초] 문제 4
위상 정렬이 가능한 그래프의 조건은?
[응용] 문제 5
공통 그래프 BFS의 A부터 방문 순서는?
[응용] 문제 6
DFS 방문 목록을 그대로 경로로 간주하면 안 되는 이유는?
[응용] 문제 7
정점9개, 연결 성분3개의 신장 숲 간선 수는?
[응용] 문제 8
A→C,B→C에서 A만 처리한 뒤 C 진입 차수는?
[도전] 문제 9
행렬 BFS가 O(V+E)가 아닌 O(V²)로 분석되는 이유는?
[도전] 문제 10
Kahn 알고리즘의 큐가 비었는데 정점이 남은 이유를 설명하세요.
연습문제 정답 및 해설
문제 1
정답: 양방향.
해설: 저장할 때도 양쪽 이웃 관계를 반영합니다.
문제 2
정답: 1.
해설: 간선이 없어도 정점 하나가 성분입니다.
문제 3
정답: O(V²).
해설: 모든 정점 쌍의 칸을 확보합니다.
문제 4
정답: 방향 비순환 그래프(DAG).
해설: 사이클의 선후 제약은 동시에 만족할 수 없습니다.
문제 5
정답: A,B,C,D,E.
해설: 지정한 이웃 순서에서 B,C가 먼저 대기하고 D는 한 번만 들어갑니다.
문제 6
정답: 되돌아가 다른 가지로 이동한 방문도 포함합니다.
해설: 연속 출력 정점 사이에 직접 간선이 없을 수 있습니다.
문제 7
정답: 6.
해설: 각 성분의 n_i-1을 더하면 V-c입니다.
문제 8
정답: 1.
해설: B의 선행 조건이 아직 남았습니다.
문제 9
정답: 이웃 존재 여부를 알아내기 위해 방문 정점마다 V칸을 확인합니다.
해설: 실제 간선이 적어도0인 칸을 건너뛰어 바로 찾을 수 없습니다.
문제 10
정답: 남은 정점들은 서로의 선행 조건을 남겨 두는 사이클에 얽혀 있습니다.
해설: 남은 유한 그래프의 모든 정점에 진입 간선이 있으면 그 간선을 역으로 계속 따라가 언젠가 정점을 재방문하게 됩니다.
실습문제
실습문제 1: 연결 판정
문제
무방향 그래프의 두 정점이 같은 성분인지 판정하세요.
요구사항
없는 정점이 있으면 False입니다.
실행 예시
True
False
힌트
한 정점에서 탐색한 도달 집합을 사용합니다.
실습문제 2: 선행 단계 묶음
문제
동시에 시작할 수 있는 작업들을 위상 단계별로 묶으세요.
요구사항
현재 단계가 시작할 때의 진입 차수0 작업만 그 단계에 포함합니다. 사이클이면 오류입니다.
실행 예시
[['A', 'B'], ['C'], ['D']]
힌트
큐의 현재 길이만큼 처리한 결과를 한 묶음으로 만드세요.
실습문제 3: 무방향 사이클
문제
단순 무방향 그래프에 사이클이 있는지 검사하세요.
요구사항
여러 성분과 고립 정점을 처리합니다. 중복 간선·자기 루프는 입력에 없습니다.
실행 예시
True
False
힌트
이미 방문한 이웃 중 바로 이전 부모가 아닌 것이 있는지 확인하세요.
실습문제 정답 및 해설
실습문제 1 정답
def are_connected(adjacency,first,second):
if first not in adjacency or second not in adjacency: return False
return second in bfs_order(adjacency,first)
print(are_connected(disconnected,"A","C"))
print(are_connected(disconnected,"A","F"))
코드 해설
기존 탐색을 재사용하므로 연결성 검사와 탐색 구현을 중복 작성하지 않습니다. 많은 질의에는 성분 번호를 미리 계산할 수 있습니다.
실행 과정
A에서 A,B,C만 도달하므로 C는참,F는거짓입니다.
핵심 아이디어와 복잡도
한 질의 O(V+E), 결과·방문 공간 O(V). 반복 질의는 전처리 후 성분 번호 비교 O(1)로 개선할 수 있습니다.
실습문제 2 정답
def topological_layers(adjacency):
indegree = {v:0 for v in adjacency}
for neighbors in adjacency.values():
for v in neighbors: indegree[v] += 1
pending = deque(v for v in adjacency if indegree[v] == 0)
layers, processed = [], 0
while pending:
layer = []
for _ in range(len(pending)):
vertex = pending.popleft()
layer.append(vertex)
processed += 1
for neighbor in adjacency[vertex]:
indegree[neighbor] -= 1
if indegree[neighbor] == 0: pending.append(neighbor)
layers.append(layer)
if processed != len(adjacency): raise ValueError("사이클")
return layers
print(topological_layers(tasks))
코드 해설
range(len(pending))는 단계 시작 시 길이를 사용하므로 처리 중 추가한 정점은 다음 단계에 남습니다. 작업 시간은 모두 한 단계라고 가정한 선후 묶음입니다.
실행 과정
A,B 동시 단계→C→D입니다.
핵심 아이디어와 복잡도
큐 경계를 이용해 층을 구분합니다. 시간 O(V+E), 공간 O(V).
실습문제 3 정답
def has_undirected_cycle(adjacency):
visited = set()
def visit(vertex,parent):
visited.add(vertex)
for neighbor in adjacency[vertex]:
if neighbor not in visited:
if visit(neighbor,vertex): return True
elif neighbor != parent: return True
return False
for start in adjacency:
if start not in visited and visit(start,None): return True
return False
print(has_undirected_cycle(adjacency))
print(has_undirected_cycle(disconnected))
코드 해설
무방향 간선은 반대쪽에서도 보이므로 부모로 돌아가는 간선 하나를 사이클로 오인하지 않도록 제외합니다. 방향 그래프에는 이 규칙을 그대로 쓰지 않습니다.
실행 과정
공통 그래프에서 A-B-D-C-A로 방문된 조상을 다시 만나 참, 분리 그래프는 모두 트리여서 거짓입니다.
핵심 아이디어와 복잡도
방문과 부모 관계를 함께 사용합니다. 기대 시간 O(V+E), 방문·재귀 공간 O(V). 정점 이름으로 None을 쓰지 않는 입력 규약입니다.
다음 학습으로
11장에서는 간선에 비용을 붙입니다. BFS의 최소 간선 수, 신장 트리의 연결 보장이 최소 비용까지 보장하는 것은 아니라는 점에서 출발합니다.
실행 파일은 code/ch10.py, 시연 출력 없이 가져오는 재사용 모듈은 code/core10.py입니다. 실습 정답은 실행 파일의 뒤쪽에 배치했습니다.
연결 유무만으로는 실제 문제의 비용을 표현할 수 없습니다. 이 장에서는 간선에 숫자를 붙이고, 모든 정점을 싸게 연결하는 문제와 출발점에서 목적지로 싸게 가는 문제를 구분합니다. 둘 다 작은 비용을 선택하지만 최적화하는 대상과 선택 규칙이 다릅니다.
11.1 가중치 그래프란?
학습 목표
간선 가중치의 의미를 문제에서 정한다.
전체 연결 비용과 경로 비용을 구분한다.
문제에서 개념으로
**가중치 그래프(Weighted Graph)**는 간선에 거리·시간·요금 등의 수치를 붙인 그래프입니다. 같은 연결망에서도 가중치를 무엇으로 정하는지에 따라 정답이 바뀝니다. 도로 길이가 짧아도 혼잡 때문에 시간이 더 걸릴 수 있습니다. 문제를 시작할 때 가중치 단위와 목적을 명확히 해야 합니다.
**최소비용 신장 트리(Minimum Spanning Tree, MST)**는 무방향 연결 그래프에서 모든 정점을 연결하는 간선들의 총합을 최소화합니다. **최단 경로(Shortest Path)**는 출발점에서 도착점까지 이동한 간선 가중치의 합을 최소화합니다. MST의 일부 경로가 원래 그래프에서 가장 짧다고 보장하지 않습니다.
구조와 작은 예제
이 장의 공통 무방향 그래프는 A-B:1,A-C:4,B-C:2,B-D:5,C-D:1입니다. A-C 직접 비용은4지만 A-B-C는3입니다. MST는 A-B,C-D,B-C의 세 간선을 선택해 총4로 모든 정점을 연결할 수 있습니다.
최소 신장 트리·최단 경로 공통 가중치 그래프
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
vertices = ["A","B","C","D"]
weighted_edges = [("A","B",1),("A","C",4),("B","C",2),
("B","D",5),("C","D",1)]
print(sum(weight for _,_,weight in weighted_edges))
코드 해설
전체 간선을 모두 설치하면 합은13이지만, 연결만 필요하다면 불필요한 간선을 빼 비용을 줄일 수 있습니다. 반대로 최단 경로 질의는 모든 간선을 후보로 남긴 상태에서 특정 출발점의 경로를 찾습니다.
실행 결과
13
시간·공간 복잡도
경로 길이가 k간선이면 비용 합산 O(k). 최적 경로·트리를 고르는 비용은 뒤 알고리즘에 따릅니다. 가중치 연산은 이 장에서 일정 비용 숫자 모형으로 분석합니다.
생각해보기와 핵심 정리
생각해보기: 전체 연결을 최소화하는 것과 A에서 각 정점으로 빠르게 가는 것이 같은 목표인가요? 아닙니다. 합산하는 간선 집합이 다릅니다.
11.2 가중치 그래프의 표현
학습 목표
간선 없음과0비용 간선을 구분한다.
인접 리스트에 이웃·가중치 쌍을 저장한다.
문제에서 개념으로
인접 행렬에 가중치를 적을 때0을 ‘간선 없음’으로 사용하면0비용 간선을 표현할 수 없습니다. 따라서 이 장에서는 간선 없음에 None을 사용합니다. 최단 거리 배열의 아직 모르는 거리는 양의 무한대 inf를 사용합니다. 구조에 간선이 없는 것과 알고리즘이 아직 거리를 모르는 것은 서로 다른 상태입니다.
인접 리스트는 (이웃, 가중치) 쌍들을 저장합니다. 무방향 간선은 양쪽에 같은 가중치로 넣습니다. 아래 도우미는 유한한 수치 가중치의 단순 그래프를 입력으로 가정하며, 중복 간선은 사전에 정리합니다. 여러 평행 도로를 허용한다면 모두 저장할지 최소값만 남길지 별도 규약이 필요합니다.
구조와 작은 예제
A
B
C
D
A
없음
1
4
없음
B
1
없음
2
5
C
4
2
없음
1
D
없음
5
1
없음
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def weighted_representations(vertices, edges, directed=False):
index = {v:i for i,v in enumerate(vertices)}
if len(index) != len(vertices): raise ValueError("중복 정점")
adjacency = {v:[] for v in vertices}
matrix = [[None]*len(vertices) for _ in vertices]
for first,second,weight in edges:
if first not in index or second not in index: raise KeyError("없는 정점")
adjacency[first].append((second,weight))
matrix[index[first]][index[second]] = weight
if not directed:
adjacency[second].append((first,weight))
matrix[index[second]][index[first]] = weight
return adjacency,matrix
adjacency,matrix = weighted_representations(vertices,weighted_edges)
print(adjacency["B"])
print(matrix[0])
코드 해설
대각선 None은 이 그래프에 자기 간선이 없다는 뜻입니다. 최단 거리에서 시작점 자신까지0인 것은 간선을 이동하지 않은 빈 경로의 비용이므로 이 행렬 대각선과 다른 개념입니다. 값이0인지 검사하는 대신 is None으로 부재를 검사해야 합니다.
실행 결과
[('A', 1), ('C', 2), ('D', 5)]
[None, 1, 4, None]
시간·공간 복잡도
행렬 O(V²), 인접 리스트 O(V+E) 저장. 이 비교 도우미는 둘 다 만들어 O(V²+E) 시간·공간을 사용합니다. 대규모 희소 그래프에서는 필요한 인접 리스트만 구성합니다.
생각해보기와 핵심 정리
생각해보기: if weight:로 간선 유무를 검사하면?0비용 간선을 없는 것으로 오인합니다. 부재 표시와 정상 숫자를 분리합니다.
11.3 최소비용 신장 트리
학습 목표
Kruskal과 Prim의 간선 선택 기준을 비교한다.
서로소 집합으로 사이클을 막는다.
문제에서 개념으로
MST는 연결된 무방향 그래프를 대상으로 합니다. 음수 간선이 있어도 MST 문제 자체는 정의되고 아래 알고리즘을 적용할 수 있습니다. 모든 정점을 연결해야 하므로 n-1간선을 선택하지만, 그중 합이 가장 작은 것을 찾습니다. 가중치가 같으면 여러 MST가 존재할 수 있습니다.
Kruskal 알고리즘은 전체 간선을 가중치 오름차순으로 보고 서로 다른 연결 묶음을 잇는 간선만 선택합니다. 같은 묶음 안의 두 정점을 연결하면 사이클이 생기므로 건너뜁니다. 묶음 관리는 **서로소 집합(Disjoint Set Union, Union-Find)**으로 합니다. 대표를 찾는 find와 두 묶음을 합치는 union이 핵심입니다. 경로 압축은 찾는 길의 대표 연결을 짧게 만들고, 크기 기준 합치기는 작은 트리를 큰 트리 밑에 붙여 깊이를 제한합니다.
Prim 알고리즘은 한 정점에서 시작하여 현재 선택된 정점 집합과 바깥을 잇는 가장 싼 간선을 고릅니다. Kruskal은 여러 묶음을 점점 합치고, Prim은 하나의 트리를 확장합니다. 둘의 공통 근거는 한쪽 집합과 다른 쪽을 잇는 절단(Cut)의 최소 간선을 안전하게 선택할 수 있다는 성질입니다. 기존 최적 트리에 그 간선을 넣어 생긴 사이클에서 다른 절단 간선을 빼면 더 비싸지 않은 트리를 얻을 수 있다는 교환 관점으로 이해할 수 있습니다.
구조와 작은 예제
Kruskal 순서
간선
선택
누적 비용
1
A-B:1
선택
1
2
C-D:1
선택
2
3
B-C:2
두 묶음 연결
4
이후
A-C:4,B-D:5
이미 연결되어 불필요
4
Prim(A 시작)
현재 트리
다음 선택
누적 비용
시작
A
A-B:1
1
2
A,B
B-C:2
3
3
A,B,C
C-D:1
4
Prim의 선택 가중치는1,2,1로 전체 오름차순이 아닙니다. 매 순간 현재 경계를 넘는 간선 중 최소를 선택하기 때문입니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
import heapq
from itertools import count
class DisjointSet:
def __init__(self, vertices):
self.parent = {v:v for v in vertices}
self.size = {v:1 for v in vertices}
def find(self, vertex):
while self.parent[vertex] != vertex:
self.parent[vertex] = self.parent[self.parent[vertex]]
vertex = self.parent[vertex]
return vertex
def union(self, first, second):
first,second = self.find(first),self.find(second)
if first == second: return False
if self.size[first] < self.size[second]: first,second = second,first
self.parent[second] = first
self.size[first] += self.size[second]
return True
def kruskal(vertices, edges):
groups = DisjointSet(vertices)
selected, total = [], 0
for first,second,weight in sorted(edges,key=lambda edge:edge[2]):
if groups.union(first,second):
selected.append((first,second,weight))
total += weight
if vertices and len(selected) != len(vertices)-1:
raise ValueError("연결되지 않아 MST가 없습니다.")
return selected,total
def prim(adjacency,start):
if start not in adjacency: raise KeyError(start)
visited = {start}
pending, selected, total = [], [], 0
sequence = count()
def offer(vertex):
for neighbor,weight in adjacency[vertex]:
if neighbor not in visited:
heapq.heappush(pending,(weight,next(sequence),vertex,neighbor))
offer(start)
while pending and len(visited) < len(adjacency):
weight,_,first,second = heapq.heappop(pending)
if second in visited: continue
visited.add(second)
selected.append((first,second,weight))
total += weight
offer(second)
if len(visited) != len(adjacency):
raise ValueError("연결되지 않아 MST가 없습니다.")
return selected,total
print(kruskal(vertices,weighted_edges))
print(prim(adjacency,"A"))
코드 해설
DisjointSet의 parent는 MST에서 선택한 실제 도로의 부모가 아니라 집합 대표를 찾는 관리 구조입니다. 혼동하지 마세요. Prim의 힙에는 나중에 쓸모없어진 간선도 남을 수 있어 꺼낼 때 도착점의 방문 여부를 다시 확인합니다. 동점 순번은 정점 객체끼리 비교하지 않도록 합니다.8장에서 직접 힙을 구현했으므로 여기서는 표준 heapq를 사용해 그래프 선택 규칙에 집중합니다.
Kruskal은 정렬 O(E log E), 집합 연산 분할 상환 O((V+E)α(V))이며 α는 매우 느리게 증가하는 역아커만 함수입니다. 이 수준에서는 거의 상수로 이해하되 정확히 O(1)이라고 단정하지 않습니다. 정렬 복사 포함 공간 O(V+E). 이 간선 힙 Prim은 최악 E개 후보를 넣어 O((V+E) log(E+1)), 공간 O(V+E). 단순 연결 그래프에서는 보통 O(E log V)로도 씁니다.
생각해보기와 핵심 정리
생각해보기: 연결되지 않았는데 간선을 V-1개 얻을 수 있을까요? 사이클 없이 모든 성분을 잇는 간선이 없으므로 불가능합니다. 최소 신장 숲을 원하면 실패 대신 각 성분 결과를 반환하도록 명세를 바꿉니다.
11.4 최단 경로
학습 목표
거리 완화와 확정을 구분한다.
Dijkstra의 비음수 조건과 오래된 힙 항목 처리를 설명한다.
문제에서 개념으로
Dijkstra 알고리즘은 출발점에서 현재 알려진 거리가 가장 작은 정점을 먼저 처리합니다. 그 정점을 거쳐 이웃으로 가는 비용이 기존 기록보다 작으면 갱신합니다. 이 갱신을 **완화(Relaxation)**라고 합니다. 새 거리=현재 거리+간선 가중치를 기존 거리와 비교하는 것이 핵심입니다.
모든 간선 가중치가0 이상이면, 가장 작은 잠정 거리의 정점은 아직 처리하지 않은 정점을 거쳐 더 짧아질 수 없습니다. 새로 지나는 간선들이 비용을 줄이지 않기 때문입니다. 음수 간선이 있으면 이 근거가 깨집니다. 따라서 아래 구현은 음수 간선을 미리 거부합니다. 음수 간선에는 Bellman–Ford 같은 다른 알고리즘을 고려하며, 음수 사이클이 도달 가능하면 어떤 정점에는 유한한 최단값 자체가 없을 수 있습니다.
힙의 기존 항목을 직접 수정하는 대신 개선된 거리를 새 항목으로 넣습니다. 예전 큰 거리 항목은 나중에 꺼낼 때 현재 dist와 다르면 버립니다. 처음 발견한 순간에 방문 완료로 고정하면 더 짧은 경로 갱신을 놓칩니다. BFS의 최초 발견 거리 확정 규칙을 그대로 가져오면 안 됩니다.
구조와 작은 예제
꺼낸 유효 정점
A
B
C
D
완화 내용
초기
0
∞
∞
∞
시작만0
A:0
0
1
4
∞
B,C 발견
B:1
0
1
3
6
C를4→3, D발견
C:3
0
1
3
4
D를6→4
D:4
0
1
3
4
더 개선 없음
힙에는 C:4,D:6 같은 오래된 항목이 남을 수 있지만 꺼낼 때 버립니다. D의 부모는 C, C의 부모는 B, B의 부모는 A여서 경로는 A-B-C-D입니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def dijkstra(adjacency,start):
if start not in adjacency: raise KeyError(start)
for neighbors in adjacency.values():
for neighbor,weight in neighbors:
if neighbor not in adjacency: raise KeyError(neighbor)
if weight < 0: raise ValueError("Dijkstra는 음수 간선을 지원하지 않습니다.")
distance = {v:float("inf") for v in adjacency}
parent = {start:None}
distance[start] = 0
sequence = count()
pending = [(0,next(sequence),start)]
while pending:
known,_,vertex = heapq.heappop(pending)
if known != distance[vertex]: continue # 개선되기 전의 오래된 후보
for neighbor,weight in adjacency[vertex]:
candidate = known + weight
if candidate < distance[neighbor]:
distance[neighbor] = candidate
parent[neighbor] = vertex
heapq.heappush(pending,(candidate,next(sequence),neighbor))
return distance,parent
def shortest_path(parent,goal):
if goal not in parent: return None
result = []
while goal is not None:
result.append(goal)
goal = parent[goal]
return result[::-1]
distance,parent = dijkstra(adjacency,"A")
print(distance)
print(shortest_path(parent,"D"))
코드 해설
distance는 모든 정점의 최단값을 담고 도달 불가능하면 inf가 남습니다. parent는 실제로 도달한 정점만 기록합니다. 동일 거리 경로에서는 < 조건이 갱신하지 않아 먼저 찾은 부모를 유지합니다.0비용 간선도 허용되며 strict 개선 조건이 같은 거리 후보를 무한히 넣지 않게 합니다. 유한한 수치 가중치와 None이 아닌 정점 이름을 사전조건으로 사용합니다.
인접 리스트·중복 후보 힙에서 O((V+E) log(E+1)) 상한과 O(V+E) 공간입니다. E=0에서는 초기화 O(V)를 별도로 보아 전체 O(V+(E+1)log(E+2))로도 표현할 수 있습니다. 단순 그래프의 통상 표기는 O((V+E)log V). 행렬에서 최소 미확정 정점을 선형으로 찾는 버전은 O(V²)이며 조밀 그래프에서 합리적일 수 있습니다.
생각해보기와 핵심 정리
생각해보기: A→C=4로 처음 발견했다고 C를 확정하면? 이후 A→B→C=3을 놓칩니다. 발견, 개선, 최소 후보 처리의 시점을 구분합니다.
CHAPTER 핵심 정리
알고리즘
목적
핵심 선택
가중치 조건
Kruskal
MST
전체 최소 간선, 다른 성분 연결
음수도 가능
Prim
MST
선택 집합 경계의 최소 간선
음수도 가능
Dijkstra
단일 출발 최단 경로
잠정 거리 최소 정점
비음수
MST는 보통 무방향 연결 그래프, Dijkstra는 비음수 방향·무방향 그래프에 적용합니다. 다른 목적의 결과를 대신 사용하지 않습니다.
연습문제
[기초] 문제 1
MST가 최소화하는 것은?
[기초] 문제 2
0비용 간선은 존재할 수 있나요?
[기초] 문제 3
Dijkstra에서 허용하는 가중치 부호는?
[기초] 문제 4
Kruskal의 사이클 검사 도구는?
[응용] 문제 5
공통 그래프의 MST 비용은?
[응용] 문제 6
A에서 C로 가는 최단 비용과 경로는?
[응용] 문제 7
Prim의 선택 가중치가 증가 순서가 아닐 수 있는 이유는?
[응용] 문제 8
Dijkstra에서 힙 거리6,현재 dist=4인 항목은?
[도전] 문제 9
MST 경로가 최단이 아닌 삼각형 반례를 구성하세요.
[도전] 문제 10
Dijkstra에서 발견 즉시 visited 처리하면 안 되는 이유를 설명하세요.
연습문제 정답 및 해설
문제 1
정답: 모든 정점을 연결하는 선택 간선의 총합.
해설: 특정 출발점의 모든 경로 길이 합과는 다릅니다.
문제 2
정답: 예.
해설: 간선 없음은 None 등 별도 표시를 사용합니다.
문제 3
**정답:**0 이상.
해설: 음수는 최소 후보 확정의 근거를 깨뜨립니다.
문제 4
정답: 서로소 집합.
해설: 같은 대표이면 이미 연결된 정점들입니다.
문제 5
**정답:**4.
해설: A-B:1,C-D:1,B-C:2를 선택합니다.
문제 6
**정답:**3, A-B-C.
해설: 직접 간선4보다1+2가 작습니다.
문제 7
정답: 후보가 현재 트리의 경계에 한정되기 때문입니다.
해설: 새 정점을 넣은 뒤 더 싼 간선이 경계에 나타날 수 있습니다.
문제 8
정답: 버립니다.
해설: 더 짧은 경로로 대체된 오래된 후보입니다.
문제 9
정답: A-B:2,B-C:2,A-C:3.
해설: MST는 비용4인 두2간선을 고르지만 그 안의 A-C 경로는4로 직접3보다 깁니다.
문제 10
정답: 첫 후보 거리가 나중에 더 작은 경로로 개선될 수 있습니다.
해설: A-C:4 뒤 B를 통해3으로 개선되는 공통 예가 반례입니다. 최소 잠정 거리 처리와 최초 발견을 구분합니다.
실습문제
실습문제 1: 최단 경로 보고서
문제
출발점에서 각 정점의 거리와 경로를 반환하세요.
요구사항
도달 불가능 정점은 (inf,None)으로 나타냅니다.
실행 예시
(4, ['A', 'B', 'C', 'D'])
(inf, None)
힌트
dijkstra 결과의 부모 맵으로 각 경로를 복원하세요.
실습문제 2: 연결 비용 절약
문제
모든 간선을 설치할 때와 MST를 설치할 때의 비용 차이를 구하세요.
요구사항
연결된 무방향 그래프이며 각 간선을 한 번만 입력합니다.
실행 예시
9
힌트
전체 합에서 MST 합을 뺍니다.
실습문제 3: 0비용 지름길
문제
0비용 간선이 포함된 방향 그래프에서 경로를 찾으세요.
요구사항
Dijkstra를 재사용하고 목표까지 거리와 경로를 출력하세요.
실행 예시
(1, ['S', 'A', 'B', 'T'])
힌트
0을 없는 간선으로 취급하지 마세요.
실습문제 정답 및 해설
실습문제 1 정답
def route_report(adjacency,start):
distance,parent = dijkstra(adjacency,start)
return {v:(distance[v],shortest_path(parent,v)) for v in adjacency}
extended = {v:neighbors.copy() for v,neighbors in adjacency.items()}
extended["X"] = []
report = route_report(extended,"A")
print(report["D"])
print(report["X"])
코드 해설
X는 거리 배열에는 있지만 parent에는 없으므로 경로 None입니다. 모든 경로를 만드는 비용은 단일 경로와 다릅니다.
실행 과정
D는 D-C-B-A를 뒤집고 X는 부재를 즉시 반환합니다.
핵심 아이디어와 복잡도
Dijkstra 비용에 전체 반환 경로 길이 합 P의 O(P)가 추가됩니다. 최악 P=O(V²)이므로 전체 경로 출력은 더 큰 비용일 수 있습니다.
실습문제 2 정답
def saved_cost(vertices,edges):
_,minimum = kruskal(vertices,edges)
return sum(weight for _,_,weight in edges)-minimum
print(saved_cost(vertices,weighted_edges))
코드 해설
무방향 인접 리스트의 양쪽 항목을 그대로 합하면 두 배로 세므로 간선 목록을 사용합니다.
실행 과정
전체13-MST4=9입니다.
핵심 아이디어와 복잡도
문제의 비용 단위를 일관되게 셉니다. 정렬이 지배하여 O(E log E) 시간, O(V+E) 공간입니다.
7장에서 배운 세 정렬은 이해하기 쉽지만 데이터가 늘면 제곱 시간의 부담이 커집니다. 이번 장은 앞서 배운 재귀·힙·분할정복·빈도 집계를 결합하여 더 다양한 정렬을 구현합니다. ‘고급’이라는 이름이 언제나 더 빠르다는 뜻은 아닙니다. 데이터의 순서·값 범위·메모리 조건에 따라 적합성이 달라집니다.
이 장에서는 특별한 설명이 없으면 비교 가능한 원소의 오름차순 정렬을 다룹니다. 셸·힙·퀵·이중피벗 퀵은 입력을 직접 수정하고 같은 목록을 반환합니다. 병합·기수·카운팅은 입력을 보존하고 새 목록을 반환합니다. 각 예제는 같은 원본의 새 복사본으로 실행하여 앞 정렬의 결과가 다음 정렬의 입력으로 섞이지 않게 합니다.
12.1 다양한 정렬 알고리즘
학습 목표
비교 기반과 값 구조 기반 정렬을 구분한다.
정렬 전후 정확성을 두 조건으로 검증한다.
문제에서 개념으로
비교 정렬은 두 원소 중 어느 것이 작은지 비교하여 순서를 결정합니다. 셸·힙·병합·퀵 정렬이 해당합니다. 기수·카운팅 정렬은 숫자의 자릿수나 제한된 값 범위를 이용하므로 단순 비교만 하는 모형과 다릅니다. 비교 정렬의 일반적인 Ω(n log n) 하한과 카운팅 정렬의 선형 시간이 모순되지 않는 이유입니다.
정렬 결과 검사는 두 가지가 필요합니다. 첫째 모든 이웃이 오름차순인지, 둘째 입력의 원소와 중복 개수를 그대로 보존했는지입니다. 빈 목록을 반환하면 첫 조건은 만족하지만 정렬은 아닙니다. Python sorted를 정답 기준으로 비교하면 작은 검증 데이터에서 두 조건을 함께 확인하기 편합니다. 알고리즘 구현 내부에서 sorted를 대신 호출하는 것과 검증 기준으로 사용하는 것은 다른 일입니다.
구조와 작은 예제
공통 입력은 [8,3,5,1,9,2], 정답은 [1,2,3,5,8,9]입니다. 추가로 빈 목록, 한 원소, 이미 정렬, 역순, 같은 값만, 중복 혼합을 확인해야 합니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
7장의 재사용 모듈에서 이미 구현한 세 함수를 가져옵니다. code 폴더 안에서 ch12.py를 실행하면 같은 폴더의 core07.py를 찾습니다. 함수 하나만 복사하려면 그 함수가 의존하는 정의도 함께 가져와야 합니다.
실행 결과
입력: [8, 3, 5, 1, 9, 2]
비교 기준: [1, 2, 3, 5, 8, 9]
시간·공간 복잡도
검증용 sorted는 O(n log n) 상한과 결과 공간 O(n)이므로 실제 알고리즘 실행 시간 측정에 포함하지 않습니다. 각 정렬의 비용과 검증 비용을 분리합니다.
생각해보기와 핵심 정리
생각해보기: 결과가 오름차순인지만 확인하면 어떤 오류를 놓칠까요? 원소 누락·중복 생성입니다. 순서와 내용 보존을 함께 검사합니다.
12.2 셸 정렬
학습 목표
간격별 부분 수열에 삽입 정렬을 적용한다.
간격 수열에 따라 복잡도가 달라짐을 설명한다.
문제에서 개념으로
삽입 정렬은 작은 값이 맨 뒤에 있으면 앞까지 한 칸씩 많이 이동해야 합니다. **셸 정렬(Shell Sort)**은 멀리 떨어진 원소끼리 먼저 정리하여 큰 이동을 앞 단계에서 수행합니다. 간격 gap만큼 떨어진 원소들을 같은 부분 수열로 보고 삽입 정렬합니다. 간격을 줄이다 마지막에1로 정리하면 전체가 정렬됩니다.
중요한 선택은 간격 수열입니다. 이 책은 이해하기 쉬운 n//2, 그다음 절반, 마지막1을 사용합니다. 다른 간격 수열은 평균·최악 성능이 달라질 수 있으므로 ‘셸 정렬 평균은 무조건 O(n^1.5)’ 같은 단일 수치를 일반화하지 않습니다. 멀리 떨어진 같은 키들의 상대 순서가 바뀔 수 있어 안정 정렬은 아닙니다.
구조와 작은 예제
gap
공통 입력의 처리 후 상태
3
[1,3,2,8,9,5]
1
[1,2,3,5,8,9]
첫 단계의 부분 수열은 인덱스(0,3),(1,4),(2,5)입니다. 각 수열만 정렬되었으므로 배열 전체는 아직 정렬되지 않았습니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def shell_sort(items, trace=None):
gap = len(items)//2
while gap > 0:
for index in range(gap,len(items)):
value,position = items[index],index
while position >= gap and items[position-gap] > value:
items[position] = items[position-gap]
position -= gap
items[position] = value
if trace is not None: trace(gap,items.copy())
gap //= 2
return items
print(shell_sort(sample.copy(),lambda gap,state:print("gap",gap,state)))
코드 해설
7장의 삽입 정렬에서 이동 간격1을 gap으로 바꾼 구조입니다. position-gap이 음수가 되지 않는지 먼저 확인합니다. trace는 설명용 상태 관찰 함수이며 지정하지 않으면 복사·출력이 발생하지 않습니다. 기본 구현의 성능 표는 trace=None 기준입니다.
절반 간격 수열의 최악 O(n²), 최선은 이미 정렬된 경우 각 간격의 순회를 합쳐 Θ(n log n)입니다. 평균은 입력 분포와 간격 수열에 의존하여 이 원고는 단일 타이트 차수를 단정하지 않습니다. 보조 공간 O(1). trace를 켜면 상태 복사·출력 비용이 별도로 생깁니다.
생각해보기와 핵심 정리
생각해보기: 마지막 gap=1이 없으면? 부분 수열끼리만 정리되어 전체 순서를 보장할 수 없습니다. 마지막 삽입 정렬이 정확성의 마무리입니다.
12.3 힙 정렬
학습 목표
최대 힙에서 큰 값을 뒤에 확정한다.
바닥부터 힙 구축이 선형인 이유를 설명한다.
문제에서 개념으로
8장의 최소 힙은 최솟값을 앞에서 꺼냈습니다. **힙 정렬(Heap Sort)**을 제자리 오름차순으로 구현할 때는 최대 힙(Max-Heap)이 편리합니다. 루트의 최댓값을 배열 끝과 바꾸고 끝을 정렬 완료 구간으로 제외합니다. 남은 루트만 아래로 내려 힙을 복구하면 됩니다.
처음 힙을 만드는 방법도 중요합니다. 원소를 하나씩 삽입하면 O(n log n)이지만, 마지막 내부 노드부터 거꾸로 내려보내면 O(n)에 구축할 수 있습니다. 아래 노드 대부분은 리프라 이동이 없고, 한두 레벨만 이동하는 노드가 많습니다. 모든 노드가 log n만큼 내려간다고 세면 지나치게 큰 상한을 얻습니다.
구조와 작은 예제
공통 입력을 최대 힙으로 만들면 [9,8,5,1,3,2]입니다.9를 끝으로 보내고 복구하면 [8,3,5,1,2 | 9], 이어 [5,3,2,1 | 8,9]처럼 정렬 완료 구간이 오른쪽에서 커집니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def sift_down_max(items,root,end):
# end는 힙 구간의 끝을 포함하지 않는 경계다.
while 2*root+1 < end:
child = 2*root+1
if child+1 < end and items[child+1] > items[child]: child += 1
if items[root] >= items[child]: break
items[root],items[child] = items[child],items[root]
root = child
def heap_sort(items,trace=None):
for root in range(len(items)//2-1,-1,-1):
sift_down_max(items,root,len(items))
if trace is not None: trace("힙",items.copy())
for end in range(len(items)-1,0,-1):
items[0],items[end] = items[end],items[0]
sift_down_max(items,0,end)
if trace is not None: trace(end,items.copy())
return items
print(heap_sort(sample.copy(),lambda end,state:print(end,state)))
코드 해설
마지막 내부 노드는 n//2-1입니다. 그 뒤는 자식이 없으므로 복구할 필요가 없습니다. 정렬 과정에서 end 자리에 놓은 최댓값은 이후 sift_down에 포함하지 않습니다. 전체 배열 길이를 계속 쓰면 확정 구간을 다시 섞어 정렬을 깨뜨립니다.
바닥부터 구축은 각 높이의 노드 수를 고려한 합 n/4×1+n/8×2+…=O(n)입니다. 이후 최대 n번 제거에 O(log n) 경로 작업으로 평균·최악 O(n log n), 보조 공간 O(1). 이 조기 종료 sift 구현은 모든 키가 같을 때 Θ(n)도 가능하므로 최선 표에서 이를 명시합니다. 일반적인 서로 다른 키의 힙 정렬 분석은 Θ(n log n)입니다. 불안정 정렬입니다.
생각해보기와 핵심 정리
생각해보기: 큰 자식이 아닌 아무 자식과 교환하면? 남겨 둔 더 큰 자식이 부모보다 커져 최대 힙 조건이 깨질 수 있습니다.
12.4 병합 정렬
학습 목표
분할과 병합의 역할을 분리한다.
동점에서 왼쪽을 먼저 선택해 안정성을 유지한다.
문제에서 개념으로
**병합 정렬(Merge Sort)**은 입력을 두 부분으로 나누고 각각 정렬한 다음 합칩니다. 원소가0개나1개이면 이미 정렬되어 있으므로 기저 사례가 됩니다. 두 정렬 결과를 합칠 때는7장의 정렬 집합 결합처럼 앞쪽 두 값을 비교합니다. 다만 정렬은 중복을 보존해야 하므로 같은 값이라고 하나를 버리면 안 됩니다.
동점에서 왼쪽 값을 먼저 선택하면 입력의 상대 순서를 유지합니다. 왼쪽 부분의 원소들은 원래 오른쪽보다 앞에 있었기 때문입니다. 아래는 입력 복사본과 작업 배열 하나를 만들어 재사용합니다. 각 재귀에서 슬라이스를 만들어 저장 비용을 흩뜨리지 않고 인덱스 범위를 전달합니다.
구조와 작은 예제
공통 입력은 [8,3,5]와[1,9,2]로 나뉩니다. 왼쪽은[3,5,8], 오른쪽은[1,2,9]가 됩니다. 병합 선택 순서는1,2,3,5,8,9입니다. 작은 문제의 답을 얻은 뒤에만 부모 병합을 수행하므로 후위 계산과 연결됩니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def merge_sort(values,trace=None):
items = list(values)
temporary = [None]*len(items)
def sort(low,high):
if high-low <= 1: return
middle = (low+high)//2
sort(low,middle)
sort(middle,high)
left,right,write = low,middle,low
while left < middle and right < high:
if items[left] <= items[right]:
temporary[write] = items[left]; left += 1
else:
temporary[write] = items[right]; right += 1
write += 1
while left < middle:
temporary[write] = items[left]; left += 1; write += 1
while right < high:
temporary[write] = items[right]; right += 1; write += 1
for index in range(low,high): items[index] = temporary[index]
if trace is not None: trace((low,high),items[low:high])
sort(0,len(items))
return items
print(merge_sort(sample,lambda bounds,state:print(bounds,state)))
print("원본:",sample)
코드 해설
low 이상 high 미만의 반열린 구간을 사용합니다. temporary는 현재 병합 구간에만 쓰고 다시 items로 복사합니다. 정렬된 두 부분을 읽는 동안 쓰기 위치가 원본을 덮어쓰지 않도록 별도 작업 배열이 필요합니다. 결과 목록 하나에 덧붙이는 순회와는 다른 이유의 보조 공간입니다.
T(n)=2T(n/2)+O(n), 각 깊이 작업 합 O(n), 깊이 O(log n)이므로 최선·평균·최악 Θ(n log n)입니다. 이 구현은 이미 정렬된 구간을 생략하는 최적화가 없습니다. 결과·작업 배열 O(n), 재귀 스택 O(log n), 전체 추가 공간 O(n). 안정적입니다.
생각해보기와 핵심 정리
생각해보기: 동점에서 오른쪽을 먼저 꺼내도 숫자 결과는 맞습니다. 무엇이 달라질까요? 같은 키를 가진 레코드의 상대 순서가 바뀌어 안정성을 잃습니다.
12.5 퀵 정렬
학습 목표
피벗 분할의 불변식을 설명한다.
분할 균형과 재귀 깊이를 연결한다.
문제에서 개념으로
**퀵 정렬(Quick Sort)**은 피벗(Pivot)을 기준으로 작은 값과 큰 값을 나누고 양쪽을 재귀 정렬합니다. 병합 정렬은 먼저 반으로 나누고 나중에 합치지만, 퀵 정렬은 값 비교로 나누는 작업을 먼저 수행합니다. 분할 후 피벗 위치가 확정되면 그 자리는 다음 재귀에 포함하지 않습니다.
아래는 마지막 원소를 피벗으로 쓰는 Lomuto 분할입니다. 읽기 위치 scan 앞에서, low부터 boundary-1까지는 피벗보다 작은 값, boundary부터 scan-1까지는 크거나 같은 값이라는 불변식을 유지합니다. 분할이 균형이면 빠르지만 정렬된 입력이나 동일값이 많으면 한쪽으로 치우칠 수 있습니다. 무작위 피벗·세 값 중앙 선택·세 방향 분할 등 개선은 각각 다른 위험을 줄입니다.
구조와 작은 예제
공통 입력의 마지막2를 피벗으로 삼으면1만 왼쪽으로 보내 최종 [1,2,5,8,9,3]이 됩니다.2는 확정 위치1입니다. 이후 왼쪽[1],오른쪽[5,8,9,3]을 독립적으로 정렬합니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def partition(items,low,high):
pivot = items[high]
boundary = low
for scan in range(low,high):
if items[scan] < pivot:
items[boundary],items[scan] = items[scan],items[boundary]
boundary += 1
items[boundary],items[high] = items[high],items[boundary]
return boundary
def quick_sort(items,trace=None):
def sort(low,high):
if low >= high: return
fixed = partition(items,low,high)
if trace is not None: trace(fixed,items.copy())
sort(low,fixed-1)
sort(fixed+1,high)
sort(0,len(items)-1)
return items
print(quick_sort(sample.copy(),lambda fixed,state:print("피벗 위치",fixed,state)))
코드 해설
partition의 high는 포함하는 경계이며 병합 정렬의 반열린 구간과 다릅니다. 각 함수 내부에서는 그 규칙을 일관되게 지킵니다. boundary는 확정된 작은 구간의 바로 다음 자리입니다. 마지막 피벗 교환으로 피벗이 두 구간 사이에 들어갑니다. 같은 키끼리도 멀리 교환될 수 있어 불안정합니다.
균형 분할은 O(n log n), 무작위 순열의 서로 다른 키를 가정한 평균도 O(n log n)입니다. 최악 T(n)=T(n-1)+O(n)으로 O(n²). 분할 자체 보조 공간 O(1)이나 재귀까지 포함하면 평균 O(log n), 최악 O(n). 따라서 단순히 ‘퀵 정렬 공간 O(1)’이라고 쓰지 않습니다. 큰 편향 입력은 Python 재귀 제한에 걸릴 수 있습니다.
생각해보기와 핵심 정리
생각해보기: 모든 값이 같으면 이 구현의 boundary가 어디에 남나요? low입니다. 매번 하나만 확정되어 제곱 시간이 됩니다. 동점 처리는 성능에도 영향을 줍니다.
12.6 이중피벗 퀵 정렬
학습 목표
두 피벗으로 세 구간을 만든다.
교환해 온 값을 다시 분류하는 이유를 설명한다.
문제에서 개념으로
**이중피벗 퀵 정렬(Dual-Pivot Quick Sort)**은 두 피벗 p≤q를 사용하여 p보다 작은 구간, p 이상 q 이하 구간, q보다 큰 구간으로 나눕니다. 구간을 세 개로 만든다고 자동으로 더 빠르거나 더 균형 잡히는 것은 아닙니다. 피벗 선택, 비교 횟수, 데이터 이동, 중복값의 영향이 함께 작용합니다.
아래 구현은 양끝 값을 피벗으로 선택하는 교육용 버전입니다. less는 작은 구간의 다음 자리, great는 큰 구간의 앞 경계, scan은 미분류 값을 읽는 위치입니다. 오른쪽에서 값을 바꾸어 가져왔을 때 그 값이 p보다 작을 수 있으므로 작은 구간 검사도 이어서 수행합니다. 이 검사를 빠뜨리면 가운데에 작은 값이 남습니다.
구조와 작은 예제
공통 입력의 양끝8,2를 바꾸어 p=2,q=8로 정합니다. 분할 후 [1,2,5,3,8,9]가 되어 피벗2와8이 확정되고 가운데[5,3]을 재귀 정렬합니다. p=q라면 가운데는 모두 그 값이므로 재귀를 생략합니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def dual_pivot_sort(items,trace=None):
def sort(low,high):
if low >= high: return
if items[low] > items[high]:
items[low],items[high] = items[high],items[low]
first,second = items[low],items[high]
less,great = low+1,high-1
scan = less
while scan <= great:
if items[scan] < first:
items[scan],items[less] = items[less],items[scan]
less += 1
elif items[scan] > second:
while scan < great and items[great] > second: great -= 1
items[scan],items[great] = items[great],items[scan]
great -= 1
if items[scan] < first:
items[scan],items[less] = items[less],items[scan]
less += 1
scan += 1
less -= 1
great += 1
items[low],items[less] = items[less],items[low]
items[high],items[great] = items[great],items[high]
if trace is not None: trace((less,great),items.copy())
sort(low,less-1)
if first != second: sort(less+1,great-1)
sort(great+1,high)
sort(0,len(items)-1)
return items
print(dual_pivot_sort(sample.copy(),lambda bounds,state:print("두 피벗",bounds,state)))
코드 해설
분할 동안 양끝의 피벗은 마지막 교환 전까지 제자리에 보존합니다. less와 great를 한 칸 조정한 위치가 두 피벗의 최종 자리입니다. 세 재귀 구간 모두 피벗을 제외하므로 반드시 문제 크기가 줄어듭니다. trace로 세 구간의 값 범위가 맞는지 확인할 수 있습니다.
서로 다른 키의 균형 분할·무작위 순열 평균에서 O(n log n), 나쁜 피벗의 최악 O(n²). 평균 스택 O(log n), 최악 O(n), 분할 보조 O(1), 불안정입니다. 이 구현은 모든 값이 같으면 가운데 재귀를 생략해 Θ(n)이므로 최선은 입력 중복 허용 여부를 함께 적습니다.
생각해보기와 핵심 정리
생각해보기: 세 구간이면 깊이가 무조건 log₃n인가요? 각 구간 크기가 비슷해야 합니다. 양끝 피벗이 최솟값·최댓값이면 가운데에 대부분이 남습니다.
12.7 기수정렬
학습 목표
낮은 자릿수부터 안정적으로 정렬한다.
숫자 범위와 자릿수 비용을 연결한다.
문제에서 개념으로
**기수 정렬(Radix Sort)**은 값을 통째로 비교하지 않고 자릿수를 기준으로 여러 번 정렬합니다. 여기서는 가장 낮은 자릿수부터 처리하는 LSD(Least Significant Digit) 방식을 사용합니다. 일의 자리를 정리한 뒤 십의 자리, 백의 자리를 정리합니다.
각 자릿수 정렬이 안정적이어야 합니다. 십의 자리가 같은 두 값은 이미 정리한 일의 자리 순서를 유지해야 전체 두 자리 순서가 맞기 때문입니다. 아래는0~9 버킷에 입력 순서대로 넣고 버킷 순서대로 이어 붙여 안정성을 얻습니다. 음이 아닌 정수만 지원하며 음수·실수는 거부합니다. 음수 지원에는 부호 처리 규칙이 추가로 필요하므로 이 코드에 그대로 넣지 않습니다.
구조와 작은 예제
다자리 예 [38,13,25,11,29,2]의 일의 자리 결과는[11,2,13,25,38,29], 십의 자리 결과는[2,11,13,25,29,38]입니다. 공통 한 자리 입력은 한 번의 버킷 분류로 끝납니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def radix_sort(values,trace=None):
if any(type(value) is not int or value < 0 for value in values):
raise ValueError("음이 아닌 정수만 지원합니다.")
items = list(values)
if not items: return items
maximum,place = max(items),1
while place <= max(1,maximum):
buckets = [[] for _ in range(10)]
for value in items:
digit = (value//place)%10
buckets[digit].append(value)
items = [value for bucket in buckets for value in bucket]
if trace is not None: trace(place,items.copy())
place *= 10
return items
print(radix_sort(sample))
print(radix_sort([38,13,25,11,29,2],lambda place,state:print("자리",place,state)))
코드 해설
place는1,10,100으로 증가합니다. 최대값이0인 경우도 한 번 처리하도록 max(1,maximum)을 사용합니다. 버킷들은 각각 새 목록이어야 하며 [[]]*10으로 만들면 모든 숫자가 같은 버킷에 섞입니다. bool은 int의 하위형이지만 여기서는 명세를 명확히 하려고 type(value) is int로 정수만 허용합니다.
밑 b, 자릿수 d에서 시간 O(d(n+b)), 추가 공간 O(n+b). 여기서 b=10이지만 d는 최댓값에 따라 증가합니다. 따라서 항상 O(n)이라고 줄이지 않고 d가 고정된 경우에만 선형으로 말합니다. 매우 큰 Python 정수의 나눗셈 비용은 별도입니다. 안정 정렬입니다.
생각해보기와 핵심 정리
생각해보기: 두 번째 자릿수 정렬이 불안정하면? 같은 십의 자리 안에서 일의 자리 순서를 잃습니다. LSD는 각 단계의 안정성이 정확성 조건입니다.
12.8 카운팅 정렬
학습 목표
값 범위에 대응하는 빈도 배열을 만든다.
누적합으로 안정적인 배치 위치를 계산한다.
문제에서 개념으로
**카운팅 정렬(Counting Sort)**은 각 값이 몇 번 나오는지 셉니다. 점수가0~100처럼 범위가 작으면 비교를 반복하지 않고 빠르게 정렬할 수 있습니다. 하지만 입력은 몇 개뿐인데 값 범위가 수십억이면 빈도 배열이 지나치게 커집니다. 시간과 메모리를 n뿐 아니라 값 범위 k로 분석해야 합니다.
숫자만 정렬한다면 빈도만큼 숫자를 다시 쓰는 방법도 있습니다. 레코드의 안정성까지 배우기 위해 누적합과 출력 배열을 사용하겠습니다. 누적합 count[v]는 v 이하 원소가 몇 개인지 나타내므로 v의 마지막 배치 위치는 count[v]-1입니다. 오른쪽에서 왼쪽으로 입력을 읽으며 배치하면 동일값의 상대 순서가 유지됩니다.
구조와 작은 예제
공통 입력은 최소1,최대9라 k=9입니다. 값1~9의 빈도는[1,1,1,0,1,0,0,1,1], 누적합은[1,2,3,3,4,4,4,5,6]입니다. 값5의 마지막 위치는4-1=3입니다. 음수는 최소값을 빼 배열 인덱스로 바꿉니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
def counting_sort(values):
if any(type(value) is not int for value in values):
raise ValueError("정수만 지원합니다.")
if not values: return []
minimum,maximum = min(values),max(values)
counts = [0]*(maximum-minimum+1)
for value in values: counts[value-minimum] += 1
for index in range(1,len(counts)): counts[index] += counts[index-1]
result = [None]*len(values)
for value in reversed(values):
index = value-minimum
counts[index] -= 1
result[counts[index]] = value
return result
print(counting_sort(sample))
print(counting_sort([2,-1,2,0,-3]))
코드 해설
minimum 오프셋으로 음수도0이상 인덱스에 대응합니다. 누적합은 배치하면서 감소하므로 초기 빈도와 다른 의미로 변합니다. 오른쪽의 같은 키를 오른쪽 자리부터 채우기 때문에 왼쪽 원소의 우선순서가 보존됩니다. 숫자만으로는 그 안정성을 관찰하기 어려워 실습에서 이름이 붙은 레코드로 확장합니다.
실행 결과
[1, 2, 3, 5, 8, 9]
[-3, -1, 0, 2, 2]
시간·공간 복잡도
k=max-min+1에서 시간 O(n+k), 보조·출력 공간 O(n+k). 빈도 배열 초기화도 k개이므로 값이 드물어도 비용을 피할 수 없습니다. 이 코드의 안정 배치 규칙은 레코드에도 확장 가능합니다.
생각해보기와 핵심 정리
생각해보기: [1,10**9] 두 개를 정렬하는 데 적합할까요? 아닙니다. n은2지만 k가 매우 커서 배열을 만들기 어렵습니다. 비교 정렬이 합리적입니다.
12.9 정렬 알고리즘의 성능 비교
학습 목표
비용 표의 전제를 확인한다.
데이터 특성과 메모리 요구로 정렬을 선택한다.
문제에서 개념으로
아래 표는 이 원고의 구체적인 구현을 기준으로 합니다. 평균은 비교 퀵 정렬에서 서로 다른 키의 무작위 순열 같은 가정이 필요하며, 모든 알고리즘에 입력과 무관한 평균이 존재하는 것처럼 읽지 않습니다. 추가 메모리는 재귀 스택과 새 결과를 포함하며 입력 배열 자체는 제외합니다. 상태 추적용 복사와 출력은 끈 상태입니다.
거의 정렬된 데이터는 삽입 정렬의 이동 횟수가 적습니다. 데이터가 작으면 단순한 삽입 정렬의 낮은 준비 비용이 유리할 수 있습니다. 메모리를 엄격히 제한하며 최악 시간을 통제하려면 힙 정렬을 고려합니다. 안정성이 필요하면 병합 정렬이나 조건에 맞는 안정 카운팅·기수를 선택합니다. 큰 데이터라도 메모리에 모두 들어가는지에 따라 문제가 달라집니다. 디스크 데이터를 나누어 정렬하고 병합하는 외부 정렬은 병합 원리를 활용하지만, 이 장의 리스트 병합 정렬을 그대로 실행하는 것과는 다른 구현입니다.
실제 Python 프로그램에서는 내장 sort·sorted가 안정성과 최적화를 제공하므로 일반 용도에 우선 고려할 만합니다. 이 원고의 직접 구현은 알고리즘 구조를 설명하기 위한 것입니다. 특정 입력에서 더 빠른지는 같은 환경의 반복 측정으로 확인하되, 몇 밀리초의 결과를 보편적인 우열로 확대하지 않습니다.
구조와 작은 예제
알고리즘
최선
평균
최악
추가 메모리
안정
특징
선택
Θ(n²)
Θ(n²)
Θ(n²)
O(1)
아니요
교환 적음, 비교 고정
삽입
Θ(n)
Θ(n²)
Θ(n²)
O(1)
예
거의 정렬·작은 입력
최적화 버블
Θ(n)
Θ(n²)
Θ(n²)
O(1)
예
무교환 회전 종료
셸(절반 간격)
Θ(n log n)
분포·간격 의존
O(n²)
O(1)
아니요
떨어진 부분 수열 정리
힙(본문 구현)
Θ(n) 가능*
Θ(n log n)
Θ(n log n)
O(1)
아니요
최대 힙·최악 보장
병합
Θ(n log n)
Θ(n log n)
Θ(n log n)
O(n)
예
일정한 분할, 추가 배열
퀵(Lomuto)
Θ(n log n)
Θ(n log n)**
Θ(n²)
평균 O(log n), 최악 O(n)
아니요
피벗 품질·중복 영향
이중피벗 퀵
Θ(n) 가능*
Θ(n log n)**
Θ(n²)
평균 O(log n), 최악 O(n)
아니요
두 피벗, 세 구간
LSD 기수
Θ(d(n+b))
Θ(d(n+b))
Θ(d(n+b))
O(n+b)
예
비음수 정수, 안정 자릿수
카운팅
Θ(n+k)
Θ(n+k)
Θ(n+k)
O(n+k)
예
정수 값 범위가 작을 때
주석 1: 힙은 모든 키가 같은 경우 sift가 즉시 멈추고, 이중피벗은 p=q인 가운데 재귀를 생략합니다. 서로 다른 키를 전제한 일반적인 최선 표는 O(n log n)입니다.
주석 2: 무작위 순열의 서로 다른 키를 가정합니다. 마지막·양끝 피벗을 고정한 실제 입력에서는 최악이 쉽게 나타날 수 있습니다.
n은 원소 수, b는 자릿수의 밑, d는 최대 자릿수, k는 정수 값 범위입니다. n=0·1의 상수 경계 사례는 점근 표에서 제외합니다.
Python 구현
이 절의 코드는 이 장의 앞선 절에서 정의한 이름을 이어서 사용합니다. 배포한 장별 .py 파일에는 필요한 코드가 순서대로 들어 있습니다.
algorithms = [selection_sort,insertion_sort,bubble_sort,shell_sort,heap_sort,
merge_sort,quick_sort,dual_pivot_sort,radix_sort,counting_sort]
for algorithm in algorithms:
source = sample.copy()
result = algorithm(source)
print(algorithm.__name__,result == sorted(sample))
코드 해설
각 함수에 새 복사본을 전달하여 같은 입력으로 비교합니다. 반환값이 정답과 같은지 검사하며 실제 검증 스크립트에서는 중복·빈 입력·역순·음수 지원 범위 등을 추가합니다. True라는 결과는 해당 입력에서 맞다는 의미이지 모든 입력에 대한 증명은 아닙니다.
이 검증 루프의 비용은 각 정렬 비용의 합이며, 이 결과를 정렬 시간 벤치마크로 해석하지 않습니다. 시간 측정 시 복사·정답 생성·출력은 측정 구간 밖으로 둡니다.
생각해보기와 핵심 정리
생각해보기: 메모리 O(1), 안정성, 최악 O(n log n)을 이 장의 간단한 구현 하나가 모두 만족하나요? 없습니다. 요구사항에 따라 어느 특성을 우선할지 결정해야 합니다. 더 복잡한 전문 알고리즘의 존재와 이 원고의 구현 범위를 구분하세요.
CHAPTER 핵심 정리
상황
우선 검토할 방법
이유와 한계
거의 정렬
삽입, Python 내장 정렬
이동·기존 순서 활용
작은 데이터
삽입
낮은 구현·준비 비용
큰 일반 비교 데이터
내장 정렬, 병합, 개선된 퀵
로그 수준 시간, 메모리·최악 조건 확인
메모리 제한
힙
추가 배열·깊은 재귀 없음
안정성 필요
병합, 내장, 범위 맞는 카운팅·기수
같은 키 순서 보존
좁은 정수 범위
카운팅
n+k 비용이 작음
고정 자릿수 정수
기수
자릿수 구조 활용
학습의 마지막 질문은 ‘어느 정렬 이름이 가장 좋은가’가 아니라 ‘입력과 요구조건을 설명하고 그에 맞는 비용을 계산할 수 있는가’입니다. 시간·공간·안정성·수정 여부를 함께 비교하세요.
연습문제
[기초] 문제 1
제자리 수정과 새 결과 반환의 차이는?
[기초] 문제 2
셸 정렬 마지막 간격은?
[기초] 문제 3
제자리 오름차순 힙 정렬에 사용하는 힙 종류는?
[기초] 문제 4
LSD 기수 정렬에서 먼저 처리하는 자릿수는?
[응용] 문제 5
공통 입력을 gap=3으로 정리한 결과는?
[응용] 문제 6
병합에서 같은 키일 때 왼쪽을 먼저 선택하는 이유는?
[응용] 문제 7
정렬된 n개에 마지막 피벗 Lomuto 퀵 정렬을 적용하면?
[응용] 문제 8
카운팅 정렬의 k는 원소 개수와 어떻게 다른가요?
[도전] 문제 9
바닥부터 힙 구축에서 모든 노드에 log n을 곱하면 왜 과대 추정인가요?
[도전] 문제 10
기수·카운팅의 선형 시간이 비교 정렬 하한과 모순되지 않는 이유는?
연습문제 정답 및 해설
문제 1
정답: 입력 목록 자체를 바꾸는지 별도 결과를 만드는지의 차이입니다.
해설: 호출자가 원본을 보존해야 하는 경우에 직접 영향을 줍니다.
문제 2
**정답:**1.
해설: 전체를 하나의 부분 수열로 정리해야 완전 정렬됩니다.
문제 3
정답: 최대 힙.
해설: 가장 큰 값을 배열 뒤쪽에 확정합니다.
문제 4
정답: 가장 낮은 자리.
해설: 일의 자리부터 처리하며 다음 안정 정렬이 이전 순서를 유지합니다.
문제 5
정답: [1,3,2,8,9,5].
해설: (8,1),(3,9),(5,2) 세 부분 수열을 각각 정리합니다.
문제 6
정답: 안정성 유지.
해설: 왼쪽 원소가 입력에서도 앞에 있었기 때문입니다.
문제 7
정답: 최악 O(n²), 스택 O(n).
해설: 피벗이 매번 최댓값이라 n-1개 문제만 남습니다.
문제 8
정답: k=max-min+1인 값 범위입니다.
해설: 입력이2개뿐이어도 두 값 차이가 크면 k는 거대할 수 있습니다.
문제 9
정답: 대부분 노드가 아래쪽이라 내려갈 높이가 작습니다.
해설: 높이별 노드 수를 가중하여 더하면 n의 상수배로 수렴합니다.
문제 10
정답: 두 알고리즘은 비교 결과만 사용하는 모형 밖의 값 구조를 활용합니다.
해설: 자릿수·유한 범위로 직접 분류하므로 모든 가능한 순열을 이진 비교 결정으로만 구별한다는 하한의 전제가 다릅니다.
실습문제
실습문제 1: 안정 점수 정렬
문제
이름·정수 점수 레코드를 점수순으로 안정 카운팅 정렬하세요.
요구사항
점수는0~100. 동일 점수의 입력 순서를 유지하고 입력은 보존합니다.
실행 예시
[('준', 70), ('민', 80), ('서', 80)]
힌트
빈도는 점수로 세되 출력에는 원래 레코드를 배치합니다.
실습문제 2: 역순 쌍 세기
문제
i<j인데 a[i]>a[j]인 쌍의 개수를 구하세요.
요구사항
원본 보존, 중복값은 역순 쌍으로 세지 않습니다. 이중 반복 O(n²)을 쓰지 않습니다.
실행 예시
4
힌트
병합에서 오른쪽 값이 먼저 나올 때 왼쪽에 남은 원소 수를 더합니다.
실습문제 3: 두 정렬 목록 병합
문제
서로 다른 날 만든 두 정렬 기록을 하나로 합치세요.
요구사항
입력은 오름차순, 중복은 모두 보존, 입력은 수정하지 않습니다.
실행 예시
[1, 2, 3, 3, 3, 8]
힌트
정렬 집합 합집합과 달리 같을 때 한 원소를 버리지 마세요.
실습문제 정답 및 해설
실습문제 1 정답
def stable_score_sort(records):
counts = [0]*101
for name,score in records:
if type(score) is not int or not 0 <= score <= 100:
raise ValueError("점수 범위 오류")
counts[score] += 1
for score in range(1,101): counts[score] += counts[score-1]
result = [None]*len(records)
for record in reversed(records):
score = record[1]
counts[score] -= 1
result[counts[score]] = record
return result
print(stable_score_sort([("민",80),("준",70),("서",80)]))
코드 해설
점수만 복원하면 이름을 잃으므로 원래 튜플을 결과에 넣습니다. 뒤에서 읽어 뒤쪽 자리를 먼저 채우는 안정 배치를 사용합니다.
실행 과정
서80을 마지막80자리, 민80을 그 앞80자리에 놓아 민→서 순서를 보존합니다.
핵심 아이디어와 복잡도
고정 범위101에서 O(n+101) 시간·공간, 즉 n에 대해 O(n). 임의 범위로 확대하면 k를 따로 셉니다.
실습문제 2 정답
def inversion_count(values):
def solve(items):
if len(items) <= 1: return items,0
middle = len(items)//2
left,count_left = solve(items[:middle])
right,count_right = solve(items[middle:])
merged,i,j,total = [],0,0,count_left+count_right
while i < len(left) and j < len(right):
if left[i] <= right[j]:
merged.append(left[i]); i += 1
else:
merged.append(right[j]); j += 1
total += len(left)-i
merged.extend(left[i:]); merged.extend(right[j:])
return merged,total
return solve(list(values))[1]
print(inversion_count([3,1,2,1]))
코드 해설
오른쪽 값보다 현재 왼쪽 값이 크면 정렬된 왼쪽의 남은 값도 모두 큽니다. 하나씩 비교하지 않고 남은 개수를 한 번에 더합니다. 같은 값은 왼쪽 우선으로 처리해 역순으로 세지 않습니다.
실행 과정
쌍은(3,첫1),(3,2),(3,마지막1),(2,마지막1) 총4개입니다.
핵심 아이디어와 복잡도
병합 정렬 구조로 O(n log n) 시간. 이 분할 슬라이스 구현도 동시에 유지되는 크기 합은 기하급수적으로 줄어 최대 O(n) 추가 공간과 O(log n) 스택이며, 전체 할당량은 O(n log n)입니다.
실습문제 3 정답
def merge_records(first,second):
result,i,j = [],0,0
while i < len(first) and j < len(second):
if first[i] <= second[j]:
result.append(first[i]); i += 1
else:
result.append(second[j]); j += 1
result.extend(first[i:]); result.extend(second[j:])
return result
print(merge_records([1,3,3],[2,3,8]))
코드 해설
같은3이 양쪽에 있어도 왼쪽 하나만 소비하고 다음 비교에서 나머지를 처리합니다. 결과 원소 수는 반드시 n+m입니다.
실행 과정
1,2 선택 후 왼쪽3 두 개,오른쪽3,8 순서입니다.
핵심 아이디어와 복잡도
이미 정렬된 부분 결과를 재사용합니다. 시간 O(n+m), 결과·슬라이스 공간 O(n+m). 전체를 다시 비교 정렬할 필요가 없습니다.
다음 학습으로
이제 새로운 문제를 만나면 입력·출력·실패 조건을 먼저 정하고, 필요한 연산과 불변식을 적은 뒤 자료구조를 고르세요. 작은 입력을 추적하고 정확성을 확인한 다음 시간·공간 비용을 분석하는 순서를 유지합니다.
실행 파일은 code/ch12.py, 시연 출력 없이 가져오는 재사용 모듈은 code/core12.py입니다. 실습 정답은 실행 파일의 뒤쪽에 배치했습니다.
실행 결과가 있는 본문·실습 코드 블록 107개를 집필 과정에서 실행했습니다. 본문에 표시한 출력은 이 실행에서 수집했습니다.
장별 실행 파일 12개를 새 Python 프로세스에서 각각 실행하고, 본문의 해당 장 출력들을 합친 결과와 일치하는지 확인했습니다.
재사용 모듈은 시연 출력을 제외한 함수·클래스 정의이며, 모든 코드 파일의 문법 검사를 통과했습니다.
통합 HTML의 목차 대상·표·코드 블록·내장 구조도와 외부 리소스 불필요 여부를 정적 검사했습니다. 이 실행 환경에는 브라우저 실행 파일이 없어 실제 화면 렌더 검토는 수행하지 못했습니다.
챕터별 자체 검토
챕터
지정 절 수
연습문제 / 해설
실습 / 해설
코드·출력 대조
01
4
10 / 10
0 / 0
확인
02
12
10 / 10
3 / 3
확인
03
6
10 / 10
3 / 3
확인
04
5
10 / 10
3 / 3
확인
05
7
10 / 10
3 / 3
확인
06
5
10 / 10
3 / 3
확인
07
7
10 / 10
3 / 3
확인
08
6
10 / 10
3 / 3
확인
09
4
10 / 10
3 / 3
확인
10
6
10 / 10
3 / 3
확인
11
4
10 / 10
3 / 3
확인
12
9
10 / 10
3 / 3
확인
각 장에서 문제의 필요성, 개념과 구조, 작은 입력의 동작, 코드 목적과 핵심 상태, 시간·공간 비용, 생각해보기, 핵심 정리와 다음 장 연결을 검토했습니다. 문제·해설 수와 절 번호는 스크립트로 검사했으며, 설명의 타당성과 전제는 별도로 읽고 확인했습니다. 자동 검사는 설명의 충분함을 정량적으로 증명하는 도구는 아닙니다.
AVL 코드는 회전·삽입을 다룹니다. 일반 BST 삭제를 AVL 삭제로 사용할 수 없습니다.
연결 성분·무방향 사이클·신장 숲은 무방향 그래프를 전제로 합니다. 위상 정렬은 DAG 대상입니다.
MST는 무방향 연결 그래프를 전제로 하며, 단절 시 실패를 알립니다. Dijkstra는 비음수 유한 가중치를 전제로 합니다.
기수 정렬은 음이 아닌 정수, 카운팅 정렬은 정수와 확보 가능한 값 범위를 전제로 합니다.
재귀 BST·DFS·단순 퀵 정렬은 큰 편향 입력에서 Python 재귀 깊이 제한을 만날 수 있습니다. 이 한계를 해당 절에서 설명했습니다.
정렬 비교표는 실제 작성한 구현의 조기 종료·동점 처리·추가 배열·호출 스택을 반영했습니다.
집필 중 보완한 분석
리스트 집합의 결과 생성에서도 중복 검사가 반복된다는 점을 비용에 포함했습니다. Python 배열 확장 비용과 분할 상환을 구별했습니다. 재귀 순회의 반복 목록 연결, 허프만의 코드 문자열 길이, 모든 최단 경로 출력의 전체 길이도 단순한 핵심 알고리즘 비용에 숨기지 않았습니다. 힙·이중피벗 정렬의 동일 키 조기 종료는 일반 표의 전제와 별도로 표기했습니다.