[ 알고리즘 ] 이것이 취업을 위한 코딩 테스트다 with 파이썬 : DFS/BFS

이주 weekwith.me·2022년 7월 10일
0

알고리즘

목록 보기
37/73
post-thumbnail

블로그를 이전 중이라 완료되기 전까지는 벨로그에 작성할 계획입니다.
이후 모든 글은 https://weekwith.me 에 작성 예정이니 다른 글이 궁금하시다면 해당 링크를 통해 방문해주세요.

또한 본 글의 모든 내용은 한빛미디어 출판사에서 출판한 나동빈의 이것이 취업을 위한 코딩 테스트다 with 파이썬 : DFS/BFS를 참고했음을 알립니다.

개념

DFS(Depth First Search)BFS(Breadth First Search) 모두 그래프(Graph)를 탐색(Search)하기 위한 알고리즘이다.

이때 그래프를 표현하기 위한 방법으로 관련 자료구조인 스택(Stack) 및 큐(Queue), 그리고 관련 알고리즘인 재귀함수(Recursive Function)에 대한 개념을 먼저 이해하고 있어야 수월하다.

관련 자료구조 및 알고리즘

스택

스택은 박스 쌓기에 비유할 수 있다. 따라서 가장 먼저 들어온 박스가 가장 마지막에 나가는 구조라 하여 선입후출(FIFO_First In Last Out) 구조 또는 가장 마지막에 들어온 박스가 가장 먼저 나가는 구조라 하여 후입선출(Last In First Out) 구조라 한다.

이를 파이썬 배열 -리스트- 을 활용해 표현해보면 아래 코드와 같다. 가장 마지막에 들어온 3이 가장 먼저 나간 것을 알 수 있다.

stack: list = []

stack.append(1)
stack.append(2)
stack.append(3)
print(stack) # > [1, 2, 3]

print(stack.pop()) # > 3
print(stack) # > [1, 2]

파이썬에서 스택 자료구조를 이용할 때는 별도의 라이브러리를 사용할 필요 없이 리스트와 append()pop() 메서드를 사용하면 된다.

큐는 스택과 반대되는 개념으로 대기 줄에 비유할 수 있다. 따라서 가장 마지막에 들어온 사람이 가장 마지막에 나가는 구조라 하여 선입선출(First In First Out) 구조라 한다.

큐 또한 스택과 마찬가지로 파이썬의 배열을 활용하여 구현할 수 있지만 이보다 속도 측면에서 더 우위가 있는 collections 내장모듈의 deque 객체를 활용하여 아래와 같이 구현할 수 있다.

from collections import deque


queue: deque = deque()

queue.append(1)
queue.append(2)
queue.append(3)

print(queue) # > deque([1, 2, 3])

print(queue.popleft()) # > 1
print(queue) # > deque([2, 3])

이때 deque 객체를 리스트 자료형으로 변경하기 위해서는 list(queue) 와 같이 감싸주면 된다.

재귀함수

재귀함수는 자기 자신을 다시 호출하는 함수를 의미한다.

return 문을 통해서 끊임 없이 함수 스스로를 계속 호출하는 형태이기 때문에 그 종료 조건을 명시하는 게 중요하다.

이때 함수 자체가 스택 자료구조에 의해 호출 순서가 결정되기 때문에 재귀함수와 스택 자료구조는 연관 깊다.

파이썬으로 구현한 대표적인 재귀함수는 아래와 같이 팩토리얼이 있다. 이때 N <= 1 이라는 if 문의 조건을 통해 재귀함수의 종료 시점을 명시했다.

def factorial(N: int) -> int:
  if N <= 1:
    return 1

  else:
    return N * factorial(N-1)

재귀함수의 접근 개념은 이후 다이나믹 프로그래밍에서 무척 중요해진다.

특정한 함수를 자신보다 더 작은 변수에 대한 함수와의 관계로 표현하는 점화식과 관련 있기 때문이다.

관련해서는 이후에 더 자세히 살펴보고자 한다.

그래프 표현 방식

개념

그래프는 노드(Node)와 간선(Edge)으로 표현되며 이때 노드를 정점(Vertex)이라 하기도 한다.

그래프 탐색은 하나의 노드를 시작으로 다수의 노드를 방문하는 것을 의미하며 이때 두 노드가 간선으로 이어져 있으면 해당 두 노드는 인접하다(Adjacent)고 표현한다.

프로그래밍에서 그래프를 표현할 수 있는 방식은 아래와 같이 인접 행렬(Adjacency Matrix)과 인접 리스트(Adjacency List)가 있다.

인접 행렬

인접 행렬의 경우 2차원 배열로 그래프의 연결 관계를 표현하는 방식이다.

이때 서로 연결되어 있지 않은 노드의 경우 무한(Infinity)의 비용이 발생하고 본인 노드 자체는 아무런 비용이 발생하지 않는 것으로 가정한다.

이를 파이썬 코드로 구현하면 아래와 같다.

INF: int = -1

graph: list[list[int]] = [
  [0, 7, 5],
  [7, 0, INF],
  [5, INF, 0],
]

grpah[0][1] 요소의 의미는 노드 0 에서 노드 1 로 가는 비용이 7 발생하는 것을 의미한다.

인접 리스트

인접 리스트의 경우 리스트로 그래프의 연결 관계를 표현하는 방식이다.

위 인접 행렬로 표현했던 그래프를 인접 리스트를 통해 표현하면 아래와 같다.

graph: list[list[int]] = [ [] for _ in range(3) ]

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

graph[0][0] 요소는 튜플 값 (1, 7) 을 반환하는데 이는 노드 0 에서 튜플의 첫 번째 요소인 노드 1 로 가는 비용이 튜플의 두 번째 요소인 7 발생하는 것을 의미한다.

차이점

인접 행렬과 인접 리스트 방식은 그래프 탐색의 목적에 따라 달라진다.

기본적으로 인접 리스트 방식의 경우 연결된 정보만을 저장하기 때문에 모든 관계를 저장하는 인접 행렬과 비교했을 때 메모리 측면에서 효율적이다.

하지만 두 노드가 연결되어 있는지 확인해야 하는 경우 인접 행렬의 경우 grpah[0][1] 과 같이 노드 0 과 노드 1 이 바로 그 좌표를 통해 확인할 수 있는 반면 인접 리스트의 경우 grpah[0] 에 저장된 모든 튜플 형태의 요소를 하나씩 확인하여 노드 1 이 존재하는지 확인해야 하기 때문에 속도 측면에서 비효율적이다.

따라서 특정한 노드와 연결된 모든 인접 노드를 순회해야 하는 경우 인접 리스트 방식이 메모리 측면에서 효율적이기 때문에 적절할 것이며 특정 노드와 연결된 특정 노드들만 확인해야 하는 경우 인접 행렬이 속도 측면에서 효율적이기 때문에 적절할 것이다.

DFS

DFS는 깊이 우선 탐색으로 스택 자료구조를 이용해 아래와 같은 순서로 작동한다.

  1. 탐색 시작 노드를 스택에 삽입하고 방문 처리를 한다.
  2. 스택의 최상단 노드에 방문하지 않은 인접 노드가 있으면 그 인접 노드를 스택에 넣고 방문 처리를 한다.
  3. 이때 방문하지 않은 인접 노드가 없으면 스택에서 최상단 노드를 꺼낸다.
  4. 2-3번 과정을 더이상 수행할 수 없을 때까지 반복한다.

이를 파이썬 코드로 구현하면 아래와 같다. 이때 탐색에 있어 데이터의 개수가 N인 경우 시간 복잡도는 O(N)이다.

def dfs(
    node: int,
    visited_info: list[bool],
    stack: list[int],
    graph: list[list[int]],
):
    visited_info[node] = True
    stack.append(node)

    adjacent_nodes: list[int] = graph[node]
    for adjacent_node in adjacent_nodes:
        if not visited_info[adjacent_node]:
            dfs(
                node=adjacent_node,
                visited_info=visited_info,
                stack=stack,
                graph=graph,
            )


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

stack: list[int] = []
visited_info: list[bool] = [False] * 9
dfs(node=1, visited_info=visited_info, stack=stack, graph=graph)

print(stack) # > [1, 2, 7, 6, 8, 3, 4, 5]

BFS

BFS는 너비 우선 탐색으로 큐 자료구조를 이용해 아래와 같은 순서로 작동한다.

  1. 탐색 시작 노드를 큐에 삽입하고 방문 처리를 한다.
  2. 큐에서 노드를 꺼내 해당 노드의 인접 노드 중에서 방문하지 않은 노드를 모두 큐에 삽입하고 방문 처리를 한다.
  3. 2번의 과정을 더이상 수행할 수 없을 때까지 반복한다.

이를 파이썬 코드로 구현하면 아래와 같다. 이때 탐색에 있어 데이터의 개수가 N인 경우 시간 복잡도는 O(N)이며 일반적으로 실제 수행 시간은 DFS보다 좋은 편이다.

from collections import deque


def bfs(
    node: int,
    visited_info: list[int],
    queue: list[int],
    result: list[int],
    graph: list[list[int]],
) -> None:
    visited_info[node] = True
    while queue:
        current_node: int = queue.popleft()
        result.append(current_node)
        for adjacent_node in graph[current_node]:
            if not visited_info[adjacent_node]:
                queue.append(adjacent_node)
                visited_info[adjacent_node] = True


graph: list[list[int]] = [
    [],
    [2, 3, 8],
    [1, 7],
    [1, 4, 5],
    [3, 5],
    [3, 4],
    [7],
    [2, 6, 8],
    [1, 7],
]
visited_info: list[bool] = [False] * 9
result: list[int] = []
queue: deque = deque([1])

bfs(
    node=1,
    visited_info=visited_info,
    queue=queue,
    result=result,
    graph=graph
)
print(result)

결론

DFS 및 BFS를 정리하면 아래 표와 같다. 이때 두 알고리즘 모두 방문한 노드에 대한 부분을 저장하는 걸 잊지 말아야 한다.

항목 DFS BFS
동작 원리 스택
구현 방법 재귀 함수 이용 큐 자료구조 이용

실전문제

01. 음료수 얼려 먹기

접근법

소스 코드

시간 복잡도

02. 미로 탈출

접근법

소스 코드

시간 복잡도

기출문제

01. 특정 거리의 도시 찾기

접근법

소스 코드

시간 복잡도

02. 연구소

접근법

소스 코드

시간 복잡도

03. 경쟁적 전염

접근법

소스 코드

시간 복잡도

04. 괄호 변환

접근법

소스 코드

시간 복잡도

05. 연산자 끼워 넣기

접근법

소스 코드

시간 복잡도

06. 감시 피하기

접근법

소스 코드

시간 복잡도

07. 인구 이동

접근법

소스 코드

시간 복잡도

08. 블록 이동하기

접근법

소스 코드

시간 복잡도

profile
Be Happy 😆

0개의 댓글