問題
頂点 $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")と記憶している(未確認)。だから本問の数え上げは指数時間で、本解の速さは入力の形に依存する
- 本問の問題文・制約・入力例は自作
概念図
ヒント(クリックで段階的に開示)
ヒント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
- 扱っていない範囲: 原典の確認、最悪ケースの構成(状態数を最大にする入力は探していない)、辺集合としての数え上げ