본문 바로가기

알고리즘과 자료구조/Graph Traversal

BFS 1: BFS 동시 탐색 유형 (백준 2146번 파이썬)

백준 2146번: 다리 만들기

일반적인 BFS를 생각해보자. 한 출발점을 기준으로 상하좌우 최단경로를 탐색하면 된다.

한 출발점을 기준으로 BFS를 수행했을 때 이동 횟수(step = 1,2,3,4)에 따른 칸

 

기본만 짚고 넘어가자면, BFS는 그래프의 모든 간선의 가중치가 동일할 때에만 최단 경로를 구할 수 있다.

2차원 배열이 나오면 BFS를 주로 쓰는 이유도 그 때문이다.

2차원 배열은 각 칸 (i, j)이 정점이고 간선의 가중치가 1로 동일한 그래프로 생각할 수 있다.

 

 

 

BFS 1: 한 출발점과 도착점이 정해져 있는 경우

따라서 2차원 배열에 어떤 한 출발점과 어떤 한 도착점이 정해져 있다면 BFS를 써서 아주 답을 쉽게 구할 수 있다.

2206번(벽 부수고 이동하기)이 그 예다. 물론 이건 다른 조건이 붙어서 어려워지지만...

 

2146번도 저 아이디어를 활용하면 풀 수 있다. 

우선 모든 섬에 대해 각 섬을 구분할 수 있도록 2차원 배열에 번호(idx_island)를 매긴 후,

각 섬에 속하는 칸에 대해서 BFS를 수행한다.

만약 중간에 현재 섬이 아닌 다른 섬(idx_island 변수로 구분)을 만난다면 탐색을 중단하고 현재까지 구한 거리를 기록한다.

이 과정을 모든 섬에 대해 반복해서 최단 거리로 업데이트한다.

 

저 아이디어를 구현한 코드는 다음과 같다.

 

첫번째: 단순하지만 무식한 풀이

import sys
from collections import deque
input = sys.stdin.readline

N = int(input())
board = [list(map(int, input().split())) for _ in range(N)]
visited = [[False]*N for _ in range(N)]

dx = [-1, 1, 0, 0]
dy = [0, 0, -1, 1]

def bfs_for_land(start_x, start_y, idx_island):
    q = deque([(start_x, start_y)])
    visited[start_x][start_y] = True
    board[start_x][start_y] = idx_island
    
    while q:
        x, y = q.popleft()
        
        for i in range(4):
            mx, my = x+dx[i], y+dy[i]
            
            if 0 <= mx < N and 0 <= my < N and board[mx][my] == 1 and not visited[mx][my]:
                visited[mx][my] = True
                board[mx][my] = idx_island
                q.append((mx, my))


def bfs_for_path(cur_island):
    dist = [[-1]*N for _ in range(N)]
    q = deque()
    
    for r in range(N):
        for c in range(N):
            if board[r][c] == cur_island:
                q.append((r, c))
                dist[r][c] = 0
    
    while q:
        x, y = q.popleft()
        
        for i in range(4):
            mx, my = x+dx[i], y+dy[i]

            if not (0 <= mx < N and 0 <= my < N):
                continue
            
            if board[mx][my] > 0 and board[mx][my] != cur_island:
                return dist[x][y]
            
            if board[mx][my] == 0 and dist[mx][my] == -1:
                dist[mx][my] = dist[x][y] + 1
                q.append((mx, my))



# 1. 각 섬을 구분한다.
idx_island = 1
for r in range(N):
    for c in range(N):
        if not visited[r][c] and board[r][c] == 1:
            bfs_for_land(r, c, idx_island)
            idx_island += 1
            
# 각 섬 사이 최단 경로를 구한다.
answer = float('inf')
for cur_island in range(1, idx_island):
    cur_min = bfs_for_path(cur_island)
    answer = min(answer, cur_min)

print(answer)

 

그런데 사실 각 섬에 대해 거리를 탐색하고 업데이트하는 과정은 비효율적이다.

내가 1번째 섬에 대해 BFS를 수행해서 2번째 섬에 가장 먼저 닿았다면,

나중에 2번째 섬에 대해 탐색할 때 1번째 섬은 탐색에서 제외해도 되지만 저 코드로는 불가능하다.

 

게다가 문제에서 요구하는 건 모든 섬 사이 최단 거리다.

최단 거리를 구하기까지 각 두 섬 사이 거리를 일일이 모두 구해야 하기 때문에 이 또한 비효율적이다.

 

지금은 문제의 입력 N의 크기가 그리 크지 않기 때문에(100 이하) 시간 초과가 나지 않고 통과하지만

만약 섬의 개수가 늘어나고 N의 크기도 커진다면 불필요한 연산 때문에 시간 초과가 날 우려가 있다.

 

 

 

BFS 2: 출발점이 여러 곳 있는 경우(동시 탐색)

만약 각 섬의 한칸 한칸에 대해 BFS를 수행하는 게 아니라

모든 섬에 대해 동시에 BFS를 수행한다면, 최단 거리를 같은 타이밍에 구할 수 있지 않을까?

 

BFS를 모든 섬에 대해 동시에 수행할 경우, 최단거리를 매번 업데이트하지 않고 찾을 수 있다.

각 섬에 대해 한 칸씩 확장해간다고 생각하자.

그럼 한 걸음씩 확장하면서 결국 확장된 영역이 겹치는 그 순간이 최단 거리가 된다.

 

물론 한 iteration에서 모든 섬에 대해 BFS를 수행해야 하기 때문에 위 코드보다 한 iteration 수행 시간이 길어지겠지만

최단 거리를 구하기까지 과정이 더 단축된다.

이 BFS 방법을 '동시 탐색'이라고 하겠다.

 

그럼 말 그대로 '각 섬에 대해 한 걸음씩 BFS를 수행'하는 코드를 살펴보자.

참고로 이 코드는 썩 좋지 않은 코드니까 이해하려 하지 말고 대충 보고 넘어가면 된다.

 

BFS 동시 탐색 풀이(추천 X)

import sys
from collections import deque
input = sys.stdin.readline

N = int(input())
board = [list(map(int, input().split())) for _ in range(N)]
visited = [[False]*N for _ in range(N)]

dx = [-1, 1, 0, 0]
dy = [0, 0, -1, 1]

def bfs_for_land(start_x, start_y, idx_island):
    q = deque([(start_x, start_y)])
    visited[start_x][start_y] = True
    board[start_x][start_y] = idx_island
    
    while q:
        x, y = q.popleft()
        
        for i in range(4):
            mx, my = x+dx[i], y+dy[i]
            
            if 0 <= mx < N and 0 <= my < N and board[mx][my] == 1 and not visited[mx][my]:
                visited[mx][my] = True
                board[mx][my] = idx_island
                q.append((mx, my))


def bfs_all_island(num_island):
    distances = [[-1]*N for _ in range(N)]
    queues = [deque() for _ in range(num_island)]
    visited = [[-1]*N for _ in range(N)]
    stop_search = False
    answer = -1
    
    for r in range(N):
        for c in range(N):
            for cur_island in range(1, num_island+1):
                if board[r][c] == cur_island:
                    queues[cur_island-1].append((0, r, c))
                    distances[r][c] = 0
                    visited[r][c] = cur_island
    
    # N*N 보드에서 섬 간 가능한 최대 거리는 2N-2, 근데 그냥 편의상 2*N이라 하자.
    for step in range(2*N):
        if stop_search:
            break
        
        for cur_island in range(1, num_island+1):
            while queues[cur_island-1]:
                cur_d, x, y = queues[cur_island-1].popleft()
                
                if cur_d > step:
                    # FIXME - 이걸 추가 안했어서 틀렸다.
                    queues[cur_island-1].appendleft((cur_d, x, y))
                    break
                
                for i in range(4):
                    mx, my = x+dx[i], y+dy[i]

                    if not (0 <= mx < N and 0 <= my < N):
                        continue
                    
                    if visited[mx][my] != -1 and visited[mx][my] != cur_island:
                        # FIXME - 바로 리턴하면 안되고, 여러 값이 나올 수 있고 최소값이 나중에 나올 수도 있기 때문에
                        # 업데이트를 해야 한다.
                        # return cur_d + distances[mx][my]
                        if answer == -1 or answer > cur_d + distances[mx][my]:
                            answer = cur_d + distances[mx][my]
                            stop_search = True
                            break
                        
                    
                    if board[mx][my] == 0 and distances[mx][my] == -1:
                        distances[mx][my] = cur_d + 1
                        queues[cur_island-1].append((cur_d+1, mx, my))
                        visited[mx][my] = cur_island
    
    return answer
        

idx_island = 1
for r in range(N):
    for c in range(N):
        if not visited[r][c] and board[r][c] == 1:
            bfs_for_land(r, c, idx_island)
            idx_island += 1

num_island = idx_island - 1
print(bfs_all_island(num_island))

 

정 말 그대로 각 섬에 대해서 한 스텝씩 BFS를 수행한 코드다. 코드를 요약하자면

 

1) 각 섬에 대해 큐를 만들고 (큐의 리스트)

2) 각 섬에 해당하는 큐마다 섬의 칸을 대입해서 수행

3) 현재 이동한 횟수를 1,2,3... 씩 증가시키면서 2)를 수행

 

돌악가긴 하지만 결과적으로 코드가 많이 복잡해진다.

 

하지만 결국 모든 섬은 2차원 배열 안에 포함되어 있으므로 이렇게 바꿀 수 있다.

 

1) 2차원 배열에 각 섬을 인덱스(idx_island)로 표시 -> island 라는 배열을 별도 생성

2) 2차원 배열의 각 칸에 대해 반복문을 돌면서 만약 이동한 칸이 섬(아무 섬이든 상관없다)이라면 BFS 수행

3) island라는 배열에 어떤 섬에서 BFS를 시작했는지 기록 및 저장

4) 만약 이동한 칸의 island 배열 값이 이미 존재하고, 현재 섬과 다른 섬이라면 최단거리를 업데이트

 

아래 코드에서 핵심은 3번이다.

즉 Dynamic Programming(동적계획법)처럼 어느 섬(출발점)에서 BFS 탐색을 출발했는지 계속 기록해나가는 것이다.

2번 아이디어는 굳이 말하자면 완전탐색의 백트래킹이라고 할 수 있겠지만 워낙 보편적인 접근방식이기도 하다.

 

말로 설명하면 역시 이해가 안되니까 코드를 읽어보자.

 

 

BFS 동시 탐색 풀이 (추천 O)

from collections import deque

dx = [1, 0, -1, 0]
dy = [0, 1, 0, -1]

N = int(input())
board = [list(map(int, input().split())) for _ in range(N)]
way = [[0]*N for _ in range(N)]
island = [[0]*N for _ in range(N)]

Q = deque()

for i in range(N):
    # row = list(map(int, input().split()))
    for j in range(N):
        # board[i][j] = row[j]
        if board[i][j] == 1:
            Q.append((i, j))
            way[i][j] = 0
        elif board[i][j] == 0:
            way[i][j] = -1

num_island = 0

for i in range(N):
    for j in range(N):
        if not board[i][j] or island[i][j] > 0:
            continue

        num_island += 1
        QI = deque()
        island[i][j] = num_island
        QI.append((i, j))

        while QI:
            x, y = QI.popleft()

            for d in range(4):
                mx, my = (x + dx[d], y + dy[d])

                if mx < 0 or my < 0 or mx >= N or my >= N:
                    continue

                if not board[mx][my] or island[mx][my] > 0:
                    continue

                island[mx][my] = num_island
                QI.append((mx, my))

bridge = N + N

while Q:
    x, y = Q.popleft()

    for d in range(4):
        mx, my = (x + dx[d], y + dy[d])

        if mx < 0 or my < 0 or mx >= N or my >= N:
            continue

        if island[mx][my] == island[x][y]:
            continue

        if island[mx][my] != 0:
            bridge = min(bridge, way[x][y] + way[mx][my])
            continue

        island[mx][my] = island[x][y]
        way[mx][my] = way[x][y] + 1
        Q.append((mx, my))

print(bridge)

 

중간에 디버깅하느라 여러 번 틀렸지만 시간 복잡도만 보면 꽤 줄어든 걸 확인할 수 있다.

첫번째 풀이가 348ms, 두번째 풀이가 284ms, 세번째 풀이가 192ms이다.

 

세번째 풀이에서도 island 배열을 board 배열과 합치거나 하는 방식으로 좀 더 시간을 줄일 수 있겠지만

그렇게 되면 바다(0으로 표시)와 섬의 인덱스(0부터 시작)를 별도로 구분해주어야 하기 때문에 헷갈릴 수도 있어

내버려두었다.

 

 

BFS: 동시 탐색 유형 정리

  1. 출발점이 여러 개 있고 각 출발점 사이 최단거리를 구하고자 할 때 쓰인다.
  2. 다이나믹 프로그래밍처럼 어느 출발점에서 BFS 탐색을 시작했는지 별도의 배열에 기록해나가면서 탐색한다.
  3. 각 출발점에 대해 큐를 별도로 생성 후 BFS 탐색하는 게 아니라, 모든 지점에 대해 전체적으로 BFS를 수행한다.