F-1: 고급 그래프 ① 최단 경로 — 걸음 수가 아니라 비용으로 길을 고른다
목차 27
안녕하세요, 여러분의 알고리즘 길잡이 홍순구 튜터입니다. 지난 시간(E-5)까지 우리는 문제를 푸는 다섯 가지 사고법을 전부 손에 넣었어요. 재귀와 완전탐색, DFS와 BFS, 그리디와 분할정복, 그리고 동적 계획법까지. 그러면서 제가 마지막에 이렇게 예고했죠. "지금까지 쌓은 자료구조와 패러다임이 F에서 한데 모인다." 오늘이 그날입니다.
오늘 만날 다익스트라는 혼자 서 있는 알고리즘이 아니에요. C-2에서 배열로 직접 짜 본 힙이 우선순위 큐로 들어오고, C-3의 인접 리스트가 무대가 되고, E-2의 BFS가 골격을 빌려주고, E-3의 그리디가 "왜 이게 맞는가"를 증명해 줍니다. 넷이 한 자리에서 만나 하나의 알고리즘이 되는 셈이죠. 그래서 오늘은 새로 외울 게 많은 시간이 아니라, 이미 아는 것들이 맞물리는 시간입니다.
시작은 E-2에서 우리가 그어 둔 선 하나를 다시 꺼내는 데서 출발할게요. "간선의 걸음값이 모두 같으면 BFS가 곧 최단 경로다." 오늘은 그 걸음값이 제각각인 세계로 넘어갑니다. 도로마다 길이가 다르고, 노선마다 요금이 다르고, 구간마다 걸리는 시간이 다른 진짜 지도 같은 그래프요. 그러면 BFS가 어떻게 무너지는지, 무엇으로 갈아 끼워야 하는지를 오늘 내내 따라갑니다.
오늘의 여정 — 걸음 수가 아니라 비용으로 길을 고른다
최단 경로 문제 걸음값이 다르면 BFS가 깨진다
│ 우선순위 큐로 갈아 끼우면 다익스트라
│
다익스트라 O(V²) 미확정 중 최소를 매번 훑어 고른다
│
다익스트라 힙 C-2의 힙으로 최소를 O(log V)에 꺼낸다
│ O(E log V) — 실전의 기본값
▼
경로 복원 거리 말고 "어디를 거쳤나"를 되짚는다
│
벨만-포드 음수 간선이 끼면 그리디가 깨진다
│ 이완 V-1회 + 음의 사이클 검출
▼
플로이드-워셜 모든 쌍의 거리를 삼중 루프로 한꺼번에
│
셋 중 고르기 입력을 보고 알고리즘을 판별한다
💡 오늘 수업의 핵심 — "간선에 가중치가 생기면 걸음 수와 비용이 갈라진다 — BFS의 큐를 우선순위 큐로 갈아 끼운 다익스트라가 기본값이고, 음수 간선이면 벨만-포드, 모든 쌍이면 플로이드-워셜로 간다"
🎯 학습 목표
- 가중치 그래프에서 BFS가 왜 최단을 보장하지 못하는지 반례로 확인하고, 다익스트라의 그리디 선택이 왜 옳은지를 교환 논법으로 설명한다.
- 다익스트라를 선형 탐색 O(V²)과 힙 O(E log V) 두 방식으로 구현해 대조하고, 부모 배열로 실제 경로까지 복원한다.
- 음수 간선이면 벨만-포드, 모든 쌍이 필요하면 플로이드-워셜로 갈아타는 판단 기준을 입력 크기와 빅오로 세운다.
Step 1: "가장 짧은 길은 어떻게 고르나" (~20분)
E-2에서 우리가 BFS로 미로의 최단 거리를 구했던 걸 떠올려 보세요. 큐에 넣고 한 겹씩 넓혀 나가다가, 목적지에 처음 닿은 순간이 곧 최단 거리였습니다. 물결이 퍼지듯 가까운 곳부터 차례로 닿으니까요. 그때 제가 조건 하나를 분명히 달아 뒀어요. 이건 간선의 걸음값이 모두 1로 똑같을 때만 성립한다고요.
이제 그 조건을 치워 봅시다. 지도를 생각해 보세요. 서울에서 부산까지 가는 길이 여러 개인데, 어떤 길은 고속도로라 한 번에 이어지지만 400km고, 어떤 길은 국도를 세 번 갈아타지만 합쳐서 300km입니다. "몇 번 갈아탔나"와 "총 몇 km인가"는 전혀 다른 이야기죠. BFS는 앞의 것만 셀 수 있습니다.
걸음 수와 비용이 정반대로 갈리는 그래프
0 --------------(100)--------------> 1 간선 1개, 비용 100
| ^
+--(1)--> 2 --(1)--> 3 --(1)---------+ 간선 3개, 비용 3
BFS에게 0에서 1까지 가는 길을 물으면 뭐라고 답할까요? "한 걸음이면 닿습니다"라고 답합니다. 큐에서 0을 꺼내 이웃을 보는 순간 1이 바로 튀어나오니까요. 하지만 실제로 싼 길은 2와 3을 거치는 우회로예요. 1+1+1로 겨우 3이니까요. 걸음 수를 세는 자로는 비용을 잴 수 없습니다. 여기서 BFS가 깨집니다.
그러면 무엇을 바꿔야 할까요? 문제는 BFS가 큐에 들어온 순서대로 꺼낸다는 데 있습니다. 먼저 들어왔다고 해서 가까운 게 아니거든요. 그러니 꺼내는 기준을 "들어온 순서"가 아니라 "지금까지 알아낸 거리가 가장 짧은 것"으로 바꾸면 됩니다. 그런 자료구조, 우리 이미 갖고 있죠. C-2에서 배열로 직접 짜 본 힙, 곧 우선순위 큐입니다.
이게 다익스트라(Dijkstra)의 전부예요. BFS의 큐를 우선순위 큐로 갈아 끼우는 것. 골격은 그대로 두고 꺼내는 규칙만 바꾸는 겁니다.
BFS와 다익스트라 — 골격은 같고 꺼내는 규칙만 다르다
BFS 큐(deque)에서 꺼낸다 → 먼저 들어온 것부터
다익스트라 힙(heapq)에서 꺼낸다 → 거리가 가장 짧은 것부터
둘 다: 꺼낸다 → 이웃을 본다 → 갱신되면 넣는다 (반복)
그런데 잠깐, 여기서 한 번 의심하고 넘어가야 합니다. "지금 가장 가까워 보이는 것"을 꺼내서 그 거리를 확정해 버리는 건, 눈앞의 최선을 집는 그리디잖아요. E-3에서 우리는 그리디가 거스름돈 문제에서 보기 좋게 깨지는 걸 봤습니다. 그러면 다익스트라의 그리디는 왜 안 깨질까요?
교환 논법으로 따져 봅시다. 아직 확정 안 된 정점 중에서 거리가 가장 작은 정점 u를 꺼냈다고 해요. 만약 u에 더 짧게 닿는 다른 길이 있다면, 그 길은 아직 확정되지 않은 어떤 정점을 반드시 거쳐 가야 합니다. 그런데 그 정점의 거리는 이미 u의 거리보다 크거나 같아요. u가 미확정 중 최소였으니까요. 거기서 간선을 더 타고 걸어가 봐야 거리는 줄지 않습니다. 간선 가중치가 음수가 아닌 한 더할수록 커지거나 그대로일 뿐이니까요. 그러니 u보다 짧은 길은 존재할 수 없고, 꺼내는 순간이 곧 확정입니다.
왜 "미확정 중 최소"를 꺼내면 확정인가
u = 미확정 중 거리가 가장 작은 정점 (거리 d)
u에 더 짧게 닿는 길이 있다고 치면
그 길은 미확정 정점 x 를 반드시 거친다
그런데 x 의 거리 ≥ d (u가 미확정 중 최소였으므로)
거기서 더 걸어가면 ≥ d + (0 이상 가중치) ≥ d
→ 모순. u 의 거리 d 는 더 줄어들 수 없다
이 논증이 통째로 기대고 있는 전제가 보이시나요? 마지막 줄의 "0 이상 가중치"입니다. 가중치가 0이면 등호가 되지만 "더 줄지는 않는다"는 결론은 그대로 성립하니, 정확한 전제는 양수가 아니라 음수가 아니다예요. 음수 간선이 하나라도 있으면 "더 걸어가면 줄지 않는다"가 무너지고, 확정했던 거리가 나중에 뒤집힐 수 있습니다. 그래서 음수 간선이 끼는 순간 다익스트라를 버려야 해요. 그럼 뭘 쓰냐고요? Step 5에서 만날 벨만-포드입니다. 오늘 그 순간까지 이 전제를 계속 염두에 두고 가세요.
💡 한 줄 정리
간선에 가중치가 생기면 걸음 수와 비용이 갈라져 BFS의 최단 보장이 깨진다. 다익스트라는 BFS의 큐를 우선순위 큐로 갈아 끼워 "지금까지 알아낸 거리가 가장 짧은 정점"부터 꺼내 확정하며, 이 그리디 선택이 옳다는 근거는 간선 가중치가 음수가 아니라는 전제 위에 서 있다.
🙋 학생 질문 — "간선 가중치가 전부 1이면 다익스트라를 써도 되나요? BFS랑 답이 같을 텐데요."
답은 같습니다. 가중치가 전부 1이면 "거리가 가장 짧은 것"과 "먼저 들어온 것"이 정확히 일치하거든요. 힙에서 꺼내는 순서가 큐에서 꺼내는 순서와 똑같아지니, 다익스트라는 그냥 BFS처럼 동작합니다.
문제는 속도예요. BFS는 O(V+E)인데 다익스트라는 힙 연산 때문에 O(E log V)입니다. 로그가 하나 더 붙죠. 정점이 10만 개쯤 되면 이 log V가 17배 차이로 나타나요. 그러니 가중치가 없는 그래프에 다익스트라를 쓰는 건 "되긴 되는데 손해 보는" 선택입니다.
그래서 문제를 읽을 때 이 질문을 먼저 던지세요. "간선마다 값이 다른가?" 다 같으면 BFS로 충분하고, 제각각이면 다익스트라입니다. 참고로 가중치가 0과 1 두 가지뿐인 특수한 경우엔 덱(deque)을 써서 O(V+E)에 푸는 0-1 BFS라는 기법도 있는데, 지금은 "그런 갈래도 있다" 정도만 알아 두시면 충분해요.
Step 2: "거리 배열 하나로 시작한다" (~20분)
원리를 잡았으니 손으로 짜 볼 차례입니다. 그런데 첫 구현에서는 일부러 힙을 쓰지 않을게요. 힙 없이, 가장 단순하게 먼저 만들어 봅니다. 그래야 "힙이 정확히 무엇을 대신해 주는가"가 선명해지거든요.
다익스트라의 뼈대는 딱 두 줄로 요약됩니다.
다익스트라의 뼈대 — 이 두 줄이 전부다
① 아직 확정 안 된 정점 중 거리가 가장 작은 것을 고른다 → 확정
② 그 정점의 이웃으로 가는 거리를 더 짧아지면 갱신한다 → 이완
①②를 정점 수만큼 반복하면 모든 최단 거리가 나온다
②에 나온 이완(relaxation)이라는 말을 짚고 갈게요. 팽팽하게 당겨져 있던 줄을 느슨하게 푼다는 뜻인데, 여기서는 "지금 알고 있는 거리보다 더 짧은 길을 찾았으면 그 값으로 줄인다"를 가리킵니다. 오늘 배울 세 알고리즘이 전부 이 이완을 반복하고, 무엇을 어떤 순서로 이완하느냐만 다릅니다. 오늘의 공통 언어예요.
이제 ①을 어떻게 구현하느냐가 성능을 가릅니다. 가장 단순한 방법은 아무 자료구조도 쓰지 않고 정점을 전부 훑어 최소를 찾는 거예요.
# algorithms/dijkstra.py
def dijkstra_matrix(graph, start):
vertices = _vertices(graph)
dist = {v: INF for v in vertices}
dist[start] = 0
visited = set()
for _ in range(len(vertices)):
# ① 미확정 정점 중 거리가 가장 작은 것을 선형 탐색으로 고른다 — 여기가 O(V)
current = None
smallest = INF
for v in vertices:
if v not in visited and dist[v] < smallest:
smallest = dist[v]
current = v
if current is None:
break # 남은 건 전부 못 닿는 정점 — 더 볼 게 없다
visited.add(current) # 꺼낸 순간 확정(그리디 선택)
# ② 이웃으로 가는 거리를 더 짧게 만들 수 있으면 갱신한다(이완)
for nxt, weight in graph.get(current, []):
if dist[current] + weight < dist[nxt]:
dist[nxt] = dist[current] + weight
return dist
그래프는 C-3의 인접 리스트를 가중치까지 담도록 늘린 형태입니다. {정점: [(이웃, 가중치), ...]} 꼴이에요. E-2에서 쓰던 {정점: [이웃, ...]}에 무게가 하나씩 붙었다고 보시면 됩니다.
INF는 float('inf'), 곧 무한대입니다. 아직 닿지 못한 정점의 거리를 이 값으로 두는데, 이게 은근히 편해요. 어떤 실제 거리와 비교해도 무조건 지니까 "아직 모르는 곳"이 자연스럽게 걸러지고, inf + 가중치도 여전히 inf라 못 닿는 곳을 경유하는 길이 저절로 배제됩니다.
실제로 어떻게 도는지 거리표를 따라가 볼게요. 아래 그래프에서 0을 출발점으로 잡습니다.
예제 그래프 (화살표 옆 숫자가 가중치)
0 --(4)--> 1 0 --(1)--> 2
1 --(1)--> 3 2 --(2)--> 1
2 --(5)--> 3 3 --(3)--> 4
5 : 들어오는 간선이 없어 0에서 못 닿는다
거리표(dist)가 갱신되는 과정 — 확정된 정점은 [ ] 로 표시
회차 확정 dist[0] dist[1] dist[2] dist[3] dist[4] dist[5]
---- ---- ------- ------- ------- ------- ------- -------
시작 - 0 inf inf inf inf inf
1 [0] 0 4 1 inf inf inf
2 [2] 0 3 1 6 inf inf
3 [1] 0 3 1 4 inf inf
4 [3] 0 3 1 4 7 inf
5 [4] 0 3 1 4 7 inf
2회차를 눈여겨보세요. 1번 정점의 거리가 4에서 3으로 줄어들었죠. 0에서 1로 곧장 가면 4인데, 2를 거쳐 돌아가면 1+2=3이라 더 싸거든요. Step 1에서 본 "우회로가 더 싼" 상황이 실제로 일어난 순간입니다. 만약 BFS였다면 0에서 한 걸음에 닿는 1을 4로 확정해 버리고 끝났을 거예요.
그리고 5번 정점은 끝까지 inf로 남습니다. 아무도 5를 가리키지 않아 도달할 방법이 없거든요. dijkstra_matrix(graph, 0)을 돌리면 {0: 0, 1: 3, 2: 1, 3: 4, 4: 7, 5: inf}가 나옵니다. 여기서 E-2의 BFS와 다른 점이 하나 있어요. BFS 거리 함수는 못 닿는 정점을 아예 결과에 안 넣었지만, 다익스트라는 키는 두되 값을 inf로 남깁니다. "못 닿는다"는 것도 정보니까요.
빅오를 따져 봅시다. 바깥 반복이 정점 수만큼 V번 돌고, 그 안에서 최소를 찾느라 다시 V개를 훑으니 시간 O(V²)입니다. 이완은 전체를 통틀어 간선 수 E번만 일어나는데, 보통 E ≤ V²라 O(V²)에 묻혀요. 공간은 거리표와 확정 집합으로 O(V)입니다.
"1초에 약 1억 연산" 잣대를 대 볼까요. V²이 1억이면 V는 약 10,000입니다. 정점이 1만 개를 넘어가면 이 방식으로는 시간 안에 못 들어와요. 그 벽을 다음 Step에서 힙으로 넘습니다.
💡 한 줄 정리
다익스트라는 "미확정 중 최소 거리 정점을 확정하고, 그 이웃을 이완한다"를 정점 수만큼 반복한다. 최소를 선형 탐색으로 고르면 한 번에 O(V)라 전체 시간 O(V²)·공간 O(V)이고, 1초 1억 연산 잣대로 정점 1만 개 언저리가 한계다.
🙋 학생 질문 — "함수 이름이 왜 matrix(행렬)인가요? 코드는 인접 리스트를 쓰는데요."
날카로운 지적이에요. 코드가 받는 건 분명 인접 리스트인데 이름은 행렬이니 어긋나 보이죠.
이 이름은 이 방식이 인접 행렬과 짝을 이룰 때 가장 자연스럽기 때문에 붙었습니다. 그래프를 인접 행렬로 담으면 한 정점의 이웃을 찾는 데도 그 행 V칸을 전부 훑어야 해요. 이웃이 둘뿐이어도 V칸을 다 봐야 하죠. 그러면 이완도 정점당 O(V)라 전체가 어차피 O(V²)가 됩니다. 최소 찾기를 O(V) 선형 탐색으로 두든 힙으로 줄이든 결과가 같아지니, 인접 행렬에는 이 단순한 방식이 딱 맞아떨어지는 겁니다.
여기서 굳이 인접 리스트로 받은 건 다음 Step의 힙 버전과 입력 형태를 똑같이 맞춰 두 트랙을 나란히 비교하기 위해서예요. 같은 그래프를 넣어 같은 답이 나오는지 확인하려면 입력이 같아야 하니까요.
실전에서는 이렇게 기억하시면 됩니다. 정점이 적고(수백 이하) 간선이 빽빽하면 인접 행렬 + 이 방식, 정점이 많고 간선이 성글면 인접 리스트 + 힙. C-3에서 배운 "인접 행렬 vs 인접 리스트" 트레이드오프가 최단 경로에서도 그대로 이어지는 셈이에요.
Step 3: "가장 가까운 후보를 힙에서 꺼낸다" (~20분)
Step 2의 병목이 어디였는지 정확히 짚어 봅시다. "미확정 중 최소를 찾겠다고 매번 V개를 훑는" 그 안쪽 반복문이었어요. 알고리즘 전체가 O(V²)가 된 이유가 오직 그것 하나입니다.
그런데 "최소를 빠르게 꺼내는 그릇", 우리 C-2에서 이미 만들었잖아요. 힙 말입니다. 배열로 완전 이진트리를 표현하고, 삽입과 삭제를 O(log n)에 처리하던 그 자료구조요. 그때 제가 "이건 나중에 다익스트라에서 회수합니다"라고 예고했는데, 지금이 그 순간입니다.
def dijkstra_heap(graph, start):
dist = {v: INF for v in _vertices(graph)}
dist[start] = 0
heap = [(0, start)] # (거리, 정점) — 힙이 거리 기준으로 정렬한다
while heap:
current_dist, node = heapq.heappop(heap) # 가장 가까운 정점을 O(log V)에
if current_dist > dist[node]:
continue # 게으른 삭제: 이미 더 짧은 길을 찾은 철 지난 항목
for nxt, weight in graph.get(node, []):
new_dist = current_dist + weight
if new_dist < dist[nxt]: # 더 짧아질 때만 갱신하고 힙에 넣는다
dist[nxt] = new_dist
heapq.heappush(heap, (new_dist, nxt))
return dist
E-2의 BFS 코드와 나란히 놓고 보면 골격이 얼마나 닮았는지 보일 거예요. deque가 heapq로, popleft()가 heappop()으로 바뀐 게 사실상 전부입니다. 꺼내는 규칙만 갈아 끼웠다는 Step 1의 말이 코드로 그대로 드러나죠.
튜플 (거리, 정점)을 힙에 넣는 게 요령입니다. 파이썬은 튜플을 앞 원소부터 비교하니, 첫 번째 자리에 거리를 두면 힙이 알아서 거리 기준으로 정렬해 줘요. C-2에서 최대 힙이 필요할 때 값에 음수를 붙였던 것과 같은 종류의 요령입니다.
여기서 하나 새로운 관용구가 등장하는데, if current_dist > dist[node]: continue 이 두 줄입니다. 왜 필요할까요?
게으른 삭제(lazy deletion)가 필요한 이유
1번 정점의 거리가 4 → 3 으로 줄었다고 하자
힙 안: (3, 1) (4, 1) ← 낡은 (4,1) 이 그대로 남아 있다
heapq 에는 특정 원소만 지우는 기능이 없다
꺼낼 때 걸러낸다:
(3, 1) 꺼냄 → dist[1] 이 3 → 유효, 처리한다
(4, 1) 꺼냄 → dist[1] 이 3 → 4 > 3 이므로 철 지난 항목, 건너뛴다
거리가 갱신될 때마다 새로 넣기만 하고 낡은 항목은 힙 안에 그대로 둡니다. heapq에는 임의의 원소를 찾아 지우는 기능이 없거든요. 대신 꺼내는 순간에 "지금 기록된 거리보다 큰 값이면 이미 철 지난 것"으로 판단해 건너뜁니다. 지울 시점을 미룬다고 해서 게으른 삭제라고 불러요. 코딩테스트 다익스트라 코드에서 거의 항상 보게 될 두 줄이니 눈에 익혀 두세요.
이제 두 트랙을 나란히 놓고 비교해 봅시다.
| 구분 | dijkstra_matrix (직접 구현) |
dijkstra_heap (내장 활용) |
|---|---|---|
| 최소 고르는 법 | 정점 전부 훑기 O(V) | heapq 로 꺼내기 O(log V) |
| 시간 복잡도 | O(V²) | O(E log V) |
| 공간 복잡도 | O(V) | O(V), 힙까지 세면 O(E) |
| 유리한 그래프 | 간선이 빽빽할 때 (E ≈ V²) | 간선이 성글 때 (E ≈ V) |
| 1초 1억 잣대 | V ≤ 약 10,000 | E 수십만~100만 |
재미있는 건 힙 버전이 항상 빠르지는 않다는 점이에요. 간선이 빽빽해서 E가 V²에 가까우면 O(E log V)는 O(V² log V)가 되어, 선형 탐색판의 O(V²)보다 log V만큼 오히려 손해입니다. 완전 그래프에 가까운 조밀한 입력이라면 단순한 쪽이 이기는 거죠.
다만 실전에서는 힙 버전을 기본값으로 잡으시면 됩니다. 코딩테스트에 나오는 그래프는 도로망이나 관계망처럼 거의 항상 성글거든요. 정점이 10만 개여도 간선은 20만 개 남짓인 식이에요. 이런 입력에서 O(V²)는 100억이라 손도 못 대지만, O(E log V)는 넉넉히 통과합니다.
🌟 실전 기본값: 가중치 그래프의 최단 경로는
heapq다익스트라로 시작하세요. 정점이 수백 이하로 아주 작고 간선이 조밀한 예외적인 경우에만 선형 탐색판을 떠올리면 충분합니다.
두 함수가 정말 같은 답을 내는지는 코드베이스 test_dijkstra.py가 같은 그래프를 양쪽에 넣어 비교하는 방식으로 확인해 두었습니다. 원리 트랙과 실전 트랙이 어긋나지 않는다는 걸 기계로 보증해 둔 거예요.
💡 한 줄 정리
힙(C-2)을 우선순위 큐로 쓰면 "미확정 중 최소 꺼내기"가 O(V)에서 O(log V)로 줄어 전체가 O(E log V)가 된다. heapq에는 임의 삭제가 없어 낡은 항목을 그대로 두고 꺼낼 때 걸러 내는 게으른 삭제 관용구를 쓰며, 간선이 성근 실전 그래프에서는 이 힙 버전이 기본값이다.
🙋 학생 질문 — "낡은 항목을 안 지우면 힙이 계속 커지잖아요. 메모리가 터지지 않나요?"
걱정하실 만한 지점인데, 상한이 분명하게 잡혀 있어서 괜찮습니다.
힙에 항목이 들어가는 건 오직 거리가 실제로 줄어들었을 때뿐이에요. 그리고 어떤 정점의 거리가 줄어들 수 있는 건, 그 정점으로 들어오는 간선을 타고 갱신될 때뿐이죠. 그러니 힙에 들어가는 항목의 총 개수는 아무리 많아도 간선 수 E를 넘지 못합니다. 공간이 O(E)로 묶이는 거예요.
간선이 20만 개인 그래프라면 힙에 최대 20만 개 튜플이 쌓이는 셈인데, 이 정도는 파이썬이 가볍게 감당합니다. 시간 쪽도 마찬가지예요. 최대 E개를 넣고 빼니 힙 연산이 O(E log V), 이게 곧 다익스트라의 복잡도가 됩니다.
물론 낡은 항목을 매번 찾아 지우면 힙이 더 작게 유지되긴 해요. 하지만 그러려면 "각 정점이 힙 어디에 있는지"를 따로 관리하는 인덱스 힙(indexed heap)을 직접 구현해야 하는데, 코드가 몇 배로 복잡해집니다. 얻는 이득에 비해 치르는 대가가 크죠. 그래서 실전에서는 거의 예외 없이 게으른 삭제를 씁니다. 복잡한 최적화보다 단순한 낭비가 나을 때도 있다는 걸 보여 주는 좋은 예예요.
Step 4: "어느 길로 갔는지 되짚기" (~20분)
지금까지 우리가 얻은 건 거리표뿐입니다. "0에서 4까지 7이 든다"는 알지만, "그래서 어느 길로 가야 하나요?"라는 질문에는 아직 답을 못 해요. 내비게이션이 "부산까지 400km입니다"라고만 하고 경로는 안 알려 주는 셈이죠.
그런데 이 문제, 우리 이미 한 번 풀어 봤습니다. E-4의 make_1 기억나시나요? 최소 연산 횟수를 구하면서 "이 값이 어디서 왔는지"를 함께 적어 뒀다가 거꾸로 되짚어 경로를 복원했죠. E-5의 LCS 문자열 복원도 같은 수법이었고요. 그때 제가 이렇게 못 박았습니다. "최적값을 구하는 알고리즘을 짰다면, 출처 배열 하나만 더 두면 경로도 나온다." 그 원칙이 그래프에서도 그대로 통합니다.
방법은 정말로 한 줄 추가예요. 어떤 정점의 거리를 갱신할 때, "누구를 거쳐서 짧아졌는지"를 함께 기록해 두면 됩니다.
def dijkstra_with_path(graph, start, end):
dist = {v: INF for v in _vertices(graph)}
dist[start] = 0
previous = {start: None} # 각 정점에 '어디서 왔는지'를 적어 둔다
heap = [(0, start)]
while heap:
current_dist, node = heapq.heappop(heap)
if current_dist > dist[node]:
continue # 게으른 삭제
if node == end:
break # 목적지를 확정했으면 더 볼 필요가 없다
for nxt, weight in graph.get(node, []):
new_dist = current_dist + weight
if new_dist < dist[nxt]:
dist[nxt] = new_dist
previous[nxt] = node # 이 정점으로 오는 최단 길의 직전 정점
heapq.heappush(heap, (new_dist, nxt))
if dist.get(end, INF) == INF:
return INF, [] # 끝내 못 닿았다(그래프에 없는 정점도 여기로)
path = [] # end에서 previous를 거꾸로 타고 start까지
node = end
while node is not None:
path.append(node)
node = previous[node]
path.reverse() # 거꾸로 모았으니 뒤집으면 start→end
return dist[end], path
Step 3 코드에서 새로 붙은 건 previous 딕셔너리 하나와, 그걸 채우는 previous[nxt] = node 한 줄, 그리고 마지막에 거꾸로 타고 올라가는 반복문입니다. 알고리즘의 뼈대는 손도 대지 않았어요.
역추적이 어떻게 도는지 그림으로 보겠습니다. Step 2의 예제 그래프에서 0에서 4까지의 경로를 복원해 볼게요.
previous 를 거꾸로 타고 올라가 경로를 복원한다
기록된 previous: 4 ← 3 3 ← 1 1 ← 2 2 ← 0 0 ← None
4 에서 시작해 거꾸로:
4 → previous[4] = 3
3 → previous[3] = 1
1 → previous[1] = 2
2 → previous[2] = 0
0 → previous[0] = None (여기서 멈춘다)
모인 순서: [4, 3, 1, 2, 0]
뒤집으면: [0, 2, 1, 3, 4] ← 이게 최단 경로
dijkstra_with_path(graph, 0, 4)를 돌리면 (7, [0, 2, 1, 3, 4])가 나옵니다. 거리 7과 실제로 지나간 정점들이 함께 나오죠. 0에서 1로 곧장 가지 않고 2를 거쳐 도는 게 눈으로 확인됩니다.
닿을 수 없는 경우도 챙겨야 해요. dijkstra_with_path(graph, 0, 5)는 (inf, [])를 냅니다. 5번은 들어오는 간선이 없어 못 닿으니, 거리는 무한대이고 경로는 빈 리스트입니다. 아예 그래프에 없는 정점 번호를 넣어도 마찬가지로 (inf, [])가 나와요. "없는 정점"도 결국 "못 닿는 정점"이니까요.
한 가지 더, if node == end: break가 눈에 띄실 겁니다. 목적지를 확정한 순간 나머지는 볼 필요가 없어 일찍 빠져나오는 최적화예요. Step 1에서 증명했듯 힙에서 꺼내는 순간이 곧 확정이라, 그때 멈춰도 답이 틀리지 않습니다. 다만 이건 한 목적지만 물을 때의 요령이에요. 모든 정점까지의 거리가 필요하면 이 줄을 빼야 합니다.
시간·공간 복잡도는 Step 3과 같은 O(E log V)·O(V)입니다. previous 딕셔너리가 O(V) 더 붙지만 이미 O(V)라 변하지 않아요. 경로 정보를 공짜에 가깝게 얻는 셈이죠.
💡 한 줄 정리
거리만으로는 "얼마나 드나"만 알고 "어디를 거치나"는 모른다. 이완할 때마다 직전 정점을 previous에 기록해 두고 목적지에서 거꾸로 타고 올라간 뒤 뒤집으면 최단 경로가 복원되며, 이는 E-4·E-5에서 익힌 "값과 함께 출처를 남긴다"는 역추적 기법을 그래프로 옮긴 것이다. 복잡도는 경로 복원을 붙여도 그대로 O(E log V)다.
🙋 학생 질문 — "최단 경로가 여러 개면 어떤 게 나오나요?"
거리가 똑같이 최소인 경로가 둘 이상일 때가 있죠. 이 코드는 그중 하나만 돌려줍니다. 어떤 게 나오는지는 이완 순서에 달려 있어요.
이유는 if new_dist < dist[nxt] 조건에 있습니다. 부등호가 작을 때만이라 같은 값으로는 갱신하지 않아요. 그래서 먼저 발견된 경로가 previous에 남고, 나중에 같은 거리의 다른 경로가 나타나도 덮어쓰지 않습니다.
문제가 "최단 경로 중 아무거나 하나"를 요구하면 이대로 충분해요. 그런데 조건이 붙는 경우가 있습니다.
- 최단 경로의 개수를 세라:
count[nxt]배열을 하나 더 둬서, 거리가 줄면count[nxt] = count[node]로 새로 시작하고, 거리가 같으면count[nxt] += count[node]로 더합니다. - 거리가 같으면 정점 번호가 작은 쪽으로: 조건을
<에서 "거리가 작거나, 같으면서 더 나은 경로일 때"로 넓혀previous를 갱신합니다.
핵심은 등호를 어떻게 다루느냐가 이 확장들의 갈림길이라는 점이에요. 문제에서 "최단 경로가 여러 개일 때"라는 단서가 보이면 부등호부터 다시 보시면 됩니다.
Step 5: "음수 간선이 끼면 그리디가 깨진다" (~20분)
Step 1에서 제가 조건 하나를 계속 머리에 두고 가자고 했죠. 다익스트라의 정당성은 "간선 가중치가 음수가 아니다"에 통째로 기대고 있다고요. 이제 그 전제를 깨 볼 시간입니다.
음수 간선이 대체 어디에 있냐고요? 생각보다 자연스럽게 등장합니다. 환전 수수료 대신 환차익이 나는 거래, 통행료를 내다가 보조금을 받는 구간, 게임에서 체력을 깎다가 회복 아이템을 얻는 길처럼 "가는데 오히려 이득이 되는" 이동이 있으면 음수 간선이에요.
이럴 때 다익스트라가 어떻게 틀리는지, Step 2의 dijkstra_matrix로 직접 따라가 봅시다.
다익스트라가 조용히 틀리는 그래프 (간선 2→1 의 가중치가 -5)
0 --(1)--> 1 --(1)--> 3
0 --(2)--> 2 --(-5)--> 1
진행:
0 확정(0) → 이완: dist[1]=1, dist[2]=2
미확정 최소는 1 (거리 1) → 1 을 확정하고 dist[3] = 1+1 = 2
그다음 2 확정(2) → 이완: 2 + (-5) = -3 이라 dist[1] 이 -3 으로 줄어든다
그런데 1 은 이미 확정돼 다시 보지 않는다
→ dist[3] 이 -3+1 = -2 로 갱신될 기회를 영영 잃는다
결과 dist[3] = 2 정답(벨만-포드) dist[3] = -2
핵심은 "확정"이라는 개념 자체가 무너진다는 데 있습니다. 1번 정점의 거리가 나중에 -3으로 줄었는데, 이미 확정해 visited에 넣어 버린 탓에 그 정점을 지나가는 3번의 거리를 다시 계산하지 않아요. 확정이 확정이 아니게 된 겁니다. 그런데도 프로그램은 오류 하나 없이 2를 내놓습니다. 조용히 틀리는 거죠.
⚠️ 직접 돌려 보면 헷갈릴 수 있는 지점: 같은 그래프를 Step 3의
dijkstra_heap에 넣으면 정답 -2가 나옵니다. 이 구현에는visited가 없어서, 거리가 줄면 그 정점을 힙에 다시 넣고 한 번 더 처리하거든요. 그런데 이건 자랑거리가 아니라 보증이 사라진 상태예요. 재방문이 몇 번 일어날지 아무도 보장하지 못해 O(E log V)라는 시간 보증이 깨지고, 최악의 그래프에서는 폭발적으로 느려집니다. 무엇보다 음의 사이클이 있으면 거리가 끝없이 줄어들어 영원히 멈추지 않아요. "음수 간선이면 다익스트라를 쓰지 않는다"는 원칙은 그대로입니다.
그래서 벨만-포드(Bellman-Ford)는 확정이라는 개념을 아예 버립니다. 순서를 따지지 않고, 그냥 모든 간선을 반복해서 훑으며 줄일 수 있으면 줄여요. Step 2에서 배운 이완만 무식하게 반복하는 겁니다.
def bellman_ford(num_vertices, edges, start):
dist = {v: INF for v in range(num_vertices)}
dist[start] = 0
for _ in range(num_vertices - 1): # V-1회차
updated = False
for u, v, weight in edges:
# 아직 못 닿은 정점(inf)에서는 출발할 수 없다
if dist[u] != INF and dist[u] + weight < dist[v]:
dist[v] = dist[u] + weight # 이완: 더 짧은 길을 찾았다
updated = True
if not updated:
break # 한 회차 동안 아무것도 안 줄면 이미 끝난 것
# V-1회차로 끝났어야 하는데 또 줄어든다면 음의 사이클이다
for u, v, weight in edges:
if dist[u] != INF and dist[u] + weight < dist[v]:
return None
return dist
입력이 인접 리스트가 아니라 [(u, v, w), ...] 간선 목록인 게 눈에 띌 거예요. 이 알고리즘은 "어떤 정점의 이웃"을 찾을 일이 없고 매 회차 간선을 통째로 훑기만 해서, 간선을 죽 늘어놓은 형태가 더 자연스럽습니다. 알고리즘이 그래프 표현을 고르는 셈이죠.
여기서 꼭 짚어야 할 게 왜 하필 V-1회인가입니다.
왜 V-1 회 이완하면 충분한가
사이클 없는 최단 경로가 지나는 정점은 최대 V 개
→ 그 경로가 쓰는 간선은 최대 V-1 개
간선 전체를 훑는 한 회차마다
최단 경로의 간선이 최소 하나씩은 제자리를 찾는다
따라서 V-1 회차면 어떤 최단 경로든 완성된다
최단 경로에 사이클이 낄 이유가 없다는 게 출발점이에요. 같은 정점을 두 번 지나는 건 가중치가 음수가 아닌 한 손해니까요. 그러면 경로가 쓰는 간선은 최대 V-1개이고, 한 회차마다 최소 한 간선씩 확정되니 V-1회면 충분합니다.
그런데 여기서 음의 사이클이라는 골치 아픈 놈이 등장합니다. 사이클을 한 바퀴 돌았는데 가중치 합이 음수라면 어떻게 될까요?
음의 사이클 — 돌수록 거리가 줄어든다
1 --(-1)--> 2 --(-1)--> 3 --(-1)--> 1 한 바퀴 합계 = -3
한 바퀴: -3 두 바퀴: -6 세 바퀴: -9 ...
무한히 돌면 거리가 무한히 작아진다
→ 이 그래프에는 '최단' 이라는 답이 존재하지 않는다
이런 그래프에는 최단 경로가 아예 존재하지 않습니다. 계속 돌기만 하면 얼마든지 줄일 수 있으니까요. 그래서 벨만-포드는 V-1회를 다 돌고 나서 한 번 더 훑어봅니다. 이미 끝났어야 하는데 또 줄어든다면, 그건 음의 사이클이 있다는 신호예요. 이때 None을 돌려줍니다.
음의 사이클 검출은 덤이 아니라 벨만-포드의 중요한 쓸모예요. 코딩테스트에서 "시간을 거슬러 출발점으로 돌아올 수 있는가" 같은 문제가 나오면, 그건 사실 "음의 사이클이 있는가"를 묻는 겁니다.
실제로 돌려 보겠습니다. 간선이 [(0,1,4), (0,2,5), (1,2,-3), (1,3,7), (2,3,4), (3,4,2)]인 그래프에서 bellman_ford(5, edges, 0)은 {0: 0, 1: 4, 2: 1, 3: 5, 4: 7}을 냅니다. 2번 정점을 보세요. 0에서 곧장 가면 5인데, 1을 거쳐 4+(-3)=1로 가는 게 더 쌉니다. 음수 간선이 실제로 답을 바꾼 거죠. 반면 [(0,1,1), (1,2,-1), (2,3,-1), (3,1,-1)]처럼 음의 사이클이 있는 그래프에 넣으면 None이 나옵니다.
빅오는 정직하게 비쌉니다. V-1회차마다 간선 E개를 전부 훑으니 시간 O(V·E), 거리표만 들고 있어 공간 O(V)입니다. 1초 1억 잣대로 V·E가 1억 이하, 정점 수천에 간선 수만 정도까지가 현실적인 선이에요. 다익스트라의 O(E log V)보다 한참 느립니다.
⚠️ 그래서 순서가 이렇습니다: 음수 간선이 없으면 무조건 다익스트라입니다. 벨만-포드는 "음수 간선이 있어서 어쩔 수 없을 때" 또는 "음의 사이클을 찾아야 할 때"만 꺼내세요. 느린 알고리즘을 습관처럼 쓰면 시간 초과를 만납니다.
💡 한 줄 정리
음수 간선이 있으면 "한 번 꺼낸 정점은 확정"이라는 다익스트라의 전제가 무너진다. 벨만-포드는 확정을 포기하고 모든 간선을 V-1회 이완하며, 한 번 더 훑어도 줄어들면 음의 사이클로 판정해 None을 낸다. 시간 O(V·E)·공간 O(V)로 느린 대신 음수를 정면으로 다룬다.
🙋 학생 질문 — "출발점에서 닿지도 못하는 곳에 음의 사이클이 있으면요?"
정확히 짚으셨어요. 그런 음의 사이클은 잡히지 않고, 잡지 않는 게 맞습니다.
코드의 이 조건이 열쇠예요. if dist[u] != INF and .... 아직 inf인 정점, 곧 출발점에서 닿을 수 없는 정점에서는 이완을 시도조차 하지 않습니다. 그러니 그런 영역에 음의 사이클이 있어도 거리가 줄어드는 일이 없고, 마지막 검사에도 걸리지 않아요.
이게 왜 올바른 동작일까요? 우리가 구하는 건 출발점에서 각 정점까지의 최단 거리입니다. 출발점에서 닿을 수도 없는 저 멀리 다른 덩어리에서 무슨 일이 벌어지든, 우리 답에는 아무 영향이 없어요. 그 정점들은 어차피 inf로 남을 테니까요. 그러니 "최단 거리가 존재하지 않는다"고 None을 돌려주면 오히려 틀린 답이 됩니다.
만약 문제가 "그래프 어딘가에 음의 사이클이 있는가"를 묻는다면 얘기가 달라져요. 그때는 모든 정점의 거리를 inf가 아니라 0으로 초기화하고 돌립니다. 가상의 출발점이 모든 정점에 0짜리 간선으로 연결된 것과 같은 효과라, 그래프 전체가 검사 범위에 들어오거든요. "어디서 출발하느냐가 무엇을 검출하느냐를 정한다"는 게 이 알고리즘의 재미있는 지점입니다.
Step 6: "모든 쌍의 거리를 한꺼번에" (~20분)
지금까지의 질문은 늘 "한 출발점에서 각 정점까지 얼마인가"였습니다. 그런데 문제가 이렇게 나온다면 어떨까요? "도시 100개가 있는데, 모든 도시 쌍 사이의 최단 거리를 전부 구하라."
다익스트라를 100번 돌리면 되긴 합니다. 실제로 그것도 정답이에요. 그런데 정점이 적을 때는 훨씬 짧은 코드로 끝내는 방법이 있습니다. 플로이드-워셜(Floyd-Warshall)입니다.
이 알고리즘이 던지는 질문은 놀랄 만큼 단순해요. 딱 하나입니다.
플로이드-워셜의 유일한 질문
"i 에서 j 로 갈 때, k 를 경유하면 더 짧아지나?"
dist[i][j] = min( dist[i][j] , dist[i][k] + dist[k][j] )
(직행) (k 를 거쳐 가기)
이 질문을 모든 k, 모든 i, 모든 j 에 대해 던지면 끝
경유지를 하나씩 허락해 가며 거리를 줄여 나가는 겁니다. 그런데 이 구조, 어딘가 익숙하지 않나요? "k번째 정점까지만 경유지로 허용했을 때의 최단 거리"를 k를 하나씩 늘려 가며 채우는 것. E-4와 E-5에서 지겹도록 한 표 채우기, 곧 동적 계획법입니다. 상태가 "출발지 × 도착지" 두 축이라 2차원 표가 되고, 경유지 k가 그 표를 갱신하는 단계 역할을 해요.
def floyd_warshall(matrix):
n = len(matrix)
dist = [row[:] for row in matrix] # 입력 비파괴 — 복사본에서 작업
for k in range(n): # 경유지 k가 반드시 가장 바깥
for i in range(n):
if dist[i][k] == INF:
continue # i에서 k에 못 닿으면 경유 자체가 불가
for j in range(n):
if dist[i][k] + dist[k][j] < dist[i][j]:
dist[i][j] = dist[i][k] + dist[k][j]
return dist
입력이 이번엔 인접 행렬입니다. matrix[i][j]가 i에서 j로 가는 가중치이고, 간선이 없으면 INF, 자기 자신은 0이에요. C-3에서 "정점이 적고 조밀하면 인접 행렬"이라고 배운 그 조건이 여기 딱 맞아떨어집니다. 모든 쌍의 거리를 담아야 하니 어차피 V×V 표가 필요하거든요.
이 알고리즘의 단골 실수를 지금 못 박고 갈게요. 삼중 루프의 순서, 특히 k가 반드시 가장 바깥이어야 한다는 겁니다.
k 가 가장 바깥이어야 하는 이유
k 를 바깥에 두면
k=0 다 돌면 → "0 번만 경유지로 쓸 때" 의 모든 쌍 최단 거리 완성
k=1 다 돌면 → "0,1 을 경유지로 쓸 때" 의 모든 쌍 최단 거리 완성
k=2 다 돌면 → "0,1,2 를 경유지로 쓸 때" ...
한 단계씩 답이 완성되며 쌓인다
i 나 j 를 바깥에 두면
dist[i][k] 나 dist[k][j] 가 아직 안 채워진 상태에서 참조된다
→ 틀린 답이 나온다
E-5의 구간 DP에서 "짧은 구간부터 채워야 긴 구간이 참조할 값이 준비된다"고 했던 것과 정확히 같은 원리예요. 작은 부분 문제가 먼저 완성돼 있어야 큰 문제가 그걸 딛고 설 수 있습니다. 여기서는 "경유지를 몇 개까지 허락했나"가 그 단계 역할을 하고요. 루프 순서를 i, j, k로 잘못 쓰면 조용히 틀린 답이 나오니, 손가락에 k가 먼저라고 새겨 두세요.
돌려 보겠습니다. 0에서 1로 4, 0에서 3으로 5, 1에서 2로 1, 2에서 3으로 3인 그래프를 인접 행렬로 넣으면 결과는 이렇습니다.
플로이드-워셜 결과 (행 = 출발, 열 = 도착)
→0 →1 →2 →3
0→ 0 4 5 5
1→ inf 0 1 4
2→ inf inf 0 3
3→ inf inf inf 0
0에서 2로 가는 직행 간선은 없는데 결과가 5로 채워졌죠. 1을 경유해서 4+1=5로 닿는 길을 찾아낸 겁니다. 0에서 3은 직행 5와 경유 5+3=8 중 직행이 이겨 5로 남았고요. 방향 그래프라 역방향은 전부 inf입니다.
빅오는 코드 모양 그대로예요. 삼중 루프니 시간 O(V³), 표 하나라 공간 O(V²)입니다. 1초 1억 잣대로 V³이 1억이면 V는 약 460인데, 파이썬은 반복문 상수가 커서 실전에서는 V 200~300 언저리가 현실적인 선입니다.
다익스트라를 V번 돌리는 것과 비교해 볼까요. 그쪽은 O(V·E log V)입니다. 간선이 성글면(E ≈ V) 이게 훨씬 빠르지만, 조밀하면(E ≈ V²) O(V³ log V)라 오히려 플로이드-워셜이 이겨요. 그리고 무엇보다 코드가 세 줄이라 실수할 여지가 적습니다. 정점이 작을 때 플로이드-워셜을 고르는 진짜 이유는 속도보다 간결함인 경우가 많아요.
💡 한 줄 정리
플로이드-워셜은 "i에서 j로 갈 때 k를 경유하면 짧아지나"를 모든 조합에 던져 모든 쌍의 최단 거리를 구하는 2차원 DP다. 경유지 k를 반드시 가장 바깥 루프에 둬야 단계별로 답이 완성되며, 시간 O(V³)·공간 O(V²)이라 정점 수백 이하일 때 쓴다.
🙋 학생 질문 — "플로이드-워셜은 음수 간선도 되나요?"
됩니다. 음수 간선이 있어도 정상적으로 동작해요. 다익스트라와 달리 "확정"이라는 개념이 없어서, 나중에 더 짧은 길이 발견되면 언제든 갱신되거든요. 벨만-포드가 음수를 다룰 수 있는 것과 같은 이유입니다.
단 음의 사이클은 여전히 안 됩니다. 사이클을 돌수록 거리가 줄어드니 최단 거리라는 답 자체가 없어지니까요.
재미있는 건 플로이드-워셜에서는 음의 사이클을 아주 쉽게 잡아낼 수 있다는 점이에요. 알고리즘을 다 돌린 뒤 대각선을 보면 됩니다. dist[i][i], 곧 "i에서 출발해 i로 돌아오는 거리"가 음수인 정점이 하나라도 있으면, 그 정점을 지나는 음의 사이클이 있다는 뜻이거든요. 자기 자신으로 돌아왔는데 거리가 마이너스라면 도는 것 자체가 이득이라는 얘기니까요.
모든 i 에 대해 dist[i][i] < 0 이면 → 음의 사이클 존재
벨만-포드는 간선을 한 번 더 훑는 검사가 따로 필요했는데, 플로이드-워셜은 이미 계산된 표의 대각선만 보면 끝납니다. 이런 소소한 편의가 "정점이 적을 땐 그냥 플로이드-워셜"을 고르게 만드는 이유 중 하나예요.
Step 7: "셋 중 무엇을 고르나" (~15분)
오늘 세 알고리즘을 만났습니다. 코딩테스트에서 진짜 시험대에 오르는 건 각각을 구현하는 능력이 아니라, 문제를 읽고 어느 것을 꺼낼지 3초 안에 정하는 능력이에요. 마지막 Step에서 그 판단 기준을 정리하겠습니다.
먼저 세 알고리즘을 한 표에 놓고 봅시다.
| 다익스트라 | 벨만-포드 | 플로이드-워셜 | |
|---|---|---|---|
| 구하는 것 | 한 점 → 모든 점 | 한 점 → 모든 점 | 모든 점 → 모든 점 |
| 음수 간선 | ❌ 불가 | ✅ 가능 | ✅ 가능 |
| 음의 사이클 | ❌ | ✅ 검출 | ✅ 대각선으로 검출 |
| 시간 복잡도 | O(E log V) | O(V·E) | O(V³) |
| 공간 복잡도 | O(V) | O(V) | O(V²) |
| 그래프 표현 | 인접 리스트 | 간선 목록 | 인접 행렬 |
| 현실적 한계 | E 수십만~100만 | V 수천 × E 수만 | V 200~300 |
이 표를 외우려 하지 마시고, 아래 판단 순서를 몸에 붙이세요. 위에서부터 차례로 물으면 됩니다.
최단 경로 알고리즘 고르는 순서
① 간선 가중치가 전부 같은가?
예 → BFS (E-2). 다익스트라는 로그만큼 손해
아니오 ↓
② 모든 쌍의 거리가 필요한가?
예 → 정점이 작으면(수백 이하) 플로이드-워셜
정점이 크고 간선이 성글면 다익스트라를 V 번
아니오 ↓
③ 음수 간선이 있는가?
예 → 벨만-포드 (음의 사이클도 여기서 검출)
아니오 ↓
④ 그 외 전부 → 다익스트라 + heapq (실전 기본값)
문제 지문에서 실마리를 읽어 내는 감각도 함께 챙겨 가시죠. 출제자는 보통 이런 말로 알고리즘을 알려 줍니다.
| 지문에 이런 말이 보이면 | 떠올릴 것 |
|---|---|
| "최소 비용", "가장 빠른 경로", "요금이 가장 싼" | 다익스트라 |
| "각 도시 사이의 거리를 모두", "임의의 두 지점" | 플로이드-워셜 |
| "시간이 거꾸로 흐르는", "이득이 되는 이동", "무한히 줄일 수 있는지" | 벨만-포드 (음의 사이클) |
| "몇 번 만에", "최소 이동 횟수", 가중치 언급이 아예 없음 | BFS (E-2) |
| "반드시 X를 거쳐서" | 다익스트라 여러 번 조합 |
마지막 줄이 흥미롭죠. 실전 문제는 오늘 배운 알고리즘을 그대로 쓰기보다 살짝 비틀어 냅니다. "반드시 경유지 X를 거쳐 A에서 B로 가는 최단 거리"라면, A에서 출발하는 다익스트라 한 번과 X에서 출발하는 다익스트라 한 번을 돌려 dist_A[X] + dist_X[B]로 합치면 되고요. "왕복 최단 거리"라면 간선 방향을 전부 뒤집은 그래프를 하나 더 만들어 다익스트라를 돌립니다.
그러니 오늘 배운 다섯 함수를 부품으로 보세요. 문제가 요구하는 조립법이 매번 조금씩 다를 뿐, 부품 자체는 오늘 만든 것 그대로입니다.
마지막으로 입력 크기를 보고 판단하는 A-1의 습관을 다시 한번 되새기고 갈게요. 정점 10만 개에 간선 30만 개인 문제에 플로이드-워셜을 꺼내면 V³이 10의 15승이라 손도 못 댑니다. 반대로 정점 100개에 모든 쌍을 물었는데 다익스트라를 100번 짜면 코드만 길어지죠. 문제를 읽고 가장 먼저 볼 것은 언제나 입력 크기의 범위입니다.
💡 한 줄 정리
최단 경로 문제는 "가중치가 다 같은가 → 모든 쌍인가 → 음수가 있는가"를 차례로 물어 알고리즘을 고른다. 기본값은 다익스트라와 heapq이고, 모든 쌍에 정점이 작으면 플로이드-워셜, 음수 간선이나 음의 사이클이면 벨만-포드다. 실전 문제는 이 부품들을 두 번 돌리거나 그래프를 뒤집어 조립하는 형태로 나온다.
🙋 학생 질문 — "다익스트라로 최장 경로를 구하면 안 되나요? 부등호만 뒤집으면 될 것 같은데요."
아주 자연스러운 발상인데, 안타깝게도 안 됩니다. 그리고 그 이유가 오늘 배운 것의 좋은 복습이 돼요.
부등호를 뒤집어 "가장 먼 것부터 꺼내 확정"한다고 해 봅시다. Step 1의 정당성 논증이 어떻게 되는지 따라가 보세요. 원래는 "미확정 중 최소인 u보다 짧은 길은 없다, 더 걸어가 봐야 커지기만 하니까"였죠. 뒤집으면 "미확정 중 최대인 u보다 긴 길은 없다, 더 걸어가 봐야..." 여기서 막힙니다. 더 걸어가면 더 길어지거든요. 확정의 근거가 통째로 무너집니다.
사실 이건 알고리즘의 흠이 아니라 문제 자체의 성질이에요. 그래프의 최장 경로 문제는 NP-난해로 알려져 있습니다. 다항 시간에 푸는 방법을 아무도 못 찾았고, 찾을 수 없으리라 여겨지는 부류예요. 같은 정점을 계속 돌면 길이를 무한히 늘릴 수 있어서, 사이클이 있는 그래프에선 답 자체가 정의되지 않기도 하고요.
다만 사이클이 없는 방향 그래프(DAG)라면 최장 경로도 풀립니다. 이때는 정점을 적절한 순서로 늘어놓고 앞에서부터 DP로 채워 나가면 돼요. 그 "적절한 순서"를 만들어 주는 알고리즘이 바로 다음 시간에 배울 위상 정렬입니다. 오늘 답 못 한 질문 하나를 다음 시간에 회수하는 셈이에요.
마무리
오프닝에서 오늘은 새로 외우는 시간이 아니라 이미 아는 것들이 맞물리는 시간이라고 했죠. 실제로 그랬는지 되짚어 볼까요. 힙은 최소를 꺼내는 그릇으로, 인접 리스트는 이웃을 훑는 통로로, BFS는 꺼내고 이완하는 골격으로, 교환 논법은 "왜 확정해도 되는가"의 증명으로, DP는 플로이드-워셜의 정체로 각각 제 몫을 했습니다. 새로 외운 것보다 다시 만난 것이 더 많은 하루였어요.
오늘 배운 핵심 세 가지
💡 하나 — 가중치가 생기면 걸음 수와 비용이 갈라진다. BFS가 최단을 보장하던 건 간선 값이 모두 같을 때뿐이었어요. 값이 제각각이면 간선 하나짜리 직행보다 간선 셋짜리 우회로가 쌀 수 있습니다. 그래서 큐를 우선순위 큐로 갈아 끼워 "먼저 들어온 것"이 아니라 "가장 가까운 것"을 꺼내는 다익스트라가 필요했죠. 골격은 BFS 그대로, 꺼내는 규칙만 바뀌었습니다.
💡 둘 — 그리디의 정당성은 전제 위에 서 있다. 미확정 중 최소를 꺼내면 곧 확정이라는 다익스트라의 근거는, 간선 가중치가 음수가 아니라는 전제에 통째로 기대고 있었어요. 음수 간선이 하나만 껴도 확정이 뒤집혀 알고리즘이 틀립니다. E-3에서 거스름돈 반례로 배운 "그리디는 정당성을 따져야 쓴다"가 그래프에서 다시 확인된 셈이에요. 전제가 깨지면 확정을 포기하고 이완만 반복하는 벨만-포드로 갑니다.
💡 셋 — 알고리즘은 외우는 게 아니라 고르는 것이다. 다익스트라·벨만-포드·플로이드-워셜은 우열이 아니라 용도가 다릅니다. 가중치가 같으면 BFS, 모든 쌍에 정점이 작으면 플로이드-워셜, 음수가 끼면 벨만-포드, 나머지는 다익스트라. 이 판단을 문제의 입력 크기와 조건에서 읽어 내는 게 A-1부터 깔아 온 빅오 감각의 최종 형태입니다.
다음 시간 예고
다음 시간(F-2)에는 고급 그래프의 나머지 절반, 집합과 순서를 다룹니다. 오늘 우리가 그래프에 던진 질문은 줄곧 "얼마나 먼가"였어요. 다음 시간의 질문은 달라집니다.
"이 둘이 같은 무리에 속하나?" — 친구의 친구의 친구까지 따라가지 않고도 한 번에 판별하는 유니온 파인드를 만납니다. 경로 압축이라는 기법 하나로 거의 O(1)에 가까워지는, 놀랍도록 짧고 강력한 자료구조예요.
"어떤 순서로 처리해야 하나?" — 선수 과목이 있는 수강 계획, 의존성이 얽힌 빌드 순서를 줄 세우는 위상 정렬입니다. C-3 과제에서 세어 본 진입 차수가 여기서 열쇠로 쓰이고, E-2의 BFS가 그대로 골격이 돼요.
"최소 비용으로 전부 잇는 방법은?" — 모든 정점을 가장 싸게 연결하는 최소 신장 트리(MST)를 크루스칼과 프림 두 갈래로 짭니다. 크루스칼은 유니온 파인드를, 프림은 오늘 쓴 힙을 그대로 가져다 써요.
그리고 오늘 제가 답을 미뤄 둔 질문 하나 기억하시죠. 사이클 없는 그래프의 최장 경로는 위상 정렬로 풀린다고 했던 것. 다음 시간에 회수하겠습니다.
과제
[기초] 신호가 모든 컴퓨터에 닿는 시간
네트워크에 컴퓨터 n대가 있고(번호 0부터 n-1), 단방향 연결이 [(출발, 도착, 지연시간), ...] 목록으로 주어집니다. start번 컴퓨터에서 신호를 보낼 때, 모든 컴퓨터가 신호를 받기까지 걸리는 시간을 반환하는 함수 network_delay(n, connections, start)를 작성하세요. 한 대라도 신호를 받을 수 없으면 -1을 반환합니다.
신호는 여러 갈래로 동시에 퍼져 나가므로, 각 컴퓨터는 자기에게 닿는 가장 빠른 경로의 시간에 신호를 받습니다. 그러니 전체가 다 받는 시간은 "가장 늦게 받는 컴퓨터의 시간"이 되겠죠. 간선 목록을 인접 리스트로 바꾸는 것부터 시작하면 됩니다. 시간·공간 복잡도를 빅오로 함께 적으세요.
[응용] 반드시 거쳐야 하는 곳이 있다면
가중치 방향 그래프와 출발점 start, 도착점 end, 그리고 반드시 거쳐야 하는 경유지 via가 주어집니다. start에서 출발해 via를 반드시 지나 end에 도착하는 최단 거리를 반환하는 함수 shortest_via(graph, start, via, end)를 작성하세요. 그런 경로가 없으면 float('inf')를 반환합니다.
핵심은 오늘 만든 함수를 부품으로 조립하는 데 있어요. 경유지를 반드시 지나야 한다면 그 경로는 두 토막으로 쪼개집니다. 어디를 어떻게 자를지, 그리고 각 토막을 무엇으로 구할지 생각해 보세요. 다익스트라를 몇 번 돌려야 하는지도 함께 답하시고, 전체 시간 복잡도를 빅오로 적으세요.
[심화] 갔다가 돌아오기
마을 n개가 단방향 도로로 이어져 있습니다. 모든 마을 사람들이 party번 마을의 잔치에 갔다가 자기 마을로 돌아옵니다. 각자 갈 때도 올 때도 최단 경로를 택할 때, 가장 오래 걸리는 사람의 왕복 시간을 반환하는 함수 longest_round_trip(n, roads, party)를 작성하세요.
도로가 단방향이라 갈 때와 올 때의 거리가 다르다는 게 함정입니다. party에서 각 마을로 가는 거리는 다익스트라 한 번이면 나오는데, 각 마을에서 party로 오는 거리는 어떻게 구할까요? 마을마다 다익스트라를 돌리면 n번이라 느립니다. 간선의 방향을 전부 뒤집은 그래프를 하나 만들어 보세요. 뒤집은 그래프에서 party를 출발점으로 삼으면 무엇이 나오는지 생각해 보시면 길이 열립니다. 다익스트라를 총 몇 번 돌리는지, 시간 복잡도가 얼마인지 함께 적으세요.
생각해볼 주제
1. 음수 간선 하나가 왜 알고리즘 전체를 무너뜨리나
다익스트라에 음수 간선이 있는 그래프를 넣으면, 프로그램은 오류 없이 멀쩡히 돌고 그럴듯한 숫자를 냅니다. 다만 그 숫자가 틀렸을 뿐이죠. 조용히 틀리는 이 성질이 실무에서는 특히 위험합니다. 다익스트라의 어떤 단계가 정확히 무엇을 가정하고 있길래 음수 간선 하나에 무너지는지, Step 1의 교환 논법으로 되짚어 설명해 보세요. 그리고 이런 "조용히 틀리는" 문제를 코딩테스트에서 미리 막으려면 무엇을 확인해야 할지도 함께 생각해 보시죠.
2. 더 빠른 알고리즘이 항상 더 나은 선택인가
O(E log V)인 힙 다익스트라는 O(V²)인 선형 탐색판보다 복잡도가 낮습니다. 그런데 간선이 빽빽한 그래프에서는 오히려 느려지고, 코드도 더 깁니다. 플로이드-워셜과 "다익스트라 V번"의 관계도 비슷하고요. 복잡도 표기의 대소 관계와 실제 성능이 갈리는 이유가 무엇인지, 그리고 알고리즘을 고를 때 빅오 말고 무엇을 함께 봐야 하는지 정리해 보세요.
3. 최단 경로 문제인지 어떻게 알아채나
오늘 다룬 알고리즘들은 지문에 "그래프"나 "최단 경로"라는 말이 없어도 등장합니다. 환전 경로, 작업 스케줄, 게임의 상태 전이처럼 겉보기엔 그래프와 무관해 보이는 문제가 실은 최단 경로 문제인 경우가 많아요. 어떤 문제를 그래프로 모델링할 수 있다고 판단하려면 무엇이 보여야 할까요? 정점과 간선에 각각 무엇을 대응시킬지를 정하는 기준을 세워 보시고, 오늘 배운 것 중 무엇을 쓸지까지 이어서 생각해 보세요.
✅ 예시 답안정답 보기
아래는 정답 하나가 아니라 모범 사례 중 하나입니다. 채점 포인트로 "무엇을 봐야 하는가"를 먼저 잡고, 풀이마다 시간·공간 빅오를 빠짐없이 답니다. 오늘 과제의 공통 주제는 새 알고리즘을 만들지 않는 것이에요. 셋 다 수업에서 만든 다익스트라를 부품으로 쓰고, 달라지는 건 입력을 어떻게 빚어 넣고 결과를 어떻게 조립하느냐뿐입니다. 코테·면접에서 이 유형이 어떻게 나오는지도 짧게 덧붙였으니, 자신이 짠 풀이와 나란히 견줘 보세요.
🎯 [과제 1 예시답안] 신호가 모든 컴퓨터에 닿는 시간
채점 포인트
| 포인트 | 설명 | 배점 |
|---|---|---|
| 간선 목록 → 인접 리스트 | [(u, v, w), ...]를 {정점: [(이웃, 가중치), ...]}로 변환했는가 |
상 |
| 고립 정점 초기화 | 간선에 없는 정점도 0..n-1 전체를 미리 깔아 뒀는가 |
상 |
| 최댓값이 답 | "전부 받는 시간 = 최단 거리들의 최댓값"임을 잡았는가 | 상 |
| 도달 불가 처리 | inf가 하나라도 있으면 -1을 반환했는가 |
상 |
| 빅오 표기 | 시간 O(E log V)·공간 O(V+E) | 중 |
풀이 예시
문제를 한 겹 벗겨 보면 익숙한 얼굴이 나옵니다. 신호는 여러 갈래로 동시에 퍼지니, 각 컴퓨터는 자기에게 닿는 가장 빠른 경로의 시간에 신호를 받아요. 그게 곧 start로부터의 최단 거리입니다. 그러면 "전부 다 받는 시간"은 가장 늦게 받는 컴퓨터를 기다려야 하니 최단 거리들의 최댓값이 되죠.
그러니 이 과제에서 실제로 할 일은 다익스트라를 짜는 게 아니라 입력을 다익스트라가 먹을 수 있는 모양으로 바꾸는 것입니다.
# algorithms/exercises_f1.py
def network_delay(n, connections, start):
graph = {v: [] for v in range(n)} # 고립 정점도 빠지지 않게 0..n-1을 먼저 깐다
for u, v, delay in connections: # 간선 목록 → 인접 리스트
graph[u].append((v, delay))
dist = dijkstra_heap(graph, start) # 오늘 만든 함수를 그대로 재사용
slowest = max((dist[v] for v in range(n)), default=0) # 가장 늦게 받는 컴퓨터가 기준
return -1 if slowest == INF else slowest
빅오는 시간 O(E log V)·공간 O(V+E)입니다. 간선 목록을 훑어 인접 리스트를 만드는 데 O(E), 다익스트라 한 번이 O(E log V), 최댓값 찾기가 O(V)라 전체가 다익스트라에 묻혀요.
돌려 보면 network_delay(4, [(0,1,1), (0,2,4), (1,2,2), (2,3,3)], 0)은 6입니다. 거리표가 {0: 0, 1: 1, 2: 3, 3: 6}인데, 여기서 2번이 눈여겨볼 대목이에요. 직행 4보다 1을 거치는 1+2=3이 빨라 3으로 정해졌고, 그 덕에 3번도 3+3=6이 됐습니다. 우회로가 더 빠른 Step 1의 상황이 그대로 나온 거죠.
신호가 퍼지는 시각 (start = 0)
시각 0 : 컴퓨터 0 이 신호를 보낸다
시각 1 : 컴퓨터 1 도착 (0→1, 비용 1)
시각 3 : 컴퓨터 2 도착 (0→1→2 로 3. 직행 0→2 의 4 보다 빠르다)
시각 6 : 컴퓨터 3 도착 (0→1→2→3)
← 마지막 도착 시각 6 이 답
흔한 실수 둘을 짚을게요. 첫째, 고립 정점을 빠뜨리기입니다. 간선에 한 번도 등장하지 않는 컴퓨터가 있으면 인접 리스트를 간선만 보고 만들 때 그 정점이 통째로 빠져요. 거리표에 아예 안 들어오니 "못 받는 컴퓨터"를 놓치고 엉뚱하게 성공을 반환합니다. 그래서 {v: [] for v in range(n)}으로 먼저 깔아 두는 한 줄이 결정적이에요. network_delay(4, [(0,1,1), (1,2,1)], 0)이 -1을 내는 게 이 처리 덕분입니다. 3번 컴퓨터가 아무 도로에도 안 나오거든요.
둘째, 최댓값이 아니라 합을 구하기입니다. "모두가 받는 시간"을 각 거리의 합으로 착각하기 쉬운데, 신호는 순차가 아니라 동시에 퍼지므로 가장 느린 하나만 기다리면 됩니다.
💡 튜터의 한마디: LeetCode 743 "Network Delay Time"이 이 유형 그대로예요. 챙길 감각은 "문제의 절반은 입력을 알고리즘이 먹는 모양으로 빚는 일"입니다. 코딩테스트 그래프 문제는 간선 목록, 격자, 좌표 쌍, 심지어 문자열 관계처럼 온갖 형태로 들어와요. 알고리즘을 아는 것만큼 "이 입력을 인접 리스트로 어떻게 옮기지"를 빠르게 처리하는 게 실전 속도를 가릅니다.
🎯 [과제 2 예시답안] 반드시 거쳐야 하는 곳이 있다면
채점 포인트
| 포인트 | 설명 | 배점 |
|---|---|---|
| 두 토막 분해 | 경로를 start→via와 via→end 둘로 쪼갤 수 있음을 봤는가 |
상 |
| 독립성 근거 | 두 토막이 서로 영향을 주지 않아 각각 최소화해도 되는 이유를 설명했는가 | 상 |
| 다익스트라 2회 | 출발점만 바꿔 두 번 호출하고 결과를 더했는가 | 상 |
| 도달 불가 처리 | inf + 무엇 = inf가 저절로 흘러 나감을 이해했는가 |
중 |
| 빅오 표기 | 시간 O(E log V) (상수배 2)·공간 O(V) | 중 |
풀이 예시
"반드시 X를 거쳐라"는 조건은 겉보기와 달리 문제를 어렵게 만들지 않습니다. 오히려 경로를 정확히 두 토막으로 잘라 주는 친절한 힌트예요.
경유지가 경로를 두 토막으로 자른다
start ────────▶ via ────────▶ end
앞 토막 뒤 토막
전체 최단 = (start→via 최단) + (via→end 최단)
여기서 한 번 의심하고 넘어갈 지점이 있어요. 앞 토막을 일부러 돌아가면 뒤 토막이 짧아지는 일은 없을까요? 없습니다. 두 토막은 via에서 완전히 끊겨 있어서, 앞 토막이 어떤 길로 왔든 뒤 토막이 보는 것은 "via에 서 있다"는 사실뿐이거든요. 서로에게 아무 정보도 넘기지 않으니 각각을 독립적으로 최소화하면 그게 전체 최소입니다. 이 독립성이 두 토막으로 쪼개도 되는 근거예요.
그러면 남은 건 각 토막을 구하는 일인데, 둘 다 "한 점에서 출발하는 최단 거리"라 다익스트라를 출발점만 바꿔 두 번 돌리면 끝납니다.
# algorithms/exercises_f1.py
def shortest_via(graph, start, via, end):
to_via = dijkstra_heap(graph, start) # d1: start에서 모든 정점까지
from_via = dijkstra_heap(graph, via) # d2: via에서 모든 정점까지
# inf + 무엇이든 inf라, 어느 토막이 끊겨도 결과가 저절로 inf가 된다
return to_via.get(via, INF) + from_via.get(end, INF)
본체가 세 줄입니다. 새 알고리즘을 짜는 대신 있는 부품을 두 번 쓴 거예요.
빅오는 시간 O(E log V)·공간 O(V)입니다. 다익스트라를 두 번 돌리지만 상수배 2가 붙을 뿐 복잡도 차수는 그대로예요. "두 배 느려지니 비효율 아닌가" 싶을 수 있는데, 빅오에서 상수는 무시된다는 A-1의 원칙이 바로 이럴 때 힘을 발휘합니다.
도달 불가 처리가 우아하게 풀리는 것도 봐 두세요. via에 못 닿으면 to_via[via]가 inf이고, inf에 무엇을 더해도 inf라 결과가 저절로 inf로 흘러 나갑니다. 따로 조건문을 달 필요가 없어요. Step 2에서 INF를 float('inf')로 둔 선택이 여기서 배당을 주는 셈입니다.
실제 값으로 확인해 볼게요. 아래 그래프에서 0에서 3으로 가는 최단 거리는 0→2→3으로 6입니다.
경유지가 우회를 강제하는 그래프
0 --(1)--> 1 --(10)--> 3 1 을 거치면 11
0 --(5)--> 2 --(1)--> 3 2 를 거치면 6 ← 그냥 최단
4 : 들어오는 간선이 없어 0 에서 못 닿는다
shortest_via(graph, 0, 1, 3)= 11 — 1을 강제로 거치느라 최단보다 5만큼 손해shortest_via(graph, 0, 2, 3)= 6 — 원래 최단 경로가 이미 2를 지나 손해가 없음shortest_via(graph, 0, 0, 3)= 6 —via == start면 앞 토막이 0이라 그냥 최단shortest_via(graph, 0, 4, 3)= inf — 4번에 닿을 수 없어 경로 자체가 없음
첫 줄과 둘째 줄의 차이가 이 문제의 재미예요. 경유지가 이미 최단 경로 위에 있으면 아무 대가도 치르지 않지만, 벗어나 있으면 그만큼 값을 더 냅니다.
💡 튜터의 한마디: 백준 1504 "특정한 최단 경로"가 이 유형인데, 거기선 경유지가 둘이라 한 겹 더 들어갑니다. 두 경유지를 거치는 순서가 start→A→B→end와 start→B→A→end 두 가지라, 양쪽을 다 계산해 작은 쪽을 골라야 해요. 챙길 감각은 "제약 조건이 경로를 토막 내면, 토막마다 아는 알고리즘을 돌리고 이어 붙인다"입니다. 경유지가 k개면 다익스트라를 k+1번 돌리고 순서 조합을 따지는 식으로 자연스럽게 확장돼요.
🎯 [과제 3 예시답안] 갔다가 돌아오기
채점 포인트
| 포인트 | 설명 | 배점 |
|---|---|---|
| 단방향 함정 인식 | 갈 때와 올 때 거리가 다름을 알고 두 방향을 따로 구했는가 | 상 |
| 역방향 그래프 | 간선을 뒤집어 V번 다익스트라를 1번으로 접었는가 | 상 |
| 역방향의 정당성 | 뒤집은 그래프의 party→x가 원래의 x→party와 같은 이유를 설명했는가 |
상 |
| 왕복 불가 처리 | 한쪽이라도 끊긴 마을을 후보에서 제외했는가 | 중 |
| 빅오 표기 | 시간 O(E log V) (다익스트라 2회)·공간 O(V+E) | 상 |
풀이 예시
왕복은 두 토막입니다. 올 때(party → 각 마을)는 쉬워요. 출발점이 party 하나로 고정이라 다익스트라 한 번이면 모든 마을까지의 거리가 한꺼번에 나옵니다.
문제는 갈 때(각 마을 → party)예요. 출발점이 마을마다 다르니 그대로 두면 마을 수만큼 다익스트라를 돌려야 하고, 그러면 O(V · E log V)로 뜁니다. 마을이 10만 개면 손도 못 대죠.
여기서 발상을 하나 뒤집습니다. 정확히는 간선을 뒤집습니다.
간선을 뒤집으면 "각 마을 → party" 가 "party → 각 마을" 이 된다
원래 그래프 뒤집은 그래프
x --(w)--> y y --(w)--> x
원래에서 "x → party" 최단 경로가
x → a → b → party (합 = 12) 라면
뒤집은 그래프에는 그 간선들이 전부 거꾸로 달려 있으므로
party → b → a → x (합 = 12) 가 그대로 존재한다
→ 뒤집은 그래프에서 party 출발 다익스트라 한 번이면
모든 마을의 "마을 → party" 거리가 한꺼번에 나온다
경로를 거꾸로 읽은 것뿐이라 지나는 간선도, 가중치 합도 똑같습니다. 그래서 출발점이 제각각이던 V개의 문제가 party 출발 하나로 접혀요. V번이 1번이 됩니다.
# algorithms/exercises_f1.py
def longest_round_trip(n, roads, party):
forward = {v: [] for v in range(n)} # 원래 방향: party → 마을 (올 때)
backward = {v: [] for v in range(n)} # 뒤집은 방향: 마을 → party 를 한 번에 (갈 때)
for u, v, time in roads:
forward[u].append((v, time))
backward[v].append((u, time)) # 간선을 거꾸로 달아 둔다
go_home = dijkstra_heap(forward, party) # party → 각 마을 (올 때)
to_party = dijkstra_heap(backward, party) # 역방향에서 party 출발 = 각 마을 → party (갈 때)
longest = -1
for v in range(n):
if to_party[v] == INF or go_home[v] == INF:
continue # 한쪽이라도 끊기면 왕복 불가 — 후보에서 뺀다
longest = max(longest, to_party[v] + go_home[v])
return longest
간선 하나를 읽으며 forward와 backward 두 그래프에 동시에 다는 게 요령입니다. 두 번 훑을 필요가 없어요.
빅오는 시간 O(E log V)·공간 O(V+E)입니다. 두 그래프를 만드는 데 O(E), 다익스트라 2회가 O(E log V)죠. 마을마다 돌렸을 때의 O(V · E log V)와 견주면 V배가 줄어든 겁니다. 정점 10만·간선 30만 그래프에서 이 차이는 통과와 시간 초과를 가릅니다.
실제로 돌려 보면 갈 때와 올 때가 왜 다른지 눈에 들어와요.
도로가 단방향이라 왕복 거리가 마을마다 다르다 (party = 0)
마을 갈 때 (마을→0) 올 때 (0→마을) 왕복
---- ------------------- -------------- ----
1 1 (1→0) 9 (0→1) 10 ← 가장 오래
2 6 (2→3→0, 우회) 3 (0→2) 9
3 4 (3→0) 4 (0→3) 8
4 2 (4→0) 올 때 도로 없음 불가
longest_round_trip(5, roads, 0)은 10을 냅니다. 마을 1을 보세요. 갈 때는 직통 도로로 1인데 올 때는 9예요. 같은 두 마을 사이인데도 방향에 따라 아홉 배 차이가 납니다. 여기서 갈 때 거리만 두 배 했다면 2가 나와 완전히 틀렸겠죠. 단방향 그래프에서 왕복은 반드시 두 방향을 따로 봐야 한다는 게 이 과제의 핵심입니다.
마을 4도 짚고 갈게요. 0으로 가는 도로는 있지만 0에서 오는 도로가 없어 왕복이 불가능합니다. 이런 마을은 후보에서 빼야지, inf를 그대로 후보에 넣으면 답이 inf가 되어 버려요.
💡 튜터의 한마디: 백준 1238 "파티"가 이 유형 그대로입니다. 챙길 감각은 "출발점이 여럿이고 도착점이 하나면, 간선을 뒤집어 출발점 하나로 만든다"예요. 이 뒤집기는 최단 경로뿐 아니라 그래프 문제 전반에서 쓰이는 도구입니다. "여러 곳에서 한 곳으로"라는 말이 보이면 반사적으로 역방향 그래프를 떠올리세요. 반대로 "한 곳에서 여러 곳으로"는 그냥 다익스트라 한 번이고요.
🤔 [생각해볼 주제 1] 음수 간선 하나가 왜 알고리즘 전체를 무너뜨리나
문제 상황 요약
다익스트라에 음수 간선이 있는 그래프를 넣으면 프로그램은 오류 없이 멀쩡히 돌고 그럴듯한 숫자를 냅니다. 다만 그 숫자가 틀렸을 뿐이죠. 예외도 경고도 없이 조용히 틀리는 이 성질이 왜 생기는지, 그리고 이런 종류의 오류를 어떻게 미리 막을지 정리해 봅시다.
튜터의 가이드 및 해설
무너지는 지점은 정확히 하나, "꺼내는 순간이 곧 확정"이라는 단계입니다.
Step 1의 논증을 다시 펼쳐 볼게요. 미확정 중 거리가 최소인 정점 u를 꺼냈을 때, u에 더 짧게 닿는 길이 있다면 그 길은 미확정 정점 x를 거쳐야 하고, x의 거리 ≥ u의 거리이며, 거기서 더 걸어가면 거리가 커지기만 하므로 모순이라고 했죠. 이 사슬에서 마지막 고리 하나가 음수 간선에 끊깁니다. "더 걸어가면 줄지 않는다"가 거짓이 되니까요. 그러면 u를 확정한 판단의 근거가 사라지는데, 확정 개념에 기대는 구현(수업의 선형 탐색판처럼 visited를 두는 쪽)은 이미 u를 확정 목록에 넣고 다시 보지 않습니다.
- Option A — 다익스트라를 그대로 쓴다: 빠릅니다(O(E log V)). 하지만 음수 간선이 있으면 나중에 발견될 더 짧은 경로를 반영하지 못해 조용히 틀린 값을 냅니다. 예외가 안 나므로 테스트를 통과해 버릴 수도 있어요.
- Option B — 벨만-포드로 간다: 확정이라는 개념 자체를 버리고 모든 간선을 V-1회 이완합니다. 느리지만(O(V·E)) 음수를 정면으로 다루고 음의 사이클까지 검출해요.
- Option C — 가중치를 양수로 옮긴다: 모든 간선에 상수를 더해 음수를 없애는 방법인데, 이건 대체로 틀립니다. 간선 수가 다른 두 경로에 더해지는 총량이 달라져 대소 관계가 뒤집히거든요. 간선 3개짜리 경로에는 3c가, 1개짜리에는 c가 더해지니까요.
현업에서든 시험장에서든 판단 순서는 단순합니다. 가중치의 부호를 먼저 확인하고, 음수가 하나라도 있을 수 있으면 다익스트라를 후보에서 뺍니다. Option C 같은 "우회로 해결"은 특수한 조건(모든 경로의 간선 수가 같다든지)에서만 성립하니 기본 전략으로 삼지 마세요.
조용히 틀리는 문제를 막는 실전 습관도 하나 챙기시죠. 문제의 입력 제약을 읽을 때 가중치 범위를 반드시 확인하는 겁니다. 1 ≤ w ≤ 1000처럼 하한이 양수면 다익스트라가 안전하고, -1000 ≤ w처럼 음수가 열려 있으면 그 자체가 출제자의 신호예요.
🎯 코테·면접에선 이렇게 나온다
"다익스트라에 음수 간선을 넣으면 어떻게 되나요?"가 단골입니다. "안 됩니다"는 절반이에요. "왜 안 되는지"를 알고리즘의 어느 단계에서 짚느냐가 갈립니다.
"다익스트라는 미확정 정점 중 거리가 최소인 것을 꺼내는 순간 그 거리를 확정하고 다시 보지 않습니다. 이 확정이 정당한 근거는 '남은 경로를 더 걸어가면 거리가 커지기만 한다'인데, 음수 간선이 있으면 그 전제가 깨져 이미 확정한 거리가 나중에 더 짧아질 수 있어요. 그런데 알고리즘은 확정한 정점을 다시 보지 않으므로 예외 없이 조용히 틀린 값을 냅니다. 그래서 음수 간선이 있으면 확정 개념을 버리고 이완만 반복하는 벨만-포드로 갑니다."
💡 실무에선
"조용히 틀리는" 부류가 가장 잡기 어려운 버그입니다. 예외가 터지면 로그에 남고 알림이 오지만, 그럴듯한 숫자가 나오면 아무도 모른 채 몇 달을 지나가거든요. 그래서 알고리즘을 고를 때는 성능뿐 아니라 "이 알고리즘이 전제하는 것이 우리 데이터에서 항상 참인가"를 함께 따집니다. 요금 계산에 할인이나 적립이 생기는 순간 음수 간선이 등장할 수 있고, 그때 다익스트라를 쓰던 코드는 조용히 틀리기 시작해요. 전제를 코드 주석이나 입력 검증으로 남겨 두면, 나중에 요구사항이 바뀔 때 그 전제가 깨졌다는 걸 알아챌 수 있습니다.
🤔 [생각해볼 주제 2] 더 빠른 알고리즘이 항상 더 나은 선택인가
문제 상황 요약
O(E log V)인 힙 다익스트라는 O(V²)인 선형 탐색판보다 복잡도가 낮습니다. 그런데 간선이 빽빽한 그래프에서는 오히려 느려지고, 코드는 더 길어요. 플로이드-워셜과 "다익스트라 V번"의 관계도 비슷합니다. 복잡도 표기의 대소 관계와 실제 성능이 갈리는 이유를 정리하고, 알고리즘을 고를 때 빅오 말고 무엇을 함께 봐야 할지 생각해 봅시다.
튜터의 가이드 및 해설
원인은 두 복잡도가 서로 다른 변수로 쓰여 있다는 데 있습니다. O(V²)와 O(E log V)를 나란히 놓고 "log가 있으니 후자가 빠르다"고 읽으면 안 돼요. V와 E의 관계를 먼저 정해야 비교가 성립합니다.
E 를 V 로 바꿔 놓고 비교해야 대소가 보인다
성긴 그래프 (E ≈ V) 힙: O(V log V) 선형: O(V²) → 힙 압승
보통 그래프 (E ≈ V·√V) 힙: O(V√V log V) 선형: O(V²) → 힙 우세
조밀 그래프 (E ≈ V²) 힙: O(V² log V) 선형: O(V²) → 선형 우세
같은 알고리즘 쌍인데도 입력의 밀도에 따라 승자가 바뀝니다. 빅오는 "입력이 커질 때의 증가율"을 말할 뿐, 어떤 입력에서 커지는지는 우리가 정해 줘야 하거든요.
- Option A — 낮은 복잡도를 무조건 고른다: 대부분의 실전 그래프가 성글기 때문에 이 전략은 웬만하면 맞습니다. 단점은 조밀한 입력에서 손해를 보고, 구현이 복잡해 실수 여지가 는다는 것.
- Option B — 입력 특성을 보고 고른다: 정점 수와 간선 수의 관계를 확인한 뒤 정합니다. 가장 정확하지만 판단에 시간이 들어요.
- Option C — 단순한 쪽을 고른다: 플로이드-워셜을 세 줄로 짜는 선택이 여기 해당합니다. 정점이 작으면 복잡도가 높아도 코드가 짧아 버그가 적고 빨리 짜요.
현업에서든 시험장에서든 빅오 말고 함께 보는 것이 셋 있습니다. 첫째 상수 인자예요. 파이썬은 반복문 상수가 커서 O(V³) 플로이드-워셜이 이론상 V=460까지여도 실제로는 200~300이 현실적인 선이었죠. 둘째 구현 난도입니다. 시험장에서 20분 걸려 짠 O(E log V)보다 5분에 짠 O(V²)가 통과하면 그쪽이 이깁니다. 셋째 문제가 요구하는 출력 형태예요. 모든 쌍이 필요한데 단일 출발점 알고리즘을 V번 돌리면 코드가 길어집니다.
정리하면 빅오는 후보를 걸러 내는 1차 필터이지 최종 결정권자가 아닙니다. 시간 안에 드는 후보를 빅오로 추린 다음, 그 안에서 입력 특성·상수·구현 난도로 고르는 게 실전 순서예요.
🎯 코테·면접에선 이렇게 나온다
"O(E log V)가 O(V²)보다 항상 빠른가요?"는 지원자가 빅오를 기호로 외웠는지 의미로 이해했는지 가르는 질문입니다.
"아닙니다. 두 식이 서로 다른 변수로 쓰여 있어서, V와 E의 관계를 정해야 비교가 됩니다. 간선이 빽빽해 E가 V²에 가까우면 O(E log V)는 O(V² log V)가 되어 선형 탐색판의 O(V²)보다 오히려 로그만큼 느려요. 다만 실전 그래프는 대부분 성글어서 E가 V의 몇 배 수준이라, 기본값은 힙 버전으로 잡고 조밀한 입력일 때만 다시 생각합니다."
💡 실무에선
성능 개선을 요청받으면 복잡도부터 낮추려는 충동이 들지만, 실제로 효과가 큰 쪽은 상수 인자인 경우가 많습니다. 같은 O(n)이라도 파이썬 반복문 대신 내장 함수를 쓰거나 입출력을 버퍼링하면 몇 배가 빨라지죠. A-1에서 "같은 O(n)인데 왜 시간 초과가 나죠?"를 다룬 게 정확히 이 이야기였어요. 그래서 개선 순서는 보통 측정 → 병목 확인 → 상수부터 → 그래도 안 되면 복잡도입니다. 측정 없이 복잡도부터 손대면 코드만 복잡해지고 체감 성능은 그대로인 일이 흔해요.
🤔 [생각해볼 주제 3] 최단 경로 문제인지 어떻게 알아채나
문제 상황 요약
오늘 다룬 알고리즘들은 지문에 "그래프"나 "최단 경로"라는 말이 없어도 등장합니다. 환전 경로, 작업 스케줄, 게임의 상태 전이처럼 겉보기엔 그래프와 무관해 보이는 문제가 실은 최단 경로 문제인 경우가 많아요. 무엇이 보이면 그래프로 모델링할 수 있다고 판단할지, 정점과 간선에 각각 무엇을 대응시킬지 기준을 세워 봅시다.
튜터의 가이드 및 해설
그래프 모델링의 신호는 생각보다 단순합니다. "상태"와 "상태를 바꾸는 행동"이 보이면 그래프예요. 상태가 정점이고, 행동이 간선입니다. 그리고 그 행동에 비용이 붙어 있고 비용의 합을 최소화하라고 하면 최단 경로 문제예요.
무엇을 정점으로, 무엇을 간선으로 볼 것인가
지도·노선 정점 = 도시·역 간선 = 도로·구간, 가중치 = 거리·요금
환전 정점 = 통화 간선 = 환전 가능 쌍, 가중치 = 수수료
퍼즐·게임 정점 = 판의 한 상태 간선 = 한 번의 조작, 가중치 = 비용
작업 흐름 정점 = 작업 단계 간선 = 선후 관계, 가중치 = 소요 시간
문자열 변환 정점 = 단어 간선 = 한 글자 바꾸기, 가중치 = 1
가장 헷갈리는 게 상태 그래프예요. 지도 문제는 정점이 눈에 보이지만, 퍼즐이나 게임은 "판의 배치 하나하나가 정점"이라는 게 잘 안 보입니다. 이때 판별 기준은 이겁니다. "지금까지 무엇을 했는지 몰라도, 현재 상태만 알면 다음 선택을 할 수 있는가?" 그렇다면 그 상태가 정점 자격을 갖춘 거예요. E-4에서 DP의 상태 정의를 잡을 때 던졌던 질문과 정확히 같습니다.
- 가중치가 전부 같다면: 간선 하나가 곧 한 걸음이니 BFS로 충분합니다. "최소 이동 횟수", "몇 번 만에"가 이 신호예요.
- 가중치가 제각각이면: 다익스트라입니다. "최소 비용", "가장 싼", "가장 빠른"이 신호고요.
- 비용에 이득(음수)이 섞이면: 벨만-포드입니다. 환전 차익이나 보너스가 등장하면 의심하세요.
현업에서든 시험장에서든 모델링에서 실제로 조심할 것은 상태 공간의 크기입니다. 정점을 정의하는 건 쉬운데, 그 정점이 몇 개인지 세지 않으면 표현은 맞아도 시간 안에 못 풀어요. 퍼즐 상태가 10의 12승 개라면 그래프로 옳게 모델링해도 소용이 없습니다. A-1의 입력 크기 잣대를 모델링 단계에서 함께 대는 이유가 여기 있어요. "정점을 무엇으로 볼까"와 "그게 몇 개일까"는 반드시 같이 물어야 합니다.
🎯 코테·면접에선 이렇게 나온다
"이 문제를 그래프로 푸는 걸 어떻게 떠올렸나요?"가 나옵니다. "그래프처럼 생겨서요"는 최악의 답이에요.
"상태와 상태를 바꾸는 행동이 있는지를 봤습니다. 현재 상태만 알면 다음 선택을 할 수 있으면 그 상태가 정점이고, 한 번의 조작이 간선이 됩니다. 여기에 조작마다 비용이 붙고 총합을 최소화하라고 하니 최단 경로 문제였고, 비용이 제각각이라 다익스트라를 골랐습니다. 고르기 전에 상태 개수가 몇 개인지 먼저 세서 시간 안에 드는지 확인했고요."
💡 실무에선
배송 경로, 결제 수단 조합, 마이크로서비스 호출 경로, 빌드 파이프라인 순서처럼 "선택지가 이어져 하나의 흐름을 이루는" 문제는 대부분 그래프로 옮길 수 있습니다. 옮기고 나면 이미 검증된 알고리즘을 그대로 쓸 수 있어서, 직접 규칙을 짜는 것보다 훨씬 안전해요. 다만 실무의 어려움은 알고리즘이 아니라 모델링의 타당성에 있습니다. 비용을 무엇으로 정의할지, 거리와 시간과 요금 중 무엇을 최소화할지는 알고리즘이 아니라 도메인이 정하거든요. 그래서 코드를 짜기 전에 "우리가 최소화하려는 게 정확히 무엇인가"를 관계자와 합의하는 일이 절반을 차지합니다.