단원 홈
1단원 · 4차시

무작정 뒤지기
큐면 최단, 스택이면 왜 아닌가

3차시에서 문제를 상태로 적었습니다. 오늘은 그 상태들을 실제로 뒤집니다. 뒤지는 방법은 딱 하나만 다릅니다 — 다음에 볼 후보 중에서 무엇을 먼저 꺼내는가. 코드로는 한 낱말, 결과로는 둘러본 칸 세 배와 두 걸음의 차이입니다.

성취기준 12인기01-02 성취기준 12인기01-03
맹목적 탐색프론티어방문 집합 너비 우선깊이 우선최단 보장
🎯 학습 목표
  • 프론티어와 방문 집합이라는 두 그릇으로 탐색을 설명하고, 프론티어를 큐로 쓰면 너비 우선, 스택으로 쓰면 깊이 우선이 된다는 것을 코드에서 짚을 수 있다.
  • 너비 우선 탐색이 최단 경로를 보장하는 까닭과 그 보장이 성립하는 조건을 말할 수 있다.
  • 같은 미로에서 두 방법을 실행해 둘러본 칸과 걸음 수를 숫자로 비교하고, 승패가 뒤집히는 미로를 직접 만들 수 있다.
🤔

여는 장면 — 미로에 들어선 두 사람

높은 곳에서 비스듬히 내려다본 미로 정원. 둥근 산울타리가 여러 겹 동심원을 이루고, 가운데에 돌기둥이 서 있다.
포르투갈 포르투의 한 공원에 있는 미로 정원을 높은 곳에서 내려다본 모습입니다. 둥근 산울타리가 여러 겹으로 가운데 돌기둥을 감싸고, 울타리 곳곳이 끊겨 통로가 됩니다. 이렇게 보면 길이 다 보이지만, 안에 들어선 사람에게 보이는 것은 눈앞의 갈림길 하나뿐입니다. 오늘 우리가 만들 프로그램도 같은 처지입니다 — 출구가 어느 쪽인지 모릅니다. 출처: Joseolgon, Wikimedia Commons (CC0)

두 사람이 같은 입구로 들어갑니다. 규칙은 하나뿐입니다. 출구가 어디인지 모른다. 지도도 없고, 소리도 안 들리고, 어느 쪽이 더 가까운지 짐작할 단서도 없습니다.

첫째 사람은 이렇게 합니다. 갈림길에서 왼쪽으로 한 걸음 가 보고 돌아옵니다. 오른쪽으로 한 걸음 가 보고 돌아옵니다. 그렇게 입구에서 한 걸음 거리인 곳을 전부 확인한 뒤에야 두 걸음 거리로 넘어갑니다. 발품은 많이 팔지만 대신 이런 것이 보장됩니다 — 어느 순간에 붙잡아 세워도 지금까지 밟은 곳은 입구에서 가까운 쪽부터 빠짐없이 채워져 있습니다. 두 걸음 거리를 훑는 중이라면 세 걸음 거리인 곳은 아직 한 칸도 밟지 않았습니다.

둘째 사람은 반대입니다. 갈림길에서 왼쪽을 골랐으면 막힐 때까지 그 길만 갑니다. 막다른 곳에 닿으면 가장 최근에 지나온 갈림길로 돌아와 다음 갈래를 고릅니다. 운이 좋으면 몇 분 만에 출구에 닿고, 운이 나쁘면 정원 반대편 끝까지 갔다가 돌아옵니다.

💭 오늘의 물음

둘 중 누가 먼저 출구를 찾을까요? 그리고 — 찾아낸 길이 가장 짧은 길이라고 말할 수 있는 사람은 누구일까요?

이 두 물음의 답이 서로 다르다는 것이 오늘의 요점입니다. 먼저 찾는 쪽과, 짧은 길을 찾는 쪽이 같은 사람이 아닙니다. 그리고 놀랍게도, 두 사람의 차이는 프로그램에서 한 낱말로 적힙니다.

사실 여러분은 이미 그 낱말을 한 번 만났습니다. 3차시 물병 문제에서 상태를 펼치던 코드의 popleft()를 pop()으로만 바꿔 보았지요. 목표를 4L로 두었을 때는 우연히 둘 다 6걸음이 나왔는데, 목표를 1L로 바꾸자 너비 우선은 4걸음, 깊이 우선은 8걸음이 되었습니다. 두 배입니다. 오늘은 그 한 낱말이 왜 그런 일을 하는지 끝까지 봅니다.

목표가 어느 쪽에 있는지 전혀 모르는 채로 정해진 순서에 따라서만 뒤지는 방식을 맹목적 탐색이라고 합니다. 오늘 배우는 두 방법이 모두 여기에 속합니다. 힌트를 쥐어 주는 이야기는 다음 시간(5차시)에 시작합니다.

1

탐색기에 든 그릇은 둘뿐이다 — 프론티어와 방문 집합

미로를 뒤지는 프로그램은 생각보다 단출합니다. 자료를 담아 두는 그릇이 딱 두 개면 됩니다.

① 프론티어 (frontier)

다음에 볼 후보를 담아 두는 그릇입니다. 아직 살펴보지는 않았지만 이미 눈에 들어온 칸들이지요. 미로에 들어선 사람이 "저기랑 저기는 아직 안 가 봤네" 하고 기억해 두는 목록이 이것입니다.

우리말로 옮기지 않고 프론티어라고 그대로 부릅니다. 아는 곳과 모르는 곳이 맞닿은 가장자리라는 뜻입니다.

② 방문 집합 (visited)

이미 본 칸을 적어 두는 그릇입니다. 없으면 같은 칸을 몇 번이고 다시 넣게 되어 탐색이 영영 끝나지 않습니다. 오늘 이것을 실제로 지워 보고 무슨 일이 나는지 숫자로 확인합니다.

오늘 코드에서는 이 그릇이 came이라는 딕셔너리 하나입니다. '봤다'는 표시와 '어디서 왔는지'를 한꺼번에 적어 둡니다.

이 두 그릇으로 탐색은 다섯 줄이 됩니다. 알고리즘 이름이 아직 하나도 안 나온다는 점을 눈여겨보세요.

  • ① 프론티어에 시작 상태를 넣는다.
  • ② 프론티어에서 하나를 꺼낸다.
  • ③ 꺼낸 것이 목표면 끝낸다.
  • ④ 그 상태의 이웃을 만들어, 아직 안 본 것만 프론티어에 넣는다.
  • ⑤ 프론티어가 빌 때까지 ②로 돌아간다.

여기 어디에도 '너비 우선'이나 '깊이 우선'이라는 말이 없습니다. 그 이름은 ②번 한 줄에서 정해집니다. 프론티어에서 무엇을 꺼내느냐가 알고리즘의 정체입니다.

큐(queue) — 먼저 넣은 것을 먼저 꺼낸다 ① ② ③ ④ 꺼내기 popleft() 넣기 append() → 너비 우선 스택(stack) — 나중에 넣은 것을 먼저 꺼낸다 ① ② ③ ④ 꺼내기 pop() 넣기 append() → 깊이 우선

넣는 곳은 둘 다 오른쪽 끝입니다. 다른 것은 꺼내는 곳뿐입니다. 큐는 반대쪽 끝에서, 스택은 같은 쪽 끝에서 꺼냅니다.

파이썬의 collections.deque는 이 두 가지를 다 할 수 있습니다. popleft()는 왼쪽 끝(가장 먼저 넣은 것)을, pop()은 오른쪽 끝(가장 나중에 넣은 것)을 꺼냅니다. 그래서 오늘 코드에서 바뀌는 곳은 정말로 한 줄, 아니 낱말 하나입니다.

ℹ️ 정식 이름

프론티어를 큐로 쓰면 너비 우선 탐색(breadth-first search, BFS), 스택으로 쓰면 깊이 우선 탐색(depth-first search, DFS)입니다. 앞으로는 줄여서 BFS·DFS로 적겠습니다. '너비'는 같은 걸음 수인 칸들을 옆으로 넓게 훑는다는 뜻이고, '깊이'는 한 갈래를 아래로 깊게 파고든다는 뜻입니다.

방문 집합 이야기를 조금 더 하겠습니다. 오늘 코드에서 came은 딕셔너리인데, 키는 칸이고 값은 그 칸에 어디서 왔는지입니다. 시작 칸의 값만 None이지요. 그래서 이 하나로 두 가지 일을 합니다.

  • 이미 봤는가? — if n not in came: 한 줄로 판정합니다.
  • 길을 어떻게 되짚는가? — 목표에서 시작해 came[cur]를 계속 따라가면 시작 칸까지 거꾸로 이어집니다. 그 길이가 곧 걸음 수입니다.

둘을 따로 두어도 되지만, 하나로 겸하면 '봤다고 표시하는 순간 부모도 함께 적힌다'는 보장이 생깁니다. 표시만 하고 부모를 안 적는 실수가 원천적으로 막힙니다.

2

왜 큐면 최단이 보장되는가

"BFS는 최단 경로를 찾아 준다"는 말은 외우면 그만인 문장처럼 보입니다. 그런데 이 문장은 증명할 수 있는 사실이고, 증명이 짧습니다. 한 문단이면 됩니다.

📐 한 문단 증명

시작 칸은 0걸음입니다. 시작 칸을 꺼내면서 그 이웃들을 넣는데, 그것들은 전부 1걸음짜리입니다. 큐는 들어온 차례대로만 꺼내므로, 1걸음짜리를 다 꺼내기 전에는 2걸음짜리를 꺼낼 수 없습니다. 1걸음짜리를 꺼내며 넣는 새 칸들은 2걸음짜리이고, 이것들은 큐의 맨 뒤에 줄을 섭니다.

그러므로 프론티어는 언제나 k걸음짜리 뭉치 다음에 k+1걸음짜리 뭉치가 오는 모양을 지킵니다. 목표를 처음 꺼내는 순간, 아직 안 꺼낸 칸은 전부 그와 같거나 더 먼 층에 있습니다. 그러니 더 짧은 길은 있을 수 없습니다. 처음 만난 순간이 곧 최단입니다.

여기서 조건 하나를 반드시 붙여야 합니다. 이 논증은 모든 이동의 비용이 똑같을 때만 성립합니다. 한 칸 옮기는 데 드는 값이 어디서나 1이라야 '층'이라는 말이 성립하기 때문입니다. 길마다 값이 다르면 — 잔디밭은 1인데 갯벌은 5라면 — 큐로는 안 됩니다. 그때 필요한 것이 7차시의 우선순위 큐이고, 지형에 따라 값이 다른 지도는 9차시에서 다룹니다.

DFS에는 왜 그런 보장이 없을까요? 스택은 가장 최근에 넣은 것을 꺼냅니다. 가장 최근에 넣은 것은 방금 꺼낸 칸의 이웃이므로, 탐색은 자꾸 더 깊은 곳으로 끌려갑니다. 목표를 만났을 때, 그 길이 짧다고 말할 근거가 어디에도 없습니다. 운이 좋았을 뿐입니다.

너비 우선 — 층을 하나씩 훑는다 1 2 3 4 5 6 7 0층 → 1층 → 2층. 층이 끝나야 다음 층. 깊이 우선 — 한 갈래를 끝까지 판다 1 5 2 7 6 4 3 오른쪽 끝까지 내려간 뒤에야 왼쪽으로.

같은 나무를 같은 규칙으로 펼쳤는데 방문 순서가 이렇게 다릅니다. 깊이 우선이 오른쪽부터 내려가는 것은, 스택이 나중에 넣은 것을 먼저 꺼내기 때문입니다. 왼쪽·오른쪽 차례로 넣으면 오른쪽이 위에 얹힙니다.

이 그림에서 미리 잡아 둘 오해가 하나 있습니다. 깊이 우선은 왼쪽부터 파고들지 않습니다. 이웃을 왼쪽·오른쪽 차례로 넣으면 스택 맨 위에 오는 것은 오른쪽이지요. 오늘 코드의 이웃 순서는 아래·위·오른쪽·왼쪽입니다. 마지막에 넣은 것이 맨 위에 얹히니, 네 이웃이 모두 처음 보는 칸이라면 DFS는 '왼쪽'을 가장 먼저 꺼냅니다. 코드에 적힌 순서와 실제로 파고드는 순서가 반대라는 것, 이것이 DFS를 읽을 때 가장 많이 놓치는 지점입니다.

⚠️ 그런데 화면에서는 오른쪽으로 달려간다

규칙은 '왼쪽 먼저'인데, 뒤에서 실제로 돌려 보면 DFS는 맨 윗줄을 따라 오른쪽으로 달려갑니다. 꺼내는 차례가 (0, 0) → (0, 1) → (0, 2) → (0, 3) 입니다. 규칙이 틀린 것일까요?

아닙니다. 까닭이 둘 있습니다. 첫째, 출발 칸 (0, 0)에는 왼쪽도 위쪽도 없습니다. 미로 바깥이라 넣을 이웃이 아래와 오른쪽 둘뿐이고, 나중에 넣은 오른쪽이 맨 위에 옵니다. 둘째, 그다음부터는 왼쪽 이웃이 방금 지나온 칸입니다. 이미 본 칸이라 프론티어에 아예 들어가지 않습니다. 그래서 '왼쪽 먼저'라는 규칙은 왼쪽에 처음 보는 칸이 있을 때만 눈에 보입니다. 규칙과 화면이 어긋나 보일 때는, 규칙이 틀린 것이 아니라 그 자리에 고를 후보가 없었던 것입니다.

3

같은 미로, 같은 함수 — 꺼내는 곳만 바꿨다

말로만 하면 여기서 멈춥니다. 숫자를 봐야 합니다. 아래가 오늘부터 1단원이 끝날 때까지 계속 쓸 미로입니다. 7·8·12차시가 이 미로를 글자 하나 안 바꾸고 그대로 이어받으니, 눈에 익혀 두면 좋습니다.

가로가 열, 세로가 행입니다. #이 벽, .이 다닐 수 있는 칸입니다. 이 미로에는 벽이 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걸음을 지켰습니다. 빠르기는 상황이 정하고, 최단 보장은 알고리즘이 정합니다.

4

무엇을 내주고 무엇을 얻는가 — 메모리와 안전

둘러본 칸 수만 보면 이 미로에서는 DFS가 이겼습니다. 그런데 알고리즘을 고를 때 실제로 먼저 바닥나는 것은 시간이 아니라 메모리인 경우가 많습니다. 프론티어에 몇 개를 이고 있어야 하는지를 따져 봅시다.

갈림길이 평균 b개이고 목표가 d걸음 거리에 있다고 합시다. BFS는 d층 전체를 프론티어에 담고 있어야 하므로 대략 bd개가 쌓입니다. DFS는 지금 파고든 갈래 하나만 기억하면 되므로 대략 b × d개면 됩니다.

3차시에서 본 숫자를 다시 꺼내 볼까요. 갈림길 b = 3, 깊이 d = 10이면 살펴볼 갈래가 310 = 59,049가지였습니다. BFS는 그만큼을 프론티어에 이고 있어야 합니다. DFS는 30개 남짓이면 됩니다. 오늘 미로처럼 작은 곳에서도 차이는 보입니다 — 프론티어에 넣은 횟수가 BFS 64회, DFS 32회로 딱 두 배입니다.

견주는 항목BFS (큐)DFS (스택)
최단 경로 보장있다 (비용이 같을 때)없다
프론티어에 쌓이는 양bd — 층 전체b × d — 갈래 하나
깊이가 무한한 문제층을 못 넘겨서 느릴 뿐영영 안 돌아온다
이 미로에서 둘러본 칸64칸22칸
이 미로에서 낸 경로17걸음19걸음

표의 셋째 줄을 조금 더 보겠습니다. 상태 공간이 끝없이 깊어질 수 있는 문제가 있습니다. 3차시에서 물병 상태에 '지금까지 부은 횟수'를 넣어 보았지요. 0L를 붓는 헛동작도 횟수를 1 올린 새 상태가 되기 때문에, 상한을 두지 않으면 상태가 끝없이 늘어났습니다. 그런 문제에 DFS를 쓰면 한 갈래를 무한히 파고들며 영영 돌아오지 않습니다. BFS는 그런 문제에서도 목표가 유한한 깊이에 있으면 언젠가는 찾아냅니다 — 메모리가 버텨 준다면요.

방문 집합을 지우면 어떻게 되나

이제 개념 1의 두 번째 그릇을 지워 봅시다. if n not in came: 한 줄만 없애면 됩니다. 65칸짜리 작은 미로이니 조금 느려지는 정도겠지 싶지요. 실제 결과는 이렇습니다.

방문 집합을 지우고꺼낸 칸프론티어에 넣은 횟수목표 도달
BFS16,00250,001실패
DFS25,00150,001실패

둘 다 5만 번을 넣고도 목표를 못 만났습니다. 코드에 걸어 둔 상한 LIMIT = 50000에서 강제로 끊은 값입니다. 상한을 풀고 5초만 더 줘 봤더니 BFS는 꺼낸 칸 4,220,938개에 프론티어가 8,955,970칸까지 자랐고, DFS는 꺼낸 칸이 3,160,494개가 되었습니다. 그러고도 끝나지 않았습니다. 다닐 수 있는 칸이 65개뿐인 미로에서 말입니다.

⚠️ 두 실패는 원인이 다르다

DFS는 진짜 무한 루프입니다. 이웃한 두 칸 A와 B를 A → B → A → B로 끝없이 왕복하며 깊이가 무한히 자랍니다.

BFS는 무한 루프가 아닙니다. 같은 칸을 여러 번 넣되 층은 꼬박꼬박 올라갑니다. 문제는 '길이 k짜리 걸어다닌 자취'를 전부 세게 된다는 것입니다. 갈림길이 셋쯤이니 17걸음에 닿기 전에 자취의 수가 3의 17제곱 규모로 불어납니다. 논리적으로는 언젠가 끝나지만 실질적으로는 끝나지 않습니다. 여기서 "BFS도 무한 루프에 빠진다"고 말하면 틀린 설명이 됩니다.

🛟 코드의 안전장치를 지우지 마세요

오늘 실행할 코드에는 LIMIT = 50000 상한과 경로 되짚기 순환 감지, 그리고 '목표에 못 닿았는가' 검사가 들어 있습니다. 군더더기처럼 보이지만 아닙니다. 이 교과서의 파이썬은 브라우저 안에서, 화면을 그리는 것과 같은 자리에서 돌아갑니다. 무한 루프는 곧 탭이 멈추는 것이고, 프론티어가 몇백만 칸으로 자라면 메모리가 모자라 꺼집니다. 상한이 있어야 '안 끝난다'는 사실 자체를 관찰할 수 있습니다.

위아래로 나란히 놓인 컴퓨터 메모리 모듈 두 장. 기판 위에 검은 메모리 칩이 한 줄로 늘어서 있고, 아래 모듈의 흰 딱지에는 DDR400 512MB라고 적혀 있다.
컴퓨터의 주 메모리 모듈 두 장입니다. 위는 128MB짜리 옛 SDRAM 모듈이고, 아래는 딱지에 적힌 대로 512MB짜리 DDR 모듈이지요. 방문 집합을 지운 BFS를 5초 돌렸더니 프론티어가 8,955,970칸까지 자랐다고 했습니다. 칸 하나가 좌표 두 개짜리 튜플이라, 64비트 PC의 파이썬으로 그만한 프론티어를 만들어 재 보면 약 570MB — 아래 모듈 한 장을 넘칩니다. 탐색에서 시간보다 메모리가 먼저 바닥난다는 말은 이런 뜻입니다. 출처: Veeblefetzer, Wikimedia Commons (CC BY 4.0)
💻

손으로 ① — 두 방법을 나란히 놓고 한 걸음씩

같은 미로를 두 벌 놓았습니다. 왼쪽은 큐로, 오른쪽은 스택으로 뒤집니다. ▶ 재생을 누르면 둘이 동시에 한 걸음씩 나아가고, 둘러본 칸이 색으로 번지면서 네 숫자가 실시간으로 오릅니다. 한쪽이 먼저 끝나면 그 자리에 멈춰 서서 기다립니다 — 어느 쪽이 얼마나 더 오래 헤매는지가 그때 눈에 들어옵니다.

그리고 칸을 눌러 벽을 세우거나 허물 수 있습니다. 미로를 고치면 그 자리에서 양쪽이 다시 돌고, 숫자와 승패가 새로 계산됩니다. 아래 과제는 이 기능으로 하는 것입니다.

🧭 큐 vs 스택 — 나란히 재생 INTERACTIVE

칸을 눌러(끌어서도 됩니다) 벽을 세우거나 허뭅니다. 두 판은 같은 미로라 한쪽을 고치면 양쪽이 함께 바뀝니다. 출발 S와 목표 G는 벽으로 만들 수 없습니다. 고치는 즉시 양쪽이 끝까지 다시 돌아 결과를 보여 주고, ▶ 재생을 누르면 처음부터 한 걸음씩 다시 봅니다.

BFS · 큐 · popleft() 대기
둘러본 칸0
프론티어0
넣은 횟수0
경로–
DFS · 스택 · pop() 대기
둘러본 칸0
프론티어0
넣은 횟수0
경로–

벽   아직 안 본 칸   둘러본 칸   프론티어에 든 칸   찾아낸 경로   지금 꺼낸 칸

미로를 고치거나 ▶ 재생을 눌러 보세요.
[안내] 교과서 미로를 올려 두었습니다. ▶ 재생을 눌러 보세요.
✍️ 과제 ① — 표 채우기

위 미로 목록에서 셋을 차례로 골라 ⏹ 끝까지를 누르고, 나오는 숫자를 적으세요. 공책에도 함께 적어 두면 확인 문제에서 그대로 씁니다.

미로BFS 둘러본 칸BFS 경로DFS 둘러본 칸DFS 경로DFS가 더 적게 봤나
교과서 미로
미로 A
미로 B

표를 다 채우면 마지막 칸에 O가 둘, X가 하나 나와야 합니다. X가 나온 미로가 무엇인지 기억해 두세요.

✍️ 과제 ② — 반례 만들기 (오늘의 진짜 과제)

교과서 미로에서는 DFS가 훨씬 적게 둘러봤습니다. 그러니 이렇게 뒤집어 봅시다.

벽을 그려서 DFS가 BFS보다 더 많이 둘러보는 미로를 만들어라.

'벽 없는 빈 미로'에서 시작하는 것이 쉽습니다. 빈 미로에서 두 숫자를 먼저 확인한 뒤, 벽을 한 줄씩 그어 가며 숫자가 어떻게 움직이는지 보세요. 성공하면 화면 아래 판정 줄에 둘러본 칸: BFS 승이라고 뜹니다.

힌트가 필요하면 미로 B를 골라 보세요. 왜 그 모양이 DFS에게 불리한지 보이면, 같은 원리로 자기만의 미로를 그릴 수 있습니다. 만든 미로를 공책에 옮겨 그리고, BFS와 DFS의 네 숫자를 함께 적으세요.

💻

손으로 ② — 한 낱말을 직접 채운다

시뮬레이터는 누군가 이미 만들어 둔 것입니다. 여러분이 한 일은 버튼을 누른 것이지요. 이제 화면을 지우고 코드로 옮깁니다. 채울 자리는 ?????로 표시한 두 곳입니다. 판박이 함수 안에도 같은 자리가 한 번씩 더 있으니, 모두 네 곳을 같은 답으로 채우면 됩니다.

💡 채우기 전에
  • 빈칸 ① — frontier.?????(). 스택은 가장 나중에 넣은 것을 꺼냅니다. deque에는 popleft()와 pop() 두 가지가 있습니다. 어느 쪽일까요?
  • 빈칸 ② — if n not in ?????:. '이미 본 칸' 목록 노릇을 하는 딕셔너리가 코드 위쪽에 하나 있습니다. 이름이 무엇인가요?

맨 위 MAZE·find·neighbors 블록은 7·8·12차시가 그대로 이어 쓰는 공유 부품입니다. 브라우저 안 파이썬은 페이지가 바뀌면 기억을 다 잃어버리기 때문에, 차시마다 이 블록을 다시 싣습니다. 여기는 고치지 마세요. 미로를 바꿔 보고 싶으면 아래쪽 판박이 함수 search_on()을 쓰면 됩니다.

✍️ 과제 ③ — 부수면서 확인하기

코드가 돌아가면, 아래 넷을 차례로 해 보고 결과를 공책에 적으세요. 예측을 먼저 적고 실행하는 것이 요령입니다.

  1. 【1】의 두 줄을 확인한다. BFS와 DFS의 둘러본 칸·경로·넣은 횟수 여섯 숫자를 옮겨 적습니다. 앞의 표와 같은가요?
  2. 【2】를 읽고 한 줄을 고른다. 이웃 순서 네 가지 중 DFS가 가장 크게 헤맨 줄은 어느 것인가요? 그때 BFS의 경로는 몇 걸음이었나요?
  3. 【3】의 미로 B를 손으로 바꿔 본다. MAZE_B의 # 하나를 .으로 바꾸고, 자리를 옮겨 가며 대여섯 번 돌려 보세요. DFS의 35걸음이 짧아진 적이 한 번이라도 있었나요? 그리고 왼쪽 세로 통로의 벽 — 2행·3행·4행의 1열 — 가운데 하나를 지우면 오히려 39걸음이 됩니다. 길을 하나 더 뚫어 줬는데 답이 나빠지는 까닭을 한 줄로 적어 보세요.
  4. 【4】의 LIMIT을 5000으로 낮춘다. 꺼낸 칸 수가 함께 줄어드는지 보세요. ⚠️ 반대로 크게 올리지는 마세요. 브라우저가 멈춥니다.
⚠️ 이런 오류가 나면

빈칸을 안 채우고 실행하면 SyntaxError가 납니다 — ?????는 파이썬이 모르는 글자니까요. 이것은 고장이 아니라 아직 안 채웠다는 신호입니다. 네 곳을 모두 채웠는지 확인하세요. 빈칸 ①에 popleft()를 넣으면 오류 없이 돌아가는데 DFS 줄도 64칸 17걸음이 됩니다. 두 줄의 숫자가 똑같으면 그 자리를 의심하세요.

📖

정리 — 오늘 손에 남은 것

오늘 우리는 탐색기 하나를 만들었습니다. 그릇은 둘, 규칙은 다섯 줄이었고, 알고리즘의 이름은 프론티어에서 무엇을 꺼내느냐로 정해졌습니다.

  • 프론티어는 다음에 볼 후보, 방문 집합은 이미 본 칸입니다. 오늘 코드에서는 came 하나가 방문 집합과 부모 표를 겸했습니다.
  • 프론티어가 큐면 너비 우선(BFS), 스택이면 깊이 우선(DFS)입니다. 코드에서 바뀌는 것은 popleft()와 pop() — 낱말 하나입니다.
  • BFS는 층을 하나씩 훑기 때문에 목표를 처음 꺼내는 순간이 곧 최단입니다. 단, 모든 이동의 비용이 같을 때만입니다.
  • 교과서 미로에서 BFS는 64칸 17걸음, DFS는 22칸 19걸음이었습니다. DFS는 3분의 1만 보고 이겼지만 두 걸음을 더 걷는 길을 냈습니다.
  • 미로 A에서는 DFS가 18칸 17걸음으로 압승했고, 미로 B에서는 36칸 35걸음으로 참패했습니다. 빠르기는 미로가 정하고, 최단 보장은 알고리즘이 정합니다.
  • 방문 집합을 지우면 65칸짜리 미로에서도 5만 번을 넣고 못 끝냅니다. DFS는 무한 왕복이고, BFS는 자취가 폭발하는 것입니다 — 원인이 다릅니다.
🔗 앞뒤로 잇기

3차시에서 받은 것 — 문제를 상태·행동·목표로 적는 법. 오늘 미로의 '칸'이 상태이고, 상하좌우 이동이 행동이며, (6, 11)이 목표였습니다.

다음으로 넘기는 것 — 오늘의 MAZE와 neighbors()는 7·8·12차시가 글자 하나 안 바꾸고 그대로 이어 씁니다. 그러니 이 미로에서 나온 17걸음은 앞으로 계속 기준선 노릇을 합니다. 어느 차시에서든 17이 아닌 값이 나오면 무언가 잘못된 것입니다.

마지막으로, 오늘 두 방법의 공통점을 짚고 갑시다. 둘 다 목표가 어느 쪽인지 모릅니다. (6, 11)이 오른쪽 아래에 있다는 것을 우리는 아는데, 프로그램은 모릅니다. 그래서 BFS는 왼쪽 위 구석까지 성실하게 훑었습니다.

만약 프로그램에게 "목표는 대충 저쪽이야" 하고 알려 줄 수 있다면 어떨까요? 정확한 거리가 아니라 어림잡은 값이라도요. 벽을 무시하고 잰 맨해튼 거리 같은 것 말입니다. 그것이 다음 시간의 이야기입니다 — 맹목적 탐색에 나침반을 쥐어 주는 일입니다.

✅

확인 문제

✍️ 문제마다 답을 쓰고 제출하기를 누르세요. 제출하면 모범 답안이 열리고, 제출한 답은 선생님께 전달됩니다.

1. BFS와 DFS의 코드 차이가 어디 한 줄인지 적고, 그 한 줄이 무엇을 바꾸는지 '프론티어'라는 낱말을 써서 설명하시오.
📖 모범 답안

프론티어에서 꺼내는 줄, 곧 frontier.popleft()냐 frontier.pop()이냐 한 곳이다. popleft()는 가장 먼저 넣은 것을 꺼내므로 프론티어가 큐로 쓰이고(BFS), pop()은 가장 나중에 넣은 것을 꺼내므로 스택으로 쓰인다(DFS). 넣는 줄(append())은 양쪽이 같다 — 바뀌는 것은 꺼내는 쪽뿐이다. 프론티어에 무엇이 담기는지가 아니라 담긴 것 중 무엇을 먼저 보느냐가 알고리즘의 정체다.

2. BFS가 최단 경로를 보장하는 까닭을 '걸음 수가 같은 칸을 전부 꺼낸 뒤에'라는 말을 넣어 설명하시오. 또 그 보장이 깨지는 조건을 하나 쓰시오.
📖 모범 답안

큐는 들어온 차례대로만 꺼낸다. 시작 칸에서 k걸음인 칸들이 프론티어에 먼저 줄을 서고, 그것들을 꺼내면서 넣는 새 칸은 전부 k+1걸음짜리라 큐의 뒤에 붙는다. 그래서 걸음 수가 같은 칸을 전부 꺼낸 뒤에야 한 걸음 더 먼 칸을 꺼낸다. 목표를 처음 꺼내는 순간, 아직 안 꺼낸 칸은 모두 그와 같거나 더 먼 층에 있으므로 더 짧은 길은 존재할 수 없다.

보장이 깨지는 조건: 이동마다 비용이 다를 때. '층'이라는 말이 성립하려면 한 칸 옮기는 값이 어디서나 같아야 한다. 잔디밭 1, 갯벌 5처럼 값이 다르면 걸음 수가 적은 길이 오히려 비쌀 수 있다. 그때는 우선순위 큐가 필요하다(7차시·9차시).

3. [오늘 실행한 결과] 교과서 미로에서 얻은 네 숫자 (BFS의 둘러본 칸·경로, DFS의 둘러본 칸·경로)를 쓰시오. 그리고 "DFS가 이겼다"는 말이 맞는지 판단하고 근거를 대시오.
📖 모범 답안

BFS 64칸 17걸음 / DFS 22칸 19걸음.

'무엇에서 이겼는가'를 말하지 않으면 맞다고도 틀리다고도 할 수 없다. 둘러본 칸에서는 DFS가 이겼다 — 64칸 대 22칸으로 3분의 1만 보고 끝냈다. 경로의 길이에서는 BFS가 이겼다 — 17걸음 대 19걸음이다. DFS는 42칸을 아꼈고 그 값을 두 걸음으로 치렀다. 그러므로 답은 "무엇을 더 중히 여기느냐에 달렸다"이고, 최단이 필요한 문제라면 DFS는 애초에 후보가 아니다. 덧붙여 BFS의 64칸은 다닐 수 있는 65칸에서 하나 모자란 값인데, 그 하나는 (6, 9)다 — 목표를 꺼내는 순간 멈추기 때문에 같은 층의 나머지 한 칸을 안 꺼낸다.

4. [오늘 실행한 결과] 시뮬레이터에서 DFS가 BFS보다 더 많이 둘러보는 미로를 만들었는가? 만든 미로를 그림으로 그리고 네 숫자를 쓰시오. 만들지 못했다면 미로 B의 숫자를 쓰고, 그 모양이 왜 DFS에게 불리한지 설명하시오.
📖 모범 답안

채점 기준은 둘이다. ① DFS의 둘러본 칸이 BFS보다 많은가, ② 그때 DFS의 경로가 BFS보다 긴가. 둘 다 O면 성공이다.

미로 B의 값: BFS 34칸 17걸음 / DFS 36칸 35걸음. 뱀처럼 굽은 한 줄짜리 통로에서는 갈림길이 사실상 없다. 깊이 우선은 한 갈래를 끝까지 파고드는 방법이므로 통로 전체를 다 걸어야만 목표에 닿고, 게다가 그렇게 걸은 길이 그대로 답이 되어 35걸음이 된다. 반대로 BFS는 통로 밖의 지름길이 있으면 그쪽을 먼저 층으로 훑어 17걸음을 지킨다. 일반화하면 목표에서 멀어지는 방향으로 긴 막다른 길을 놓는 것이 DFS를 이기는 방법이다.

5. 방문 집합 검사(if n not in came:)를 지우면 무슨 일이 벌어질지 먼저 예측한 뒤 실제로 지워서 확인하고, 결과를 숫자로 쓰시오. 그리고 "BFS도 무한 루프에 빠진다"는 설명이 맞는지 판단하시오.
📖 모범 답안

상한 LIMIT = 50000에서 끊었을 때 BFS는 꺼낸 칸 16,002 · 넣은 횟수 50,001, DFS는 꺼낸 칸 25,001 · 넣은 횟수 50,001로 둘 다 목표를 못 만났다. 상한을 풀고 5초를 주면 BFS는 프론티어가 8,955,970칸까지 자란다.

"BFS도 무한 루프"는 틀린 설명이다. DFS는 이웃한 두 칸을 A→B→A→B로 끝없이 왕복하므로 깊이가 무한히 자라는 진짜 무한 루프다. 반면 BFS는 층을 꼬박꼬박 올라가므로 논리적으로는 언젠가 끝난다. 다만 '길이 k짜리 걸어다닌 자취'를 전부 세게 되어 17걸음에 닿기 전에 자취 수가 3의 17제곱 규모로 불어난다. 끝나지 않는 것이 아니라, 끝나기를 기다릴 수 없는 것이다.

6. 상태 공간이 무한히 깊어질 수 있는 문제를 하나 들고, 거기에 DFS를 쓰면 안 되는 까닭을 쓰시오. 그런 문제에서 DFS를 그래도 쓰려면 무엇을 덧붙여야 하겠는가?
📖 모범 답안

예: 3차시 물병 문제에서 상태에 '지금까지 부은 횟수'를 넣은 표현. 0L를 붓는 헛동작도 횟수만 1 올린 새 상태가 되므로 상태가 끝없이 늘어난다. 상한을 두지 않으면 무한이었다. 바둑처럼 같은 자리를 오갈 수 있는 문제, 숫자를 계속 키울 수 있는 문제도 마찬가지다.

DFS는 한 갈래를 끝까지 파고드는 방법이라, 그 갈래가 무한이면 영영 돌아오지 않는다. 목표가 옆 갈래에 한 걸음 거리로 있어도 못 찾는다. 이것은 느린 것이 아니라 답을 못 내는 것이다.

덧붙일 것: 깊이 상한이다. 예를 들어 '10걸음까지만 파고들고 돌아온다'로 막고, 못 찾으면 상한을 11, 12로 늘려 다시 돈다. 이렇게 하면 DFS의 적은 메모리를 쓰면서도 BFS처럼 얕은 답을 먼저 찾을 수 있다. 오늘 코드의 LIMIT도 같은 생각의 가장 거친 형태다.

🔁 되돌아보기

오늘 목표가 어디인지 모르는 채로 미로를 뒤졌고, 큐로 뒤지면 64칸에 17걸음, 스택으로 뒤지면 22칸에 19걸음이라는 것을 직접 확인했습니다. 다음 시간에는 프로그램에게 "목표는 대충 저쪽이야" 하고 어림값을 쥐어 줍니다. 같은 미로에서 그 값 하나로 둘러본 칸이 어디까지 줄어드는지 보게 됩니다.

🔎

더 알아보기

오늘의 두 방법은 어디서 태어났고, 둘의 장점을 한데 모으면 무엇이 되나

회색 탁자 위에 놓인 손바닥만 한 구리 회로 기판. 검게 파낸 좁은 홈 사이로 구릿빛 배선 길과 부품 구멍이 미로처럼 이어져 있다.
역사

BFS는 세 번 태어났다 — 미로에서 회로 기판까지

너비 우선 탐색을 처음 적은 사람은 독일의 컴퓨터 개척자 콘라트 추제입니다. 1945년 무렵 자기가 설계한 프로그래밍 언어 플랑칼퀼을 다룬 박사 논문에 그래프에서 서로 이어진 부분을 찾는 방법으로 적었는데, 논문이 받아들여지지 않아 1972년에야 세상에 나왔습니다.

그사이 1959년 미국의 에드워드 무어가 같은 방법을 따로 찾아냈습니다. 그의 논문 제목이 '미로를 빠져나가는 가장 짧은 길'이니, 오늘 우리가 한 일과 똑같지요. 1961년에는 C. Y. 리가 이것을 회로 기판의 배선에 썼습니다. 기판을 격자로 나누고 출발 단자에서 1, 그 이웃에 2, 그다음에 3… 물결처럼 번호를 매겨 나가다가 도착 단자에 닿으면, 번호가 하나씩 줄어드는 칸을 거꾸로 밟아 선을 긋습니다. 오늘 코드의 '층'과 came 되짚기가 그대로 들어 있습니다.

사진처럼 구리판에서 배선 길만 남기고 나머지를 녹여 낸 것이 회로 기판입니다. 리의 방법은 길이 있기만 하면 가장 짧은 배선을 반드시 찾지만, 격자가 커지면 칸마다 번호를 적어 둬야 해서 메모리를 많이 먹었습니다. 그 뒤 나온 배선 방법들이 고친 것도 오늘의 결론과 같은 방향입니다 — 기억할 양을 줄이거나, 목표 쪽으로 먼저 번지게 하거나.

사진: 손으로 부식해 만든 구리 회로 기판 · 출처: Tinux, Wikimedia Commons (CC0)

트레모의 분필 규칙 오늘 코드 통로에 들어갈 때 입구에 분필로 표시 came 에 적는다 — 방문 집합 막다른 곳에 닿으면 지나온 갈림길로 되돌아감 가장 나중에 넣은 것부터 꺼낸다 — 스택 이미 표시된 갈림길이면 들어가지 않고 돌아섬 이미 본 칸은 프론티어에 안 넣는다 출구가 있으면 반드시 찾고, 어느 통로도 두 번 넘게 걷지 않는다. 그러나 찾은 길이 가장 짧다는 보장은 없다 — 오늘의 DFS처럼.
역사

분필 한 자루의 깊이 우선 — 트레모의 미로 풀이

깊이 우선 탐색은 컴퓨터보다 훨씬 먼저 있었습니다. 19세기 프랑스의 샤를 피에르 트레모는 미로 안에서 분필 하나만 가지고 출구를 찾는 방법을 내놓았고, 수학자 에두아르 뤼카가 1882년에 펴낸 수학 놀이 책에 이 방법을 소개했습니다.

규칙은 그림의 왼쪽과 같습니다. 통로에 들어갈 때 입구에 표시를 하고, 막히면 지나온 갈림길로 돌아가 아직 표시가 없는 통로를 고릅니다. 새 통로를 따라왔는데 이미 표시가 있는 갈림길에 닿았다면, 그 길은 한 번 본 곳으로 이어지는 고리이니 곧장 돌아섭니다. 이렇게 하면 출구가 있는 미로에서는 반드시 출구에 닿고, 어느 통로도 두 번보다 많이 걷지 않습니다.

오른쪽과 맞대 보면 오늘 코드와 한 줄씩 짝이 맞습니다. 분필 표시가 방문 집합이고, '되돌아갈 갈림길'을 차례로 기억하는 것이 스택입니다. 그러니 분필을 버리면 어떻게 될지도 오늘 이미 보았지요 — A와 B를 끝없이 오가는 DFS가 바로 표시 없이 미로에 들어간 사람입니다.

깊이 상한을 한 칸씩 늘리며 DFS를 다시 돈다 (갈림길 3) 상한 0 상한 1 상한 2 상한 3 둘러본 칸 1 둘러본 칸 4 (새 층 3) 둘러본 칸 13 (새 층 9) 둘러본 칸 40 (새 층 27) 다시 도는 얕은 층 이번에 처음 닿는 층 갈림길 3 · 깊이 10 둘러보는 칸 기억하는 칸 너비 우선 88,573 59,049 반복 깊이 심화 132,854 (1.5배) 약 30
원리 더 깊이

둘의 장점만 모으면 — 반복 깊이 심화

오늘 표를 다시 보면 두 방법은 서로 반대편을 잘합니다. BFS는 최단을 보장하지만 층 전체를 이고 있어야 하고, DFS는 갈래 하나만 기억하면 되지만 보장이 없습니다. 확인 문제 6의 모범 답안이 둘을 합치는 길을 이미 적어 두었지요 — 깊이에 상한을 두고, 못 찾으면 상한을 하나씩 늘려 처음부터 다시 도는 것입니다. 이것을 반복 깊이 심화(iterative deepening)라고 합니다.

상한이 k이면 k걸음 안의 칸을 전부 보고서야 k+1로 넘어가므로, BFS처럼 가장 얕은 목표를 먼저 찾습니다(이동 비용이 모두 같을 때). 그러면서 한 번에 기억하는 것은 DFS처럼 지금 파고든 갈래 하나뿐입니다. 얕은 층을 매번 다시 도는 게 낭비 같지만, 그림처럼 새로 닿는 맨 아래층이 그 위의 층들을 다 합친 것보다 큽니다. 본문의 예처럼 갈림길 3, 깊이 10이면 둘러보는 칸은 BFS의 1.5배에 그치고, 기억할 양은 59,049칸에서 30칸 남짓으로 줄어듭니다.

1970년대 체스 프로그램들이 먼저 이 방법을 썼고, 1985년 리처드 코프(Richard Korf)가 시간과 메모리 양쪽에서 좋은 선택임을 논문으로 정리했습니다. 8차시에서 만날 IDA*는 여기에 어림값을 더한 것으로, 문턱을 '걸음 수' 대신 '지나온 비용 + 어림값'으로 겁니다.