블로그를 이전 중이라 완료되기 전까지는 벨로그에 작성할 계획입니다.
이후 모든 글은 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는 깊이 우선 탐색으로 스택 자료구조를 이용해 아래와 같은 순서로 작동한다.
이를 파이썬 코드로 구현하면 아래와 같다. 이때 탐색에 있어 데이터의 개수가 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는 너비 우선 탐색으로 큐 자료구조를 이용해 아래와 같은 순서로 작동한다.
이를 파이썬 코드로 구현하면 아래와 같다. 이때 탐색에 있어 데이터의 개수가 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 |
---|---|---|
동작 원리 | 스택 | 큐 |
구현 방법 | 재귀 함수 이용 | 큐 자료구조 이용 |