DFS/BFS 완전정복을 위한 DFS/BFS 기본 이론

1. 그래프의 기본 구조

 

그래프는 노드(node)와 간선(edge)으로 표현되고 이때 노드를 정점(vertex)이라고도 말한다

 

그래프 탐색이란 하나의 노드를 시작으로 다수의 노드를 방문하는 것이다

 

두 노드가 간선으로 연결되어 있다면 '두 노드는 인접하다'(adjacent)라고 말한다

 

 

 

노드를 도시, 간선을 도로라고 생각해보자. A라는 도시(노드)에서 B라는 도시(노드)로 이동하기 위해서 A와 B를 연결하는 도로(간선)를 거친다고 이해하면 쉬울 것이다

 

 

2. 프로그래밍에서 그래프를 표현하는 방식

 

인접행렬(adjacency matrix) : 2차원 배열로 그래프의 연결 관계를 표현하는 방식

 

인접리스트(adjacency list) : 리스트로 그래프의 연결 관계를 표현하는 방식

 

 

인접행렬 방식은 2차원 배열에 각 노드가 연결된 형태를 기록하는 방식

 

위와 같이 연결된 그래프를 인접 행렬로 표현하면 오른쪽과 같고 2차원 리스트로 구현할 수 있다

 

자기 자신끼리는 비용이 0이라고 정하고 연결이 되지 않은 노드끼리는 무한의 비용이라고 정한다

 

실제 코드에서는 정답이 될 수 없는 아주 큰 값 중에서 999999999, 987654321 등의 값으로 초기화한다

 

INF = 999999999 #무한 비용 선언

#2차원 리스트를 이용해 인접 행렬을 표현함

graph = [
  [0,7,5],
  [7,0,INF],
  [5,INF,0]
]

print(graph)
[[0, 7, 5], [7, 0, 999999999], [5, 999999999, 0]]

 

 

인접 리스트 방식에서는 데이터를 어떤 방식으로 저장할까?

 

다음 그림처럼 모든 노드에 대하여 각각 연결된 노드에 대한 정보를 차례대로 연결하여 저장한다

 

 

 

인접 리스트는 '연결 리스트'라는 자료구조를 이용해 구현

 

파이썬에서는 리스트 자료형이 append()를 제공하여 전통적인 프로그래밍에서의 배열과 연결 리스트 기능을 모두 기본으로 제공한다

 

그래서 단순히 2차원 리스트만 이용하면 인접 리스트를 이용한 그래프 표현을 구현할 수 있다

 

#행이 3개인 2차원 리스트로 인접 리스트 표현

graph = [[] for _ in range(3)]

print(graph)

#노드 0에 연결된 노드 정보 저장(노드, 거리)

graph[0].append((1,7))
graph[0].append((2,5))

#노드 1에 연결된 노드 정보 저장(노드,거리)

graph[1].append((0,7))

#노드 2에 연결된 노드 정보 저장(노드,거리)

graph[2].append((0,5))

print(graph)

[[], [], []]
[[(1, 7), (2, 5)], [(0, 7)], [(0, 5)]]

 

인접리스트 방식과 인접행렬 방식은 무슨 차이가 있을까?

 

메모리 측면에서 인접행렬 방식은 모든 관계를 저장해야하므로 노드 개수가 많을수록 메모리가 불필요하게 낭비된다

 

반면 인접리스트 방식은 연결된 정보만을 저장하기 때문에 메모리를 효율적으로 사용한다

 

하지만 이와 같은 속성 때문에 인접 리스트 방식은 인접행렬 방식에 비해 특정한 두 노드가 연결되어 있는지에 대한 정보를 얻는 속도가 느리다

 

인접 리스트 방식에서는 연결된 데이터를 하나씩 확인해야하기 때문이다

 

예를 들어 0번과 1번이 연결되어 있는지 알기 위해서는 인접행렬 방식에서는 graph[0][1]이나 graph[1][0]의 값을 확인하면 된다

 

반면 인접리스트방식에서는 노드 0이나 노드 1에 대한 인접리스트를 가지고 와서 하나씩 순회하여 확인해봐야한다

 

그러므로 만약 특정한 노드와 연결된 모든 인접한 노드를 순회해야하는 경우는 인접리스트 방식이 메모리 공간의 낭비가 적다.

 

특정한 인접 노드를 찾기 위해서 어차피 모두 순회해야하니까 전부 순회해야한다면 당연히 전부 순회하는 것이 효율적

 

 

3. DFS

 

깊이 우선 탐색으로 그래프에서 깊은 부분을 우선적으로 탐색하는 알고리즘

 

특정한 경로로 탐색하다가 특정한 상황에서 최대한 깊숙이 들어가서 노드를 방문한 후, 다시 돌아가 다른 경로로 탐색하는 알고리즘

 

DFS는 스택 자료구조를 이용하고 구체적인 동작은 다음과 같다

 

1) 탐색 시작 노드를 스택에 삽입하고 방문 처리를 한다

 

2) 스택의 최상단 노드에 방문하지 않은 인접 노드가 있으면 그 인접노드를 스택에 넣고 방문 처리를 한다. 

 

방문하지 않은 인접 노드가 없으면 스택에서 최상단 노드를 꺼낸다.

 

3) 2)번의 과정을 더 이상 수행할 수 없을 때까지 반복한다

 

※) '방문 처리'는 스택에 한번 삽입되어 처리된 노드가 다시 삽입되지 않게 체크하는 것이다. 방문 처리를 함으로써 각 노드를 한 번씩만 처리할 수 있다

 

예를 들어 다음과 같은 그래프를 생각해보자

 

 

노드 1을 시작으로 DFS를 이용해 탐색을 진행하면 어떻게 될까?

 

직관적으로 깊이 우선 탐색이라는 이름에서 알 수 있듯이 단순하게 가장 깊숙이 위치하는 노드에 닿을 때까지 탐색하면 된다

 

DFS를 이용해 탐색하면 다음과 같다

 

일반적으로 인접한 노드 중에서 방문하지 않은 노드가 여러개 있으면 번호가 낮은 순서부터 처리한다. 

 

코딩테스트에서 보통 번호가 낮은 순서부터 처리하도록 명시하는 경우가 종종 있어서 그렇다

 

STEP1) 시작 노드인 '1'을 스택에 삽입하고 방문 처리를 함

 

 

STEP2) 스택의 최상단 노드인 '1'에 방문하지 않은 인접 노드가 2,3,8이 있는데 번호가 가장 작은 노드인 2를 스택에 넣고 방문 처리를 함

 

 

STEP3) 스택의 최상단 노드인 '2'에 방문하지 않은 인접 노드인 '7'이 있다. '7'번 노드를 스택에 넣고 방문 처리를 함

 

 

STEP4) 스택의 최상단 노드인 '7'에 방문하지 않은 인접 노드 '6', '8'이 있다. 이 중에서 번호가 가장 작은 노드인 '6'을 스택에 넣고 방문 처리를 함

 

 

STEP5) 스택의 최상단 노드인 '6'에 방문하지 않은 인접 노드가 없다. 따라서 스택에서 '6'번 노드를 꺼낸다

 

 

STEP6) 스택의 최상단 노드인 '7'에 방문하지 않은 인접노드 '8'이 있다. 따라서 '8'번 노드를 스택에 넣고 방문 처리를 함

 

 

STEP7) 스택의 최상단 노드인 '8'에 방문하지 않은 인접 노드가 없다. 따라서 스택에서 '8'번 노드를 꺼낸다

 

 

STEP8) 스택의 최상단 노드인 7에 방문하지 않은 인접 노드가 없으므로 스택에서 7번을 꺼낸다

 

 

STEP9) 스택의 최상단 노드인 '2'에 방문하지 않은 인접 노드가 없으므로 스택에서 2번을 꺼낸다

 

 

STEP10) 스택의 최상단 노드인 '1'에 방문하지 않은 인접 노드가 3으로 있어서 3을 스택에 넣고 방문 처리를 함

 

 

STEP11) 스택의 최상단 노드인 3에 방문하지 않은 인접 노드인 4와 5가 있는데 번호가 가장 작은 노드인 4를 스택에 넣고 방문 처리를 함

 

 

STEP12) 스택의 최상단 노드인 4에 방문하지 않은 인접 노드인 5가 있다. 5번 노드를 스택에 넣고 방문 처리를 한다

 

 

STEP13) 남아 있는 노드에 방문하지 않은 인접 노드가 없으므로 모든 노드를 차례대로 꺼내면 다음과 같다

 

 

결과적으로 노드의 탐색 순서(스택에 들어간 순서)는 다음과 같다

 

1 > 2 > 7 > 6 > 8 > 3 > 4 > 5

 

깊이 우선 탐색 알고리즘인 DFS는 스택 자료구조에 기초한다는 점에서 간단히 구현이 가능하다

 

실제로는 스택을 쓰지 않아도 되며 탐색을 수행함에 있어서 데이터의 개수가 N개인 경우 O(N)의 시간이 소요된다

 

또한 DFS는 스택을 이용하는 알고리즘이므로 실제 구현은 재귀 함수를 이용할 때 매우 간결하게 구현할 수 있다

 

#각 노드가 연결된 정보를 인접 리스트 방식으로 표현
#노드는 1번부터 8번까지 있지만 파이썬은 zero index니까 0은 아무것도 연결 안되었다는 표시로 []

graph = [
  
  [],
  [2,3,8],
  [1,7],
  [1,4,5],
  [3,5],
  [3,4],
  [7],
  [2,6,8],
  [1,7]
]

#각 노드가 방문된 정보를 리스트로 표현함
#0번부터 8번까지 총 9개

visited = [False] * 9

#dfs 함수 정의

def dfs(graph,v,visited):
    
    #현재 노드를 방문 처리함
    visited[v] = True

    print(v, end = ' ')

    #현재 노드와 연결된 다른 노드를 재귀적으로 방문함

    for i in graph[v]: #현재 노드 v와 연결된 노드들은 graph[v]에 있다
        
        if not visited[i]: #번호 순서대로 인접 리스트 graph[v]에 있는데 가장 먼저 방문하지 않은 노드를 찾았다면
            
            dfs(graph,i,visited) #그 노드 i에 대해 다시 dfs를 재귀적으로 수행

            #종료 조건이 있나?
            #어차피 모든 노드가 방문처리되면 not visited[i]에 안걸리니까 상관없을듯


dfs(graph,1,visited)
1 2 7 6 8 3 4 5

 

<코드 분석>

 

예시 그래프를 인접리스트 방식으로 표현함

 

번호가 낮은 순서대로 방문 처리할거니까 번호가 낮은 순서대로 인접 리스트에 넣어준다

 

노드 번호는 1번부터 8번까지 있는데 파이썬은 zero index니까 0번은 아무것도 연결이 안되어있다는 뜻으로 []

 

graph = [
  
  [],
  [2,3,8],
  [1,7],
  [1,4,5],
  [3,5],
  [3,4],
  [7],
  [2,6,8],
  [1,7]
]

 

각 노드가 방문되었다는 처리를 하는 visited를 1차원 리스트로 표현

 

특히 1번부터 8번까지 8개 있지만 0번부터 생각했으니까 총 9개

 

스택을 쓰지 않고 True, False를 사용한 1차원 리스트를 쓴다는 점을 눈여겨봐야함

 

visited = [False] * 9

 

dfs 함수를 재귀적으로 구현함

 

탐색하고자 하는 노드는 방문 처리를 수행해야하니 visited[v] = True로 변경하고

 

현재 방문한 노드에 연결된 인접한 노드들은 graph[v]에 있다

 

graph[v]에서 노드들을 순회하여 가장 먼저 방문하지 않은 노드가 발견된다면 dfs()함수를 수행하면 된다

 

def dfs(graph,v,visited):
    
    visited[v] = True

    print(v, end = ' ')

    for i in graph[v]: 
        
        if not visited[i]: 
            dfs(graph,i,visited)

 

4. BFS

 

너비 우선 탐색 알고리즘

 

DFS가 최대한 멀리 있는 노드를 우선적으로 탐색해가는 알고리즘이라면 BFS는 가까운 노드부터 탐색하는 알고리즘

 

BFS 구현은 First In First Out의 큐를 이용하는 것이 정석

 

인접한 노드를 반복적으로 큐에 넣도록 알고리즘을 작성하면 자연스럽게 먼저 들어온 노드가 방문처리 되어 먼저 나가게 되어 가까운 노드부터 탐색을 진행한다

 

1) 탐색 시작 노드를 큐에 삽입하고 방문처리

 

2) 큐에서 노드를 꺼내 해당 노드의 인접 노드 중에서 방문하지 않은 노드를 모두 큐에 삽입하고 방문 처리를 한다

 

3) 2)번 과정을 더 이상 수행할 수 없을 때까지 반복

 

 

위 그래프를 BFS를 이용하여 탐색하면 다음과 같다

 

마찬가지로 인접한 노드가 여러 개 있으면 숫자가 작은 노드부터 먼저 큐에 삽입한다고 가정한다

 

큐는 먼저 들어온 원소가 먼저 나가므로 위로 들어온 원소가 아래로 나간다

 

STEP1) 시작 노드인 '1'을 큐에 삽입하고 방문 처리를 한다. 방문 처리된 노드는 빨간색으로, 큐에서 꺼내 현재 처리하는 노드는 하늘색으로 표시

 

 

STEP2) 큐에서 노드 '1'을 꺼내고 방문하지 않은 인접 노드 '2','3','8'을 모두 큐에 삽입하고 방문 처리

 

 

STEP3) 큐에서 노드 '2'를 꺼내고 방문하지 않은 인접 노드 '7'을 큐에 삽입하고 방문 처리

 

 

STEP4) 큐에서 노드 3을 꺼내고 방문하지 않은 인접 노드 4,5를 큐에 삽입하고 방문 처리

 

 

STEP5) 큐에서 노드 8을 꺼내고 방문하지 않은 인접 노드가 없으므로 무시한다

 

 

STEP6) 큐에서 노드 7을 꺼내고 방문하지 않은 인접 노드 6을 큐에 삽입하고 방문 처리를 함

 

 

STEP7) 남아 있는 노드에 방문하지 않은 인접 노드는 없으므로 모든 노드를 차례대로 꺼내면 최종적으로 다음과 같다

 

1 > 2 > 3 > 8 > 7 > 4 > 5 > 6

 

너비 우선 탐색 알고리즘인 BFS는 큐 자료구조에 기초한다는 점에서 구현이 간단하다

 

실제로 구현함에 있어 deque 라이브러리를 사용하는 것이 좋으며 탐색을 수행함에 있어서 O(N)이 소요된다.

 

일반적으로는 재귀함수로 구현한 DFS는 컴퓨터 시스템 동작 특성상 실제 프로그램의 수행 시간이 느려질 수 있어서

 

BFS가 실제로는 수행 시간이 좋은 편이다

 

from collections import deque

#각 노드가 연결된 정보를 리스트 자료형으로 표현(2차원 인접리스트)

graph = [
  [],
  [2,3,8],
  [1,7],
  [1,4,5],
  [3,5],
  [3,4],
  [7],
  [2,6,8],
  [1,7]
]

#각 노드가 방문된 정보를 리스트 자료형으로 표현(1차원 리스트)

visited = [False]*9

#BFS 함수 정의

def bfs(graph, start, visited):
    
    #큐를 위해 deque 사용

    queue = deque([start])

    #현재 노드를 방문 처리함

    visited[start] = True

    #큐가 빌 때까지 반복

    while queue:
        
        #큐에서 왼쪽 번호가 가장 작은것부터 뽑아 출력

        v = queue.popleft()

        print(v, end = ' ')

        #해당 원소 v와 인접한 노드들은 graph[v]에 존재

        #인접한 노드들 중에서 아직 방문하지 않은 노드가 있다면 큐에 삽입하고 방문처리

        for i in graph[v]:
            
            if not visited[i]:
                
                queue.append(i)

                visited[i] = True

bfs(graph,1,visited) #bfs 호출
1 2 3 8 7 4 5 6

 

<코드 분석>

 

인접리스트 방식으로 graph를 표현

 

방문 처리를 위한 리스트를 True/False 1차원 리스트 visited = [False] * 9로 표현

 

bfs 함수 구현

 

def bfs(graph, start, visited):
    
    #큐를 위해 deque 사용

    queue = deque([start])

    #현재 노드를 방문 처리함

    visited[start] = True

    #큐가 빌 때까지 반복

    while queue:
        
        #큐에서 왼쪽 번호가 가장 작은것부터 뽑아 출력

        v = queue.popleft()

        print(v, end = ' ')

        #해당 원소 v와 인접한 노드들은 graph[v]에 존재

        #인접한 노드들 중에서 아직 방문하지 않은 노드가 있다면 큐에 삽입하고 방문처리

        for i in graph[v]:
            
            if not visited[i]:
                
                queue.append(i)

                visited[i] = True

 

시작 노드를 원소로 가지는 deque 초기화하고

 

시작 노드는 방문처리한 다음에 while 반복문으로 deque가 빌 때까지 반복

 

deque.popleft()로 원소 하나씩 빼서 이 노드와 인접한 노드들은 graph[v]에 존재

 

graph[v]에 존재하는 인접한 노드들 중에서 아직 방문하지 않은 노드가 있다면 deque에 삽입하고 방문처리를 함

 

 

5. 요약

 

더 다양한 방식으로 구현할 수 있지만 다음과 같은 방법이 가장 간결한 방식

 

DFS

 

동작원리) 스택

구현방법) 재귀함수 이용

 

BFS

 

동작원리) 큐구현방법) 큐 자료구조 이용

 

DFS와 BFS 유형에서 1차원 배열이나 2차원 배열도 그래프로 생각하면 수월하게 문제를 풀 수 있다

 

예를 들어 게임 맵이 3*3 형태의 2차원 배열이고 각 데이터를 좌표라고 생각해보자

 

게임 캐릭터가 (1,1) 좌표에 있다고 표현할 때 각 좌표를 상하좌우로만 이동할 수 있다면

 

모든 좌표의 형태를 다음처럼 그래프 형태로 바꿔서 생각할 수 있다

 

 

그러므로 2차원 배열에서의 탐색 문제를 만나면 그래프 형태로 바꿔서 생각해보도록 하자. 풀이 방법을 더 쉽게 떠올릴 수 있을 것이다.

TAGS.

Comments