본문으로 건너뛰기
김신건의 로그

Dynamic Connectivity: 간선 추가/삭제 연결성

· 수정 · 📖 약 6분 · 1,760자/단어 #algorithm #data-structure #connectivity
Dynamic Connectivity, 동적 연결성

정의

Dynamic Connectivity 는 간선의 추가와 삭제 가 모두 일어나는 그래프에서 두 정점의 연결 여부를 유지하는 문제입니다.

  • 정점 N 개, 쿼리 Q 개
  • 각 쿼리: 간선 추가 / 간선 삭제 / 두 정점이 같은 컴포넌트인지 확인

문제 상황과 동기

간선 삽입만 있는 경우 Union-Find 로 O(α(N)) amortized 처리가 가능합니다. 하지만 삭제가 포함되면 Union-Find 를 그대로 쓸 수 없습니다.

연산방법복잡도
삽입만[[disjoint-setUnion-Find]]
삭제만시간 역행 후 삽입 문제로 환원O(α(N)) amortized
삽입 + 삭제 (오프라인)D&C + DSU with RollbackO(Q log Q · α(N))
삽입 + 삭제 (온라인)[[dynamic-treeLink-Cut Tree]]

오프라인 vs 온라인

오프라인: 쿼리를 미리 전부 알고 있는 경우. 배치 처리 가능. PS 에서 가장 자주 등장.

온라인: 쿼리가 이전 답에 의존하는 경우. Link-Cut Tree 또는 Euler Tour Tree 필요.

시각화

알고리즘 선택 흐름:

flowchart TD
    A["동적 연결성 문제"] --> B{"온라인 쿼리?"}
    B -->|"Yes"| C["Link-Cut Tree O(log N)"]
    B -->|"No (오프라인)"| D{"삭제 있음?"}
    D -->|"삽입만"| E["Union-Find O(α)"]
    D -->|"삭제만"| F["시간 역행 후 삽입"]
    D -->|"삽입+삭제"| G["D&C + DSU with Rollback"]
    G --> H["O(Q log Q · α(N))"]
    F --> E

오프라인 D&C 핵심 구조:

flowchart LR
    A["쿼리 시간축 0..Q"] --> B["각 간선의 유효 구간 계산"]
    B --> C["세그트리 시간축에 간선 분배"]
    C --> D["DFS: 진입시 DSU 추가"]
    D --> E["리프: 쿼리 응답"]
    E --> F["퇴출시 DSU 롤백"]

핵심 아이디어

오프라인 D&C + DSU with Rollback

각 간선은 존재하는 시간 구간 [add_time, del_time) 을 가집니다. 이 구간을 세그먼트 트리의 시간 축 에 배분하면, 각 노드에는 그 시간 범위 전체에서 활성인 간선 목록이 들어갑니다.

시간 [0, 8) 에서 간선 e 가 [2, 6) 에 존재한다면:
  세그트리 노드 [2,3], [4,5] 등에 e 를 배분 (최대 2 log Q 개 노드)

트리를 DFS 하며:

  • 진입: 현재 노드의 간선들을 DSU 에 추가 (union by rank, path compression 없이)
  • 리프 도달: 해당 시간 슬롯의 쿼리에 답
  • 퇴출: 진입 시 추가한 간선들을 롤백

Rollback DSU

Path compression 을 쓰지 않고 union by rank 만 사용. 대신 연산 스택에 변경 내역을 기록하고 퇴출 시 되돌립니다.

rollback_stack: [(node, old_parent, old_rank), ...]
union(u, v):
    u = find(u), v = find(v)  // path compression 없이
    if rank[u] < rank[v]: swap(u, v)
    stack.push((v, parent[v], rank[u]))
    parent[v] = u
    if rank[u] == rank[v]: rank[u]++

rollback(checkpoint):
    while stack.size() > checkpoint:
        (v, old_parent, old_rank_u) = stack.pop()
        parent[v] = old_parent (즉, v)
        rank[find(v)] = old_rank_u

Small-to-Large 병합 (Smaller-to-Larger Merge)

Smaller-to-Larger 기법은 동적 연결성과 조합할 수 있습니다. 각 컴포넌트의 속성 집합을 유지할 때, 크기가 작은 쪽을 큰 쪽에 병합하면 총 이동 횟수 O(N log N).

알고리즘: 오프라인 D&C

전처리

  1. 각 간선의 (add_time, del_time) 구간 계산
  2. 세그먼트 트리 노드에 간선 배분: assign(node, l, r, el, er, edge_id)
  3. 쿼리 배열 구성: queries[t] = 시간 t 에서 확인할 (u, v) 쌍

DFS

dfs(node, l, r, dsu):
    checkpoint = dsu.stack.size()
    for edge in seg_tree[node]:
        dsu.union(edge.u, edge.v)
    if l == r:
        answer[queries[l]] = dsu.connected(qu, qv)
    else:
        mid = (l + r) / 2
        dfs(2*node, l, mid, dsu)
        dfs(2*node+1, mid+1, r, dsu)
    dsu.rollback(checkpoint)

구현

// Dynamic Connectivity: Offline D&C + DSU with Rollback
#include <bits/stdc++.h>
using namespace std;

struct DSU {
  vector<int> par, rank_;
  vector<pair<int,int>> stk; // (node, old_par_or_rank_flag)
  vector<tuple<int,int,int>> ops; // (v, old_par, old_rank_u)

  DSU(int n) : par(n), rank_(n, 0) {
      iota(par.begin(), par.end(), 0);
  }
  int find(int x) {
      while (par[x] != x) x = par[x]; // no path compression
      return x;
  }
  bool unite(int u, int v) {
      u = find(u); v = find(v);
      if (u == v) { ops.push_back({-1, -1, -1}); return false; }
      if (rank_[u] < rank_[v]) swap(u, v);
      ops.push_back({v, par[v], rank_[u]});
      par[v] = u;
      if (rank_[u] == rank_[v]) rank_[u]++;
      return true;
  }
  bool connected(int u, int v) { return find(u) == find(v); }
  int checkpoint() { return ops.size(); }
  void rollback(int cp) {
      while ((int)ops.size() > cp) {
          auto [v, old_par, old_rank_u] = ops.back(); ops.pop_back();
          if (v == -1) continue;
          rank_[par[v]] = old_rank_u;
          par[v] = old_par; // restore to itself
      }
  }
};

const int MAXQ = 1 << 18;
vector<pair<int,int>> seg[MAXQ * 2];

void seg_add(int node, int l, int r, int ql, int qr, pair<int,int> e) {
  if (qr < l || r < ql) return;
  if (ql <= l && r <= qr) { seg[node].push_back(e); return; }
  int mid = (l + r) / 2;
  seg_add(2*node, l, mid, ql, qr, e);
  seg_add(2*node+1, mid+1, r, ql, qr, e);
}

int ans[MAXQ];
pair<int,int> query[MAXQ]; // {u, v}

void dfs(int node, int l, int r, DSU& dsu) {
  int cp = dsu.checkpoint();
  for (auto [u, v] : seg[node]) dsu.unite(u, v);
  if (l == r) {
      if (query[l].first != -1)
          ans[l] = dsu.connected(query[l].first, query[l].second);
  } else {
      int mid = (l + r) / 2;
      dfs(2*node, l, mid, dsu);
      dfs(2*node+1, mid+1, r, dsu);
  }
  dsu.rollback(cp);
}

int main() {
  ios::sync_with_stdio(0); cin.tie(0);
  int n, q; cin >> n >> q;
  // edge_time[{u,v}] = add_time
  map<pair<int,int>, int> edge_time;

  fill(query, query + q, make_pair(-1, -1));

  for (int t = 0; t < q; t++) {
      int type; cin >> type;
      if (type == 1) { // add edge
          int u, v; cin >> u >> v;
          if (u > v) swap(u, v);
          edge_time[{u, v}] = t;
      } else if (type == 2) { // delete edge
          int u, v; cin >> u >> v;
          if (u > v) swap(u, v);
          auto it = edge_time.find({u, v});
          seg_add(1, 0, q-1, it->second, t-1, {u, v});
          edge_time.erase(it);
      } else { // query connectivity
          int u, v; cin >> u >> v;
          query[t] = {u, v};
      }
  }
  // 쿼리 종료 시까지 살아있는 간선
  for (auto& [e, st] : edge_time)
      seg_add(1, 0, q-1, st, q-1, e);

  DSU dsu(n);
  dfs(1, 0, q-1, dsu);

  for (int t = 0; t < q; t++)
      if (query[t].first != -1)
          cout << (ans[t] ? "YES" : "NO") << "\n";
}
stdin
4 6
1 0 1
1 1 2
3 0 2
2 1 2
3 0 2
3 0 3
결과
YES
YES
NO

복잡도

방법쿼리전처리/공간비고
D&C + DSU RollbackO(log Q · α(N))O(Q log Q)오프라인
[[dynamic-treeLink-Cut Tree]]O(log N)O(N)
Holm-Lichtenberg-ThorupO(log² N) amortizedO(N + M)온라인, 구현 복잡

오프라인 D&C 는 세그트리 높이 O(log Q) 번 union/rollback 이 발생하고, 각 union/find 는 rank 만 쓰므로 O(log N). 총 O(Q log Q log N).

변형 / 활용

간선 가중치 연결성

간선에 가중치가 있고, “임의 경로 중 최대 간선 가중치 최솟값” 을 구하는 경우 (Link-Cut Tree 의 path aggregate 기능) 도 D&C 프레임워크로 처리 가능.

연결 컴포넌트 크기 유지

DSU 에 컴포넌트 크기 배열을 추가하고 롤백 시 함께 복구. 추가 비용 없음.

이분 그래프 여부 체크

간선 추가/삭제 중 이분성 유지 여부: 가중치 있는 DSU (홀수 사이클 감지) + 롤백.

Euler Tour Tree 기반 온라인

온라인이 필요하면 각 스패닝 포레스트를 Euler Tour 로 표현, 스패닝 포레스트 간선 추가/삭제를 O(log² N) 에 처리.

함정

WARNING

DSU with Rollback 에서 path compression 절대 금지. Path compression 을 쓰면 롤백이 불가능합니다. Union by rank 만 사용하세요.

1. 간선 중복 처리

같은 간선이 여러 번 추가/삭제되는 경우 edge_time 을 multimap 또는 카운터로 관리해야 합니다.

2. 쿼리 인덱스

시간 축 [0, Q) 는 쿼리 개수로 잡되, 간선 add/del 이 같은 시간 슬롯에 쿼리가 없어도 올바르게 처리해야 합니다.

3. 재귀 깊이

Python 에서 sys.setrecursionlimit(10**6) 필수. Q = 10^5 면 세그트리 높이 17, 충분.

4. 메모리

세그트리 노드 2Q 개 × 간선 목록. 최악 O(Q log Q) 간선. Q = 10^5 에서 약 170만 간선 슬롯.

BOJ 연습 문제

번호제목링크
BOJ 15675괄호 문자열과 쿼리 (연결성 변형)BOJ
BOJ 16905Dynamic GraphBOJ

참고

이 글의 용어 (5개)
분리 집합 (Disjoint Set, Union-Find)algorithm
정의 분리 집합 (Disjoint Set, Union-Find) 은 서로 겹치지 않는 집합들을 관리하며, 다음 두 연산을 거의 상수 시간 (amortized O(α(N)), α는…
세그먼트 트리 (Segment Tree)algorithm
정의 세그먼트 트리 (Segment Tree) 는 배열의 구간 쿼리 (range query) 와 점 갱신 (point update) 를 모두 O(log N) 에 처리하는 이진 트…
오일러 투어 테크닉 (Euler Tour Technique)algorithm
정의 오일러 투어 테크닉 (ETT, Euler Tour Technique) 은 트리를 DFS 방문 순서로 펼쳐 서브트리 쿼리를 구간 쿼리로 변환하는 정형. 각 노드 u 의 in-…
작은 집합을 큰 집합에 합치기 (Smaller to Larger)algorithm
정의 Smaller to Larger (작은 집합을 큰 집합에 합치기) 는 두 집합을 합칠 때 항상 작은 쪽을 큰 쪽에 합치면 전체 O(N log N) 또는 O(N log^2 N…
Dynamic Tree (Link/Cut Tree, Euler Tour Tree, Top Tree)algorithm
정의 Dynamic Tree 는 트리에 간선 추가 (link) / 제거 (cut) 가 섞이는 환경에서 경로 / 서브트리 집계 쿼리 를 O(log N) 에 처리하는 자료구조 가족.…

이 개념을 다룬 위키 페이지 (1)

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기