Day 179-Q4 — 2026-10-15 | 最小 s-t カットの個数(最大流の残余グラフで「閉じた集合」を数える。強連結成分の DAG の順序イデアルの数え上げは #P 困難なので、成分ごとの積と分岐のメモ化で数える)

2026-10-15 赤色 Master (Phase 8+) ★★★★★★★★★ フロー(焦点帯 M2)。Day178-Q5 では残余グラフの到達性と強連結成分で「最小カットに必ず入る辺・入りうる辺」を分類した。今回はその先で、最小カットそのものが何通りあるかを数える。最小カットは残余グラフで閉じた頂点集合と 1 対 1 に対応するので、問題は「DAG の順序イデアルの個数」になる。これは一般に多項式時間では無理(#P 困難)と知られているので、$N\le50$ で実際に速く数える工夫をする

問題

頂点 $1..N$、辺 $M$ 本の有向グラフがあり、辺 $i$ は $u_i\to v_i$、容量 $c_i$ である。頂点 $s,t$ が与えられる。

頂点集合 $X$ で $s\in X$、$t\notin X$ のものを s-t カットと呼び、その容量を「$X$ から出て $X$ の外へ入る辺の容量の和」とする。容量が最小の s-t カットの容量と、容量が最小になる $X$ の個数を出力せよ($X$ は頂点集合として区別する。どこにも辺の無い頂点を $X$ に入れるかどうかも別のカットとして数える)。

入力形式

N M s t
u_1 v_1 c_1
...
u_M v_M c_M

制約

#制約
1$2\le N\le50$、$0\le M\le1000$
2$1\le s,t\le N$、$s\ne t$
3$1\le u_i,v_i\le N$、$u_i\ne v_i$、$1\le c_i\le10^9$(多重辺あり)

入出力例

入力例1

5 4 1 5
1 2 1
2 3 1
3 4 1
4 5 1

出力例1

1
4

(一本道。どの辺を切っても容量 1 で、$X=\{1\},\{1,2\},\{1,2,3\},\{1,2,3,4\}$ の 4 通り。自由な頂点 2,3,4 を独立に選べると思うと $2^3=8$ と誤る)

入力例2

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

出力例2

3
16

(頂点 7 はどこにも辺が無いので、7 を入れるかどうかで 2 倍。残る 2,3,4,5 には「3 を入れるなら 2 も」「5 を入れるなら 4 と 2 も」という制約が付き、8 通り。全部で 16。全カットの列挙と一致)

背景と出典

  • 最小カットが残余グラフで閉じた集合と 1 対 1 に対応すること、そして成分の DAG の「閉じた集合」が最小カットの全体を表すことは Picard–Queyranne 1980 と記憶している(原典は未確認、Day178-Q5 でも同じ注記)
  • 最小カットの個数を数える問題が #P 完全であることは Provan–Ball 1983("The complexity of counting cuts and of computing the probability that a graph is connected")と記憶している(未確認)。だから本問の数え上げは指数時間で、本解の速さは入力の形に依存する
  • 本問の問題文・制約・入力例は自作

概念図

入力例 2 の残余グラフ(流量 3 で全辺が飽和 → 残るのは逆向きの辺だけ)。u → v は「u を S に入れるなら v も」 1 2 3 4 5 6 7 s:必ず S t:必ず外 孤立:×2 自由な {2,3,4,5} の閉じた集合(8 個) ∅ {2} {2,3} {4} {2,4} {2,3,4} {2,4,5} {2,3,4,5} × 頂点 7 の 2 通り = 16(全列挙と一致) 2^(自由な成分数) = 2^5 = 32 は誤り (3 を入れたら 2 も、5 を入れたら 4 と 2 も) 一般には DAG の順序イデアルの数え上げ = #P 困難 → 積への分解+分岐+メモ化

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

ヒント1(方向性)

最大流 $f$ を 1 つ求めると、どの s-t カット $X$ についても「容量 − 流量 = $X$ から外へ出る残余辺の残余容量の和」になる。最小カットになる条件を残余グラフの言葉で言い換える。

ヒント2(アプローチ)

$X$ が最小カット ⇔ 残余辺 $u\to v$ で $u\in X$、$v\notin X$ となるものが無い ⇔ $X$ は残余グラフの到達で閉じている。強連結成分を 1 点に縮めると DAG になり、「後続をすべて含む成分集合」を数える問題になる。$s$ から届く成分は必ず入り、$t$ へ届く成分は必ず外。

ヒント3(誘導)
  • 残りの「自由な成分」について、比較可能(どちらかからどちらかへ届く)な関係で連結成分に分け、独立な部分の個数は積にする
  • 連結なら、比較可能な相手が最も多い成分 $v$ で場合分け: $v\in X$ なら $v$ の後続をすべて入れて取り除く、$v\notin X$ なら $v$ の先行をすべて外して取り除く。残りの成分集合(ビット列)でメモ化

模範解答 (Python)

模範解答を表示する(まず自力で解いてから)
import sys
from collections import deque


def max_flow(n, edges, s, t):
    """Dinic。残余容量つきの隣接リスト (to, cap, rev) を返す"""
    g = [[] for _ in range(n)]
    for u, v, c in edges:
        g[u].append([v, c, len(g[v])])
        g[v].append([u, 0, len(g[u]) - 1])
    flow = 0
    while True:
        level = [-1] * n
        level[s] = 0
        dq = deque([s])
        while dq:
            u = dq.popleft()
            for v, c, _ in g[u]:
                if c > 0 and level[v] < 0:
                    level[v] = level[u] + 1
                    dq.append(v)
        if level[t] < 0:
            return flow, g
        it = [0] * n

        def dfs(u, f):
            if u == t:
                return f
            while it[u] < len(g[u]):
                e = g[u][it[u]]
                v, c, r = e
                if c > 0 and level[v] == level[u] + 1:
                    d = dfs(v, min(f, c))
                    if d:
                        e[1] -= d
                        g[v][r][1] += d
                        return d
                it[u] += 1
            return 0

        while True:
            f = dfs(s, 1 << 62)
            if not f:
                break
            flow += f


def scc(n, adj):
    """Tarjan(反復)。comp[v] を返す"""
    index = [0] * n
    low = [0] * n
    onst = [False] * n
    comp = [-1] * n
    st = []
    cnt = 1
    nc = 0
    for r in range(n):
        if index[r]:
            continue
        work = [(r, 0)]
        index[r] = low[r] = cnt; cnt += 1
        st.append(r); onst[r] = True
        while work:
            u, i = work[-1]
            if i < len(adj[u]):
                work[-1] = (u, i + 1)
                v = adj[u][i]
                if not index[v]:
                    index[v] = low[v] = cnt; cnt += 1
                    st.append(v); onst[v] = True
                    work.append((v, 0))
                elif onst[v]:
                    low[u] = min(low[u], index[v])
            else:
                work.pop()
                if work:
                    p = work[-1][0]
                    low[p] = min(low[p], low[u])
                if low[u] == index[u]:
                    while True:
                        w = st.pop(); onst[w] = False
                        comp[w] = nc
                        if w == u:
                            break
                    nc += 1
    return nc, comp


def main():
    data = sys.stdin.buffer.read().split()
    n, m, s, t = (int(x) for x in data[:4])
    s -= 1; t -= 1
    edges = []
    for i in range(m):
        u, v, c = (int(x) for x in data[4 + 3 * i:7 + 3 * i])
        edges.append((u - 1, v - 1, c))
    flow, g = max_flow(n, edges, s, t)
    # 残余グラフ: 容量の残る辺 u->v。S が最小カット ⇔ s∈S, t∉S, S が残余辺で閉じている
    radj = [[v for v, c, _ in g[u] if c > 0] for u in range(n)]
    nc, comp = scc(n, radj)
    succ = [0] * nc                    # 成分 DAG の「到達できる成分」のビット集合(推移閉包)
    direct = [0] * nc
    for u in range(n):
        for v in radj[u]:
            if comp[u] != comp[v]:
                direct[comp[u]] |= 1 << comp[v]
    # Tarjan の成分番号は逆トポロジカル順(後続が小さい番号)なので小さい順に閉包を作れる
    for c in range(nc):
        r = 1 << c
        d = direct[c]
        while d:
            b = d & -d
            r |= succ[b.bit_length() - 1]
            d ^= b
        succ[c] = r
    pred = [0] * nc
    for c in range(nc):
        x = succ[c]
        while x:
            b = x & -x
            pred[b.bit_length() - 1] |= 1 << c
            x ^= b
    cs, ct = comp[s], comp[t]
    forced_in = succ[cs]               # s から届く成分は必ず S
    forced_out = pred[ct]              # t へ届く成分は必ず T
    free = ((1 << nc) - 1) & ~forced_in & ~forced_out
    nb = [succ[c] | pred[c] for c in range(nc)]   # 比較可能な成分(連結成分分けに使う)

    sys.setrecursionlimit(10000)
    memo = {}

    def count(mask):
        """mask 内の成分について「後続で閉じた部分集合」の数"""
        if mask == 0:
            return 1
        if mask in memo:
            return memo[mask]
        # 比較可能性で連結成分に分け、独立なら積
        low = mask & -mask
        comp_mask = low
        frontier = low
        while frontier:
            b = frontier & -frontier
            frontier ^= b
            nxt = nb[b.bit_length() - 1] & mask & ~comp_mask
            comp_mask |= nxt
            frontier |= nxt
        if comp_mask != mask:
            res = count(comp_mask) * count(mask & ~comp_mask)
        else:
            # 比較可能な相手が最も多い成分 v で場合分け
            best, bv = -1, 0
            x = mask
            while x:
                b = x & -x
                x ^= b
                c = b.bit_length() - 1
                k = (nb[c] & mask).bit_count()
                if k > best:
                    best, bv = k, c
            res = count(mask & ~succ[bv]) + count(mask & ~pred[bv])
            # v∈S なら後続すべて S(決まったので除く)、v∉S なら先行すべて T
        memo[mask] = res
        return res

    print(flow)
    print(count(free))


main()

計算量: 最大流は Dinic で $O(N^2M)$ 以下、成分と推移閉包は $O(N^2)$。数え上げは最悪指数(#P 困難)だが、メモ化の状態数は本セッションの入力で最大 6518 個。$N=40$ で最小カットが $1.3\times10^9$ 通りある入力も 0.01 s、$N=100$ の乱択 DAG(状態 6181 個)で 0.03 s(Python 3.14.8、Apple M5 Pro、起動込み)。

Step-by-Step 解説

1カットの容量と残余容量

最大流 $f$(流量 $|f|$)を 1 つ取る。s-t カット $X$ について、流量保存から

$$\mathrm{cap}(X)-|f|=\sum_{u\in X,\,v\notin X}\bigl(c(u,v)-f(u,v)\bigr)+\sum_{u\notin X,\,v\in X}f(u,v)$$

右辺はちょうど「$X$ から外へ出る残余辺の残余容量の和」。すべて非負なので、$X$ が最小 ⇔ 右辺が 0 ⇔ $X$ から外へ出る残余辺が 1 本も無い。これはどの最大流を選んでも同じ集合族になる。

2閉じた集合 = 成分 DAG の順序イデアル

「$u\in X$ で残余辺 $u\to v$ があれば $v\in X$」なので、$X$ は残余グラフの到達で閉じている。強連結成分は丸ごと入るか丸ごと外なので成分を 1 点に縮め、DAG の上で「後続をすべて含む集合」を数える。$s$ を含むので $s$ から届く成分は全部入り、$t$ を含まないので $t$ へ届く成分は全部外。残りの成分が自由。

3独立な部分は積

比較可能でない成分どうしは互いに制約しない。比較可能性で連結成分に分けると、それぞれの数え上げの積になる。入力例 2 の頂点 7 は誰とも比較可能でないので 2 倍。

4分岐とメモ化

連結な成分集合では、比較可能な相手が最も多い成分 $v$ で分ける。$v\in X$ なら $v$ の後続(推移閉包)がすべて $X$、$v\notin X$ なら $v$ の先行がすべて外。どちらでも $v$ と一緒に多くの成分が決まり、残りがまた分かれやすい。残りの成分集合をビット列にしてメモ化する。Python の整数ならビット列が 50 ビットを超えても問題ない。

5入力例 2 を手で

最大流 3($1\!-\!2\!-\!3\!-\!6$、$1\!-\!4\!-\!5\!-\!6$、$1\!-\!2\!-\!5\!-\!6$)で全辺が飽和し、残余辺は逆向きだけ: $3\to2$、$5\to4$、$5\to2$、$2\to1$、$4\to1$、$6\to3$、$6\to5$。$s=1$ から届くのは $\{1\}$、$t=6$ へ届くのは $\{6\}$。自由な $\{2,3,4,5\}$ の閉じた集合は $\varnothing,\{2\},\{2,3\},\{4\},\{2,4\},\{2,3,4\},\{2,4,5\},\{2,3,4,5\}$ の 8 個、頂点 7 で 2 倍して 16。

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

案計算量採否理由
$2^{N-2}$ 通りを全部試す$O(2^NM)$検算用のみ$N\le14$ が限度
自由な成分を独立とみて $2^{(\text{自由な成分数})}$$O(N^2M)$不採用(誤り)600 入力中 222 で誤る(実測)。入力例 1 で 8
「$s$ から届く集合」の 1 通りだけ$O(N^2M)$不採用(誤り)600 入力中 364 で最小カットが 2 通り以上ある(実測)
飽和辺の組合せで数える—不採用同じ辺集合を与える頂点集合が複数ありうる(入力例 2 の頂点 7)。本問は頂点集合で数える
成分 DAG を位相順に並べ、境界のビット DP幅に指数不採用反鎖が大きい(幅 19 の二部 DAG)と状態が爆発する。分岐+積の方が今回の入力では速かった(未計測の比較、理由は構造から)
積への分解+分岐+メモ化(本解)最悪指数採用状態数は最大 6518 個(実測)

前提が変わったら

  • $N$ が数百なら、本解の最悪ケース(比較可能性が密でも疎でもない幅の広い順序)は避けられない。#P 困難なので、近似(乱択で一様に近い数え上げ)か、木幅の小さいグラフに限るなどの追加の前提が要る
  • 数えるのが辺集合(同じ辺集合になるカットを 1 つと数える)なら、頂点集合の数え上げから「辺に触れない成分の自由度」を割り引く必要があり、単純な割り算ではない。本セッションでは扱っていない
  • 無向グラフなら各辺を両向きにして同じ方法が使える。大域最小カット($s,t$ を決めない)の個数は $O(N^2)$ 以下と知られている(Karger と記憶、未確認)ので、話が全く変わる

よくあるミス

ミス原因正しい書き方
自由な成分の数 $k$ で $2^k$閉じている条件を忘れる後続で閉じた集合だけ数える(入力例 1)
強連結成分に分けずに頂点で数える閉路上の頂点が同時に動くことを忘れる成分を 1 点に縮める
残余グラフに容量 0 の辺を入れる逆辺を無条件に足す残余容量 $>0$ の辺だけ
推移閉包でなく直接の後続だけで分岐「$v\in X$ なら後続全部」を 1 段で止めるsucc を推移閉包にする
孤立頂点を忘れる辺の無い頂点はカットに関係ないと思う頂点集合で数えるので 2 倍(入力例 2)

次のステップ

  • 発展1: 最小カットを辞書順に $k$ 番目まで列挙する(分岐の木をたどる)
  • 発展2: 成分 DAG の幅(最大反鎖、Dilworth)を求め、幅が小さいときは境界ビット DP に切り替えて速さを比べる
  • 発展3: 無向グラフの大域最小カットの個数を Karger の縮約で数え、全列挙と比べる

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

  • 全探索との一致(実測): $N=2..12$、$M=1..25$、容量 1〜4 の乱択 600 グラフで、$2^{N-2}$ 通りの全列挙と最小容量・個数とも全件一致(個数 1 が 236 グラフ、最大 768 通り)
  • 重い形との一致(実測): $N=16$、流量 0 で残り 14 頂点が二部 DAG・乱択 DAG・平面の点の支配順序の 60 グラフで全列挙と一致
  • 誤った方法(実測、同じ 600 グラフ): $2^{(\text{自由な成分数})}$ は 222 で誤り。1 通りと答えると 364 で誤り
  • 時間と状態数(実測、起動込み): $N=40$ の二部 DAG(個数 $8.2\times10^8$〜$1.3\times10^9$)0.01 s・状態 1136 以下、$N=50$ の二部 DAG 0.01〜0.02 s・状態最大 6518、$N=100$ の乱択 DAG 0.03 s 以下・状態最大 6181、幅 19 の crown($a_i<b_j$、$i\ne j$)0.01 s・状態 109
  • 扱っていない範囲: 原典の確認、最悪ケースの構成(状態数を最大にする入力は探していない)、辺集合としての数え上げ

自己評価

自分の回答

気づき・メモ