개발/공부 기록

[파이썬 기초 문법 총정리 #2] 자료구조 4총사 완전 정복 — List, Tuple, Set, Dict (Ch.12~15)

cedis 2026. 3. 8. 01:47

 


들어가며

C에서 배열을 쓸 때는 크기를 미리 정해야 했다. int arr[100]; 같은 식으로.
파이썬을 처음 보면 리스트, 튜플, 셋, 딕셔너리가 전부 비슷하게 생겨 보여서 "뭘 언제 써야 하지?" 라는 생각이 든다.

크래프톤 정글 2주차 W3Schools 12~15챕터를 직접 쳐보면서 각각 언제 쓰는 건지 감이 잡혔다.
이번 글은 그 4가지 자료구조를 C 출신 시선으로 비교 정리한 것이다.

시리즈 개요: W3Schools 파이썬 튜토리얼 1~25챕터를 공부한 내용을 묶어 정리합니다.
이번 편은 챕터 12~15: 자료구조 4총사 — List, Tuple, Set, Dictionary

한눈에 보는 4총사 비교

자료구조 기호 순서 변경 가능 중복 허용 주 용도
List [ ] O O O 일반 배열 대체, 순서 있는 컬렉션
Tuple ( ) O X O 변하면 안 되는 값, 함수 다중 반환
Set { } X O X 중복 제거, 집합 연산, O(1) 검색
Dictionary {k: v} O (3.7+) O 키: X key-value 매핑, 해시맵

1. List — C 배열보다 훨씬 편하다 (Ch.12)

파이썬 리스트는 C 배열과 비슷하지만 훨씬 강력하다. 크기를 미리 안 정해도 되고, 서로 다른 타입을 섞어도 되고, 중간에 삽입·삭제가 자유롭다.
알고리즘 풀 때 가장 많이 쓰게 되는 자료구조다.

기본 생성과 인덱싱

# 리스트 생성 — 대괄호, 타입 혼합 가능
fruits = ["apple", "banana", "cherry"]
mixed  = [1, "hello", True, 3.14]  # 타입 혼합도 OK

# 인덱싱 — 0부터 시작, 음수는 뒤에서부터
print(fruits[0])   # apple
print(fruits[-1])  # cherry — 마지막 요소

# 슬라이싱 — [시작:끝:간격], 끝은 미포함
print(fruits[1:3])   # ['banana', 'cherry']
print(fruits[::2])   # ['apple', 'cherry'] — 2칸씩 건너뜀
print(fruits[::-1])  # ['cherry', 'banana', 'apple'] — 리스트 뒤집기

추가·삽입·삭제

lst = ["a", "b", "c"]

# 끝에 추가
lst.append("d")        # ['a', 'b', 'c', 'd']

# 중간에 삽입 — insert(위치, 값)
lst.insert(1, "x")      # ['a', 'x', 'b', 'c', 'd']

# 다른 리스트 통째로 붙이기
lst.extend(["e", "f"])  # ['a', 'x', 'b', 'c', 'd', 'e', 'f']

# 삭제 — remove(값), pop(인덱스)
lst.remove("x")        # 값으로 첫 번째 일치 항목 제거
lst.pop()              # 마지막 요소 제거 + 반환
lst.pop(0)             # 인덱스 0 제거 + 반환
del lst[1]             # 인덱스 1 삭제 (반환값 없음)
lst.clear()            # 리스트 비우기 (변수는 살아있음)

헷갈리는 포인트: remove()으로 찾아 지우고, pop()인덱스로 찾아 지운다. del은 변수 자체도 지울 수 있다 (del lst).

리스트 컴프리헨션 — 파이썬의 꽃

C에서 배열을 초기화하거나 필터링할 때 for 루프를 길게 써야 했다. 파이썬은 이걸 한 줄로 압축한다.
처음엔 낯설어 보이지만 익숙해지면 코드가 훨씬 짧고 읽기 좋아진다.

# 기본 문법: [표현식 for 변수 in 이터러블 if 조건]

# 1) 1~9 제곱 리스트
squares = [x**2 for x in range(1, 10)]
# [1, 4, 9, 16, 25, 36, 49, 64, 81]

# 2) "a"가 포함된 과일만 필터링
fruits = ["apple", "banana", "cherry", "mango"]
result = [x for x in fruits if "a" in x]
# ['apple', 'banana', 'mango']

# 3) 조건부 표현식 (삼항연산자처럼)
result = ["짝수" if x % 2 == 0 else "홀수" for x in range(1, 6)]
# ['홀수', '짝수', '홀수', '짝수', '홀수']

# 4) 2차원 배열 초기화 (C에서 int arr[3][4] 하던 것)
matrix = [[0] * 4 for _ in range(3)]
# [[0,0,0,0], [0,0,0,0], [0,0,0,0]]

주의: [[0]*4]*3은 같은 리스트 객체를 3번 참조하므로 한 행을 바꾸면 전체가 바뀐다. 2D 배열은 컴프리헨션으로 초기화해야 안전하다.

정렬과 복사

nums = [3, 1, 4, 1, 5, 9]

# sort() — 원본 변경 (in-place)
nums.sort()             # [1, 1, 3, 4, 5, 9]
nums.sort(reverse=True)  # [9, 5, 4, 3, 1, 1]

# sorted() — 원본 유지, 새 리스트 반환
new = sorted(nums)       # 원본 nums는 그대로

# 복사 — 얕은 복사 (Shallow Copy)
copy1 = nums.copy()     # 메서드
copy2 = list(nums)      # 생성자
copy3 = nums[:]         # 슬라이싱

# 주의: copy2 = nums 는 참조 복사 — 같은 객체를 가리킴!


2. Tuple — 변경 불가 리스트, 언제 쓰나? (Ch.13)

튜플은 리스트랑 거의 같은데 딱 한 가지가 다르다: 한번 만들면 수정할 수 없다.
C의 const 배열과 비슷한 개념이다.

처음엔 "그럼 리스트 쓰면 되지 왜 튜플이 있어?"라고 생각했는데, 쓰다 보니 명확히 구분이 된다.

기본 사용법

# 튜플 생성 — 소괄호
t = ("apple", "banana", "cherry")
print(t[0])   # apple
print(t[-1])  # cherry

# 단일 요소 튜플 — 반드시 쉼표가 필요!
one = ("apple",)   # 튜플
not_tuple = ("apple")  # 이건 그냥 문자열!

# 수정 시도 → TypeError
# t[0] = "mango"   → TypeError: 'tuple' object does not support item assignment

# 수정이 필요하면 리스트로 변환 후 다시 튜플로
lst = list(t)
lst[0] = "mango"
t = tuple(lst)   # ('mango', 'banana', 'cherry')

언패킹 — 튜플의 진짜 활용

튜플의 가장 강력한 기능은 언패킹이다. 함수에서 여러 값을 동시에 반환할 때 특히 많이 쓴다.

# 기본 언패킹 — 변수 개수가 요소 개수와 같아야 함
fruits = ("apple", "banana", "cherry")
a, b, c = fruits
print(a)  # apple

# *rest — 나머지를 리스트로 받기
first, *rest = fruits
print(first)  # apple
print(rest)   # ['banana', 'cherry']

# 함수에서 여러 값 반환 — 사실 튜플을 반환하는 것
def minmax(lst):
    return min(lst), max(lst)

lo, hi = minmax([3, 1, 4, 1, 5])
print(lo, hi)  # 1 5

# 변수 교환 — C에서 temp 변수 필요했던 것
x, y = 10, 20
x, y = y, x   # 한 줄로 swap!

튜플을 쓰는 이유: 딕셔너리의 키로 쓸 수 있다 (리스트는 불가). 함수 반환값이 고정되어 있음을 명시적으로 나타낼 수 있다. 변경을 원치 않는 설정값에 쓴다.


3. Set — 중복 제거와 집합 연산 (Ch.14)

C에는 집합 자료구조가 기본으로 없다. 비슷한 걸 구현하려면 bool 배열을 쓰거나 직접 만들어야 했다.
파이썬 Set은 중복을 자동으로 제거하고 교집합·합집합 같은 수학적 집합 연산을 지원한다.
그리고 핵심은 검색이 O(1)이라는 것 — 해시 테이블 기반이기 때문이다.

기본 사용법과 특징

# 생성 — 중괄호. 순서 보장 없음!
s = {"apple", "banana", "cherry", "apple"}
print(s)   # {'apple', 'banana', 'cherry'} — 중복 apple 제거됨

# 빈 셋은 반드시 set()으로! {} 는 빈 딕셔너리다
empty = set()

# 리스트 중복 제거에 활용
nums = [1, 2, 2, 3, 3, 3]
unique = list(set(nums))   # [1, 2, 3]

# in 연산자 — O(1) 검색 (리스트는 O(n))
if "apple" in s:
    print("있다")

# True==1, False==0 이므로 중복으로 처리됨
s2 = {1, 2, 3, True}   # True는 1과 같으므로 {1, 2, 3}

집합 연산 — 진짜 편리한 부분

a = {1, 2, 3, 4}
b = {3, 4, 5, 6}

# 합집합 (union) — a 또는 b에 있는 것
print(a | b)           # {1, 2, 3, 4, 5, 6}
print(a.union(b))      # 동일

# 교집합 (intersection) — 둘 다 있는 것
print(a & b)                   # {3, 4}
print(a.intersection(b))       # 동일

# 차집합 (difference) — a에만 있는 것
print(a - b)                   # {1, 2}
print(a.difference(b))         # 동일

# 대칭 차집합 — 한쪽에만 있는 것 (교집합 제외)
print(a ^ b)                            # {1, 2, 5, 6}
print(a.symmetric_difference(b))        # 동일

추가/제거 — remove vs discard

s = {"apple", "banana", "cherry"}

s.add("mango")       # 단일 추가
s.update(["grape", "kiwi"])  # 여러 개 추가

s.remove("apple")    # 없으면 KeyError 발생
s.discard("apple")   # 없어도 에러 없음 (안전한 제거)

실전 팁: 존재 여부가 불확실할 때는 remove() 대신 discard()를 쓰자. 에러 처리 코드를 줄일 수 있다.


4. Dictionary — 파이썬의 해시맵 (Ch.15)

C로 해시맵을 직접 구현하면 꽤 복잡하다. 파이썬에서는 딕셔너리가 그 역할을 기본으로 해준다.
key: value 쌍으로 데이터를 저장하고, key로 O(1) 검색이 가능하다. Python 3.7부터는 삽입 순서도 보장된다.

기본 사용법

# 생성
car = {"brand": "Ford", "model": "Mustang", "year": 1964}

# 접근 방법 2가지
print(car["brand"])         # Ford — 키 없으면 KeyError
print(car.get("color"))      # None — 키 없어도 에러 없음
print(car.get("color", "N/A"))  # N/A — 기본값 지정 가능

# 추가/수정 — 같은 문법
car["color"] = "red"           # 새 키 추가
car["year"] = 2024            # 기존 키 수정
car.update({"model": "F-150"})  # 여러 키 한번에 수정

# 삭제
car.pop("color")    # 해당 키 삭제 + 반환
car.popitem()      # 마지막 삽입 항목 삭제
del car["model"]    # 키 삭제
car.clear()        # 전체 비우기

순회 — keys(), values(), items()

person = {"name": "철수", "age": 25, "city": "서울"}

# 키만 순회
for k in person.keys():
    print(k)      # name, age, city

# 값만 순회
for v in person.values():
    print(v)      # 철수, 25, 서울

# 키-값 쌍으로 순회 — items()가 가장 많이 쓰임
for k, v in person.items():
    print(f"{k}: {v}")   # name: 철수, age: 25, city: 서울

# 키 존재 확인
if "name" in person:
    print("있다")

실전 패턴 — 빈도 카운트

알고리즘 문제에서 딕셔너리가 가장 많이 쓰이는 패턴 중 하나는 빈도 카운트다.

# 문자 빈도 카운트
text = "mississippi"
freq = {}
for ch in text:
    freq[ch] = freq.get(ch, 0) + 1
print(freq)
# {'m': 1, 'i': 4, 's': 4, 'p': 2}

# collections.Counter 로 한 줄로 동일한 결과
from collections import Counter
freq2 = Counter(text)   # Counter({'i': 4, 's': 4, 'p': 2, 'm': 1})

# 중첩 딕셔너리
family = {
    "child1": {"name": "Emily", "age": 10},
    "child2": {"name": "Tom",   "age": 8},
}
print(family["child1"]["name"])  # Emily

⚡ 자료구조별 시간복잡도 성능 비교

알고리즘 문제를 풀 때 자료구조 선택이 통과/시간초과를 가른다.
같은 작업이라도 어떤 구조를 쓰느냐에 따라 O(1)과 O(n)이 갈리기 때문이다.
한눈에 외워두면 문제 풀 때 바로 써먹을 수 있다.

연산별 시간복잡도 Big-O 전체표

연산 List [ ] Tuple ( ) Set { } Dict {k:v}
인덱스 접근 a[i] O(1) O(1)
키/값 검색 x in a O(n) O(n) O(1) * O(1) *
끝에 추가 append / add O(1) — (불변) O(1) O(1)
중간 삽입 insert(i, x) O(n) — (불변) — (순서없음)
삭제 remove / pop / del O(n) — (불변) O(1) O(1)
정렬 sort() O(n log n) — (불변) — (순서없음)
전체 순회 for x in a O(n) O(n) O(n) O(n)
메모리 효율 보통 ✅ 최고 낮음 낮음

* 해시 충돌이 없는 평균 케이스. 최악의 경우(해시 충돌 연속) Set/Dict 검색도 O(n)이 될 수 있으나 실제로는 거의 발생하지 않는다.

핵심만 기억하는 카드 — "검색 속도 차이가 전부다"

🔵 List / Tuple

O(n)

x in list는 앞에서부터 하나씩 비교.
10만 개면 최대 10만 번 비교.

🟠 Set

O(1)

x in set는 해시값 계산 1번으로 끝.
10만 개든 1억 개든 속도 동일.

🟢 Dict

O(1)

d[key] 접근도 해시 기반.
key 검색, 삽입, 삭제 모두 O(1).

실제로 얼마나 차이날까? — timeit 비교

import timeit

n = 100_000
data_list = list(range(n))
data_set  = set(range(n))
data_dict = {i: i for i in range(n)}

target = n - 1   # 리스트 최악의 경우: 맨 끝 원소 검색

t_list = timeit.timeit(lambda: target in data_list, number=1000)
t_set  = timeit.timeit(lambda: target in data_set,  number=1000)
t_dict = timeit.timeit(lambda: target in data_dict, number=1000)

print(f"List : {t_list:.4f}s")   # 예) 2.1832s
print(f"Set  : {t_set:.4f}s")    # 예) 0.0001s  ← 수천 배 빠름
print(f"Dict : {t_dict:.4f}s")   # 예) 0.0001s  ← 동일

실측 결과: 10만 개 원소에서 in 검색 1,000회 반복 시
List ≈ 2~3초 / Set·Dict ≈ 0.0001초 — 약 20,000배 차이가 난다.

Tuple이 List보다 가벼운 이유 — 메모리 비교

import sys

lst = [0, 1, 2, 3, 4]
tpl = (0, 1, 2, 3, 4)

print(sys.getsizeof(lst))  # 104 bytes
print(sys.getsizeof(tpl))  # 80 bytes  ← 더 가볍다

리스트는 동적으로 크기를 늘릴 수 있어야 하기 때문에 오버알로케이션(over-allocation)을 한다.
미리 여유 공간을 잡아놓는 것이다. 반면 튜플은 고정 크기이므로 딱 필요한 만큼만 차지한다.
수백만 개의 좌표처럼 변하지 않는 대량 데이터라면 Tuple이 메모리 측면에서 훨씬 유리하다.

내부 구현 — 왜 그런 성능이 나오나?

자료구조 내부 구현 그래서 이런 성능
List 동적 배열 (Dynamic Array)
메모리 연속 할당 + 오버알로케이션
인덱스 O(1), 검색 O(n),
append 평균 O(1), insert O(n)
Tuple 고정 배열 (Fixed Array)
생성 후 메모리 변경 없음
인덱스 O(1), 검색 O(n),
메모리 효율 최고, 생성 속도 빠름
Set 해시 테이블 (Hash Table)
hash(x) → 버킷 직접 접근
검색·삽입·삭제 O(1),
순서 없음, 인덱스 없음
Dict 해시 테이블 (Hash Table)
key → hash → 버킷, value 저장
키 검색·삽입·삭제 O(1),
삽입 순서 보존 (Python 3.7+)

정리: List·Tuple은 배열 기반이라 인덱스 접근이 빠르고, Set·Dict는 해시 기반이라 검색·삽입·삭제가 빠르다.
알고리즘 문제에서 "이 값이 존재하는지 반복적으로 확인해야 한다"면 리스트 대신 Set/Dict로 바꾸는 것만으로도 통과 여부가 갈릴 수 있다.


어떤 자료구조를 언제 써야 할까?

처음엔 리스트만 쓰게 된다. 그런데 문제를 풀다 보면 성능 차이가 생긴다. 상황에 맞게 골라서 써야 한다.

상황 추천 이유
순서 있는 데이터, 수정 필요 List append/remove 자유, 인덱스 접근
변하면 안 되는 값 (좌표, RGB, 설정값) Tuple 불변이라 dict 키로 사용 가능, 의도 명시
중복 제거, 빠른 존재 확인 필요 Set O(1) 검색, 자동 중복 제거
key → value 매핑, 빈도 카운트 Dict O(1) 검색, 의미 있는 키
두 집합의 공통 원소 찾기 Set intersection() 한 줄로 해결
함수에서 여러 값 반환 Tuple 언패킹으로 변수에 바로 받기

마무리

처음엔 "리스트 하나면 되지 않나?"라는 생각이었다. 근데 직접 써보면서 각각의 쓰임새가 점점 명확해졌다.

특히 Set의 O(1) 검색과 집합 연산, Dictionary의 get() 기본값 패턴, 리스트 컴프리헨션은
알고리즘 문제를 풀 때 코드를 확 줄여주는 핵심 도구가 됐다.

다음 편은 Ch.16~18: 조건문, while, for 루프 다. 파이썬 루프에서도 C와 다른 것들이 꽤 있다.

네 가지를 다 외우려 하지 말고, 언제 어떤 걸 쓰는지 감을 잡는 게 먼저다.

크래프톤 정글 2주차 | W3Schools Python Ch.12~15 | 정리: choihyunjin1

#크래프톤정글 #파이썬자료구조 #리스트컴프리헨션 #딕셔너리 #Python