Day 177-Q4 — 2026-10-13 | 完全グラフの最適比率全域木(辺を並べずに O(N^2) の Prim を Dinkelbach 法で数回だけ回す)

2026-10-13 赤色 Master (Phase 8+) ★★★★★★★★★ グラフ(焦点帯 M2)。Day176-Q4 は辺が $M\le10^5$ 本の疎なグラフで、比 $\lambda$ を分数で持った Kruskal を Dinkelbach 法で回した。今回は全点対が辺になる完全グラフ($N=1000$ で約 50 万本)。辺を並べて整列する Kruskal は 1 回ごとに 50 万本の整列が要るので、辺を作らずに行列の行をその場で使う $O(N^2)$ の Prim に切り替える

問題

平面上に $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)$ より速い

概念図

入力例 2: 和の比が最小の木(左)と、費用だけ最小の木(右)。辺のラベルは 費用 c / 長さ l 最適: 費用 5 / 長さ 7.848 = 0.6371 c=1 / l=3.61 c=2 / l=1.41 c=2 / l=2.83 1z=4 2z=3 3z=6 4z=2 費用最小: 4 / 6.020 = 0.6645 c=1 / l=3.61 c=2 / l=1.41 c=1 / l=1.00 1z=4 2z=3 3z=6 4z=2 費用の小さい辺 2-4(c=1)を取ると長さも短く(l=1.00)なり、比はかえって悪くなる。和の比は辺ごとの比では決まらない。 Dinkelbach: λ0 = 0 で費用最小の木(右, 0.6645)→ λ1 = 0.6645 で重み c − λ1·l の最小木(左)→ λ2 = 0.6371 で木が変わらず停止

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

ヒント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 版

自己評価

自分の回答

気づき・メモ