🌲

『部分木の答えを有向辺で伝える』全方位木 DP

に公開

こんにちは、だいくすとらです (≧▽≦)

先日 X で、以下のような全方位木 DP のポストをしました。ぶっちゃけ、このアルゴリズムのアイデアはこの画像だけでかなり説明できているのですが、より丁寧に解説記事を書いてみました〜💪
解説では式を使って説明していますが、それぞれ図や動画を添えているので「式わかんない!┐(´д`)┌」という人は図と動画だけ見てもある程度理解できるようにしています!

https://x.com/daikusutora3/status/1983911271079788969?s=20

0. 問題設定

頂点数 N の木(連結・閉路なし)が与えられる。
各無向辺 (u, v) の長さはすべて 1 とする。
各頂点 r について「r から一番遠い頂点までの距離(辺数)」を求めよ。

よくある「各頂点からの最遠距離を全部出してね」というやつです。
素直にやると、各頂点を BFS / DFS の始点にして O(N^2) かかってしまうので、もう少しうまくやりたいところです。

ここでは 全方位木 DPを、「頂点ではなく有向辺に DP を載せる」形で説明します。

1. 全体のイメージ:頂点ではなく「辺」に DP を載せる

ふつうの木 DP では「頂点ごとに DP の値を持つ」ことが多いですが、ここでは 無向辺 (u, v) を 2 本の有向辺 u \to v,\ v \to u に分けて、それぞれに値を持たせます。

無向辺を 2 本の有向辺として扱う

このとき、有向辺 u \to v に対応するdp[u \to v] には次のような意味の値を持たせます。

  • u 側の木の高さ」(= u からその木の中で一番遠い頂点までの距離)
  • + 辺 (u, v) の 1 本ぶん

部分木から伝わる最遠距離

上の図では、青い部分木の中での最長距離(u からの距離)が 3 で、本体の赤い辺を 1 本足して、dp[u \to v] = 4 になっているイメージです。

つまり u 側の部分木が、自分の高さに +1 した値を、矢印 u \to vv に送っていると考えてください。

1.1 各頂点の答え ans[v]

頂点 v には、隣接頂点 x \in N(v) ごとに「その方向からの部分木の情報」が dp[x \to v] として集まってきます。v から見れば、各方向からの候補がすべてそろっている状態なので、

ans[v] = \max_{x \in N(v)} dp[x \to v]

とおけば、v から最も遠い頂点までの距離がその最大値として得られます。

頂点 v に集まる各方向からの距離

上の図では、頂点 v に集まる値が 4, 2, 1 なので、答えはその最大値で ans[v] = 4 になります。

したがって最終的にやることは、すべての有向辺 u \to vdp[u \to v] を計算することだけです。

2. dp[u \to v] の更新式と葉のベースケース

dp[u \to v] は、「u に入ってくる矢印のうち、v 以外から来る最大値に 1 を足したもの」と定義します。図で言うと青い矢印の最大値に赤い矢印自身の+1を足したものが赤い矢印の値 dp[u \to v] だ、という式です。

dp[u \to v] = 1 + \max_{x \in N(u),\ x \neq v} dp[x \to u]

u から見て v 以外の方向

u では、ベースケースとして dp[u \to v] = 1 + 0 = 1 となります。この葉から始まった値が伝搬していき、最終的に木全体のすべての有向辺 u \to vdp[u \to v] が行き渡る、というイメージです。

こちらの動画はこの更新式によるDPの値の計算を映像にしたものです。葉から値が木全体に伝搬していくイメージが掴めると思います。

  • 画面上: いま計算している辺の様子を映すエリアです。左側に更新式、右側にその式に対応する辺のアニメーションが表示されます。
  • 画面下: 計算し終わった dp[u \to v] を並べておくエリアです。左側に bottom up 方向の辺、右側に top down 方向の辺の計算結果が順番に描かれていきます。

https://www.youtube.com/watch?v=oEjnNA0sL1A

3. 「自分以外の最大値」を素直に求めると重くなる

計算量は下から上の向き(bottom-up)の有向辺の値を計算するときと、上から下の向き(top-down)の有向辺の値を計算するときとで異なります。

bottom-upでは各頂点 u について「親方向の 1 本」dp[u \to \mathrm{par}(u)] だけ計算すればよく、これは素直に書いても全体で O(N) です。

 の親

一方top-down では以下の式を「u の子ども v」ごとに計算します。素朴にやると、子どもを 1 人選ぶたびに「その子以外の隣接頂点を全部なめて最大を取り直す」ことになります。

dp[u \to v] = 1 + \max_{x \in N(u), x \neq v} dp[x \to u]

top down での「v 以外の最大値」の作り直し

上の図のように、子どもを変えるたびに、毎回ほぼ同じ青い矢印の列を何度も読み込むことになるので、u の次数が大きいと一気に計算量が重くなります。この「毎回、自分以外の最大を取り直している」部分をまとめて処理するために、次で prefix / suffix(前後からの累積最大) を使います。

4. prefix / suffix で「自分以外の最大」を O(1) で出す

4.1 親と子どもを並べる

累積を計算するために木をどこかで根付き木にして、頂点 u について

  • 親:p
  • 子ども:c_0, c_1, \dots, c_{k-1}0 始まり)

と並べておきます。

4.2 prefix / suffix の定義

頂点 u の子ども c_0, \dots, c_{k-1} について、次のように配列を定めます。

\mathrm{pref}[i] = \max_{j \in [0, i)} dp[c_j \to u], \quad \mathrm{pref}[0] = 0
\mathrm{suf}[i] = \max_{j \in [i, k)} dp[c_j \to u], \quad \mathrm{suf}[k] = 0

ここで[a, b)は「a \le j < b を満たす添字 j(半開区間)」を表します(例えば j \in [1, k)1 \le j < k, つまり j = 1,2,\dots,k-1)。

このとき、子ども c_i について

  • 左側の最大値:\mathrm{pref}[i]
  • 右側の最大値:\mathrm{suf}[i+1]

がどちらも O(1) で一発で取れます。

子ども方向の prefix / suffix

4.3 子ども c_i への遷移式

子ども c_i に向かう矢印 u \to c_i を考えます。u に入ってくる辺のうち、「c_i からの辺」以外を見たいので、次の 3 つを使います。

  • 親方向:dp[p \to u]u が根なら 0
  • 左側の子どもたち:\mathrm{pref}[i]
  • 右側の子どもたち:\mathrm{suf}[i+1]

なので更新式は以下のように書けます。

dp[u \to c_i] = 1 + \max\bigl( dp[p \to u],\ \mathrm{pref}[i],\ \mathrm{suf}[i+1] \bigr)

親 + 左側 + 右側の 3 方向

各頂点 u ごとに prefix / suffix を作るのは O(\deg(u))
そこから各 dp[u \to c_i] を求めるのも O(\deg(u)) なので、木全体では O(N) で計算できます。

5. 実装例(Python)

ここまでの内容を、そのまま Python で書いたコードを載せておきます。

  • prefix / suffix を使わず、素直に「自分以外の最大」を取り直す O(N^2)の非高速版
  • prefix / suffix を使って O(N) で動く高速版

の 2 パターンです。どちらも、記事中の簡単なサンプル木をそのままベタ書きしています。

5.1 prefix / suffix なし(O(N^2)

top-down 側で毎回「自分以外の最大値」を取り直している素直な実装です。 アルゴリズムのイメージをつかむ用としては分かりやすいですが、次数が大きい頂点があると計算量が O(N^2) まで膨らみます。

from collections import deque

# ========= サンプル木 =========
N = 10
EDGES = [
    (0, 1),
    (0, 2),
    (0, 3),
    (1, 4),
    (1, 5),
    (4, 6),
    (2, 7),
    (7, 8),
    (3, 9),
]

tree = [[] for _ in range(N)]
for s, t in EDGES:
    tree[s].append(t)
    tree[t].append(s)

# ------------------------------------------------------------
# 0. 根付き木にして、親 par と BFS 順 order と children を作る
# ------------------------------------------------------------
par = [-1] * N
order = []
children = [[] for _ in range(N)]
root = 0
q = deque([root])

while q:
    u = q.popleft()
    order.append(u)
    for v in tree[u]:
        if v == par[u]:
            continue
        par[v] = u
        children[u].append(v)
        q.append(v)

# dp[u][v] = 有向辺 u -> v に載せる値
dp = [{v: 0 for v in adj} for adj in tree]

# ------------------------------------------------------------
# 1. bottom-up
# ------------------------------------------------------------
for u in reversed(order):
    v = par[u]
    if v == -1:
        continue
    dp[u][v] = 1 + max(
        (dp[x][u] for x in tree[u] if x != v),
        default=0,
    )

# ------------------------------------------------------------
# 2. top-down(毎回「自分以外の最大」を取り直す)
# ------------------------------------------------------------
for u in order:
    for v in children[u]:
        dp[u][v] = 1 + max(
            (dp[x][u] for x in tree[u] if x != v),
            default=0,
        )

# ------------------------------------------------------------
# 3. 各頂点 v に「入る」有向辺の最大値が
#    「v から最も遠い頂点までの距離」になる。
# ------------------------------------------------------------
ans = []
for v in range(N):
    ans.append(max(dp[x][v] for x in tree[v]))

print(*ans)

5.2 prefix / suffix を使った高速版(O(N)

本文で解説した「有向辺 DP + prefix / suffix」による高速版です。

from collections import deque
from itertools import accumulate

# ========= サンプル木 =========
N = 10
EDGES = [
    (0, 1),
    (0, 2),
    (0, 3),
    (1, 4),
    (1, 5),
    (4, 6),
    (2, 7),
    (7, 8),
    (3, 9),
]

tree = [[] for _ in range(N)]
for s, t in EDGES:
    tree[s].append(t)
    tree[t].append(s)

# ------------------------------------------------------------
# 0. 根付き木にして、親 par と BFS 順 order と children を作る
# ------------------------------------------------------------
par = [-1] * N
order = []
children = [[] for _ in range(N)]
root = 0
q = deque([root])

while q:
    u = q.popleft()
    order.append(u)
    for v in tree[u]:
        if v == par[u]:
            continue
        par[v] = u
        children[u].append(v)
        q.append(v)

# dp[u][v] = 有向辺 u -> v に載せる値
dp = [{v: 0 for v in adj} for adj in tree]

# ------------------------------------------------------------
# 1. bottom-up: dp[u][v] = 1 + max( dp[x][u] (x != v) )
# ------------------------------------------------------------
for u in reversed(order):
    v = par[u]
    if v == -1:
        continue
    dp[u][v] = 1 + max(
        (dp[x][u] for x in tree[u] if x != v),
        default=0,
    )

# ------------------------------------------------------------
# 2. prefix / suffix
# ------------------------------------------------------------
pref = [[] for _ in range(N)]
suf = [[] for _ in range(N)]

for u in range(N):
    vals = [dp[v][u] for v in children[u]]
    # pref[u][i] = children[u][:i] から来る最大値
    pref[u] = [0] + list(accumulate(vals, max))
    # suf[u][i+1] = children[u][i+1:] から来る最大値
    suf[u] = list(accumulate(reversed(vals), max))[::-1] + [0]

# ------------------------------------------------------------
# 3. top-down: dp[u][v] = 1 + max( par_in, pref[u][i], suf[u][i+1] )
# ------------------------------------------------------------
for u in order:
    p = par[u]
    par_in = dp[p][u] if p != -1 else 0
    for i, v in enumerate(children[u]):
        dp[u][v] = 1 + max(par_in, pref[u][i], suf[u][i + 1])

# ------------------------------------------------------------
# 4. 各頂点 v に「入る」有向辺の最大値が
#    「v から最も遠い頂点までの距離」になる。
# ------------------------------------------------------------
ans = [max(dp[x][v] for x in tree[v]) for v in range(N)]
print(*ans)

6. まとめ

この全方位木 DP は、各有向辺に「自分側の部分木情報+1」を載せて葉から木全体へと伝搬させることで、どの頂点を根にしても成り立つ情報を計算するテクニックです。さらに、各頂点で必要になる「自分以外の最大値」を prefix / suffix でまとめて求めることで、素朴な実装では O(N^2) かかる計算を全体 O(N) に高速化できるのがポイントです。

もし分からないところやここ間違ってるよ!とかがあったら気軽にコメントで教えてください!

宣伝

自分はうさねこらーじさんプロデュースの VTuber グループ「はぴねすと∞」からデビュー予定です!
2026/2/11 に初配信を予定しているので、よかったら遊びに来てください〜

📺 初配信はこちら
https://www.youtube.com/watch?v=XyrEB8eoCso

また、はぴねすと∞の各メンバーの X アカウントも載せておきます。
みんな魅力的な人ばかりなので、気になったメンバーがいたらぜひフォローしてもらえると嬉しいです!

https://x.com/usaneko_xlarge
https://x.com/suzushiro_346
https://x.com/chirimotsuriko
https://x.com/daikusutora3

Discussion