Day 081-Q3 — 動的グラフ辺着色(Undo DSU + 二部グラフ性の動的判定)

2026-07-04 赤色 Master / Phase 8+ ★★★★★★★★★ Dynamic Graph Coloring・Parity DSU・Offline Divide & Conquer

問題

$N$ 頂点の無向グラフに対し,$Q$ 個のクエリを処理せよ:

  • A $u$ $v$: 辺 $(u, v)$ を追加
  • R $u$ $v$: 辺 $(u, v)$ を削除
  • Q $u$ $v$: $u$-$v$ の連結性と,連結成分の二部グラフ判定を答える

答えは NO(非連結),YES 二部(連結かつ二部グラフ),YES 非二部(連結かつ奇数閉路あり)のいずれか。

制約

パラメータ範囲備考
$N$$2 \le N \le 10^5$頂点数
$Q$$1 \le Q \le 10^5$クエリ数
多重辺・自己ループなし保証

入出力例

入力例1

4 8
A 1 2
A 2 3
A 3 4
Q 1 4
A 4 1
Q 1 3
R 4 1
Q 1 4

出力例1

YES 二部
YES 非二部
YES 二部

概念図: パリティ DSU と二部グラフ判定

パリティ DSU — 各ノードが「根への距離 mod 2」を保持 二部グラフ(偶数閉路のみ): 1(0) 2(1) 3(0) 4(1) 色 0(緑) と 色 1(紫) — 異なる色が隣接 非二部グラフ(奇数閉路あり): 1(0) 2(1) 3(0) 3角形: 1→2→3→1 で奇数閉路! 辺(1,3)追加時: parity[1]=0, parity[3]=0 → 同じ → 奇数閉路 Undo DSU の必要性: 辺削除に対応するためにパス圧縮を禁止し,rank による union のみ使用(O(log N) per op) 各 union を history に記録 → undo 時に逆順で復元 → Offline Divide & Conquer で O((N+Q) log Q α(N))

ヒント

ヒント1(方向性)

二部グラフ判定は Union-Find に「根への距離 mod 2」(パリティ)を持たせることで実現できる。同一成分内で辺 $(u, v)$ を追加するとき,$\text{parity}[u] == \text{parity}[v]$ なら奇数閉路。

ヒント2(アプローチ)

辺削除に対応するために Undo DSU(パス圧縮なし,rank による union,history に記録)を使う。Offline Divide & Conquer で各辺の生存区間をセグメント木に登録し,DFS しながら union/undo を繰り返す。

ヒント3(ほぼ答え)
class UndoDSU:
    def find(self, x):
        """パス圧縮なし (Undo のため)"""
        p = 0
        while self.par[x] != x:
            p ^= self.parity[x]
            x = self.par[x]
        return x, p

    def union(self, u, v):
        ru, pu = self.find(u)
        rv, pv = self.find(v)
        if ru == rv:
            if pu == pv:  # 同パリティ → 奇数閉路
                self.bipartite[ru] = False
            return
        # rank による union (高い方が根)
        # parity[rv] = pu ^ pv ^ 1 に設定

    def undo(self):
        # history から最後の union を逆順に復元

模範解答(Union-Find + パリティ版 / 辺追加のみ)

import sys
def solve():
    input = sys.stdin.readline
    N, Q = map(int, input().split())

    parent = list(range(N+1))
    rank = [0] * (N+1)
    parity = [0] * (N+1)  # 根への距離 mod 2
    bipartite = [True] * (N+1)

    def find(x):
        if parent[x] == x: return x, 0
        root, p = find(parent[x])
        parity[x] ^= parity[parent[x]]
        parent[x] = root
        return root, parity[x]

    def union(x, y):
        rx, px = find(x); ry, py = find(y)
        if rx == ry:
            if px == py: bipartite[rx] = False
            return
        if rank[rx] < rank[ry]: rx, ry = ry, rx; px, py = py, px
        parent[ry] = rx
        parity[ry] = px ^ py ^ 1
        bipartite[rx] = bipartite[rx] and bipartite[ry]
        if rank[rx] == rank[ry]: rank[rx] += 1

    edges = set()
    out = []
    for _ in range(Q):
        line = input().split()
        t, u, v = line[0], int(line[1]), int(line[2])
        if t == 'A':
            if (u, v) not in edges and (v, u) not in edges:
                edges.add((u, v)); union(u, v)
        elif t == 'R':
            edges.discard((u, v)); edges.discard((v, u))
        else:
            ru, _ = find(u); rv, _ = find(v)
            if ru != rv: out.append("NO")
            elif bipartite[find(u)[0]]: out.append("YES 二部")
            else: out.append("YES 非二部")

    print('\n'.join(out))

solve()

Step-by-Step 解説

Step 1: パリティ DSU の設計

各ノードに「根への距離 mod 2」を持たせる。パス圧縮時: parity[x] ^= parity[parent[x]]。union 時: parity[ry] = pu ^ pv ^ 1(辺で距離差 1)。

Step 2: 奇数閉路の検出

同一成分内で辺 $(u, v)$ を追加するとき,$\text{parity}[u] == \text{parity}[v]$(同じ側)なら奇数閉路 → 成分を非二部グラフとしてマーク。

Step 3: 辺削除の対応(Undo DSU)

パス圧縮を禁止し,rank による union のみ使用($O(\log N)$ per op)。各 union を history に記録,undo 時に逆順で復元。

Step 4: Offline Divide & Conquer

各辺の生存区間 [t_add, t_remove) を記録 → 時刻軸のセグメント木に登録 → solve(node, lo, hi) で DFS: ノードの辺を union → 子を再帰 → undo。全体 $O((N+Q) \log Q \cdot \log N)$。

よくあるミス

ミス原因正しい書き方
パス圧縮と Undo の衝突圧縮でノードの parity が変わるUndo DSU ではパス圧縮禁止
パリティの XOR 誤りpu ^ pv で 1 を XOR し忘れ辺なので距離差 1: pu ^ pv ^ 1
二部性フラグの復元漏れundo 時に bip を復元しないhistory に bip も記録して復元

次のステップ

  • 発展問題: Offline Dynamic 2-Coloring(辺追加・削除 + パリティクエリの完全 Offline 版)
  • Link-Cut Tree に parity を持たせた完全 Online 実装

自己評価

理解度: / /

自分の回答:

気づき・メモ: