본 시리즈는 프로그래밍 알고리즘의 개념을 정리하고 실습을 진행해보는 시리즈입니다.
탐색(Search) 알고리즘은 주어진 데이터 구조 내에서 원하는 데이터를 찾아내기 위한 일련의 과정입니다.
탐색 알고리즘은 크게 두 가지 범주로 나눌 수 있습니다:
이번 시리즈에서는 이러한 다양한 탐색 알고리즘을 순차적으로 다루면서 그 개념과 활용 방법을 소개합니다.
저번 포스팅에서 배열 기반의 투 포인터(Two-Pointer)와 이진 탐색(Binary Search)을 중점적으로 다루었고, 이번 포스팅에서는 깊이 우선 탐색(DFS), 너비 우선 탐색(BFS)을 중점적으로 다루도록 하겠습니다.
깊이 우선 탐색(DFS)은 트리나 그래프와 같은 자료구조에서 한 경로를 끝까지 탐색한 후, 다른 경로로 백트래킹(backtracking)하며 탐색을 진행하는 알고리즘입니다.
DFS는 한 경로를 끝까지 탐색한 후에 다음 경로로 이동하는 방식으로, 탐색 깊이가 우선시됩니다. 다음은 DFS의 주요 단계입니다:
DFS는 재귀 호출뿐만 아니라 스택 자료구조를 통해 반복문 방식으로도 구현할 수 있습니다.
다음 그래프에서 DFS를 사용하여 노드 1에서 탐색을 시작하는 과정을 살펴보겠습니다.
DFS 탐색 순서 : 1 -> 2 -> 5 -> 6 -> 3 -> 4
DFS를 재귀적으로 구현한 코드 (Python):
def dfs(graph, node, visited):
# 현재 노드를 방문 처리
visited[node] = True
print(node, end=' ')
# 현재 노드와 연결된 다른 노드를 재귀적으로 방문
for neighbor in graph[node]:
if not visited[neighbor]:
dfs(graph, neighbor, visited)
# 그래프를 인접 리스트로 표현 (0번 노드는 사용하지 않음)
graph = {
1: [2, 3, 4],
2: [1, 5, 6],
3: [1],
4: [1],
5: [2],
6: [2]
}
# 방문한 노드를 기록할 리스트
visited = [False] * 7
# DFS 시작 (노드 1부터 탐색 시작)
dfs(graph, 1, visited) # 1 2 5 6 3 4
DFS를 재귀적으로 구현한 코드 (Java):
import java.util.*;
public class DFSExample {
public static void dfs(int node, boolean[] visited, Map<Integer, List<Integer>> graph) {
// 현재 노드를 방문 처리
visited[node] = true;
System.out.print(node + " ");
// 현재 노드와 연결된 다른 노드를 재귀적으로 방문
for (int neighbor : graph.get(node)) {
if (!visited[neighbor]) {
dfs(neighbor, visited, graph);
}
}
}
public static void main(String[] args) {
// 그래프를 인접 리스트로 표현 (1-based index)
Map<Integer, List<Integer>> graph = new HashMap<>();
graph.put(1, Arrays.asList(2, 3, 4));
graph.put(2, Arrays.asList(1, 5, 6));
graph.put(3, Arrays.asList(1));
graph.put(4, Arrays.asList(1));
graph.put(5, Arrays.asList(2));
graph.put(6, Arrays.asList(2));
// 방문한 노드를 기록할 배열
boolean[] visited = new boolean[7];
// DFS 시작 (노드 1부터 탐색 시작)
dfs(1, visited, graph); // 1 2 5 6 3 4
}
}
DFS는 재귀 호출을 통해 쉽게 구현할 수 있지만, 명시적인 스택 자료구조를 사용하여 반복문 방식으로도 구현할 수 있습니다.
예시 설명:
다음 그래프에서 DFS를 스택 자료구조를 사용하여 노드 1에서 탐색을 시작하는 과정을 살펴보겠습니다.
DFS 탐색 순서 : 1 -> 4 -> 3 -> 2 -> 6 -> 5
DFS를 명시적 스택으로 구현한 코드 (Python):
def dfs_stack(graph, start):
visited = [False] * (max(graph.keys()) + 1) # 최대 노드 번호에 맞게 visited 리스트 크기 설정
stack = [start] # 시작 노드를 스택에 넣음
while stack:
node = stack.pop()
if not visited[node]:
print(node, end=' ')
visited[node] = True
# 스택에 인접 노드를 추가
# 스택의 특성상 후입선출(LIFO)이기 때문에,
# 인접 노드가 추가되는 순서에 따라 탐색 순서가 달라질 수 있습니다.
stack.extend(graph[node])
# 테스트용 그래프
graph = {
1: [2, 3, 4],
2: [1, 5, 6],
3: [1],
4: [1],
5: [2],
6: [2]
}
print()
# DFS 시작 (노드 1부터 탐색 시작)
dfs_stack(graph, 1) # 출력: 1 4 3 2 6 5
DFS를 명시적 스택으로 구현한 코드 (Java):
import java.util.*;
public class DFSStackExample {
public static void dfsStack(int start, Map<Integer, List<Integer>> graph) {
boolean[] visited = new boolean[graph.size() + 1]; // 방문한 노드를 기록할 배열
Stack<Integer> stack = new Stack<>();
stack.push(start); // 시작 노드를 스택에 넣음
while (!stack.isEmpty()) {
int node = stack.pop();
if (!visited[node]) {
System.out.print(node + " ");
visited[node] = true;
// 스택에 인접 노드를 추가
// 후입선출(LIFO) 방식이므로, 인접 노드를 역순으로 추가하여 재귀 호출과 동일한 순서를 유지할 수 있습니다.
List<Integer> neighbors = graph.get(node);
Collections.reverse(neighbors); // 역순으로 추가
for (int neighbor : neighbors) {
if (!visited[neighbor]) {
stack.push(neighbor);
}
}
}
}
}
public static void main(String[] args) {
// 그래프를 인접 리스트로 표현 (1-based index)
Map<Integer, List<Integer>> graph = new HashMap<>();
graph.put(1, Arrays.asList(2, 3, 4));
graph.put(2, Arrays.asList(1, 5, 6));
graph.put(3, Arrays.asList(1));
graph.put(4, Arrays.asList(1));
graph.put(5, Arrays.asList(2));
graph.put(6, Arrays.asList(2));
// DFS 시작 (노드 1부터 탐색 시작)
dfsStack(1, graph); // 출력: 1 2 5 6 3 4
}
}
재귀 호출 방식과 스택 방식에서의 탐색 순서가 어떻게 달라질 수 있는지 예시로 설명하면 다음과 같습니다:
재귀 호출 방식:
1 → 2 → 5 → 6 → 3 → 4
스택 방식 (기본 순서로 인접 노드 추가):
1 → 4 → 3 → 2 → 6 → 5
스택 방식 (인접 노드를 역순으로 추가):
1 → 2 → 5 → 6 → 3 → 4
DFS의 시간 복잡도는 O(V + E)입니다. 여기서:
DFS는 모든 노드를 방문하고, 각 노드에서 연결된 모든 간선을 확인하므로 V + E
에 비례하는 시간이 소요됩니다.
장점:
한계:
너비 우선 탐색(BFS)은 트리나 그래프에서 가까운 노드부터 차례대로 탐색하는 알고리즘입니다.
BFS의 주요 특징:
BFS는 큐를 사용하여 노드를 탐색합니다. 다음은 BFS의 주요 단계입니다:
다음 그래프에서 BFS를 사용하여 노드 1에서 탐색을 시작하는 과정을 살펴보겠습니다.
BFS 탐색 순서 : 1 -> 2 -> 3 -> 4 -> 5 -> 6
BFS를 구현한 코드 (Python):
from collections import deque
def bfs(graph, start):
visited = [False] * (max(graph.keys()) + 1) # 최대 노드 번호에 맞게 visited 리스트 크기 설정
queue = deque([start]) # 큐에 시작 노드를 넣음
visited[start] = True # 시작 노드를 방문 처리
while queue:
node = queue.popleft() # 큐에서 노드를 꺼냄
print(node, end=' ')
# 현재 노드와 연결된, 아직 방문하지 않은 모든 노드를 큐에 추가
for neighbor in graph[node]:
if not visited[neighbor]:
queue.append(neighbor)
visited[neighbor] = True # 방문 처리
# 테스트용 그래프
graph = {
1: [2, 3, 4],
2: [1, 5, 6],
3: [1],
4: [1],
5: [2],
6: [2]
}
print()
# BFS 시작 (노드 1부터 탐색 시작)
bfs(graph, 1) # 출력: 1 2 3 4 5 6
BFS를 구현한 코드 (Java):
import java.util.*;
public class BFSExample {
public static void bfs(int start, Map<Integer, List<Integer>> graph) {
boolean[] visited = new boolean[graph.size() + 1]; // 방문한 노드를 기록할 배열
Queue<Integer> queue = new LinkedList<>();
queue.offer(start); // 큐에 시작 노드를 넣음
visited[start] = true; // 시작 노드를 방문 처리
while (!queue.isEmpty()) {
int node = queue.poll(); // 큐에서 노드를 꺼냄
System.out.print(node + " ");
// 현재 노드와 연결된, 아직 방문하지 않은 모든 노드를 큐에 추가
for (int neighbor : graph.get(node)) {
if (!visited[neighbor]) {
queue.offer(neighbor);
visited[neighbor] = true; // 방문 처리
}
}
}
}
public static void main(String[] args) {
// 그래프를 인접 리스트로 표현 (1-based index)
Map<Integer, List<Integer>> graph = new HashMap<>();
graph.put(1, Arrays.asList(2, 3, 4));
graph.put(2, Arrays.asList(1, 5, 6));
graph.put(3, Arrays.asList(1));
graph.put(4, Arrays.asList(1));
graph.put(5, Arrays.asList(2));
graph.put(6, Arrays.asList(2));
// BFS 시작 (노드 1부터 탐색 시작)
bfs(1, graph); // 출력: 1 2 3 4 5 6
}
}
BFS의 시간 복잡도는 O(V + E)입니다. 여기서:
BFS는 모든 노드를 방문하고, 각 노드에서 연결된 모든 간선을 확인하므로 V + E
에 비례하는 시간이 소요됩니다. 이는 DFS의 시간 복잡도와 동일합니다.
장점:
한계:
DFS와 BFS는 모두 그래프나 트리 구조에서 자주 사용하는 탐색 알고리즘이지만, 탐색 방식과 응용 분야에서 차이가 있습니다.
이 파트에서는 두 알고리즘을 비교하여 그 차이점과 사용 시 유리한 상황을 살펴보겠습니다.
알고리즘 | 탐색 방식 | 사용 자료구조 | 탐색 순서 | 최단 경로 보장 |
---|---|---|---|---|
DFS | 깊이 우선 탐색 | 스택 (또는 재귀 호출) | 한 경로를 끝까지 탐색 | 최단 경로를 보장하지 않음 |
BFS | 너비 우선 탐색 | 큐 (FIFO) | 가까운 노드부터 차례로 탐색 | 최단 경로를 보장 (가중치가 동일할 경우) |
DFS는 탐색 깊이를 우선시하며, 한 경로를 끝까지 탐색한 후 다른 경로를 탐색하는 방식입니다.
BFS는 탐색 범위를 넓게하며, 시작점에서 가까운 노드부터 차례대로 탐색하는 방식입니다.
DFS와 BFS 모두 시간 복잡도는 O(V + E)로 동일합니다.
각 노드를 방문하고, 각 노드에 연결된 모든 간선을 확인하는 데 걸리는 시간이 탐색 시간에 영향을 미칩니다.
알고리즘 | 메모리 사용 | 특성 |
---|---|---|
DFS | 비교적 적음 (재귀 깊이에 비례) | 트리나 그래프가 매우 깊을 경우 스택 오버플로우가 발생할 수 있음 |
BFS | 비교적 큼 (큐 크기에 비례) | 트리나 그래프가 매우 넓을 경우 큐가 커져 메모리 사용량 증가 |
알고리즘 | 주요 응용 분야 |
---|---|
DFS | 경로 탐색 (미로 찾기, 사이클 탐지) 위상 정렬 강한 연결 요소 |
BFS | 최단 경로 탐색 (미로 탐색, 네트워크 경로 탐색) 최소 신장 트리 (가중치가 동일할 때) 그래프 레벨 탐색 |
DFS가 유리한 상황:
BFS가 유리한 상황:
이번 포스팅에서는 DFS(깊이 우선 탐색)과 BFS(너비 우선 탐색)의 개념과 그 차이점을 살펴보았습니다.
다음 포스팅에서는 백트래킹(Backtracking)에 대해 다루며, 어떻게 탐색 과정에서 불필요한 경로를 제거하고 효율적으로 문제를 해결할 수 있는지 설명할 예정입니다.