한입 알고리즘

큐와 BFS: 가까운 곳부터

먼저 넣은 것을 먼저 꺼내면 최단 거리(shortest path)가 나온다

4장 · 넓게 퍼지기 · 읽는 시간 약 18분

목차 · 진행 0%

    매장 안내 화면에 지도가 있다. 칸으로 나눈 바닥 지도에서 입구부터 원하는 매대까지 몇 칸을 걸어야 하는지 보여 주고 싶다. 벽과 진열대는 지나갈 수 없다. 비슷한 문제가 여러 곳에 있다. 조직도나 카테고리 를 깊이별로 한 줄씩 그리는 일, 친구 관계에서 "2촌까지만" 추천하는 일도 그렇다. 셋 다 출발점에서 가까운 것부터 차례로 보는 문제다.

    가까운 것부터 보려면 "다음에 볼 것"을 줄 세워 두는 자료 구조가 필요하다. 이 장에서는 그 줄인 큐(queue)와, 큐로 (graph)를 한 겹씩 넓혀 가는 너비 우선 탐색(breadth-first search, BFS)을 본다. 개념 정리는 큐, BFS, 그래프 노트에 있다.

    큐: 먼저 넣은 것을 먼저

    큐는 뒤에 넣고 앞에서 꺼내는 줄이다. 먼저 넣은 것이 먼저 나오는 이 규칙을 FIFO(first-in, first-out)라 한다. 은행 번호표와 같다.

    JavaScript 배열로 큐를 만들면 push로 넣고 shift로 꺼내고 싶어진다. 그런데 shift는 맨 앞 원소를 빼면서 남은 원소를 모두 한 칸씩 왼쪽으로 옮긴다(MDN: Array.prototype.shift). 원소가 n개면 꺼낼 때마다 n번 가까이 옮기니, n개를 모두 꺼내면 옮기는 횟수가 n²에 비례할 수 있다. Python 리스트의 pop(0)도 같은 이유로 O(n)이다. Python 문서는 리스트의 pop(0)과 insert(0, v)에 O(n) 메모리 이동 비용이 든다고 적고, 양 끝에서 넣고 빼는 일은 collections.deque가 양쪽 모두 거의 O(1)로 해 준다고 안내한다(Python 문서: deque).

    const queue = ['a', 'b', 'c']let head = 0const first = queue[head] // 'a'head++ // 배열은 그대로 두고 다음에 꺼낼 위치만 옮긴다queue.push('d') // 뒤에 넣기

    JavaScript 배열 메서드만으로는 앞에서 O(1)로 꺼낼 수 없어서, 이 책은 배열을 지우지 않고 다음에 꺼낼 위치 head만 하나씩 옮기는 방법을 쓴다. 꺼낸 칸이 메모리에 남지만 꺼내기는 O(1)이다.

    격자 BFS: 거리를 한 겹씩

    격자(grid)의 칸 하나를 노드(node), 상하좌우로 붙은 두 칸 사이를 간선(edge)으로 보면 격자는 그래프다(그래프 용어는 『개발 수학』 Unit 10). BFS는 이렇게 돈다.

    1. 출발 칸의 거리를 0으로 적고 큐에 넣는다.
    2. 큐 앞에서 칸 하나를 꺼낸다.
    3. 그 칸의 이웃 중 지도 안이고, 벽이 아니고, 아직 거리가 없는 칸에 (꺼낸 칸의 거리 + 1)을 적고 큐 뒤에 넣는다.
    4. 큐가 빌 때까지(또는 도착 칸을 꺼낼 때까지) 2~3을 되풀이한다.
    예측먼저 답해 보세요

    지도가 S.# / ..E 두 줄이다. 가운데 아래 칸 (1, 1)(행, 열, 0부터 셈)은 위 칸 (0, 1)과도, 왼쪽 칸 (1, 0)과도 붙어 있다. BFS가 끝날 때까지 (1, 1)의 거리는 몇 번 적힐까?

    선택지와 해설 보기
    실험 격자에서 가까운 칸부터 거리 매기기

    아래 코드 추적에서는 두 언어의 코드를 줄마다 맞추려고 Python도 1절의 JavaScript처럼 리스트 + head 방식으로 적었다. 실제 Python 코드라면 deque를 쓰면 된다.

    실험격자에서 가까운 칸부터 거리 매기기

    왼쪽 위 S에서 출발해 오른쪽 아래 E까지 몇 칸인지 구합니다. 아래 지도에는 이미 매긴 거리가 숫자로 찍히고, 맨 아래 줄은 큐에 남은 칸(행, 열)입니다.

    언어
    입력
    ▶ 다음에 실행할 줄(디버거가 멈춘 줄)
    방금 실행한 줄
    변수·출력·아래 설명은 방금 실행한 줄까지의 상태입니다.
    카드 안에서 ← → 키로도 넘길 수 있습니다.
    const grid = ['S.#', '..E']const rows = grid.length, cols = grid[0].lengthconst dist = Array.from({ length: rows }, () => Array(cols).fill(-1))dist[0][0] = 0const queue = [[0, 0]]let head = 0while (head < queue.length) {  const [r, c] = queue[head]  head++  if (grid[r][c] === 'E') break  for (const [dr, dc] of [[0, 1], [1, 0], [0, -1], [-1, 0]]) {    const nr = r + dr, nc = c + dc    if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue    if (grid[nr][nc] === '#' || dist[nr][nc] !== -1) continue    dist[nr][nc] = dist[r][c] + 1    queue.push([nr, nc])  }}console.log(`거리 ${dist[rows - 1][cols - 1]}`)
    변수
    grid
    ['S.#', '..E']
    출력
     
    1/63단계.
    • 해볼 것
    • 길이 있는 지도를 끝까지 실행해 거리가 매겨지는 순서를 본다
    • 막힌 지도를 끝까지 실행해 큐가 비는 순간을 본다

    길이 있는 지도를 끝까지 돌리면 거리는 (0, 1)과 (1, 0)이 1, (1, 1)이 2, E가 3으로 적히고 거리 3이 찍힌다. 막힌 지도에서는 출발 칸의 두 이웃이 모두 벽이라 큐에 아무것도 들어가지 않아 바로 빈다.

    (1, 1)의 거리는 (0, 1)을 꺼낼 때 2로 한 번만 적힌다. 뒤이어 (1, 0)을 꺼낼 때는 (1, 1)에 이미 거리가 있어 건너뛴다. 도착 칸에 닿지 못하면 그 칸의 거리는 처음 값 -1 그대로다. "갈 수 없다"를 따로 표시하지 않아도 -1이 그 뜻이 된다.

    dist는 거리 기록과 방문 표시를 함께 한다. 거리를 적는 순간이 큐에 넣는 순간이라서, 같은 칸이 큐에 두 번 들어가지 않는다. 칸이 V개, 간선이 E개면(V는 vertex, E는 edge의 머리글자) 칸마다 한 번 꺼내고 간선마다 한 번 살펴보니 시간은 O(V + E)다. 격자에서는 칸마다 이웃이 넷이라 칸 수에 비례한다.

    왜 처음 적은 거리가 가장 짧을까

    큐 안을 들여다보면 거리 d인 칸들이 앞에, d + 1인 칸들이 뒤에 있다. 거리 d인 칸을 꺼내 이웃에 d + 1을 적어 뒤에 붙이니, 큐에는 언제나 많아야 두 종류의 거리만 있고 작은 쪽이 앞이다. 그래서 칸은 거리 0인 것 전부, 1인 것 전부, 2인 것 전부… 순서로 꺼내진다. 2절의 S.# / ..E 지도에서 큐에 들어간 칸의 거리는 차례로 0, 1, 1, 2, 3이다. 큐 안의 거리는 절대 줄지 않는다. 어떤 칸에 거리를 적는 순간은 큐에서 꺼낸 칸의 거리 + 1을 적는 순간이고, 그 뒤에 꺼내는 칸은 모두 거리가 같거나 더 크다. 그러니 어떤 칸을 처음 적을 때의 거리보다 작은 거리를 나중에 줄 방법이 없다. 처음 적은 값이 최단 거리다.

    이 논리는 모든 간선의 비용이 같을 때만 성립한다. 칸마다 지나는 시간이 다르다면(계단은 3초, 복도는 1초) "먼저 꺼낸 칸 = 가까운 칸"이 깨진다. 그때는 다익스트라(Dijkstra) 알고리즘처럼 비용이 가장 작은 것을 먼저 꺼내는 우선순위 큐(priority queue)를 쓴다. 우선순위 큐는 8장(힙)에서 다룬다.

    함정 찾기 AI가 짠 최단 거리 함수, 어디가 문제일까?
    함정 찾기AI가 짠 최단 거리 함수, 어디가 문제일까?
    내가 AI에게 한 요청
    “격자 지도에서 출발 칸부터 모든 칸까지의 최단 거리를 BFS로 구하는 함수를 짜 줘. #은 벽이야.”

    의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 3개입니다.

    정리하면 틀린 줄은 셋이다. fill로 모든 행이 한 배열을 가리키고(행마다 새 배열을 만든다), pop()으로 뒤에서 꺼내 스택이 되고(앞에서 꺼낸다), 이미 거리가 적힌 칸을 거르지 않아 끝나지 않는다(dist가 -1인 칸만 넣는다).

    단계별로 묶기: 트리 레벨 순회

    카테고리 를 깊이별로 한 줄씩 그리려면 "이번 깊이의 노드가 어디까지인가"를 알아야 한다. 큐에서 꺼내는 순서는 이미 깊이 순서다. 남은 것은 경계다. 한 단계를 시작할 때 큐에 남은 개수를 세어 두고 정확히 그만큼만 꺼내면, 그동안 뒤에 붙인 자식들은 다음 단계 몫으로 남는다.

    예측먼저 답해 보세요

    레벨 순회(level-order traversal) 코드가 단계를 시작할 때 count = queue.length - head로 개수를 먼저 구해 두고 count번만 꺼낸다. 이렇게 미리 세어 두는 이유는?

    선택지와 해설 보기
    실험 트리를 깊이별로 한 줄씩
    실험트리를 깊이별로 한 줄씩

    자식 목록으로 적은 트리를 깊이(단계)별로 묶어 출력합니다. 아래 칸은 큐 배열 전체이고, 흐린 칸은 이미 꺼낸 노드입니다.

    언어
    입력
    ▶ 다음에 실행할 줄(디버거가 멈춘 줄)
    방금 실행한 줄
    변수·출력·아래 설명은 방금 실행한 줄까지의 상태입니다.
    카드 안에서 ← → 키로도 넘길 수 있습니다.
    const children: Record<string, string[]> = { '전체': ['의류', '가전'], '의류': ['상의', '하의'], '가전': ['TV'], '상의': ['셔츠'] }const queue = ['전체']let head = 0let depth = 0while (head < queue.length) {  const count = queue.length - head  const level: string[] = []  for (let i = 0; i < count; i++) {    const node = queue[head]    head++    level.push(node)    queue.push(...(children[node] ?? []))  }  console.log(`${depth}단계: ${level.join(', ')}`)  depth++}
    변수

    아직 변수가 없습니다.

    출력
     
    1/53단계.
    • 해볼 것
    • 카테고리 트리를 끝까지 실행해 본다
    • 메뉴 트리로 바꿔 count가 단계마다 어떻게 바뀌는지 본다

    카테고리 트리는 0단계: 전체, 1단계: 의류, 가전, 2단계: 상의, 하의, TV, 3단계: 셔츠 네 줄을 찍는다. count가 1, 2, 3, 1로 바뀌는 동안 큐 배열은 계속 길어지고 head만 앞으로 간다.

    트리는 한 노드에 이르는 길이 하나뿐이라 방문 표시가 없어도 된다. 친구 관계처럼 (사이클, cycle)이 있는 그래프라면 격자의 dist처럼 이미 본 노드를 기록하는 Set이 필요하다. "2촌까지 추천"은 이 레벨 순회를 2단계에서 멈추는 것이다.

    확인 문제 장을 덮기 전에 떠올려 보기
    확인 문제장을 덮기 전에 떠올려 보기

    1. BFS가 처음 적은 거리가 최단 거리라고 말할 수 있는 조건은?

    2. BFS에서 같은 칸이 큐에 두 번 들어가지 않게 하려면 방문 표시를 언제 할까?

    3. Python에서 원소가 많은 큐의 앞에서 하나씩 꺼낼 때 알맞은 것은?

    더 풀어 보기(선택) · 불이 모두 번지는 데 몇 분 걸릴까
    보너스 문제불이 모두 번지는 데 몇 분 걸릴까

    격자에 F(처음부터 타는 칸), .(빈 칸), #(벽)이 있다. 불은 1분마다 상하좌우의 빈 칸으로 번진다. 불씨는 하나 이상이고 여러 개일 수 있다. 모든 빈 칸이 타는 데 걸리는 시간(분)을 구하고, 불이 끝내 닿지 못하는 빈 칸이 있으면 -1을 내자. 칸 수를 n이라 할 때 O(n)으로 풀어 보자.

    F...
    #..F  → 2
    
    F.#
    ##.
    #..  → -1

    이 장을 언급한 노트

    다음5장. DFS와 백트래킹: 끝까지 가 보고 돌아오기가까운 곳부터 넓히는 BFS와 반대로, 한 갈래를 끝까지 파고들었다가 돌아오는 DFS와 백트래킹을 봅니다.

    『한입 알고리즘』 4장. 큐와 BFS: 가까운 곳부터 · younggeun0. Claude와 함께 썼고, 저자가 아직 전체 내용을 검수하지 않은 초안입니다. 사실 주장은 공식 문서로, 예제 코드는 실제로 실행해 확인했습니다.

    보기 옵션