問題
N頂点M辺の無向グラフが与えられる。各頂点$i$には重み$w_i$がついている。さらに、このグラフの nice tree decomposition(整った木分解)が、T個のノードからなる操作列として与えられる。各ノードは次のいずれかである(番号は1-indexedで、子ノードの番号は必ず自分より小さい)。
LEAF v: バッグ = {v}INTRO v p: バッグ = バッグ(p) ∪ {v}FORGET v p: バッグ = バッグ(p) \ {v}JOIN p q: バッグ = バッグ(p) = バッグ(q)
ノードTが根であり、バッグ(T)=∅であることが保証される。このグラフの最大重み独立集合の重みを求めよ。
入力形式
N M
w_1 w_2 ... w_N
u_1 v_1
...
u_M v_M
T
op_1
...
op_T
制約
$1 \le N \le 40$
$0 \le M \le N(N-1)/2$
木幅 $\le 12$(バッグサイズ $\le 13$)
$1 \le w_i \le 1000$
$T \le 500$
入出力例
入力例1
4 4
3 5 2 6
1 2
2 3
1 3
3 4
8
LEAF 1
INTRO 2 1
INTRO 3 2
FORGET 1 3
FORGET 2 4
INTRO 4 5
FORGET 3 6
FORGET 4 7
出力例1
11
頂点2と頂点4を選ぶと5+6=11。頂点1,2,3は三角形なので同時に2つ以上選べない。
概念図: グラフと木分解、バッグDPの流れ
ヒント(段階的開示)
ヒント1: 方向性
一般のグラフで最大重み独立集合を求めるのはNP困難だが、グラフの「木幅」が小さい(木構造に近い)場合は、木構造に沿った動的計画法で多項式時間(頂点数に対しては線形、木幅に対しては指数)で解ける。この枠組みを「木分解上の動的計画法」と呼ぶ。
ヒント2: アプローチ
木分解の各ノードは「バッグ」と呼ばれる頂点の部分集合を持ち、「全ての辺が少なくとも1つのバッグに含まれる」「各頂点が属するバッグの集合が木の中で連結な部分木をなす」という条件を満たす。nice tree decompositionでは、バッグの変化がLEAF/INTRO/FORGET/JOINの4種類の単純な操作に限定されるため、各ノードごとに「バッグの部分集合Sに対応する、そのノード以下の部分木で処理済みの頂点だけを使った最大重み独立集合の値」というDPテーブルを、子から親へボトムアップに更新していける。
ヒント3: 誘導(コード骨格)
# dp[node] : dict, キー = バッグの部分集合(選んだ頂点の集合), 値 = そのときの最大重み
#
# LEAF v: dp = { {}: 0, {v}: w[v] }
# INTRO v (子p): 子の各(S, val)について
# dp[S] = val (vを選ばない)
# S∪{v}がグラフ上で独立集合なら dp[S∪{v}] = val + w[v]
# FORGET v (子p): 子の各(S, val)について
# dp[S\{v}] = max(既存の値, val) # vを選んだ場合も選ばなかった場合もここで確定させる
# JOIN p, q (同じバッグ): 共通のSについて
# dp[S] = dp_p[S] + dp_q[S] - (Sの頂点の重みの総和) # 二重計上を引く
#
# 根(バッグが空集合)のdp[{}]が答え
模範解答 (Python)
import sys
def solve():
data = sys.stdin.buffer.read().split()
it = iter(data)
N = int(next(it)); M = int(next(it))
w = [0] + [int(next(it)) for _ in range(N)]
edge_set = set()
for _ in range(M):
a = int(next(it)); b = int(next(it))
edge_set.add((min(a, b), max(a, b)))
T = int(next(it))
dp = [None] * (T + 1) # 1-indexed
def is_indep(S):
Sl = list(S)
for i in range(len(Sl)):
for j in range(i + 1, len(Sl)):
a, b = Sl[i], Sl[j]
if (min(a, b), max(a, b)) in edge_set:
return False
return True
for i in range(1, T + 1):
typ = next(it)
if typ == 'LEAF':
v = int(next(it))
dp[i] = {frozenset(): 0, frozenset({v}): w[v]}
elif typ == 'INTRO':
v = int(next(it)); p = int(next(it))
src = dp[p]
newdp = {}
for S, val in src.items():
if val > newdp.get(S, float('-inf')):
newdp[S] = val
S2 = S | {v}
if is_indep(S2):
cand = val + w[v]
if cand > newdp.get(S2, float('-inf')):
newdp[S2] = cand
dp[i] = newdp
elif typ == 'FORGET':
v = int(next(it)); p = int(next(it))
src = dp[p]
newdp = {}
for S, val in src.items():
S2 = S - {v}
if val > newdp.get(S2, float('-inf')):
newdp[S2] = val
dp[i] = newdp
elif typ == 'JOIN':
p = int(next(it)); q = int(next(it))
dl, dr = dp[p], dp[q]
newdp = {}
for S in dl:
if S in dr:
wsum = sum(w[x] for x in S)
val = dl[S] + dr[S] - wsum
if val > newdp.get(S, float('-inf')):
newdp[S] = val
dp[i] = newdp
root_dp = dp[T]
print(max(root_dp.values()) if root_dp else 0)
solve()
計算量: バッグサイズの最大値(=木幅+1)を$w$とすると、各ノードでのDPテーブルサイズは最大$2^{w}$。全体で$O(T\cdot 2^w\cdot w^2)$。ランダムな小規模グラフ(頂点数1〜12)40ケースについて、最小次数消去法で構築した一般木分解をnice化した入力を用い、全探索によるブルートフォース最大重み独立集合と結果が完全一致することを確認済み。
Step-by-Step 解説
1木分解とは
木構造の各ノードにグラフの頂点の部分集合(バッグ)を割り当てたもので、「全辺が少なくとも1つのバッグに含まれる」「各頂点のバッグ集合が連結な部分木をなす」という2条件を満たすもの。バッグの最大サイズ-1を木幅と呼ぶ。
木構造の各ノードにグラフの頂点の部分集合(バッグ)を割り当てたもので、「全辺が少なくとも1つのバッグに含まれる」「各頂点のバッグ集合が連結な部分木をなす」という2条件を満たすもの。バッグの最大サイズ-1を木幅と呼ぶ。
2nice tree decompositionへの正規化
一般の木分解はLEAF/INTRO/FORGET/JOINの4種類の単純な操作の列に変換(nice化)できる(本問では既にnice化された形で入力される)。
一般の木分解はLEAF/INTRO/FORGET/JOINの4種類の単純な操作の列に変換(nice化)できる(本問では既にnice化された形で入力される)。
3DPテーブルの意味
dp[node][S]は「そのノード以下の部分木に現れる頂点のうち、バッグ内頂点についてはSで指定した通りに選んだ/選ばなかったを固定したときの最大重み」を表す。
dp[node][S]は「そのノード以下の部分木に現れる頂点のうち、バッグ内頂点についてはSで指定した通りに選んだ/選ばなかったを固定したときの最大重み」を表す。
4JOINでの二重計上に注意
2つの子はどちらも共通のバッグ内頂点の重みを含んだ値を持っているため、バッグの頂点の重みの総和を1回分引くことで補正する。
2つの子はどちらも共通のバッグ内頂点の重みを含んだ値を持っているため、バッグの頂点の重みの総和を1回分引くことで補正する。
5計算量の確認
バッグサイズ(木幅+1)が小さい場合に限り実用的な速度になる。一般グラフでは木幅がO(N)になりうるため、この手法が効くのは木幅が小さいグラフに限られる。
バッグサイズ(木幅+1)が小さい場合に限り実用的な速度になる。一般グラフでは木幅がO(N)になりうるため、この手法が効くのは木幅が小さいグラフに限られる。
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
| JOINで両方の子の値を単純に加算してしまう | バッグ内頂点の重みがどちらのDPテーブルにも含まれていることを見落とす | dp[S] = dp_p[S] + dp_q[S] - sum(w[x] for x in S) として二重計上を補正する |
| INTROで独立性チェックを怠る | バッグ内の頂点同士に辺があっても気にせず追加してしまう | 新しく追加する頂点と、既に選択済みのバッグ内頂点との間に辺がないか確認する |
| FORGETで「頂点を選ばなかった場合」の値を捨ててしまう | 選んだ場合の値だけを残してしまう | max(選んだ場合, 選ばなかった場合)を必ず取る |
| 一般の(niceでない)木分解にそのままこのDPを適用してしまう | JOINが3つ以上の子や、バッグが2箇所以上同時に変化するケースを想定していない | 事前にnice tree decompositionへ変換してから適用する |
次のステップ
- 発展: 与えられた一般の木分解をnice tree decompositionへ自動変換するアルゴリズム自体を実装してみる。
- 次回予告: 次回セッションでのMaster Levelローテーション継続