Day 140-Q1 — Gabow's Algorithm(パスベース強連結成分分解)

2026-09-01 赤色 Master / Phase 8+ ★★★★★★★★★ 2本スタック方式によるSCC分解(Tarjan法のlowlinkを使わない構成)

問題

$N$ 頂点 $M$ 辺の有向グラフが与えられる。頂点は $0$ から $N-1$ で番号付けされている。

Gabow のアルゴリズムは、Tarjan の低リンク値(lowlink)を使う方式とは異なり、2本のスタック(訪問済み頂点を積む「パススタック」$S$ と、SCC候補の境界を管理する「境界スタック」$P$)だけを使って強連結成分分解を行う path-based な手法である。

以下の手順で $N$ 頂点すべてを起点に深さ優先探索を行い、各頂点に SCC 番号を割り当てよ。

  • 頂点 $v$ を訪問する際、$S,P$ 両方に $v$ を積み、行きがけ順の番号(preorder)を記録する
  • $v$ から辺 $(v, w)$ をたどる際: $w$ が未訪問なら再帰的に訪問。$w$ が訪問済みでSCC番号未確定なら、$P$ の頂点のうち preorder が $w$ より大きいものをすべて $P$ から取り除く
  • $v$ の子の処理をすべて終えた時点で $P$ の一番上が $v$ 自身であれば、$P$ から $v$ を取り除き、$S$ から $v$ が出てくるまで pop し続け、pop した頂点すべてに同じ SCC 番号を割り当てる(確定した順に $0,1,2,\dots$)

DFS は頂点 $0,1,\dots,N-1$ の順で未訪問なら開始し、隣接頂点は入力で辺が与えられた順にたどる。SCC の個数と各頂点のSCC番号を出力せよ。

入力形式

N M
u_1 v_1
:
u_M v_M

制約

$1 \le N \le 10^5$
$0 \le M \le 2\times10^5$
$0 \le u_i, v_i < N$

入出力例

入力例1

6 7
0 1
1 2
2 0
1 3
3 4
4 5
5 3

出力例1

2
1 1 1 0 0 0

入力例2

3 2
0 1
1 2

出力例2

3
2 1 0

概念図

入力例1: {0,1,2} と {3,4,5} の2つのサイクル + 橋(1→3) 0 1 2 橋(SCC間の辺) 3 4 5 DFS順: 0→1→2(戻り0)→3→4→5(戻り3) {3,4,5} が先にP最上位=v成立→SCC番号0で確定 続いて{0,1,2}がSCC番号1で確定 出力: comp = [1,1,1,0,0,0](確定した順に番号付け=逆トポロジカル順)

ヒント(段階的開示)

ヒント1(方向性)

Tarjan のアルゴリズムは各頂点に lowlink 値(自分自身または子孫からの後退辺でたどり着ける最小 preorder 値)を持たせて管理する。Gabow の方式はこれを明示的な値として持たず、「境界スタック $P$」という補助スタックの中に「まだ確定していない SCC の境界候補」を preorder の降順に保持することで、同じ情報を間接的に表現する。$P$ の中身を出し入れするだけで lowlink 計算と同じ効果が得られる点がこのアルゴリズムの本質である。

ヒント2(アプローチ)

$P$ に積まれている頂点の preorder は常に下から上へ単調増加になるように保たれる。後退辺 $(v,w)$($w$ がまだ SCC 未確定の既訪問頂点)を見つけたとき、「$w$ より新しく積まれた境界候補はすべて、今回発見した後退辺によって $w$ と同じ SCC に吸収されうる」ため、$P$ から pop してよい。$v$ の探索が終わった時点で $P$ の最上位が $v$ 自身であれば、それは「$v$ を境界とする SCC が確定した」ことを意味する。

ヒント3(誘導)
preorder = [-1]*n
comp = [-1]*n
idx = 0
comp_count = 0
S, P = [], []

def dfs(v):
    global idx, comp_count
    preorder[v] = idx; idx += 1
    S.append(v); P.append(v)
    for w in adj[v]:
        if preorder[w] == -1:
            dfs(w)
        elif comp[w] == -1:
            while preorder[P[-1]] > preorder[w]:
                P.pop()
    if P[-1] == v:
        P.pop()
        while True:
            w = S.pop()
            comp[w] = comp_count
            if w == v:
                break
        comp_count += 1

$N \le 10^5$ では再帰が深くなりうるため、模範解答ではジェネレータを使った反復DFSに書き換えている(挙動は上記の再帰版と完全に一致する)。

模範解答 (Python)

import sys

def main():
    input_data = sys.stdin.read().split()
    idx_ptr = 0
    n = int(input_data[idx_ptr]); idx_ptr += 1
    m = int(input_data[idx_ptr]); idx_ptr += 1
    adj = [[] for _ in range(n)]
    for _ in range(m):
        u = int(input_data[idx_ptr]); idx_ptr += 1
        v = int(input_data[idx_ptr]); idx_ptr += 1
        adj[u].append(v)

    preorder = [-1] * n
    comp = [-1] * n
    idx_counter = 0
    comp_count = 0
    S = []
    P = []

    for start in range(n):
        if preorder[start] != -1:
            continue
        stack = [(start, iter(adj[start]))]
        preorder[start] = idx_counter; idx_counter += 1
        S.append(start)
        P.append(start)
        while stack:
            v, it = stack[-1]
            advanced = False
            for w in it:
                if preorder[w] == -1:
                    preorder[w] = idx_counter; idx_counter += 1
                    S.append(w)
                    P.append(w)
                    stack.append((w, iter(adj[w])))
                    advanced = True
                    break
                elif comp[w] == -1:
                    while preorder[P[-1]] > preorder[w]:
                        P.pop()
            if advanced:
                continue
            stack.pop()
            if P[-1] == v:
                P.pop()
                while True:
                    w = S.pop()
                    comp[w] = comp_count
                    if w == v:
                        break
                comp_count += 1

    out = []
    out.append(str(comp_count))
    out.append(' '.join(map(str, comp)))
    print('\n'.join(out))

main()

Step-by-Step 解説

12本スタックの役割分担
$S$(パススタック)は現在のDFS経路上でまだSCC未確定の頂点すべてをpreorder順に保持する。$P$(境界スタック)は $S$ の部分集合で、「今後SCCの境界(根)になりうる候補」をpreorderの狭義単調増加を保ったまま保持する。
2後退辺の処理がlowlink計算に相当
$(v,w)$ が後退辺($w$がスタック上)のとき、$w$より後に発見された境界候補はすべて、この閉路によって$w$と同一SCCに吸収されうるため$P$から取り除いてよい。
3SCCの確定タイミング
$v$の子孫の探索が終わった時点で$P$の最上位が$v$自身のままなら、「$v$を根とする閉じたSCCが確定した」ことを意味し、$S$から$v$まで popした頂点集合が1つのSCCになる。
4SCC番号の順序
SCCは確定した順(=DFSの帰りがけで根が見つかった順)に番号付けられ、これは縮約DAGの逆トポロジカル順序に一致する。
5計算量と実装上の注意
DFSは各頂点・各辺を定数回しか処理しないため$O(N+M)$。$N$が大きいとPythonの再帰はスタックオーバーフローするため、模範解答は反復DFS(ジェネレータ保持)に書き換えている。

よくあるミス

ミス原因正しい書き方
$P$から pop する条件を >= にしてしまう「preorder が $w$ 以上」を誤って含めてしまう正しくは preorder[P[-1]] > preorder[w] の間だけ pop($w$自身は残す)
SCC番号を確定した逆順で振ってしまう直感的に「source側から0」と振りたくなる本問の仕様では確定した順(sink側から)に $0,1,2,\dots$ と振る
再帰版をそのまま提出しRecursionErrorになる$N$が大きいケースを想定していない反復DFS(スタック+ジェネレータ)に書き換える
自己ループ辺の特別扱いをしてしまうTarjan法のlowlink実装の癖を持ち込む自己ループも通常の後退辺として自然に処理される
隣接リストをsetで管理し順序を失う出力の再現性を軽視する入力順を保持するためlistを使う

次のステップ

  • 発展: SCC番号列を使って縮約グラフ(各SCCを1頂点としたDAG)を構築し、トポロジカルソートせよ。SCC番号が確定順(=逆トポロジカル順)であることを利用すると、追加のソートなしで縮約DAGのトポロジカル順序が得られることを確認せよ。
  • 発展2: Tarjanのアルゴリズム(lowlink方式)で同じグラフを処理し、SCCの集合としての分割結果が完全一致することを確認せよ(番号の割り当て方は異なってよい)。

自己評価

自分の回答

気づき・メモ