Day 168-Q4 — 2026-10-03 | 最大重み閉包(燃やす埋める問題。最小カットで最大値を、残余グラフの到達集合で「最小の選び方」を復元)

2026-10-03 赤色 Master (Phase 8+) ★★★★★★★★★ グラフ・フロー(焦点帯 M2)。「A を選ぶなら B も選ばねばならない」という含意制約つきの選択問題を、最小カットに帰着する(Project Selection / 最大重み閉包)。値だけでなく、最小カットの S 側の最小の集合を残余グラフから取り出して「最適解の中で最も小さい選び方」まで答える

問題

$N$ 個の項目があり、項目 $i$ を選ぶと $w_i$ の得点(負なら費用)を得る。制約が $M$ 個あり、制約 $(a,b)$ は「項目 $a$ を選ぶなら項目 $b$ も必ず選ぶ」を意味する。選び方(部分集合)はすべての制約を満たさなければならない(何も選ばないのも可)。

得点の総和の最大値と、その最大値を達成する選び方のうち項目数が最小のものの項目数を出力せよ。

入力形式

N M
w_1 w_2 ... w_N
a_1 b_1
...
a_M b_M

制約

#条件
1$1\le N\le3000$、$0\le M\le20000$
2$-10^9\le w_i\le10^9$
3$1\le a_i,b_i\le N$、$a_i\ne b_i$(同じ制約が複数回あっても、閉路があってもよい)
4入力はすべて整数

入出力例

入力例1

3 2
9 -3 -4
1 2
1 3

出力例1

2 3

(項目 1 を選ぶと 2 と 3 も要る。$9-3-4=2$。何も選ばない 0 より大きい)

入力例2

4 3
7 -3 -3 2
1 2
1 3
4 3

出力例2

3 4

(全部選んで $7-3-3+2=3$。項目 4 も選ぶのは、4 が要求する 3 が 1 の要求でどうせ選ばれるから)

入力例3

3 2
5 -2 -3
1 2
1 3

出力例3

0 0

(全部選んでも $5-2-3=0$ で、何も選ばないのと同じ得点。項目数が最小なので 0 個)

背景と出典

「選ぶなら必ずこれも選ぶ」という含意つきの最大化は、最大重み閉包(maximum weight closure)。Picard(1976)が最小カットに帰着したと私は記憶している(文献未確認)。競技プログラミングでは「燃やす埋める問題」「プロジェクト選択問題」の名で、含意・排他・ペナルティの形の制約が最小カットに載る型として知られる。

この問題の価値は 3 点。(1) 含意制約を容量 ∞ の辺に落とす。(2) 値の最大化を「正の重みの総和 − 最小カット」に直す。(3) 最小カットは一意でないので、同点の選び方のどれを出すかを決める(残余グラフで S から届く点の集合が、最小の S 側)。

概念図

入力例 1(w = 9, -3, -4。1 を選ぶなら 2 と 3 も選ぶ)の最小カット9∞∞34S123T最小カット = 3+4 = 7値 = 正の重みの総和 9 − 最小カット 7 = 2S 側 {S,1,2,3} を選ぶ(含意辺 ∞ は切れないので 2,3 も S 側)→ 項目数 3

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

ヒント1(方向性)

正の重みの項目を「選べば得」、負の重みの項目を「選べば損」と見て、「得の総和から、諦めた得と払った費用を引く」と考える。諦めた得と払った費用の合計を最小化する問題は、辺の切断コストの最小化、つまり最小カットの形をしている。

ヒント2(アプローチ)

頂点 $S,T$ を足す。$w_i>0$ の項目には辺 $S\to i$(容量 $w_i$)、$w_i<0$ には辺 $i\to T$(容量 $-w_i$)。制約 $(a,b)$ には辺 $a\to b$(容量 $\infty$)。カットの S 側を「選ぶ項目」と解釈すると、$S\to i$ が切られる = 得を諦める、$i\to T$ が切られる = 費用を払う、$a\to b$ が切れない = 「$a$ を選び $b$ を選ばない」を禁止。答えの値は $\sum_{w_i>0}w_i-\text{最小カット}$。

ヒント3(誘導)

最大フローを求めたあと、**残余グラフで $S$ から辿れる頂点の集合が、最小カットの S 側のうち最小**のもの。その集合に含まれる項目が、最適な選び方のうち項目数最小のもの(集合として最小、したがって個数も最小)。フローは Dinic(BFS で層を作り、DFS で増加路)。

模範解答 (Python)

模範解答を表示する(まず自力で解いてから)
import sys
from collections import deque


def main():
    data = sys.stdin.buffer.read().split()
    n, m = int(data[0]), int(data[1])
    w = [int(v) for v in data[2:2 + n]]
    S, T = n, n + 1
    V = n + 2
    to, cap, head, nxt = [], [], [-1] * V, []

    def add(u, v, c):
        to.append(v); cap.append(c); nxt.append(head[u]); head[u] = len(to) - 1
        to.append(u); cap.append(0); nxt.append(head[v]); head[v] = len(to) - 1

    INF = 1 << 60
    total = 0
    for i in range(n):
        if w[i] > 0:
            total += w[i]
            add(S, i, w[i])             # 選べば得られる利益: 切られたら損
        elif w[i] < 0:
            add(i, T, -w[i])            # 選べば払う費用
    for e in range(m):
        a, b = int(data[2 + n + 2 * e]) - 1, int(data[3 + n + 2 * e]) - 1
        add(a, b, INF)                  # a を選ぶなら b も必須(a -> b を切れない辺に)

    flow = 0
    while True:
        level = [-1] * V
        level[S] = 0
        q = deque([S])
        while q:
            u = q.popleft()
            e = head[u]
            while e != -1:
                if cap[e] > 0 and level[to[e]] < 0:
                    level[to[e]] = level[u] + 1
                    q.append(to[e])
                e = nxt[e]
        if level[T] < 0:
            break
        it = head[:]
        while True:
            # 反復的 DFS で増加路を 1 本ずつ探す
            path = []
            u = S
            while u != T:
                e = it[u]
                while e != -1 and not (cap[e] > 0 and level[to[e]] == level[u] + 1):
                    e = nxt[e]
                it[u] = e
                if e == -1:
                    if not path:
                        break
                    level[u] = -1       # 行き止まり。この頂点はもう使わない
                    e = path.pop()
                    u = to[e ^ 1]
                    continue
                path.append(e)
                u = to[e]
            if u != T:
                break
            f = min(cap[e] for e in path)
            for e in path:
                cap[e] -= f
                cap[e ^ 1] += f
            flow += f
    # 残余グラフで S から届く頂点 = 最小カットの S 側のうち最小のもの = 最小の最適な選び方
    seen = [False] * V
    seen[S] = True
    st = [S]
    while st:
        u = st.pop()
        e = head[u]
        while e != -1:
            if cap[e] > 0 and not seen[to[e]]:
                seen[to[e]] = True
                st.append(to[e])
            e = nxt[e]
    print(total - flow, sum(seen[:n]))


main()

計算量: Dinic は一般に $O(V^2E)$ だが、実測では十分速い。実測(Python 3.14.7、M1 Mac)で $N=3000,M=20000$ のランダムなグラフが 0.6〜0.8 s、項目 1500+1500 の二部(辺 18000)が 0.2 s、$N=1000,M=5000$ が 0.08 s。

Step-by-Step 解説

1選び方とカットを 1 対 1 に対応させる

カット $(A,B)$($S\in A,T\in B$)に対し、「$A$ に入る項目を選ぶ」と読む。含意辺 $a\to b$ の容量が $\infty$ なので、有限のカットでは「$a\in A$ かつ $b\in B$」が起きない = $a$ を選ぶなら $b$ も選ぶ。つまり有限のカットは実行可能な選び方と 1 対 1。

2カットの値が「損失」になる

選んだ集合 $X$ に対し、カットの値は $\sum_{i\notin X,\,w_i>0}w_i+\sum_{i\in X,\,w_i<0}(-w_i)$。前者は選ばなかった得(諦めた得)、後者は選んだ費用。$\sum_{i\in X}w_i=\sum_{w_i>0}w_i-\text{カット}$。最小カット = 最大得点。

3最小の最適解の取り出し

最大フローの後、残余グラフで $S$ から届く頂点の集合 $R$ は、最小カットの S 側として包含関係で最小(私の理解する標準的な事実。ここでは検証で確かめた)。$R\setminus\{S\}$ の項目数が、最適な選び方のうち最小の項目数。得点 0 の項目($w_i=0$)は辺を持たず、選ぶ必要が無いので $R$ に入らない。

4実装上の注意

容量 ∞ は $2^{60}$ とした(得点の総和 $\le3\times10^{12}$ より十分大きい)。Dinic の DFS は再帰でなく反復で書く(再帰だと深さが $N$ に達して Python の再帰上限に当たる)。「行き止まり」の頂点は level[u] = -1 にして再訪を防ぐ。

5最適解は一意でない

同じ最大得点を達成する選び方が複数あり、項目数の最小と最大が違う入力が 400 件中 58 件あった(全探索での実測)。たとえば入力例 3 は「何も選ばない」と「全部選ぶ」が同点。問題文は個数が最小のものを求めるので、$S$ から届く方(最小側)を数える。$T$ に届けない頂点の補集合のような最大側の集合を取ると、この 58 件で個数が合わない。

迷った他案と採らなかった理由

案計算量採否理由
部分集合の全探索$O(2^N)$検算用のみ$N\le10$ なら 1024 通り。400 件の検算に使った
正の項目と、それが要求するものを貪欲に選ぶ$O(N+M)$不採用費用が得を上回る項目も選んでしまう。400 件中 124 件で誤答(実測)
含意制約を無視して正の項目だけ選ぶ$O(N)$不採用400 件中 271 件で誤答(実測)
最大フロー + 残余グラフ(本解)Dinic採用全探索と 400 件、値と最小個数の両方が一致
閉路を強連結成分に縮約して DAG の DP$O(N+M)$不採用縮約後も「選ぶ・選ばない」の閉包は DP で解けない(閉包は部分集合なので指数)。フローが本質

前提が変わったら:

  • **「$a$ を選び $b$ を選ばない」にペナルティ $c$** なら、辺 $a\to b$ の容量を $\infty$ でなく $c$ にする(燃やす埋める問題の一般形。この変更は検証していない)
  • **排他($a,b$ のどちらか一方) や 3 項の制約**は、補助頂点を足す。3 項以上の任意の制約は一般には最小カットに載らない
  • **$N,M\ge10^5$** なら Python の Dinic は遅い。C++ の Dinic で 10^5 規模、または Go(本リポジトリの方針で毎回走る制御部品の言語)で実装する

よくあるミス

ミス原因正しい書き方
含意の向きを逆にする$b\to a$ と書く「$a$ を選ぶなら $b$ も」は $a\to b$($a\in A$ なら $b\in A$ が必要)
容量 ∞ を小さく取る$10^9$ などにする得点の総和より大きく($2^{60}$)
正の項目だけ拾って、必要な項目の費用を数えない含意を無視辺で表現(271/400 誤答)
最大側の最適解を数える$T$ 側から逆向きに辿った補集合を使う$S$ から残余グラフで辿る(最小と最大で個数が違う入力が 58/400)
DFS を再帰で書く深さが $N$ に達する反復 DFS

次のステップ

  • 発展1: 含意違反にペナルティ $c_{ab}$ を付けた一般形(燃やす埋める)を実装し、全探索と比べる
  • 発展2: 画像のノイズ除去(各画素を白/黒、隣と違うとペナルティ)を最小カットで解く
  • 発展3: Picard の原典で、最大閉包と最小カットの対応の証明を確認する(本セッションでは未確認)

検証ログ(本セッションで実行した数値)

  • 全探索との照合(実測): $N\le10$、$M\le14$(閉路を含む)の 400 件で、全部分集合を調べた「最大得点と、その中の最小項目数」とプログラム全体の出力が全件一致
  • 誤った方法(実測、別の 400 件): 正の項目+その閉包の貪欲は 124/400、含意を無視して正の項目の総和は 271/400 で最大値が誤り。最大側の最適解と最小側の最適解で項目数が違う入力は 58/400
  • 入力例(実測): 例 1 2 3、例 2 3 4、例 3 0 0
  • 実行時間(実測): $N=3000,M=20000$(乱数のグラフ)が 0.6〜0.8 s、二部 1500+1500(辺 18000)が 0.2 s、$N=1000,M=5000$ が 0.08 s
  • 扱っていない範囲: 含意違反にペナルティを付けた一般形、$N>3000$、最悪入力(Dinic の最悪計算量を出す入力)、「残余グラフの到達集合が包含で最小」の証明、Picard の原典確認

自己評価

自分の回答

気づき・メモ