Day 047-Q5 — Link-Cut Tree 辺重みパスクエリ(辺ノードテクニック + 最大値伝播)

2026-05-31 赤色 Master / Phase 8+ ★★★★★★★★★ Link-Cut Tree / Edge Node Technique / Path Query

問題

$N$ 頂点の木(辺には重みあり)に対して以下の $Q$ クエリを処理せよ:

  • 1 u v w:辺 $(u,v)$ の重みを $w$ に変更する
  • 2 u v:頂点 $u$ から頂点 $v$ へのパス上の辺の重みの最大値を求めよ

制約

$2 \le N \le 2 \times 10^5$
$1 \le Q \le 2 \times 10^5$
$0 \le w \le 10^9$
時間制限: 3秒

入出力例

入力例 1

5
1 2 3
2 3 1
3 4 4
4 5 2
4
2 1 4
1 2 3 5
2 1 5
2 3 5

出力例 1

4
5
5

概念図: 辺ノードテクニックと Link-Cut Tree

元の木(辺に重み) 1 2 3 4 w=3 w=1 w=4(max) 辺ノード変換 辺ノードテクニック 1 e12 val=3 2 e23 val=1 3 e34 val=4 4 Link-Cut Tree 主要操作 access(x): x から LCT根まで preferred path を繋ぎ、x を Splay 木の根に make_root(x): access(x) → push_rev(x) で x を表現木の根に変更 path_max(x, y): make_root(x) → access(y) → y の Splay 木の mx 値を返す update_val(e, w): 辺ノード e の val を w に更新 → access(e) で mx を再計算 辺ノードの mx = 0 (頂点ノードの val=0 なので、mx には辺ノードの val だけが反映される) クエリ path_max(1, 4): make_root(1) → access(4) → Splay木 {1-e12-2-e23-3-e34-4} の mx = max(0,3,0,1,0,4,0) = 4 → 辺重みの最大値 4 を O(log N) amortized で取得

ヒント(段階的開示)

ヒント1: 方向性
辺の重みを辺の端点のうち深さの深い方の頂点の値として持たせる(辺ノードテクニック)。これにより「辺重みのパス最大値」が「頂点値のパス最大値(辺ノードのみ非ゼロ)」と等価になり、Link-Cut Tree で処理できます。
ヒント2: アプローチ
  • 辺 $(u,v,w)$ を辺ノード $e$(追加頂点)として $u-e-v$ の経路に変換し、$e$ の val を $w$ とする
  • 頂点ノードの val = 0(辺ノードだけが非ゼロ)にすることで、path_max はパス上の辺重みの最大を返す
  • 各ノードに mx(部分木内の最大 val)を保持し、push_up で集約
  • 辺重み更新: 辺ノードの val を変更し、access で祖先の mx を更新
ヒント3: push_up と mx 集約
def push_up(self, x):
    l, r = self.ch[x]
    self.mx[x] = self.val[x]  # 自分の val
    if l: self.mx[x] = max(self.mx[x], self.mx[l])
    if r: self.mx[x] = max(self.mx[x], self.mx[r])

def path_max(self, x, y):
    self.make_root(x)
    self.access(y)
    return self.mx[y]  # x-y パスの Splay 木全体の mx

模範解答 (Python)

import sys
input = sys.stdin.readline

class LCT:
    def __init__(self, n):
        self.n = n
        self.ch = [[0, 0] for _ in range(n + 1)]
        self.par = [0] * (n + 1)
        self.val = [0] * (n + 1)
        self.mx = [0] * (n + 1)
        self.rev = [False] * (n + 1)

    def is_root(self, x):
        p = self.par[x]
        return p == 0 or (self.ch[p][0] != x and self.ch[p][1] != x)

    def push_up(self, x):
        l, r = self.ch[x]
        self.mx[x] = self.val[x]
        if l: self.mx[x] = max(self.mx[x], self.mx[l])
        if r: self.mx[x] = max(self.mx[x], self.mx[r])

    def push_rev(self, x):
        self.ch[x][0], self.ch[x][1] = self.ch[x][1], self.ch[x][0]
        self.rev[x] ^= True

    def push_down(self, x):
        if self.rev[x]:
            if self.ch[x][0]: self.push_rev(self.ch[x][0])
            if self.ch[x][1]: self.push_rev(self.ch[x][1])
            self.rev[x] = False

    def rotate(self, x):
        y = self.par[x]
        z = self.par[y]
        k = 1 if self.ch[y][1] == x else 0
        w = self.ch[x][1 - k]
        if not self.is_root(y):
            if self.ch[z][0] == y: self.ch[z][0] = x
            else: self.ch[z][1] = x
        self.par[x] = z
        self.ch[x][1 - k] = y
        self.par[y] = x
        self.ch[y][k] = w
        if w: self.par[w] = y
        self.push_up(y)
        self.push_up(x)

    def splay(self, x):
        stack = []
        u = x
        stack.append(u)
        while not self.is_root(u):
            u = self.par[u]
            stack.append(u)
        while stack:
            self.push_down(stack.pop())
        while not self.is_root(x):
            y = self.par[x]
            if not self.is_root(y):
                z = self.par[y]
                if (self.ch[z][0] == y) == (self.ch[y][0] == x):
                    self.rotate(y)
                else:
                    self.rotate(x)
            self.rotate(x)

    def access(self, x):
        last = 0
        y = x
        while y:
            self.splay(y)
            self.ch[y][1] = last
            self.push_up(y)
            last = y
            y = self.par[y]
        self.splay(x)
        return last

    def make_root(self, x):
        self.access(x)
        self.push_rev(x)

    def link(self, x, y):
        self.make_root(x)
        self.par[x] = y

    def path_max(self, x, y):
        self.make_root(x)
        self.access(y)
        return self.mx[y]

    def update_val(self, x, v):
        self.access(x)
        self.val[x] = v
        self.push_up(x)


def main():
    N = int(input())
    lct = LCT(N + N)  # 頂点 1..N + 辺ノード N+1..2N-1

    edge_node = {}

    for i in range(N - 1):
        u, v, w = map(int, input().split())
        eid = N + i + 1  # 辺ノード番号 (1-indexed)
        lct.val[eid] = w
        lct.mx[eid] = w
        edge_node[(min(u,v), max(u,v))] = eid
        lct.link(u, eid)
        lct.link(eid, v)

    Q = int(input())
    out = []
    for _ in range(Q):
        line = list(map(int, input().split()))
        if line[0] == 1:
            _, u, v, w = line
            eid = edge_node[(min(u,v), max(u,v))]
            lct.update_val(eid, w)
        else:
            _, u, v = line
            out.append(lct.path_max(u, v))

    print('\n'.join(map(str, out)))

main()

Step-by-Step 解説

1辺重みを頂点値として管理
LCT は本来頂点値を扱う。辺 $(u,v,w)$ を辺ノード $e$(追加頂点)として $u-e-v$ の経路に変換し、$e$ の val を $w$ とする。頂点ノードの val = 0 にすることで、path_max はパス上の辺ノードの val の最大を返す。
2LCT の Splay Tree 部分
各 preferred path を Splay Tree で管理。ノード $x$ が「aux tree の根」か否かは is_root(x) で判定(親が $x$ を子として持たないか)。
3access(x) 操作
$x$ から LCT の根まで preferred path を繋ぐ。各 aux tree の根に splay し、右の子を繋ぎ替えながら遡る。
4path_max(x, y) の実装
make_root(x) で $x$ を根にし、access(y) で $x$-$y$ パスを1つの Splay Tree に収める。その根の mx 値が答え。
5辺重み更新
辺ノード $e$ の val を変更し、access(e) でその祖先の mx 値を再計算する。

計算量

構築: $O(N \log N)$ amortized
path_max クエリ: $O(\log N)$ amortized
辺重み更新: $O(\log N)$ amortized
全体: $O((N + Q) \log N)$ amortized

よくあるミス

ミス原因正しい書き方
is_root の判定が逆子の判定と根の判定を混同親が自分を ch[0] か ch[1] に持てば非根
push_down を splay の前にスタックで実行しない反転フラグが子に伝播しないまま rotatesplay 前に根までのパスを逆順で push_down
辺ノードの番号衝突頂点 1..N と辺ノードが重複辺ノードは N+1 以降の番号を使う
link 後に mx 値を更新しないリンク後の集約値が古いlink 内か直後に push_up

次のステップ

  • 発展問題: LCT による動的木の連結性チェック + パス上の辺重みの和クエリ
  • 関連: Day009 Q5(Link-Cut Tree 基礎実装)の復習
  • 応用: ネットワークの帯域変更に対するリアルタイムボトルネック検出

自己評価