Day 178-Q3 — 2026-10-14 | 木の上の凸包トリック(根へ向かう伝令の DP。DFS の行きで 1 か所だけ上書きし帰りで戻す「二分探索で挿入」の CHT。末尾から pop する CHT はほうき型の木で O(N^2) になる)

2026-10-14 赤色 Master (Phase 8+) ★★★★★★★★★ DP 高速化(焦点帯 M1)。列の上の CHT は「傾きが単調なら末尾から pop、各直線は 1 回しか消えない」で償却 $O(N)$ になる。木の上では DFS で枝を戻るたびに pop した直線を戻す必要があり、同じ直線が何度も消えるので償却が壊れる。挿入位置を二分探索で求め、配列の 1 か所を上書きして top を縮めるだけにすると、戻すのは 1 か所で済み、1 頂点 $O(\log N)$ が最悪でも保たれる

問題

$N$ 個の町が $N-1$ 本の道で木になっている。町 1 は首都で、道 $j$ は町 $u_j,v_j$ を長さ $w_j$ で結ぶ。$d_i$ を町 1 から町 $i$ までの距離とする。

町 $i\ (2\le i\le N)$ には伝令が 1 人いて、出発の準備に $S_i$ 分、距離 1 進むのに $V_i$ 分かかる。町 $i$ で事件が起きると、町 $i$ の伝令が首都へ向かって走り出す。途中の町(首都への道の上の町)に着いた伝令は、走り続けるか、その町の伝令に知らせを渡すかを選べる。渡された伝令は準備時間 $S$ のあとで同じように走る。首都の伝令はいない(首都に着いたら終わり)。

各町 $i=2,\dots,N$ について、知らせが首都に届くまでの最短時間 $T_i$ を出力せよ。すなわち

$$T_1=0,\qquad T_i=\min_{j\text{ は }i\text{ の真の祖先}}\bigl(T_j+S_i+V_i\,(d_i-d_j)\bigr)$$

入力形式

N
u_1 v_1 w_1
...
u_{N-1} v_{N-1} w_{N-1}
S_2 V_2
...
S_N V_N

出力形式

$T_2,\dots,T_N$ を空白区切りで 1 行に

制約

#制約
1$2\le N\le2\times10^5$
2$1\le w_j\le10^4$
3$0\le S_i\le10^{12}$、$0\le V_i\le10^6$
4入力は木

入出力例

入力例1

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

出力例1

20 36 11 26

(町 4 は $S=4,V=1$ と速いので首都まで自分で走って $4+7=11$。町 5 は町 4 に渡して $3+3\cdot4+11=26$)

入力例2

4
1 2 10
2 3 10
3 4 10
0 1
1000 1
0 100

出力例2

10 1020 2010

(町 4 の最善は親の町 3 でも首都でもなく、町 2 に渡す $0+100\cdot20+10=2010$。「親か首都か」の 2 択にすると 2020 で誤る。2 例とも祖先を全部試す素朴な DP と一致)

背景と出典

  • 同じ漸化式は CEOI 2009 の “Harbingers” と記憶している(原典の問題文は本セッションで未確認。本問の問題文・制約・入力例は自作)
  • 列の上の凸包トリック(CHT)は Day018 以降何度か扱った。Day149-Q3 のロールバック付き Union-Find と同じく、「戻せる」ためには 1 回の操作が変える場所を少なくするのが鍵になる
  • 「pop 版は木で $O(N^2)$」は本セッションで最悪ケースを作って測った(下の検証ログ)

概念図

左:pop 版が O(N²) になる「ほうき型」 右:本解の 1 か所上書きとロールバック 1 2 3 u 道の直線 L 本(全部が凸包に載る) 枝 L 本。各枝の頂点が道の直線を全部 pop → 帰りに全部戻す → L² 回。N=6×10⁴ で 4.0×10⁸ 回・39.8 s 下りる前(祖先の直線、傾きの降順) 首都 L1 L2 L3 L4 top=5 二分探索で pos=1 → hull[1]=新、top=2(L2..L4 は配列に残る) 首都 新 L2 L3 L4 top=2 帰り:保存した (pos=1, L1, top=5) を書き戻す 首都 L1 L2 L3 L4 top=5 1 頂点の変更は 1 か所 → 戻すのも 1 か所 問い合わせ・挿入とも二分探索 → 最悪でも O(log N) ほうき型 N=2×10⁵ で 0.54 s(実測)

ヒント(クリックで段階的に開示)

ヒント1(方向性)

$T_i=S_i+V_id_i+\min_j\bigl(T_j-d_j\cdot V_i\bigr)$。祖先 $j$ ごとに直線 $y=T_j-d_j\,x$ を考え、$x=V_i$ での最小値を取ればよい。DFS で根から下りていくとき、今いる頂点の祖先の直線だけが集合にある状態を保ちたい。

ヒント2(アプローチ)

根からの経路で $d_j$ は狭義に増えるので、直線は傾き $-d_j$ の降順に追加される。最小値の下側包絡線は配列で持てて、

  • 問い合わせ: $x$ での値は添字について下に凸 → 二分探索
  • 追加: 末尾から「新しい直線のせいで不要になる直線」を消す。不要になるかどうかは添字について単調
ヒント3(誘導)

末尾から 1 本ずつ pop すると、帰りに pop した直線を全部戻す必要がある。代わりに「最初に不要になる位置 pos」を二分探索で求め、(pos, 元の直線, 元の top) を保存して hull[pos] = 新しい直線; top = pos+1 とする。帰りは保存した 3 つを書き戻すだけ。pos より後ろの直線は配列に残っているので消えていない。

模範解答 (Python)

模範解答を表示する(まず自力で解いてから)
import sys


def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    adj = [[] for _ in range(n + 1)]
    p = 1
    for _ in range(n - 1):
        u, v, w = int(data[p]), int(data[p + 1]), int(data[p + 2])
        p += 3
        adj[u].append((v, w))
        adj[v].append((u, w))
    S = [0, 0]
    V = [0, 0]
    for _ in range(n - 1):
        S.append(int(data[p]))
        V.append(int(data[p + 1]))
        p += 2
    dist = [0] * (n + 1)
    dp = [0] * (n + 1)
    # 凸包は「祖先の直線 y = dp[j] - dist[j]*x」を傾きの降順に並べた配列。長さ top
    HD = [0] * (n + 1)            # 傾きの符号を反転した値 dist[j]
    HB = [0] * (n + 1)            # 切片 dp[j]
    HD[0], HB[0] = 0, 0           # 首都の直線
    top = 1
    # 反復 DFS。戻るときに (上書きした位置, 元の直線, 元の top) を書き戻す
    stack = [(1, 0, 0)]           # (頂点, 親, 状態 0=入る/1=出る)
    saved = {}
    while stack:
        v, par, state = stack.pop()
        if state == 1:
            pos, od, ob, otop = saved.pop(v)
            HD[pos], HB[pos] = od, ob
            top = otop
            continue
        if v != 1:
            d = dist[v]
            x = V[v]
            # 最小値の直線を二分探索: 値は添字について下に凸
            lo, hi = 0, top - 1
            while lo < hi:
                m = (lo + hi) >> 1
                if HB[m] - HD[m] * x >= HB[m + 1] - HD[m + 1] * x:
                    lo = m + 1
                else:
                    hi = m
            val = S[v] + x * d + HB[lo] - HD[lo] * x
            dp[v] = val
            # 挿入位置を二分探索: 最初に「新しい直線に負ける」位置 k (1<=k<top)
            lo, hi = 1, top
            while lo < hi:
                m = (lo + hi) >> 1
                d1, b1 = HD[m - 1], HB[m - 1]
                # m 番目が不要 ⇔ (b_new-b1)(d_m-d1) <= (b_m-b1)(d_new-d1)
                if (val - b1) * (HD[m] - d1) <= (HB[m] - b1) * (d - d1):
                    hi = m
                else:
                    lo = m + 1
            pos = lo
            saved[v] = (pos, HD[pos], HB[pos], top)
            HD[pos], HB[pos] = d, val
            top = pos + 1
            stack.append((v, par, 1))
        for u, w in adj[v]:
            if u != par:
                dist[u] = dist[v] + w
                stack.append((u, v, 0))
    print(" ".join(map(str, dp[2:])))


main()

計算量: 各頂点で二分探索 2 回、$O(N\log N)$。メモリ $O(N)$。$N=2\times10^5$ で乱数の木 0.37 s、道(深さ $2\times10^5$)0.36 s、pop 版の最悪ケース(ほうき型)でも 0.54 s(Python 3.14.8、Apple M5 Pro、負荷平均 23〜25 での実測、起動込み)。値は最大で $S+V\cdot d\approx10^{12}+10^6\cdot2\times10^9$ 程度、交差判定の積は $10^{30}$ 級になるので Python の整数のまま比べる。

Step-by-Step 解説

1直線に直す

$T_j+S_i+V_i(d_i-d_j)=\underbrace{S_i+V_id_i}_{i\text{ だけ}}+\underbrace{T_j-d_j V_i}_{\text{直線 }j\text{ の }x=V_i\text{ での値}}$。祖先の直線の集合に対する最小値の問い合わせになる。

2列なら普通の CHT

根から葉への 1 本の道なら、$d_j$ が増える順に直線を足し、末尾から不要な直線を pop する。各直線は 1 回しか pop されないので償却 $O(1)$。

3木では償却が壊れる

DFS で子へ下りて pop した直線は、兄弟の子へ行く前に戻さなければならない。長さ $L$ の道の先に $L$ 本の枝があり、各枝の頂点が道の直線をすべて pop するような木(ほうき型)では、同じ $L$ 本が $L$ 回ずつ消えて戻る。$N=3\times10^4$ で pop 回数 $1.0\times10^8$・10.6 s、$N=6\times10^4$ で $4.0\times10^8$・39.8 s と、$N$ が 2 倍で 4 倍になった(実測)。

41 か所だけ上書きする

不要になる直線は末尾側の連続した区間なので、その先頭 pos を二分探索で求める。配列の pos に新しい直線を書き、top を pos+1 にする。pos+1 以降の直線は配列に残ったまま「見えなくなる」だけ。帰りには pos の元の直線と元の top を戻せば、配列は完全に元どおり。1 頂点の変更が 1 か所なので、戻す手間も 1 か所。

5不要判定の式

直線 $k$(傾き $-d_k$、切片 $b_k$)について、$k-1$ と新しい直線 $n$ の交点が、$k-1$ と $k$ の交点より左(以下)なら $k$ は不要。分母を払って

$$(b_n-b_{k-1})(d_k-d_{k-1})\le(b_k-b_{k-1})(d_n-d_{k-1})$$

$d$ は経路上で狭義に増える($w\ge1$)ので分母は正。浮動小数を使わないので誤差が無い。

迷った他案と採らなかった理由

案計算量採否理由
祖先を全部試す$O(N\cdot\text{深さ})$検算用のみ道 $N=2\times10^4$ で 15.08 s(実測)。$2\times10^5$ では 100 倍
列と同じ pop 版 CHT+pop した直線を戻す最悪 $O(N^2)$不採用乱数の木では 0.39 s で速いが、ほうき型 $N=6\times10^4$ で 39.8 s(実測)。$2\times10^5$ では pop が約 $4.4\times10^9$ 回(導出)で届かない
Li Chao 木をロールバック付きで$O(N\log C)$不採用(未実装)1 回の追加が変える節点は $O(\log C)$ 個で戻せる。$x=V_i$ の範囲 $10^6$ で 20 段。二分探索版より定数が大きいと見込んで採らなかった(測っていない)
重心分解・HLD で列の CHT に帰着$O(N\log^2N)$不採用実装が重く、計算量も悪い
二分探索で挿入+1 か所の上書き(本解)$O(N\log N)$採用最悪ケースでも 0.54 s

前提が変わったら

  • 辺の長さに 0 があると $d$ が等しい直線が並び、不要判定の分母が 0 になる。同じ $d$ なら切片の小さい方だけ残す処理が要る
  • 問い合わせの $x$ が単調(例: 深いほど $V$ が大きい)でも、木では兄弟で $x$ が戻るのでポインタを進める方法は使えない。二分探索のまま
  • 祖先でなく任意の頂点から受け取れるなら木の構造が要らず、全頂点の直線集合に対する問題になる(別問題)

よくあるミス

ミス原因正しい書き方
戻すとき top だけ戻し、上書きした pos を戻さない「top を縮めただけ」と思うpos の元の直線も書き戻す。500 入力中 111 で誤り(実測)
帰りに何も戻さない列の CHT をそのまま使う兄弟の枝の直線が混ざる。500 入力中 169 で誤り(実測)
親から受け取るだけ「渡すなら直前の町」と思う祖先すべてが候補。500 入力中 450 で誤り(実測)
親か首都の 2 択中間の祖先を見落とす入力例 2。500 入力中 164 で誤り(実測)
交点を浮動小数で比べる積が $10^{30}$ 級になり 64 ビット浮動小数では等号が狂う分母を払って整数で比べる
再帰 DFS深さ $2\times10^5$ で再帰の上限反復 DFS(入る/出るの 2 状態)

次のステップ

  • 発展1: Li Chao 木のロールバック版を実装し、本解と同じ入力で速さを比べる
  • 発展2: $T_i$ に「町 $i$ を通る伝令の数」の制限が付いたら(容量付き)、DP が貪欲で崩れるかを全探索で調べる
  • 発展3: 道を下る向き(首都から葉へ)の DP に変え、部分木ごとの直線集合をマージする(Li Chao 木のマージ)方法と比べる

検証ログ(本セッションで実行した数値)

  • 正しさ(実測): 頂点数 2〜40 の乱数の木と道 500 個(辺長・$S$・$V$ の上限を 0〜$10^{12}$ で振った)で、祖先を全部試す DP・本解・pop 版の 3 つが全件一致
  • 誤った方法(実測、同じ 500 個): 上書き位置を戻さない 111、ロールバックなし 169、親だけ 450、親か首都の 2 択 164 個で出力が違う
  • 時間(実測、起動込み、負荷平均 23〜25): 本解 $N=2\times10^5$ で乱数 0.37 s(202 MB)、道 0.36 s(240 MB)、ほうき型 0.54 s(171 MB)。ほうき型 $N=3\times10^4$ / $6\times10^4$ で本解 0.08 / 0.15 s、pop 版 10.6 s(pop $1.0\times10^8$ 回)/ 39.8 s($4.0\times10^8$ 回)。pop 版は乱数 $N=2\times10^5$ では 0.39 s(pop 195483 回)。祖先を全部試す DP は道 $N=2\times10^4$ で 15.08 s
  • ほうき型の作り方: 深さ $L=N/3$ の道(町 $j$ は $S=j^2,V=1$ で $T_j=j^2+j$、点 $(d,T)$ が下に凸に並ぶ)の先に、子を 1 つずつ持つ枝を約 $N/3$ 本。枝の頂点は $S=0,V=1$ で $T=d$ となり、首都との線分の下にあるので道の直線を全部 pop する
  • 扱っていない範囲: 原典の確認、Li Chao 木版の測定、辺長 0

自己評価

自分の回答

気づき・メモ