問題
$N$ 頂点 $M$ 辺の連結な無向グラフがあり、辺 $i$ は頂点 $u_i,v_i$ を結び、費用 $a_i$ と利得 $b_i$ を持つ。全域木 $T$ の比
$$\rho(T)=\frac{\sum_{i\in T}a_i}{\sum_{i\in T}b_i}$$
の最小値を既約分数 p/q で出力せよ($\rho=0$ なら 0/1)。
入力形式
N M
u_1 v_1 a_1 b_1
...
u_M v_M a_M b_M
制約
| # | 制約 |
|---|---|
| 1 | $2\le N\le10^4$、$N-1\le M\le10^5$ |
| 2 | $0\le a_i\le10^6$、$1\le b_i\le10^6$ |
| 3 | グラフは連結(多重辺はありうる、自己ループはない) |
入出力例
入力例1
3 3
1 2 2 2
1 3 3 8
3 2 1 1出力例1
4/9(全域木は 3 つ: $\{12,13\}$ で $5/10$、$\{12,32\}$ で $3/3$、$\{13,32\}$ で $4/9$。最適な木は、1 辺だけの比で見ると最悪の $1/1$ の辺 3-2 を含む)
入力例2
2 2
1 2 0 5
1 2 3 1出力例2
0/1入力例3
4 5
1 2 6 2
2 3 4 4
3 4 5 1
4 1 2 3
1 3 7 7出力例3
13/14(3 例とも全域木の全列挙と一致)
背景と出典
- 最小比率全域木(minimum ratio spanning tree)は Chandrasekaran(1977, *Networks*)が扱った、と私は記憶している(原典は本セッションで未確認)。競技プログラミングでは POJ 2728「Desert King」(完全グラフ上の最適比率全域木)が有名だと記憶している(未確認)
- Dinkelbach 法(Dinkelbach 1967): $\lambda_{k+1}=\rho(T_k)$、$T_k=\arg\min_T\sum(a-\lambda_kb)$ を繰り返す。分数計画に対する Newton 法で、超 1 次収束する。組合せ最適化の比の問題では反復回数が強多項式で抑えられる(Radzik 1992 と記憶、未確認)。本問の実測では Kruskal 4〜7 回で止まった
- 本問は自作。Day131-Q2(K 個選択の比)と同じ法を、判定問題が最小全域木になる形に移した
概念図
ヒント(クリックで段階的に開示)
ヒント1(方向性)
「最小値が $\lambda$ 以下か」は「$\sum a\le\lambda\sum b$ となる木があるか」、つまり「$\sum(a_i-\lambda b_i)\le0$ の木があるか」。重み $a_i-\lambda b_i$ の最小全域木を 1 本作れば判定できる。
ヒント2(アプローチ)
二分探索でもよいが、浮動小数の $\lambda$ では答えを既約分数にできない。Dinkelbach 法なら、$\lambda$ を毎回「実際にある木の比」に更新するので、$\lambda$ は常に分数 $p/q$ で、終わったときの $\lambda$ がそのまま答え。
ヒント3(誘導)
- $\lambda=p/q$ のとき、重み $a_i-\lambda b_i$ を $q$ 倍した $a_iq-b_ip$(整数)で辺を並べても、最小全域木は同じ
- 初期値は $\lambda=0$($\sum a$ 最小の木)の比
- 最小全域木 $T$ の $\sum a\cdot q-\sum b\cdot p\ge0$ なら $\lambda$ が最適。そうでなければ $\lambda\leftarrow\sum a/\sum b$(既約にする)
模範解答 (Python)
模範解答を表示する(まず自力で解いてから)
import sys
from math import gcd
def main():
data = sys.stdin.buffer.read().split()
n, m = int(data[0]), int(data[1])
U = [int(x) - 1 for x in data[2:2 + 4 * m:4]]
V = [int(x) - 1 for x in data[3:3 + 4 * m:4]]
A = [int(x) for x in data[4:4 + 4 * m:4]]
B = [int(x) for x in data[5:5 + 4 * m:4]]
def kruskal(p, q):
"""辺の重み a_i*q - b_i*p(= q·(a_i - λ b_i)、λ = p/q)で最小全域木を作り、Σa と Σb を返す"""
key = [a * q - b * p for a, b in zip(A, B)]
order = sorted(range(m), key=key.__getitem__)
par = list(range(n))
sa = sb = 0
need = n - 1
for e in order:
x, y = U[e], V[e]
while par[x] != x:
par[x] = par[par[x]]
x = par[x]
while par[y] != y:
par[y] = par[par[y]]
y = par[y]
if x != y:
par[x] = y
sa += A[e]
sb += B[e]
need -= 1
if need == 0:
break
return sa, sb
# Dinkelbach: λ_0 は任意の全域木の比。λ_{k+1} = (T_k の Σa)/(T_k の Σb)、T_k は a - λ_k b の最小全域木
p, q = kruskal(0, 1) # λ = 0 のときの木(Σa 最小の木)から始める
g = gcd(p, q)
p, q = p // g, q // g
while True:
sa, sb = kruskal(p, q)
if sa * q - sb * p >= 0: # min Σ(a - λb) = 0 なら λ が最適
break
g = gcd(sa, sb)
p, q = sa // g, sb // g
print(f"{p}/{q}")
main()
計算量: Kruskal 1 回 $O(M\log M)$、それを $K$ 回。$N=10^4,M=10^5$ で $K=5\sim7$、0.60〜0.69 s(Python 3.14.7、M1 MacBook Pro 8GB、負荷平均 11〜18 での実測、起動込み)。重みの整数は $|aq-bp|\le10^6\cdot\sum b\le10^{16}$ 程度。
Step-by-Step 解説
1比の最小化を判定に直す
$\rho^*=\min_T\rho(T)$ とすると、$h(\lambda)=\min_T\sum_{i\in T}(a_i-\lambda b_i)$ について $h(\lambda)\ge0\iff\lambda\le\rho^*$($b>0$ なので $\sum b>0$)。$h$ は 1 次関数の最小なので上に凸で減少し、根がちょうど $\rho^*$。
2判定は最小全域木
$h(\lambda)$ は辺の重みを $a_i-\lambda b_i$ にした最小全域木の重み。Kruskal なら辺の順序だけが要るので、$q>0$ 倍した整数 $a_iq-b_ip$ で並べてよい。
3Dinkelbach の更新
$\lambda_k=\rho(T_{k-1})$ から、$T_k=\arg\min\sum(a-\lambda_kb)$ を作り $\lambda_{k+1}=\rho(T_k)$。$h(\lambda_k)\le\sum_{T_{k-1}}(a-\lambda_kb)=0$ なので $\lambda_{k+1}\le\lambda_k$。$h(\lambda_k)=0$ になったら $\lambda_k=\rho^*$。木の個数は有限なので必ず止まり、凸な $h$ の Newton 法なので反復は少ない。
4既約分数
$\lambda$ は毎回ある木の比 $\sum a/\sum b$ で、$\gcd$ で割って持つ。最後の $\lambda$ をそのまま出力すれば既約分数になる。
迷った他案と採らなかった理由
| 案 | 計算量 | 採否 | 理由 |
|---|---|---|---|
全域木の全列挙(Fraction) | 指数 | 検算用のみ | $N\le7,M\le12$ |
| 浮動小数の二分探索(60 回) | $60\,M\log M$ | 不採用(遅く、分数に戻せない) | 4.90〜6.64 s($N=10^4,M=10^5$、実測)。最後の木の比を Fraction にすれば値は合うが、$\lambda$ 自体を limit_denominator で分数に戻すと 2 入力とも誤り(例 555533501/5059507539、正しくは 651453272/5933094467) |
| $\sum a$ 最小の木の比 | $M\log M$ | 不採用(誤り) | 500 グラフ中 154 で誤答(実測) |
| 1 辺の比 $a/b$ の小さい順に Kruskal | $M\log M$ | 不採用(誤り) | 500 グラフ中 86 で誤答(実測)。入力例 1 が反例 |
| $\sum b$ 最大の木の比 | $M\log M$ | 不採用(誤り) | 500 グラフ中 279 で誤答(実測) |
| 整数の Dinkelbach(本解) | $K\,M\log M$、$K=4\sim7$ | 採用 | 0.60〜0.69 s |
前提が変わったら
- 完全グラフ(POJ 2728 型、$M=N^2/2$)なら Kruskal の整列が重いので、各反復を Prim 法 $O(N^2)$ にする
- $b_i$ に負や 0 があると $\sum b$ の符号が木によって変わり、判定の向きが壊れる。本問の $b\ge1$ はそのための条件
- $N=10^5,M=2\times10^5$ では 2.61〜3.37 s(実測、Kruskal 6〜7 回)。整列を前回の順序からの挿入ソートにするなどの工夫が要る(未実装)
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
| 浮動小数で $a-\lambda b$ を比べる | 小数で十分だと思う | $\lambda=p/q$ を分数で持ち、$aq-bp$ の整数で比べる |
| 1 辺ごとの比 $a/b$ で並べる | 比の最小化を辺ごとの貪欲と混同 | 86/500 で誤答(実測) |
| 最初の $\lambda$ を 0 にしたまま「$h(0)\ge0$ なら答え 0」と判定し忘れる | 終了条件の書き漏れ | 初期値も実在の木の比にしておけば、同じ終了条件で扱える(入力例 2) |
| 既約にせずに出力する | $\sum a/\sum b$ をそのまま出す | $\gcd$ で割る |
| 判定の向きを逆にする | $h(\lambda)<0$ の意味の取り違え | $h(\lambda)<0$ なら $\lambda$ より小さい比の木がある |
次のステップ
- 発展1: 最小比率の有向全域木(最小有向木、Chu–Liu/Edmonds)に Dinkelbach を載せる
- 発展2: 最小平均閉路(Karp)と最小比率閉路の関係を、本問と同じ判定の形で書き直す
- 発展3: Radzik(1992)の反復回数の上界を原典で確かめる(本セッションでは未確認)
検証ログ(本セッションで実行した数値)
- 全域木の全列挙との照合(実測): $N=2\sim7$、$M\le12$、$a,b$ の上限 3・10・1000 のランダム連結グラフ 500 個(多重辺あり)。既約分数が全件一致
- 誤った方法(実測、同じ 500 グラフ): $\sum a$ 最小 154、辺の比の貪欲 86、$\sum b$ 最大 279 で誤答
- 反例探し(実測): 3 頂点 3 辺・$a,b\le9$ で、辺の比の貪欲と $\sum a$ 最小の両方が誤る例のうち $\sum(a+b)$ 最小のものを入力例 1 にした
- 実行時間と反復回数(実測、起動込み): $N=10^4,M=10^5$ で Kruskal 5 回 0.60 s・7 回 0.69 s。$N=2000,M=2\times10^5$ で 4 回 0.66 s・7 回 1.14 s。$N=10^5,M=2\times10^5$ で 5 回 2.61 s・6 回 3.37 s。浮動小数の二分探索 60 回は $N=10^4$ で 4.90〜6.64 s
- 扱っていない範囲: 完全グラフ向けの Prim 版、反復回数の理論上界、原典の確認