Chapter 01

자료구조란 무엇인가?

1.1 자료구조의 정의

자료구조(Data Structure)란 데이터를 효율적으로 저장하고 관리하기 위한 구조입니다. 컴퓨터 프로그램에서 다루는 모든 데이터는 어떤 형태로든 자료구조에 담겨 있습니다. 변수 하나도 가장 단순한 자료구조이고, 복잡한 소셜 네트워크의 친구 관계도 그래프라는 자료구조로 표현됩니다.

일상생활에 비유하면, 자료구조는 물건을 정리하는 방법입니다. 책을 바닥에 아무렇게나 쌓아두면 원하는 책을 찾기 어렵지만, 책장에 주제별로 정리하면 순식간에 찾을 수 있습니다. 데이터도 마찬가지입니다. 어떤 자료구조를 선택하느냐에 따라 프로그램의 속도와 효율이 극적으로 달라집니다.

1.2 왜 자료구조를 배워야 하는가?

같은 문제라도 자료구조를 잘 선택하면 실행 시간을 수백, 수천 배 단축할 수 있습니다. 예를 들어 100만 개의 데이터에서 특정 값을 찾을 때, 정렬되지 않은 배열에서는 최대 100만 번 비교해야 하지만, 해시 테이블을 사용하면 단 1번에 찾을 수 있습니다.

  • 효율적인 프로그래밍 : 적절한 자료구조 선택은 알고리즘 성능의 핵심입니다.
  • 문제 해결 능력 향상 : 복잡한 문제를 체계적으로 분해하고 해결하는 사고력을 기릅니다.
  • 코딩 테스트 필수 : 대부분의 기업 코딩 테스트에서 자료구조 지식이 요구됩니다.
  • 실무 프로그래밍의 토대 : 데이터베이스, 운영체제, 네트워크, AI 등 모든 분야의 기반입니다.

1.3 자료구조의 분류

자료구조는 크게 선형 자료구조비선형 자료구조로 나뉩니다. 선형 자료구조는 데이터가 일렬로 나열된 형태이고, 비선형 자료구조는 계층적이거나 그물처럼 연결된 형태입니다.

분류자료구조특징예시
선형배열 (Array)연속된 메모리, 인덱스 접근성적표, 픽셀 데이터
연결 리스트 (Linked List)포인터로 연결, 동적 크기음악 재생 목록
스택 (Stack)후입선출 (LIFO)Ctrl+Z 되돌리기
큐 (Queue)선입선출 (FIFO)프린터 대기열
덱 (Deque)양쪽 입출력슬라이딩 윈도우
비선형트리 (Tree)계층 구조, 부모-자식파일 시스템, HTML DOM
힙 (Heap)최대/최소 빠른 접근우선순위 큐
해시 테이블 (Hash Table)키-값 쌍, O(1) 접근사전, 캐시
그래프 (Graph)정점과 간선지도, 소셜 네트워크

1.4 추상 자료형 (ADT) vs 자료구조

자료구조를 본격적으로 배우기 전에, 추상 자료형(Abstract Data Type, ADT)이라는 개념을 이해해야 합니다. ADT는 "무엇을 할 수 있는가"를 정의한 것이고, 자료구조는 "어떻게 구현할 것인가"를 결정한 것입니다.

예를 들어 "리스트(List)"라는 ADT는 삽입, 삭제, 검색 등의 연산을 정의합니다. 이를 배열로 구현하면 "배열 리스트"가 되고, 포인터로 구현하면 "연결 리스트"가 됩니다. ADT는 설계도이고, 자료구조는 그 설계도의 실제 건축물인 셈입니다.

이 튜토리얼에서는 C 언어와 Python을 함께 사용합니다. C는 메모리와 포인터를 직접 다루어 자료구조의 내부 동작을 이해하기에 좋고, Python은 간결한 문법으로 핵심 로직에 집중하기에 좋습니다. 두 언어 모두 이해하면 어떤 언어에서든 자료구조를 구현할 수 있게 됩니다.

1.5 핵심 용어 정리

용어영문설명
노드Node데이터를 담는 기본 단위 (연결 리스트, 트리 등)
포인터Pointer메모리 주소를 가리키는 변수
인덱스Index배열에서 원소의 위치 번호 (0부터 시작)
순회Traversal자료구조의 모든 원소를 방문하는 것
시간 복잡도Time Complexity알고리즘 실행 시간의 증가율
공간 복잡도Space Complexity알고리즘이 사용하는 메모리의 증가율
Chapter 02

알고리즘 분석 기초 - 시간 복잡도와 Big-O

2.1 왜 알고리즘 분석이 중요한가?

자료구조를 배우기 전에 반드시 알아야 할 것이 알고리즘의 효율성을 측정하는 방법입니다. "이 프로그램이 빠른가?"를 판단하려면, 실행 시간을 측정할 수도 있지만 컴퓨터 성능에 따라 결과가 달라집니다. 그래서 컴퓨터 과학에서는 입력 크기(n)에 따른 연산 횟수의 증가율을 기준으로 효율성을 분석합니다. 이것이 바로 Big-O 표기법입니다.

2.2 Big-O 표기법

Big-O 표기법은 최악의 경우(Worst Case) 알고리즘이 수행하는 연산 횟수의 상한을 나타냅니다. 상수와 낮은 차수 항은 무시하고, 가장 큰 영향을 주는 항만 남깁니다.

Big-O이름n=1,000일 때 연산 수예시
O(1)상수1배열 인덱스 접근, 해시 테이블 조회
O(log n)로그≈ 10이진 탐색
O(n)선형1,000배열 순회, 선형 탐색
O(n log n)선형 로그≈ 10,000병합 정렬, 퀵 정렬 (평균)
O(n²)이차1,000,000버블 정렬, 이중 반복문
O(n³)삼차1,000,000,000행렬 곱셈 (단순)
O(2ⁿ)지수≈ 10³⁰⁰ (불가능)부분 집합 열거, 피보나치 (단순 재귀)
O(n!)팩토리얼≈ 10²⁵⁶⁷ (불가능)순열 열거, 외판원 문제 (브루트포스)

2.3 Big-O 계산하는 법

Big-O를 계산할 때 핵심 규칙은 세 가지입니다. 첫째, 상수는 버립니다(3n → O(n)). 둘째, 낮은 차수 항은 버립니다(n² + n → O(n²)). 셋째, 중첩된 반복문은 곱합니다.

Python
# O(1) - 상수 시간: 입력 크기와 무관하게 항상 일정
def get_first(arr):
    return arr[0]   # 배열 크기가 1만이든 100만이든 1번 연산

# O(n) - 선형 시간: 입력 크기에 비례
def find_max(arr):
    max_val = arr[0]
    for x in arr:      # n번 반복
        if x > max_val:
            max_val = x
    return max_val

# O(n²) - 이차 시간: 이중 반복문
def has_duplicate(arr):
    for i in range(len(arr)):         # n번
        for j in range(i+1, len(arr)):  # 최대 n번
            if arr[i] == arr[j]:        # n × n = n²
                return True
    return False

# O(log n) - 로그 시간: 매 단계마다 절반으로 줄어듦
def binary_search(arr, target):
    lo, hi = 0, len(arr) - 1
    while lo <= hi:        # 범위가 절반씩 줄어듦 → log₂(n)번
        mid = (lo + hi) // 2
        if arr[mid] == target:
            return mid
        elif arr[mid] < target:
            lo = mid + 1
        else:
            hi = mid - 1
    return -1

2.4 공간 복잡도

시간 복잡도와 함께 공간 복잡도(Space Complexity)도 중요합니다. 공간 복잡도는 알고리즘이 사용하는 추가 메모리의 양을 나타냅니다. 입력 데이터 자체가 차지하는 공간은 제외하고, 알고리즘이 동작하면서 "추가로" 필요한 메모리만 셉니다.

Python
# 공간 O(1): 추가 메모리를 상수만큼만 사용
def sum_array(arr):
    total = 0            # 변수 1개 (상수)
    for x in arr:
        total += x
    return total

# 공간 O(n): 입력 크기에 비례하는 추가 메모리
def reverse_array(arr):
    result = []          # 새 배열 → n개 원소 저장
    for x in reversed(arr):
        result.append(x)
    return result
시간과 공간의 트레이드오프 : 많은 경우 시간을 줄이려면 공간이 더 필요하고, 공간을 아끼려면 시간이 더 걸립니다. 예를 들어 해시 테이블은 O(n)의 추가 공간을 사용하지만, 검색을 O(1)로 만듭니다. 상황에 맞는 균형을 찾는 것이 자료구조 선택의 핵심입니다.

2.5 각 자료구조별 시간 복잡도 요약

자료구조접근탐색삽입삭제
배열O(1)O(n)O(n)O(n)
연결 리스트O(n)O(n)O(1)*O(1)*
스택O(n)O(n)O(1)O(1)
O(n)O(n)O(1)O(1)
해시 테이블-O(1) 평균O(1) 평균O(1) 평균
이진 탐색 트리O(log n)*O(log n)*O(log n)*O(log n)*
O(1) 최대/최소O(n)O(log n)O(log n)

* 연결 리스트의 삽입/삭제는 위치를 이미 알고 있을 때 O(1)이고, BST는 균형이 잡혀있을 때의 복잡도입니다.

Tip! Big-O를 처음 배울 때는 "반복문이 몇 번 도는가?"에 집중하세요. 단일 반복문은 O(n), 이중 반복문은 O(n²), 매번 절반으로 나누면 O(log n)입니다. 이 세 가지만 확실히 알아도 대부분의 코드를 분석할 수 있습니다.
Chapter 03

배열 (Array)

3.1 배열이란?

배열은 같은 타입의 데이터를 연속된 메모리 공간에 순서대로 저장하는 가장 기본적인 자료구조입니다. 각 원소는 인덱스(0부터 시작)를 통해 O(1) 시간에 직접 접근할 수 있습니다. 마치 아파트 호수를 알면 바로 해당 집을 찾아갈 수 있는 것과 같습니다.

배열의 메모리 구조
인덱스: [0] [1] [2] [3] [4] ┌──────┬──────┬──────┬──────┬──────┐ 값: │ 10 │ 20 │ 30 │ 40 │ 50 │ └──────┴──────┴──────┴──────┴──────┘ 주소: 0x100 0x104 0x108 0x10C 0x110 → arr[3]의 주소 = 시작주소 + (3 × 원소크기) = 0x100 + 12 = 0x10C → 그래서 인덱스 접근이 O(1)!

3.2 배열의 장단점

장점으로는 인덱스를 통한 빠른 접근(O(1))과 캐시 지역성이 좋아 순회가 빠르다는 점이 있습니다. 메모리가 연속적이므로 CPU 캐시에 한꺼번에 올라와서 실제 성능이 이론치보다 좋은 경우가 많습니다.

단점으로는 크기가 고정되어 있어 생성 후 늘리기 어렵고(정적 배열), 중간에 삽입/삭제하면 나머지 원소를 모두 밀거나 당겨야 해서 O(n)이 걸린다는 점입니다.

3.3 C 언어로 구현하는 배열

C
/* 정적 배열: 크기가 컴파일 시점에 결정됨 */
#include <stdio.h>

int main() {
    // 선언과 동시에 초기화
    int arr[5] = {10, 20, 30, 40, 50};
    
    // 인덱스로 접근 - O(1)
    printf("arr[2] = %d\n", arr[2]);  // 30
    
    // 값 변경
    arr[2] = 99;
    
    // 순회 - O(n)
    for (int i = 0; i < 5; i++) {
        printf("%d ", arr[i]);
    }
    // 출력: 10 20 99 40 50
    
    return 0;
}

3.4 동적 배열 (Dynamic Array)

정적 배열의 크기 제한을 해결한 것이 동적 배열입니다. Python의 list, Java의 ArrayList, C++의 std::vector가 동적 배열입니다. 내부적으로 배열이 꽉 차면 더 큰 배열을 새로 할당하고, 기존 데이터를 복사합니다(보통 2배로 확장). 이를 amortized O(1)이라 부릅니다.

Python
# Python의 list는 동적 배열
arr = [10, 20, 30, 40, 50]

# 인덱스 접근 - O(1)
print(arr[2])         # 30

# 끝에 추가 - amortized O(1)
arr.append(60)         # [10, 20, 30, 40, 50, 60]

# 중간에 삽입 - O(n) (뒤의 원소를 모두 밀어야 함)
arr.insert(2, 99)      # [10, 20, 99, 30, 40, 50, 60]

# 삭제 - O(n)
arr.pop(2)              # [10, 20, 30, 40, 50, 60]

# 끝에서 삭제 - O(1)
arr.pop()               # [10, 20, 30, 40, 50]

# 길이 확인
print(len(arr))         # 5

# 슬라이싱 - O(k) (k = 슬라이스 크기)
sub = arr[1:4]          # [20, 30, 40]

3.5 배열의 주요 연산과 복잡도

연산시간 복잡도설명
인덱스 접근 arr[i]O(1)주소 계산으로 바로 접근
끝에 추가 appendO(1) 평균공간 부족 시 O(n) 복사 발생
중간 삽입 insert(i, x)O(n)i 이후 원소를 모두 한 칸씩 이동
중간 삭제 pop(i)O(n)i 이후 원소를 모두 한 칸씩 당김
끝에서 삭제 pop()O(1)마지막 원소만 제거
검색 (값으로)O(n)처음부터 끝까지 순회하며 비교
검색 (정렬된 배열)O(log n)이진 탐색 가능
주의! C 언어에서 배열의 인덱스 범위를 벗어나면(buffer overflow) 프로그램이 비정상 종료되거나, 다른 메모리를 덮어쓰는 심각한 버그가 발생합니다. 항상 배열 크기를 확인하세요. Python에서는 IndexError 예외가 발생합니다.

3.6 2차원 배열

행렬, 게임 보드, 이미지 등 2차원 데이터를 표현할 때 2차원 배열을 사용합니다.

Python
# 3×4 2차원 배열 (행렬)
matrix = [
    [1,  2,  3,  4],
    [5,  6,  7,  8],
    [9, 10, 11, 12]
]

# 접근: matrix[행][열]
print(matrix[1][2])   # 7 (2번째 행, 3번째 열)

# 전체 순회
rows = len(matrix)       # 3
cols = len(matrix[0])    # 4

for r in range(rows):
    for c in range(cols):
        print(matrix[r][c], end=" ")
    print()

# 0으로 초기화된 5×5 행렬 만들기
grid = [[0] * 5 for _ in range(5)]
Tip! Python에서 2차원 배열을 만들 때 [[0]*5]*5를 사용하면 안 됩니다! 이렇게 하면 5개의 행이 모두 같은 리스트 객체를 참조하게 되어, 하나를 바꾸면 전부 바뀝니다. 반드시 리스트 컴프리헨션 [[0]*5 for _ in range(5)]을 사용하세요.
Chapter 04

연결 리스트 (Linked List)

4.1 연결 리스트란?

연결 리스트는 각 데이터(노드)가 다음 노드의 주소(포인터)를 가지고 있어 체인처럼 연결된 자료구조입니다. 배열과 달리 메모리가 연속적일 필요가 없으며, 삽입·삭제가 매우 빠릅니다(O(1)). 대신 인덱스로 직접 접근이 불가능하여, 원하는 위치까지 처음부터 따라가야 합니다(O(n)).

단일 연결 리스트 (Singly Linked List)
head │ ▼ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ data: 10 │───▶│ data: 20 │───▶│ data: 30 │───▶│ data: 40 │───▶ NULL └──────────┘ └──────────┘ └──────────┘ └──────────┘ 노드 1 노드 2 노드 3 노드 4

4.2 배열 vs 연결 리스트

기준배열연결 리스트
메모리 배치연속분산 (포인터로 연결)
인덱스 접근O(1)O(n)
삽입/삭제 (중간)O(n)O(1)* (위치를 알 때)
메모리 사용데이터만데이터 + 포인터 (추가 공간)
크기 변경고정 (정적) / 재할당 (동적)자유롭게 가능
캐시 성능좋음 (지역성)나쁨 (분산)

4.3 단일 연결 리스트 구현 (Python)

Python
class Node:
    """연결 리스트의 한 칸 (노드)"""
    def __init__(self, data):
        self.data = data    # 저장할 데이터
        self.next = None    # 다음 노드를 가리키는 포인터


class SinglyLinkedList:
    """단일 연결 리스트"""
    def __init__(self):
        self.head = None    # 리스트의 첫 번째 노드
        self.size = 0
    
    def push_front(self, data):
        """맨 앞에 삽입 - O(1)"""
        new_node = Node(data)
        new_node.next = self.head  # 새 노드가 기존 head를 가리킴
        self.head = new_node       # head를 새 노드로 변경
        self.size += 1
    
    def push_back(self, data):
        """맨 뒤에 삽입 - O(n)"""
        new_node = Node(data)
        if not self.head:          # 리스트가 비어있으면
            self.head = new_node
        else:
            curr = self.head
            while curr.next:       # 마지막 노드까지 이동
                curr = curr.next
            curr.next = new_node   # 마지막 뒤에 연결
        self.size += 1
    
    def pop_front(self):
        """맨 앞 삭제 - O(1)"""
        if not self.head:
            return None
        data = self.head.data
        self.head = self.head.next  # head를 다음 노드로 이동
        self.size -= 1
        return data
    
    def search(self, target):
        """값 검색 - O(n)"""
        curr = self.head
        idx = 0
        while curr:
            if curr.data == target:
                return idx
            curr = curr.next
            idx += 1
        return -1
    
    def display(self):
        """전체 출력"""
        curr = self.head
        parts = []
        while curr:
            parts.append(str(curr.data))
            curr = curr.next
        print(" → ".join(parts) + " → None")


# 사용 예시
ll = SinglyLinkedList()
ll.push_back(10)
ll.push_back(20)
ll.push_back(30)
ll.push_front(5)
ll.display()   # 5 → 10 → 20 → 30 → None

print(ll.search(20))  # 2 (인덱스)
ll.pop_front()
ll.display()   # 10 → 20 → 30 → None

4.4 이중 연결 리스트 (Doubly Linked List)

이중 연결 리스트는 각 노드가 이전(prev)다음(next) 두 개의 포인터를 가집니다. 양방향 순회가 가능하고, 끝에서 삭제도 O(1)로 할 수 있습니다. 대신 포인터 하나당 추가 메모리가 필요합니다.

이중 연결 리스트 (Doubly Linked List)
head tail │ │ ▼ ▼ NULL ◀── ┌─────┐ ◀──▶ ┌─────┐ ◀──▶ ┌─────┐ ◀──▶ ┌─────┐ ──▶ NULL │ 10 │ │ 20 │ │ 30 │ │ 40 │ └─────┘ └─────┘ └─────┘ └─────┘
Python
class DNode:
    def __init__(self, data):
        self.data = data
        self.prev = None   # 이전 노드
        self.next = None   # 다음 노드


class DoublyLinkedList:
    def __init__(self):
        self.head = None
        self.tail = None
        self.size = 0
    
    def push_back(self, data):
        """맨 뒤 삽입 - O(1) (tail 포인터 덕분)"""
        new_node = DNode(data)
        if not self.tail:
            self.head = self.tail = new_node
        else:
            new_node.prev = self.tail
            self.tail.next = new_node
            self.tail = new_node
        self.size += 1
    
    def pop_back(self):
        """맨 뒤 삭제 - O(1) (이중 연결이므로 가능!)"""
        if not self.tail:
            return None
        data = self.tail.data
        if self.head == self.tail:
            self.head = self.tail = None
        else:
            self.tail = self.tail.prev
            self.tail.next = None
        self.size -= 1
        return data
실무에서 연결 리스트는? 현대 프로그래밍에서는 캐시 성능이 좋은 동적 배열(ArrayList, Vector)을 더 자주 사용합니다. 하지만 연결 리스트는 운영체제의 메모리 관리, LRU 캐시, 해시 테이블의 충돌 처리(체이닝) 등에서 여전히 핵심적으로 사용됩니다. 무엇보다 포인터와 동적 메모리의 개념을 이해하는 데 최고의 학습 도구입니다.
Chapter 05

스택 (Stack)

5.1 스택이란?

스택은 후입선출(LIFO: Last In, First Out) 원칙을 따르는 자료구조입니다. 가장 나중에 넣은 데이터가 가장 먼저 나옵니다. 접시를 쌓아올린 것을 생각하면 됩니다. 새 접시는 맨 위에 올리고(push), 꺼낼 때도 맨 위에서 꺼냅니다(pop).

스택의 동작 원리 (LIFO)
push(30) pop() │ │ ▼ ▼ ┌──────┐ ┌──────┐ │ 30 │ ← top │ 20 │ ← top (30이 제거됨) ├──────┤ ├──────┤ │ 20 │ │ 10 │ ├──────┤ └──────┘ │ 10 │ └──────┘

5.2 스택의 핵심 연산

연산설명시간 복잡도
push(x)맨 위에 원소 추가O(1)
pop()맨 위 원소 제거 및 반환O(1)
peek() / top()맨 위 원소 확인 (제거하지 않음)O(1)
isEmpty()스택이 비어있는지 확인O(1)
size()원소 개수 반환O(1)

5.3 Python으로 스택 구현

Python
class Stack:
    """리스트 기반 스택 구현"""
    def __init__(self):
        self._data = []
    
    def push(self, item):
        """맨 위에 추가"""
        self._data.append(item)
    
    def pop(self):
        """맨 위 제거 및 반환"""
        if self.is_empty():
            raise IndexError("Stack is empty")
        return self._data.pop()
    
    def peek(self):
        """맨 위 확인 (제거하지 않음)"""
        if self.is_empty():
            raise IndexError("Stack is empty")
        return self._data[-1]
    
    def is_empty(self):
        return len(self._data) == 0
    
    def __len__(self):
        return len(self._data)


# 사용 예시
s = Stack()
s.push(10)
s.push(20)
s.push(30)
print(s.peek())   # 30
print(s.pop())    # 30
print(s.pop())    # 20
print(len(s))      # 1

5.4 스택 활용 예제 - 괄호 검사

스택의 가장 대표적인 활용은 괄호 짝 맞추기입니다. 여는 괄호가 나오면 스택에 넣고, 닫는 괄호가 나오면 스택에서 꺼내서 짝이 맞는지 확인합니다.

Python
def is_valid_parentheses(s):
    """괄호 문자열이 올바른지 검사"""
    stack = []
    pairs = {')': '(', '}': '{', ']': '['}
    
    for ch in s:
        if ch in '({[':
            stack.append(ch)       # 여는 괄호 → push
        elif ch in ')}]':
            if not stack or stack[-1] != pairs[ch]:
                return False      # 짝이 안 맞음
            stack.pop()            # 닫는 괄호 → pop
    
    return len(stack) == 0       # 스택이 비어야 올바름

# 테스트
print(is_valid_parentheses("({[]})"))   # True
print(is_valid_parentheses("({[})"))    # False
print(is_valid_parentheses("(("))       # False

5.5 스택의 활용 사례

  • 함수 호출 스택(Call Stack) : 프로그램이 함수를 호출할 때마다 복귀 주소를 스택에 저장합니다. 재귀도 이 원리입니다.
  • 되돌리기(Undo) : 텍스트 편집기에서 Ctrl+Z를 누르면 스택에서 이전 상태를 꺼냅니다.
  • 웹 브라우저 뒤로가기 : 방문한 페이지를 스택에 저장합니다.
  • 수식 계산 : 후위 표기법(Postfix) 계산에 스택을 사용합니다.
  • DFS(깊이 우선 탐색) : 그래프 탐색에서 스택(또는 재귀)을 활용합니다.
Chapter 06

큐 (Queue)

6.1 큐란?

큐는 선입선출(FIFO: First In, First Out) 원칙을 따르는 자료구조입니다. 가장 먼저 넣은 데이터가 가장 먼저 나옵니다. 은행 대기줄을 생각하면 됩니다. 먼저 온 사람이 먼저 서비스를 받습니다.

큐의 동작 원리 (FIFO)
enqueue(40) dequeue() │ │ ▼ ▼ ┌──────┬──────┬──────┬──────┐ ┌──────┬──────┬──────┐ │ 10 │ 20 │ 30 │ 40 │ →→→ │ 20 │ 30 │ 40 │ └──────┴──────┴──────┴──────┘ └──────┴──────┴──────┘ front rear front rear ↑ ↑ 10이 나옴 (가장 먼저 들어온 것)

6.2 큐의 핵심 연산

연산설명시간 복잡도
enqueue(x)뒤(rear)에 원소 추가O(1)
dequeue()앞(front) 원소 제거 및 반환O(1)
front() / peek()앞 원소 확인 (제거하지 않음)O(1)
isEmpty()큐가 비어있는지 확인O(1)

6.3 Python으로 큐 구현

Python의 리스트로 큐를 만들면 앞에서 삭제할 때 O(n)이 걸리므로, collections.deque를 사용하는 것이 효율적입니다.

Python
from collections import deque

# deque를 사용한 효율적인 큐
queue = deque()

# enqueue (뒤에 추가)
queue.append(10)
queue.append(20)
queue.append(30)
print(queue)        # deque([10, 20, 30])

# dequeue (앞에서 제거) - O(1)!
front = queue.popleft()
print(front)        # 10
print(queue)        # deque([20, 30])

# peek (앞 원소 확인)
print(queue[0])      # 20

6.4 원형 큐 (Circular Queue)

배열로 큐를 구현하면, dequeue할 때마다 앞 공간이 낭비됩니다. 이를 해결한 것이 원형 큐입니다. 배열의 끝에 도달하면 다시 처음으로 돌아가서 빈 공간을 재활용합니다.

Python
class CircularQueue:
    """고정 크기 원형 큐"""
    def __init__(self, capacity):
        self.capacity = capacity
        self.queue = [None] * capacity
        self.front = 0        # 앞 인덱스
        self.rear = -1        # 뒤 인덱스
        self.size = 0
    
    def enqueue(self, item):
        if self.size == self.capacity:
            raise OverflowError("Queue is full")
        self.rear = (self.rear + 1) % self.capacity  # 원형으로!
        self.queue[self.rear] = item
        self.size += 1
    
    def dequeue(self):
        if self.size == 0:
            raise IndexError("Queue is empty")
        item = self.queue[self.front]
        self.front = (self.front + 1) % self.capacity  # 원형으로!
        self.size -= 1
        return item

6.5 큐의 활용 사례

  • BFS(너비 우선 탐색) : 그래프 탐색의 핵심 도구입니다.
  • 프로세스 스케줄링 : 운영체제가 실행할 프로세스 순서를 관리합니다.
  • 프린터 대기열 : 인쇄 요청을 순서대로 처리합니다.
  • 메시지 큐 : 카카오톡, Slack 등의 메시지 시스템에서 순서대로 전달합니다.
  • 버퍼(Buffer) : 데이터 속도 차이를 완충합니다 (영상 스트리밍 버퍼링).
Chapter 07

덱 (Deque)

7.1 덱이란?

덱(Deque, Double-Ended Queue)은 양쪽 끝에서 삽입과 삭제가 모두 가능한 자료구조입니다. 스택과 큐를 합친 것이라고 볼 수 있습니다. 앞에서 넣고 뒤에서 빼도 되고, 뒤에서 넣고 앞에서 빼도 됩니다.

덱의 동작 원리
push_front() push_back() │ │ ▼ ▼ ◀──┌──────┬──────┬──────┬──────┐──▶ │ 10 │ 20 │ 30 │ 40 │ ◀──└──────┴──────┴──────┴──────┘──▶ ▲ ▲ │ │ pop_front() pop_back()

7.2 Python deque 활용

Python
from collections import deque

dq = deque()

# 양쪽에서 삽입
dq.append(20)        # 뒤에 추가:       [20]
dq.append(30)        # 뒤에 추가:       [20, 30]
dq.appendleft(10)   # 앞에 추가:       [10, 20, 30]
dq.append(40)        # 뒤에 추가:       [10, 20, 30, 40]

# 양쪽에서 삭제
print(dq.popleft())  # 10 (앞에서 제거): [20, 30, 40]
print(dq.pop())      # 40 (뒤에서 제거): [20, 30]

# maxlen으로 크기 제한 (슬라이딩 윈도우에 유용)
window = deque(maxlen=3)
for i in range(5):
    window.append(i)
    print(list(window))

# 출력:
# [0]
# [0, 1]
# [0, 1, 2]
# [1, 2, 3]    ← 0이 자동으로 빠짐
# [2, 3, 4]    ← 1이 자동으로 빠짐
Tip! Python에서 큐나 스택이 필요할 때 리스트 대신 collections.deque를 사용하세요. 리스트의 pop(0)은 O(n)이지만, deque의 popleft()는 O(1)입니다. 성능 차이가 데이터가 클수록 극적으로 벌어집니다.
Chapter 08

재귀 (Recursion)

8.1 재귀란?

재귀는 함수가 자기 자신을 호출하는 프로그래밍 기법입니다. 복잡한 문제를 더 작은 동일한 문제로 나누어 해결합니다. 트리, 그래프 등 비선형 자료구조를 다룰 때 매우 자연스럽게 사용됩니다. 재귀를 이해하려면 재귀를 이해해야 합니다. (이 문장 자체가 재귀입니다!)

재귀에는 두 가지 핵심 요소가 있습니다. 기저 사례(Base Case)는 더 이상 재귀하지 않고 바로 답을 반환하는 조건이고, 재귀 사례(Recursive Case)는 문제를 더 작은 부분으로 나누어 자기 자신을 호출하는 부분입니다. 기저 사례가 없으면 무한 루프에 빠집니다.

8.2 기본 예제 - 팩토리얼

Python
def factorial(n):
    """n! = n × (n-1) × ... × 1"""
    # 기저 사례 (Base Case)
    if n <= 1:
        return 1
    # 재귀 사례 (Recursive Case)
    return n * factorial(n - 1)

print(factorial(5))  # 120

# 호출 과정 시각화:
# factorial(5) = 5 × factorial(4)
#              = 5 × 4 × factorial(3)
#              = 5 × 4 × 3 × factorial(2)
#              = 5 × 4 × 3 × 2 × factorial(1)
#              = 5 × 4 × 3 × 2 × 1
#              = 120

8.3 피보나치 수열과 메모이제이션

피보나치 수열은 재귀의 대표적 예제이지만, 단순 재귀는 매우 비효율적입니다(O(2ⁿ)). 같은 값을 반복 계산하기 때문입니다. 메모이제이션(Memoization)으로 이미 계산한 결과를 저장하면 O(n)으로 개선됩니다.

Python
# ❌ 비효율적 재귀 - O(2ⁿ)
def fib_naive(n):
    if n <= 1:
        return n
    return fib_naive(n-1) + fib_naive(n-2)

# ✅ 메모이제이션 - O(n)
def fib_memo(n, memo={}):
    if n <= 1:
        return n
    if n not in memo:
        memo[n] = fib_memo(n-1, memo) + fib_memo(n-2, memo)
    return memo[n]

# ✅ 반복문 (Bottom-Up DP) - O(n), 공간 O(1)
def fib_iter(n):
    if n <= 1:
        return n
    a, b = 0, 1
    for _ in range(2, n+1):
        a, b = b, a + b
    return b

print(fib_memo(50))   # 12586269025 (즉시 계산됨)
print(fib_iter(50))   # 12586269025
주의! Python의 기본 재귀 깊이 제한은 약 1,000입니다. 깊은 재귀가 필요하면 sys.setrecursionlimit()으로 늘리거나, 반복문으로 변환하세요. 재귀가 깊어지면 스택 오버플로(Stack Overflow)가 발생합니다.

8.4 재귀적 사고 연습 - 하노이 탑

하노이 탑은 재귀의 아름다움을 보여주는 고전 문제입니다. n개의 원판을 출발 기둥에서 목표 기둥으로 옮기되, 한 번에 하나씩, 큰 원판 위에 작은 원판만 올릴 수 있습니다.

Python
def hanoi(n, source, target, auxiliary):
    """원판 n개를 source에서 target으로 이동"""
    if n == 1:
        print(f"원판 1: {source} → {target}")
        return
    # 1) 위의 n-1개를 보조 기둥으로
    hanoi(n-1, source, auxiliary, target)
    # 2) 가장 큰 원판을 목표 기둥으로
    print(f"원판 {n}: {source} → {target}")
    # 3) 보조 기둥의 n-1개를 목표 기둥으로
    hanoi(n-1, auxiliary, target, source)

hanoi(3, 'A', 'C', 'B')
# 원판 1: A → C
# 원판 2: A → B
# 원판 1: C → B
# 원판 3: A → C
# 원판 1: B → A
# 원판 2: B → C
# 원판 1: A → C
# → 총 2³ - 1 = 7번 이동
재귀 vs 반복 : 모든 재귀는 반복문으로 변환할 수 있고, 반대도 마찬가지입니다. 재귀는 코드가 간결하고 직관적이지만 함수 호출 오버헤드가 있습니다. 반복문은 성능이 좋지만 코드가 복잡해질 수 있습니다. 트리 순회처럼 재귀가 자연스러운 경우에는 재귀를, 성능이 중요한 경우에는 반복문을 선택하세요.
Chapter 09

트리 기초 (Tree)

9.1 트리란?

트리는 계층적 구조를 표현하는 비선형 자료구조입니다. 하나의 루트(Root) 노드에서 시작하여 아래로 가지를 뻗는 형태입니다. 파일 시스템의 폴더 구조, HTML의 DOM, 회사의 조직도 등이 모두 트리 구조입니다.

트리의 구조와 용어
[A] ← 루트(Root), 깊이 0 / | \ / | \ [B] [C] [D] ← 깊이 1 / \ / \ [E] [F] [G] [H] ← 깊이 2 (리프 노드) / \ [I] [J] ← 깊이 3 (리프 노드) 높이(Height) = 3 (루트에서 가장 먼 리프까지의 거리)

9.2 트리의 핵심 용어

용어영문설명
루트Root트리의 최상위 노드 (부모가 없음)
부모Parent바로 위 노드 (B의 부모는 A)
자식Child바로 아래 노드 (A의 자식은 B, C, D)
리프Leaf자식이 없는 노드 (E, I, J, G, H)
형제Sibling같은 부모를 가진 노드 (B, C, D)
깊이Depth루트에서 해당 노드까지의 거리
높이Height해당 노드에서 가장 먼 리프까지의 거리
차수Degree자식 노드의 수 (A의 차수는 3)
서브트리Subtree한 노드를 루트로 하는 부분 트리

9.3 이진 트리 (Binary Tree)

이진 트리는 각 노드가 최대 2개의 자식(왼쪽, 오른쪽)을 가지는 트리입니다. 자료구조에서 가장 많이 다루는 트리 유형으로, 이진 탐색 트리(BST), 힙, 수식 트리 등의 기반입니다.

Python
class TreeNode:
    """이진 트리 노드"""
    def __init__(self, data):
        self.data = data
        self.left = None     # 왼쪽 자식
        self.right = None    # 오른쪽 자식


# 트리 구성 예시
#       1
#      / \
#     2   3
#    / \   \
#   4   5   6

root = TreeNode(1)
root.left = TreeNode(2)
root.right = TreeNode(3)
root.left.left = TreeNode(4)
root.left.right = TreeNode(5)
root.right.right = TreeNode(6)

9.4 트리 순회 (Traversal)

트리의 모든 노드를 방문하는 방법에는 크게 네 가지가 있습니다. 앞 세 가지는 깊이 우선(DFS) 방식이고, 마지막은 너비 우선(BFS) 방식입니다.

Python
from collections import deque

# 1. 전위 순회 (Pre-order): 루트 → 왼쪽 → 오른쪽
def preorder(node):
    if node is None:
        return
    print(node.data, end=" ")   # 루트 먼저
    preorder(node.left)
    preorder(node.right)

# 2. 중위 순회 (In-order): 왼쪽 → 루트 → 오른쪽
def inorder(node):
    if node is None:
        return
    inorder(node.left)
    print(node.data, end=" ")   # 가운데
    inorder(node.right)

# 3. 후위 순회 (Post-order): 왼쪽 → 오른쪽 → 루트
def postorder(node):
    if node is None:
        return
    postorder(node.left)
    postorder(node.right)
    print(node.data, end=" ")   # 루트 마지막

# 4. 레벨 순회 (Level-order / BFS)
def levelorder(root):
    if not root:
        return
    q = deque([root])
    while q:
        node = q.popleft()
        print(node.data, end=" ")
        if node.left:
            q.append(node.left)
        if node.right:
            q.append(node.right)

# 실행 결과 (위에서 만든 트리 기준)
preorder(root)     # 1 2 4 5 3 6
inorder(root)      # 4 2 5 1 3 6
postorder(root)    # 4 5 2 6 3 1
levelorder(root)   # 1 2 3 4 5 6
순회 기억법 : 전위(Pre)·중위(In)·후위(Post)의 "전·중·후"는 루트를 언제 방문하느냐를 가리킵니다. 전위는 루트가 "전(먼저)", 중위는 "중간", 후위는 "후(나중)"입니다. 왼쪽→오른쪽 순서는 항상 동일합니다.
Chapter 10

이진 탐색 트리 (Binary Search Tree)

10.1 BST란?

이진 탐색 트리(BST)는 이진 트리에 정렬 규칙을 추가한 것입니다. 모든 노드에 대해 왼쪽 서브트리의 모든 값 < 노드 값 < 오른쪽 서브트리의 모든 값이 성립합니다. 이 규칙 덕분에 탐색·삽입·삭제를 평균 O(log n)에 수행할 수 있습니다.

이진 탐색 트리 예시
[15] / \ [8] [23] / \ / \ [4] [12] [18] [30] / \ [2] [13] → 중위 순회(In-order)하면 정렬된 순서: 2 4 8 12 13 15 18 23 30

10.2 BST 구현

Python
class BSTNode:
    def __init__(self, key):
        self.key = key
        self.left = None
        self.right = None


class BST:
    def __init__(self):
        self.root = None
    
    def insert(self, key):
        """삽입 - 올바른 위치를 찾아 새 노드 배치"""
        self.root = self._insert(self.root, key)
    
    def _insert(self, node, key):
        if node is None:
            return BSTNode(key)
        if key < node.key:
            node.left = self._insert(node.left, key)
        elif key > node.key:
            node.right = self._insert(node.right, key)
        # 중복 키는 무시
        return node
    
    def search(self, key):
        """탐색 - 값 비교로 좌/우 결정"""
        return self._search(self.root, key)
    
    def _search(self, node, key):
        if node is None:
            return False
        if key == node.key:
            return True
        elif key < node.key:
            return self._search(node.left, key)
        else:
            return self._search(node.right, key)
    
    def inorder(self):
        """중위 순회 → 정렬된 순서 출력"""
        result = []
        self._inorder(self.root, result)
        return result
    
    def _inorder(self, node, result):
        if node:
            self._inorder(node.left, result)
            result.append(node.key)
            self._inorder(node.right, result)


# 사용 예시
bst = BST()
for v in [15, 8, 23, 4, 12, 18, 30, 2, 13]:
    bst.insert(v)

print(bst.inorder())     # [2, 4, 8, 12, 13, 15, 18, 23, 30]
print(bst.search(12))   # True
print(bst.search(99))   # False

10.3 BST의 한계 - 편향 트리

데이터가 정렬된 순서로 삽입되면 BST가 한쪽으로 치우쳐 연결 리스트처럼 되어, 모든 연산이 O(n)으로 퇴화합니다. 이를 해결한 것이 AVL 트리레드-블랙 트리 같은 자가 균형 트리입니다.

편향 트리 (최악의 BST)
1, 2, 3, 4, 5 순서로 삽입하면: [1] \ [2] \ [3] \ [4] \ [5] → 높이 = n-1, 탐색 O(n) — 연결 리스트와 동일!
실무에서는 대부분 직접 BST를 구현하지 않고, 언어에 내장된 균형 트리를 사용합니다. C++의 std::set/std::map(레드-블랙 트리), Java의 TreeMap, Python의 sortedcontainers.SortedList 등이 있습니다. 하지만 BST의 원리를 이해해야 이런 도구들을 올바르게 활용할 수 있습니다.
Chapter 11

힙 (Heap)

11.1 힙이란?

힙은 완전 이진 트리 형태로, 부모 노드가 자식 노드보다 항상 크거나(최대 힙) 작은(최소 힙) 성질을 만족하는 자료구조입니다. 가장 큰 값 또는 가장 작은 값을 O(1)에 확인하고, O(log n)에 삽입·삭제할 수 있습니다. 우선순위 큐의 대표적인 구현체입니다.

최소 힙 (Min Heap) 예시
[3] ← 최소값이 항상 루트 / \ [5] [8] / \ / \ [9] [7][10][12] 배열 표현: [3, 5, 8, 9, 7, 10, 12] 인덱스: 0 1 2 3 4 5 6 부모: (i-1) // 2 왼쪽 자식: 2*i + 1 오른쪽 자식: 2*i + 2

11.2 힙의 핵심 특성

힙은 실제로는 배열로 구현합니다. 완전 이진 트리의 성질 덕분에 포인터 없이도 부모-자식 관계를 인덱스 계산으로 알 수 있습니다. 인덱스 i의 부모는 (i-1)//2, 왼쪽 자식은 2i+1, 오른쪽 자식은 2i+2입니다.

11.3 Python heapq 사용

Python
import heapq

# Python의 heapq는 최소 힙(Min Heap)
heap = []
heapq.heappush(heap, 8)
heapq.heappush(heap, 3)
heapq.heappush(heap, 5)
heapq.heappush(heap, 12)
heapq.heappush(heap, 1)

print(heap)              # [1, 3, 5, 12, 8]
print(heap[0])            # 1 (최소값 확인 - O(1))

# 최소값 꺼내기 - O(log n)
print(heapq.heappop(heap))  # 1
print(heapq.heappop(heap))  # 3
print(heapq.heappop(heap))  # 5

# 리스트를 힙으로 변환 - O(n)
data = [9, 1, 7, 3, 5]
heapq.heapify(data)
print(data)              # [1, 3, 7, 9, 5]

# 최대 힙이 필요할 때: 값에 -를 붙여서 사용
max_heap = []
for v in [3, 1, 5, 2]:
    heapq.heappush(max_heap, -v)

print(-heapq.heappop(max_heap))  # 5 (최대값)
print(-heapq.heappop(max_heap))  # 3

11.4 힙의 활용 - K번째 큰 원소 찾기

Python
import heapq

def kth_largest(nums, k):
    """크기 k의 최소 힙을 유지하여 k번째 큰 원소를 찾음"""
    heap = nums[:k]
    heapq.heapify(heap)
    
    for num in nums[k:]:
        if num > heap[0]:       # 현재 k번째보다 크면
            heapq.heapreplace(heap, num)  # 교체
    
    return heap[0]              # 힙의 루트 = k번째 큰 값

print(kth_largest([3,1,5,12,7,9,2], 3))  # 7 (3번째 큰 수)

# 또는 더 간단하게:
print(heapq.nlargest(3, [3,1,5,12,7,9,2])[-1])  # 7
힙의 활용 사례 : 우선순위 큐(작업 스케줄링, 다익스트라 알고리즘), Top-K 문제, 중앙값 관리(최대 힙 + 최소 힙), 힙 정렬 등에 사용됩니다.
Chapter 12

해시 테이블 (Hash Table)

12.1 해시 테이블이란?

해시 테이블은 키(Key)를 해시 함수에 넣어 인덱스를 계산하고, 그 인덱스에 값(Value)을 저장하는 자료구조입니다. 평균적으로 삽입·탐색·삭제를 O(1)에 수행할 수 있어 가장 빠른 탐색 구조 중 하나입니다. Python의 dict, Java의 HashMap이 해시 테이블입니다.

해시 테이블의 동작 원리
키 해시 함수 인덱스 배열(버킷) "apple" → hash("apple") → 3 → [ ... | ... | ... | "apple":500 | ... ] "banana" → hash("banana") → 1 → [ ... | "banana":300 | ... | ... | ... ] "cherry" → hash("cherry") → 3 → 충돌! (같은 인덱스) 충돌 해결: 체이닝 → 인덱스 3에 연결 리스트로 저장 버킷[3] → [apple:500] → [cherry:200] → NULL

12.2 해시 함수의 역할

해시 함수는 임의의 크기의 데이터를 고정된 크기의 값(해시 값)으로 변환합니다. 좋은 해시 함수는 입력을 균일하게 분포시켜 충돌을 최소화합니다. 같은 입력은 항상 같은 해시 값을 반환해야 합니다(결정성).

12.3 충돌 해결 방법

방법원리장점단점
체이닝 (Chaining)같은 인덱스에 연결 리스트로 저장구현 간단, 삭제 용이추가 메모리 필요
개방 주소법 (Open Addressing)충돌 시 다른 빈 슬롯을 찾음추가 메모리 불필요삭제 복잡, 클러스터링

12.4 간단한 해시 테이블 구현

Python
class HashTable:
    """체이닝 방식 해시 테이블"""
    def __init__(self, capacity=16):
        self.capacity = capacity
        self.buckets = [[] for _ in range(capacity)]
        self.size = 0
    
    def _hash(self, key):
        """해시 함수: 키를 인덱스로 변환"""
        return hash(key) % self.capacity
    
    def put(self, key, value):
        """삽입/수정 - 평균 O(1)"""
        idx = self._hash(key)
        for i, (k, v) in enumerate(self.buckets[idx]):
            if k == key:
                self.buckets[idx][i] = (key, value)  # 기존 키 → 수정
                return
        self.buckets[idx].append((key, value))  # 새 키 → 추가
        self.size += 1
    
    def get(self, key):
        """검색 - 평균 O(1)"""
        idx = self._hash(key)
        for k, v in self.buckets[idx]:
            if k == key:
                return v
        raise KeyError(key)
    
    def remove(self, key):
        """삭제 - 평균 O(1)"""
        idx = self._hash(key)
        for i, (k, v) in enumerate(self.buckets[idx]):
            if k == key:
                self.buckets[idx].pop(i)
                self.size -= 1
                return
        raise KeyError(key)

# 사용
ht = HashTable()
ht.put("apple", 500)
ht.put("banana", 300)
print(ht.get("apple"))    # 500

12.5 Python dict 활용

Python
# Python dict = 고성능 해시 테이블
d = {}
d["apple"] = 500        # 삽입 O(1)
d["banana"] = 300
print(d["apple"])        # 500 - 조회 O(1)
print("apple" in d)      # True - 존재 확인 O(1)
del d["banana"]         # 삭제 O(1)

# 실전 활용: 빈도 세기
def count_frequency(arr):
    freq = {}
    for x in arr:
        freq[x] = freq.get(x, 0) + 1
    return freq

print(count_frequency(["a","b","a","c","b","a"]))
# {'a': 3, 'b': 2, 'c': 1}

# 실전 활용: 두 수의 합 (Two Sum)
def two_sum(nums, target):
    seen = {}
    for i, num in enumerate(nums):
        complement = target - num
        if complement in seen:
            return [seen[complement], i]
        seen[num] = i
    return []

print(two_sum([2, 7, 11, 15], 9))  # [0, 1]
해시 테이블의 시간 복잡도 : 평균적으로 O(1)이지만, 모든 키가 같은 인덱스로 해시되는 최악의 경우 O(n)이 됩니다. 좋은 해시 함수와 적절한 적재율(load factor) 관리가 중요합니다. Python의 dict는 적재율이 2/3을 넘으면 자동으로 크기를 2배로 늘립니다.
Chapter 13

그래프 기초 (Graph)

13.1 그래프란?

그래프는 정점(Vertex/Node)과 정점을 연결하는 간선(Edge)으로 이루어진 자료구조입니다. 트리도 그래프의 특수한 형태입니다(사이클이 없는 연결 그래프). 소셜 네트워크의 친구 관계, 도로망, 웹 페이지 링크, 전력망 등 현실 세계의 많은 관계를 그래프로 모델링할 수 있습니다.

13.2 그래프의 종류

종류설명예시
무방향 그래프간선에 방향 없음 (A↔B)친구 관계, 도로
방향 그래프(유향)간선에 방향 있음 (A→B)팔로우 관계, 웹 링크
가중 그래프간선에 가중치(비용) 있음도시 간 거리, 요금
비가중 그래프간선에 가중치 없음단순 연결 관계

13.3 그래프 표현 방법

그래프를 코드로 표현하는 방법은 크게 두 가지입니다.

인접 리스트 (Adjacency List) - 가장 많이 사용

각 정점마다 연결된 정점들의 리스트를 저장합니다. 간선 수가 적은 희소 그래프에 효율적입니다.

Python
# 인접 리스트 (딕셔너리)
graph = {
    'A': ['B', 'C'],
    'B': ['A', 'D', 'E'],
    'C': ['A', 'F'],
    'D': ['B'],
    'E': ['B', 'F'],
    'F': ['C', 'E']
}

# "A와 연결된 정점은?" → O(1)
print(graph['A'])   # ['B', 'C']

# 가중 그래프: 인접 리스트 + 가중치
weighted_graph = {
    '서울': [('대전', 140), ('강릉', 165)],
    '대전': [('서울', 140), ('부산', 200)],
    '부산': [('대전', 200)],
    '강릉': [('서울', 165)]
}

인접 행렬 (Adjacency Matrix)

V×V 크기의 2차원 배열에 연결 여부를 저장합니다. 두 정점 간 연결 확인이 O(1)이지만, 공간이 O(V²)로 큽니다. 밀집 그래프에 적합합니다.

Python
# 인접 행렬 (0: 연결 안 됨, 1: 연결됨)
# 정점: A=0, B=1, C=2, D=3
matrix = [
    [0, 1, 1, 0],  # A: B, C와 연결
    [1, 0, 0, 1],  # B: A, D와 연결
    [1, 0, 0, 1],  # C: A, D와 연결
    [0, 1, 1, 0],  # D: B, C와 연결
]

# "A와 B가 연결되어 있나?" → O(1)
print(matrix[0][1])  # 1 (연결됨)
기준인접 리스트인접 행렬
공간O(V + E)O(V²)
간선 존재 확인O(차수)O(1)
모든 인접 정점 탐색O(차수)O(V)
적합한 경우희소 그래프 (E ≪ V²)밀집 그래프 (E ≈ V²)
Chapter 14

그래프 탐색 - BFS와 DFS

14.1 BFS (너비 우선 탐색)

BFS는 시작 정점에서 가까운 정점부터 순서대로 방문합니다. 를 사용하여 구현하며, 최단 경로를 찾는 데 사용됩니다. 파도가 퍼져나가듯이 한 겹씩 탐색합니다.

Python
from collections import deque

def bfs(graph, start):
    """너비 우선 탐색"""
    visited = set()
    queue = deque([start])
    visited.add(start)
    order = []
    
    while queue:
        node = queue.popleft()          # 큐에서 꺼냄
        order.append(node)
        
        for neighbor in graph[node]:
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append(neighbor)  # 큐에 넣음
    
    return order

graph = {
    'A': ['B', 'C'],
    'B': ['A', 'D', 'E'],
    'C': ['A', 'F'],
    'D': ['B'],
    'E': ['B', 'F'],
    'F': ['C', 'E']
}

print(bfs(graph, 'A'))  # ['A', 'B', 'C', 'D', 'E', 'F']

14.2 DFS (깊이 우선 탐색)

DFS는 한 방향으로 끝까지 깊이 들어간 후, 막히면 되돌아와서 다른 방향을 탐색합니다. 스택 또는 재귀로 구현합니다. 미로 찾기처럼 한 길로 쭉 가다가 막히면 되돌아오는 방식입니다.

Python
# DFS - 재귀 버전
def dfs_recursive(graph, node, visited=None):
    if visited is None:
        visited = set()
    visited.add(node)
    print(node, end=" ")
    
    for neighbor in graph[node]:
        if neighbor not in visited:
            dfs_recursive(graph, neighbor, visited)

# DFS - 스택 버전 (반복)
def dfs_iterative(graph, start):
    visited = set()
    stack = [start]
    order = []
    
    while stack:
        node = stack.pop()             # 스택에서 꺼냄
        if node not in visited:
            visited.add(node)
            order.append(node)
            for neighbor in reversed(graph[node]):
                if neighbor not in visited:
                    stack.append(neighbor)  # 스택에 넣음
    
    return order

dfs_recursive(graph, 'A')        # A B D E F C
print()
print(dfs_iterative(graph, 'A'))  # ['A', 'B', 'D', 'E', 'F', 'C']

14.3 BFS vs DFS 비교

기준BFSDFS
자료구조큐 (Queue)스택 (Stack) / 재귀
탐색 순서가까운 곳부터 (층별)깊은 곳부터 (끝까지)
최단 경로보장 (비가중 그래프)보장 안 됨
메모리많이 사용 (넓게 확장)적게 사용 (깊이만큼)
활용최단 거리, 레벨별 순회경로 존재 확인, 사이클 검출, 위상 정렬
언제 뭘 쓸까? "최단 거리"를 구하는 문제라면 BFS, "모든 경로를 탐색"하거나 "백트래킹"이 필요하면 DFS를 선택하세요. 두 방법 모두 시간 복잡도는 O(V + E)로 동일합니다.
Chapter 15

정렬 알고리즘

15.1 정렬이 왜 중요한가?

정렬은 컴퓨터 과학에서 가장 기본적이고 중요한 연산입니다. 데이터를 정렬하면 이진 탐색(O(log n))이 가능해지고, 중복 제거, 순위 매기기, 데이터 시각화 등 수많은 후속 작업이 쉬워집니다. 정렬 알고리즘을 공부하면 분할 정복, 비교 기반 한계 등 알고리즘의 핵심 개념을 자연스럽게 이해하게 됩니다.

15.2 O(n²) 정렬 - 기본 정렬들

버블 정렬 (Bubble Sort)

인접한 두 원소를 비교하여 교환하는 과정을 반복합니다. 가장 이해하기 쉽지만 가장 느린 정렬입니다.

Python
def bubble_sort(arr):
    """버블 정렬 - O(n²)"""
    n = len(arr)
    for i in range(n):
        swapped = False
        for j in range(n - 1 - i):
            if arr[j] > arr[j+1]:
                arr[j], arr[j+1] = arr[j+1], arr[j]
                swapped = True
        if not swapped:       # 교환이 없으면 이미 정렬됨
            break
    return arr

선택 정렬 (Selection Sort)

Python
def selection_sort(arr):
    """선택 정렬 - O(n²), 교환 횟수는 최대 n번"""
    n = len(arr)
    for i in range(n):
        min_idx = i
        for j in range(i+1, n):
            if arr[j] < arr[min_idx]:
                min_idx = j
        arr[i], arr[min_idx] = arr[min_idx], arr[i]
    return arr

삽입 정렬 (Insertion Sort)

Python
def insertion_sort(arr):
    """삽입 정렬 - O(n²), 거의 정렬된 데이터에는 O(n)"""
    for i in range(1, len(arr)):
        key = arr[i]
        j = i - 1
        while j >= 0 and arr[j] > key:
            arr[j+1] = arr[j]       # 한 칸씩 오른쪽으로 밀기
            j -= 1
        arr[j+1] = key             # 올바른 위치에 삽입
    return arr

15.3 O(n log n) 정렬 - 효율적인 정렬들

병합 정렬 (Merge Sort)

분할 정복(Divide and Conquer)의 대표 알고리즘입니다. 배열을 반으로 나누고, 각각 정렬한 뒤, 합칩니다. 항상 O(n log n)을 보장하지만 O(n) 추가 공간이 필요합니다.

Python
def merge_sort(arr):
    """병합 정렬 - O(n log n), 공간 O(n)"""
    if len(arr) <= 1:
        return arr
    
    mid = len(arr) // 2
    left = merge_sort(arr[:mid])      # 왼쪽 반 정렬
    right = merge_sort(arr[mid:])     # 오른쪽 반 정렬
    
    return merge(left, right)          # 합치기

def merge(left, right):
    """두 정렬된 배열을 하나로 합침"""
    result = []
    i = j = 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            result.append(left[i])
            i += 1
        else:
            result.append(right[j])
            j += 1
    result.extend(left[i:])
    result.extend(right[j:])
    return result

print(merge_sort([38, 27, 43, 3, 9, 82, 10]))
# [3, 9, 10, 27, 38, 43, 82]

퀵 정렬 (Quick Sort)

Python
def quick_sort(arr):
    """퀵 정렬 - 평균 O(n log n), 최악 O(n²)"""
    if len(arr) <= 1:
        return arr
    
    pivot = arr[len(arr) // 2]        # 피벗 선택
    left = [x for x in arr if x < pivot]
    middle = [x for x in arr if x == pivot]
    right = [x for x in arr if x > pivot]
    
    return quick_sort(left) + middle + quick_sort(right)

print(quick_sort([38, 27, 43, 3, 9, 82, 10]))
# [3, 9, 10, 27, 38, 43, 82]

15.4 정렬 알고리즘 비교

알고리즘평균최악공간안정성특징
버블 정렬O(n²)O(n²)O(1)안정교육용, 실무 사용 X
선택 정렬O(n²)O(n²)O(1)불안정교환 횟수 최소
삽입 정렬O(n²)O(n²)O(1)안정거의 정렬된 데이터에 최적
병합 정렬O(n log n)O(n log n)O(n)안정항상 일정한 성능
퀵 정렬O(n log n)O(n²)O(log n)불안정실무에서 가장 빠른 경우 많음
힙 정렬O(n log n)O(n log n)O(1)불안정추가 공간 없이 보장된 성능
안정 정렬(Stable Sort)이란 같은 값의 원소들이 정렬 후에도 원래 순서를 유지하는 것입니다. 예를 들어 학생을 이름순으로 정렬한 후, 안정 정렬로 점수순 재정렬하면 같은 점수의 학생은 이름순이 유지됩니다. Python의 sorted()list.sort()는 Timsort(병합 정렬 + 삽입 정렬 하이브리드)를 사용하며, 안정 정렬입니다.
Chapter 16

탐색 알고리즘

16.1 선형 탐색 (Linear Search)

처음부터 끝까지 하나씩 비교하며 찾습니다. 정렬되지 않은 데이터에서 사용하며, 시간 복잡도는 O(n)입니다.

Python
def linear_search(arr, target):
    """선형 탐색 - O(n)"""
    for i, val in enumerate(arr):
        if val == target:
            return i
    return -1

16.2 이진 탐색 (Binary Search)

이진 탐색은 정렬된 배열에서 중간값과 비교하여 탐색 범위를 절반으로 줄이는 알고리즘입니다. O(log n)으로 매우 효율적입니다. 100만 개 데이터에서 최대 20번 비교로 답을 찾습니다.

Python
def binary_search(arr, target):
    """이진 탐색 - O(log n), 배열이 정렬되어 있어야 함"""
    lo, hi = 0, len(arr) - 1
    
    while lo <= hi:
        mid = (lo + hi) // 2
        if arr[mid] == target:
            return mid               # 찾음!
        elif arr[mid] < target:
            lo = mid + 1             # 오른쪽 절반 탐색
        else:
            hi = mid - 1             # 왼쪽 절반 탐색
    
    return -1                       # 못 찾음

arr = [2, 5, 8, 12, 16, 23, 38, 56, 72, 91]
print(binary_search(arr, 23))  # 5 (인덱스)
print(binary_search(arr, 99))  # -1 (없음)

# Python 내장 모듈
import bisect
idx = bisect.bisect_left(arr, 23)
print(idx, arr[idx])  # 5, 23
이진 탐색의 함정! 이진 탐색은 배열이 반드시 정렬되어 있어야 합니다. 정렬되지 않은 배열에 이진 탐색을 적용하면 잘못된 결과가 나옵니다. 또한 (lo + hi) / 2는 큰 값에서 오버플로가 발생할 수 있으므로, C/Java에서는 lo + (hi - lo) / 2를 사용하세요.
Chapter 17

트라이 (Trie)

17.1 트라이란?

트라이(Trie, Prefix Tree)는 문자열을 효율적으로 저장하고 검색하기 위한 트리 자료구조입니다. 각 노드가 문자 하나를 나타내며, 루트에서 특정 노드까지의 경로가 하나의 단어 또는 접두사(prefix)를 형성합니다. 자동 완성, 사전 검색, IP 라우팅 등에 사용됩니다.

트라이 예시 (apple, app, bat, bar 저장)
(root) / \ [a] [b] | | [p] [a] | / \ [p] [t] [r] | ● ● [l] | [e] ● ● = 단어의 끝 표시 "app" 검색: root → a → p → p (●) → 존재! "api" 검색: root → a → p → i (없음) → 미존재

17.2 트라이 구현

Python
class TrieNode:
    def __init__(self):
        self.children = {}       # 자식 노드 (문자 → TrieNode)
        self.is_end = False     # 단어의 끝인지 표시


class Trie:
    def __init__(self):
        self.root = TrieNode()
    
    def insert(self, word):
        """단어 삽입 - O(m), m = 단어 길이"""
        node = self.root
        for ch in word:
            if ch not in node.children:
                node.children[ch] = TrieNode()
            node = node.children[ch]
        node.is_end = True
    
    def search(self, word):
        """정확한 단어 검색 - O(m)"""
        node = self._find_node(word)
        return node is not None and node.is_end
    
    def starts_with(self, prefix):
        """접두사 존재 여부 - O(m)"""
        return self._find_node(prefix) is not None
    
    def _find_node(self, prefix):
        node = self.root
        for ch in prefix:
            if ch not in node.children:
                return None
            node = node.children[ch]
        return node


# 사용 예시
trie = Trie()
for w in ["apple", "app", "bat", "bar"]:
    trie.insert(w)

print(trie.search("app"))        # True
print(trie.search("ap"))         # False (단어의 끝이 아님)
print(trie.starts_with("ap"))    # True (접두사로는 존재)
print(trie.starts_with("cat"))   # False
트라이의 복잡도 : 삽입·검색 모두 O(m)(m = 문자열 길이)으로, 저장된 문자열 개수(n)에 영향을 받지 않습니다. 해시 테이블도 O(m)이지만, 트라이는 접두사 검색이 가능하다는 점에서 차별됩니다. 단, 메모리를 많이 사용하는 것이 단점입니다.
Chapter 18

유니온-파인드 (Union-Find / Disjoint Set)

18.1 유니온-파인드란?

유니온-파인드는 서로소 집합(Disjoint Set)을 관리하는 자료구조입니다. 여러 원소가 어떤 그룹에 속해 있는지를 빠르게 확인하고, 두 그룹을 합치는 연산을 효율적으로 수행합니다. "이 두 사람이 같은 친구 그룹인가?"를 판단하는 것과 같습니다.

18.2 핵심 연산

연산설명최적화 후 복잡도
find(x)x가 속한 집합의 대표(루트)를 찾음거의 O(1) (역 아커만 함수)
union(x, y)x와 y가 속한 두 집합을 합침거의 O(1)

18.3 구현 (경로 압축 + 랭크 최적화)

Python
class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))  # 처음엔 각자 자기 자신이 대표
        self.rank = [0] * n              # 트리 높이 (최적화용)
    
    def find(self, x):
        """경로 압축: 루트까지 가면서 모든 노드를 루트에 직접 연결"""
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])
        return self.parent[x]
    
    def union(self, x, y):
        """랭크 기반 합치기: 낮은 트리를 높은 트리 아래에"""
        rx, ry = self.find(x), self.find(y)
        if rx == ry:
            return False    # 이미 같은 집합
        if self.rank[rx] < self.rank[ry]:
            rx, ry = ry, rx
        self.parent[ry] = rx
        if self.rank[rx] == self.rank[ry]:
            self.rank[rx] += 1
        return True
    
    def connected(self, x, y):
        """같은 집합인지 확인"""
        return self.find(x) == self.find(y)


# 사용 예시: 5명의 친구 관계
uf = UnionFind(5)   # 0, 1, 2, 3, 4

uf.union(0, 1)       # {0,1} {2} {3} {4}
uf.union(2, 3)       # {0,1} {2,3} {4}
print(uf.connected(0, 1))  # True
print(uf.connected(0, 3))  # False

uf.union(1, 3)       # {0,1,2,3} {4}
print(uf.connected(0, 3))  # True (이제 같은 그룹)
활용 사례 : 크루스칼 최소 신장 트리 알고리즘, 네트워크 연결 여부 확인, 이미지 영역 분류, 사이클 검출 등에 사용됩니다. 코딩 테스트에서도 자주 출제되는 핵심 자료구조입니다.
Chapter 19

실전 문제 풀이

지금까지 배운 자료구조를 활용하여 실전 문제를 풀어봅니다. 각 문제에서 어떤 자료구조를 왜 선택했는지에 집중하세요.

19.1 유효한 괄호 문자열 (스택)

Python
# 문제: 괄호 (), {}, []로만 이루어진 문자열이 올바른지 판단
# 자료구조 선택: 스택 (여는 괄호 push, 닫는 괄호에서 pop하여 짝 확인)

def is_valid(s):
    stack = []
    pair = {')': '(', '}': '{', ']': '['}
    for ch in s:
        if ch in pair.values():
            stack.append(ch)
        elif ch in pair:
            if not stack or stack.pop() != pair[ch]:
                return False
    return len(stack) == 0

print(is_valid("()[]{}"))   # True
print(is_valid("([)]"))     # False
print(is_valid("{[]}"))     # True

19.2 BFS로 최단 거리 찾기 (큐 + 그래프)

Python
# 문제: 미로에서 (0,0)부터 (n-1,m-1)까지 최단 거리
# 자료구조 선택: 큐(BFS) - 비가중 그래프 최단 경로 보장

from collections import deque

def shortest_path(maze):
    rows, cols = len(maze), len(maze[0])
    if maze[0][0] == 1 or maze[rows-1][cols-1] == 1:
        return -1
    
    queue = deque([(0, 0, 1)])   # (행, 열, 거리)
    visited = {(0, 0)}
    directions = [(0,1), (0,-1), (1,0), (-1,0)]
    
    while queue:
        r, c, dist = queue.popleft()
        if r == rows-1 and c == cols-1:
            return dist
        
        for dr, dc in directions:
            nr, nc = r+dr, c+dc
            if (0 <= nr < rows and 0 <= nc < cols
                    and maze[nr][nc] == 0
                    and (nr, nc) not in visited):
                visited.add((nr, nc))
                queue.append((nr, nc, dist+1))
    
    return -1

maze = [
    [0, 0, 0, 1],
    [1, 0, 1, 0],
    [0, 0, 0, 0],
    [0, 1, 1, 0]
]
print(shortest_path(maze))  # 7

19.3 Top K 빈출 원소 (해시 테이블 + 힙)

Python
# 문제: 배열에서 가장 자주 등장하는 k개 원소 반환
# 자료구조: dict(빈도 세기) + heapq(Top-K 추출)

import heapq
from collections import Counter

def top_k_frequent(nums, k):
    count = Counter(nums)            # 빈도 세기 O(n)
    return heapq.nlargest(k, count.keys(), key=count.get)

print(top_k_frequent([1,1,1,2,2,3], 2))  # [1, 2]

19.4 자료구조 선택 가이드

상황추천 자료구조이유
순서대로 처리FIFO 보장
되돌리기 (Undo)스택LIFO 보장
빠른 검색 (키-값)해시 테이블O(1) 평균
정렬된 상태 유지BST / 힙삽입 후에도 정렬 유지
최대/최소 빠른 접근O(1) 확인, O(log n) 삽입/삭제
최단 경로 (비가중)큐 (BFS)레벨별 탐색 = 최단 보장
연결 그룹 확인유니온-파인드거의 O(1)에 그룹 확인·합치기
접두사 검색트라이O(m)에 접두사 매칭
잦은 중간 삽입/삭제연결 리스트O(1) 삽입/삭제 (위치 알 때)
Chapter 20

부록 - 자료구조 치트시트 & 학습 로드맵

20.1 자료구조 핵심 치트시트

치트시트
/* =============================================
   자료구조 치트시트 - 핵심 요약
   ============================================= */

/* ── 1. 배열 (Array) ── */
접근: O(1)  |  탐색: O(n)  |  삽입/삭제(끝): O(1)  |  삽입/삭제(중간): O(n)
→ 인덱스 접근이 많을 때, 순차 데이터에 적합

/* ── 2. 연결 리스트 (Linked List) ── */
접근: O(n)  |  탐색: O(n)  |  삽입/삭제(위치 알 때): O(1)
→ 잦은 삽입/삭제, 크기가 동적으로 변할 때

/* ── 3. 스택 (Stack) ── */
push/pop/peek: O(1)  |  LIFO (후입선출)
→ 괄호 검사, DFS, 되돌리기, 수식 계산

/* ── 4. 큐 (Queue) ── */
enqueue/dequeue/peek: O(1)  |  FIFO (선입선출)
→ BFS, 프로세스 스케줄링, 버퍼

/* ── 5. 해시 테이블 (Hash Table) ── */
삽입/탐색/삭제: O(1) 평균, O(n) 최악
→ 빈도 세기, 중복 확인, 캐시, 딕셔너리

/* ── 6. 이진 탐색 트리 (BST) ── */
삽입/탐색/삭제: O(log n) 평균, O(n) 최악(편향)
중위 순회 = 정렬된 순서
→ 정렬된 데이터 관리, 범위 탐색

/* ── 7. 힙 (Heap) ── */
최대/최소 확인: O(1)  |  삽입/삭제: O(log n)
→ 우선순위 큐, Top-K, 힙 정렬

/* ── 8. 그래프 (Graph) ── */
인접 리스트: 공간 O(V+E), 탐색 O(V+E)
인접 행렬: 공간 O(V²), 연결 확인 O(1)
BFS: 큐 사용, 최단 경로  |  DFS: 스택/재귀, 경로 탐색

/* ── 9. 트라이 (Trie) ── */
삽입/탐색: O(m), m = 문자열 길이
→ 자동 완성, 접두사 검색, 사전

/* ── 10. 유니온-파인드 (Union-Find) ── */
find/union: 거의 O(1) (경로 압축 + 랭크)
→ 집합 관리, 연결 여부, 크루스칼 MST

/* ── 정렬 알고리즘 ── */
버블/선택/삽입: O(n²) — 교육용
병합 정렬: O(n log n), 안정, 공간 O(n)
퀵 정렬: O(n log n) 평균, 실무에서 빠름
파이썬 sorted(): Timsort, O(n log n), 안정

/* ── Big-O 성장 순서 ── */
O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(2ⁿ) < O(n!)

20.2 자주 하는 실수 & 해결법

실수증상해결법
재귀 기저 사례 누락무한 재귀 → 스택 오버플로if 조건으로 종료 지점 반드시 명시
배열 인덱스 초과IndexError / Segfault범위 검사 (0 ≤ i < len)
방문 체크 누락 (BFS/DFS)무한 루프, 중복 방문visited 집합 사용
리스트 pop(0)으로 큐 구현O(n) → 시간 초과collections.deque의 popleft() 사용
이진 탐색에서 정렬 확인 안 함잘못된 결과반드시 정렬 후 이진 탐색 적용
해시 충돌 무시데이터 손실체이닝 또는 개방 주소법 구현
2차원 배열 얕은 복사한 행 변경 시 전체 변경리스트 컴프리헨션으로 생성
BST에 정렬된 데이터 삽입O(n) 편향 트리균형 트리 사용 또는 랜덤 삽입

20.3 학습 로드맵

단계기간학습 내용이 튜토리얼
Level 1 입문1~2주배열, 연결 리스트, Big-OCh.1 ~ Ch.4
Level 2 기초2~4주스택, 큐, 덱, 재귀Ch.5 ~ Ch.8
Level 3 중급1~2개월트리, BST, 힙, 해시 테이블Ch.9 ~ Ch.12
Level 4 심화2~3개월그래프, BFS/DFS, 정렬, 탐색Ch.13 ~ Ch.16
Level 5 고급3~6개월트라이, 유니온-파인드, 세그먼트 트리, DPCh.17 ~ Ch.18 + 추가 학습

20.4 마치며

축하합니다! 전 20장의 자료구조론 완전정복 튜토리얼을 모두 마치셨습니다.

자료구조는 프로그래밍의 뼈대입니다. 어떤 언어를 사용하든, 어떤 분야에서 일하든, 자료구조를 이해하는 개발자와 그렇지 않은 개발자 사이에는 큰 차이가 있습니다. 이 튜토리얼에서 배운 각 자료구조의 장단점과 적용 상황을 기억하고, 문제를 만날 때마다 "어떤 자료구조가 적합할까?"를 먼저 생각하는 습관을 들이세요.

자료구조 학습의 핵심 3원칙

1. 직접 구현하세요 — 배열, 연결 리스트, 스택, 큐, BST, 해시 테이블을 최소 한 번은 밑바닥부터 직접 구현해 보세요. 내부 동작을 이해해야 올바르게 활용할 수 있습니다.

2. 시간 복잡도를 항상 의식하세요 — 코드를 작성할 때 "이 연산은 O(몇)이지?"를 항상 생각하세요. 이 습관이 효율적인 프로그래머와 그렇지 않은 프로그래머를 구분합니다.

3. 문제를 많이 풀어보세요 — 백준(BOJ), 프로그래머스, LeetCode 등에서 자료구조별 문제를 꾸준히 풀어보세요. 이론만으로는 실력이 늘지 않습니다. 문제를 풀다 보면 "아, 이건 스택이구나!" "이건 BFS구나!" 하는 감이 자연스럽게 생깁니다.

자료구조의 세계는 넓고 깊습니다. 이 튜토리얼이 여러분의 자료구조 여정에 든든한 첫걸음이 되기를 바랍니다!