問題
$N$ 頂点 $M$ 辺の連結な無向グラフがある。辺 $i$ は頂点 $u_i$ と $v_i$ を結び、長さは $c_i$ である(多重辺はあり得るが、自己ループは無い)。
頂点 1 から出発して頂点 1 に戻る歩道のうち、すべての辺を 1 回以上通るものの長さの最小値を求め、その歩道を 1 つ出力せよ。
入力形式
N M
u_1 v_1 c_1
...
u_M v_M c_M
出力形式
1 行目に最小の長さ、2 行目に歩道の辺の数 $L$、3 行目に通る辺の番号を通る順に $L$ 個。条件を満たす歩道ならどれでもよい。
制約
| # | 条件 |
|---|---|
| 1 | $2\le N\le10^4$、$N-1\le M\le2\times10^4$ |
| 2 | $1\le c_i\le10^9$ |
| 3 | グラフは連結 |
| 4 | 次数が奇数の頂点の数を $K$ とすると $K\le22$ |
入出力例
入力例1
4 5
1 2 1
2 3 1
3 4 1
4 1 1
1 3 5出力例1
11
7
1 1 4 3 2 2 5(奇数次は頂点 1 と 3。最短路 1-2-3(長さ 2)をもう 1 回通る。辺の合計 9 と合わせて 11)
入力例2
3 3
1 2 2
2 3 3
3 1 4出力例2
9
3
1 2 3(すべての次数が偶数なので、オイラー閉路そのまま)
入力例3
4 4
1 3 1
3 4 1
1 2 9
1 3 5出力例3
26
6
1 2 2 4 3 3(奇数次は 1, 2, 3, 4。一番近い組 (1, 3) から組ませる貪欲は (1,3)=1 と (2,4)=11 で 28 になる。正解は (1,2)=9 と (3,4)=1 で 26。本セッションのランダム探索で見つけた最小の反例。3 例とも、各辺を 1 回か 2 回通す全組み合わせの最小と一致)
背景と出典
- 中国人郵便配達問題(Guan Meigu 1962 が最初と記憶): 無向グラフでは、奇数次の頂点の集合 $T$ を最短路で 2 つずつ組ませ、組ごとに最短路の辺を 1 回ずつ足すとオイラーグラフになる。最小の足し方は「$T$ 上で距離 = 最短路長とした最小重み完全マッチング」。多項式時間の解法は Edmonds–Johnson(1973)と記憶している(どちらの原典も本セッションでは未確認)
- なぜフローで書けないか: 有向(Day171-Q4)では「入次数 > 出次数の頂点から、出次数 > 入次数の頂点へ」流すので、供給と需要の向きが決まる。無向では奇数次の頂点どうしに供給・需要の区別が無く、「どの 2 つを組ませるか」自体が組合せの選択になる。一般グラフの最小重み完全マッチングは花の縮約(重み付き Edmonds)が要る
- 本問は $K\le22$ に絞り、マッチングを bit DP で解く。「残りのうち最小番号の頂点を必ず誰かと組ませる」と決めると、0 から到達する状態は $2^K$ よりずっと少ない($K=16,18,20,22$ で 1597, 4181, 10946, 28657 個、実測)。この数はフィボナッチ数 $F_{K+1}$ と一致した(4 つの $K$ で数えただけで、証明はしていない)
本問は Day171-Q4 の無向版として自作。
概念図
ヒント(クリックで段階的に開示)
ヒント1(方向性)
すべての次数が偶数なら、オイラー閉路がそのまま答え。奇数次の頂点(必ず偶数個)を何とかして偶数次にすればよい。辺を「もう 1 回通る」ことは、その辺を複製することと同じ。
ヒント2(アプローチ)
複製した辺だけを見ると、各奇数次頂点の次数は奇数、他の頂点は偶数。こういう辺集合は「奇数次頂点を 2 つずつ結ぶ道」に分解でき、最小にするなら各道は最短路。だから「奇数次頂点の組ませ方のうち、最短路長の和が最小のもの」を探す。
ヒント3(誘導)
- 奇数次の頂点ごとに Dijkstra($K$ 回)で距離行列と最短路木を作る
dp[mask]= 組み終わった集合がmaskのときの残りの最小コスト。maskに入っていない最小番号 $i$ を、残りの $j$ と組ませる。0 から幅優先で到達するmaskだけを列挙し、逆順に埋める- 組ませた $(i,j)$ ごとに $i$ の最短路木を $j$ からさかのぼって辺を複製し、Hierholzer で閉路を作る
模範解答 (Python)
模範解答を表示する(まず自力で解いてから)
import sys
import heapq
def main():
data = sys.stdin.buffer.read().split()
n, m = int(data[0]), int(data[1])
eu = [0] * m
ev = [0] * m
ew = [0] * m
adj = [[] for _ in range(n)]
deg = [0] * n
for i in range(m):
u = int(data[2 + 3 * i]) - 1
v = int(data[3 + 3 * i]) - 1
c = int(data[4 + 3 * i])
eu[i], ev[i], ew[i] = u, v, c
adj[u].append((v, c, i))
adj[v].append((u, c, i))
deg[u] += 1
deg[v] += 1
odd = [v for v in range(n) if deg[v] % 2]
k = len(odd)
INF = float('inf')
# 奇頂点ごとに Dijkstra(距離と、木をたどるための親の辺)
dist = []
pe = []
for s in odd:
d = [INF] * n
par = [-1] * n
d[s] = 0
pq = [(0, s)]
while pq:
du, u = heapq.heappop(pq)
if du > d[u]:
continue
for v, c, i in adj[u]:
nd = du + c
if nd < d[v]:
d[v] = nd
par[v] = i
heapq.heappush(pq, (nd, v))
dist.append(d)
pe.append(par)
D = [[dist[a][odd[b]] for b in range(k)] for a in range(k)]
# 奇頂点の最小重み完全マッチング: 未使用のうち最小番号の i を必ず誰かと組ませる。
# 到達する mask は「下位が詰まった接頭辞+上の数ビット」だけで、2^k よりずっと少ない
full = (1 << k) - 1
memo = {full: 0}
choice = {}
order = [0] # 0 から到達する mask を列挙(popcount の小さい順)
seen = {0}
for mask in order:
if mask == full:
continue
rest = full ^ mask
i = (rest & -rest).bit_length() - 1
r = rest ^ (1 << i)
while r:
low = r & -r
nm = mask | (1 << i) | low
if nm not in seen:
seen.add(nm)
order.append(nm)
r ^= low
for mask in reversed(order): # popcount の大きい順に確定
if mask == full:
continue
rest = full ^ mask
i = (rest & -rest).bit_length() - 1
Di = D[i]
best = INF
bj = -1
r = rest ^ (1 << i)
while r:
low = r & -r
j = low.bit_length() - 1
val = Di[j] + memo[mask | (1 << i) | low]
if val < best:
best = val
bj = j
r ^= low
memo[mask] = best
choice[mask] = bj
total = sum(ew)
extra = [0] * m # 各辺をもう何回通るか
mask = 0
while mask != full:
rest = full ^ mask
i = (rest & -rest).bit_length() - 1
j = choice[mask]
total += D[i][j]
v = odd[j] # odd[i] からの最短路木を odd[j] からさかのぼる
while v != odd[i]:
e = pe[i][v]
extra[e] += 1
v = eu[e] if ev[e] == v else ev[e]
mask |= (1 << i) | (1 << j)
# Hierholzer(反復版)で頂点 1 から出る閉路を辺番号の列で
g = [[] for _ in range(n)]
cnt = []
for i in range(m):
c = 1 + extra[i]
for _ in range(c):
idx = len(cnt)
cnt.append(i)
g[eu[i]].append((ev[i], idx))
g[ev[i]].append((eu[i], idx))
used = [False] * len(cnt)
ptr = [0] * n
stack = [(0, -1)]
route = []
while stack:
v, e = stack[-1]
lst = g[v]
while ptr[v] < len(lst) and used[lst[ptr[v]][1]]:
ptr[v] += 1
if ptr[v] == len(lst):
stack.pop()
if e >= 0:
route.append(cnt[e] + 1)
else:
to, idx = lst[ptr[v]]
used[idx] = True
stack.append((to, idx))
route.reverse()
print(total)
print(len(route))
print(' '.join(map(str, route)))
main()
計算量: Dijkstra $K$ 回で $O(K\,M\log N)$、マッチングの DP は到達状態数 $\times K$($K=22$ で約 2.9 万状態)、Hierholzer は $O(M+\text{複製した辺の数})$。$N=10^4$、$M=2\times10^4$、$K=22$ で 1.30〜1.52 s(Python 3.14.7、M1 MacBook Pro 8GB、負荷平均 2〜3 での実測、起動込み)。
Step-by-Step 解説
距離だけでなく、各頂点へ最後に使った辺 par[v] も持つ。あとで最短路の辺を実際に複製するのに使う。
mask は組み終わった奇数次頂点の集合。最小番号の未使用 $i$ は「誰かと組む」ことが確定しているので、$i$ の相手 $j$ だけを選べばよい。こうすると同じ組ませ方を別の順で数えることがなく、遷移は $K$ 本以下。
0 から「最小の未使用 $i$ と、もう 1 つ」を足していくと、mask は常に「下位の数ビットが全部埋まり、その上に何ビットか立つ」形になる。$2^K$ の全状態を回す版と同じ答えを出しつつ、DP だけの時間は $K=20$ で 7.91 s → 0.13 s、$K=22$ で 32.6 s → 0.34 s(実測)。
組 $(i,j)$ について、$i$ からの最短路木を $j$ からさかのぼり、通った辺の extra を 1 増やす。各辺を $1+\text{extra}$ 本とみなしたグラフは全頂点が偶数次で連結なので、頂点 1 からの Hierholzer(スタックを使う反復版)でオイラー閉路が取れる。出力は辺の番号の列なので、多重辺があっても一意に検査できる。
入力例 3 で、一番近い組 (1,3) を先に取ると、残りの (2,4) が遠い($9+1+1=11$)。少し遠い (1,2)=9 を選ぶと残りの (3,4)=1 が近くなる。組ませ方は全体で決めないといけない。
迷った他案と採らなかった理由
| 案 | 計算量 | 採否 | 理由 |
|---|---|---|---|
| 各辺を 1 回か 2 回通す全組み合わせ | $O(2^M)$ | 不採用 | 検証用。最適解では各辺の複製は高々 1 回で足りる(Edmonds–Johnson と記憶、未確認)。$M\le12$ まで |
| 一番近い奇数次の組から貪欲に | $O(K^3)$ | 不採用(誤り) | 800 グラフ中 61 グラフで長さを誤る(実測) |
| 最小費用流(Day171-Q4 の有向版の方法) | — | 不採用 | 無向では供給と需要の向きが無く、組ませ方を流れで表せない |
| $2^K$ の全状態を回す bit DP | $O(2^K K)$ | 不採用 | 正しいが、DP だけで $K=20$ で 7.91 s、$K=22$ で 32.6 s(実測) |
| 重み付き一般マッチング(重み付き Edmonds) | $O(K^3)$ | 不採用 | $K$ が大きいときの本命。実装が長く(双対変数の更新と花の展開)、$K\le22$ なら DP で 0.34 s に収まる |
| 到達する状態だけの bit DP(本解) | 到達状態数 $\times K$ | 採用 | 全体 1.30〜1.52 s |
前提が変わったら:
- $K$ が数百以上(奇数次の頂点が多い)なら、重み付き Edmonds($O(K^3)$)か、Blossom V のような実装に切り替える。本解は $K=24$ で 2.07〜2.20 s、$K=30$ で 16.77 s(実測)
- 一部の辺が一方通行(混合グラフ)なら、混合郵便配達問題は NP 困難と記憶しており(未確認)、整数計画か近似になる
- 辺を通る回数に上限がある、またはいくつかの辺だけ通ればよい(農村郵便配達)なら、別の問題になる
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
| 一番近い組から貪欲に組ませる | 局所的に最短でも全体で最小とは限らない | 800 グラフ中 61 グラフで誤答(実測)。DP で組ませ方全体を最小化 |
| bit DP で $i$ と $j$ を両方自由に選ぶ | 同じ組ませ方を $(K/2)!$ 通りの順で数え、遷移が $K^2$ 本に | 最小の未使用 $i$ を固定し、相手 $j$ だけ選ぶ |
| 最短路の長さだけ足して、歩道を出せない | 複製する辺を覚えていない | Dijkstra の par を持ち、木をさかのぼって extra を増やす |
| 歩道を頂点の列で出力する | 多重辺のどれを通ったか分からない | 辺の番号の列で出す |
| 再帰版の Hierholzer | 歩道の長さが 2 万を超え、再帰の深さ上限 | スタックを使う反復版 |
次のステップ
- 発展1: 重み付き Edmonds(双対変数つきの花アルゴリズム)を実装し、$K=1000$ 級で本解の DP と比べる
- 発展2: 「到達する状態の数がフィボナッチ数になる」ことを証明する(本セッションでは $K=16..22$ で数えただけ)
- 発展3: 農村郵便配達問題(指定した辺だけ通ればよい)の小さい場合を全探索と比べる
検証ログ(本セッションで実行した数値)
- 正解との照合(実測): $N\le7$、$M\le12$、辺の長さ $\le3$/$\le10$/$\le100$ のランダムな連結グラフ 800 個(多重辺あり)で、各辺を 1 回か 2 回通す全組み合わせのうち全頂点が偶数次になる最小と、出力の長さが一致。出力の歩道が「頂点 1 から始まり 1 に戻る・隣り合う辺がつながる・全辺を含む・長さの和が出力と等しい」ことも全件で確認。不一致 0($2^K$ 全状態の DP 版 400 個、本解 400 個)
- 誤った方法(実測): 一番近い組からの貪欲は 800 グラフ中 61 グラフで長さを誤る。最小の反例は入力例 3
- 到達状態数(実測): $K=16,18,20,22$ で 1597, 4181, 10946, 28657($2^K$ は 65536 〜 約 419 万)
- DP だけの時間(実測、距離はランダム): 全状態/到達状態のみ で $K=16$: 0.345/0.013 s、$K=18$: 1.665/0.045 s、$K=20$: 7.912/0.131 s、$K=22$: 32.6/0.341 s。両者の答えは一致
- 実行時間(実測、起動込み、$N=10^4$, $M=2\times10^4$, 長さ $\le10^9$): $K=18$: 1.08 s、$K=20$: 1.19 s、$K=22$: 1.30〜1.52 s(3 ケース)、$K=24$: 2.07〜2.20 s、$K=30$: 16.77 s。歩道の長さは約 2.26 万辺
- 扱っていない範囲: 重み付き Edmonds、混合グラフ、農村郵便配達、原典の確認