問題
$N$ 頂点 $M$ 本の重み付き無向辺を持つグラフが与えられる。整数 $K$ が指定される。
辺の部分集合 $S$ を選び、$S$ を高々 $K$ 本の互いに辺素な森(forest)に分割できる(=どの1本の森も閉路を含まない)とき、$S$ は「$K$-森分割可能」であるという。
$K$-森分割可能な部分集合 $S$ の中で、辺の重みの総和が最大になるものを1つ求め、その最大重み総和と、各採用辺がどの森($1$〜$K$)に属するかを出力せよ。
入力形式
N M K
u_1 v_1 w_1
...
u_M v_M w_M
制約
入出力例
入力例1
4 5 1
1 2 5
2 3 4
3 4 3
4 1 2
1 3 6
出力例1
14
$K=1$ なので通常の最大重み全域森(Kruskal法)と同じ結果になる。重い順に 1-3(6), 1-2(5) を採用すると $\{1,2,3\}$ が連結。次の 2-3(4) は閉路になるため不採用。3-4(3) を採用して4頂点すべてが連結し、合計 $6+5+3=14$
概念図
ヒント(段階的開示)
ヒント1(方向性)
「$K$ 本の森に分割できる」という条件は、$K$ 個のグラフィックマトロイド(森が独立集合であるマトロイド)の合併(union)として定式化できる。マトロイド合併定理(Nash-Williamsの定理の一般化)により、この合併も1つのマトロイドになることが知られている。マトロイドであれば、貪欲法(重い辺から順に「独立性を保てるなら採用」を繰り返す)で最大重み集合が求まる——ただし「独立性を保てるか」の判定自体が難しい。
ヒント2(アプローチ)
辺 $e$ を追加しようとしたとき、まず単純に「$K$ 個の森のどれか1つに直接入れられるか(両端点がその森でまだ連結していないか)」を確認する。それで無理なら、交換増加路(augmenting path)を探す:ある森 $i$ に入っている辺 $f$ を取り除いて $e$ をそこに入れ、代わりに $f$ を別の森 $j$ に押し出す……という「玉突き」を繰り返して、最終的にどこかの森に空きを作れないかをBFS/DFSで探索する。見つかれば経路に沿って付け替え、見つからなければ $e$ はこれ以上追加できない。
ヒント3(誘導)
交換増加路探索の骨格(小規模な制約なので、付け替えのたびにDSUを再構築する素朴な実装でよい):
def can_place_directly(forest_id, u, v, colors):
# forest_id の辺集合だけでDSUを作りu,vが非連結かチェック
...
def find_augmenting(e, colors, K):
# colors[edge] = 現在の森番号 or None
# BFSで e を起点に「eをforest iに入れたときfをどかせば良い」の連鎖を探す
...
各ステップでDSUを作り直すため計算量は悪化するが($O(M)$ 制約なので許容範囲)、アルゴリズムの本質的な正しさを優先する。
模範解答 (Python)
import sys
from itertools import combinations
def solve():
data = sys.stdin.read().split()
idx = 0
N = int(data[idx]); idx += 1
M = int(data[idx]); idx += 1
K = int(data[idx]); idx += 1
edges = []
for i in range(M):
u = int(data[idx]); idx += 1
v = int(data[idx]); idx += 1
w = int(data[idx]); idx += 1
edges.append((u, v, w))
class DSU:
def __init__(self, n):
self.p = list(range(n + 1))
def find(self, x):
while self.p[x] != x:
self.p[x] = self.p[self.p[x]]
x = self.p[x]
return x
def union(self, x, y):
rx, ry = self.find(x), self.find(y)
if rx == ry:
return False
self.p[rx] = ry
return True
def independent_in(forest_edges_idx, excluded, extra):
# forest_edges_idx: 現在その森に属している辺インデックス集合
# excluded: 除外する辺インデックス(付け替えで一時的に抜く)
# extra: 追加で入れてみる (u, v)
dsu = DSU(N)
for ei in forest_edges_idx:
if ei == excluded:
continue
u, v, w = edges[ei]
if u == v:
return False
dsu.union(u, v)
u, v = extra
if u == v:
return False
return dsu.union(u, v)
color = [None] * M # color[i] = 0..K-1 or None
forest_members = [[] for _ in range(K)]
order = sorted(range(M), key=lambda i: -edges[i][2])
for e in order:
u, v, w = edges[e]
if u == v:
continue # 自己ループはどの森にも入らない
# 直接入れられる森を探す
placed = False
for c in range(K):
if independent_in(forest_members[c], None, (u, v)):
color[e] = c
forest_members[c].append(e)
placed = True
break
if placed:
continue
# 交換増加路探索: BFS on edges
# state: 現在「まだ居場所が決まっていない辺」= cur (最初は e)
# 各 cur について、各森 c 内の辺 f を1つ抜いて cur を入れられるなら (c, f) が候補
# f はその後「新たに居場所を探す辺」になる
visited_edges = {e}
parent = {} # child_edge -> (forest_c, replaced_edge_f) の記録
queue = [e]
found = None
while queue and found is None:
cur = queue.pop(0)
cu, cv, cw = edges[cur]
for c in range(K):
# cur を forest c に「直接」入れられるか(別の辺を抜かずに)
if independent_in(forest_members[c], None, (cu, cv)):
found = (cur, c, None)
break
# forest c 内の各辺 f を抜いて cur を入れられるか調べる
for f in forest_members[c]:
if independent_in(forest_members[c], f, (cu, cv)):
if f not in visited_edges:
visited_edges.add(f)
parent[f] = (cur, c)
queue.append(f)
if found:
break
if found is None:
continue # これ以上 e を追加できない
# 経路復元して付け替えを実行
cur, final_color, _ = found
# cur から e まで parent を遡って、玉突きの連鎖を構築
chain = [cur]
node = cur
while node != e:
prev, prev_color = parent[node]
chain.append(prev)
node = prev
chain.reverse() # chain[0] == e, chain[-1] == cur (居場所が確定する辺)
# 実際の付け替え: chain[i] は元々 chain[i+1] がいた森から追い出されて、
# chain[i] 自身は次段階で別の森 (parent[chain[i+1]] の c) に入る、最終的に chain[-1] は final_color に直接入る
# 簡潔にするため、各辺の新しい色を後ろから確定させる
new_color_for = {}
new_color_for[chain[-1]] = final_color
for i in range(len(chain) - 2, -1, -1):
nxt = chain[i + 1]
c_of_nxt_origin = parent[nxt][1] # nxt が元々いた森
new_color_for[chain[i]] = c_of_nxt_origin
for ei in chain:
old_c = color[ei]
if old_c is not None:
forest_members[old_c].remove(ei)
new_c = new_color_for[ei]
color[ei] = new_c
forest_members[new_c].append(ei)
total = sum(edges[i][2] for i in range(M) if color[i] is not None)
print(total)
result_lines = []
for i in range(M):
if color[i] is not None:
result_lines.append(f"{i} {color[i] + 1}")
for line in result_lines:
print(line)
solve()
Step-by-Step 解説
グラフィックマトロイド(森が独立集合)を $K$ 個用意し、それぞれ「森 $1$」「森 $2$」……「森 $K$」に対応させる。辺集合 $S$ が $K$ 本の森に分割可能であることは、$S$ が $K$ 個のグラフィックマトロイドの合併マトロイドにおいて独立であることと同値である。マトロイド合併定理(Edmonds, Nash-Williams)は、この合併も1つのマトロイドになることを保証する。
一般に、あるマトロイド $\mathcal{M}$ の独立集合の中で重み最大のものを求めるには、重い要素から順に「独立性が保てるなら採用」を繰り返す貪欲法が最適解を与える(マトロイドの基本定理)。今回の「合併マトロイド」もマトロイドである以上、この貪欲法がそのまま適用できる。
問題は「辺 $e$ を追加しても合併マトロイドで独立か」を判定する部分である。直接どこかの森に入れられれば簡単だが、無理な場合は「ある森から辺を追い出し、追い出された辺は別の森へ、さらにダメなら……」という玉突きの連鎖(augmenting path)を探す必要がある。この探索構造はBFSで組める:各「まだ居場所が未定の辺」をキューに積み、各森について「直接入るか」「その森のどれか1辺を追い出せば入るか」を調べていく。
増加路が見つかったら、連鎖の末尾(直接入れる辺)から逆順に、各辺の新しい所属森を確定させ、実際に
forest_members を更新する。これはちょうど二部マッチングにおける増加路のマッチング更新と同じ考え方である。今回の実装は独立性判定のたびにDSUを再構築する素朴な方法で、$O(M)$ かけて判定するため全体で $O(M^3 K)$ 程度になる($M \le 60$ なので現実的)。実運用では削除可能なUnion-Find(Link-Cut Treeやオフライン分割統治DSU)を使うことで高速化できる。
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
| 各森を独立に「貪欲Kruskal」してしまう(森ごとに分けて別々に最大化) | K本まとめての最適性を無視している | マトロイド合併全体で1回の貪欲法を回す必要がある |
| 増加路探索で「直接入れられる森」を見つけても他の可能性を探し続けてしまう | 最初に見つかった解で確定してよいことを理解していない | 直接入れられる森が見つかった時点でその増加路は確定してよい |
| 増加路の付け替え順序を間違える(末尾から確定させるべきところを先頭から行う) | 玉突きの依存関係の向きを誤解 | 「最終的にどの森に直接入るか」が確定している末尾から逆順に色を決める |
| 自己ループを森の一部として扱おうとする | 自己ループは必ず閉路を作ることを見落とす | 自己ループの辺は最初から除外する |
次のステップ
- 発展: 一般の(グラフィックとは限らない)マトロイド同士の合併では、独立性オラクルが与えられている前提でこの交換増加路アルゴリズムがそのまま使える。分割マトロイド・線形マトロイドとの合併に一般化すると、スケジューリング問題や資源割当問題に応用できる。
- 次回予告: Eppstein's K-Shortest Paths Algorithm(サイドトラック辺とpersistent leftist heapによるk最短ウォーク列挙)