그래프(Graph)란 무엇일까?

그래프는 정점(Vertex)와 간선(Edge)으로 구성된 자료구조이다.

 

  • 정점(Vertex) : 그래프의 구성요소로, 사람이나 도시처럼 개별 요소를 나타낸다.
  • 간선(Edge) : 정점들을 연결하는 선이다. 두 정점 사이의 관계(예:도로, 친구 관계)를 나타낸다.
  • 예시 : 학교에서 여러 학생(정점)들이 친구 관계(관선)를 맺고 있는 것을 그래프로 생각할 수 있다.

 

그래프의 표현 방법

그래프를 컴퓨터에서 표현하는 방법에는 여러 가지가 있다. 가장 일반적인 두 가지 방법은 인접 리스트인접 행렬이다.

  • 인접 리스트(Adjacency List) : 각 정점에 연결된 정접들의 리스트를 저장한다.

       예시

0: [1, 2]
1: [0, 3]
2: [0, 3]
3: [1, 2]

 

  • 인접 행렬(Adjacency Matrix) : 정점 간의 연결 여부를 2차원 배열 형태로 저장한다.

        예시

matrix[0][1] = 1

 

 

현실 세계에서의 그래프 예시로는 

  • 학교 네트워크 : 학생들을 정점으로 하고, 친구 관계를 간선으로 표현
  • 지도와 도로 : 도시를 정점, 도로를 간선으로 표현하여 경로 탐색 문제 해결
  • 인터넷 : 웹 페이지를 정점으로 하고, 하이퍼링크를 간선으로 표현하여 검색 알고리즘 등에 응용

등이 있다.

 

 

 

깊이 우선 탐색(DFS, Depth First Search)

1. DFS 의 기본 원리

DFS는 시작 정점에서 출발하여 한 방향으로 끝까지 탐색한 후, 더 이상 진행할 수 없으면 마지막 분기점으로 돌아가 다른 경로를 탐색하는 방식이다. 이러한 과정은 재귀 호출을 통해 자연스럽게 구현된다.

 

2. DFS의 동작 방식과 재귀 호출

동작 방식 :

  1. 시작 정점을 방문하고, 방문 목록에 추가한다.
  2. 현재 정점에서 인접한 정점 중 아직 방문하지 않은 정점을 선택한다.
  3. 선택한 정점으로 이동하여 같은 과정을 반복한다.
  4. 더 이상 방문할 정점이 없으면, 이전 정점으로 되돌아가 (Backtracking) 다른 경로를 탐색한다.

재귀 호출 : DFS는 재귀적으로 자신을 호출하면서, 깊은 경로를 먼저 탐색한다.

 

 

3. DFS의 장단점

 

장점 :

  • 구현이 간단하고 직관적이다.
  • 경로 탐색, 사이클 검출 등 다양한 문제에 응용할 수 있다.

단점 : 

  • 재귀 호출로 인해 스택 오버플로우가 발생할 수 있다.
  • 최악의 경우 모든 정점을 방문하므로, 시간 복잡도가 높을 수 있다.

 

4. DFS 구현 예제(Kotlin)

// DFS를 구현하기 위해, 방문한 정점을 기록할 집합과 재귀 함수를 사용합니다.
fun dfs(node: Int, visited: MutableSet<Int>, graph: Map<Int, List<Int>>) {
    // 현재 정점을 방문 목록에 추가하고, 출력합니다.
    visited.add(node)
    println("DFS 방문: $node")

    // 현재 정점과 연결된 모든 정점을 순회합니다.
    for (neighbor in graph[node] ?: emptyList()) {
        // 아직 방문하지 않은 정점이면 재귀 호출합니다.
        if (!visited.contains(neighbor)) {
            dfs(neighbor, visited, graph)
        }
    }
}

fun mainDFS() {
    // 간단한 무방향 그래프를 인접 리스트로 표현합니다.
    val graph: Map<Int, List<Int>> = mapOf(
        0 to listOf(1, 2), // 0번 정점은 1번,2번 정점에 간선을 갖는다.
        1 to listOf(0, 3),
        2 to listOf(0, 3),
        3 to listOf(1, 2)
    )
    
    val visited = mutableSetOf<Int>()
    println("=== DFS 탐색 결과 ===")
    dfs(0, visited, graph)
}

 

위에 코드에서 인접 리스트로 표현된 그래프를 실제로 살펴보면

인접리스트 예시

위 그림에서 방향성을 나타내는 화살표를 빼고 안쪽에 있는 간선 3개를 빼면 위의 코드의 그래프이다.

 

해당 코드를 실행해보면 시작 정점 0에서 시작하여 0 > 1 > 3> 2 순으로 방문을 진행하고, 각 단계에서 재귀 호출이 이루어지고, 되돌아가며 다른 경로를 탐색하는 모습은 미로에서 한 길로 쭉 들어갔따가 다시 돌아가는 것과 비슷하다.

 

 

 

 

너비 우선 탐색(BFS, Breadth First Search)

1. BFS의 기본 원리

BFS는 시작 정점에서부터 인접한 모든 정점을 먼저 방문하고, 그 다음 단계의 정점을 방문하는 방식이다. 즉, "가까운 것부터 넓게" 탐색하는 방법이다.

 

 

2. BFS의 동작 방식과 큐의 역할

동작 방식 :

  1. 시작 정점을 방문하고, 큐에 추가한다.
  2. 큐에서 정점을 꺼내 그 정점과 인접한 모든 정점을 방문한다.
  3. 방문한 정점을 큐에 추가하면서, 순차적으로 탐색한다.

큐의 역할 :  BFS에서는 먼저 들어온 정점을 먼저 처리하는 FIFO(선입선출) 자료구조인 큐를 사용한다.

 

 

3. BFS의 장단점

장점 : 

  • 최단 경로를 찾는 데 효과적이다.
  • 큐를 사용하므로 DFS보다 스택 오버플로우 위험이 적다.

단점 : 

  • 모든 인접 정점을 저장해야 하므로 메모리 사용량이 많아질 수 있다.
  • 그래프의 모든 정점을 한 번씩 방문하므로, 큰 그래프에서는 시간이 오래 걸릴 수 있다.

 

4. BFS 구현 예제(Kotlin)

import java.util.LinkedList
import java.util.Queue

fun bfs(start: Int, graph: Map<Int, List<Int>>) {
    val visited = mutableSetOf<Int>()
    val queue: Queue<Int> = LinkedList()

    // 시작 정점을 방문 처리하고 큐에 추가합니다.
    visited.add(start)
    queue.offer(start)

    while (queue.isNotEmpty()) {
        val node = queue.poll()
        println("BFS 방문: $node")
        
        // 현재 정점과 연결된 모든 정점을 순회하며 방문하지 않은 정점을 큐에 추가합니다.
        for (neighbor in graph[node] ?: emptyList()) {
            if (!visited.contains(neighbor)) {
                visited.add(neighbor)
                queue.offer(neighbor)
            }
        }
    }
}

fun mainBFS() {
    // 간단한 무방향 그래프를 인접 리스트로 표현합니다.
    val graph: Map<Int, List<Int>> = mapOf(
        0 to listOf(1, 2),
        1 to listOf(0, 3),
        2 to listOf(0, 3),
        3 to listOf(1, 2)
    )

    println("=== BFS 탐색 결과 ===")
    bfs(0, graph)
}

 

코드의 실행에 대해서 생각해보면 시작 정점 0에서 시작하여, 먼저 0에 인접한 1과 2를 방문하고, 그 후 1과 2의 인접 정점(3 등)을 방문하는 순서로 진행된다.

 

 

 

DFS와 BFS의 비교

1. 탐색 방식의 차이

  • DFS : 한 방향으로 깊게 들어간 후, 더 이상 진행할 수 없으면 되돌아가는 방식 -> "깊이 우선"으로 탐색한다.
  • BFS : 시작 정점에서 가까운 정점을 모두 방문한 후, 그 다음 레벨로 넘어가는 방식 -> "너비 우선"으로 탐색한다.

 

2. 시간 복잡도와 메모리 사용 비교

  • 시간 복잡도 : 두 알고리즘 모두 그래프의 정점 수(V)와 간선 수 (E)에 따라 O(V+E)의 시간 복잡도를 가진다.
  • 메모리 사용 : DFS는 재귀 호출(또는 스택)을 사용하므로 깊이에 따라 메모리 사용량이 결정된다.                                                                         / BFS는 큐에 모든 인접 정점을 저장하므로, 그래프가 넓을 경우 메모리 사용량이 증가할 수 있다.

 

언제 각각 사용해야 할까?

  • DFS 사용 경우 : 경로 깊이가 중요한 경우 / 모든 경로를 탐색해야 하는 문제(예: 순열, 조합, 백트래킹 문제)
  • BFS 사용 경우 : 최단 경로 문제 / 너비 우선 탐색을 통해 빠르게 탐색할 수 있는 경우

 

위의 DFS와 BFS는 문제를 더 풀어보면서 Kotlin 을 연습해봐야겠다.

 

+ Recent posts