퍼져나가며 찾자! BFS와 DFS
안녕, 나 코딩냥이다냥! 오늘은 지금까지 배운 걸 전부 쓴다냥. 8장의 격자와 방향표, 9장의 큐와 스택이 오늘을 위한 준비였다냥. 알고리즘 두 개를 배우는데, 둘 다 뼈대가 열 줄 남짓이라냥.
오늘의 학습 목표
- 격자와 그래프가 같은 것임을 안다.
- BFS를 큐로, DFS를 스택이나 재귀로 쓴다.
- 방문 표시를 언제 해야 하는지 안다.
- 최단 거리는 BFS만 준다는 것을 안다.
1단계 · 격자도 그래프라냥 (7분)
그래프라고 하면 어려워 보이지만, 점들과 그 사이의 연결일 뿐이라냥. 8장에서 다룬 격자가 이미 그래프였다냥.
비유로 이해하기 — 지하철 노선도
역이 점이고 선로가 연결이라냥. "여기서 저기까지 몇 정거장이냥?"이 오늘 푸는 문제라냥.
격자에서는 칸이 점이고, 상하좌우로 붙어 있는 것이 연결이라냥. 그래서 8장의 방향표가 그대로 쓰인다냥.
DIRS = ((-1, 0), (1, 0), (0, -1), (0, 1))격자가 아닌 그래프는 인접 리스트로 담는다냥. adj[u] 에 u 와 이어진
점들을 넣어두는 거라냥.
adj = [[] for _ in range(n + 1)]
for _ in range(m):
u, v = map(int, input().split())
adj[u].append(v)
adj[v].append(u) # 방향이 없으면 양쪽 다 넣는다냥격자든 인접 리스트든 "지금 칸에서 갈 수 있는 곳들" 을 주는 건 똑같다냥. 그래서 알고리즘은 하나도 안 바뀐다냥.
2단계 · BFS는 물결처럼 퍼진다냥 (12분)
BFS는 가까운 곳부터 차례로 본다냥. 연못에 돌을 던지면 물결이 동그랗게 퍼지는 것과 같다냥. 큐를 쓰면 저절로 그렇게 된다냥.
- 시작점을 큐에 넣고 방문 표시한다
- 큐에서 하나 꺼낸다
- 그 칸의 이웃 중 아직 안 간 곳을 큐에 넣고 방문 표시한다
- 큐가 빌 때까지 2~3을 반복한다
from collections import deque
q = deque([(sr, sc)])
seen[sr][sc] = True
while q:
r, c = q.popleft()
for dr, dc in DIRS:
nr, nc = r + dr, c + dc
if 0 <= nr < n and 0 <= nc < m and not seen[nr][nc]:
seen[nr][nc] = True # 넣을 때 표시한다냥
q.append((nr, nc))방문 표시는 큐에 넣을 때 한다냥. 꺼낼 때 하면 안 된다냥.
꺼낼 때 표시하면, 아직 큐 안에서 기다리는 칸을 다른 이웃이 또 넣는다냥. 같은 칸이 큐에 여러 번 쌓인다냥.
작은 입력에서는 답이 맞게 나오기도 한다냥. 하지만 큐가 쓸데없이 부풀어서 입력이 커지면 느려지고 메모리도 먹는다냥. 처음부터 넣을 때 표시하는 습관을 들이자냥.
9장에서 배운 대로 큐는 deque 로 만들자냥. list.pop(0) 을 쓰면 칸 수가
많아질 때 그것만으로 느려진다냥.
3단계 · 거리도 같이 적는다냥 (10분)
BFS의 진짜 힘이라냥. 방문 표시 대신 거리를 적어두면 최단 거리가 공짜로 나온다냥.
dist = [[-1] * m for _ in range(n)]
dist[sr][sc] = 0
q = deque([(sr, sc)])
while q:
r, c = q.popleft()
for dr, dc in DIRS:
nr, nc = r + dr, c + dc
if 0 <= nr < n and 0 <= nc < m and dist[nr][nc] == -1:
dist[nr][nc] = dist[r][c] + 1
q.append((nr, nc))dist 를 -1 로 채워두면 방문 표시와 거리를 한 배열로 해결한다냥.
-1 이면 아직 안 간 곳이라냥. seen 을 따로 둘 필요가 없다냥.
끝나고도 -1 인 칸은 아예 갈 수 없는 곳이라냥.
최단 거리는 BFS만 준다냥. DFS로 먼저 도착한 경로는 최단이 아니라냥.
DFS는 한 방향으로 끝까지 파고들기 때문에, 빙 돌아가는 길로 먼저 닿을 수 있다냥. 오늘 3번 문제의 숨겨진 테스트가 정확히 그걸 잡는다냥.
가까운 곳부터 보는 BFS는 처음 닿았을 때가 곧 최단이라 그런 일이 없다냥.
4단계 · DFS는 끝까지 파고든다냥 (12분)
DFS는 갈 수 있는 데까지 쭉 간 다음, 막히면 되돌아온다냥. 미로에서 한 손을 벽에 대고 걷는 것과 비슷하다냥.
큐 대신 스택을 쓰면 된다냥. 딱 한 글자만 바뀐다냥.
st = [(sr, sc)]
seen[sr][sc] = True
while st:
r, c = st.pop() # popleft 가 아니라 pop 이라냥
...재귀로 쓰면 더 짧다냥.
def dfs(r, c):
seen[r][c] = True
for dr, dc in DIRS:
nr, nc = r + dr, c + dc
if 0 <= nr < n and 0 <= nc < m and not seen[nr][nc]:
dfs(nr, nc)재귀 DFS에는 깊이 함정이 있다냥. 7장에서 배운 그거라냥.
파이썬은 재귀가 1000번쯤 쌓이면 RecursionError 로 터진다냥. 그런데 격자가
크면 깊이가 쉽게 그걸 넘긴다냥.
import sys
sys.setrecursionlimit(10 ** 6)이 한 줄로 풀린다냥. 오늘 2번 문제에 100 x 100이 전부 땅인 격자가 들어 있는데, 이 줄이 없으면 정답 코드도 터진다냥.
깊이는 칸 수가 아니라 파고든 길이라냥. 그래서 헷갈린다냥.
땅이 3000칸이어도 여기저기 흩어진 모양이면 깊이는 얕게 끝난다냥. 반대로 빈틈없이 꽉 찬 격자는 뱀처럼 훑고 지나가서 깊이가 칸 수만큼 된다냥.
큰 격자에서 깊이가 걱정되면 그냥 BFS를 쓰자냥. BFS는 큐를 쓰니 깊이 문제가 아예 없다냥.
5단계 · 무엇을 세느냐에 따라 (8분)
뼈대는 하나인데 붙이는 것만 바꾸면 여러 문제가 풀린다냥. 오늘 문제들이 전부 이 변주라냥.
- 덩어리 개수 — 아직 안 간 칸에서 탐색을 시작한 횟수를 센다
- 덩어리 크기 — 한 번의 탐색에서 꺼낸 칸 수를 센다
- 최단 거리 — 방문 표시 대신 거리를 적는다
- 여러 곳에서 동시에 — 시작점을 전부 큐에 넣고 시작한다
마지막 것이 오늘 5번 문제라냥. 시작점마다 따로 탐색할 필요가 없다냥. 한꺼번에 넣으면 물결이 동시에 퍼진다냥.
오늘 배운 내용 정리
먼저 스스로 답해보고, "정답 보기"를 눌러서 맞는지 확인해보자냥!
이제 직접 풀어볼 차례다냥
오늘 문제는 다섯 개라냥. 12번은 덩어리 세기, 3번은 최단 거리, 4번은 격자가
아닌 그래프, 5번은 여러 곳에서 동시에라냥. 뼈대는 다섯 문제가 전부
똑같다냥. 한 번 익혀두면 나머지는 변주일 뿐이라냥. 가보자냥
