Devin.KR

큐·방문 집합과 BFS

90분 안팎

학습 목표

4방향 격자에서 최단 경로와 경로 없음 결과를 계산합니다.

개념

움직일 수 있는 칸들을 연결합니다

팽창 지도가 준비되어도 어느 순서로 목표에 갈지는 정해지지 않았습니다. 이번에는 빈 셀 하나를 정점으로 보고 오른쪽·위·왼쪽·아래 이웃으로 이동하는 그래프를 만듭니다. 모든 이동의 비용은 1입니다. 로봇의 자세와 회전 시간은 이 비용에 들어가지 않습니다. BFS의 결과를 가장 빠른 주행 경로라고 부르지 않고 이동 횟수가 최소인 셀 경로라고 설명합니다.

실습 함수 bfs는 이미 차단된 grid와 start·goal 셀을 받습니다. 이 레슨에서는 팽창을 다시 수행하지 않습니다. 앞 단계의 출력과 탐색 입력을 나누면 실패가 지도 정책 때문인지 탐색 구현 때문인지 확인하기 쉽습니다. 최단 경로가 여러 개일 수 있으므로 이웃 순서도 결과 계약에 포함합니다.

큐가 거리 층을 유지합니다

출발 셀을 deque에 넣고 popleft로 가장 먼저 넣은 셀을 꺼냅니다. 출발에서 한 번 이동하는 후보는 두 번 이동하는 후보보다 먼저 처리됩니다. 모든 간선 비용이 같으므로 이 순서가 최단 이동 횟수를 보장합니다. list.pop()으로 뒤에서 꺼내면 깊게 파고드는 순서가 되어 같은 근거를 사용할 수 없습니다. deque를 선택한 이유는 FIFO 의미와 앞쪽 삭제 비용을 함께 명확하게 하기 위해서입니다.

한 셀의 이웃을 생성할 때 거대한 인접 행렬을 미리 만들 필요는 없습니다. 정해 둔 네 방향 차이를 현재 (열,행)에 더합니다. 다음 셀이 지도 안이고 빈 칸인지 먼저 검사한 뒤 발견 여부를 확인합니다. 음수 인덱스를 그대로 읽으면 Python이 마지막 행이나 열을 반환하므로 경계 조건은 배열 접근보다 앞에 있어야 합니다.

발견과 방문의 시점을 정합니다

parent 사전에는 출발 셀을 키로, None을 값으로 넣습니다. 이 사전의 키는 이미 발견한 셀의 집합이고 값은 그 셀로 들어오기 직전의 부모입니다. 이웃을 큐에 넣기 전에 parent[next]=current를 기록합니다. 꺼낼 때에야 기록하면 같은 셀이 여러 부모에게 중복 등록되어 큐가 커지고 부모가 흔들릴 수 있습니다.

이미 발견한 셀은 다시 넣지 않습니다. 단위 비용 BFS에서는 첫 발견이 최단 이동 횟수로 도달한 결과입니다. 나중에 같은 셀로 오는 길이 더 짧아지지 않기 때문입니다. 비용이 서로 다른 지도에서는 이 규칙만으로 최소 비용을 찾을 수 없습니다. 그 경우의 비용 갱신과 우선순위 큐는 더 읽기의 그래프 탐색 장으로 이어갑니다.

부모를 거슬러 경로를 복원합니다

목표를 꺼냈으면 goal에서 parent를 따라 None까지 이동합니다. 얻은 목록은 목표부터 출발까지의 역순이므로 마지막에 뒤집습니다. 반환 경로에는 시작과 목표를 모두 포함합니다. 셀 목록 길이가 4이면 이동은 3번입니다. 부모를 None으로 계속 넣는 실수는 목표 한 셀만 반환하게 만들며 이동 횟수와 연결 검사가 이를 드러냅니다.

재구성 결과의 각 연속 두 셀은 맨해튼 거리 1이어야 합니다. 모든 셀은 빈 공간이어야 하고 첫 셀과 마지막 셀은 요청과 같아야 합니다. 이 검사들은 특정 경로를 하드코딩한 답을 걸러내는 데 도움이 됩니다. 하지만 최단성 자체는 이웃 후보와 FIFO 탐색 근거 또는 독립적인 거리 계산으로 확인해야 합니다. 연결만 되어 있다고 최단인 것은 아닙니다.

같은 시작과 목표를 별도로 다룹니다

빈 시작 셀과 목표 셀이 같으면 그 셀 하나를 반환합니다. 이동 횟수는 0이며 경로 없음과 구별됩니다. 시작 셀이 장애물이라면 같은 좌표여도 성공하지 않습니다. 먼저 범위와 빈 공간을 검사한 뒤 탐색을 시작하는 이유입니다. start나 goal이 범위 밖이면 빈 목록을 반환합니다. 지도 자체가 깨진 경우는 ERROR이고 유효 지도에서 이동할 수 없는 경우는 []입니다.

목표에 도달하지 못하면 큐가 결국 비며 빈 경로로 종료됩니다. 끝없이 while True로 돌거나 마지막 후보를 목표 대신 반환하지 않습니다. 계획 실패를 정상적인 반환값으로 정하면 후속 액션이 PATH_NOT_FOUND와 정지를 명확하게 처리할 수 있습니다. 빈 경로를 성공 경로로 간주하고 인덱스 0에 접근하면 IndexError가 생깁니다.

동률 경로의 재현성을 확보합니다

오른쪽·위·왼쪽·아래 순서를 리스트나 튜플로 유지합니다. 방향을 집합으로 만들거나 지도 입력을 임의로 뒤집으면 길이가 같은 다른 경로가 선택될 수 있습니다. 재현 가능한 경로는 실패 기록을 비교할 때 중요합니다. 같은 지도·출발·목표·이웃 순서에서 셀 목록이 같아야 제어기 변화만 비교할 수 있습니다.

예를 들어 장애물 없는 3×2 격자에서 (0,0)에서 (2,1)로 가는 길은 여러 개입니다. 이번 순서에서는 오른쪽을 먼저 확장해 (0,0),(1,0),(2,0),(2,1)을 돌려줍니다. 다른 최단 목록이 수학적으로 틀린 것은 아니지만 이번 실습의 고정 순서 계약에는 맞지 않습니다. 채점 불일치가 나오면 길이뿐 아니라 동률 처리 순서를 살펴봅니다.

탐색 비용과 제어 비용을 나눠 봅니다

가로 W·세로 H 지도에서 셀마다 한 번 발견하고 네 방향만 검사하므로 탐색 시간과 부모 저장량은 O(W×H) 범위입니다. 실제 Python 객체의 바이트 수는 이 식에서 계산하지 않습니다. 로봇 반경 팽창의 전처리 비용은 별개이며 이번 단순 구현은 후보 셀마다 장애물 목록을 살펴봅니다. BFS의 복잡도를 전체 파이프라인 비용이라고 말하면 전처리를 빠뜨립니다.

4방향 경로는 모서리에서 진행 방향이 갑자기 바뀝니다. 같은 이동 횟수라도 회전이 많으면 실행 시간이 늘 수 있습니다. 최소 시간이나 회전 패널티를 목표로 삼으려면 상태에 방향을 넣거나 비용 모델을 바꾸어야 합니다. 지금은 셀 연결 검증을 완성한 뒤 다음 레슨에서 좌표와 속도 상한으로 변환합니다.

실습에서 디버깅 근거를 만듭니다

parent의 TODO를 채우고 우회·직선·동일 셀·분리된 지도·막힌 목표·범위 밖을 검사합니다. 출력은 [열,행] 쌍들의 JSON 목록입니다. 디버깅용 큐 출력은 최종 답에 섞지 않습니다. 경로가 목표 하나만 나오면 부모 값, 경로가 반대로 나오면 뒤집기, 막힌 셀을 지나는 경우는 free 검사와 배열 접근 순서를 봅니다. TypeError는 입력 셀의 정수 형태를 먼저 확인합니다.

제출에는 찾은 경로의 셀 수와 이동 횟수, 고정 이웃 순서를 적습니다. []가 나온 사례는 시작/목표 차단인지 연결 단절인지 지도 위에 표시합니다. 빈 반환값만 보고 알고리즘 오류라고 단정하지 않습니다. 선택한 차단 지도에서 길이 없을 수 있으며 원본 공간이 넓더라도 팽창 정책이 보수적으로 닫았을 가능성도 함께 설명합니다.

따라하기

FIFO 순서를 관찰합니다

꺼내기와 새 이웃 넣기가 기존 후보 뒤로 이어지는지 봅니다.

from collections import deque
q=deque(['start','right'])
print(q.popleft())
q.append('up')
print(list(q))

실행 결과

start
['right', 'up']

동률 최단 경로를 얻습니다

셀 수와 이동 횟수를 구분해 읽습니다.

"""Static grid planning; cells are (column,row), row 0 is the bottom."""
import math
from collections import deque

DIRECTIONS = ((1,0),(0,1),(-1,0),(0,-1))
EPS=1e-10

def validate_grid(grid):
    if not isinstance(grid,list) or not grid or not isinstance(grid[0],str) or not grid[0]:
        raise ValueError("BAD_MAP")
    width=len(grid[0])
    if any(not isinstance(row,str) or len(row)!=width or set(row)-set(".#?") for row in grid):
        raise ValueError("BAD_MAP")
    return width,len(grid)

def bfs(grid,start,goal):
    width,height=validate_grid(grid)
    def free(cell):
        return (isinstance(cell,(tuple,list)) and len(cell)==2 and
                all(type(v) is int for v in cell) and
                0<=cell[0]<width and 0<=cell[1]<height and grid[cell[1]][cell[0]]=='.')
    if not free(start) or not free(goal): return []
    start,goal=tuple(start),tuple(goal)
    queue=deque([start]);parent={start:None}
    while queue:
        here=queue.popleft()
        if here==goal:
            path=[]
            while here is not None:
                path.append(here);here=parent[here]
            return path[::-1]
        for dc,dr in DIRECTIONS:
            nxt=(here[0]+dc,here[1]+dr)
            if free(nxt) and nxt not in parent:
                parent[nxt]=here
                queue.append(nxt)
    return []

p=bfs(['...','...'],(0,0),(2,1))
print(p)
print(f'cells={len(p)} moves={len(p)-1}')

실행 결과

[(0, 0), (1, 0), (2, 0), (2, 1)]
cells=4 moves=3

동일 셀과 연결 단절을 비교합니다

두 반환값을 후속 액션에서 서로 다르게 처리해야 합니다.

"""Static grid planning; cells are (column,row), row 0 is the bottom."""
import math
from collections import deque

DIRECTIONS = ((1,0),(0,1),(-1,0),(0,-1))
EPS=1e-10

def validate_grid(grid):
    if not isinstance(grid,list) or not grid or not isinstance(grid[0],str) or not grid[0]:
        raise ValueError("BAD_MAP")
    width=len(grid[0])
    if any(not isinstance(row,str) or len(row)!=width or set(row)-set(".#?") for row in grid):
        raise ValueError("BAD_MAP")
    return width,len(grid)

def bfs(grid,start,goal):
    width,height=validate_grid(grid)
    def free(cell):
        return (isinstance(cell,(tuple,list)) and len(cell)==2 and
                all(type(v) is int for v in cell) and
                0<=cell[0]<width and 0<=cell[1]<height and grid[cell[1]][cell[0]]=='.')
    if not free(start) or not free(goal): return []
    start,goal=tuple(start),tuple(goal)
    queue=deque([start]);parent={start:None}
    while queue:
        here=queue.popleft()
        if here==goal:
            path=[]
            while here is not None:
                path.append(here);here=parent[here]
            return path[::-1]
        for dc,dr in DIRECTIONS:
            nxt=(here[0]+dc,here[1]+dr)
            if free(nxt) and nxt not in parent:
                parent[nxt]=here
                queue.append(nxt)
    return []

print(bfs(['.'],(0,0),(0,0)))
print(bfs(['.#.'],(0,0),(2,0)))

실행 결과

[(0, 0)]
[]

확인 문제

실습

grid는 팽창이 끝난 차단 지도이며 .만 통과합니다. start·goal은 [열,행] 정수 쌍입니다. 4방향을 오른쪽·위·왼쪽·아래 순서로 탐색합니다. 최초 발견 때 부모를 기록하고 시작·목표를 포함한 JSON 경로를 반환합니다. 경로 없음·범위 밖·차단 끝점은 [], 빈 동일 셀은 셀 하나입니다. 지도 형식 오류는 ERROR입니다. parent 연결 TODO를 고치고 제공한 큐와 출력 코드는 유지합니다.

모범 답안
"""Static grid planning; cells are (column,row), row 0 is the bottom."""
import math
from collections import deque

DIRECTIONS = ((1,0),(0,1),(-1,0),(0,-1))
EPS=1e-10

def validate_grid(grid):
    if not isinstance(grid,list) or not grid or not isinstance(grid[0],str) or not grid[0]:
        raise ValueError("BAD_MAP")
    width=len(grid[0])
    if any(not isinstance(row,str) or len(row)!=width or set(row)-set(".#?") for row in grid):
        raise ValueError("BAD_MAP")
    return width,len(grid)

def bfs(grid,start,goal):
    width,height=validate_grid(grid)
    def free(cell):
        return (isinstance(cell,(tuple,list)) and len(cell)==2 and
                all(type(v) is int for v in cell) and
                0<=cell[0]<width and 0<=cell[1]<height and grid[cell[1]][cell[0]]=='.')
    if not free(start) or not free(goal): return []
    start,goal=tuple(start),tuple(goal)
    queue=deque([start]);parent={start:None}
    while queue:
        here=queue.popleft()
        if here==goal:
            path=[]
            while here is not None:
                path.append(here);here=parent[here]
            return path[::-1]
        for dc,dr in DIRECTIONS:
            nxt=(here[0]+dc,here[1]+dr)
            if free(nxt) and nxt not in parent:
                parent[nxt]=here
                queue.append(nxt)
    return []

import json,sys
try:
    obj=json.load(sys.stdin)
    print(json.dumps(bfs(obj['grid'],obj['start'],obj['goal'])))
except (ValueError,TypeError,KeyError):
    print('ERROR')

더 읽기

면접 질문

  • 실험 기록을 재생할 때 함께 남겨야 할 정보를 설명해 주시면 됩니다.