Ray Book
자료구조와 알고리즘

그래프와 탐색

노드와 간선으로 연결된 세계, BFS, DFS, 그래프 표현 방법을 시각화합니다

csdata-structuregraphbfsdfstraversal

그래프란

트리는 계층 구조를 표현하는 자료구조였습니다. 하지만 현실의 많은 관계는 계층이 아닌 네트워크 형태입니다. 소셜 네트워크의 친구 관계, 도시 간 도로, 웹 페이지 간의 하이퍼링크가 대표적입니다.

**그래프 (Graph)**는 **노드 (정점, Vertex)**와 **간선 (Edge)**으로 구성된 자료구조입니다. 트리도 그래프의 특수한 형태 (사이클이 없는 연결 그래프)입니다.

그래프의 종류

**방향 그래프 (Directed Graph)**는 간선에 방향이 있습니다. 트위터의 팔로우 관계가 대표적입니다. A가 B를 팔로우해도 B가 A를 팔로우하지 않을 수 있습니다.

방향 그래프:           무방향 그래프:
A --> B               A --- B
      |                     |
C --> D               C --- D

**무방향 그래프 (Undirected Graph)**는 간선에 방향이 없습니다. 페이스북의 친구 관계가 대표적입니다. A와 B가 친구이면 양방향입니다.

**가중 그래프 (Weighted Graph)**는 간선에 가중치 (비용, 거리 등)가 있습니다. 도시 간 도로의 거리, 네트워크 대역폭 등을 표현합니다.

가중 그래프:

A --5-- B
        |
A --3-- C --4-- D --2-- B

핵심 용어

  • 정점 (Vertex) : 그래프의 노드
  • 간선 (Edge) : 두 정점을 연결하는 선
  • 인접 (Adjacent) : 간선으로 직접 연결된 두 정점
  • 차수 (Degree) : 한 정점에 연결된 간선의 수
  • 경로 (Path) : 정점의 나열로, 연속된 정점이 간선으로 연결
  • 사이클 (Cycle) : 시작 정점과 끝 정점이 같은 경로

그래프 표현

그래프를 코드로 표현하는 방법은 크게 두 가지입니다.

인접 리스트 (Adjacency List)

각 정점에 대해 연결된 정점의 리스트를 저장합니다. 공간 효율적 이고, 대부분의 실무 그래프에서 사용합니다.

// 인접 리스트 - 객체(해시맵) 사용
const graph = {
  A: ["B", "C"],
  B: ["A", "D", "E"],
  C: ["A", "E", "F"],
  D: ["B"],
  E: ["B", "C", "G"],
  F: ["C"],
  G: ["E"],
};

// Map 사용 (더 안전한 방식)
const graphMap = new Map([
  ["A", ["B", "C"]],
  ["B", ["A", "D", "E"]],
  ["C", ["A", "E", "F"]],
  ["D", ["B"]],
  ["E", ["B", "C", "G"]],
  ["F", ["C"]],
  ["G", ["E"]],
]);

인접 행렬 (Adjacency Matrix)

정점 간 연결을 2차원 배열로 표현합니다. matrix[i][j] = 1이면 정점 i와 j가 연결되어 있습니다.

// 인접 행렬 (A=0, B=1, C=2, D=3)
//       A  B  C  D
const matrix = [
  [0, 1, 1, 0], // A
  [1, 0, 0, 1], // B
  [1, 0, 0, 1], // C
  [0, 1, 1, 0], // D
];

// matrix[0][1] === 1 → A와 B는 연결됨
// matrix[0][3] === 0 → A와 D는 연결 안 됨

비교

특성인접 리스트인접 행렬
공간 복잡도O(V + E)O(V^2)
간선 존재 확인O(degree)O(1)
모든 이웃 탐색O(degree)O(V)
간선 추가O(1)O(1)
적합한 경우희소 그래프 (간선 적음)밀집 그래프 (간선 많음)
실무 사용 빈도높음낮음

대부분의 실무 그래프는 희소 그래프 (정점 수에 비해 간선이 적은)이므로 인접 리스트가 표준입니다. 소셜 네트워크를 예로 들면, 사용자가 10억 명이어도 한 사람의 친구는 수백~수천 명에 불과합니다. 인접 행렬로 표현하면 10억 x 10억 = 10^18 크기의 배열이 필요하지만, 인접 리스트는 실제 간선 수만큼만 공간을 사용합니다.

BFS (너비 우선 탐색)

BFS (Breadth-First Search)는 시작 정점에서 가까운 정점부터 탐색하는 알고리즘입니다. **큐 (Queue)**를 사용하며, 레벨별로 탐색합니다.

의사코드

BFS(graph, start):
    queue = [start]
    visited = {start}

    while queue is not empty:
        node = queue.dequeue()        // 큐 앞에서 꺼냄 (FIFO)
        process(node)

        for each neighbor of node:
            if neighbor not in visited:
                visited.add(neighbor)
                queue.enqueue(neighbor) // 큐 뒤에 추가

핵심: 큐의 FIFO (First-In, First-Out) 특성 덕분에, 먼저 발견된 (가까운) 노드가 먼저 처리됩니다.

JavaScript 구현

function bfs(graph, start) {
  const visited = new Set([start]);
  const queue = [start];
  const order = [];

  while (queue.length > 0) {
    const node = queue.shift(); // 큐 앞에서 꺼냄
    order.push(node);

    for (const neighbor of graph[node]) {
      if (!visited.has(neighbor)) {
        visited.add(neighbor);
        queue.push(neighbor); // 큐 뒤에 추가
      }
    }
  }

  return order;
}

const graph = {
  A: ["B", "C"],
  B: ["A", "D", "E"],
  C: ["A", "E", "F"],
  D: ["B"],
  E: ["B", "C", "G"],
  F: ["C"],
  G: ["E"],
};

console.log(bfs(graph, "A"));
// ["A", "B", "C", "D", "E", "F", "G"]

시간 복잡도는 O(V + E)입니다. 모든 정점을 한 번씩 방문하고 (V), 모든 간선을 한 번씩 확인합니다 (E).

참고 : Array.shift()는 O(n)이므로 대규모 그래프에서는 별도의 큐 자료구조를 사용하는 것이 좋습니다. 이전 글의 스택과 큐 구현을 활용할 수 있습니다.

BFS 시각화

아래 시각화에서 BFS가 큐를 사용해 레벨별로 탐색하는 과정을 확인하세요. 큐 상태와 방문 순서에 주목합니다.

초기 상태1 / 9
A
B
C
D
E
F
G
:
A
현재 노드방문 완료대기 중
7개의 노드와 7개의 간선으로 구성된 무방향 그래프입니다. 노드 A에서 BFS를 시작합니다. 큐에 시작 노드를 넣습니다.

DFS (깊이 우선 탐색)

DFS (Depth-First Search)는 한 경로를 끝까지 탐색한 후 되돌아오는 알고리즘입니다. 스택 (Stack) 또는 재귀를 사용합니다.

의사코드

DFS-Stack(graph, start):
    stack = [start]
    visited = {}

    while stack is not empty:
        node = stack.pop()            // 스택 위에서 꺼냄 (LIFO)
        if node in visited: continue
        visited.add(node)
        process(node)

        for each neighbor of node:
            if neighbor not in visited:
                stack.push(neighbor)  // 스택 위에 추가

DFS-Recursive(graph, node, visited):
    visited.add(node)
    process(node)

    for each neighbor of node:
        if neighbor not in visited:
            DFS-Recursive(graph, neighbor, visited)

핵심: 스택의 LIFO (Last-In, First-Out) 특성 때문에, 가장 최근에 발견된 (깊은) 노드가 먼저 처리됩니다. 재귀 방식은 콜 스택이 암묵적으로 스택 역할을 합니다.

JavaScript 구현

// 스택 기반 DFS
function dfsStack(graph, start) {
  const visited = new Set();
  const stack = [start];
  const order = [];

  while (stack.length > 0) {
    const node = stack.pop(); // 스택 위에서 꺼냄
    if (visited.has(node)) continue;

    visited.add(node);
    order.push(node);

    // 이웃을 역순으로 추가 (원래 순서대로 탐색하려면)
    for (const neighbor of [...graph[node]].reverse()) {
      if (!visited.has(neighbor)) {
        stack.push(neighbor);
      }
    }
  }

  return order;
}

// 재귀 기반 DFS
function dfsRecursive(graph, start, visited = new Set(), order = []) {
  visited.add(start);
  order.push(start);

  for (const neighbor of graph[start]) {
    if (!visited.has(neighbor)) {
      dfsRecursive(graph, neighbor, visited, order);
    }
  }

  return order;
}

const graph = {
  A: ["B", "C"],
  B: ["A", "D", "E"],
  C: ["A", "E", "F"],
  D: ["B"],
  E: ["B", "C", "G"],
  F: ["C"],
  G: ["E"],
};

console.log(dfsStack(graph, "A"));
// ["A", "B", "D", "E", "C", "F", "G"]

console.log(dfsRecursive(graph, "A"));
// ["A", "B", "D", "E", "C", "F", "G"]

시간 복잡도는 BFS와 동일하게 O(V + E)입니다.

참고 : 재귀 DFS와 스택 DFS의 방문 순서가 다를 수 있습니다. 이웃을 처리하는 순서가 다르기 때문입니다. 둘 다 정당한 DFS입니다.

DFS 시각화

아래 시각화에서 DFS가 스택을 사용해 한 경로를 끝까지 파고드는 과정을 확인하세요. BFS와 방문 순서가 어떻게 다른지 비교해 봅니다.

초기 상태1 / 9
A
B
C
D
E
F
G
스택:
A
현재 노드방문 완료대기 중
같은 그래프에서 이번에는 DFS를 시작합니다. 노드 A에서 출발합니다. 스택에 시작 노드를 넣습니다.

BFS vs DFS 비교

특성BFSDFS
자료구조큐 (FIFO)스택/재귀 (LIFO)
탐색 방식레벨별 (넓게)경로별 (깊게)
시간 복잡도O(V + E)O(V + E)
공간 복잡도O(V), 최악의 경우 한 레벨의 모든 노드O(V), 최악의 경우 경로 길이만큼
최단 경로 보장가중치 없는 그래프에서 보장보장하지 않음
적합한 경우최단 경로, 레벨별 탐색경로 존재 여부, 사이클 감지, 위상 정렬

언제 BFS를 쓰는가

  • 최단 경로 : 미로에서 출구까지 최단 거리를 찾을 때
  • 레벨별 탐색 : 소셜 네트워크에서 1촌, 2촌, 3촌을 구분할 때
  • 가까운 노드 우선 : 게임에서 주변 범위 내 적을 탐색할 때

언제 DFS를 쓰는가

  • 경로 존재 여부 : 두 노드 사이에 경로가 있는지 확인할 때
  • 사이클 감지 : 의존성 그래프에서 순환 참조를 찾을 때
  • 위상 정렬 : 작업 순서를 결정할 때 (빌드 시스템, 과목 선수 관계)
  • 미로 생성 : 무작위 DFS로 미로를 만들 때

실무 연결

npm 의존성 그래프

npm install을 실행하면 npm은 의존성 그래프를 탐색합니다. 패키지 A가 B와 C에 의존하고, B가 D에 의존하면:

my-app
  react (B)
    loose-envify (D)
    scheduler (E)
  next (C)
    react (B)    <-- 이미 방문! 중복 설치 방지
    postcss (F)

npm은 BFS/DFS를 사용해 모든 의존성을 탐색하고, 중복을 감지해 node_modules를 최적화합니다. npm ls 명령어로 이 그래프를 직접 확인할 수 있습니다.

웹 크롤링

검색 엔진의 크롤러는 웹 페이지를 그래프로 봅니다. 각 페이지가 노드, 하이퍼링크가 간선입니다. BFS를 사용하면 시작 페이지에서 가까운 페이지부터 크롤링합니다. 중요한 페이지일수록 많은 링크로 연결되어 있어 빨리 발견됩니다.

소셜 네트워크 추천

"알 수도 있는 친구" 기능은 BFS로 구현할 수 있습니다. 현재 사용자에서 BFS를 2~3단계 수행하면, 직접 친구가 아닌 친구의 친구를 찾을 수 있습니다. 공통 친구가 많을수록 추천 점수가 높아집니다.

function suggestFriends(graph, user) {
  const friends = new Set(graph[user]);
  const suggestions = new Map(); // 후보 -> 공통 친구 수
  const visited = new Set([user, ...friends]);
  const queue = [...friends];

  while (queue.length > 0) {
    const friend = queue.shift();
    for (const fof of graph[friend]) {
      if (!visited.has(fof)) {
        visited.add(fof);
        const mutual = graph[fof].filter((n) => friends.has(n)).length;
        suggestions.set(fof, mutual);
      }
    }
  }

  // 공통 친구 수가 많은 순서로 정렬
  return [...suggestions.entries()]
    .sort((a, b) => b[1] - a[1])
    .map(([user, count]) => ({ user, mutualFriends: count }));
}

다음 단계

그래프 탐색은 자료구조의 "활용" 단계입니다. 배열에서 시작해 링크드 리스트, 스택/큐, 해시맵, 트리, 그래프까지 자료구조의 핵심을 모두 다뤘습니다.

다음 글에서는 데이터를 정렬하는 알고리즘을 다룹니다. 버블 정렬부터 퀵 정렬까지, 각 알고리즘이 데이터를 어떻게 움직이는지 시각화합니다.