파이썬 공부 Day 3: 튜플·딕셔너리·집합같은 중괄호인데 딕셔너리가 되기도, 집합이 되기도 합니다
Day 2에서 리스트를 배웠고, 오늘은 그 친척 셋을 만났습니다. 소괄호를 쓰는 튜플, 중괄호에 키와 값을 짝지어 넣는 딕셔너리, 같은 중괄호에 값만 넣는 집합입니다.
C를 먼저 배운 입장에서 튜플은 익숙했고 딕셔너리는 부러웠습니다. 문법은 금방 외웠습니다. 막힌 곳은 실습이었습니다.
📦 튜플: 못 바꾸는 리스트
튜플은 리스트처럼 값을 순서대로 담습니다. 차이는 하나입니다. 한 번 만들면 요소를 바꿀 수 없습니다(불변, immutable). 인덱싱, 슬라이싱, +로 잇기, *로 반복하기는 리스트와 똑같이 됩니다.
t = (1, [2, 3])
t[1].append(4) # 안의 리스트는 고칠 수 있다
print(t) # (1, [2, 3, 4])
t[0] = 10 # 의도한 에러: TypeError
칸을 다른 값으로 갈아 끼우려 하면 'tuple' object does not support item assignment가 납니다. 그런데 칸에 든 리스트의 내용은 바뀝니다. 튜플이 막는 건 칸을 바꾸는 일뿐입니다. 꼭 고쳐야 한다면 list()로 바꿔 고친 뒤 tuple()로 되돌립니다.
콤마 하나가 갈림길
t = 1, 2, 3 # 괄호를 빼도 튜플
print(type(t)) # <class 'tuple'>
a = (90) # 괄호만 있으면 그냥 숫자
b = (90,) # 콤마가 있어야 튜플
print(type(a)) # <class 'int'>
print(type(b)) # <class 'tuple'>
(90)이 정수라는 게 처음엔 어색했습니다. 생각해 보면 C에서도 (90)은 괄호를 친 90일 뿐입니다. 파이썬은 괄호가 아니라 콤마를 보고 튜플을 만듭니다(빈 튜플 ()만 예외).
언패킹: 스왑이 한 줄
a, b = 10, 20
a, b = b, a # 스왑
print(a, b) # 20 10
q, r = divmod(45, 20) # (몫, 나머지)
print(q, r) # 2 5
C에서 스왑을 하려면 temp 변수를 두고 세 줄을 썼습니다. 파이썬은 오른쪽 b, a가 튜플로 묶였다가 풀리면서 한 줄로 끝납니다. divmod()가 몫과 나머지를 같이 돌려주는 것도 사실은 튜플 하나를 돌려주는 겁니다.
🗂️ 딕셔너리: 키로 찾는 상자
딕셔너리는 키(key)로 값(value)을 찾습니다. 전화번호부에서 이름으로 번호를 찾는 것과 같습니다. 위치(인덱스)라는 개념이 없어서 인덱싱·슬라이싱이 안 되고, 무조건 키로 조회합니다.
C였다면 해시 테이블을 직접 짜야 했을 구조가 중괄호 한 쌍으로 끝납니다. 넣은 순서는 파이썬 3.7부터 유지됩니다.
d = {"a": 1, "a": 2}
print(d) # {'a': 2}
print(type({"a"})) # <class 'set'>
key = ("이름", "나이")
print({key: 1}) # {('이름', '나이'): 1}
bad = {["이름"]: 1} # 의도한 에러: TypeError
반드시 키: 값 쌍으로 씁니다. 키만 쓰면 딕셔너리가 아니라 집합이 됩니다. 키는 중복될 수 없고, 같은 키를 두 번 쓰면 에러 대신 마지막 값으로 덮어씁니다. 앞의 1이 사라졌는데 아무도 알려 주지 않는다는 점이 무섭습니다.
키로는 문자열, 숫자, 튜플은 되고 리스트는 안 됩니다. 리스트를 키로 넣으면 unhashable type: 'list'라는 TypeError가 납니다. unhashable은 해시값을 만들 수 없다는 뜻입니다. 정확한 조건은 "못 바꾸는 값"보다 이쪽이라, 튜플이라도 안에 리스트가 들어 있으면({(1, [2]): 3}) 같은 TypeError가 납니다.
내 실습 코드의 실수
점수표를 만들다가 이렇게 썼습니다.
score = {'홍길동: 100', '김철수: 90'}
print(type(score)) # <class 'set'>
따옴표가 키: 값 전체를 감싸 버렸습니다. 결과는 딕셔너리가 아니라 문자열 두 개가 든 집합입니다. 에러가 안 나니 출력 모양을 보기 전까지는 눈치채기 어렵습니다. 맞는 코드는 {'홍길동': 100, '김철수': 90}입니다.
꺼내기: 대괄호와 get()
st = {"name": "홍길동", "age": 20}
print(st.get("phone")) # None
print(st.get("phone", "없음")) # 없음
print("홍길동" in st) # False
print(st["phone"]) # 의도한 에러: KeyError
대괄호는 없는 키를 만나면 KeyError로 프로그램을 멈춥니다. get()은 None이나 지정한 기본값을 돌려줍니다. 키가 있을지 모르면 get()을 씁니다. in은 키만 검사해서 값인 "홍길동"은 못 찾습니다.
추가·수정·삭제, 그리고 괄호 없는 clear
st = {"name": "홍길동", "age": 20}
st["phone"] = "010-0000-0000" # 추가
st["age"] = 21 # 수정
del st["name"] # 삭제
print(st)
# {'age': 21, 'phone': '010-0000-0000'}
추가와 수정은 문법이 같습니다. 키가 이미 있느냐 없느냐로 갈립니다.
전부 지울 때는 clear()를 씁니다. 여기서 실습 중에 한 번 당했습니다. 괄호를 빼먹고 member.clear라고 쓰면 에러도 없이 아무 일도 일어나지 않습니다.
member = {'나이': 20}
member.clear # 가리키기만 하고 호출은 안 함
print(member) # {'나이': 20}
member.clear()
print(member) # {}
C에서 func;라고만 쓰면 함수가 호출되지 않는 것과 같은 이치입니다. 차이라면 C 컴파일러는 -Wall을 켜면 경고라도 띄워 주는데, 파이썬은 끝까지 조용합니다.
개수 세기는 get(키, 0) + 1
실무와 코딩 테스트에 자주 나온다는 패턴입니다. 처음 보는 키면 0에서 시작하니 KeyError 없이 셀 수 있습니다.
count = {}
for ch in "hello":
count[ch] = count.get(ch, 0) + 1
print(count)
# {'h': 1, 'e': 1, 'l': 2, 'o': 1}
top = sorted(count.items(),
key=lambda x: x[1],
reverse=True)
print(top)
# [('l', 2), ('h', 1), ('e', 1), ('o', 1)]
센 다음에는 많이 나온 순으로 보고 싶어집니다. 딕셔너리에는 sort()가 없어서 items()의 (키, 값) 튜플을 sorted()에 넘기고 key=lambda x: x[1]로 값을 기준으로 삼습니다. lambda는 함수를 다루는 Day 4에서 다시 봅니다. 세기 자체는 표준 라이브러리 collections.Counter가 한 줄로 해 줍니다.
딕셔너리 실습에서 헷갈린 것들
개념은 알겠는데 손이 안 따라 준 부분입니다. 대부분은 리스트에서 쓰던 손버릇을 딕셔너리에 그대로 들고 와서 생긴 문제였습니다. 딕셔너리 메서드 전체와 리스트 문법 비교는 파이썬 치트시트에 모아 뒀습니다.
values()를 빼먹으면 키가 담긴다
"한 번에 여러 값 할당하기" 문제였습니다. 리스트는 그냥 언패킹하면 위치 순서대로 담깁니다. 딕셔너리를 같은 식으로 풀면 값이 아니라 키가 들어갑니다.
animals = ['사자', '코끼리', '하마']
a1, a2, a3 = animals
print(a1) # 사자
d = {'v1': 32, 'l1': [1, 2, 3]}
i1, i2 = d
print(i1, i2) # v1 l1
i1, i2 = d.values()
print(i2[-1]) # 3
list(d.values())처럼 리스트로 감쌀 필요는 없습니다. 언패킹만으로 충분합니다.
append는 리스트 메서드
딕셔너리에 append를 쓰면 AttributeError가 납니다. 값이 리스트라면 그 리스트를 꺼내서 붙입니다.
d = {'l1': [1, 2, 3]}
d['l1'].append(4)
print(d) # {'l1': [1, 2, 3, 4]}
d.append(4) # 의도한 에러: AttributeError
평균: len()으로 나누고 :.2f로 찍기
점수 개수를 3으로 고정해 두고 나눴다가 틀렸습니다. 점수가 하나 늘면 바로 어긋납니다. 리스트에 /=를 바로 쓰는 것도 안 됩니다.
score = [92, 96, 98, 100]
print(sum(score) / 3)
# 128.66666666666666 (4개인데 3으로 나눔)
print(sum(score) / len(score)) # 96.5
score /= 3 # 의도한 에러: TypeError
자릿수도 헷갈렸습니다. round(88.0, 2)는 88.0이라 소수점 아래 0을 채워 주지 않습니다. 항상 두 자리로 보이게 하려면 f-string의 :.2f를 씁니다.
avg = 88.0
print(round(avg, 2)) # 88.0
print(f'{avg:.2f}') # 88.00
stu = {'name': '홍길동',
'score': [92, 96, 98]}
avg = sum(stu['score']) / len(stu['score'])
print(f"{stu['name']} {avg:.2f}")
# 홍길동 95.33
출력 쪽에서 헷갈린 점은 이렇습니다.
{}가 값을 넣는 자리입니다. 빼먹으면 글자 그대로 찍힙니다{avg:.2f}의 콜론은 값과 형식을 나누는 기호입니다.stu['name']처럼 키로 꺼낼 때는 콜론이 없습니다- 바깥을
f"로 열었으면 안쪽 키는'name'처럼 작은따옴표로 씁니다 (파이썬 3.12 이전에는 같은 따옴표를 쓰면 문법 오류입니다) \n은 따옴표 안에 있어야 줄바꿈입니다.print(a \n)처럼 밖에 쓰면 SyntaxError가 납니다
셀만 따로 돌리면 NameError
노트북 셀은 위에서 실행한 변수를 공유합니다. 그래서 dict_a를 만든 앞 셀을 건너뛰고 중간 셀 하나만 돌리면 NameError: name 'dict_a' is not defined가 납니다. 그 셀 안에 변수를 다시 적거나, 맨 위 셀부터 순서대로 실행하면 해결됩니다.
🧮 집합: 중복 없는 주머니
집합(set)은 중복을 허용하지 않고 순서도 없습니다. 수학의 집합처럼 교집합·합집합·차집합을 계산하려고 만든 자료형입니다. 요소 규칙은 딕셔너리 키와 같아서 리스트는 넣을 수 없습니다.
print(type({})) # <class 'dict'>
empty = set()
hello = set("잘지내지요")
print(len(hello)) # 4
print(sorted(hello))
# ['내', '요', '잘', '지']
중괄호를 같이 쓰다 보니 빈 {}는 딕셔너리 차지입니다. 빈 집합은 set()으로 만듭니다. set("잘지내지요")는 '지'가 두 번이라 4개만 남습니다. 그대로 출력하면 순서가 제멋대로이고 실행할 때마다 달라질 수도 있어서, 위에서는 sorted()로 줄을 세워 찍었습니다.
집합 연산
f1 = {'피자', '치킨', '위스키', '초밥'}
f2 = {'초밥', '커리', '위스키'}
print(sorted(f1 & f2)) # ['위스키', '초밥']
print(sorted(f1 - f2)) # ['치킨', '피자']
print(sorted(f1 ^ f2))
# ['치킨', '커리', '피자']
print(len(f1 | f2)) # 5
&는 교집합, -는 차집합, ^는 한쪽에만 있는 것, |는 합집합입니다. 기호 대신 intersection() 같은 메서드를 써도 결과는 같습니다. 부분집합 검사까지 넣은 연산·메서드 전체 목록은 파이썬 치트시트에 정리했습니다.
remove와 discard
추가는 append가 아니라 add입니다. 값을 골라 지울 때는 remove와 discard 두 가지를 씁니다.
s = {1, 2, 3}
s.add(4)
s.discard(100) # 없어도 조용히 넘어감
print(s) # {1, 2, 3, 4}
s.remove(100) # 의도한 에러: KeyError
차이는 딕셔너리의 대괄호와 get() 관계와 닮았습니다. 없을 때 멈추느냐, 넘어가느냐입니다.
중복 제거: 순서까지 지키려면
리스트를 set으로 바꿨다가 다시 리스트로 돌리면 중복이 빠집니다. 다만 list(set(nums))는 작은 정수에서 정렬된 것처럼 보여도 순서를 보장하지 않습니다. 원래 순서를 지킨 채 중복만 빼려면 dict.fromkeys()를 씁니다.
nums = [3, 1, 3, 2, 1]
print(sorted(set(nums))) # [1, 2, 3]
print(list(dict.fromkeys(nums)))
# [3, 1, 2]
키는 중복될 수 없고 넣은 순서는 유지된다는 딕셔너리 성질을 그대로 빌린 방법입니다.
네 자료형 한눈에 비교
| 자료형 | 모양 | 특징 |
|---|---|---|
| 리스트 | [1, 2] | 순서 O, 수정 O |
| 튜플 | (1, 2) | 순서 O, 수정 X |
| 딕셔너리 | {'a': 1} | 키로 조회, 키 중복 X |
| 집합 | {1, 2} | 순서 X, 중복 X |
| 이럴 때 | 이걸로 |
|---|---|
| 순서가 중요하고 값을 계속 바꿔야 함 | 리스트 |
좌표 (x, y)처럼 한 묶음이고 바뀌면 안 됨 | 튜플 |
| 방문 여부, 중복 제거처럼 있는지만 빠르게 확인 | 집합 |
| 이름이나 번호로 값을 찾거나 개수를 셈 | 딕셔너리 |
빈 것 만들기와 시간 복잡도까지 넣은 전체 비교표는 파이썬 치트시트에 있습니다.
집합과 딕셔너리가 빠른 이유
둘 다 해시값으로 자리를 바로 찾아가서, 개수가 늘어도 in 검사 속도가 거의 그대로입니다(평균 O(1)). "평균"이 붙는 건 해시값이 겹치면 느려질 수 있어서입니다. 리스트의 in은 앞에서부터 하나씩 비교하니 O(n)입니다. in을 여러 번 돌릴 거라면 리스트를 한 번 set으로 바꿔 두는 편이 빠릅니다.
서로 변환하기
리스트·튜플·집합 사이는 앞에서 list(), tuple(), set()으로 오갔습니다. 남은 건 딕셔너리로 가는 길과, 집합에 무엇을 넣을 수 있느냐입니다.
keys, vals = "abc", [1, 2, 3]
print(dict(zip(keys, vals)))
# {'a': 1, 'b': 2, 'c': 3}
visited = {(1, 2)} # 좌표는 튜플로
print((1, 2) in visited) # True
visited.add([3, 4]) # 의도한 에러: TypeError
zip()은 두 묶음을 같은 위치끼리 짝지어 주고, dict()가 그 짝을 키와 값으로 받습니다. 리스트를 집합에 못 넣는 것도 해시값을 만들 수 없어서입니다. 방문한 좌표를 모을 때는 리스트가 아니라 튜플로 넣습니다.
튜플은 못 바꾸는 리스트입니다. 딕셔너리는 키로 찾는 상자이고, 키:값 쌍이 필수이며 키는 중복될 수 없습니다. 집합은 중복 없는 주머니라 순서가 없고 교집합·합집합·차집합을 계산합니다.
오늘 가장 오래 붙잡은 건 문법이 아니라 손버릇이었습니다. 리스트에서 쓰던 append와 위치로 꺼내던 습관이 딕셔너리에서 그대로 에러가 됐습니다. 지금 같은 실습을 다시 푼다면 print(type(x))부터 찍어 보겠습니다. 집합인 줄 모르고 딕셔너리처럼 다루던 실수는 그 한 줄이면 바로 보입니다.
다음은 제어문(if·while·for)입니다. Day 4에서 이어집니다.