Python

이코테 : DFS/ BFS - 그래프 탐색 알고리즘 (Python)

식초 2020. 11. 15. 18:22

※모든 사진과 자료의 출처는 나동빈 [이것이 취업을 위한 코딩 테스트다] 입니다

 

그래프 탐색 알고리즘

탐색(Search) : 많은 양의 데이터 중에서 원하는 데이터를 찾는 과정

대표적 그래프 탐색 알고리즘 : DFS, BFS

DFS, BFS는 코테에서 자주 등장하는 유형

대기업 공채에서 자주 출제

 

 

 


스택 자료구조

먼저 들어 온 데이터나중에 나가는 형식(선입후출)의 자료구조

입구와 출구가 동일한 형태로 스택을 시각화한다.

ex. 박스 쌓기

 

스택 동작 예시

삭제는 가장 마지막에 있던 원소가 삭제된다.

 

스택 구현 예제

 

stack = [ ]     #리스트 자료형 사용한다.

 

#삽입(5) - 삽입(2) - 삽입(3) - 삽입(7) - 삭제( ) - 삽입(1) - 삽입(4) - 삭제( )

stack .append(5)     #가장 오른쪽에 원소 삽입 : append()

stack .append(2)

stack .append(3)

stack .append(7)

stack .pop( )          #가장 오른쪽에서 원소 꺼낸다 : pop()

stack .append(1)

stack .append(4)

stack .pop( )

 

print(stack[ ::-1 ])     #최상단 원소부터 출력 = 먼저 나가고자 하는 원소

print(stack)     #최하단 원소부터 출력

 

(출력)

[ 1, 3, 2, 5 ]

[ 5, 2, 3, 1 ]

별도의 자료구조가 존재하지 않는다. 리스트와 기존 함수를 가지고도 파이썬은 스택함수 구현 가능하다.

 

 

 


큐 자료구조

먼저 들어온 데이터먼저 나가는 형식(선입선출)의 자료구조이다.

큐는 입구와 출구가 모두 뚫려 있는 터널과 같은 형태이다.

ex. 은행의 대기열

 

 

큐 동작 예시

 

큐 구현 예제

 

from collections import deque

 

#큐(Queue) 구현을 위해 덱(deque) 라이브러리 사용

queue = deque( )

 

#삽입(5) - 삽입(2) - 삽입(3) - 삽입(7) - 삭제( ) - 삽입(1) - 삽입(4) - 삭제( )

queue .append(5)    #가장 오른쪽에 원소추가 : 리스트의 append와 동일하다

queue .append(2)

queue .append(3)

queue .append(7)

queue .popleft( )    #가장 왼쪽에 원소 꺼낸다

queue .append(1)

queue .append(4)

queue .popleft( )

 

print(queue)         #먼저 들어온 순서대로 출력

queue.reverse( )    #역순으로 바꾸기

print(queue)         #나중에 들어온 원소부터 출력

 

(출력)

deque( [3, 7, 1, 4] )

deque( [4, 1, 7, 3] )

리스트 자료구조 말고 덱을 이용해서 큐를 구현한다. 시간 복잡도가 줄어든다.

 

 

 


재귀함수

재귀함수(Recursive function)란 자기 자신을 다시 호출하는 함수이다.

 

단순한 형태의 재귀 함수 예제

-'재귀 함수를 호출합니다.'라는 문자열을 무한히 출력한다.

-어느 정도 출력하다가 최대 재귀 깊이 초과 메시지가 출력된다.

def recursive_function( ):    #함수를 정의한다

    print('재귀 함수를 호출합니다.')    #문자를 출력한다

    recursive_function( )      #다시 자기 자신을 호출한다

 

recursive_function( )    #이 함수를 밖에서 호출하면 반복적으로 자기 자신을 호출한다

while이나 for문을 이용하지 않고도 반복문을 만들 수 있다.

 

 

재귀 함수의 종료 조건

문제 풀이에서는 재귀 함수의 종료 조건을 반드시 명시해야 한다.

종료 조건을 제대로 명시 안 하면 무한히 호출된다.

종료 조건을 포함한 재귀 함수 예제

 

def recursive_function( i ):

    #100번째 호출을 했을 때 종료되도록 종료 조건 명시

    if i == 100:     #시작부분에 명시

        return

    print(i, '번째 재귀함수에서', i+1, '번째 재귀함수를 호출합니다.')

    recursive_function( i+1 )   #자동으로 1식 증가하게 한다.

    print(i, '번째 재귀함수를 종료합니다.')

 

recursive_function(1)

99번째부터 1까지 차례대로 종료된다고 뜬다.

재귀함수를 이용하면 스택에 데이터를 넣었다가 빼는 것과 비슷하게 나온다.

 

 

 

팩토리얼 구현 예제 : 재귀함수 사용

n! = 1 x 2 x 3 x ... x (n-1) x n

수학적으로 0! = 1! = 1

#반복적으로 구현한 n!

def factorial_iterative(n):

    result = 1

    # 1부터 n까지의 수를 차례대로 곱하기

    for i in range(1, n+1):

        result *= i 

    return result

 

 

#재귀적으로 구현한 n!

def factorial_recursive(n):

    if n <= 1:   #n이 1 이하인 경우 1을 반환

        return 1

    #n! = n * (n-1)!를 그대로 코드로 작성하기

    return n * factorial_recursive(n-1)

 

 

#각각의 방식으로 구현한 n! 출력(n=5)

print('반복적으로 구현: ', factorial_iterative(5))

print('재귀적으로 구현: ', factorial_recursive(5))

 

 

(출력)

반복적으로 구현: 120

재귀적으로 구현: 120

재귀적으로 구현하면 코드가 더 간결하고 직관적이다.

 

 

 

유클리도 호제법(최대공약수 계산) 예제 : 재귀함수 사용

두 개의 자연수에 대한 최대공약수를 구하는 대표적인 알고리즘으로는 유클리드 호제법이 있다.

 

유클리드 호제법

-두 자연수 A, B에 대하여 (A > B) A를 B로 나눈 나머지를 R이라고 한다.

-이때 A와 B의 최대 공약수B와 R의 최대공약수같다.

 

유클리드 호제법의 아이디어를 그대로 재귀 함수로 작성할 수 있다.

-예시 : GCD(192, 162)

단계 A B
1 192 162
2 162 30
3 30 12
4 12 6

식을 바꾸는 형태가 반복적이며 동일한 구조를 가진다 = 재귀함수로 직관적으로 만들 수 있다.

def gcd(a, b):

    if a % b == 0:

        return b

    else:

        return gcd(b, a % b)

 

print(gcd(192, 162))

 

(출력)

6

 

 

재귀 함수 사용의 유의 사항

재귀 함수를 잘 활용하면 복잡한 알고리즘을 간결하게 작성할 수 있다.

-단, 오히려 다른 사람이 이해하기 어려운 형태의 코드가 될 수도 있으므로 신중하게 사용해야 한다.

모든 재귀 함수반복문을 이용하여 동일한 기능을 구현할 수 있다.

재귀 함수가 반복문보다 유리한 경우도 있고, 불리한 경우도 있다.

컴퓨터가 함수를 연속적으로 호출하면 컴퓨터 메모리 내부의 스택 프레임에 쌓인다.

-그래서 스택을 사용해야 할 때 구현상 스택 라이브러리 대신재귀 함수를 이용한다.

 ex. DFS를 재귀 함수로 구현하기도 한다.

 

 

 


DFS (Depth-First Search)

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

DFS는 스택 자료구조 (or 재귀 함수)를 이용하며, 구체적인 동작 과정은 아래와 같다.

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

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

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

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

 

 

DFS 동작 예시

[step 0] 그래프를 준비한다. (방문 기준: 번호가 낮은 인접 노드부터)

    시작 노드 : 1

 

[step 1] 시작 노드인 '1'스택에 삽입하고 방문 처리 한다.

 

[step 2] 스택의 최상단 노드인 '1'에 방문하지 않은 인접 노드 2, 3, 8이 있다.

    이 중에서 가장 작은 노드인 2스택에 넣고 방문 처리 한다.

 

[step 3] 스택의 최상단 노드인 2에 방문하지 않은 인접 노드 7이 있다.

    따라서 7번 노드스택에 넣고 방문 처리를 한다.

 

[step 4] 스택의 최상단 노드인 7에 방문하지 않은 인접 노드 6, 8이 있다.

    이 중에서 가장 작은 노드인 6스택에 넣고 방문 처리 한다.

깊게 들어갔다가 더는 들어갈 곳이 없으면 밖으로 꺼내준다. 

 

탐색 순서 : 1 -> 2 -> 7 -> 6  -> 8 -> 3 -> 4 -> 5

 

스택 자료구조 대신에 재귀 함수 이용할 수 있다.

 

 

DFS 소스코드 예제

#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)   #재귀 함수 이용해서 방문 진행한다

 

#각 노드가 연결된 정보를 표현 (2차원 리스트)

graph = [

    [],          #인덱스 0인 부분은 비워둔다.

    [2, 3, 8],  #1번 노드에 인접한 노드

    [1, 7],

    [1, 4, 5],

    [3, 5],

    [3, 4],

    [7],

    [2, 6, 8],

    [1, 7]

]

 

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

visited = [False] * 9    #처음에는 모든 노드를 한 번도 방문하지 않은 것으로 처리한다.

                             #인덱스 0을 사용하지 않기 위해서 n+1개인 9로 리스트를 초기화 한다. 더 직관적이다.

 

#정의된 DFS 함수 호출

dfs(graph, 1, visited)

 

(출력)

1 2 7 6 8 3 4 5

 

 

 


BFS (Breadth-First Search)

BFS는 너비 우선 탐색, 그래프에서 가까운 노드부터 우선적으로 탐색하는 알고리즘이다.

BFS는 큐 자료구조를 이용하며, 구체적인 동작 과정은 아래와 같다.

1. 탐색 시작 노드큐에 삽입하고 방문 처리한다.

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

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

 

BFS는 특정 조건에서 최단 경로 문제 해결 목적으로 효과적이다.

 

 

BFS 동작 예시

[step 0] 그래프를 준비한다. (방문 기준 : 번호가 낮은 인접 노드부터)

    시작 노드 : 1

 

[step 1] 시작 노드인 1큐에 삽입하고 방문 처리를 한다.

 

[step 2] 큐에서 노드 1을 꺼내 방문하지 않은 인접 노드 2, 3, 8큐에 삽입하고 방문 처리한다.

 

[step 3] 큐에서 노드 2를 꺼내 방문하지 않은 인접노드 7큐에 삽입하고 방문 처리한다.

1은 방문 처리가 되어있다. 또하지 않는다.

 

[step 4] 큐에서 노드 3을 꺼내 방문하지 않은 인접 노드 4, 5큐에 삽입하고 방문 처리한다.

 

[step 5] 큐에서 노드 8을 꺼내고 방문하지 않은 인접 노드가 없으므로 무시한다.

 

탐색 순서 : 1 -> 2 -> 3 -> 8 -> 7 -> 4 -> 5 -> 6

 

각 간선의 비용이 동일한 상황에서 최단 거리 문제 해결하기 위해 사용 가능하다.

 

 

BFS 소스코드 예제

from collections import deque

 

#BFS 메서드 정의

def bfs(graph, start, visited):

    #큐(Queue) 구현을 위해 deque 라이브러리 사용

    queue = deque([start])    #시작 노드를 queue에 넣어준다.

    #현재 노드를 방문 처리

    visited[start] = True

    #큐가 빌 때까지 반복

    while queue:

        #큐에서 하나의 원소를 뽑아 출력하기

        v = queue .popleft()   #popleft() : 가장 먼저 들어온 원소를 꺼낸다.

        print(v, end=' ')

        #아직 방문하지 않은 인접한 원소들을 큐에 삽입

        for i in graph[v]:

            if not visited[i]:

                queue.append(i)

                visited[i] = True

 

#각 노드가 연결된 정보를 표현 (2차원 리스트)

graph = [  

    [],          #원소가 9개인 리스트 객체를 만들어 준다. 0인덱스는 비워준다.

    [2, 3, 8],

    [1, 7],

    [1, 4, 5],

    [3, 5],

    [3, 4],

    [7],

    [2, 6, 8],

    [1, 7]

]

 

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

visited = [False] * 9

 

#정의된 BFS 함수 호출

bfs(graph, 1, visited)

 

(출력)

1 2 3 8 7 4 5 6

 

 

 


음료수 얼려 먹기 : 문제 설명

N X M 크기의 얼음 틀이 있다. 구멍이 뚫려 있는 부분은 0, 칸막이가 존재하는 부분은 1로 표시된다. 

구멍이 뚫려 있는 부분끼리 상, 하, 좌, 우로 붙어 있는 경우 서로 연결되어 있는 것으로 간주한다.

이때 얼음 틀의 모양이 주어졌을 때 생성되는 총 아이스크림의 개수를 구하는 프로그램을 작성하시오.

다음의 4 X 5 얼음 틀 예시에서는 아이스크림이 총 3개 생성된다.

 

연결 요소 찾기, connected_component 문제이기도 하다.

 

 

문제 조건

난이도 : 중하  |  풀이 시간 : 30분  |  시간 제한 : 1초  |  메모리 제한 : 128MB

입력 조건 

첫번째 줄에 얼음 틀의 세로 길이 N과 가로 길이 M이 주어진다.

   (1 <= N, M <= 1,000)  전체 얼음 틀 공간 : 100만개 이하

두번째 줄부터 N+1번째 줄까지 얼음 틀의 형태가 주어진다.

이때 구멍이 뚫려있는 부분은 0, 막혀 있는 부분은 1이다.

출력 조건

한 번에 만들 수 있는 아이스크림의 개수를 출력한다.

 

문제 해결 아이디어

이 문제는 DFS 혹은 BFS로 해결할 수 있다. 

얼음을 얼릴 수 있는 공간이 상, 하, 좌, 우로 연결되어 있다고 표현하므로 그래프 형태로 모델링 한다.

방문처리가 이루어지는 지점에 대해서만 카운트를 수행하면 전체 연결 요소가 몇개인지 계산할 수 있다.

 

DFS를 활용하는 알고리즘은 아래와 같다.

1. 특정한 지점의 주변 상, 하, 좌, 우를 살펴본 뒤

   주변 지점 중에서 값이 0이면서 아직 방문하지 않은 지점이 있다면 해당 지점을 방문한다.

2. 방문한 지점에서 다시 상, 하, 좌, 우를 살펴보면서 방문을 진행하는 과정을 반복하면,

   연결된 모든 지점을 방문할 수 있다.

3. 모든 노드에 대하여 1~2번의 과정을 반복하며, 방문하지 않은 지점의 수를 카운트한다.

 

#DFS로 특정 노드를 방문하고 연결된 모든 노드들도 방문

def dfs(x, y):

    #주어진 범위를 벗어나는 경우에는 즉시 종료

    if x <= -1 or  x >= n or  y <= -1 or  y <= m:

         return False

    #현재 노드를 아직 방문하지 않았다면

    if graph[x][y] == 0:

        #해당 노드 방문 처리

        graph[x][y] = 1

        #상, 하, 좌, 우의 위치들도 모두 재귀적으로 호출

        dfs( x-1, y )

        dfs( x, y-1 )

        dfs( x+1, y )

        dfs( x, y+1 )

        return True    #결과적으로 True값을 반환할 수 있게 한다. 현재 위치에서 dfs 수행된 것이기에 카운트 한다

    return False


 # N, M을 공백을 기준으로 구분하여 입력 받기

n, m = map(int, input() .split())

 

#2차원 리스트의 맵 정보 입력 받기

graph = [ ]

for i in range(n):

    graph .append(list(map(int, input())))   #한줄을 입력받고 정수형으로 바꿔서 다시 리스트 형태로 바꿔준다

 

#모든 노드(위치)에 대하여 음료수 채우기

result = 0

for i in range(n):

    for j in range(m):

        #현재 위치에서 DFS 수행

        if dfs(i, j) == True:    #방문처리가 된거라면

            result += 1  #그때 카운트한다.

 

print(result)    #정답 출력

 

 

 


미로 탈출 : 문제 설명

식초는 N X M 크기의 직사각형 형태의 미로에 갇혔다. 미로에는 여러 마리의 괴물이 있어 이를 피해 탈출해야 한다.

 

식초의 위치는 (1, 1)이며 미로의 출구는 (N, M)의 위치에 존재하며 한 번에 한 칸씩 이동할 수 있다.

이때 괴물이 있는 부분은 0으로, 괴물이 없는 부분은 1로 표시되어 있다. 미로는 반드시 탈출할 수 있는 형태로 나온다.

 

이때 식초가 탈출하기 위해 움직여야 하는 최소 칸의 개수를 구하라. 칸을 셀 때는 시작 칸과 마지막 칸을 모두 포함해서 계산한다.

 

 

문제 조건

난이도 : 중하  |  풀이 시간 : 30분  |  시간제한 : 1초  |  메모리 제한 128MB

입력 조건

첫째 줄에 두 정수 N, M(4 <= N, M <= 200)이 주어진다. 다음 N개의 줄에는 각각 M개의 정수(0 or 1)로 미로의 정보가 주어진다. 각각의 수들은 공백 없이 붙어서 입력으로 제시된다. 시작 칸과 마지막 칸은 항상 1이다.

출력 조건

첫째 줄에 최소 이동 칸의 개수를 출력합니다.

 

해결 아이디어

BFS는 시작 지점에서 가까운 노드부터 차례대로 그래프의 모든 노드를 탐색한다.

상, 하, 좌, 우로 연결되는 모든 노드로의 거리가 1로 동일하다.

-따라서 (1, 1)지점부터 BFS를 수행하여 모든 노드의 최단 거리 값을 기록하면 해결할 수 있다.

 

[step 1] 처음에 (1, 1)의 위치에서 시작한다.

 

[step 2] (1, 1)좌표에서 상, 하, 좌, 우로 탐색을 진행하면, 바로 옆 노드인 (1, 2)위치의 노드를 방문하게 되고,

           새롭게 방문하는 (1, 2)노드의 값을 2로 바꾸게 된다.

이유 : 거리가 2이다. 최단 경로를 기록해야 하기 때문이다. 이 노드도 큐에 담기게 된다.

다시 이 노드를 꺼낸 다음에 상하좌우 탐색 후 인접한 노드에 방문하게 된다.

매번 새로운 지점을 방문할 때 그 이전지점까지 거리 +1  기록한다

 

[step 3]  마찬가지로 BFS를 계속 수행하면 결과적으로 다음과 같이 최단 경로의 값들이 1씩 증가하는 형태로 변경된다.

 

#BFS 소스코드 구현

def bfs(x, y):

    #큐(Queue) 구현을 위해 deque 라이브러리 사용

    queue = deque()

    queue .append((x, y))   #(x, y)인 튜플 데이터를 담는다.

    #큐가 빌 때까지 반복하기

    while queue:

        x, y = queue . popleft()   #반복할 때마다 큐에서 원소를 꺼내서

        #현재 위치에서 4가지 방향으로의 위치 확인

        for i in range(4):

            nx = x+dx[i]

            ny = y+dy[i]

           #미로 찾기 공간을 벗어난 경우 무시

           if nx < 0 or  nx >=n or ny < 0 or  ny >= m:

               continue

           #벽(괴물)인 경우 무시

           if graph[nx][ny] == 0:

               continue

           #해당 노드를 처음 방문하는 경우에만 최단 거리 기록

           if graph[nx][ny] == 1:

              graph[nx][ny] = graph[x][y] + 1   #직전 노드값에 1을 더한다.

              queue .append((nx, ny))

    #가장 오른쪽 아래까지의 최단 거리 반환

    return graph[n-1][m-1]


from collections import deque

 

#N, M을 공백을 기준으로 구분하여 입력받기

n, m = map(int, input().split())

#2차원 리스트의 맵 정보 입력 받기

graph = []

for i in range(n):

    graph.append(list(map(int, input()))) 

 

#이동할 네 가지 방향 정의 (상, 하, 좌, 우)

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

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

 

#BFS를 수행한 결과 출력

print(bfs(0, 0))