단원 홈
1단원 · 7차시

A*를 내 손으로 짠다
시뮬레이터 없이 30줄로

6차시에서는 칸마다 f = g + h가 찍히는 화면을 보고 h 배율 슬라이더를 움직였습니다. 오늘은 그 화면을 끄고 같은 일을 코드로 적습니다. 4차시 미로 코드에서 실제로 바뀌는 곳은 두 군데뿐입니다.

성취기준 12인기01-04
프론티어우선순위 큐heapq f = g + w·h허용 가능성문턱
🎯 학습 목표
  • 탐색의 프론티어를 우선순위 큐로 바꾸고, 우선순위 자리에 g + w·h를 넣어 A*를 직접 완성할 수 있다.
  • 같은 함수에서 w만 바꾸어 다익스트라·A*·부풀린 A*를 돌리고, 둘러본 칸과 경로 길이를 표로 비교할 수 있다.
  • 어림을 부풀렸을 때 빨라지는 문턱과 최단이 깨지는 문턱이 서로 다른 곳에 있음을 직접 재어 확인할 수 있다.
🤔

여는 장면 — 화면을 끄면 무엇이 남는가

6차시 시뮬레이터에서 여러분은 칸마다 세 숫자를 보았습니다. 왼쪽 위에 지나온 비용 g, 오른쪽 위에 남은 어림 h, 가운데 큰 글씨로 f. 한 걸음씩 재생할 때마다 f가 가장 작은 칸이 뽑혔지요. h 배율을 0.1씩 올려 가며 둘러본 칸이 65에서 52로, 다시 21로 줄어드는 것도 보았습니다.

숲 속 풀밭에 둥글게 심은 산울타리 미로를 하늘에서 내려다본 모습. 한가운데에 작은 정자가 있고 통로에 사람 몇이 보인다.
프랑스 슈농소성의 산울타리 미로를 하늘에서 찍은 사진입니다. 위에서 내려다보면 가운데 정자까지 가는 길이 한눈에 보이지만, 통로에 선 사람에게는 갈림길과 막다른 길뿐입니다. 탐색 알고리즘은 언제나 통로에 선 쪽입니다 — 오늘 우리가 코드로 적는 것은 그 사람이 지키는 규칙입니다. 출처: Lieven Smits, Wikimedia Commons (CC BY-SA 3.0)

그런데 시뮬레이터는 누군가 미리 만들어 둔 것입니다. 여러분이 한 일은 슬라이더를 민 것이고요. 성취기준 [12인기01-04]는 "정보 이용 탐색 방법을 적용한 인공지능 프로그램을 개발한다"고 적혀 있습니다. 슬라이더를 민 것은 개발이 아닙니다. 오늘의 물음은 그래서 이렇습니다.

💭 오늘의 물음

화면에서 본 A*를 코드로 옮기면, 정확히 어느 글자가 다익스트라와 달라지는가?

말로 답하면 "A*는 h를 함께 본다"입니다. 맞는 말이지만 이 문장으로는 프로그램을 못 만듭니다. 어느 줄, 어느 자리인지를 알아야 만들 수 있어요. 답을 미리 말해 두겠습니다.

📌 오늘 확인할 것

4차시에서 큐를 스택으로 바꿔 BFS와 DFS를 갈랐던 그 코드에서, 오늘 바뀌는 곳은 두 군데입니다. 하나는 프론티어를 무엇으로 쓰느냐(큐 → 우선순위 큐), 다른 하나는 꺼낼 순서를 무엇으로 정하느냐(들어온 차례 → f 값). MAZE·neighbors()·경로 복원은 한 글자도 바뀌지 않습니다.

바뀌지 않는 부분을 정확히 아는 일이 왜 중요할까요. 8차시에서 우리는 미로가 아닌 문제 — 조각을 밀어 맞추는 8-퍼즐 — 에 오늘 짠 함수를 그대로 옮겨 붙입니다. 그때 astar() 본체는 한 글자도 고치지 않고, 미로와 이웃 함수와 어림 함수만 갈아 끼웁니다. 오늘 "바뀌지 않는 부분"의 경계를 손으로 그어 두는 까닭이 그것입니다.

1

그릇이 이름을 정한다 — 프론티어를 무엇으로 쓰는가

프론티어는 3차시부터 계속 나온 낱말입니다. 탐색이 하는 일은 결국 두 줄로 줄어듭니다. 대기실에서 하나를 꺼낸다. 그 이웃들을 대기실에 넣는다. 이것을 반복할 뿐이에요.

그러면 알고리즘의 이름을 정하는 것은 무엇일까요. 넣는 방식은 다 같습니다. 다른 것은 누구를 먼저 꺼내느냐 하나뿐입니다.

대기실의 자료구조먼저 꺼내는 것이름성질
큐(줄 서기)가장 먼저 들어온 것BFS 가까운 곳부터 고르게 퍼진다 · 최단 보장
스택(쌓기)가장 나중에 들어온 것DFS 한 방향으로 깊이 파고든다 · 최단 아님
우선순위 큐우선순위가 가장 작은 것 다익스트라 · A*무엇을 우선순위로 삼느냐가 정체를 정한다
4차시에서 첫 두 줄을 이미 해 보았습니다. 오늘은 셋째 줄입니다.

4차시에서 popleft()를 pop()으로 한 낱말 바꾸었더니 둘러본 칸이 64에서 22로, 경로가 17걸음에서 19걸음으로 달라졌지요. 한 낱말이 알고리즘의 이름을 바꾼 것입니다. 오늘 바꾸는 것도 딱 그만큼입니다.

파이썬에서 우선순위 큐는 heapq로 씁니다. 새 자료형을 배울 필요는 없어요. 보통의 리스트를 넣고 빼는 함수 두 개만 알면 됩니다.

4차시 · BFS (큐)

from collections import deque

frontier = deque([START])
while frontier:
    cur = frontier.popleft()
    ...
        frontier.append(n)

오늘 · A* (우선순위 큐)

import heapq

openq = [(0, START)]
while openq:
    f, cur = heapq.heappop(openq)
    ...
        heapq.heappush(openq, (f값, n))

heappop은 맨 앞의 가장 작은 값을 꺼냅니다. 들어 있는 것이 튜플이면 첫 칸끼리 먼저 견주고, 같으면 둘째 칸으로 넘어가요. 그러니 넣을 때 (우선순위, 칸) 꼴로 넣어 두면 꺼낼 때 자동으로 우선순위가 가장 작은 칸이 나옵니다. 그 우선순위 자리에 무엇을 적느냐가 오늘의 전부입니다.

💡 왜 하필 '가장 작은 것'인가

우리가 다루는 f는 비용이기 때문입니다. 비용은 작을수록 좋지요. 만약 점수가 높을수록 좋은 문제라면 부호를 뒤집어 -점수를 넣습니다. 파이썬의 heapq에는 최소 힙 하나뿐이라, 최대 힙이 필요할 때 다들 이렇게 씁니다.

⚠️ 튜플이 왜 세 칸인가

오늘 코드는 (f, g, 칸) 세 칸짜리 튜플을 넣습니다. f가 같은 칸이 여럿일 때 파이썬은 둘째 칸을 견주고, 그래도 같으면 셋째 칸을 견줍니다. 그래서 꺼내는 순서가 언제나 하나로 정해지고, 몇 번을 돌려도 같은 숫자가 나와요. 재현되지 않는 실험은 실험이 아닙니다.

덧붙여 하나. 이 미로에서는 둘째 칸 g를 빼고 (f, 칸) 두 칸으로만 돌려도 네 설정의 숫자가 하나도 바뀌지 않습니다. 동점을 실제로 가르는 것은 둘째 칸이 아니라 첫째 칸 f 자체이기 때문이에요. 고쳤는데 아무 일도 일어나지 않는 자리도 있다는 것 — 이것도 알아 둘 만합니다.

2

우선순위 자리에 무엇을 적는가 — 저울의 눈금 w

6차시의 'h 배율' 슬라이더를 기억하나요. 그것을 코드로 옮기면 곱셈 하나가 늘어나는 것으로 끝납니다.

우선순위 = g + w × h

여기서 g는 지나온 비용, h는 남은 어림입니다. g는 이미 걸은 걸음 수라 확실히 아는 값이고, h는 벽을 무시하고 잰 맨해튼 거리라 짐작한 값입니다. 확실한 것과 짐작한 것을 한 저울에 올려 놓고, w로 짐작 쪽의 무게를 조절하는 셈이에요.

w = 0

다익스트라

h를 아예 안 본다. 지나온 비용만 보고 사방으로 둥글게 퍼진다. 최단은 보장하지만 많이 둘러본다.

w = 1

A*

g와 h를 있는 그대로 더한다. h가 실제 남은 비용을 넘지 않는 한 최단을 보장한다.

w = 5

부풀린 A*

남은 거리를 5배로 과장한다. 목표 쪽으로 돌진해 훨씬 적게 둘러보지만, 최단이 깨진다.

w가 0일 때 우선순위는 그냥 g입니다. 지나온 비용이 가장 작은 칸부터 꺼낸다는 뜻이고, 이것이 곧 다익스트라 알고리즘이에요. 세 알고리즘이 서로 다른 프로그램이 아니라 숫자 하나의 차이라는 것은 코드로 옮기고 나서야 보입니다. 시뮬레이터에서는 버튼 세 개가 각각 다른 것처럼 보였지만요.

안경을 쓰고 흰 수염을 기른 노년의 에츠허르 다익스트라가 체크무늬 셔츠에 붉은 조끼를 입고 옆쪽을 바라보는 초상
에츠허르 다익스트라(1930–2002) — 네덜란드의 컴퓨터 과학자입니다. 1956년 암스테르담에서 20분쯤 만에 최단 경로 알고리즘을 떠올렸고, 1959년 논문으로 발표했습니다. 지도 위 길찾기의 뿌리가 된 이 방법이 오늘 우리 코드에서는 w = 0이라는 한 글자로 들어 있습니다. 출처: Hamilton Richards, Wikimedia Commons (CC BY-SA 3.0)
💭 먼저 예측해 봅시다

w를 0과 1 사이, 예를 들어 0.5로 두면 어떻게 될까요. 다익스트라 65칸과 A* 52칸의 딱 가운데쯤, 58칸 언저리일 것 같지요. 6차시 시뮬레이터로 재 보면 그렇지 않습니다 — w = 0.5는 64칸입니다. 다익스트라에서 겨우 한 칸 줄었어요. 61칸이 되는 것은 w = 0.7부터이고, 0.8에서 57칸, 0.9에 가서야 52칸이 됩니다. 배율과 둘러본 칸은 비례하지 않습니다 — 줄어드는 일이 대부분 0.7~0.9 사이에 몰려 있어요.

다만 이 구간 어디에서도 경로는 17걸음 그대로입니다. h를 줄이는 쪽은 안전하고 부풀리는 쪽만 위험해요. 왜 한쪽만 위험할까요? 개념 4에서 숫자로 답합니다.

3

한 줄이 빠지면 돌긴 도는데 답이 틀린다

여기서 A* 구현의 가장 흔한 버그를 미리 만나 두겠습니다. 4차시 BFS에는 없었고 오늘 새로 생기는 문제입니다.

어떤 칸 N에 처음 닿았을 때 비용이 7이었다고 합시다. 그래서 (7+h, N)을 대기실에 넣었어요. 그런데 탐색이 계속되다가 다른 길로 같은 칸 N에 비용 5로 닿는 경로가 나타납니다. BFS에서는 이런 일이 안 생겼습니다 — 가까운 곳부터 층층이 퍼지니 처음 닿은 값이 곧 최선이었으니까요. 우선순위 큐는 층을 건너뛰며 꺼내므로 이 일이 실제로 생깁니다.

  • 대기실에 (7+h, N)과 (5+h, N)이 둘 다 들어 있게 됩니다.
  • 둘 중 싼 쪽(5)이 먼저 꺼내집니다. 여기까지는 아무 문제가 없어요.
  • 문제는 나중에 비싼 쪽(7)도 꺼내진다는 것입니다. 이미 5로 처리를 끝낸 칸을 7이라는 낡은 값으로 다시 처리하면서, 이웃들의 비용과 부모를 7 기준으로 덮어씁니다.
  • 덮어쓰기가 되풀이되면 부모 표에 고리가 생깁니다. A의 부모가 B인데 B의 부모가 다시 A인 상태예요. 그러면 목표에서 거꾸로 되짚는 일이 영원히 끝나지 않습니다.
정상 — 부모를 따라가면 반드시 출발점에 닿는다 S A B G 화살표 = 부모 부모 · 부모 · 부모 → 출발점에서 멈춘다 조건문이 빠지면 — 두 칸이 서로를 부모로 가리킨다 A B A의 부모는 B, B의 부모는 A. 되짚기가 끝나지 않는다.

경로 복원은 목표에서 부모를 따라 거꾸로 걷는 일입니다. 부모 표에 고리가 하나라도 생기면 그 걸음은 멈추지 않습니다.

이 버그가 무서운 까닭은 망가지는 모습이 설정마다 다르기 때문입니다. 어떤 설정에서는 대기실이 폭발하고, 어떤 설정에서는 도착은 하는데 경로를 못 만듭니다. 오늘 실습 ③에서 그 차이를 직접 보게 됩니다. 막는 방법은 두 줄입니다.

# ① 낡은 표를 들고 온 손님은 돌려보낸다
if gc > g.get(cur, 10**9):
    continue

# ② 더 싼 길을 찾았을 때만 기록하고 대기실에 넣는다
if ng < g.get(n, 10**9):
    g[n], came[n] = ng, cur
    heapq.heappush(openq, (ng + w * h(n), ng, n))

①은 꺼낸 뒤의 방어이고 ②는 넣기 전의 방어입니다. 둘 다 있어야 해요. ①만 두면 대기실이 쓸데없이 부풀고, ②만 두면 낡은 값이 그대로 처리됩니다.

📌 g.get(n, 10**9)는 무슨 뜻인가

"칸 n의 기록된 비용을 가져오되, 아직 기록이 없으면 10억으로 친다"는 뜻입니다. 안 가 본 칸은 무한히 비싼 셈 치는 거예요. 그러면 ng < 10억이 언제나 참이 되어 처음 만나는 칸은 무조건 기록됩니다. 파이썬에서 '무한대'를 흉내 내는 흔한 방법이에요. g[n]이라고 쓰면 없는 칸에서 KeyError로 죽습니다.

⚠️ 오늘 코드의 안전장치는 장식이 아니다

아래 실습 코드에는 LIMIT = 20000이라는 상한과, 경로를 되짚을 때 같은 칸을 두 번 밟았는지 보는 검사가 들어 있습니다. 실습 ③에서 조건문을 지우면 실제로 끝나지 않는 코드가 되기 때문이에요. 이 교과서의 파이썬은 웹브라우저 안에서 돌아가는데, 끝나지 않는 코드는 곧 탭이 얼어붙는 것입니다. 이 차시를 만들면서 실제로 겪었고, 그래서 넣었습니다. 망가지더라도 말은 하고 망가지게 만드는 것 — 여러분이 앞으로 짤 프로그램에도 같은 배려를 넣으세요.

4

맞바꿈을 숫자로 — 문턱은 하나가 아니라 둘이다

오늘 쓰는 미로는 4차시부터 계속 쓰던 그 미로입니다. 7행 12열, 벽이 아닌 칸은 65칸, 출발 S는 왼쪽 위 (0,0), 목표 G는 오른쪽 아래 (6,11), 맨해튼 거리는 17이고 최단 경로도 17걸음입니다. 같은 미로에 w만 바꿔 넣으면 이런 숫자가 나옵니다.

설정w둘러본 칸경로 길이대기실에 넣은 횟수최단인가
다익스트라0.065 17걸음64O
A*1.052 17걸음58O
2배 부풀림2.021 17걸음34O
5배 부풀림5.020 19걸음32X
실습에서 여러분이 직접 찍어 볼 값입니다. 먼저 보고 놀란 뒤에 확인하는 편이 좋습니다.

표를 세 번 읽어 봅시다.

  • 다익스트라가 둘러본 65칸은 벽이 아닌 칸 전부입니다. 목표를 찾고도 멈추지 못하고 결국 미로를 통째로 훑은 셈이에요. h를 안 보면 어느 쪽이 목표에 가까운지 모르니 당연한 결과입니다.
  • A*는 52칸으로 줄었는데 경로는 똑같이 17걸음입니다. 13칸을 아꼈는데 잃은 것이 없어요. 이것이 좋은 어림의 값어치입니다.
  • 5배로 부풀리면 20칸까지 줄지만 경로가 19걸음이 됩니다. 2걸음을 더 걷는 대가로 둘러본 칸을 65에서 20으로 줄인 것이지요. 이것이 좋은 거래인지 나쁜 거래인지는 문제가 정합니다. 게임 속 유닛이라면 2걸음 손해가 눈에 안 띄지만, 수술 로봇의 경로라면 이야기가 다릅니다.

여기까지가 흔히 말하는 '맞바꿈'입니다. 그런데 w를 1.0에서 0.1씩 올려 보면 이야기가 한 겹 깊어집니다. 값이 바뀌는 지점만 추리면 이렇습니다.

w둘러본 칸경로 길이무슨 일이 일어났나
1.05217걸음A* 그대로
1.12117걸음 속도가 꺾이는 문턱 — 2.5배 빨라졌는데 최단은 그대로
1.1 ~ 3.02117걸음 스무 개 값이 전부 같다 — 평평한 구간
3.12019걸음 최단이 깨지는 문턱
0.1 간격으로 5.0까지 훑은 결과입니다. 3.1 위로는 더 바뀌지 않습니다.

문턱이 두 개이고, 둘 사이가 2.0만큼 떨어져 있습니다. 이 사실이 왜 중요할까요. "w를 올리면 빨라지는 대신 최단을 잃는다"는 흔한 설명은 이 미로에서는 절반만 맞습니다. w가 1.1에서 3.0 사이일 때는 2.5배 빨라지면서 최단도 그대로 지킵니다. 맞바꿈이 아니라 그냥 이득이에요. 이 구간이 있다는 것을 모르면 "안전하게 w=1로 두자"는 결론에 그치게 됩니다.

💡 왜 1.1에서 그렇게 크게 떨어지나

이 미로에서는 최단 경로에 놓일 수 있는 칸이 전부 f = 17로 동점입니다. w = 1인 A*는 그 52칸을 전부 꺼내 봐야 끝나요. 우선순위가 다 같으니 줄일 수가 없습니다. 그런데 w를 조금만 올리면 목표에 가까운 칸의 f가 상대적으로 작아지면서 동점이 깨집니다. 그 순간 52칸이 21칸으로 내려앉아요. w가 하는 일의 절반은 동점을 깨는 것입니다.

⚠️ 4차시는 64칸이었는데 오늘은 65칸이다

4차시 BFS는 64칸을 둘러보았고, 오늘 다익스트라는 65칸입니다. 벽이 아닌 칸이 65칸이니 BFS는 딱 한 칸을 끝내 안 꺼낸 셈이에요. 그 한 칸은 (6,9)입니다. 알고리즘의 우열이 아니라 마지막 동점을 처리하는 순서의 차이입니다.

이 미로에서 출발점으로부터 거리가 17인 칸은 딱 둘 — (6,9)와 목표 (6,11)입니다. BFS의 큐는 들어온 차례대로 꺼내는데 목표가 먼저 들어와 있어서, 목표를 꺼내고 그대로 끝납니다. 다익스트라의 대기실은 (f, g, 칸)을 견주는데 (17, 17, (6,9))가 (17, 17, (6,11))보다 작아서 (6,9)를 먼저 꺼내고, 그다음에 목표를 꺼냅니다.

증거는 아래 코드 상자 ②에 있습니다. (6,9)를 벽으로 막고 돌리면 다익스트라가 정확히 64칸이 됩니다. 경로 길이는 두 차시 모두 17걸음으로 같아요. 비용이 모두 같은 미로에서는 BFS와 다익스트라가 사실상 같은 알고리즘이라는 뜻입니다.

마지막으로, 부풀리는 쪽만 위험한 까닭입니다. 꺼낸 칸을 f 값별로 나눠 세면 이렇게 나옵니다.

설정꺼낸 칸f=17f=19f=21
다익스트라 w=06552 103
A* w=15252 ——
부풀림 w=52016 4—

최단 비용이 17이므로 f가 19나 21인 칸은 답이 될 수 없는 칸입니다. 다익스트라가 A*보다 더 꺼낸 13칸은 정확히 그 칸들이에요. h는 절대 음수가 아니니 f = g + h ≥ g이고, 따라서 A*가 꺼내는 칸은 언제나 다익스트라가 꺼내는 칸의 부분집합입니다. 맨해튼 거리는 벽을 무시하고 재므로 벽을 더 세워도 이 관계는 깨지지 않아요. 코드 상자 ②에서 벽을 하나씩·둘씩 전부 세워 보며 이 주장을 시험합니다.

🧪

대조 실험실 — 코드가 낼 값을 먼저 눈으로 잡아 둔다

코드를 짜기 전에 답을 먼저 알아 두는 것이 오늘의 순서입니다. 아래 실험실은 여러분이 곧 짤 astar()와 똑같은 규칙으로 이 페이지 안에서 직접 계산합니다. 미리 적어 둔 결과를 펼쳐 보이는 것이 아니라, 여러분이 슬라이더를 놓은 그 w로 그 자리에서 미로를 다시 풉니다. 그러니 조금 뒤 코드가 찍는 숫자와 한 자리도 다르면 안 됩니다. 다르다면 둘 중 하나가 틀린 것이고, 그것을 찾는 것이 오늘 15분의 절반입니다.

🧭 A* 대조 실험실 — 같은 미로, w 하나만 바꾼다 INTERACTIVE

배율 w를 0.0에서 5.0까지 0.1씩 움직여 보세요. ▶ 재생을 누르면 대기실에서 칸을 꺼내는 순서가 그대로 칠해집니다. 칸 안의 숫자는 몇 번째로 꺼냈는가입니다. '조건문' 스위치를 끄면 개념 3에서 본 그 버그가 실제로 일어납니다.

벽 먼저 꺼낸 칸 나중에 꺼낸 칸 대기실에 있는 칸 찾아낸 경로
둘러본 칸–
경로 길이–
대기실에 넣은 횟수–
최단(17걸음)인가–
[안내] w를 정하고 ▶ 재생을 누르세요.
🎯 실험실에서 할 일 세 가지 — 공책에 적습니다
  1. 표 채우기. w = 0.0 / 1.0 / 2.0 / 5.0 네 설정을 돌려 (둘러본 칸, 경로 길이) 네 쌍을 적습니다. 아래 대조표에 옮겨 적으면 바로 채점됩니다.
  2. 문턱 두 개 찾기. w를 1.0에서 0.1씩 올리며 ① 둘러본 칸이 처음 뚝 떨어지는 값과 ② 경로가 처음 17걸음보다 길어지는 값을 찾습니다. 두 값이 같습니까, 다릅니까?
  3. 반례 만들기. '조건문' 스위치를 끄고 w를 0.0 · 1.0 · 5.0으로 두어 보세요. 세 번 다 같은 방식으로 망가지지 않습니다. 무엇이 어떻게 달랐는지 한 줄씩 적습니다.

대조표 — 내가 적은 값과 계산한 값을 맞춰 본다

6차시 시뮬레이터에서 적어 둔 값이 있으면 그것을 먼저 넣어 보세요. 없으면 위 실험실이나 아래 코드에서 얻은 값을 넣습니다. 대조하기를 누르면 이 페이지가 네 설정을 그 자리에서 다시 풀어 견줍니다.

설정내가 적은 둘러본 칸내가 적은 경로 걸음판정
w = 0.0 –
w = 1.0 –
w = 2.0 –
w = 5.0 –
💡 실험실이 '어설픈 시뮬레이터'가 아닌 까닭

이 화면에는 정답이 저장되어 있지 않습니다. 여러분이 슬라이더를 3.7에 놓으면 그 값으로 미로를 처음부터 다시 풉니다. 그래서 교과서에 적히지 않은 설정도 얼마든지 시험할 수 있어요. 교과서가 모르는 값을 물어볼 수 있어야 실험입니다.

💻

손으로 — A*를 완성하고, 문턱을 재고, 일부러 부순다

이제 코드입니다. 아래 상자에 빈칸이 두 곳 있습니다. 개념 2의 저울과 개념 3의 두 줄을 그대로 옮겨 적으면 됩니다. 나머지는 4차시에서 쓰던 것 그대로예요 — MAZE·find()·neighbors()는 한 글자도 바꾸지 않았습니다.

오늘의 실습은 넷입니다. 코드가 찍는 【1】~【3】이 그 자리를 하나씩 맡습니다.

  • 실습 ① 빈칸 두 곳을 채운다. ?????가 있는 자리는 둘뿐입니다. 실행해서 【1】에 65/17 · 52/17 · 21/17 · 20/19가 나오면 성공.
  • 실습 ② 대조한다. 위 실험실과 6차시에서 적은 값이 【1】과 같은가요? 52가 아니라 65가 나왔다면 빈칸 ①에서 w * h(n)을 빠뜨린 것입니다 — 그러면 배율이 무시되어 어느 w를 넣어도 다익스트라가 됩니다. 오류 없이 조용히 틀리는 가장 흔한 실수예요.
  • 실습 ③ 일부러 부순다. 【3】이 조건문을 지운 판을 함께 돌립니다. 세 설정이 서로 다르게 망가지는 것을 확인하세요. 더 확실히 하려면 빈칸 ②에 채운 식을 True로 바꿔 직접 지워 보세요. 그러면 【1】의 네 줄이 전부 ⚠️ 경고로 바뀌고, 【2】의 41줄은 경로 자리가 모두 순환이 됩니다. 그 조건문 하나가 오늘 본 모든 숫자를 떠받치고 있었다는 뜻이에요.
  • 실습 ④ 문턱을 잰다. 【2】가 w를 1.0에서 0.1씩 5.0까지 올린 41줄을 그대로 찍습니다. 눈으로 훑어 값이 달라지는 자리를 직접 찾아 보세요. 그다음에 밑에 붙는 요약 세 줄과 맞춰 봅니다. 몇에서 꺾이고 몇에서 깨집니까?
💡 막혔을 때

빈칸 ①은 개념 2의 저울 상자에 그대로 적혀 있습니다. 변수 이름만 코드에 맞추면 돼요 — 지나온 비용은 ng, 배율은 w, 남은 어림은 h(n)입니다. 빈칸 ②는 개념 3의 코드 상자에서 보라색으로 칠한 그 줄의 괄호 안입니다. ?????를 지우고 그 자리에 쓰세요.

세 설정이 서로 다르게 망가집니다. 실행 결과는 이렇습니다.

설정정상일 때조건문을 지우면
w = 0 (다익스트라)65칸 · 넣은 횟수 64 폭주 — 20,001번 넣고 6,714칸 꺼내고도 못 끝냄
w = 1 (A*)52칸 · 넣은 횟수 58 984칸 · 2,738번 뒤 부모 표에 고리
w = 5 (부풀림)20칸 · 넣은 횟수 32 23칸 · 61번 뒤 부모 표에 고리

조건을 지우면 더 비싼 길을 찾았을 때도 g[n]과 came[n]을 그 비싼 값으로 덮어씁니다. 그러면 위쪽의 '낡은 표를 돌려보내는' 검사가 무력해져 같은 칸이 몇 번이고 다시 처리돼요.

  • w = 0은 h를 아예 안 보니 어느 쪽이 목표인지 모른 채 이 되풀이가 미로 전체로 번집니다. 목표에 닿기도 전에 대기실이 터집니다.
  • w = 1·w = 5는 목표 쪽으로 밀려가 도착은 합니다. 그런데 그동안 부모가 뒤엉켜 고리가 만들어져 있어요. 목표에서 거꾸로 되짚으면 그 고리를 영원히 돕니다. w가 클수록 고장이 더 일찍 나타납니다 — 2,738번과 61번의 차이입니다.

이것이 이 버그의 무서운 점입니다. 같은 한 줄을 지웠는데 어떤 설정에서는 '안 끝남'으로, 다른 설정에서는 '경로를 못 만듦'으로 나타납니다. "한 번 돌려 봤더니 되던데요"가 증명이 될 수 없는 까닭이에요.

두 번째 코드 상자는 확인 문제 6번의 증거입니다. "벽을 하나 더 세워 A*가 다익스트라보다 더 많이 둘러보게 만들 수 있을까?" 말로 다투기보다 가능한 벽을 전부 세워 보는 편이 빠릅니다. 벽 한 개짜리 58가지와 벽 두 개짜리 1,647가지를 전수로 돌리고, 이어서 꺼낸 칸을 f 값별로 나눠 세어 왜 그런지까지 봅니다. 빈칸은 코드 상자 ①과 똑같은 답이 들어갑니다.

📌 코드 상자 ②를 읽는 법

【4】의 셋째 줄을 눈여겨보세요. 벽 (6,9)를 막으면 다익스트라가 64칸이 됩니다. 개념 4의 경고 상자에서 예고한 그 칸이에요. 4차시 BFS의 64칸이 어디서 온 숫자인지가 여기서 확인됩니다.

【4】는 벽 두 개짜리 1,647가지를 전부 돌리느라 실행에 몇 초가 걸립니다. 벽을 세 개로 넓히지 마세요 — 경우의 수가 수만 가지로 늘어 브라우저가 오래 멈춥니다. '전수로 확인한다'는 방법은 셀 수 있을 때만 쓸 수 있는 방법입니다.

📖

정리 — 오늘 바뀐 것은 두 줄이었다

4차시의 BFS 코드와 오늘의 A* 코드를 나란히 놓으면, 실제로 달라진 곳은 이렇습니다.

부분4차시 BFS오늘 A*바뀌었나
MAZE · find() · neighbors()그대로그대로 —
경로 복원(came 되짚기)그대로그대로 —
프론티어dequeheapq 바뀜
꺼내는 순서들어온 차례 g + w×h 가 작은 것바뀜
중복을 막는 방법방문 표시로 충분 더 싼 길일 때만 갱신추가

오늘 얻은 것을 한 줄로 줄이면 이렇습니다. 알고리즘의 이름은 그릇과 우선순위가 정한다. 큐면 BFS, 스택이면 DFS, 우선순위 큐에 g를 넣으면 다익스트라, g + h를 넣으면 A*, g + 5h를 넣으면 부풀린 A*입니다. 다섯 가지가 다섯 개의 프로그램이 아니라 한 프로그램의 다섯 가지 설정이었어요.

그리고 숫자로 확인한 것이 둘입니다. 첫째, 좋은 어림은 공짜로 이득을 준다 — 65칸이 52칸으로 줄었는데 경로는 17걸음 그대로였습니다. 둘째, 문턱은 하나가 아니었다 — 빨라지는 문턱 1.1과 최단이 깨지는 문턱 3.1 사이에 2.0만큼의 안전한 구간이 있었습니다. 첫째만 알면 A*를 쓸 수 있고, 둘째까지 알아야 A*를 얼마나 부풀려도 되는지 판단할 수 있습니다.

📌 오늘 짠 함수는 다음 두 차시에서 그대로 쓴다

8차시에서는 미로가 아니라 8-퍼즐을 풉니다. astar() 본체는 한 글자도 고치지 않고 neighbors()와 h()만 조각 퍼즐용으로 갈아 끼웁니다. 어림을 무엇으로 잡느냐에 따라 둘러보는 상태가 48,390개에서 283개까지 달라져요. 9차시에서는 칸마다 비용이 다른 지형 지도로 넘어가 ng = gc + 1의 1을 지형 비용으로 바꿉니다. 오늘 그은 '바뀌지 않는 부분'의 경계가 그때 값어치를 합니다.

🔁 되돌아보기

오늘 화면 대신 코드로 A*를 짜서 65 · 52 · 21 · 20이라는 네 숫자를 직접 찍었고, 문턱이 1.1과 3.1 두 개라는 것을 0.1씩 올려 가며 재었습니다. 가장 놀랐던 숫자 하나를 고르고 왜 놀랐는지 한 줄로 적어 보세요. 다음 시간에는 이 함수를 미로 밖으로 들고 나갑니다.

✅

확인 문제

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

1. 4차시 BFS 코드와 오늘의 A* 코드를 나란히 놓고, 바뀐 곳 두 군데를 짚고 각각 무엇을 바꾸는지 한 줄씩 쓰시오.
📖 모범 답안

① 프론티어의 자료구조 — deque에서 heapq(우선순위 큐)로. 꺼낼 후보를 담는 그릇이 바뀐다.
② 꺼내는 순서의 기준 — '들어온 차례'에서 g + w×h가 가장 작은 것으로. 이 기준이 알고리즘의 정체를 정한다.

덧붙여 하나가 새로 늘었다 — '더 싼 길을 찾았을 때만 갱신'하는 조건문이다. BFS에서는 가까운 곳부터 층층이 퍼지므로 처음 닿은 값이 곧 최선이라 필요가 없었지만, 우선순위 큐는 층을 건너뛰며 꺼내므로 같은 칸에 더 싼 길이 나중에 나타날 수 있다.

2. 프론티어를 우선순위 큐로 쓰는 까닭을 '무엇을 먼저 꺼내야 하는가'로 설명하시오. 그리고 heapq에 넣는 튜플의 첫 칸에 무엇을 적어야 하는지 쓰시오.
📖 모범 답안

A*는 "지금까지 든 비용 + 앞으로 들 어림"이 가장 작은 칸, 곧 가장 유망한 칸을 먼저 살펴야 한다. 큐나 스택은 들어온 순서로만 꺼내므로 '가장 작은 f를 가진 칸'을 골라낼 방법이 없다. 우선순위 큐는 넣을 때 우선순위를 함께 적어 두면 꺼낼 때 자동으로 가장 작은 것을 준다.

튜플의 첫 칸에는 우선순위, 곧 g + w×h를 적는다. heappop이 튜플의 첫 칸부터 견주기 때문이다. 칸 좌표를 첫 칸에 적으면 좌표가 작은 순서로 꺼내지므로 탐색이 아니라 그냥 훑기가 된다.

3. 오늘 실행한 결과 — 코드 상자 ①의 【1】에서 얻은 네 줄을 표로 채우고, 어느 설정이 '빠르지만 최선이 아닌지' 짚으시오. 또 다익스트라의 65칸이 무엇을 뜻하는 숫자인지 한 줄로 쓰시오.
📖 모범 답안

w=0 65칸·17걸음 / w=1 52칸·17걸음 / w=2 21칸·17걸음 / w=5 20칸·19걸음.

빠르지만 최선이 아닌 것은 w=5다. 둘러본 칸을 52에서 20으로 줄이는 대신 경로가 2걸음 길어졌다. w=2는 21칸으로 w=5와 거의 같게 빠르면서도 최단을 지켰으므로 '빠르지만 최선이 아니다'에 해당하지 않는다.

65는 이 미로에서 벽이 아닌 칸 전부다. 다익스트라는 h를 안 보므로 어느 쪽이 목표에 가까운지 모르고, 그래서 목표를 찾고도 멈출 근거가 없어 미로를 통째로 훑는다.

4. 오늘 실행한 결과 — 코드 상자 ①의 【2】에서 w를 1.0부터 0.1씩 올렸을 때 둘러본 칸이 처음 크게 줄어든 값과 경로가 처음 길어진 값을 각각 쓰시오. 두 값이 다르다는 사실이 무엇을 뜻하는지 설명하시오.
📖 모범 답안

둘러본 칸이 처음 줄어든 값은 w = 1.1(52칸 → 21칸), 경로가 처음 길어진 값은 w = 3.1(17걸음 → 19걸음)이다.

두 값이 2.0만큼 떨어져 있다는 것은, w가 1.1에서 3.0 사이일 때는 2.5배 빨라지면서 최단도 그대로 지킨다는 뜻이다. 흔히 말하는 "빨라지는 대신 최단을 잃는다"는 맞바꿈이 이 구간에서는 성립하지 않는다. 부풀리기가 곧 위험이 아니라, 얼마나 부풀리느냐가 문제인 것이다.

1.1에서 크게 떨어지는 까닭은 이 미로에서 최단 경로에 놓일 수 있는 칸이 전부 f = 17로 동점이기 때문이다. w를 조금만 올리면 그 동점이 깨지면서 52칸이 21칸으로 내려앉는다. 다만 이 두 문턱 값은 미로가 정한다 — 벽을 바꾸면 달라진다.

5. '더 싼 길일 때만 갱신' 조건문을 지웠을 때 w = 0 · 1 · 5 에서 각각 무엇이 망가졌는지 관찰한 대로 쓰고, 그 결과가 왜 더 위험한지 설명하시오.
📖 모범 답안

w=0은 대기실에 20,001번을 넣고 6,714칸을 꺼내고도 목표에 못 닿아 상한에 걸렸다. w=1은 도착은 했지만 부모 표에 고리가 생겨 경로를 되짚을 수 없었다(984칸 · 2,738번). w=5도 같은 고장인데 훨씬 일찍 나타났다(23칸 · 61번).

위험한 까닭은 한 줄을 지웠는데 증상이 설정마다 다르기 때문이다. 한 설정에서 돌려 보고 "되던데요"라고 하면, 다른 설정에서 전혀 다른 모습으로 터진다. 더구나 셋 다 '틀린 답을 조용히 낸다'가 아니라 '안 끝난다 / 경로를 못 만든다'로 나타나서, 증상만 보고는 원인이 우선순위 식인지 갱신 조건인지 구별하기 어렵다. 안전장치(LIMIT과 고리 검사)가 없었다면 브라우저가 그대로 멈췄을 것이다.

6. 벽을 하나 더 세워 A*가 다익스트라보다 오히려 더 많이 둘러보게 만들 수 있는가? 코드 상자 ②의 결과를 근거로 답하고, 그 까닭을 f = g + h로 설명하시오.
📖 모범 답안

만들 수 없다. 벽 한 개를 세울 수 있는 자리 58가지를 전부 해 보았을 때 'A* − 다익스트라'의 최댓값이 −4였고(벽 (3,10): 다익스트라 64칸 / A* 60칸), 벽 두 개짜리 1,647가지에서도 최댓값이 −3이었다. 한 번도 양수가 되지 않았다.

까닭은 이렇다. 맨해튼 거리는 벽을 무시하고 재므로 실제 남은 비용보다 결코 크지 않다(허용 가능성). h가 음수가 아니므로 f = g + h ≥ g이고, 최단 비용을 넘는 f를 가진 칸은 A*가 꺼내지 않는다. 코드 상자 ②의 【5】가 그것을 세어 보여 준다 — 다익스트라가 꺼낸 65칸은 f=17이 52칸, f=19가 10칸, f=21이 3칸인데, A*는 그중 f=17짜리 52칸만 꺼냈다. 곧 A*가 꺼내는 칸은 언제나 다익스트라가 꺼내는 칸의 부분집합이다. 벽을 세워도 맨해튼 거리는 그대로이므로 이 포함 관계가 깨지지 않는다.

둘이 같아질 수는 있다. 어림이 알려 주는 정보가 없을수록 A*도 결국 전부 뒤지게 되고, 그때 A*는 다익스트라와 같아진다. '어림이 쓸모없어지는' 상황이 그것이다.

🔎

더 알아보기

오늘 채운 빈칸 두 곳은 어디서 왔고, 그 뒤에서 무엇이 돌아가며, 어디까지 부풀려도 되는가

유리 진열장 안에 전시된 로봇 셰이키. 바퀴 달린 상자 모양 몸체 위에 카메라와 안테나 틀이 솟아 있고, 뒤편에 옛 흑백 사진이, 앞에 설명판이 놓여 있다.
역사

A*는 로봇 하나의 길찾기에서 태어났다 — 셰이키

사진은 미국 스탠퍼드 연구소(SRI)가 1960년대 후반에 만든 이동 로봇 셰이키(Shakey)입니다. 카메라로 방 안을 보고, 상자와 문의 위치를 지도로 만든 다음, 어디로 갈지 스스로 계획해서 움직였어요. 지금은 미국 컴퓨터 역사 박물관에 전시되어 있습니다.

이 로봇이 방과 복도 사이에서 길을 찾게 하려고 SRI의 피터 하트, 닐스 닐슨, 버트럼 라파엘이 1968년 논문으로 내놓은 방법이 A*입니다. 다익스트라의 방법에 '남은 거리의 어림'을 더해 목표 쪽 칸을 먼저 살피게 한 것이지요. 이들은 어림이 실제 남은 비용을 넘지 않으면 A*가 반드시 최단 경로를 찾는다는 것도 함께 증명했습니다. 오늘 여러분이 채운 빈칸 ①이 그 어림을 더하는 자리이고, 빈칸 ②가 '더 싼 길일 때만 고친다'는 규칙입니다.

쉰 해가 훌쩍 지났지만 A*는 지금도 게임 속 캐릭터의 길찾기, 로봇의 경로 계획처럼 지도가 있고 목표가 하나인 문제에서 가장 먼저 꺼내는 도구입니다. 6차시 화면에서 본 f = g + h가 바로 그 논문의 식입니다.

사진: 컴퓨터 역사 박물관에 전시된 셰이키 · 출처: The wub, Wikimedia Commons (CC BY-SA 4.0)

17 19 18 21 20 19 22 ← heappop 이 꺼내는 자리 부모 ≤ 자식 리스트 속 모습 — k번 칸의 자식은 2k+1, 2k+2번 칸 17 19 18 21 20 19 22 [0][1][2] [3][4][5] [6]
원리 더 깊이

heapq 는 줄을 다 세우지 않는다 — 이진 힙

오늘 대기실로 쓴 heapq는 보통의 파이썬 리스트를 이진 힙이라는 모양으로 유지합니다. 지키는 규칙은 단 하나, 부모는 두 자식보다 작거나 같다입니다. 그림처럼 리스트의 k번 칸 아래에 2k+1번과 2k+2번 칸이 매달린 나무로 보면 되고, 이 규칙 덕분에 맨 앞 0번 칸에는 언제나 가장 작은 값이 있습니다.

눈여겨볼 점은 나머지가 정렬되어 있지 않다는 것입니다. 그림의 리스트는 17, 19, 18, … 순서라서 크기순이 아니지요. 우리에게 필요한 것은 '지금 가장 작은 하나'뿐이니, 전부 줄 세우는 수고를 하지 않는 것입니다. 하나를 넣거나 꺼낼 때는 나무의 한 줄기만 따라 위아래로 자리를 바꾸면 되므로, 자료가 n개일 때 걸리는 시간이 log n에 비례합니다.

65칸짜리 미로에서는 매번 리스트를 통째로 정렬해도 차이가 느껴지지 않습니다. 하지만 8차시의 8-퍼즐은 어림 없이 풀면 48,390개의 상태를 꺼냅니다. 그때는 '필요한 만큼만 정리한다'는 이 설계가 곧 속도의 차이가 됩니다.

벽 S G h = 4 벽을 무시하고 잰 맨해튼 거리 실제 = 10 벽을 돌아가는 길 4 ≤ 10 허용 가능 w = 3 이면 어림은 3 × 4 = 12 — 실제 10 을 넘는다 어림이 실제를 넘는 순간 '반드시 최단'이라는 보장이 사라진다. 보장이 사라졌다고 곧바로 틀리는 것은 아니다.
오해 바로잡기

부풀리면 틀린다? — 허용 가능성과 '보장'의 차이

맨해튼 거리라는 이름은 바둑판처럼 길이 난 뉴욕 맨해튼에서 왔습니다. 건물을 뚫고 갈 수 없으니 두 지점 사이를 가로 이동 + 세로 이동으로 잰다는 뜻이에요. 우리 미로도 상하좌우로만 움직이니 사정이 같습니다. 그림처럼 벽이 가로막으면 실제로는 10걸음을 돌아가야 하는데 맨해튼 거리는 4라고 말합니다. 이렇게 어림이 실제 남은 비용을 결코 넘지 않는 성질을 허용 가능성(admissibility)이라 부르고, A*의 최단 보장이 정확히 여기서 나옵니다.

w를 곱하면 사정이 달라집니다. 그림에서 w = 3이면 어림이 12가 되어 실제 10을 넘지요. 그 순간 이론이 주던 보장은 사라집니다. 그러나 오늘 우리 미로는 w를 3.0까지 올려도 17걸음을 지켰고, 3.1에서야 경로가 길어졌습니다. 보장이 없다는 것과 틀린다는 것은 다른 말입니다. 반대로 w를 1보다 작게 하면 어림이 더 작아질 뿐이라 여전히 허용 가능해요. 줄이는 쪽이 안전한 까닭입니다.

그래서 빠르기가 더 급한 곳에서는 일부러 w를 1보다 크게 둔 가중 A*(weighted A*)를 씁니다. 허용 가능한 어림에 w를 곱하면 찾은 경로가 최단의 w배를 넘지 않는다는 것이 증명되어 있어서, '얼마까지 손해를 감수할지'를 숫자로 정할 수 있거든요. 오늘 w = 5의 19걸음도 17의 5배보다 훨씬 짧았습니다. 유닛 수백 개가 한꺼번에 길을 찾는 게임에는 이 거래가 잘 맞고, 몇 걸음의 손해가 곧 위험인 일에는 맞지 않습니다.