C-3: 그래프 표현 — 정점을 자유롭게 잇고, 두 방법으로 담는다
목차 27
안녕하세요, 홍순구 튜터입니다. 지난 시간 우리는 완전 이진트리를 배열 한 장에 담아 "가장 급한 것"을 O(log n)에 꺼내는 힙을 만들었습니다. 그리고 이런 말을 남겨 뒀죠. "트리는 사실 사이클 없이 연결된 특수한 그래프다. 그 제약을 풀면 그래프가 된다." 오늘 그 제약을 풀어 봅니다.
그래프(graph)는 코딩테스트에서 가장 자주 나오는 주제 중 하나입니다. 미로 탈출, 섬의 개수, 친구 추천, 최단 경로 — 겉보기엔 다 달라 보이지만 속을 열어 보면 전부 "정점과 간선으로 이뤄진 그래프 문제"예요. 그래서 그래프를 컴퓨터에 어떻게 담느냐가 이 뒤에 이어질 탐색 알고리즘 전체의 발판이 됩니다.
오늘 핵심은 딱 두 가지입니다. 그래프를 담는 두 방법 — 인접 행렬과 인접 리스트 — 을 직접 짜 보고, "언제 무엇을 쓸지"를 메모리와 시간의 트레이드오프로 고르는 겁니다.
오늘의 여정 — 비선형 자료구조의 마지막, 그래프
용어·종류 → 두 가지 표현 → 실전·다음으로
정점·간선·차수 인접 행렬 (공간 O(V²)) defaultdict(list)
방향/무방향·가중치 인접 리스트 (공간 O(V+E)) DFS·BFS(E-2)의 발판
💡 오늘 수업의 핵심 — "정점을 간선으로 자유롭게 이은 그래프를, 인접 행렬과 인접 리스트라는 두 방법으로 담고 트레이드오프로 고른다"
🎯 학습 목표
- 그래프 용어(정점·간선·차수)와 종류(방향/무방향·가중치)를 안다.
- 인접 행렬과 인접 리스트를 직접 구현하고, 공간·시간의 빅오 트레이드오프로 둘을 견준다.
- 실전에서
defaultdict(list)로 인접 리스트를 만들고, 다음 알고리즘(DFS·BFS)의 발판을 놓는다.
Step 1: "트리의 제약을 풀면 그래프"
먼저 우리가 어디까지 왔는지 지도를 다시 펴 봅시다. 카테고리 B에서 한 줄로 늘어선 선형 자료구조(스택·큐·연결 리스트·해시)를 배웠고, 카테고리 C에서 가지 치는 비선형 자료구조로 넘어왔어요. C-1 트리, C-2 힙, 그리고 오늘 C-3 그래프가 비선형의 마지막입니다.
선형 (한 줄로 늘어섬) 비선형 (가지 치고 이어짐)
스택·큐·연결 리스트·해시 트리 (C-1) 힙 (C-2) 그래프 (C-3, 오늘)
지난 시간까지의 트리를 떠올려 볼게요. 트리는 규칙이 엄격했습니다. 부모에서 자식으로 내려가는 한 방향이 있고, 어느 노드로 가는 길도 딱 하나뿐이었죠. 되돌아오는 사이클도 없었고요. 그래프는 이 규칙을 다 풀어 줍니다. 정점끼리 자유롭게, 서로를 가리키며 이어져요.
트리 (C-1) 그래프 (C-3)
부모 → 자식, 한 방향 정점끼리 자유롭게 서로 이음
어디로 가든 길은 하나 길이 여러 갈래·되돌아오는 사이클도
1 0 ─── 1
/ \ │ ╱ │
2 3 2 ─── 3 ─── 4
/ \
4 5
사이클 없음·연결 사이클 있음·자유로운 연결
이 자유로운 구조를 부르는 용어 세 개만 잡고 갑시다. 어렵지 않아요.
- 정점(vertex): 데이터 하나입니다. 사람, 도시, 웹페이지 같은 것들이요. 노드(node)라고도 부릅니다.
- 간선(edge): 두 정점을 잇는 선입니다. 친구 관계, 도로, 링크가 다 간선이에요.
- 차수(degree): 한 정점에 붙은 간선의 수입니다. "이 사람 친구가 몇 명인가"가 곧 차수죠.
그래프가 왜 필요할까요? 세상엔 트리처럼 딱 떨어지는 계층 관계만 있는 게 아니기 때문입니다. 회원과 게시물은 부모-자식으로 정리되지만, 친구 관계는 그렇지 않아요. A가 B의 친구이고 B가 C의 친구인데 A가 다시 C의 친구일 수 있죠. 도시 간 도로도 이리저리 얽히고, 지하철 노선도 순환합니다. 이렇게 계층이 아니라 서로 얽힌 관계를 담는 그릇이 그래프입니다.
🎯 코테에서는 문제 지문에 "정점 N개, 간선 M개가 주어진다", "도시 사이를 잇는 길", "친구 관계", "노드가 연결되어 있다" 같은 표현이 나오면 거의 그래프 문제예요. 미로·섬·네트워크·경로 문제가 전부 여기 속합니다.
💡 한 줄 정리
그래프는 정점(vertex)을 간선(edge)으로 자유롭게 이은 비선형 자료구조다. 트리의 "한 방향·사이클 없음·유일한 경로" 제약을 풀면 그래프가 되고, 계층이 아니라 서로 얽힌 관계(친구·도로·링크)를 담는다.
🙋 학생 질문 — "튜터님, 트리도 그래프라면서 왜 따로 배웠나요?"
좋은 질문이에요. 트리는 그래프의 특수한 경우가 맞습니다. 정점 N개를 사이클 없이 연결하고, 간선이 정확히 N-1개인 그래프가 트리예요.
그런데 이 제약 덕분에 트리는 그래프보다 훨씬 단순하고 빠른 연산이 가능합니다. 부모-자식 방향이 정해져 있어 "어디서 왔는지"를 따로 표시 안 해도 되고, 경로가 유일해서 두 노드 사이 길을 헤맬 일이 없죠. BST는 그 위에 "왼쪽은 작게, 오른쪽은 크게"까지 얹어 탐색을 O(log n)에 했고요.
그래서 계층 관계(조직도·폴더·BST)는 트리로 단순하게 풀고, 얽힌 관계(친구·도로)는 그래프로 일반화해 푸는 겁니다. 도구가 단순할수록 특정 문제엔 더 강해요.
Step 2: "화살표가 있나, 거리가 있나"
같은 그래프라도 종류가 나뉩니다. 문제를 읽고 "이건 어떤 종류의 그래프인가"를 판별하는 게 모델링의 첫걸음이에요. 두 가지 축만 보면 됩니다. 간선에 방향이 있나, 간선에 무게(가중치)가 있나.
먼저 방향입니다. 인스타그램을 떠올려 보세요. 장원영이 카리나를 팔로우해도, 카리나가 장원영을 팔로우한 건 아니에요. 화살표가 한쪽으로만 갑니다. 이게 방향 그래프(directed graph)예요. 반대로 "서로 친구"는 양쪽이 대등하죠. A가 B의 친구면 B도 A의 친구입니다. 이건 무방향 그래프(undirected graph)예요.
무방향 (친구 관계) 방향 (인스타 팔로우)
장원영 ─── 카리나 장원영 ──▶ 카리나
서로 친구(양쪽 대등) 장원영이 카리나를 팔로우(한쪽만)
0 ─── 1 0 ──▶ 1
다음은 가중치입니다. 지금까지 간선은 "이어졌다/아니다"만 표시했어요. 그런데 도시 사이 도로라면 거리가 다르죠. 서울-대전 140km, 대전-부산 200km처럼요. 이렇게 간선에 숫자(무게)가 붙은 게 가중치 그래프(weighted graph)입니다. 이 무게가 거리일 수도, 요금일 수도, 걸리는 시간일 수도 있어요.
가중치 (도시 간 거리)
서울 ──140── 대전 ──200── 부산
숫자 = 간선의 무게(거리·요금·시간 등)
가중치가 없는 그래프에서 최단 경로는 "간선을 가장 적게 거치는 길"이고, 가중치가 있으면 "무게 합이 가장 작은 길"이 됩니다. 앞의 것은 나중에 BFS(E-2)로, 뒤의 것은 다익스트라(F-1)로 푸는데, 오늘은 "이런 구분이 있다"까지만 알아 두면 충분해요.
🎯 코테에서는 지문이 "서로 팔로우" 같은 대칭 관계면 무방향, "A가 B에게 의존한다·선행 과목" 같은 일방 관계면 방향입니다. "거리·비용·시간"이 간선마다 다르게 주어지면 가중치 그래프고요. 이 세 단어를 지문에서 먼저 찾으세요.
💡 한 줄 정리
그래프는 간선의 방향 유무(방향/무방향)와 무게 유무(가중치/비가중치)로 갈린다. 팔로우는 방향, 친구는 무방향, 도로 거리는 가중치다. 문제 지문의 "대칭 관계·일방 관계·거리/비용" 단어가 종류를 알려 준다.
🙋 학생 질문 — "튜터님, 방향 그래프로 무방향을 표현할 수도 있나요?"
네, 그리고 실제로 구현할 때 그렇게 합니다. 무방향 간선 하나(A-B)는 방향 간선 두 개(A→B, B→A)와 똑같아요. 양쪽으로 화살표를 다 그으면 결국 대칭이 되니까요.
그래서 이따 코드에서 보겠지만, 무방향 그래프를 만들 때 간선 (u, v) 하나를 넣으면 내부적으로는 u의 이웃에 v를, v의 이웃에도 u를 함께 넣습니다. 화살표 두 개를 긋는 셈이죠.
반대로 방향 그래프를 무방향처럼 다룰 순 없어요. 정보가 한쪽뿐이라 되돌릴 수 없거든요. 그래서 "무방향은 방향의 특수한 경우(양쪽 대칭)"라고 이해하면 깔끔합니다.
Step 3: "표 한 장으로 — 인접 행렬"
이제 진짜 문제입니다. 정점과 간선을 컴퓨터에 어떻게 담을까요? 첫 번째 방법은 인접 행렬(adjacency matrix)입니다. A-2에서 다뤘던 2차원 배열(격자)을 그대로 씁니다. 격자를 기억하시죠? 그때의 grid[행][열]이 여기선 matrix[정점][정점]이 됩니다.
정점을 0번부터 번호로 매기고, V×V 크기의 표를 만듭니다. matrix[u][v]가 1이면 u와 v 사이에 간선이 있는 거예요(0이면 없음). 우리가 Step 1에서 그린 다섯 정점 그래프를 표로 옮기면 이렇게 됩니다.
간선: 0-1, 0-2, 1-2, 1-3, 2-3, 3-4 (무방향)
0 1 2 3 4
0 [ 0 1 1 0 0 ] 0의 행: 1,2 와 이어짐
1 [ 1 0 1 1 0 ] 1의 행: 0,2,3 과 이어짐
2 [ 1 1 0 1 0 ]
3 [ 0 1 1 0 1 ]
4 [ 0 0 0 1 0 ] 4의 행: 3 과만 이어짐
무방향이라 표가 대각선을 기준으로 대칭인 게 보이시나요? 0-1 간선은 matrix[0][1]과 matrix[1][0] 양쪽에 다 1로 찍힙니다. 화살표 두 개를 그은 것과 같죠. 코드로 짜 봅시다.
# structures/graph.py
class AdjacencyMatrixGraph:
def __init__(self, num_vertices):
self.n = num_vertices
# V×V 표를 0으로 채운다. 0 = 간선 없음, 그 외 값 = 간선(가중치)
self.matrix = [[0] * num_vertices for _ in range(num_vertices)]
def add_edge(self, u, v, weight=1, directed=False):
"""u-v 간선을 놓는다. 한 칸만 채우니 O(1). 무방향이면 양쪽을 대칭으로."""
self.matrix[u][v] = weight
if not directed:
self.matrix[v][u] = weight # 무방향: u→v와 v→u가 같다(대칭)
def has_edge(self, u, v):
"""u에서 v로 가는 간선이 있나. 표의 한 칸만 보면 되니 O(1)."""
return self.matrix[u][v] != 0
def neighbors(self, u):
"""u의 이웃(이어진 정점)을 번호 순으로 모은다. 한 행을 다 훑어 O(V)."""
return [v for v in range(self.n) if self.matrix[u][v] != 0]
def degree(self, u):
"""u의 차수(붙은 간선 수). 한 행을 세어 O(V)."""
return sum(1 for v in range(self.n) if self.matrix[u][v] != 0)
add_edge의 weight 인자를 보세요. 기본값 1을 넣으면 "이어졌다"만 표시하고, 다른 숫자를 넣으면 Step 2의 가중치가 그 칸에 저장됩니다. directed=True면 한쪽만, 아니면 양쪽 대칭으로 찍고요. 표 한 장에 방향·가중치까지 다 담기죠.
실제로 넣고 확인해 보면 이렇게 동작합니다.
edges = [(0, 1), (0, 2), (1, 2), (1, 3), (2, 3), (3, 4)]
mg = AdjacencyMatrixGraph(5)
for u, v in edges:
mg.add_edge(u, v)
mg.has_edge(1, 3) # True (표의 [1][3] 한 칸만 확인)
mg.has_edge(0, 4) # False (0과 4는 이어지지 않음)
mg.neighbors(1) # [0, 2, 3]
여기서 빅오를 봅시다. 이 방식의 강점은 has_edge예요. "u와 v가 이어졌나"를 표의 한 칸만 보고 답하니 O(1)입니다. 대신 약점이 있어요. 간선이 하나도 없어도 V×V 칸을 늘 차지하니 공간이 O(V²)입니다. 이웃을 모으려면 한 행 전체를 훑어야 해서 neighbors와 degree는 O(V)고요.
| 연산 | 인접 행렬 | 왜 |
|---|---|---|
간선 확인 has_edge |
O(1) | 표의 한 칸만 본다 |
이웃 순회 neighbors |
O(V) | 한 행 전체를 훑는다 |
| 공간 | O(V²) | 간선이 없어도 V×V 칸을 차지 |
🎯 코테에서는 정점 수가 적고(수백 이하) 간선이 조밀하거나, "두 정점이 이어졌나"를 아주 자주 물어야 하는 문제에서 인접 행렬이 편합니다. 플로이드-워셜 최단 경로(F-1)가 대표적으로 인접 행렬 위에서 도는 알고리즘이에요.
💡 한 줄 정리
인접 행렬은 V×V 표에 간선을 담아, 두 정점이 이어졌는지를 O(1)에 확인한다. 대신 간선이 없어도 V×V 칸을 차지해 공간이 O(V²)이고, 이웃 순회는 한 행을 훑어 O(V)다. 정점이 적고 조밀한 그래프에 어울린다.
🙋 학생 질문 — "튜터님, 정점이 번호가 아니라 이름(문자열)이면 어떻게 하나요?"
현실 문제에선 정점이 "서울·부산" 같은 이름이거나 "kuromi·pikachu" 같은 아이디인 경우가 많죠. 이럴 땐 두 가지 방법이 있어요.
첫째, 이름을 번호로 바꾸는 사전을 하나 둡니다. {"서울": 0, "부산": 1, ...} 같은 dict로 이름↔번호를 매핑해 두고, 내부에선 번호로 다루는 거예요. 입출력만 이름으로 하고 계산은 번호로 하면 인접 행렬을 그대로 쓸 수 있습니다.
둘째, 아예 dict 기반 인접 리스트로 갑니다. 다음 Step에서 볼 방식인데, 키를 이름 그대로 써서 adj["서울"] = ["대전", ...]처럼 담는 거죠. 정점에 번호를 안 매겨도 되니 이름이 많을 때 편합니다. 코테에선 이 두 번째가 흔해요.
Step 4: "이웃만 적어 — 인접 리스트"
인접 행렬의 약점은 낭비였어요. 친구가 두 명뿐인 사람도 전체 인원수만큼의 칸을 차지했죠. 두 번째 방법 인접 리스트(adjacency list)는 이 낭비를 없앱니다. 발상은 단순해요. 각 정점마다 "이 정점과 이어진 이웃 목록"만 들고 있는 겁니다.
우리 다섯 정점 그래프를 인접 리스트로 옮기면 이렇게 됩니다.
0 → [1, 2] 0은 1,2 와 이어짐
1 → [0, 2, 3] 1은 0,2,3 과 이어짐
2 → [0, 1, 3]
3 → [1, 2, 4]
4 → [3] 4는 3 과만 이어짐
인접 행렬의 5×5=25칸과 비교해 보세요. 여기선 실제 간선(양방향이라 6×2=12개)만큼만 저장합니다. 간선이 적을수록 이 차이가 커져요. 코드로 봅시다.
class AdjacencyListGraph:
def __init__(self, num_vertices):
self.n = num_vertices
self.adj = [[] for _ in range(num_vertices)] # 정점마다 이웃 목록 하나
def add_edge(self, u, v, directed=False):
"""u-v 간선을 놓는다. 목록 맨 뒤에 붙이니 O(1). 무방향이면 양쪽에 넣는다."""
self.adj[u].append(v)
if not directed:
self.adj[v].append(u) # 무방향: v의 목록에도 u를 넣는다
def neighbors(self, u):
"""u의 이웃 목록을 그대로 돌려준다. 이미 모여 있어 O(1)."""
return self.adj[u]
def has_edge(self, u, v):
"""u에서 v로 가는 간선이 있나. 이웃 목록을 뒤져 O(degree)."""
return v in self.adj[u]
def degree(self, u):
"""u의 차수. 이웃 목록 길이라 O(1)."""
return len(self.adj[u])
add_edge가 하는 일은 목록 맨 뒤에 이웃을 붙이는 것뿐이에요. 무방향이면 양쪽 목록에 서로를 넣죠. Step 2의 "무방향 = 화살표 두 개"가 여기서 append 두 번으로 나타납니다.
lg = AdjacencyListGraph(5)
for u, v in [(0, 1), (0, 2), (1, 2), (1, 3), (2, 3), (3, 4)]:
lg.add_edge(u, v)
lg.neighbors(1) # [0, 2, 3] (이미 모여 있어 바로 반환)
lg.degree(1) # 3
빅오가 인접 행렬과 거울처럼 뒤집힙니다. 공간은 실제 간선만 담아 O(V+E)예요(정점 V개 + 간선 E개). 이웃 순회는 그 정점의 목록만 보면 되니 낭비 없이 차수만큼, degree도 목록 길이라 O(1)입니다. 대신 "u-v가 이어졌나"는 목록을 처음부터 뒤져야 해서 O(degree)로, 행렬의 O(1)보다 느려요. 이게 맞바꾼 대가입니다.
| 연산 | 인접 리스트 | 인접 행렬 (Step 3) |
|---|---|---|
간선 확인 has_edge |
O(degree) | O(1) |
| 이웃 순회 | O(degree) | O(V) |
| 공간 | O(V+E) | O(V²) |
🎯 코테에서는 실전 그래프 문제의 대부분이 인접 리스트를 씁니다. 대회·채용 문제의 그래프는 보통 간선이 정점 수에 비해 적은 희소 그래프라, 공간 O(V+E)와 알뜰한 이웃 순회가 결정적이거든요. DFS·BFS·다익스트라가 전부 인접 리스트 위를 걷습니다.
💡 한 줄 정리
인접 리스트는 정점마다 이웃 목록만 들고 있어 공간이 O(V+E)로 알뜰하고, 이웃 순회가 차수만큼이라 낭비가 없다. 대신 간선 확인은 목록을 뒤져 O(degree)로 행렬보다 느리다. 간선이 적은 희소 그래프의 기본 표현이다.
🙋 학생 질문 — "튜터님, 인접 리스트는 간선 확인이 왜 느린가요?"
인접 리스트에서 has_edge(1, 3)을 물으면, 1번의 이웃 목록 [0, 2, 3]을 앞에서부터 훑어 3이 있나 찾아야 해요. 목록이 길면(차수가 크면) 그만큼 오래 걸리니 O(degree)입니다. 행렬은 matrix[1][3] 한 칸만 보면 끝이라 O(1)이었죠.
만약 간선 확인을 아주 자주 해야 하는 문제라면, 이웃을 리스트 대신 집합(set)으로 담는 절충안이 있어요. adj[1] = {0, 2, 3}처럼요. B-3에서 배운 대로 집합의 in은 평균 O(1)이니, 간선 확인이 O(1)로 빨라집니다.
물론 공짜는 아니에요. 집합은 리스트보다 메모리를 더 쓰고, 순서가 없어 "이웃을 번호 순으로" 같은 요구엔 안 맞죠. 그래서 "간선 확인이 잦다"는 신호가 뚜렷할 때만 set으로 바꿉니다. 늘 트레이드오프예요.
Step 5: "언제 표, 언제 목록?"
두 방법을 다 짜 봤으니, 오늘의 핵심 질문에 답할 차례입니다. 인접 행렬과 인접 리스트, 언제 무엇을 고를까요? 이건 암기가 아니라 빅오로 판단하는 거예요. 두 표현을 한 표에 나란히 놓고 봅시다.
| 기준 | 인접 행렬 | 인접 리스트 |
|---|---|---|
| 공간 | O(V²) | O(V+E) |
| 간선 확인 (u-v?) | ✅ O(1) | O(degree) |
| 한 정점의 이웃 순회 | O(V) | ✅ O(degree) |
| 모든 간선 순회 | O(V²) | ✅ O(V+E) |
| 어울리는 그래프 | 밀집(간선 많음) | 희소(간선 적음) |
판단의 핵심은 "간선이 얼마나 빽빽한가"예요. 정점이 V개일 때 간선은 최대 약 V²개까지 생길 수 있습니다(모든 쌍이 이어진 완전 그래프). 그런데 현실의 그래프는 대부분 그보다 훨씬 적어요. 친구가 아무리 많아도 전 세계 인구만큼은 아니잖아요. 이렇게 간선이 정점 수에 비례하는 정도(E ≈ V 수준)면 희소 그래프, V²에 가까우면 밀집 그래프라고 합니다.
여기에 A-1의 "1초 ≈ 1억 연산" 잣대를 대 봅시다. 정점이 10만 개인 그래프를 인접 행렬로 담으면 10만 × 10만 = 100억 칸이 필요해요. 이건 메모리에 아예 올릴 수가 없습니다. 반면 간선이 20만 개뿐인 희소 그래프라면 인접 리스트는 O(V+E) = 30만 정도로 거뜬하죠. 그래서 코테의 그래프 문제는 대부분 인접 리스트가 정답입니다.
정점 V = 100,000 일 때
인접 행렬 : V² = 100억 칸 → 메모리 초과, 못 올림
인접 리스트 : V + E ≈ 30만 → 거뜬함 (간선 E = 20만 가정)
⚠️ 그렇다고 인접 행렬이 쓸모없는 건 아니에요. 정점이 적고(수백 이하) 간선이 빽빽하거나, "두 정점이 이어졌나"를 매우 자주 물어야 하면 O(1) 간선 확인이 빛납니다. 플로이드-워셜(F-1)처럼 모든 쌍의 최단 거리를 다루는 알고리즘은 인접 행렬 위에서 돕니다. 정답은 하나가 아니라 "입력 크기와 연산 패턴을 보고 고르는" 거예요.
🎯 코테에서는 문제의 정점 수 제한을 먼저 보세요. V가 1000 이하로 작으면 인접 행렬도 O(V²) = 100만이라 부담 없고, V가 10만처럼 크면 무조건 인접 리스트입니다. "정점 수의 제곱이 시간·공간 안에 드나"가 첫 판단이에요.
💡 한 줄 정리
간선이 적은 희소 그래프(E ≈ V)는 인접 리스트(공간 O(V+E)), 간선이 빽빽한 밀집 그래프나 간선 확인이 잦으면 인접 행렬(간선 확인 O(1))이 유리하다. 정점 수 제한을 보고 "V²이 시간·공간 안에 드나"로 고른다. 코테는 대개 인접 리스트다.
🙋 학생 질문 — "튜터님, 그럼 실무에선 거의 인접 리스트만 쓰나요?"
코딩테스트와 대부분의 응용에선 그렇다고 봐도 됩니다. SNS 친구 관계, 도로망, 웹 링크 — 현실의 큰 그래프는 예외 없이 희소하거든요. 페이스북 사용자가 수십억이지만 한 사람의 친구는 수백 명 수준이죠. V²으로 담는 건 상상도 못 합니다.
다만 인접 행렬이 진짜 강할 때도 분명 있어요. 정점이 작고 밀집한 그래프, 예를 들어 도시 몇십 개 사이 모든 거리를 다루는 플로이드-워셜(F-1)은 인접 행렬이 자연스럽습니다. 또 간선의 존재 여부를 O(1)에 계속 물어야 하는 상황도요.
그리고 대규모 실무에선 이 둘을 넘어서기도 해요. 그래프가 너무 커서 메모리 하나에 안 들어가면 Neo4j 같은 그래프 데이터베이스나 분산 저장을 씁니다. 하지만 그 바탕 원리는 결국 오늘 배운 인접 리스트예요. 원리를 알면 어떤 도구를 만나도 읽힙니다.
Step 6: "실전은 defaultdict(list)"
Step 4에서 인접 리스트를 클래스로 직접 짜 원리를 봤어요. 이제 이 과목의 두 번째 트랙, "실전에선 어떻게 쓰나"로 갑니다. 코테에서 그래프를 만날 때 매번 클래스를 짜진 않아요. 파이썬 표준 라이브러리 collections.defaultdict로 몇 줄이면 끝납니다.
먼저 코테의 그래프 입력이 어떻게 생겼는지 봅시다. 보통 이런 식으로 간선 목록이 주어져요.
5 6 간선: 정점 5개, 간선 6개
0 1 (0-1 간선)
0 2 (0-2 간선)
1 2
1 3
2 3
3 4
이 간선 목록을 인접 리스트로 바꾸는 게 첫 일입니다. 그냥 dict를 쓰면 처음 보는 정점 키에서 KeyError가 나요. 아직 목록을 안 만들어 뒀으니까요. defaultdict(list)는 이 문제를 없앱니다. 처음 보는 키를 건드리면 빈 목록을 자동으로 만들어 줘서, 곧바로 append하면 됩니다.
from collections import defaultdict
def build_adjacency_list(edges, directed=False):
adj = defaultdict(list)
for u, v in edges:
adj[u].append(v)
if not directed:
adj[v].append(u)
return adj
이게 전부예요. 간선을 한 번씩만 훑으니 O(E)에 인접 리스트가 완성됩니다. 직접 짠 AdjacencyListGraph가 하던 일과 똑같지만, 정점 수를 미리 안 넘겨도 되고 클래스 정의도 필요 없죠. 실전에선 이 다섯 줄이 표준입니다.
edges = [(0, 1), (0, 2), (1, 2), (1, 3), (2, 3), (3, 4)]
adj = build_adjacency_list(edges) # 무방향
adj[3] # [1, 2, 4]
adj[0] # [1, 2]
방향 그래프면 directed=True를 넘겨 한쪽만 넣으면 됩니다. 선행 과목 관계("A를 들어야 B를 들을 수 있다")나 작업 순서 같은 방향 그래프를 이 한 인자로 다 담아요.
이게 이 과목이 두 트랙을 두는 이유입니다. 직접 구현으로 "인접 리스트가 왜 O(V+E)인지"를 손으로 봤으니, 실전에선 defaultdict(list)를 믿고 씁니다. 원리를 아는 사람은 이 한 줄이 안에서 무슨 일을 하는지 알기에, 언제 이걸 쓰고 언제 set으로 바꿀지(Step 4 질문)를 판단할 수 있어요.
🎯 코테에서는 그래프 문제를 받으면
graph = defaultdict(list)로 시작해 간선을 담는 게 거의 관용구입니다. 무방향이면 양쪽에 넣는 것만 잊지 마세요. 이 한 줄을 빠뜨려 한쪽 방향만 넣는 실수가 그래프 문제의 단골 함정이에요.
💡 한 줄 정리
실전에선 collections.defaultdict(list)로 간선 목록을 O(E)에 인접 리스트로 만든다. 처음 보는 키에 빈 목록을 자동으로 만들어 줘 append만 하면 되고, 무방향이면 양쪽에 넣는다. 직접 구현으로 원리를 본 뒤 실전은 이 관용구를 믿고 쓴다.
🙋 학생 질문 — "튜터님, defaultdict 말고 그냥 dict로는 못 하나요?"
할 수는 있는데 번거로워요. 보통 dict로 하려면 매번 "이 키가 있나 확인하고, 없으면 빈 목록부터 만들고" 하는 절차를 거쳐야 합니다.
adj = {}
for u, v in edges:
if u not in adj:
adj[u] = []
adj[u].append(v)
dict.setdefault(u, []).append(v)로 한 줄에 줄일 수도 있지만, 읽기엔 defaultdict가 더 깔끔하죠. defaultdict(list)는 "없는 키는 빈 목록으로 취급하라"를 자료구조 자체에 새겨 둔 셈이라, 반복문 안이 adj[u].append(v) 한 줄로 단정해집니다.
주의할 점 하나. defaultdict는 없는 키를 조회만 해도 빈 목록을 만들어 버려요. adj[999]를 무심코 읽으면 999번 키가 생깁니다. 그래서 "이 정점에 이웃이 있나" 확인할 땐 if 999 in adj로 먼저 물어야 해요.
Step 7: "그래프를 다 만들었으니"
그래프를 담는 두 방법을 다 배웠습니다. 이제 담은 그래프로 뭘 할 수 있는지, 그리고 이게 어디로 이어지는지 보며 오늘을 마무리해요.
가장 기본은 우리가 짠 degree와 neighbors입니다. 차수를 세면 "친구가 가장 많은 사람", "가장 붐비는 교차로"를 찾을 수 있어요. 이웃을 순회하면 "이 정점에서 한 걸음 갈 수 있는 곳"이 나오고요. 이 이웃 순회가 사실 엄청나게 중요합니다. 왜냐하면 그래프 위를 걷는 모든 알고리즘이 결국 "지금 정점의 이웃을 보고, 그중 하나로 이동한다"의 반복이거든요.
그래프 위를 걷는다는 것
지금 정점 1 에 있음
→ 이웃 [0, 2, 3] 을 봄 (neighbors)
→ 아직 안 가 본 2 로 이동 (방문 체크 필요!)
→ 다시 2 의 이웃을 봄 ...
여기서 딱 하나가 빠졌어요. "아직 안 가 본"을 어떻게 알까요? 그래프는 사이클이 있어서, 표시를 안 해 두면 왔던 곳을 무한히 맴돌 수 있습니다. 그래서 방문 체크가 필요해요. 어디를 다녀왔는지 집합(set)이나 배열에 기록하며 걷는 거죠. B-3의 집합이 여기서 다시 등장합니다.
이 "이웃을 보며 방문 체크하고 걷는다"에 순서 규칙을 얹으면 두 가지 탐색이 됩니다. 다음에 갈 곳을 스택으로 관리하면 DFS(깊이 우선 탐색), 큐로 관리하면 BFS(너비 우선 탐색)예요. B-1에서 배운 스택과 큐가 바로 여기 쓰입니다. 오늘은 발판까지만 놓고, 실제 걷기는 다음 카테고리에서 만나요.
오늘의 그래프가 어디로 이어지나
그래프 (C-3, 오늘)
+ 스택/큐(B-1) + 방문 체크(B-3) ──▶ DFS·BFS (E-2) "그래프 위를 걷기"
+ 힙(C-2) ──▶ 다익스트라 (F-1) "가중치 최단 경로"
지난 시간 힙을 배우며 "우선순위 큐로 가장 가까운 것부터 꺼낸다"를 심어 뒀죠. 그 힙과 오늘의 그래프가 만나면 다익스트라 최단 경로(F-1)가 됩니다. 지도에서 출발지부터 "지금까지 가장 가까운 정점"을 힙으로 계속 꺼내며 이웃으로 뻗어 나가는 거예요. 오늘 그래프를 손에 넣었으니, 다익스트라를 만날 때 절반은 이미 아는 셈입니다.
🎯 코테에서는 "미로 최단 거리", "섬의 개수", "네트워크 연결 요소"가 전부 오늘 만든 그래프 위에서 DFS·BFS로 푸는 대표 유형입니다. 그래프를 인접 리스트로 모델링하는 것까지가 오늘 배운 절반이고, 그 위를 걷는 게 다음 절반이에요.
💡 한 줄 정리
그래프의 이웃 순회는 "지금 정점에서 한 걸음"이고, 사이클 때문에 방문 체크가 반드시 필요하다. 다음 갈 곳을 스택으로 두면 DFS, 큐로 두면 BFS(E-2)가 되고, 힙(C-2)을 더하면 다익스트라(F-1)가 된다. 오늘 그래프 표현이 그 모든 탐색의 발판이다.
🙋 학생 질문 — "튜터님, 이웃 순회만으로는 최단 경로를 못 구하나요?"
이웃 순회는 "한 걸음"이에요. 최단 경로는 그 한 걸음을 규칙 있게 반복하고, 어디까지 몇 걸음에 닿았는지 기록해야 나옵니다. 그 규칙과 기록이 바로 다음에 배울 BFS예요.
가중치가 없는 그래프에서 BFS는 출발점에서 가까운 정점부터 물결처럼 퍼져 나가며 방문합니다. 큐에 이웃을 넣고 하나씩 꺼내 걷는데, 이 "먼저 넣은 걸 먼저 꺼내는" 순서 덕분에 처음 도달한 순간이 곧 최단 거리가 되죠. 그래서 가중치 없는 최단 경로는 BFS가 답입니다.
가중치가 있으면 "간선 수"가 아니라 "무게 합"이 최소인 길을 찾아야 해서, 단순한 큐 대신 힙(우선순위 큐)이 필요해요. 그게 다익스트라(F-1)입니다. 오늘은 그 길들이 모두 그래프 표현 위에 선다는 것만 기억하면 충분해요.
마무리
오늘은 비선형 자료구조의 마지막, 그래프를 배웠습니다. 트리의 제약을 풀어 정점을 자유롭게 잇고, 그 얽힘을 인접 행렬과 인접 리스트라는 두 방법으로 담았어요. 그리고 "언제 무엇을 쓸지"를 공간과 시간의 트레이드오프로 골랐습니다. 이걸로 카테고리 C의 세 그릇 — 트리·힙·그래프 — 이 모두 채워졌네요.
오늘 배운 핵심 세 가지
- 💡 하나 — 그래프는 정점을 간선으로 자유롭게 이은 구조다. 트리의 "한 방향·사이클 없음·유일한 경로" 제약을 풀면 그래프가 된다. 방향/무방향, 가중치/비가중치로 종류가 갈리고, 계층이 아니라 얽힌 관계(친구·도로·링크)를 담는다.
- 💡 둘 — 담는 방법은 인접 행렬과 인접 리스트, 둘의 빅오가 거울처럼 뒤집힌다. 행렬은 간선 확인 O(1)·공간 O(V²), 리스트는 공간 O(V+E)·이웃 순회 O(degree). 정점이 많고 간선이 적은 희소 그래프면 인접 리스트, 작고 밀집하거나 간선 확인이 잦으면 인접 행렬이다.
- 💡 셋 — 실전은
defaultdict(list), 그리고 이 위에서 탐색이 돈다. 간선 목록을 O(E)에 인접 리스트로 만들고, 이웃 순회 + 방문 체크로 그 위를 걷는다. 스택이면 DFS, 큐면 BFS(E-2), 힙을 더하면 다익스트라(F-1)다.
다음 시간 예고
이제 데이터를 담는 그릇(자료구조)을 선형부터 비선형까지 다 배웠습니다. 다음 시간부터는 카테고리 D로 넘어가, 그 위에서 데이터를 다루는 알고리즘을 봅니다. 첫 주제는 정렬(sort)이에요. 뒤죽박죽인 데이터를 줄 세우는 방법인데, 버블·선택 정렬의 O(n²)를 병합·퀵 정렬의 O(n log n)으로 끌어내리는 여정입니다. 지난 시간 힙으로 "다 넣고 하나씩 빼면 정렬된다"를 잠깐 봤는데, 그 힙 정렬도 다시 만나요. 정렬이 정렬로 끝나지 않고 이진 탐색(D-3)·투 포인터(D-4)의 발판이 된다는 것까지, 다음 시간에 이어 갑니다.
과제
오늘 배운 그래프 표현을 직접 손으로 다뤄 보는 문제들입니다. 풀어 본 뒤 예시 답안과 맞춰 보세요. 각 풀이의 시간·공간 복잡도를 빅오로 적는 것도 잊지 마세요.
[기초] 가장 인기 있는 정점 찾기
무방향 그래프가 정점 수 n과 간선 목록 edges([(u, v), ...])로 주어집니다. 이 그래프에서 차수가 가장 큰 정점의 번호를 반환하는 함수 most_connected(n, edges)를 작성하세요. 차수가 같은 정점이 여럿이면 번호가 가장 작은 것을 반환합니다.
예를 들어 n=5, edges=[(0,1),(0,2),(1,2),(1,3),(2,3),(3,4)]이면 1번이 0·2·3과 이어져 차수 3으로 가장 크니 답은 1입니다. "친구가 가장 많은 사람 찾기" 같은 유형이에요. 인접 리스트를 통째로 만들 필요 없이, 간선을 훑으며 차수만 세도 됩니다. 무방향이라 간선 하나가 양 끝 정점 차수를 모두 올린다는 점을 챙기세요. 시간·공간 복잡도를 빅오로 적으세요.
[응용] 인접 행렬을 인접 리스트로
인접 행렬 matrix(V×V 2차원 리스트, matrix[u][v]가 0이 아니면 간선)가 주어질 때, 이를 인접 리스트(각 정점의 이웃 번호 목록을 담은 리스트)로 변환하는 함수 matrix_to_list(matrix)를 작성하세요.
예를 들어 4×4 행렬에서 0이 아닌 칸을 모으면 [[1,2],[0,3],[0,3],[1,2]] 같은 인접 리스트가 나옵니다. 오늘 배운 두 표현을 손으로 오가는 문제예요. 행렬을 인접 리스트로 바꿀 때 왜 O(V²)가 드는지(간선이 적어도 빈 칸까지 다 봐야 하죠), 그리고 결과 인접 리스트의 공간이 왜 O(V+E)로 줄어드는지를 빅오로 함께 적으세요.
[심화] 방향 그래프의 진입·진출 차수
방향 그래프가 정점 수 n과 간선 목록 edges(각 (u, v)는 u에서 v로 향하는 간선)로 주어집니다. 정점마다 (진입 차수, 진출 차수)를 계산해 리스트로 반환하는 함수 in_out_degree(n, edges)를 작성하세요. 진입 차수는 그 정점으로 들어오는 화살표 수, 진출 차수는 나가는 화살표 수입니다.
예를 들어 edges=[(0,1),(0,2),(1,2),(2,3)]이면 0번은 (진입 0, 진출 2), 2번은 (진입 2, 진출 1)이 됩니다. 무방향과 달리 방향 그래프는 두 차수를 따로 세야 해요. 힌트를 하나 드리면, 이 진입 차수는 나중에 위상 정렬(F-2)에서 "가장 먼저 처리할 정점(들어오는 게 없는 것)"을 고르는 열쇠가 됩니다. 순회 없이 간선만 한 번 훑으면 O(V+E)에 끝나요. 시간·공간 복잡도를 빅오로 적으세요.
생각해볼 주제
정답이 하나가 아닌 질문들입니다. 혼자 고민해도 좋고, 스터디에서 토론해도 좋아요.
1. 인접 행렬과 인접 리스트, 무엇을 보고 고를까?
오늘 두 표현의 빅오가 거울처럼 뒤집힌 걸 봤습니다. 행렬은 간선 확인 O(1)·공간 O(V²), 리스트는 공간 O(V+E)·이웃 순회 O(degree)였죠. 그렇다면 어떤 문제를 받았을 때 무엇을 고를지, 그 판단 기준을 스스로 정리해 보세요. 정점 수 제한이 얼마일 때 인접 행렬의 O(V²)가 감당되는지, 간선이 얼마나 빽빽해야 밀집이라 부를 만한지, "두 정점이 이어졌나"를 자주 물어야 하는 문제와 "한 정점의 이웃을 훑는" 문제 중 각각 어느 표현이 유리한지를 짚어 보면 좋습니다.
2. 방향 그래프로는 무엇을 표현하나?
무방향 그래프는 "서로 친구"처럼 대칭 관계를, 방향 그래프는 "팔로우"처럼 한쪽으로만 향하는 관계를 담았습니다. 방향이 꼭 필요한 관계에는 또 무엇이 있을까요? 선행 과목("A를 들어야 B를 들을 수 있다"), 작업 순서, 웹페이지 링크 같은 예를 떠올려 보세요. 그리고 [심화] 과제에서 다룬 진입 차수가 이런 방향 그래프에서 무엇을 뜻하는지 — 예컨대 "진입 차수가 0인 정점"은 어떤 특별한 위치인지 — 생각해 보면, 왜 위상 정렬이 진입 차수에서 출발하는지가 보일 거예요.
3. 트리는 그래프의 특수한 경우 — 카테고리 B·C를 돌아보며
오늘 "트리는 사이클 없이 연결된 특수한 그래프"라는 말로 문을 열었습니다. 이 한 문장에 카테고리 B와 C 전체가 담겨 있어요. 선형 자료구조(스택·큐·연결 리스트·해시)에서 비선형(트리·힙·그래프)으로 넘어온 흐름을 되짚어 보세요. 연결 리스트의 노드가 다음 하나를 가리켰다면 트리의 노드는 자식 여럿을, 그래프의 정점은 이웃을 자유롭게 가리켰죠. 이 그릇들이 각각 어떤 관계를 담기에 좋은지, 그리고 왜 자료구조를 먼저 배운 뒤에 알고리즘(정렬·탐색·탐색 알고리즘)으로 넘어가는지를 정리해 보면, 앞으로 배울 D·E·F가 왜 이 순서인지 선명해집니다.
✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 가장 인기 있는 정점 찾기
채점 포인트
| 항목 | 확인 |
|---|---|
| 차수 세기 | 인접 리스트를 다 만들 필요 없이 간선을 훑으며 차수 배열만 올림 |
| 무방향 처리 | 간선 하나가 양 끝 정점 차수를 모두 +1 (양쪽 대칭) |
| 동점 규칙 | 정점 번호 순으로 보며 "더 큰" 것만 갱신 → 동점이면 작은 번호가 남음 |
| 빅오 | 간선 한 번 + 정점 한 번 훑기 → 시간 O(V+E)·공간 O(V) |
풀이 예시
인접 리스트를 통째로 만들 필요가 없어요. 우리가 궁금한 건 각 정점의 차수뿐이니, 간선을 한 번 훑으며 차수만 세면 됩니다. 무방향 그래프라 간선 하나가 양 끝 정점의 차수를 모두 올린다는 점이 핵심이에요.
def most_connected(n, edges):
degree = [0] * n
for u, v in edges:
degree[u] += 1
degree[v] += 1
best = 0
for v in range(1, n):
if degree[v] > degree[best]:
best = v
return best
먼저 크기 n인 차수 배열을 0으로 시작합니다. 간선 (u, v)마다 degree[u]와 degree[v]를 함께 올려요(무방향이라 둘 다). 그다음 0번부터 훑으며 지금까지의 최대(best)보다 큰 정점이 나오면 갱신합니다.
동점 규칙이 이 순회에 자연스럽게 담겨 있어요. >(초과)로만 갱신하니, 차수가 같으면 먼저 자리 잡은 작은 번호가 그대로 남습니다. 굳이 따로 처리 안 해도 "동점이면 작은 번호"가 지켜지죠.
n=5, edges=[(0,1),(0,2),(1,2),(1,3),(2,3),(3,4)]의 차수를 세어 봅시다.
간선을 훑으며 차수 누적
정점: 0 1 2 3 4
차수: 2 3 3 3 1
└ 1번이 처음으로 차수 3에 도달 → best=1 (2,3도 3이지만 갱신 안 됨)
1·2·3번이 모두 차수 3으로 동점인데, 번호가 가장 작은 1이 답이 됩니다. 빅오는 간선을 한 번(O(E)), 정점을 한 번(O(V)) 훑어 시간 O(V+E), 차수 배열 하나라 공간 O(V)예요.
💡 튜터의 한마디: "그래프 문제인데 연결 여부를 자세히 볼 필요 없이 개수만 필요하다" 싶으면, 인접 리스트를 다 짓지 말고 차수만 세는 게 빠릅니다. 그래프 모델링의 첫 질문은 늘 "이 문제가 진짜로 요구하는 정보가 무엇인가"예요. 필요 이상으로 자료구조를 짓지 않는 것도 실력입니다.
🎯 [과제 2 예시답안] 인접 행렬을 인접 리스트로
채점 포인트
| 항목 | 확인 |
|---|---|
| 행렬 전수 순회 | V×V 칸을 모두 훑어 0이 아닌 칸만 이웃으로 모음 |
| 결과 구조 | 정점마다 이웃 번호 목록 하나(list of list) |
| 빅오 대조 | 변환 O(V²)(빈 칸까지 다 봄)·결과 공간 O(V+E)로 줄어듦 |
| 빈 그래프 처리 | 간선 없는 정점은 빈 목록 그대로 유지 |
풀이 예시
오늘 배운 두 표현을 손으로 오가는 문제예요. 인접 행렬은 matrix[u][v]가 0이 아니면 u-v 간선이었죠. 각 행 u를 훑어 0이 아닌 칸 v를 모으면 그게 u의 이웃 목록입니다.
# structures/exercises_c3.py
def matrix_to_list(matrix):
n = len(matrix)
adj = [[] for _ in range(n)]
for u in range(n):
for v in range(n):
if matrix[u][v] != 0:
adj[u].append(v)
return adj
정점마다 빈 이웃 목록을 하나씩 준비하고, 행렬의 모든 칸을 이중 반복으로 봅니다. 0이 아닌 칸을 만나면 그 열 번호 v를 u의 목록에 붙여요. 번호 순으로 훑으니 결과 이웃 목록도 자연히 오름차순으로 정렬됩니다.
인접 행렬 인접 리스트
0 1 2 3
0 [ 0 1 1 0 ] 0 → [1, 2]
1 [ 1 0 0 1 ] → 1 → [0, 3]
2 [ 1 0 0 1 ] 2 → [0, 3]
3 [ 0 1 1 0 ] 3 → [1, 2]
여기서 두 표현의 트레이드오프가 빅오로 드러납니다. 변환은 V×V 칸을 빠짐없이 봐야 해서 O(V²)예요. 간선이 몇 개 없어도 빈 칸까지 다 확인해야 하니까요. 반면 결과로 나온 인접 리스트는 실제 간선만 담아 공간이 O(V+E)로 줄어듭니다. "간선 확인 O(1)을 얻는 대신 공간 O(V²)를 치른" 행렬에서, "공간 O(V+E)를 얻는 대신 간선 확인이 느려진" 리스트로 옮긴 셈이죠.
💡 튜터의 한마디: 두 표현을 서로 변환할 줄 알면, 문제가 주는 형식과 내가 풀기 편한 형식이 다를 때 다리를 놓을 수 있어요. 간혹 문제가 인접 행렬로 입력을 주는데 정작 풀이는 인접 리스트가 편한 경우가 있습니다. 이럴 때 이 O(V²) 변환 한 번으로 넘어가면 됩니다. 다만 V가 아주 크면 이 변환 자체가 부담이니, 그땐 입력을 처음부터 간선 목록으로 받도록 설계를 바꾸는 게 낫고요.
🎯 [과제 3 예시답안] 방향 그래프의 진입·진출 차수
채점 포인트
| 항목 | 확인 |
|---|---|
| 방향 구분 | 간선 (u, v)에서 u는 진출(+1), v는 진입(+1)으로 따로 셈 |
| 두 배열 | 진입 차수·진출 차수를 각각 배열로 관리 |
| 결과 묶기 | 정점마다 (진입, 진출) 튜플로 묶어 반환 |
| 빅오 | 간선 한 번만 훑음 → 시간 O(V+E)·공간 O(V), 순회 불필요 |
풀이 예시
무방향에서는 간선 하나가 양쪽 차수를 똑같이 올렸지만, 방향 그래프는 다릅니다. 간선 (u, v)는 "u에서 v로 향한다"라서, u의 진출 차수(나가는 화살표)와 v의 진입 차수(들어오는 화살표)를 따로 올려야 해요.
# structures/exercises_c3.py
def in_out_degree(n, edges):
indeg = [0] * n
outdeg = [0] * n
for u, v in edges:
outdeg[u] += 1
indeg[v] += 1
return [(indeg[i], outdeg[i]) for i in range(n)]
진입 차수 배열과 진출 차수 배열을 따로 두고, 간선을 한 번 훑으며 outdeg[u]와 indeg[v]를 각각 올립니다. 마지막에 정점 순서대로 두 값을 튜플로 묶어 돌려줘요. 순회(DFS·BFS)가 전혀 필요 없이 간선만 한 번 세면 되니 O(V+E)에 끝납니다.
edges=[(0,1),(0,2),(1,2),(2,3)]의 화살표를 세어 봅시다.
방향 그래프 0 ──▶ 1 ──▶ 2 ──▶ 3
└────▶ 2 (0→2 도 있음)
정점: 0 1 2 3
(진입,진출): (0, 2) (1, 1) (2, 1) (1, 0)
나가기만 중간 들어옴2 도착만
0번은 나가는 간선만 둘(진입 0), 3번은 들어오는 간선만 하나(진출 0)죠. 2번은 0과 1에서 들어와 진입 차수가 2입니다.
여기서 진입 차수가 특별한 뜻을 가져요. 진입 차수가 0인 정점은 "아무도 나를 가리키지 않는다", 즉 선행 조건이 없어 가장 먼저 처리할 수 있는 정점입니다. 위상 정렬(F-2)이 바로 이 진입 차수 0인 정점부터 하나씩 꺼내며 순서를 정하는 알고리즘이에요. 오늘 이 차수를 세 본 게 그 발판이 됩니다.
💡 튜터의 한마디: 방향 그래프를 만나면 "진입과 진출을 나눠 세야 하나"를 먼저 물으세요. 선행 과목, 작업 순서, 빌드 의존성 같은 문제는 진입 차수 0(시작점)과 진출 차수 0(끝점)이 결정적 단서가 됩니다. 무방향의 차수 하나가 방향에선 둘로 갈린다는 것만 기억하면, 위상 정렬로 자연스럽게 이어져요.
🤔 [생각해볼 주제 1] 인접 행렬과 인접 리스트, 무엇을 보고 고를까
문제 상황 요약
오늘 두 표현의 빅오가 거울처럼 뒤집힌 걸 봤습니다. 행렬은 간선 확인 O(1)·공간 O(V²), 리스트는 공간 O(V+E)·이웃 순회 O(degree)였죠. 어떤 문제를 받았을 때 무엇을 고를지, 그 판단 기준을 어떻게 세울까요?
튜터의 가이드 및 해설
판단의 첫 기준은 정점 수 제한입니다. 인접 행렬은 V×V 칸을 무조건 차지하니, V가 크면 공간부터 터져요. A-1의 "1초 1억" 잣대를 공간에도 대 보면, V=10만이면 V²=100억 칸이라 메모리에 못 올립니다. 그래서 V가 수만을 넘으면 선택지가 인접 리스트뿐이에요. 반대로 V가 1000 이하로 작으면 V²=100만이라 행렬도 부담 없죠.
둘째 기준은 간선의 밀도입니다. 간선이 정점 수에 비례하는 정도(E ≈ V)면 희소 그래프라 인접 리스트가 알뜰하고, V²에 가깝게 빽빽하면(밀집) 어차피 리스트도 O(V²) 공간이라 행렬의 O(1) 간선 확인이 매력적이에요. 현실 그래프는 대부분 희소해서 리스트가 기본입니다.
셋째 기준은 연산 패턴입니다. "두 정점이 이어졌나?"를 아주 자주 물어야 하면 O(1)인 행렬이, "한 정점의 이웃을 훑는" 게 주된 작업이면 낭비 없는 리스트가 유리해요. DFS·BFS는 이웃 순회가 전부라 리스트, 플로이드-워셜은 모든 쌍 간선 확인이라 행렬이죠.
한 줄로 답하면, "V가 크거나 간선이 희소하거나 이웃 순회가 주 작업이면 인접 리스트, V가 작고 밀집하거나 간선 확인이 잦으면 인접 행렬"입니다. 세 기준(정점 수·밀도·연산 패턴)을 순서대로 짚으면 거의 다 갈립니다.
🎯 코테·면접에선 이렇게 나온다
"그래프를 어떻게 저장하실 건가요?"라는 질문에 "인접 리스트요"만 답하면 절반이에요. "정점 수 제한과 간선 밀도를 보고 고릅니다. 대개는 희소라 인접 리스트지만, 정점이 작고 간선 확인이 잦으면 인접 행렬"까지 답해야 완성입니다. 문제의 V ≤ ? 제한을 먼저 확인하는 습관이 면접에서도 그대로 드러나요.
💡 실무에선
SNS 친구망, 도로망, 웹 링크 그래프는 예외 없이 희소해서 실무의 기본은 인접 리스트입니다. 다만 그래프가 메모리 한 대를 넘어서면 이야기가 달라져요. 그땐 인접 리스트를 디스크나 그래프 데이터베이스(Neo4j 등)에 얹고, 자주 함께 조회되는 이웃을 묶어 저장하는 최적화가 들어갑니다. 표현 방식의 선택이 "메모리에 다 들어가나"라는 규모 판단에서 시작한다는 게 실무의 감각이에요.
🤔 [생각해볼 주제 2] 방향 그래프로는 무엇을 표현하나
문제 상황 요약
무방향은 "서로 친구"처럼 대칭 관계를, 방향은 "팔로우"처럼 한쪽으로만 향하는 관계를 담았습니다. 방향이 꼭 필요한 관계엔 또 무엇이 있고, 진입 차수는 거기서 무엇을 뜻할까요?
튜터의 가이드 및 해설
방향이 필요한 관계의 공통점은 비대칭입니다. "A가 B에게" 성립한다고 "B가 A에게"가 성립하지 않는 관계죠. 몇 가지 떠올려 봅시다. 선행 과목("자료구조를 들어야 알고리즘을 들을 수 있다")은 순서가 거꾸로면 말이 안 됩니다. 작업 의존성("설계가 끝나야 구현을 시작한다"), 웹페이지 링크(A가 B를 링크해도 B가 A를 링크하진 않음), 빌드 순서 — 전부 방향 그래프예요.
이런 방향 그래프에서 진입 차수가 특별한 의미를 가집니다. 진입 차수가 0인 정점은 "나를 가리키는(내 앞에 와야 하는) 것이 없다", 즉 아무 선행 조건 없이 가장 먼저 할 수 있는 일이에요. 선행 과목 그래프라면 "아무 선수 과목이 없는 입문 과목", 빌드 그래프라면 "가장 먼저 컴파일할 파일"이죠. 반대로 진출 차수가 0인 정점은 "더 이상 이어지는 게 없는" 종착점입니다.
그래서 "순서를 정하라"는 문제는 진입 차수 0에서 출발해요. 진입 차수가 0인 정점을 하나 꺼내 처리하고, 그 정점이 가리키던 이웃들의 진입 차수를 1씩 줄이면, 또 새로 0이 된 정점이 "이제 할 수 있는 일"로 드러납니다. 이걸 반복하면 전체 순서가 나오는데, 이게 위상 정렬(F-2)이에요. 오늘 [심화] 과제에서 진입 차수를 세 본 게 이 알고리즘의 첫 조각입니다.
한 줄로 답하면, "방향 그래프는 선행·의존·링크처럼 비대칭 관계를 담고, 진입 차수 0은 '선행 조건 없이 가장 먼저 할 수 있는 정점'이라 순서를 정하는 위상 정렬의 출발점이 된다"입니다.
🎯 코테·면접에선 이렇게 나온다
"작업들의 실행 순서를 정하라", "선수 과목을 고려한 수강 순서", "빌드 의존성 해결" 같은 문제가 방향 그래프 + 위상 정렬 유형이에요. 지문에 "A를 먼저 해야 B를 할 수 있다" 같은 일방 의존이 보이면 방향 그래프를 떠올리고, 진입 차수부터 세는 게 정석입니다. "사이클이 있으면 순서를 못 정한다(교착)"까지 언급하면 깊이가 보여요.
💡 실무에선
빌드 도구(Gradle·Make)가 파일 의존성을 방향 그래프로 두고 위상 정렬로 컴파일 순서를 정합니다. 작업 스케줄러, 데이터 파이프라인의 단계 순서, 패키지 매니저의 설치 순서도 전부 같은 원리예요. 그리고 실무에선 "사이클 탐지"가 함께 중요합니다. 의존성에 순환이 생기면(A가 B를, B가 A를 필요로 함) 순서를 정할 수 없어 빌드가 멈추는데, 방향 그래프의 진입 차수가 끝까지 0이 안 되는 정점이 남는 걸로 이 순환을 잡아내죠.
🤔 [생각해볼 주제 3] 트리는 그래프의 특수한 경우 — 카테고리 B·C를 돌아보며
문제 상황 요약
오늘 "트리는 사이클 없이 연결된 특수한 그래프"라는 말로 문을 열었습니다. 이 한 문장에 카테고리 B·C 전체가 담겨 있어요. 선형에서 비선형으로 넘어온 흐름을 되짚고, 왜 자료구조를 먼저 배운 뒤 알고리즘으로 가는지 정리해 봅시다.
튜터의 가이드 및 해설
자료구조들을 "노드가 무엇을 가리키는가"로 줄 세우면 한 흐름이 보입니다. 연결 리스트(B-2)의 노드는 다음 하나를 가리켰어요. 한 줄로 늘어선 선형이었죠. 트리(C-1)의 노드는 자식 여럿을 가리켰습니다. 가지 치는 비선형이 됐고요. 그래프(C-3)의 정점은 이웃을 자유롭게 가리킵니다. 사이클도, 여러 갈래 경로도 생겨요.
이걸 제약의 관점으로 뒤집으면 포함 관계가 됩니다. 그래프가 가장 일반적이고, 거기에 "사이클 없이 연결"이라는 제약을 걸면 트리, 트리에 "노드마다 자식 최대 둘 + 왼쪽<나<오른쪽"을 걸면 BST가 돼요. 연결 리스트는 "노드마다 자식 하나"인 트리라고도 볼 수 있고요. 제약이 셀수록 특수하지만, 그만큼 특정 연산이 빨라집니다. BST의 O(log n) 탐색이 그 제약의 대가로 얻은 선물이죠.
왜 자료구조(B·C)를 먼저 배우고 알고리즘(D·E·F)으로 가는지도 여기서 보여요. 알고리즘은 자료구조라는 그릇 위에서 돕니다. 정렬(D-1)은 배열을, 이진 탐색(D-3)은 정렬된 배열을, DFS·BFS(E-2)는 그래프를, 다익스트라(F-1)는 그래프와 힙을 딛고 서요. 그릇을 모르면 그 위에서 도는 알고리즘이 막힙니다. 오늘 그래프 표현을 손에 넣었으니, 이제 그 위를 걷는 탐색으로 넘어갈 준비가 된 거예요.
한 줄로 답하면, "연결 리스트→트리→그래프는 노드가 가리키는 대상이 하나→여럿→자유로 넓어지는 흐름이고, 제약을 걸수록 특수해지며 특정 연산이 빨라진다. 자료구조라는 그릇을 먼저 익혀야 그 위에서 도는 알고리즘(D·E·F)이 막히지 않는다"입니다.
🎯 코테·면접에선 이렇게 나온다
"트리와 그래프의 관계는?"이라는 질문에 "트리는 사이클 없이 연결된 그래프고, 간선이 정점 수보다 하나 적다(V-1개)"라고 답하면 정확해요. 면접관은 이 한 문장으로 지원자가 자료구조를 개별 암기했는지, 계보로 이해했는지를 가늠합니다. "연결 리스트⊂트리⊂그래프"라는 포함 관계를 말할 수 있으면 깊이가 드러나요.
💡 실무에선
이 계보 감각은 도구를 고를 때 그대로 쓰입니다. 계층 관계(조직도·댓글 대댓글·폴더)면 트리로 단순하게, 얽힌 관계(친구·추천·의존성)면 그래프로 일반화해 모델링하죠. "이 데이터가 트리로 충분한가, 그래프까지 필요한가"를 먼저 물으면 불필요한 복잡도를 피할 수 있어요. 트리로 될 걸 그래프로 짜면 사이클 처리·방문 체크 같은 짐을 공연히 지게 되니까요. 제약이 강한 특수 구조를 고를 수 있으면, 그만큼 코드가 단순하고 빨라집니다.