Day 145-Q1 — Christofidesアルゴリズム(TSP 1.5-近似・MST+奇数次数頂点の最小重みマッチング)

2026-09-06 赤色 Master / Phase 8+ ★★★★★★★★★ 計量TSPの最適解の高々1.5倍を保証する多項式時間近似アルゴリズム

問題

$N$ 個の点が2次元平面上に与えられる(各点間の距離はユークリッド距離。三角不等式を満たすためChristofidesアルゴリズムの前提条件を満たす)。

Christofidesアルゴリズムは次の手順で巡回路の上界を構成する。

  1. 全点を頂点とする完全グラフ上で最小全域木(MST)を求める。
  2. MST上で次数が奇数である頂点集合 $O$ を求める(握手補題より $|O|$ は必ず偶数)。
  3. $O$ の頂点だけからなる完全グラフ上で、最小重み完全マッチング $M$ を求める。
  4. MST辺と $M$ の辺を合わせると全頂点が偶数次数になりオイラー閉路が存在する。重複訪問頂点をショートカットすると、三角不等式によりコストが増えないハミルトン閉路を得る。

本問で求めるもの: 「MSTの重み + 奇数次数頂点集合上の最小重み完全マッチングの重み」(= Christofidesアルゴリズムが保証する巡回路長の上界値)を求めよ。複数の最小重み完全マッチングが存在してもその総重量は一意に定まるため、答えは一意である。

入力形式

N
x_1 y_1
:
x_N y_N

制約

$2 \le N \le 12$
$0 \le x_i, y_i \le 1000$(整数)
全ての点は相異なる
絶対誤差または相対誤差 $10^{-6}$ 以内で許容

入出力例

入力例1

4
0 0
0 1
1 0
1 1

出力例1

4.000000

単位正方形の4点。MSTは3辺(重み計3)、残る奇数次数2点間のマッチング(重み1)を加えて計4。

概念図:MST + マッチングによる偶数次数化

MST(実線)+ 奇数次数マッチング(破線) A B C D E MST(4辺) D-E: 奇数次数(次数1)頂点の最小重みマッチング辺 A,B,C は次数2(偶数)。D,E は次数1(奇数)→マッチング辺で次数2に補正しオイラー閉路化

ヒント(段階的開示)

ヒント1(方向性)

MSTだけでは各頂点の次数の偶奇がバラバラで、そのままオイラー閉路(全ての辺をちょうど1回ずつ通る閉路)を作ることはできない。オイラー閉路が存在する必要十分条件は「全頂点の次数が偶数(連結グラフの場合)」。MSTには必ず奇数次数の頂点が偶数個存在するので、それらを偶数次数に「直す」必要がある。

ヒント2(アプローチ)

奇数次数の頂点同士を追加辺で結んで次数を1つ増やせば偶数になる。追加辺の総重量を最小化するため「奇数次数頂点集合上の最小重み完全マッチング」を使う。一般には Blossom Algorithm(Edmonds)が必要だが、本問は $N \le 12$ なので奇数次数頂点も高々12個であり、ビットマスクDP($O(2^k k^2)$)で厳密に解ける。

ヒント3(誘導)
def min_weight_perfect_matching(vertices, dist):
    k = len(vertices)
    FULL = (1 << k) - 1
    dp = [float('inf')] * (1 << k)
    dp[0] = 0.0
    for mask in range(1 << k):
        if dp[mask] == float('inf'):
            continue
        i = 0
        while mask & (1 << i):
            i += 1
        if i >= k:
            continue
        for j in range(i + 1, k):
            if not (mask & (1 << j)):
                nmask = mask | (1 << i) | (1 << j)
                w = dist[vertices[i]][vertices[j]]
                dp[nmask] = min(dp[nmask], dp[mask] + w)
    return dp[FULL]

「最初に立っていないビット $i$」を固定してペア $(i,j)$ を全探索することで同じマッチングの重複カウントを回避する。

模範解答 (Python)

import sys
import math

def main():
    data = sys.stdin.buffer.read().split()
    idx = 0
    N = int(data[idx]); idx += 1
    pts = []
    for _ in range(N):
        x = int(data[idx]); idx += 1
        y = int(data[idx]); idx += 1
        pts.append((x, y))

    dist = [[0.0] * N for _ in range(N)]
    for i in range(N):
        for j in range(N):
            dist[i][j] = math.hypot(pts[i][0] - pts[j][0], pts[i][1] - pts[j][1])

    # --- Step 1: Prim法によるMST構築 ---
    INF = float('inf')
    in_mst = [False] * N
    min_edge = [INF] * N
    parent = [-1] * N
    min_edge[0] = 0.0
    mst_weight = 0.0
    degree = [0] * N
    for _ in range(N):
        u = -1
        for v in range(N):
            if not in_mst[v] and (u == -1 or min_edge[v] < min_edge[u]):
                u = v
        in_mst[u] = True
        mst_weight += min_edge[u]
        if parent[u] != -1:
            degree[u] += 1
            degree[parent[u]] += 1
        for v in range(N):
            if not in_mst[v] and dist[u][v] < min_edge[v]:
                min_edge[v] = dist[u][v]
                parent[v] = u

    # --- Step 2: 奇数次数頂点の抽出 ---
    odd_vertices = [v for v in range(N) if degree[v] % 2 == 1]
    k = len(odd_vertices)

    # --- Step 3: ビットマスクDPによる最小重み完全マッチング ---
    if k == 0:
        matching_weight = 0.0
    else:
        FULL = (1 << k) - 1
        dp = [INF] * (1 << k)
        dp[0] = 0.0
        for mask in range(1 << k):
            if dp[mask] == INF:
                continue
            i = 0
            while mask & (1 << i):
                i += 1
            if i >= k:
                continue
            for j in range(i + 1, k):
                if not (mask & (1 << j)):
                    nmask = mask | (1 << i) | (1 << j)
                    w = dist[odd_vertices[i]][odd_vertices[j]]
                    nd = dp[mask] + w
                    if nd < dp[nmask]:
                        dp[nmask] = nd
        matching_weight = dp[FULL]

    print(f"{mst_weight + matching_weight:.6f}")

main()

Step-by-Step 解説

1MSTの構築と次数計算
Prim法で $O(N^2)$ のMSTを構築しながら各頂点の次数を同時に記録する。$N \le 12$ なので十分高速。
2握手補題と奇数次数頂点
次数の総和は辺数の2倍(偶数)になるため、奇数次数の頂点は必ず偶数個存在する。これによりステップ3のマッチングが必ず完全マッチングとして成立する。
3最小重み完全マッチング(ビットマスクDP)
一般グラフの最小重み完全マッチングは本来Blossom Algorithmが必要だが、$k\le12$なので$O(2^k k^2)$のビットマスクDPで厳密に解ける。
4オイラー閉路とショートカット(理論補足)
MST+マッチング辺は全頂点偶数次数のためオイラー閉路が存在し、既訪問頂点をスキップすると三角不等式によりコストは増えない。
5近似保証の直感
$\text{OPT}\ge\text{MST重み}$、かつ$\text{OPT}\ge2\times\text{マッチング重み}$が成り立つため、上界$\le1.5\,\text{OPT}$が導かれる。

よくあるミス

ミス原因正しい書き方
MSTの次数を辺の総数の2倍で数え重複カウントする無向辺を両端点で1回ずつ数える処理を書き忘れる`parent[u]!=-1`の頂点について両端点の次数を1ずつ増やす
奇数次数頂点が0個のケースでエラーになる正方形など対称な配置ではMSTの次数が全て偶数になり得る`k==0`の場合は`matching_weight=0.0`として特別扱いする
ビットマスクDPで同じペアを2回数え重み二重計上する全頂点ペアを2重ループで回してしまう「最初に立っていないビット」を固定し相手を全探索することで遷移を1通りに限定
距離計算で桁落ちさせる`math.hypot`を使わず`(dx**2+dy**2)**0.5`にする`math.hypot(dx, dy)`で安定計算する
一般グラフの最小重み完全マッチングを「難しすぎる」と誤解する頂点数が小さいことを見落とす$N\le12$なら奇数次数頂点も高々12個でビットマスクDPで十分

次のステップ

  • 発展: 実際にオイラー閉路を構成し(Hierholzerのアルゴリズム)、ショートカットして具体的な巡回路を出力せよ。
  • 次回予告: Vizingの定理と辺彩色(Δ+1色によるバックトラック構成)

自己評価

自分の回答

気づき・メモ