Day 118-Q5 — 木分解上の動的計画法(Nice Tree Decomposition)

2026-08-10 赤色 Master / Phase 8+ ★★★★★★★★★ 有界木幅グラフ・木分解DP

問題

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-2-3 + 頂点4) と対応する木分解 元のグラフ 1 2 3 4 nice tree decomposition (バッグの連なり) {1,2,3} {2,3} {3} {3,4} {4} {} INTRO↓ FORGET↓ INTRO↓ FORGET↓ FORGET↓ 各バッグの部分集合ごとに「独立集合の最大重み」を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を木幅と呼ぶ。
2nice tree decompositionへの正規化
一般の木分解はLEAF/INTRO/FORGET/JOINの4種類の単純な操作の列に変換(nice化)できる(本問では既にnice化された形で入力される)。
3DPテーブルの意味
dp[node][S]は「そのノード以下の部分木に現れる頂点のうち、バッグ内頂点についてはSで指定した通りに選んだ/選ばなかったを固定したときの最大重み」を表す。
4JOINでの二重計上に注意
2つの子はどちらも共通のバッグ内頂点の重みを含んだ値を持っているため、バッグの頂点の重みの総和を1回分引くことで補正する。
5計算量の確認
バッグサイズ(木幅+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ローテーション継続

自己評価

自分の回答

気づき・メモ