問題
$N$ 頂点 $M$ 辺の連結な単純無向グラフが与えられる。どの辺も高々 1 つの単純閉路にしか含まれない(サボテングラフ)ことが保証される。頂点 $i$ には重み $w_i$ がある。互いに隣り合わない頂点の部分集合(独立集合)のうち、重みの合計が最大のものの値を求めよ。
入力形式
N M
w_1 ... w_N
a_1 b_1
...
a_M b_M
制約
| # | 条件 |
|---|---|
| 1 | $1\le N\le2\times10^5$, $N-1\le M$ |
| 2 | $1\le w_i\le10^9$ |
| 3 | 多重辺・自己ループは無い。グラフは連結で、サボテンである |
入力例1
3 3
5 1 4
1 2
2 3
3 1出力例1
5入力例2
5 5
3 1 4 1 5
1 2
2 3
3 1
3 4
4 5出力例2
9(頂点 3 と 5 を選んで $4+5=9$)
入力例3
7 8
1 1 1 1 1 1 1
1 2
2 3
3 1
3 4
4 5
5 6
6 4
1 7出力例3
3(三角形 1-2-3 と三角形 4-5-6 が橋 3-4 でつながり、頂点 7 が頂点 1 にぶら下がる)
背景と出典
一般のグラフの最大独立集合は NP 困難だが、木幅が小さいグラフでは DP で解ける。サボテンは木幅が高々 2 で、閉路を 1 つずつ畳む DP で解ける、と記憶している(本セッションでは出典を確認していない)。木の最大重み独立集合は「$v$ を選ぶ/選ばない」の 2 状態で解ける標準的な DP。サボテンでは、閉路の頂上 $a$ を選ぶか選ばないかによって、閉路の残りの頂点(鎖)に課す制約が変わる。
この問題で確かめたいのは次の 3 点。(1) 閉路を鎖として畳む DP が、独立集合の全探索と全件一致するか。(2) 後退辺を捨てて木として解く誤り、および頂上を選ぶときに鎖の両端を禁止しない誤りが、何件で外れるか。(3) $N=2\times10^5$ の深い構造で、再帰なしで通るか。
ヒント(クリックで段階的に開示)
ヒント1(方向性)
木の DP は「$v$ を選ぶ/選ばない」の値を子から畳む。閉路があると、子が親だけでなく頂上の頂点とも隣り合う。DFS 木を作ると、閉路は「後退辺 1 本と、それがまたぐ木の辺の列」になる。
ヒント2(アプローチ)
後退辺 $(b,a)$($b$ が子孫、$a$ が祖先)で閉路ができる。頂上を $a$、鎖を $c_1,\dots,c_k=b$($a$ の子側から)とする。鎖の各頂点 $c_i$ は、閉路以外の枝(ぶら下がった部分)の値を持つ。頂上 $a$ を選ぶなら $c_1,c_k$ は選べない。選ばないなら制約は鎖の隣接(連続する 2 頂点を選ばない)だけ。
ヒント3(誘導)
各頂点 $v$ に $d_0[v]$($v$ を選ばない)と $d_1[v]$($v$ を選ぶ)を持つ。ただし閉路の親辺側は畳まない(鎖の頂点が親へ値を渡すのは頂上だけ)。DFS の訪問順の逆順で、橋でつながる子は普通に親へ足し、閉路の鎖は頂上に着いたときにまとめて畳む。鎖の畳みは、先頭から $s_0,s_1$(選ばない/選ぶ)を持つ 1 次元 DP を、両端制約あり・なしの 2 通りで行う。
模範解答 (Python)
模範解答を表示する(まず自力で解いてから)
import sys
def solve(n, w, edges):
adj = [[] for _ in range(n)]
for a, b in edges:
adj[a].append(b); adj[b].append(a)
parent = [-1]*n; depth = [-1]*n; order = []
depth[0] = 0
stack = [0]; it = [0]*n
order.append(0)
back = [] # (子孫 b, 祖先 a)
while stack:
v = stack[-1]
if it[v] < len(adj[v]):
u = adj[v][it[v]]; it[v] += 1
if depth[u] == -1:
depth[u] = depth[v]+1; parent[u] = v
order.append(u); stack.append(u)
elif u != parent[v] and depth[u] < depth[v]:
back.append((v, u)) # 後退辺(単純グラフなので親との重複は無い)
else:
stack.pop()
in_cycle = [False]*n # 親への辺が閉路に属するか
cycles = [[] for _ in range(n)] # 頂点 a を頂上とする閉路の鎖 [c1..ck](a の子側から)
for b, a in back:
chain = []; x = b
while x != a:
chain.append(x); in_cycle[x] = True; x = parent[x]
chain.reverse()
cycles[a].append(chain)
d0 = [0]*n; d1 = [w[v] for v in range(n)] # d0: v を選ばない / d1: 選ぶ(v の「閉路を除く」下側だけ)
NEG = float('-inf')
for v in reversed(order):
# この時点で d0[v], d1[v] には「橋の子」の分が入っている。頂上が v の閉路を畳む
for chain in cycles[v]:
for fix_ends in (False, True):
# 鎖上の独立集合の最大値。fix_ends なら両端 c1, ck は選べない(v を選ぶ場合)
s0 = d0[chain[0]] # c1 を選ばない
s1 = NEG if fix_ends else d1[chain[0]] # c1 を選ぶ
for c in chain[1:]:
n0 = max(s0, s1) + d0[c]
n1 = s0 + d1[c]
s0, s1 = n0, n1
if fix_ends: val = s0
else: val = max(s0, s1)
if fix_ends: d1[v] += val
else: d0[v] += val
# fix_ends=True の値は ck を選べない: s0 は「ck を選ばない」なので OK
p = parent[v]
if p != -1 and not in_cycle[v]:
d1[p] += d0[v]
d0[p] += max(d0[v], d1[v])
return max(d0[0], d1[0])
def main():
data = sys.stdin.read().split()
n, m = int(data[0]), int(data[1])
w = [int(x) for x in data[2:2+n]]
edges = [(int(data[2+n+2*i])-1, int(data[3+n+2*i])-1) for i in range(m)]
print(solve(n, w, edges))
if __name__ == "__main__": main()
計算量: 各頂点・各辺を定数回見るので $O(N+M)$。実測(Python 3.14.7、M1 Mac)で、無作為なサボテン $N=2\times10^5$(辺 247914 本)が 1.09 s。
Step-by-Step 解説
再帰なしの DFS で parent、depth、訪問順 order を作る。すでに訪問済みで、親でなく、深さが浅い頂点への辺が後退辺。単純グラフなので、親への辺を「頂点」で見分けてよい(多重辺があれば辺の番号が要る)。
後退辺 $(b,a)$ から親をたどって $a$ に着くまでの頂点を、$a$ の子側から並べ替えたものが鎖 $c_1,\dots,c_k$。鎖の頂点は in_cycle = True にして、親への辺が閉路の辺であることを覚える。サボテンなので、1 つの辺が 2 つの閉路に属することはなく、この印は一意。
in_cycle でない頂点 $v$ は、親への辺が橋。$d_1[p]\mathrel{+}=d_0[v]$(親を選ぶなら $v$ は選べない)、$d_0[p]\mathrel{+}=\max(d_0[v],d_1[v])$。
訪問順の逆順で頂点 $v$ を処理するとき、$v$ が頂上の閉路について、鎖の各頂点の $d_0,d_1$ はすでに確定している(子孫だから)。
- $v$ を選ばない場合: 鎖は両端の制約が無く、隣り合う 2 頂点を選ばない独立集合の最大。$d_0[v]\mathrel{+}=$ その値。
- $v$ を選ぶ場合: $c_1,c_k$ は $v$ と隣り合うので選べない。$d_1[v]\mathrel{+}=$ $c_1$ を選ばず、最後の $c_k$ も選ばない鎖の最大。
鎖の DP は $s_0$(直前を選ばない)、$s_1$(選ぶ)を持ち、$n_0=\max(s_0,s_1)+d_0[c]$、$n_1=s_0+d_1[c]$。$c_1$ を選ばない制約は初期値 $s_1=-\infty$、$c_k$ を選ばない制約は最後に $s_0$ だけを取る。
根(頂点 1)の $\max(d_0,d_1)$。
迷った他案と採らなかった理由
| 案 | 計算量 | 採否 | 理由 |
|---|---|---|---|
| DFS 木+鎖の畳み(本解) | $O(N+M)$ | 採用 | 全探索 500 件・分枝限定との照合 150 件と一致 |
| 後退辺を捨てて木として DP | $O(N+M)$ | 不採用 | 閉路の隣接制約を落とし、独立でない頂点を選ぶ(135/400 件で誤答) |
| 閉路上の 1 頂点を「選ぶ/選ばない」で固定し、各場合を木に直す | $O(2^{c}(N+M))$($c$ は閉路の数) | 不採用 | 閉路が多いと指数。サボテンでは頂上で局所的に畳めるので不要 |
| 木分解(幅 2)の一般的な DP | $O(N)$ | 不採用 | 一般の木分解を作る手間が大きい。サボテンの構造を直接使う方が短い |
前提が変わったら:
- サボテンでなく、辺が複数の閉路に属する一般のグラフでは、独立集合は NP 困難(木幅が大きいと指数)。本解は使えない
- 頂点の色分け(最小彩色)や最大クリークなどは、サボテンでも閉路の長さの偶奇で場合分けが変わる。本解の形(頂上で鎖を畳む)はそのままは使えない
- 辺の削除・追加をクエリで受ける(動的サボテン)場合は、Top Tree などの動的 DP が要る。実装していない
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
| 後退辺を無視して木として解く | 閉路の隣接制約を落とす | 後退辺から鎖を作り、頂上で畳む(135/400 件で誤答) |
| 頂上を選ぶとき鎖の両端を禁止しない | 頂上と $c_1,c_k$ が隣り合うのを忘れる | fix_ends の場合は $s_1=-\infty$ から始め、最後は $s_0$ だけを取る(223/400 件で誤答) |
| 鎖の頂点から親へ値を渡す | 閉路の辺を橋と同じに扱う | in_cycle の頂点は親へ足さない(頂上が畳む) |
| 再帰で DFS を書く | 深さ $N$ で再帰上限に当たる | 明示的なスタックで書く |
次のステップ
- 発展1: サボテン上の最小頂点被覆(独立集合の補集合)や、最大重みの辺独立集合(マッチング)を同じ形で解く
- 発展2: 閉路が 2 辺連結成分に拡がった「2 辺連結だが木幅の小さい」グラフ(Cactus の一般化)での独立集合を考える
- 次回予告: 焦点帯 M1 から 3 問以上を含む通常のローテーション
検証ログ(本セッションで実行した数値)
- 全探索との照合(実測): 頂点 2〜14、閉路(長さ 3〜6)とぶら下がりをランダムに足して作ったサボテン 500 件で、独立集合の全部分集合の全探索と全件一致
- 分枝限定との照合(実測): 頂点 2〜12 の 400 件、頂点 30〜45 で $w\le10^9$ の 150 件で、最大次数の頂点で分岐する厳密解(別実装)と一致
- 誤った式(実測): 後退辺を捨てると 400 件中 135 件、頂上を選ぶときの両端の禁止を落とすと 223 件で外れる
- 入力例(実測): 5、9、3(出力例どおり)
- 実行時間(実測): 無作為なサボテン $N=2\times10^5$(辺 247914 本)で 1.09 s。長さ 199998 の道の先に三角形を付けた例が 0.50 s、長さ $2\times10^5$ の 1 本の閉路が 0.44 s(答えは 100000)。いずれも再帰なしで通る
- 扱っていない範囲: 多重辺・自己ループのある場合、サボテン性の検証(入力が保証する前提)、動的な辺の更新、$N=2\times10^5$ の結果の独立な検算(大きい入力は時間のみ測り、答えは別実装と比べていない)