본문으로 건너뛰기

가중치와 순서와 연결성 - 이름과 비용: 알고리즘 ep.04

같은 그래프라도 묻는 것이 달라지면 쓰는 도구가 달라집니다.


조건이 도구를 정합니다

ep.01 문제를 그래프로 바꿔 보기에서 본 너비 우선 탐색은 간선 비용이 모두 같을 때만 최단 거리를 보장합니다. 비용이 다르면 다른 도구가 필요합니다.

문제를 읽을 때 확인할 신호가 몇 가지 있습니다.

그래프 문제의 신호에서 알고리즘을 고르는 선택 트리 다이어그램. 최상단 노드는 무엇을 묻는가라는 질문임. 첫 분기는 세 갈래로 나뉨. 왼쪽 갈래는 최단 경로로, 그 아래에서 간선 비용이 모두 같은가라는 질문에 예이면 너비 우선 탐색, 아니오이면 음수 간선이 있는가로 이어짐. 음수 간선이 없으면 다익스트라, 있으면 벨만포드로 연결됨. 그 아래 별도로 모든 쌍의 거리가 필요한가라는 질문에서 플로이드워셜로 이어지는 가지가 있음. 가운데 갈래는 연결 관계로, 이어져 있는지만 알면 되는가에서 유니온 파인드, 최소 비용으로 전부 잇는가에서 최소 신장 트리로 나뉨. 오른쪽 갈래는 순서 제약으로, 선행 관계를 만족하는 순서가 필요한가에서 위상정렬로 연결됨. 각 알고리즘 노드 아래에 시간 복잡도가 작은 글씨로 표기되며, 너비 우선 탐색과 위상정렬은 O(V + E), 다익스트라는 O(E log V), 벨만포드는 O(V × E), 플로이드워셜은 O(V³), 유니온 파인드는 경로 압축으로 O(α(V)), 최소 신장 트리는 O(E log V)임. 모든 상자가 이름 한 줄과 O 표기 한 줄로 같은 형식임. 다이어그램 제목은 「그래프라는 것을 알아도, 무엇을 묻는지가 남습니다」, 부제는 「자료구조는 하나인데 알고리즘은 여럿입니다. 질문이 갈래를 정합니다」임. 연결 관계 갈래 아래에는 「α는 역아커만 함수로, 사실상 상수로 봅니다」와 「크루스칼은 유니온 파인드를 그대로 씁니다」, 위상정렬 노드에는 「사이클이 있으면 순서가 존재하지 않습니다. 그래서 사이클 판별에도 씁니다」라는 주석이 붙어 있음. 공통 전제 상자에는 V는 정점 수, E는 간선 수이며 간선이 빽빽하면 인접 행렬이, 성기면 인접 리스트가 유리하다고 적혀 있음. 하단 정리는 「먼저 무엇을 묻는지 정하고, 그다음 간선의 성질을 봅니다. 순서가 바뀌면 매번 다익스트라를 꺼내게 됩니다」임.


다익스트라는 확정된 정점을 늘려갑니다

다익스트라 알고리즘(Dijkstra’s algorithm) 은 시작점에서 각 정점까지의 최단 거리를 구합니다.

아직 확정되지 않은 정점 중 현재까지 알려진 거리가 가장 짧은 것을 꺼내 확정합니다. 그 정점을 거쳐 가면 더 짧아지는 이웃이 있으면 거리를 갱신합니다. 이 과정을 반복합니다.

거리가 가장 짧은 정점을 매번 꺼내야 하므로 우선순위 큐가 필요합니다. 자료구조 시리즈 ep.04 트리의 모양이 성능을 만든다에서 만든 최소 힙을 그대로 씁니다.

function dijkstra(graph, start, n) {
  const dist = new Array(n).fill(Infinity);
  dist[start] = 0;

  const pq = new MinHeap((a, b) => a.cost - b.cost);
  pq.push({ node: start, cost: 0 });

  while (pq.size > 0) {
    const { node, cost } = pq.pop();
    if (cost > dist[node]) continue; // 이미 더 좋은 경로로 처리된 항목

    for (const { to, weight } of graph[node]) {
      const next = cost + weight;
      if (next < dist[to]) {
        dist[to] = next;
        pq.push({ node: to, cost: next });
      }
    }
  }
  return dist;
}

cost > dist[node]로 걸러내는 줄이 중요합니다. 같은 정점이 큐에 여러 번 들어갈 수 있는데, 이미 더 짧은 경로로 확정된 항목은 버려야 합니다. 이 검사를 빠뜨리면 불필요한 갱신이 반복됩니다.

복잡도는 우선순위 큐를 쓸 때 O((V + E) log V)입니다.

다익스트라가 정점을 하나씩 확정하며 이웃 거리를 갱신하는 과정을 네 단계로 보여주는 다이어그램. 정점 A, B, C, D가 사각형 꼴로 놓이고 간선은 A-B가 1, A-C가 4, B-C가 2, B-D가 6, C-D가 3임. 각 노드 아래에 현재까지 알려진 거리가 적혀 있고 확정된 정점은 초록, 이번에 꺼낸 정점은 주황으로 표시됨. STEP 1에서 A를 꺼내 확정하고 B는 1, C는 4로 갱신함. STEP 2에서 가장 짧은 B를 확정하고 C가 1 더하기 2인 3으로 줄고 D는 7이 됨. STEP 3에서 C를 확정하고 D가 3 더하기 3인 6으로 줄어듦. STEP 4에서 D를 확정해 모든 거리가 정해짐. 다이어그램 제목은 「가장 가까운 것부터 확정하고, 거쳐 가면 짧아지는 이웃만 갱신합니다」, 부제는 「정점 A B C D · 간선 A-B 1, A-C 4, B-C 2, B-D 6, C-D 3 · 시작은 A」이고 하단에 「한 번 확정한 정점은 다시 보지 않습니다. 간선 비용이 음수가 아니어야 이 결정이 안전합니다」, 「C의 거리가 4에서 3으로 줄어든 것처럼, 직접 가는 길보다 다른 정점을 거치는 길이 짧을 수 있습니다」라고 적혀 있음.

지도 앱의 경로 안내가 이 계산입니다. 네트워크 장비도 같은 알고리즘을 쓰는데, OSPF는 각 링크의 비용을 간선 가중치로 두고 최단 경로를 구합니다.


음수 간선이 있으면 전제가 무너집니다

다익스트라는 한 번 확정한 정점을 다시 보지 않습니다. 이 결정이 안전한 이유는 간선 비용이 음수가 아니기 때문입니다. 앞으로 어떤 경로를 더해도 거리가 줄어들 수 없으니, 지금 가장 짧은 것이 최종적으로도 가장 짧습니다.

음수 간선이 있으면 이 논리가 깨집니다. 멀리 돌아가는 경로에 큰 음수 간선이 있으면 나중에 더 짧아질 수 있습니다.

벨만-포드 알고리즘(Bellman-Ford algorithm) 은 모든 간선을 정점 수만큼 반복하며 갱신해 이 경우를 처리합니다. 느리지만 음수 간선을 다루고, 갱신이 계속 일어나면 음수 사이클이 있다는 판정까지 할 수 있습니다. 복잡도는 O(V × E)입니다.

function bellmanFord(edges, start, n) {
  const dist = new Array(n).fill(Infinity);
  dist[start] = 0;

  // 정점 수 - 1번이면 모든 최단 경로가 확정된다
  for (let i = 0; i < n - 1; i++) {
    for (const { u, v, weight } of edges) {
      if (dist[u] === Infinity) continue;
      if (dist[u] + weight < dist[v]) dist[v] = dist[u] + weight;
    }
  }

  // 한 번 더 돌려서 갱신되면 음수 사이클이 있다
  for (const { u, v, weight } of edges) {
    if (dist[u] !== Infinity && dist[u] + weight < dist[v]) return null;
  }
  return dist;
}

반복 횟수가 정점 수 - 1인 이유는 최단 경로가 지나는 간선이 많아야 그만큼이기 때문입니다. 그보다 더 줄어든다면 같은 정점을 다시 지났다는 뜻이고, 그것이 음수 사이클입니다.

플로이드-워셜 알고리즘(Floyd-Warshall algorithm) 은 모든 정점 쌍 사이의 최단 거리를 한 번에 구합니다. 삼중 반복문 하나로 끝나 구현이 짧습니다. 대신 O(V³)이라 정점이 수백 개 이하일 때만 씁니다. 정점 500개면 삼중 반복문이 1억 2,500만 번이라 자료구조 시리즈 ep.01 크기가 접근법을 정한다의 기준선에 걸립니다.

// 거쳐 가는 정점 k를 가장 바깥에 두는 것이 핵심
for (let k = 0; k < n; k++)
  for (let i = 0; i < n; i++)
    for (let j = 0; j < n; j++)
      dist[i][j] = Math.min(dist[i][j], dist[i][k] + dist[k][j]);

반복문 순서가 바뀌면 틀립니다. k가 바깥에 있어야 “k번까지의 정점만 거쳐 가는 최단 거리”라는 단계적 확장이 성립합니다.

네 가지를 조건별로 정리하면 이렇습니다.

알고리즘쓰는 조건구하는 것복잡도
너비 우선 탐색간선 비용이 모두 같음한 시작점에서의 최단 거리O(V + E)
다익스트라음수 간선 없음한 시작점에서의 최단 거리O((V + E) log V)
벨만-포드음수 간선 있음, 음수 사이클 판정한 시작점에서의 최단 거리O(V × E)
플로이드-워셜정점 수백 개 이하모든 쌍의 최단 거리O(V³)

연결 여부만 필요하면 거리는 계산하지 않습니다

두 정점이 이어져 있는지만 알면 되는 문제가 있습니다. 경로도 거리도 필요 없습니다.

유니온 파인드(union-find) 는 원소들을 그룹으로 묶고 두 원소가 같은 그룹인지 확인하는 데 특화된 구조입니다.

class UnionFind {
  #parent;
  #rank;

  constructor(n) {
    this.#parent = Array.from({ length: n }, (_, i) => i);
    this.#rank = new Array(n).fill(0);
  }

  find(x) {
    if (this.#parent[x] !== x) {
      this.#parent[x] = this.find(this.#parent[x]); // 경로 압축
    }
    return this.#parent[x];
  }

  union(a, b) {
    const rootA = this.find(a);
    const rootB = this.find(b);
    if (rootA === rootB) return false; // 이미 같은 그룹

    // 낮은 트리를 높은 트리 아래에 붙인다
    if (this.#rank[rootA] < this.#rank[rootB]) {
      this.#parent[rootA] = rootB;
    } else if (this.#rank[rootA] > this.#rank[rootB]) {
      this.#parent[rootB] = rootA;
    } else {
      this.#parent[rootB] = rootA;
      this.#rank[rootA]++;
    }
    return true;
  }
}

두 가지 최적화가 들어가 있습니다. 경로 압축(path compression) 은 루트를 찾는 김에 거쳐 온 노드들을 루트에 직접 붙입니다. 랭크 기반 합치기(union by rank) 는 낮은 트리를 높은 트리 아래에 붙여 높이가 커지는 것을 막습니다.

유니온 파인드에서 경로 압축 전후의 트리 모양을 비교한 다이어그램. 왼쪽 「압축 전 · 한 줄로 늘어선 트리」 패널에는 1, 2, 3, 4가 세로 한 줄로 이어져 있고 맨 아래 4가 주황으로 표시되며 「4에서 루트 1까지 세 번 올라가야 합니다」라고 적혀 있음. 오른쪽 「압축 후 · 모두 루트에 직접 연결」 패널에는 루트 1 아래에 2, 3, 4가 나란히 붙어 있고 4가 초록으로 표시되며 「다음 find부터는 한 번에 루트에 닿습니다」라고 적혀 있음. 다이어그램 제목은 「루트를 찾는 김에 지나온 노드를 루트에 직접 붙입니다」, 부제는 「4에서 find를 호출했을 때의 트리 변화」이고 하단에 「랭크 기반 합치기는 낮은 트리를 높은 트리 아래에 붙여 처음부터 높이가 커지는 것을 막습니다」, 「둘을 함께 쓰면 연산당 비용이 사실상 상수에 가까워집니다」라고 적혀 있음.

둘을 함께 쓰면 연산당 비용이 사실상 상수에 가까워집니다. 정확히는 역아커만 함수라는 극히 느리게 증가하는 함수에 비례하는데, 현실적인 입력 범위에서는 4를 넘지 않는다고 알려져 있습니다.

같은 사람이 만든 계정 여러 개를 하나로 묶을 때 이 구조가 맞습니다. 이메일이나 전화번호가 겹치는 쌍을 발견할 때마다 합치고, 마지막에 같은 그룹인지만 물으면 됩니다. 이미지에서 서로 붙어 있는 픽셀 덩어리를 세는 계산도 같은 방식입니다.


최소 비용으로 전부 잇는 문제

모든 정점을 연결하되 간선 비용의 합을 최소로 만드는 것이 최소 신장 트리(minimum spanning tree) 입니다.

크루스칼 알고리즘(Kruskal’s algorithm) 은 간선을 비용 순으로 정렬한 뒤 싼 것부터 하나씩 추가합니다. 이미 연결된 두 정점을 다시 잇는 간선은 사이클을 만드니 건너뜁니다. 이 판정에 유니온 파인드를 씁니다.

function kruskal(n, edges) {
  edges.sort((a, b) => a.weight - b.weight);
  const uf = new UnionFind(n);
  let total = 0;
  let count = 0;

  for (const { u, v, weight } of edges) {
    if (uf.union(u, v)) { // 사이클이 아니면 채택
      total += weight;
      count++;
      if (count === n - 1) break; // 간선 n-1개면 완성
    }
  }
  return total;
}

프림 알고리즘(Prim’s algorithm) 은 정점 하나에서 시작해 트리에 붙일 수 있는 가장 싼 간선을 계속 고릅니다. 우선순위 큐를 쓴다는 점에서 다익스트라와 구조가 비슷합니다. 간선이 많고 조밀한 그래프에서는 프림이 유리하고, 희소한 그래프에서는 정렬 비용이 지배하는 크루스칼이 유리한 편입니다.

function prim(graph, n) {
  const inTree = new Array(n).fill(false);
  const pq = new MinHeap((a, b) => a.weight - b.weight);
  pq.push({ to: 0, weight: 0 }); // 아무 정점에서 시작
  let total = 0;

  while (pq.size > 0) {
    const { to, weight } = pq.pop();
    if (inTree[to]) continue; // 이미 트리에 있으면 버린다

    inTree[to] = true;
    total += weight;
    for (const edge of graph[to]) {
      if (!inTree[edge.to]) pq.push(edge);
    }
  }
  return total;
}

다익스트라가 시작점에서의 누적 거리를 큐에 넣는다면, 프림은 간선 하나의 비용만 넣습니다. 이 차이가 최단 경로와 최소 신장 트리를 가릅니다.

크루스칼과 프림이 최소 신장 트리를 만드는 간선 선택 순서를 비교한 다이어그램. 두 패널 모두 정점 A, B, C, D와 간선 A-B 1, B-C 2, C-D 3, A-C 4, B-D 6을 담고 있으며, 선택된 간선은 초록, 이번에 고르는 간선은 주황 굵은 선, 고르지 않은 간선은 흐린 선으로 표시됨. 왼쪽 「크루스칼 · 싼 간선부터」 패널에는 「1 → 2 → 3 순서로 고릅니다. A-C 4는 사이클이라 건너뜁니다」, 오른쪽 「프림 · 한 정점에서 자라기」 패널에는 「A에서 시작해 트리에 붙는 가장 싼 간선을 계속 고릅니다」라고 적혀 있음. 다이어그램 제목은 「같은 트리에 도달하지만 고르는 순서가 다릅니다」, 부제는 「정점 A B C D · 간선 A-B 1, B-C 2, C-D 3, A-C 4, B-D 6 · 최소 신장 트리 비용은 6」이고 하단에 「크루스칼은 간선을 전부 정렬해두고 사이클만 피하며 고르고, 프림은 현재 트리에 닿는 간선 중에서만 고릅니다」, 「간선이 조밀하면 프림이, 성기면 정렬 비용이 지배하는 크루스칼이 유리합니다. 결과 트리는 같습니다」라고 적혀 있음.

두 알고리즘 모두 그리디입니다. ep.03 언제 욕심내고 언제 기억하는가에서 본 탐욕 선택 속성이 증명된 사례입니다.

최소 신장 트리는 연결 비용을 줄여야 하는 설계에 씁니다. 지점 사이에 회선을 깔거나 센서를 잇는 계획에서 전체 길이를 최소로 만드는 계산이 그렇습니다.


순서 제약은 위상정렬입니다

작업 사이에 선행 관계가 있을 때 가능한 실행 순서를 찾는 것이 위상정렬(topological sort) 입니다. 빌드 의존성, 강의 선수 과목, 작업 스케줄이 여기 속합니다.

방향 그래프이면서 사이클이 없어야 성립합니다. A가 B를 요구하고 B가 A를 요구하면 순서를 정할 수 없습니다.

각 정점의 진입 차수를 세고, 진입 차수가 0인 정점부터 꺼내는 방식이 흔히 쓰입니다.

function topologicalSort(graph, n) {
  const indegree = new Array(n).fill(0);
  for (let u = 0; u < n; u++) {
    for (const v of graph[u]) indegree[v]++;
  }

  const queue = [];
  for (let i = 0; i < n; i++) if (indegree[i] === 0) queue.push(i);

  const order = [];
  let head = 0;
  while (head < queue.length) {
    const u = queue[head++];
    order.push(u);
    for (const v of graph[u]) {
      if (--indegree[v] === 0) queue.push(v); // 선행 조건이 모두 해소된다
    }
  }

  return order.length === n ? order : null; // null이면 사이클 존재
}

진입 차수가 0인 정점을 큐로 빼면서 위상정렬 순서를 만드는 과정을 네 단계로 보여주는 다이어그램. A에서 B와 C로, B와 C에서 D로 향하는 화살표가 있고 각 노드 위에 남은 진입 차수가 적혀 있음. 꺼낸 정점은 주황, 처리가 끝난 정점은 초록으로 표시되고 처리된 정점에서 나가는 화살표는 흐려짐. STEP 1에서 차수가 0인 A를 꺼내고 결과는 비어 있음. STEP 2에서 B와 C의 차수가 0이 되며 결과는 A. STEP 3에서 C를 꺼내고 결과는 A B. STEP 4에서 D까지 꺼내 결과는 A B C가 되고 넷 모두 나옴. 다이어그램 제목은 「앞에 아무것도 남지 않은 작업부터 꺼냅니다」, 부제는 「A → B, A → C, B → D, C → D 의존 관계 · 숫자는 남은 진입 차수」이고 하단에 「간선을 하나 지울 때마다 도착 정점의 진입 차수가 1씩 줄고, 0이 되는 순간 큐에 들어갑니다」, 「B와 C는 순서를 바꿔도 됩니다. 위상정렬의 답은 하나가 아닙니다」, 「결과 길이가 정점 수보다 적으면 차수가 0이 되지 못한 정점이 남았다는 뜻이고, 그것이 사이클입니다」라고 적혀 있음.

결과 길이가 정점 수보다 적으면 사이클이 있다는 뜻입니다. 순환 의존성 탐지를 함께 얻는 셈입니다.

빌드 도구가 매번 이 계산을 합니다. 번들러는 모듈이 서로를 import하는 순서를 위상정렬로 정하고, 데이터베이스 마이그레이션 도구는 먼저 적용해야 하는 스크립트를 같은 방식으로 찾습니다. 순환 의존성 오류는 정렬이 끝까지 가지 못했을 때 나옵니다.


참고 자료

  • Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. (2009). Introduction to Algorithms (3rd ed.). MIT Press.
  • Dijkstra, E. W. (1959). A note on two problems in connexion with graphs. Numerische Mathematik, 1(1), 269-271.
  • Sedgewick, R., & Wayne, K. (2011). Algorithms (4th ed.). Addison-Wesley.

다음 편 예고

ep.05 - 문자열 안에서 패턴 찾기

문자열에서 부분 문자열을 찾는 일은 이중 반복문이면 됩니다. 그보다 빠를 수 있는 이유는 실패한 비교에서 정보를 얻기 때문입니다. KMP의 실패 함수가 그 정보입니다.