問題
平面上に $N$ 個の村があり、村 $i$ は位置 $(x_i,y_i)$、標高 $z_i$ にある。全村を水路でつなぐため、$N-1$ 本の水路を選んで全村を連結にする(全域木)。
村 $i,j$ を結ぶ水路の費用は標高差 $|z_i-z_j|$、長さは平面上の距離 $\sqrt{(x_i-x_j)^2+(y_i-y_j)^2}$ である。選んだ水路の(費用の合計)÷(長さの合計)を最小にせよ。絶対誤差または相対誤差が $10^{-6}$ 以下なら正解とする。
入力形式
N
x_1 y_1 z_1
...
x_N y_N z_N
制約
| # | 制約 |
|---|---|
| 1 | $2\le N\le1000$ |
| 2 | $0\le x_i,y_i<10^4$、$0\le z_i<10^7$、すべて整数 |
| 3 | 位置 $(x_i,y_i)$ はすべて異なる |
入出力例
入力例1
4
0 0 0
0 1 1
1 1 2
1 0 3出力例1
1.0000000000(1 辺 1 の正方形の 4 頂点。外周を村 1→2→3→4 とたどる木は費用 3・長さ 3 で比 1。対角線(長さ $\sqrt2$)を使う木はどれもこれより悪い)
入力例2
4
4 4 4
1 2 3
3 3 6
2 2 2出力例2
0.6370894117(最適は村 1 から他の 3 村へ張る星形で、費用 5・長さ 7.848…。費用だけ最小の木(費用 4)は 0.6645、辺ごとの比 $c/l$ の小さい順に Kruskal で選ぶと 0.6920 で、どちらも誤り。2 例とも全域木の全列挙と一致)
背景と出典
- POJ 2728「Desert King」が同じ設定(村の座標と標高、費用 = 標高差、長さ = 距離、比を最小化、$N\le1000$)の問題だと私は記憶している(問題文は本セッションで未確認。入力例 1 はその入力例として記憶しているもの)。本問の制約と入力例 2 は自作
- Dinkelbach 法(Dinkelbach 1967、Management Science と記憶、未確認): 比 $\sum c/\sum l$ の最小化を、$\lambda$ を固定した $\min\sum(c-\lambda l)$ の繰り返しに直す。新しい $\lambda$ を今の解の比にすると Newton 法になり、少ない回数で止まる。Day131・Day176-Q4 と同じ枠組み
- Prim 法の $O(N^2)$ 版は、ヒープを使わず「木からの最短の重み」を配列で持って毎回最小を探す形。辺が $\Theta(N^2)$ 本ある密なグラフでは、ヒープ版 $O(M\log N)$ や Kruskal $O(M\log M)$ より速い
概念図
ヒント(クリックで段階的に開示)
ヒント1(方向性)
答えを $\lambda^*$ とすると、すべての全域木 $T$ で $\sum_T c-\lambda^*\sum_T l\ge0$ で、最適な木で等号。つまり「重み $c-\lambda l$ の最小全域木の重みが 0 になる $\lambda$」を探す問題になる。
ヒント2(アプローチ)
- $\lambda$ を固定すれば、ただの最小全域木
- 二分探索なら $\lambda$ を 50 回ほど試す。Dinkelbach 法なら、今の木の比 $\sum c/\sum l$ を次の $\lambda$ にするだけで数回で止まる
- 完全グラフでは辺の本数が $N(N-1)/2$。辺を並べて整列すると 1 回ごとに $O(N^2\log N)$
ヒント3(誘導)
- Prim:
key[j]= 木の頂点から $j$ への最小の重み、par[j]= そのときの木側の頂点 - 頂点 $u$ を木に入れたら、行
C[u],L[u]からC[u][j] - lam*L[u][j]を作り、keyと比べて小さい方を取る。木に入った頂点はINFを足して選ばれないようにする - Python ではこの更新を内包表記 1 本で書くと、1 頂点あたりの Python の命令が $N$ 回の単純な比較だけになる
模範解答 (Python)
模範解答を表示する(まず自力で解いてから)
import sys
from math import hypot
def main():
data = sys.stdin.buffer.read().split()
n = int(data[0])
X = [int(v) for v in data[1:1 + 3 * n:3]]
Y = [int(v) for v in data[2:2 + 3 * n:3]]
Z = [int(v) for v in data[3:3 + 3 * n:3]]
if n == 1:
print("0.0000000000")
return
# 完全グラフなので辺を並べず、行列の行をその場で作る
C = [[abs(Z[i] - zj) for zj in Z] for i in range(n)] # 費用(高さの差)
L = [[hypot(X[i] - xj, Y[i] - yj) for xj, yj in zip(X, Y)] for i in range(n)] # 長さ
INF = float('inf')
def prim(lam):
"""重み C - lam*L の最小全域木を O(N^2) の Prim で求め、(Σ費用, Σ長さ) を返す"""
key = [INF] * n
par = [0] * n
block = [0.0] * n # 木に入った頂点は INF にして二度と選ばない
key[0] = 0.0
sc = sl = 0.0
u = 0
for _ in range(n - 1):
block[u] = INF
key[u] = INF
Cu, Lu = C[u], L[u]
row = [c - lam * l + b for c, l, b in zip(Cu, Lu, block)]
par = [u if r < k else p for r, k, p in zip(row, key, par)]
key = [r if r < k else k for r, k in zip(row, key)]
m = min(key)
v = key.index(m)
p = par[v]
sc += C[p][v]
sl += L[p][v]
u = v
return sc, sl
lam = 0.0
for _ in range(100): # Dinkelbach(Newton 法)。実際は数回で止まる
sc, sl = prim(lam)
nl = sc / sl
if nl >= lam - 1e-12 * max(1.0, lam) and _ > 0:
lam = min(lam, nl)
break
lam = nl
print(f"{lam:.10f}")
main()
計算量: Prim 1 回 $O(N^2)$、Dinkelbach の回数 $K$ 回で $O(KN^2)$。行列 2 枚で $O(N^2)$ のメモリ。$N=1000$ の 4 入力で $K=3\sim5$、0.40〜1.09 s・70〜102 MB(Python 3.14.8、Apple M5 Pro、負荷平均 4〜13 での実測、起動込み)。
Step-by-Step 解説
1比を「引き算の符号」に直す
$F(\lambda)=\min_T\bigl(\sum_T c-\lambda\sum_T l\bigr)$ とする。$\sum l>0$ なので $F$ は $\lambda$ について狭義減少で、$F(\lambda^*)=0$ となる $\lambda^*$ が答え。$F(\lambda)<0$ なら $\lambda$ は大きすぎ、$F(\lambda)>0$ なら小さすぎ。
2Dinkelbach 法
$\lambda_0=0$ から、$\lambda_k$ での最小木 $T_k$ の比 $\lambda_{k+1}=\sum_{T_k}c/\sum_{T_k}l$ に進む。$F$ は直線(木ごとの $\sum c-\lambda\sum l$)の最小で上に凸の折れ線、$\lambda_{k+1}$ は $T_k$ の直線と $F=0$ の交点なので Newton 法。$\lambda$ は単調に減り、木が変わらなくなったら止まる。
3完全グラフでの Prim
辺を作らずに、木に入れた頂点 $u$ の行 C[u], L[u] で key を更新する。1 回の更新は長さ $N$ のリストの内包表記 3 本(重みの行、親の更新、key の更新)で、$N-1$ 回で $O(N^2)$。既に木に入った頂点は block に INF を入れて、key が下がらないようにする。
4浮動小数の扱い
長さが無理数なので Day176-Q4 のような分数の厳密計算はできない。止める条件は「新しい $\lambda$ が今の $\lambda$ より相対 $10^{-12}$ 以上小さくならない」。全列挙との照合では、誤差 $10^{-6}$ を超えた入力は 0 個だった。
5回数と時間
$N=1000$ の乱数 3 入力で $K=4,5,4$、格子状に並べた入力で $K=3$。二分探索(50 回)にすると同じ入力で 7.33 s、Kruskal(50 万本を毎回整列)にすると 3.14 s で、本解の 0.54 s より遅い。
迷った他案と採らなかった理由
| 案 | 1 回の最小全域木 | 回数 | 採否 | 理由 |
|---|---|---|---|---|
| 全域木の全列挙(Prüfer 列) | — | $N^{N-2}$ | 検算用のみ | $N\le7$ まで |
| 費用だけ最小の木の比 | $O(N^2)$ | 1 | 不採用(誤り) | 500 入力中 272 で誤答(実測)。入力例 2 で 0.6645 |
| 長さの合計が最大の木の比 | $O(N^2)$ | 1 | 不採用(誤り) | 500 入力中 367 で誤答(実測) |
| 辺ごとの比 $c/l$ の小さい順に Kruskal | $O(N^2\log N)$ | 1 | 不採用(誤り) | 500 入力中 159 で誤答(実測)。入力例 2 で 0.6920 |
| $\lambda$ の二分探索 + $O(N^2)$ Prim | $O(N^2)$ | 50 | 不採用(遅い) | 正しいが $N=1000$ で 7.33 s(実測) |
| Dinkelbach + Kruskal(辺 50 万本を毎回整列) | $O(N^2\log N)$ | 4〜5 | 不採用(遅い) | 正しいが $N=1000$ で 3.14 s(実測) |
| Dinkelbach + $O(N^2)$ Prim(本解) | $O(N^2)$ | 3〜5 | 採用 | 0.40〜1.09 s |
前提が変わったら
- $N$ が $10^4$ 以上なら行列 2 枚($2N^2$ 個の数)が載らない($N=1000$ で 102 MB)。行列を持たず、行を毎回座標から計算する(時間は増える)か、NumPy で行を配列演算にする
- 辺が疎($M\ll N^2$)なら Day176-Q4 の Kruskal に戻す
- 費用も長さも整数なら、$\lambda$ を既約分数で持って重みを整数にし(Day176-Q4)、浮動小数の止め方の問題を消せる
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
| 辺ごとの比 $c/l$ で貪欲に選ぶ | 「比の和」と「和の比」の混同 | 和の比は Dinkelbach か二分探索(入力例 2) |
木に入った頂点の key が再び下がる | 内包表記で全頂点を一律に更新 | block に INF を足して除く |
| 完全グラフで辺を全部作って Kruskal | 疎なグラフの習慣 | 密なグラフは $O(N^2)$ の Prim |
| Dinkelbach を $\lambda$ が完全に等しくなるまで回す | 浮動小数で等号を待つ | 相対 $10^{-12}$ 以上減らなくなったら止める |
| 長さ 0 の辺で割る | 同じ位置の村 | 制約で位置はすべて異なる。外せば $\sum l>0$ が崩れる |
次のステップ
- 発展1: 行列を持たない版(行を毎回座標から作る)で、$N=3000$ の時間とメモリを測る
- 発展2: Dinkelbach の回数が $N$ や値の範囲でどう変わるかを、$N=100\sim1000$ で数える
- 発展3: 最適比率の最短路(負閉路判定が要る)と、本問の全域木版を比べ、どちらも「$\lambda$ を固定した最小化」に帰着することを確かめる
検証ログ(本セッションで実行した数値)
- 全列挙との照合(実測): $N=2\sim7$、座標 $0..20$、標高 $0..30$ の 500 入力で、Prüfer 列による全域木の全列挙の最小比と、誤差 $10^{-6}$ 以内で全件一致
- 誤った方法(実測、同じ 500 入力): 費用最小の木 272、長さ最大の木 367、辺の比で Kruskal 159 で誤答
- 時間(実測、起動込み): $N=1000$ の乱数 3 入力で 0.57〜1.09 s($K=4,5,4$)、格子状 1 入力で 0.40 s($K=3$)。メモリ 70〜102 MB
- 他案の時間(実測、同じ乱数 1 入力、答えはすべて 1.6895155674): 本解 0.54 s、二分探索 50 回 7.33 s、Dinkelbach+Kruskal 3.14 s
- 扱っていない範囲: POJ 2728 の問題文の確認、$N>1000$、NumPy 版