PHpullh

PYTHON · 심층 가이드

Python 컬렉션 완전 정리

딕셔너리의 실제 동작부터 Counter·deque·heapq·bisect, itertools 조합, collections.abc 기반 커스텀 컨테이너까지 자료구조 선택 기준을 실무 관점으로 정리합니다.

주제 18개 · 예제 코드 포함 · 최종 수정 2026-08-30 · 작성 pullh 편집팀

자료구조를 고르는 일은 대부분 복잡도 표가 아니라 접근 패턴의 문제입니다. Python의 list는 끝에서 추가·삭제할 때만 빠르고 앞에서 빼면 매번 전체를 밀어야 하므로, 큐가 필요하면 deque가 답입니다. dict는 3.7부터 삽입 순서 유지가 언어 명세로 보장되어 OrderedDict의 역할이 크게 줄었습니다. 다만 키는 해시 가능해야 하므로 리스트는 키가 될 수 없고, 대신 튜플이나 frozenset을 씁니다.

읽는 순서는 밀도 순이 좋습니다. 딕셔너리 — 완전 정복으로 기본기를 다진 뒤 딕셔너리 컴프리헨션ChainMap으로 조합·병합 기법을 확장합니다. 성능이 필요한 구간은 heapq 힙 큐bisect 이진 검색이 담당하는데, 둘 다 정렬 상태를 전제로 하므로 정렬 알고리즘과 함께 읽어야 의미가 붙습니다. itertools 활용은 반복 처리의 재료 창고이고, collections.abc커스텀 컨테이너는 만든 자료구조가 내장 타입처럼 보이게 만드는 마무리입니다.

이터레이터는 한 번 쓰면 비어 버립니다. map, filter, zip, itertools의 결과를 변수에 담아 두 번 순회하면 두 번째 루프는 조용히 아무것도 하지 않습니다. 여러 번 볼 값이면 리스트로 굳히고, itertools.tee는 소비 간격만큼 내부 버퍼가 쌓인다는 점을 감안해야 합니다. heapq도 최소 힙만 제공하므로 최대 힙은 값의 부호를 뒤집거나 (-priority, item) 튜플을 넣는 관용구를 씁니다. 이때 우선순위가 같으면 두 번째 원소끼리 비교되므로 비교 불가한 객체를 넣으면 터집니다.

01딕셔너리 — 완전 정복

Python에서 가장 많이 쓰는 자료구조. 생성, 조작, 컴프리헨션, 병합까지.

Python code

# 딕셔너리 생성
d1 = {"name": "Alice", "age": 30}
d2 = dict(name="Bob", age=25)
d3 = dict.fromkeys(["a", "b", "c"], 0)  # {'a':0,'b':0,'c':0}

# 접근 & 안전한 읽기
print(d1["name"])              # Alice
print(d1.get("email", "N/A")) # N/A (KeyError 없음)

# 수정
d1["email"] = "alice@test.com"
d1.update({"age": 31, "city": "Seoul"})

# setdefault — 없으면 설정하고 반환
d1.setdefault("score", 100)

# 순회
for key in d1:          print(key)
for val in d1.values(): print(val)
for k, v in d1.items(): print(f"{k}: {v}")

# 딕셔너리 병합 (Python 3.9+)
merged = d1 | d2      # 새 딕셔너리
d1 |= {"extra": True} # 인플레이스 병합

# 딕셔너리 컴프리헨션
squares = {x: x**2 for x in range(1, 6)}
inverted = {v: k for k, v in squares.items()}

# 중첩 딕셔너리 안전 접근
from collections import defaultdict
word_count = defaultdict(int)
for word in "python is great and python is easy".split():
    word_count[word] += 1
print(dict(sorted(word_count.items(), key=lambda x: -x[1])))
알아두면 좋은 점

Python 3.7+에서 딕셔너리는 삽입 순서를 보장합니다. 순서 있는 딕셔너리가 필요할 때 별도 OrderedDict는 대부분 불필요합니다.

자주 하는 실수

존재하지 않는 키에 d["key"]로 접근하면 KeyError입니다. 불확실한 키는 항상 d.get("key", default)를 사용하세요.

02collections 모듈 — Counter, deque, namedtuple

표준 라이브러리의 강력한 컬렉션 타입을 활용합니다.

Python code

from collections import Counter, deque, namedtuple, OrderedDict

# Counter — 빈도 계산
text = "python programming is fun and python is easy"
word_count = Counter(text.split())
print(word_count.most_common(3))
# [('python', 2), ('is', 2), ('programming', 1)]

c1 = Counter("apple")
c2 = Counter("pear")
print(c1 + c2)   # Counter({'p':2,'a':2,'e':1,'l':1,'r':1})
print(c1 & c2)   # 교집합

# deque — 양방향 큐 (O(1) 추가/삭제)
dq = deque([1, 2, 3], maxlen=5)
dq.appendleft(0)    # [0, 1, 2, 3]
dq.append(4)        # [0, 1, 2, 3, 4]
dq.rotate(1)        # [4, 0, 1, 2, 3]

# namedtuple — 이름 있는 튜플
Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
print(p.x, p.y)     # 3 4
print(p._asdict())  # {'x': 3, 'y': 4}

# typing.NamedTuple — 타입 힌트 버전 (권장)
from typing import NamedTuple

class Employee(NamedTuple):
    name: str
    dept: str
    salary: float = 50000.0

emp = Employee("Alice", "Engineering")
print(emp)   # Employee(name='Alice', dept='Engineering', salary=50000.0)
알아두면 좋은 점

deque는 리스트 앞에 원소를 추가할 때 O(n)인 리스트와 달리 O(1)입니다. 큐나 최근 N개 항목 유지에 사용하세요.

자주 하는 실수

Counter에서 없는 키를 조회하면 KeyError 대신 0을 반환합니다. 이 점이 일반 딕셔너리와 다릅니다.

03httpx 비동기 HTTP 클라이언트

requests 대체, async with AsyncClient 패턴

Python code

<span class="cm">// httpx 비동기 HTTP 클라이언트 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("httpx 비동기 HTTP 클라이언트") }
알아두면 좋은 점

PYTHON 공식 문서를 함께 참고하세요.

자주 하는 실수

자주 발생하는 실수에 주의하세요.

04itertools 활용

itertoolschain, product, permutations 등으로 효율적인 이터레이션을 수행합니다.

Python code

from itertools import chain, product, permutations, combinations

# chain: 여러 이터러블 연결
a = [1, 2, 3]
b = [4, 5, 6]
print(list(chain(a, b)))  # [1, 2, 3, 4, 5, 6]

# product: 데카르트 곱
colors = ["빨강", "파랑"]
sizes = ["S", "M", "L"]
for color, size in product(colors, sizes):
    print(f"  {color}-{size}", end="")
print()

# permutations: 순열
print(list(permutations([1, 2, 3], 2)))
# [(1,2), (1,3), (2,1), (2,3), (3,1), (3,2)]

# combinations: 조합
print(list(combinations([1, 2, 3, 4], 2)))
# [(1,2), (1,3), (1,4), (2,3), (2,4), (3,4)]

# accumulate: 누적 연산
from itertools import accumulate
import operator
data = [1, 2, 3, 4, 5]
print(list(accumulate(data)))                  # 누적 합
print(list(accumulate(data, operator.mul)))    # 누적 곱
알아두면 좋은 점

itertools 함수는 지연 평가(lazy)되므로 메모리 효율적입니다. 큰 데이터셋에 적합합니다.

자주 하는 실수

permutationscombinations는 큰 입력에 대해 결과가 폭발적으로 증가합니다. n=20이면 순열은 약 2.4×10¹⁸개입니다.

05collections.abc

collections.abc로 커스텀 컬렉션이 올바른 인터페이스를 구현하는지 보장합니다.

Python code

from collections.abc import (
    Iterable, Iterator, Sequence, MutableSequence, Mapping
)

# 타입 체크
print(isinstance([1, 2], Sequence))      # True
print(isinstance({1: 2}, Mapping))       # True
print(isinstance(range(10), Sequence))   # True

# 커스텀 Sequence 구현
class FibSequence(Sequence):
    def __init__(self, n: int):
        self._data = []
        a, b = 0, 1
        for _ in range(n):
            self._data.append(a)
            a, b = b, a + b

    def __getitem__(self, index):
        return self._data[index]

    def __len__(self):
        return len(self._data)

fib = FibSequence(10)
print(list(fib))         # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
print(5 in fib)          # True (자동 구현됨)
print(fib.index(8))      # 6 (자동 구현됨)
print(fib.count(1))      # 2 (자동 구현됨)
print(fib[2:5])          # [1, 2, 3]
알아두면 좋은 점

ABC를 상속하면 필수 메서드를 구현하지 않았을 때 인스턴스 생성 시점에 에러가 발생하여 버그를 조기에 발견합니다.

자주 하는 실수

__getitem__만 구현하면 이터러블처럼 동작하지만, Sequence ABC를 상속하면 __len__도 필수입니다.

06커스텀 컨테이너

매직 메서드를 구현하여 내장 컬렉션처럼 동작하는 커스텀 컨테이너를 만듭니다.

Python code

class LimitedStack:
    """크기 제한이 있는 스택"""
    def __init__(self, maxsize: int = 10):
        self._items: list = []
        self._maxsize = maxsize

    def push(self, item):
        if len(self) >= self._maxsize:
            raise OverflowError(f"스택 최대 크기({self._maxsize}) 초과")
        self._items.append(item)

    def pop(self):
        if not self:
            raise IndexError("빈 스택")
        return self._items.pop()

    def __len__(self):
        return len(self._items)

    def __bool__(self):
        return len(self._items) > 0

    def __contains__(self, item):
        return item in self._items

    def __iter__(self):
        return reversed(self._items)

    def __repr__(self):
        return f"LimitedStack({self._items})"

stack = LimitedStack(5)
for i in range(5):
    stack.push(i)

print(stack)           # LimitedStack([0, 1, 2, 3, 4])
print(3 in stack)      # True
print(len(stack))      # 5
print(list(stack))     # [4, 3, 2, 1, 0]
print(stack.pop())     # 4
알아두면 좋은 점

__bool__을 구현하면 if stack:으로 빈 컨테이너를 자연스럽게 검사할 수 있습니다.

자주 하는 실수

__len__ 없이 __bool__만 구현하면 len() 호출 시 TypeError가 발생합니다.

07리스트 컴프리헨션 심화

중첩 루프, 조건부 표현식, walrus 연산자를 활용한 고급 리스트 컴프리헨션 패턴입니다.

Python code

# 중첩 루프
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flat = [x for row in matrix for x in row]
print(flat)  # [1, 2, 3, 4, 5, 6, 7, 8, 9]

# 행렬 전치
transposed = [[row[i] for row in matrix] for i in range(3)]
print(transposed)  # [[1, 4, 7], [2, 5, 8], [3, 6, 9]]

# 조건부 표현식 (if-else)
nums = range(-5, 6)
labels = ["양수" if n > 0 else "0" if n == 0 else "음수"
          for n in nums]
print(labels)

# walrus 연산자로 중복 계산 방지
import math
data = [2, 7, 15, 3, 25, 8]
results = [(x, y) for x in data
           if (y := math.sqrt(x)) > 2.5]
print(results)  # [(7, 2.6457...), (15, 3.8729...), (25, 5.0), (8, 2.8284...)]

# 중첩 vs 제너레이터 (메모리 효율)
big_flat = [x for row in range(1000)
            for x in range(1000)]  # 메모리 사용 큼
# 제너레이터가 더 효율적
big_gen = (x for row in range(1000)
           for x in range(1000))
알아두면 좋은 점

컴프리헨션이 3줄 이상이면 일반 for 루프가 더 읽기 좋습니다. 가독성을 우선하세요.

자주 하는 실수

중첩 컴프리헨션의 루프 순서는 일반 for 루프와 같습니다. 바깥 루프가 먼저, 안쪽 루프가 나중입니다.

08딕셔너리 컴프리헨션

딕셔너리 컴프리헨션으로 딕셔너리를 간결하게 생성하고 변환합니다.

Python code

# 기본 딕셔너리 컴프리헨션
squares = {x: x**2 for x in range(1, 6)}
print(squares)  # {1: 1, 2: 4, 3: 9, 4: 16, 5: 25}

# 키-값 뒤집기
inv = {v: k for k, v in squares.items()}
print(inv)  # {1: 1, 4: 2, 9: 3, 16: 4, 25: 5}

# 조건부 필터링
scores = {"Alice": 85, "Bob": 62, "Carol": 91, "Dave": 45}
passed = {k: v for k, v in scores.items() if v >= 70}
print(passed)  # {'Alice': 85, 'Carol': 91}

# 중첩 딕셔너리 변환
students = {
    "Alice": {"math": 90, "eng": 85},
    "Bob": {"math": 70, "eng": 92},
}
averages = {name: sum(s.values()) / len(s)
            for name, s in students.items()}
print(averages)  # {'Alice': 87.5, 'Bob': 81.0}

# zip으로 두 리스트를 딕셔너리로
keys = ["name", "age", "city"]
values = ["Alice", 30, "서울"]
info = {k: v for k, v in zip(keys, values)}
print(info)  # {'name': 'Alice', 'age': 30, 'city': '서울'}
알아두면 좋은 점

두 리스트를 딕셔너리로 변환할 때 dict(zip(keys, values))가 컴프리헨션보다 간결합니다.

자주 하는 실수

값이 중복되는 키-값 뒤집기에서는 마지막 값만 남습니다. 모든 값을 보존하려면 defaultdict(list)를 사용하세요.

09제너레이터 표현식

제너레이터 표현식으로 메모리 효율적인 지연 평가 이터레이터를 만듭니다.

Python code

# 제너레이터 표현식 vs 리스트 컴프리헨션
import sys

list_comp = [x**2 for x in range(10000)]
gen_expr = (x**2 for x in range(10000))

print(f"리스트: {sys.getsizeof(list_comp):,} bytes")
print(f"제너레이터: {sys.getsizeof(gen_expr)} bytes")

# 함수 인자로 직접 전달 (괄호 생략 가능)
total = sum(x**2 for x in range(100))
print(f"제곱의 합: {total}")

exists = any(x > 50 for x in [10, 20, 60, 30])
print(f"50 초과 존재: {exists}")  # True

# 체이닝
lines = ["  hello  ", "", "  world  ", "  ", "python"]
cleaned = list(
    filter(None, (line.strip() for line in lines))
)
print(cleaned)  # ['hello', 'world', 'python']

# 대용량 파일 처리 패턴
# total_size = sum(
#     os.path.getsize(f)
#     for f in glob.glob("**/*.py", recursive=True)
# )
알아두면 좋은 점

함수의 유일한 인자가 제너레이터 표현식이면 바깥 괄호를 생략할 수 있습니다: sum(x for x in data).

자주 하는 실수

제너레이터는 한 번만 순회 가능합니다. 여러 번 순회하려면 리스트로 변환하거나 새 제너레이터를 생성하세요.

10heapq 힙 큐

heapq로 우선순위 큐를 구현합니다. 최솟값/최댓값을 O(log n)에 추출합니다.

Python code

import heapq

# 기본 힙 연산
data = [5, 3, 8, 1, 9, 2]
heapq.heapify(data)  # 리스트를 힙으로 변환 O(n)
print(data)  # [1, 3, 2, 5, 9, 8]

# 삽입과 추출
heapq.heappush(data, 0)
print(heapq.heappop(data))   # 0 (최솟값)
print(heapq.heappop(data))   # 1

# nlargest / nsmallest
scores = [85, 92, 78, 95, 88, 76, 91]
print(heapq.nlargest(3, scores))   # [95, 92, 91]
print(heapq.nsmallest(3, scores))  # [76, 78, 85]

# 우선순위 큐 구현
class PriorityQueue:
    def __init__(self):
        self._heap = []
        self._counter = 0

    def push(self, priority: int, item):
        heapq.heappush(self._heap, (priority, self._counter, item))
        self._counter += 1

    def pop(self):
        return heapq.heappop(self._heap)[2]

pq = PriorityQueue()
pq.push(2, "낮은 우선순위")
pq.push(0, "높은 우선순위")
pq.push(1, "중간 우선순위")
print(pq.pop())  # 높은 우선순위
알아두면 좋은 점

heapq는 최소 힙만 지원합니다. 최대 힙이 필요하면 값에 -1을 곱하세요.

자주 하는 실수

heapq에서 튜플 비교 시 첫 번째 요소가 같으면 두 번째 요소를 비교합니다. 비교 불가능한 객체면 에러가 발생하므로 카운터를 추가하세요.

11bisect 이진 검색

bisect로 정렬된 리스트에서 이진 검색과 삽입을 O(log n)에 수행합니다.

Python code

import bisect

# 정렬된 리스트에 삽입 위치 찾기
data = [10, 20, 30, 40, 50]
pos = bisect.bisect(data, 25)
print(f"25의 삽입 위치: {pos}")  # 2

# insort: 삽입 위치에 바로 추가
bisect.insort(data, 25)
print(data)  # [10, 20, 25, 30, 40, 50]

# 성적 등급 매기기
def grade(score):
    breakpoints = [60, 70, 80, 90]
    grades = "FDCBA"
    return grades[bisect.bisect(breakpoints, score)]

scores = [33, 60, 77, 85, 92, 100]
for s in scores:
    print(f"  {s}점 → {grade(s)}등급")

# bisect_left vs bisect_right
arr = [1, 3, 3, 3, 5, 7]
print(bisect.bisect_left(arr, 3))   # 1 (왼쪽)
print(bisect.bisect_right(arr, 3))  # 4 (오른쪽)

# 정렬된 리스트에서 값 검색
def binary_search(arr, target):
    i = bisect.bisect_left(arr, target)
    if i < len(arr) and arr[i] == target:
        return i
    return -1

print(binary_search(arr, 3))   # 1
print(binary_search(arr, 4))   # -1
알아두면 좋은 점

bisect_leftbisect_right의 차이는 동일한 값이 있을 때 왼쪽/오른쪽에 삽입하는지입니다.

자주 하는 실수

bisect는 정렬된 리스트에서만 올바르게 동작합니다. 정렬되지 않은 리스트에서 사용하면 잘못된 결과가 나옵니다.

12deque 활용

deque는 양끝에서 O(1)으로 삽입/삭제가 가능한 양방향 큐입니다.

Python code

from collections import deque

# 기본 사용
dq = deque([1, 2, 3, 4, 5])
dq.appendleft(0)      # 왼쪽 추가
dq.append(6)           # 오른쪽 추가
print(dq)  # deque([0, 1, 2, 3, 4, 5, 6])

dq.popleft()           # 왼쪽 제거 O(1)
dq.pop()               # 오른쪽 제거 O(1)

# 회전
dq.rotate(2)           # 오른쪽으로 2칸
print(dq)
dq.rotate(-2)          # 왼쪽으로 2칸

# maxlen: 고정 크기 버퍼 (슬라이딩 윈도우)
buffer = deque(maxlen=3)
for i in range(5):
    buffer.append(i)
    print(f"  추가 {i}: {list(buffer)}")
# 마지막: [2, 3, 4]

# 이동 평균 계산
def moving_avg(data, window=3):
    buf = deque(maxlen=window)
    results = []
    for val in data:
        buf.append(val)
        results.append(sum(buf) / len(buf))
    return results

prices = [100, 102, 104, 103, 105, 107]
print(moving_avg(prices, 3))
알아두면 좋은 점

maxlen 설정된 deque는 가득 차면 반대쪽 원소를 자동으로 제거하여 슬라이딩 윈도우에 적합합니다.

자주 하는 실수

deque의 인덱스 접근은 O(n)입니다. 랜덤 접근이 빈번하면 리스트를 사용하세요.

13ChainMap

ChainMap으로 여러 딕셔너리를 하나로 연결하여 계층적 설정을 관리합니다.

Python code

from collections import ChainMap

# 계층적 설정
defaults = {"color": "red", "size": 10, "font": "Arial"}
user_prefs = {"color": "blue", "font": "Helvetica"}
cli_args = {"color": "green"}

config = ChainMap(cli_args, user_prefs, defaults)
print(config["color"])  # green (가장 앞쪽 우선)
print(config["size"])   # 10 (defaults에서 가져옴)
print(config["font"])   # Helvetica

# 모든 키 조회
print(list(config.keys()))

# 새 레이어 추가
session = config.new_child({"size": 20})
print(session["size"])    # 20
print(session["color"])   # green
print(session.parents["size"])  # 10

# maps로 모든 레이어 접근
for i, m in enumerate(config.maps):
    print(f"  레이어 {i}: {m}")

# 변경은 첫 번째 맵에만 적용
config["new_key"] = "value"
print(cli_args)  # {'color': 'green', 'new_key': 'value'}
알아두면 좋은 점

ChainMap은 딕셔너리를 복사하지 않으므로 원본 딕셔너리가 변경되면 즉시 반영됩니다.

자주 하는 실수

ChainMap에 값을 설정하면 항상 첫 번째 맵에만 추가됩니다. 특정 레이어를 수정하려면 .maps[n]에 직접 접근하세요.

14불변 컬렉션

Python에서 불변 컬렉션을 활용하여 안전한 데이터 구조를 만드는 방법입니다.

Python code

from types import MappingProxyType
from dataclasses import dataclass, field

# MappingProxyType: 읽기 전용 딕셔너리 뷰
original = {"a": 1, "b": 2, "c": 3}
readonly = MappingProxyType(original)
print(readonly["a"])      # 1
# readonly["a"] = 10      # TypeError!

# 원본 변경은 뷰에 반영됨
original["d"] = 4
print(readonly["d"])      # 4

# 불변 데이터클래스
@dataclass(frozen=True)
class Config:
    host: str
    port: int
    tags: tuple[str, ...] = ()  # 불변 시퀀스

    def with_port(self, new_port: int) -> "Config":
        """변경된 복사본 반환"""
        return Config(self.host, new_port, self.tags)

cfg = Config("localhost", 8080, ("web", "api"))
# cfg.port = 9090  # FrozenInstanceError

cfg2 = cfg.with_port(9090)
print(f"{cfg.port} → {cfg2.port}")  # 8080 → 9090

# 해시 가능 (딕셔너리 키로 사용 가능)
cache = {cfg: "기본 설정", cfg2: "새 설정"}
print(cache[cfg])  # 기본 설정
알아두면 좋은 점

불변 데이터클래스는 해시 가능하므로 딕셔너리 키나 세트 원소로 사용할 수 있습니다.

자주 하는 실수

frozen=True 데이터클래스에 mutable 필드(리스트)를 넣으면 외부에서 내용을 변경할 수 있습니다. 튜플이나 frozenset을 사용하세요.

15정렬 알고리즘

Python의 sorted()list.sort()의 고급 사용법과 커스텀 정렬 기법입니다.

Python code

from operator import attrgetter, itemgetter

# 다중 키 정렬
students = [
    {"name": "Alice", "grade": 3, "score": 85},
    {"name": "Bob", "grade": 2, "score": 92},
    {"name": "Carol", "grade": 3, "score": 92},
    {"name": "Dave", "grade": 2, "score": 85},
]

# 학년 오름차순 → 점수 내림차순
result = sorted(students, key=lambda s: (s["grade"], -s["score"]))
for s in result:
    print(f"  {s['grade']}학년 {s['name']}: {s['score']}점")

# operator 모듈 (더 빠름)
by_score = sorted(students, key=itemgetter("score"), reverse=True)

# 안정 정렬 활용 (Timsort)
# 여러 키로 정렬 시 덜 중요한 키부터 정렬
data = [(1, "b"), (2, "a"), (1, "a"), (2, "b")]
data.sort(key=lambda x: x[1])  # 먼저 문자 정렬
data.sort(key=lambda x: x[0])  # 그 다음 숫자 정렬
print(data)  # [(1, 'a'), (1, 'b'), (2, 'a'), (2, 'b')]

# functools.cmp_to_key (복잡한 비교)
from functools import cmp_to_key
words = ["banana", "Apple", "cherry"]
result = sorted(words, key=cmp_to_key(
    lambda a, b: (a.lower() > b.lower()) - (a.lower() < b.lower())
))
print(result)  # ['Apple', 'banana', 'cherry']
알아두면 좋은 점

Python의 Timsort는 안정 정렬이므로 같은 키의 원소 순서가 보존됩니다. 이를 활용해 다단계 정렬이 가능합니다.

자주 하는 실수

list.sort()는 원본을 변경하고 None을 반환합니다. sorted()는 새 리스트를 반환합니다.

16이진 검색

이진 검색 알고리즘을 직접 구현하고 bisect과 비교합니다.

Python code

import bisect
from typing import Optional

def binary_search(arr: list, target) -> Optional[int]:
    """이진 검색: O(log n)"""
    left, right = 0, len(arr) - 1
    while left <= right:
        mid = (left + right) // 2
        if arr[mid] == target:
            return mid
        elif arr[mid] < target:
            left = mid + 1
        else:
            right = mid - 1
    return None

# 하한/상한 검색
def lower_bound(arr, target):
    """target 이상인 첫 번째 위치"""
    return bisect.bisect_left(arr, target)

def upper_bound(arr, target):
    """target 초과인 첫 번째 위치"""
    return bisect.bisect_right(arr, target)

data = [1, 3, 3, 3, 5, 7, 9]
print(f"검색 3: 인덱스={binary_search(data, 3)}")
print(f"하한 3: {lower_bound(data, 3)}")  # 1
print(f"상한 3: {upper_bound(data, 3)}")  # 4
print(f"3의 개수: {upper_bound(data, 3) - lower_bound(data, 3)}")  # 3

# 조건 기반 이진 검색
def find_min_satisfying(lo, hi, predicate):
    while lo < hi:
        mid = (lo + hi) // 2
        if predicate(mid):
            hi = mid
        else:
            lo = mid + 1
    return lo

# x^2 >= 100인 최소 x
result = find_min_satisfying(0, 100, lambda x: x * x >= 100)
print(f"x²≥100인 최소 x: {result}")  # 10
알아두면 좋은 점

조건 기반 이진 검색(parametric search)은 최적화 문제를 O(log n)으로 풀 수 있는 강력한 기법입니다.

자주 하는 실수

(left + right) // 2는 매우 큰 수에서 오버플로 가능합니다. Python은 임의 정밀도이므로 문제없지만, 다른 언어에서는 left + (right - left) // 2를 사용합니다.

17트리와 그래프

Python으로 트리와 그래프 자료구조를 구현하고 순회하는 방법입니다.

Python code

from collections import defaultdict, deque

# 이진 트리
class TreeNode:
    def __init__(self, val, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

# 트리 순회
def inorder(node):
    if node:
        yield from inorder(node.left)
        yield node.val
        yield from inorder(node.right)

root = TreeNode(4,
    TreeNode(2, TreeNode(1), TreeNode(3)),
    TreeNode(6, TreeNode(5), TreeNode(7)))

print("중위:", list(inorder(root)))  # [1, 2, 3, 4, 5, 6, 7]

# 그래프 (인접 리스트)
graph = defaultdict(list)
edges = [("A","B"), ("A","C"), ("B","D"), ("C","D"), ("D","E")]
for u, v in edges:
    graph[u].append(v)
    graph[v].append(u)

# BFS
def bfs(graph, start):
    visited = {start}
    queue = deque([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

print("BFS:", bfs(graph, "A"))  # ['A', 'B', 'C', 'D', 'E']
알아두면 좋은 점

yield from을 사용한 트리 순회는 재귀보다 메모리 효율적입니다.

자주 하는 실수

그래프에서 visited 체크를 빠뜨리면 사이클이 있을 때 무한 루프에 빠집니다.

18해시 테이블 구현

Python dict의 기반인 해시 테이블을 직접 구현하여 내부 동작 원리를 이해합니다.

Python code

class HashTable:
    def __init__(self, size=16):
        self._size = size
        self._buckets: list[list] = [[] for _ in range(size)]
        self._count = 0

    def _hash(self, key) -> int:
        return hash(key) % self._size

    def __setitem__(self, key, value):
        bucket = self._buckets[self._hash(key)]
        for i, (k, v) in enumerate(bucket):
            if k == key:
                bucket[i] = (key, value)
                return
        bucket.append((key, value))
        self._count += 1

    def __getitem__(self, key):
        bucket = self._buckets[self._hash(key)]
        for k, v in bucket:
            if k == key:
                return v
        raise KeyError(key)

    def __contains__(self, key):
        try:
            self[key]
            return True
        except KeyError:
            return False

    def __len__(self):
        return self._count

ht = HashTable()
ht["name"] = "Alice"
ht["age"] = 30
ht["city"] = "서울"
print(ht["name"])       # Alice
print("age" in ht)      # True
print(len(ht))          # 3
알아두면 좋은 점

실제 Python dict는 오픈 어드레싱(open addressing)을 사용하지만, 이 예제는 이해하기 쉬운 체이닝(chaining) 방식입니다.

자주 하는 실수

해시 충돌이 많으면 O(1) → O(n)으로 성능이 저하됩니다. 적절한 해시 함수와 리사이징 전략이 필요합니다.

정리하며

  • 앞쪽에서 넣고 빼는 큐라면 listpop(0) 대신 deque를 씁니다.
  • map·filter·itertools 결과는 일회용입니다. 재사용할 값은 리스트로 굳힙니다.
  • 최대 힙은 heapq에 음수 우선순위나 (-p, item) 튜플을 넣어 구현합니다.
  • 딕셔너리 키에는 튜플·frozenset 같은 해시 가능한 불변 타입만 넣습니다.

더 깊이 들어가고 싶다면 Python 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.