Day 124-Q3 — Weighted Matroid Union(重み付きマトロイド合併・K-forest分割)

2026-08-16 赤色 Master / Phase 8+ ★★★★★★★★★ グラフ理論マトロイドの合併定理と拡張増加路によるK本の森への最大重み分割

問題

$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 \le N \le 30$
$0 \le M \le 60$
$1 \le K \le 5$
$1 \le w_i \le 1000$
グラフは単純とは限らない(多重辺・自己ループの可能性あり、自己ループは常にどの森にも入れられない)

入出力例

入力例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$

概念図

マトロイド合併の交換増加路(K=2 の例) 新規辺 e 直接は不可 森1(満杯) 辺 f が森1に所属 fを追い出す 森2に f を配置成功 結果: eが森1へ、fが森2へ(玉突きの付け替え) 森1 = {e} 森2 = {f} BFSで「どの辺をどこに移せば全体が独立集合に保てるか」を探索する

ヒント(段階的開示)

ヒント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 解説

1マトロイド合併としての定式化
グラフィックマトロイド(森が独立集合)を $K$ 個用意し、それぞれ「森 $1$」「森 $2$」……「森 $K$」に対応させる。辺集合 $S$ が $K$ 本の森に分割可能であることは、$S$ が $K$ 個のグラフィックマトロイドの合併マトロイドにおいて独立であることと同値である。マトロイド合併定理(Edmonds, Nash-Williams)は、この合併も1つのマトロイドになることを保証する。
2マトロイドなら貪欲法が最適
一般に、あるマトロイド $\mathcal{M}$ の独立集合の中で重み最大のものを求めるには、重い要素から順に「独立性が保てるなら採用」を繰り返す貪欲法が最適解を与える(マトロイドの基本定理)。今回の「合併マトロイド」もマトロイドである以上、この貪欲法がそのまま適用できる。
3独立性判定=交換増加路探索
問題は「辺 $e$ を追加しても合併マトロイドで独立か」を判定する部分である。直接どこかの森に入れられれば簡単だが、無理な場合は「ある森から辺を追い出し、追い出された辺は別の森へ、さらにダメなら……」という玉突きの連鎖(augmenting path)を探す必要がある。この探索構造はBFSで組める:各「まだ居場所が未定の辺」をキューに積み、各森について「直接入るか」「その森のどれか1辺を追い出せば入るか」を調べていく。
4経路復元と付け替え
増加路が見つかったら、連鎖の末尾(直接入れる辺)から逆順に、各辺の新しい所属森を確定させ、実際に forest_members を更新する。これはちょうど二部マッチングにおける増加路のマッチング更新と同じ考え方である。
5計算量とスケーラビリティ
今回の実装は独立性判定のたびに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最短ウォーク列挙)

自己評価

自分の回答

気づき・メモ