본문으로 건너뛰기
← 블로그로 돌아가기
CS 기초>튜토리얼
Python자료구조딕셔너리

파이썬 공부 Day 3: 튜플·딕셔너리·집합같은 중괄호인데 딕셔너리가 되기도, 집합이 되기도 합니다

9분 읽기

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에서 이어집니다.

// RELATED

관련 포스트