Day 166-Q4 — 2026-09-30 | DAG の最小パス被覆(頂点を共有してよい版)

2026-09-30 赤色 Master (Phase 8+) ★★★★★★★★★ 9/30 のバックフィル分 グラフ(焦点帯 M2)。頂点を共有してよいパス被覆は、推移閉包の上の最大二部マッチングで N−マッチング数。閉包を作らずに元の辺でマッチングすると頂点素のパス被覆(別の問題)になることを数で確かめる

問題

$N$ 頂点 $M$ 辺の有向非巡回グラフ(DAG)がある。有向パス(頂点列 $v_1\to v_2\to\cdots\to v_k$。$k=1$ も可)をいくつか選び、すべての頂点がいずれかのパスに含まれるようにしたい。同じ頂点を複数のパスが通ってよいとき、必要なパスの最小本数を求めよ。

入力形式

N M
a_1 b_1
...
a_M b_M

制約

#条件
1$1\le N\le1000$
2$0\le M\le N(N-1)/2$(辺 $a_i\to b_i$、同じ辺が重複して与えられることはない)
3グラフは DAG(閉路を持たない)

入出力例

入力例1

5 4
1 3
2 3
3 4
3 5

出力例1

2

($1\to3\to4$ と $2\to3\to5$。頂点 3 を共有する。頂点を共有しない場合は 3 本)

入力例2

3 3
1 2
2 3
1 3

出力例2

1

入力例3

4 3
1 2
1 3
1 4

出力例3

3

(頂点 1 から出る 3 本の辺。1 本のパスは 1 から 1 つの先にしか行けない)

背景と出典

「共有を許すパス被覆の最小本数=半順序の最大反鎖の大きさ」は Dilworth の定理で、DAG の推移閉包を半順序と見たときの鎖被覆に当たる。マッチングによる証明・構成(Fulkerson 1956)として覚えている(本セッションでは原典を確認していない)。

この問題で確かめたいのは次の 3 点。(1) 閉包上のマッチングが、パスの全列挙+集合被覆の最小値と一致すること。(2) 元の辺でそのままマッチングする方法は頂点素のパス被覆の答えになり、共有が要る入力で外れること。(3) その入力は一様ランダムな疎な DAG ではまれにしか現れず、入力例の作り方(頂点 3 のようなハブ)で初めて見抜けること。

ヒント(クリックで段階的に開示)

ヒント1(方向性)

パスが頂点を共有してよいとき、パスの本数を最小にするには、ある頂点 $u$ から $u$ に到達できる別の頂点 $w$ へ「飛ぶ」ことを許してよい(途中の頂点は他のパスが通ってくれるから)。この「飛ぶ」関係は、元の辺ではなく何に当たるか。

ヒント2(アプローチ)

到達可能性(推移閉包)を作り、$u\to w$ が到達可能なら二部グラフの左 $u$・右 $w$ に辺を張る。パス被覆の本数は $N-(\text{この二部グラフの最大マッチング})$。閉包は DAG をトポロジカル順の逆に見て、各頂点の到達集合を子の到達集合の OR で作る(ビット集合)。

ヒント3(誘導)

最大マッチングは、増加パスを探す Kuhn 法で十分。到達集合を整数のビット集合で持てば、「まだ訪れていない右頂点」を reach[u] & ~vis で一度に取り出せる。答えは n - マッチング数。

模範解答 (Python)

模範解答を表示する(まず自力で解いてから)
import sys
def main():
    data = sys.stdin.buffer.read().split()
    n, m = int(data[0]), int(data[1])
    adj = [0]*n
    for k in range(m):
        a = int(data[2+2*k])-1; b = int(data[3+2*k])-1
        adj[a] |= 1 << b
    # 推移閉包(DAG なので、トポロジカル逆順に reach[v] = adj[v] | OR reach[w])
    indeg = [0]*n
    for v in range(n):
        x = adj[v]
        while x:
            lb = x & -x; indeg[lb.bit_length()-1] += 1; x ^= lb
    order = []; st = [v for v in range(n) if indeg[v] == 0]
    while st:
        v = st.pop(); order.append(v)
        x = adj[v]
        while x:
            lb = x & -x; w = lb.bit_length()-1; x ^= lb
            indeg[w] -= 1
            if indeg[w] == 0: st.append(w)
    reach = [0]*n
    for v in reversed(order):
        r = adj[v]; x = adj[v]
        while x:
            lb = x & -x; r |= reach[lb.bit_length()-1]; x ^= lb
        reach[v] = r
    # 閉包上の二部マッチング(Kuhn、ビット集合で未訪問の右頂点だけを見る)
    matchR = [-1]*n
    sys.setrecursionlimit(10000)
    def try_(u, vis):
        cand = reach[u] & ~vis[0]
        while cand:
            lb = cand & -cand; w = lb.bit_length()-1
            vis[0] |= lb
            if matchR[w] < 0 or try_(matchR[w], vis):
                matchR[w] = u; return True
            cand = reach[u] & ~vis[0]
        return False
    mt = 0
    for u in range(n):
        if try_(u, [0]): mt += 1
    print(n - mt)
main()

計算量(導出のみ、最悪は未測定): 閉包が $O(NM/64)$、マッチングが最悪 $O(N\cdot N^2/64)$。実測(Python 3.14、M1 Mac)で $N=1000,M=50000$ が 0.38 s(答え 21)、$N=1000,M=1500$ が 0.09 s(答え 432)。

Step-by-Step 解説

1頂点素のパス被覆との違い

各頂点がちょうど 1 本のパスに含まれる(頂点素)なら、元の辺の上の最大マッチングで $N-(\text{マッチング数})$。頂点を共有してよいなら、飛ばした頂点を他のパスが通ってくれるので、$u$ から到達できる任意の $w$ へ「次」を張ってよい。この違いが本問の核。

2推移閉包をビット集合で作る

DAG なのでトポロジカル順に並べ、逆順に reach[v] = adj[v] | OR reach[w]($w$ は $v$ の子)とする。各 reach[v] は $N$ ビットの整数。$N=1000$ なら 1000 ビット整数の OR が $M$ 回。

3閉包上の二部マッチング

左の頂点 $u$ と右の頂点 $w$ を、$w\in\mathrm{reach}[u]$ のとき辺で結ぶ。最大マッチングの大きさを $m$ とすると、最小パス数は $N-m$(マッチングの 1 辺が「パスを 1 本つなぐ」ことに当たり、1 本つなぐごとにパスが 1 本減るから)。

4ビット集合での枝刈り

Kuhn 法の try_ で、まだ訪れていない右頂点の集合を vis として 1 つの整数で持つ。cand = reach[u] & ~vis で候補を一度に求め、訪れるたびに vis を更新して cand を引き直す。リストの走査より速い。

5元の辺でマッチングすると何が外れるか

サンプル 1 のように頂点 3 に 2 本入り 2 本出るグラフでは、元の辺のマッチングは $1\to3$ と $3\to4$(または $3\to5$)の 2 辺までしか張れず(頂点 3 を右に使えるのは 1 回)、答えが $5-2=3$ になる。実測で、頂点 6〜12 の疎なランダム DAG 300 件のうち 4 件で本解と食い違った(まれ)。

迷った他案と採らなかった理由

案計算量採否理由
推移閉包+二部マッチング(本解)$O(N^3/64)$ 程度採用パス全列挙+集合被覆と 300 件全件一致
元の辺で二部マッチング$O(NM)$不採用頂点素のパス被覆の答えになり、共有が要る入力で外れる(疎ランダムで 4/300、入力例 1 は確実に外れる)
パス全列挙+集合被覆の最小指数検算用のみ$N\le9$ でしか動かない
最大反鎖を直接探す直接の方法は未検討不採用Dilworth により値は上と同じ。反鎖そのものはマッチングから König の定理で復元できると記憶(未確認)

前提が変わったら:

  • 頂点素に変える(パスが頂点を共有できない)と、閉包を作らず元の辺でマッチングする。答えは本問より大きくなる
  • $N\ge10^4$ では閉包のビット集合が $10^8$ ビットになり、Python で間に合うかは未測定。閉包を作らずに最大反鎖を別の方法で求める話は本問では扱わない(Hopcroft–Karp にしても閉包の辺数が支配的)
  • 辺に重みがある(パスの総コストを最小にする)と、最小費用流になる。本問の解は使えない

よくあるミス

ミス原因正しい書き方
元の辺でそのままマッチングする頂点素のパス被覆と取り違える推移閉包を作ってからマッチング
閉包をトポロジカル順の順方向に作る子の reach が未計算逆順に `reach[v] = adj[v] \OR reach[w]`
Kuhn 法の再帰が深くなる増加パスの長さが最大 $N$sys.setrecursionlimit を上げる(本解は 10000)
$N-\text{マッチング数}$ を $M-\ldots$ と書く辺数と頂点数の取り違えn - mt

次のステップ

  • 発展1: 最大反鎖の頂点集合を König の定理で復元して出力する
  • 発展2: 頂点素のパス被覆(元の辺でマッチング)との答えの差が最大になる DAG を探す
  • 次回予告: 焦点帯外(M3 以降)を含む通常のローテーション

検証ログ(本セッションで実行した数値)

  • 全列挙との照合(実測): $N\le9$ の 300 件で、DFS で全パスの頂点集合を列挙して集合被覆の最小本数を求めた値とプログラムの出力が全件一致
  • 元の辺でそのままマッチング(実測): 同じ 300 件のうち 1 件で本解と異なり、別の頂点 6〜12 の疎なランダム 300 件では 4 件で異なる。入力例 1 では本解の 2 に対し 3 を出す(まれにしか現れないことが分かった)
  • 入力例(実測): 例 1 は 2、例 2 は 1、例 3 は 3(出力例どおり)
  • 実行時間(実測): $N=1000,M=50000$ で 0.38 s、$N=1000,M=1500$ で 0.09 s
  • 扱っていない範囲: Dilworth の定理とマッチングによる構成の原典確認、最大反鎖の復元、$N\ge10^4$、辺重み付き

自己評価

自分の回答

気づき・メモ