3
같은 미로, 같은 함수 — 꺼내는 곳만 바꿨다
말로만 하면 여기서 멈춥니다. 숫자를 봐야 합니다.
아래가 오늘부터 1단원이 끝날 때까지 계속 쓸 미로입니다.
7·8·12차시가 이 미로를 글자 하나 안 바꾸고 그대로 이어받으니, 눈에 익혀 두면 좋습니다.
0 1 2 3 4 5 6 7 8 9 10 11
0 S . . . . . # . # # . #
1 . # . . . . . . . . . #
2 # # . . # . . . . . . .
3 # . . . . . . # . . . .
4 . . . # . . . . . # . .
5 . . # . . . # . . # . #
6 . . . . . . . # # . . G
가로가 열, 세로가 행입니다. #이 벽, .이 다닐 수 있는 칸입니다. 이 미로에는 벽이 19칸 있습니다.
크기는 7행 12열 = 84칸이고 그중 벽이 19칸이라 다닐 수 있는 칸은 65칸입니다.
출발 S는 왼쪽 위 (0, 0), 목표 G는 오른쪽 아래 (6, 11)입니다.
좌표는 (행, 열) 순서로 적습니다.
먼저 최단 걸음 수를 짐작해 봅시다. 벽을 없는 셈 치면 아래로 6칸, 오른쪽으로 11칸이니
6 + 11 = 17걸음입니다. 이렇게 가로세로로만 재서 더한 값을
맨해튼 거리라고 합니다.
벽이 있으니 실제로는 이보다 길어질 수 있습니다. 17걸음보다 짧아질 수는 결코 없습니다.
이 미로는 다행히 돌아가지 않는 길이 실제로 있어서, 최단이 정확히 17걸음입니다.
이제 같은 함수에 popleft()와 pop()만 바꿔 넣고 돌린 결과입니다.
뒤에서 여러분이 직접 실행할 값이니, 먼저 예측해 보고 표를 보세요.
| 방법 | 프론티어 | 둘러본 칸 | 경로 | 프론티어에 넣은 횟수 |
| BFS (너비 우선) | 큐 popleft() |
64칸 | 17걸음 | 64 |
| DFS (깊이 우선) | 스택 pop() |
22칸 | 19걸음 | 32 |
예측이 맞았나요? 많은 학생이 거꾸로 예측합니다.
"깊이 우선은 헤매니까 더 많이 둘러보겠지" 하고요. 그런데 이 미로에서는 DFS가
BFS의 3분의 1만 둘러보고 답을 냈습니다. 64칸 대 22칸입니다. 싸게 이겼습니다.
그러나 그 답은 17걸음이 아니라 19걸음입니다. 두 걸음을 더 걸어야 하는 길이지요.
DFS는 42칸을 아꼈고, 그 값을 답의 질로 치렀습니다.
한편 BFS는 65칸 중 64칸, 그러니까 거의 전부를 들여다보고서 17걸음을 얻었습니다.
⚠️ 값을 잘못 읽기 쉬운 자리
'둘러본 칸'은 프론티어에서 꺼내 본 칸의 수이지 프로그램이 지나간 길의 길이가 아닙니다.
'경로'가 길의 길이입니다. 둘을 섞으면 "DFS가 22걸음 만에 도착했다"는 엉뚱한 문장이 됩니다.
22는 들여다본 칸의 수이고, 도착까지의 걸음은 19입니다.
64와 65 — 한 칸이 비는 까닭
다닐 수 있는 칸이 65칸인데 BFS는 왜 64칸만 꺼냈을까요? 못 간 곳이 있어서가 아닙니다.
목표를 꺼내는 순간 멈추기 때문입니다.
이 미로에서 출발로부터 17걸음인 칸은 딱 둘 — 목표 (6, 11)과 같은 줄에 있는 (6, 9)입니다. 그보다 먼 칸은 없습니다.
BFS는 큐 순서상 목표를 먼저 꺼내고 그 자리에서 끝내므로, (6, 9)는 영영 안 꺼냅니다.
65 − 64 = 1칸이 그것입니다.
🔗 7차시를 미리 짚어 둡니다
7차시에서 같은 미로에 다익스트라를 돌리면 65칸이 나옵니다.
오늘의 BFS 64칸과 한 칸 다르지요. 두 차시를 나란히 놓고 보면 "어느 쪽이 틀렸나?" 싶겠지만
둘 다 맞습니다. 다익스트라는 대기실을 (f, g, 칸) 순서로 정렬하는데,
(17, 17, (6,9))가 (17, 17, (6,11))보다 작아서 (6, 9)를 목표보다 먼저 꺼냅니다.
알고리즘의 성능 차이가 아니라 마지막 층에서 동점을 처리하는 순서의 차이입니다.
경로는 둘 다 17걸음으로 같습니다 — 그것이 '이동 비용이 모두 같으면 BFS와 다익스트라는 같은 일을 한다'는 사실의 확인입니다.
DFS의 성적은 운에 가깝다
이웃을 살펴보는 방향의 순서만 바꿔 봅시다. 미로도 그대로, 알고리즘도 그대로입니다.
바뀌는 것은 neighbors()가 이웃을 내놓는 차례뿐입니다.
| 이웃을 내놓는 순서 | DFS 둘러본 칸 | DFS 경로 | BFS 둘러본 칸 | BFS 경로 |
| 아래 · 위 · 오른 · 왼 (교과서 순서) |
22칸 | 19걸음 | 64칸 | 17걸음 |
| 오른 · 왼 · 아래 · 위 |
47칸 | 29걸음 | 64칸 | 17걸음 |
| 위 · 아래 · 왼 · 오른 |
20칸 | 19걸음 | 65칸 | 17걸음 |
| 왼 · 오른 · 위 · 아래 |
38칸 | 29걸음 | 65칸 | 17걸음 |
BFS는 거의 안 흔들립니다 — 둘러본 칸이 64에서 65 사이를 오갈 뿐이고, 경로는 언제나 17걸음입니다.
반면 DFS는 22칸 19걸음에서 47칸 29걸음까지 출렁입니다.
코드를 고친 것도, 미로를 고친 것도 아닙니다. 이웃을 적는 차례를 바꿨을 뿐입니다.
이것이 두 방법의 성격을 가장 정확히 보여 주는 표입니다.
BFS의 성적은 미로가 정하고, DFS의 성적은 운이 정합니다.
승패는 미로가 뒤집는다
지금까지 본 한 미로만으로 "DFS가 더 빠르다"고 결론 내면, 오해를 하나 지우고 새 오해를 얻는 셈입니다.
벽을 옮겨 두 미로를 더 만들어 보았습니다. 같은 7행 12열이고 출발과 목표도 같은 자리입니다.
| 미로 | BFS 둘러본 칸 | BFS 경로 | DFS 둘러본 칸 | DFS 경로 |
| 교과서 미로 | 64칸 | 17걸음 |
22칸 | 19걸음 |
| 미로 A — 오른쪽에 세로 벽 하나 | 79칸 | 17걸음 |
18칸 | 17걸음 |
| 미로 B — 뱀처럼 굽은 통로 | 34칸 | 17걸음 |
36칸 | 35걸음 |
미로 A에서 DFS는 18칸만 보고 끝냅니다. 게다가 경로도 17걸음, 즉 최단입니다.
벽이 오른쪽에 세로로 서 있어 아래로 파고들기만 하면 목표에 곧장 닿기 때문입니다.
BFS는 79칸을 다 봅니다 — 이 미로의 빈칸이 정확히 79칸이니 한 칸도 안 남기고 훑은 것입니다.
다만 여기서 DFS의 17걸음은 보장이 아니라 우연입니다. 벽이 그렇게 서 있어서 그랬을 뿐입니다.
미로 B에서는 정반대가 됩니다. DFS가 36칸을 둘러보고 35걸음짜리 길을 내놓습니다.
BFS보다 더 많이 보고 두 배 긴 길을 낸 것이지요.
뱀처럼 굽은 통로에 갇히면 깊이 우선은 그 통로를 끝까지 다 걸어야 합니다.
그리고 그 통로가 그대로 답이 되어 버립니다.
💡 오늘의 결론이 될 문장
'깊이 우선이 항상 빠르다'도 거짓이고 '항상 느리다'도 거짓입니다. 미로가 정합니다.
세 미로에서 변하지 않은 것은 딱 하나 — BFS는 언제나 17걸음을 지켰습니다.
빠르기는 상황이 정하고, 최단 보장은 알고리즘이 정합니다.
시뮬레이터는 누군가 이미 만들어 둔 것입니다. 여러분이 한 일은 버튼을 누른 것이지요.
이제 화면을 지우고 코드로 옮깁니다. 채울 자리는 ?????로 표시한 두 곳입니다.
판박이 함수 안에도 같은 자리가 한 번씩 더 있으니, 모두 네 곳을 같은 답으로 채우면 됩니다.
💡 채우기 전에
- 빈칸 ① —
frontier.?????(). 스택은 가장 나중에 넣은 것을 꺼냅니다.
deque에는 popleft()와 pop() 두 가지가 있습니다. 어느 쪽일까요?
- 빈칸 ② —
if n not in ?????:. '이미 본 칸' 목록 노릇을 하는 딕셔너리가
코드 위쪽에 하나 있습니다. 이름이 무엇인가요?
맨 위 MAZE·find·neighbors 블록은 7·8·12차시가 그대로 이어 쓰는 공유 부품입니다.
브라우저 안 파이썬은 페이지가 바뀌면 기억을 다 잃어버리기 때문에, 차시마다 이 블록을 다시 싣습니다.
여기는 고치지 마세요. 미로를 바꿔 보고 싶으면 아래쪽 판박이 함수 search_on()을 쓰면 됩니다.
✍️ 과제 ③ — 부수면서 확인하기
코드가 돌아가면, 아래 넷을 차례로 해 보고 결과를 공책에 적으세요.
예측을 먼저 적고 실행하는 것이 요령입니다.
- 【1】의 두 줄을 확인한다. BFS와 DFS의 둘러본 칸·경로·넣은 횟수 여섯 숫자를 옮겨 적습니다.
앞의 표와 같은가요?
- 【2】를 읽고 한 줄을 고른다. 이웃 순서 네 가지 중 DFS가 가장 크게 헤맨 줄은 어느 것인가요?
그때 BFS의 경로는 몇 걸음이었나요?
- 【3】의 미로 B를 손으로 바꿔 본다.
MAZE_B의 # 하나를 .으로 바꾸고,
자리를 옮겨 가며 대여섯 번 돌려 보세요. DFS의 35걸음이 짧아진 적이 한 번이라도 있었나요?
그리고 왼쪽 세로 통로의 벽 — 2행·3행·4행의 1열 — 가운데 하나를 지우면 오히려 39걸음이 됩니다.
길을 하나 더 뚫어 줬는데 답이 나빠지는 까닭을 한 줄로 적어 보세요.
- 【4】의
LIMIT을 5000으로 낮춘다. 꺼낸 칸 수가 함께 줄어드는지 보세요.
⚠️ 반대로 크게 올리지는 마세요. 브라우저가 멈춥니다.
⚠️ 이런 오류가 나면
빈칸을 안 채우고 실행하면 SyntaxError가 납니다 — ?????는 파이썬이 모르는 글자니까요.
이것은 고장이 아니라 아직 안 채웠다는 신호입니다. 네 곳을 모두 채웠는지 확인하세요.
빈칸 ①에 popleft()를 넣으면 오류 없이 돌아가는데 DFS 줄도 64칸 17걸음이 됩니다.
두 줄의 숫자가 똑같으면 그 자리를 의심하세요.