Day 180-Q2 — 2026-10-16 | 距離の上限つき祖先からの木 DP(NOI2014「購票」と同じ形と記憶)を、深さのセグメント木の各節点に凸包を持たせて巻き戻す

2026-10-16 赤色 Master (Phase 8+) ★★★★★★★★★ DP 高速化(焦点帯 M1)。Day178-Q3 は「根からの道の祖先全部」から選ぶので、道全体の凸包 1 つを二分探索で上書きして巻き戻せば済んだ。祖先に距離の上限が付くと、使える祖先は道の後ろの一部(区間)になり、道全体の凸包からは答えが出ない(全体の凸包で消えた直線が、区間の中では最良になりうる)。深さの添字でセグメント木を作り、各節点にその区間の凸包を持たせ、Day178-Q3 の「1 か所上書き+巻き戻し」を節点ごとに行う(Day179-Q3 の「発展1」の、傾きが単調な場合)

問題

頂点 $1..N$ の根付き木(根は 1)がある。頂点 $v\ (v\ge2)$ の親は $f_v$、親との辺の長さは $s_v$。根からの距離を $D_v$ とする。

頂点 $v$ から根へ行くには、$v$ の真の祖先 $u$ で $D_v-D_u\le l_v$ を満たすものを 1 つ選んで、料金 $p_v\cdot(D_v-D_u)+q_v$ を払って $u$ まで移動し、$u$ からは $u$ の最安料金で根へ行く。$\mathrm{dp}_1=0$ として

$$\mathrm{dp}_v=\min_{\substack{u:\ v\text{ の真の祖先}\\ D_v-D_u\le l_v}}\Bigl(\mathrm{dp}_u+p_v\,(D_v-D_u)\Bigr)+q_v$$

をすべての $v=2..N$ について求めよ。

入力形式

N
f_2 s_2 p_2 q_2 l_2
...
f_N s_N p_N q_N l_N

制約

#制約
1$2\le N\le10^5$
2$1\le f_v<v$、$1\le s_v\le10^6$
3$0\le p_v\le10^6$、$0\le q_v\le10^{12}$
4$s_v\le l_v\le2\times10^{11}$(親は必ず選べる)
5出力 $\mathrm{dp}_2,\dots,\mathrm{dp}_N$ を 1 行ずつ

入出力例

入力例1

5
1 4 3 5 8
2 3 2 6 6
2 4 5 5 7
3 4 2 3 8

出力例1

17
29
42
34

(頂点 5 は $D_5=11$、$l_5=8$ なので根(距離 11)

入力例2

5
1 1 5 0 10
2 1 0 100 10
3 1 5 0 1
4 1 5 0 10

出力例2

5
100
105
20

(一本道。頂点 4 は $l_4=1$ なので親の頂点 3 しか使えず $100+5=105$。上限を無視すると根から $5\cdot3=15$。頂点 5 は上限が広いので根から $5\cdot4=20$)

背景と出典

  • NOI2014「購票」(中国の全国大会)がこの形の問題だと記憶している(問題文・制約は未確認)。そこでの有名な解法として「点分治+凸包」「HLD+セグメント木+凸包」があると記憶している
  • Day178-Q3(CEOI 2009 Harbingers と同じ形と記憶、未確認)は上限なしの版。そこで使った「凸包の配列の 1 か所を上書きして、帰りに書き戻す」を、本問ではセグメント木の各節点で行う
  • 本問の制約・入力例は自作。上の記憶と制約が違う可能性がある

概念図

入力例 1:頂点 5(D = 11、l = 8)が使える祖先は D ≥ 3 の区間=深さ 1〜2 の頂点 2, 3 だけ 1 D=0 2 D=4 3 D=7 4 D=8 5 D=11 赤:距離 11 > 8 で使えない 0 1 2 3 4 x = p x = p_5 = 2 での値:根 0・頂点2 9・頂点3 15 根を除いた最小は頂点 2 の 9 → dp_5 = 9 + 2·11 + 3 = 34 道全体の凸包:頂点 2 の直線は根と頂点 3 に挟まれて消える (どの x でも 3 本の中で最小にならない) → 区間外の根を捨てると頂点 3 だけが残り 40 と誤る 深さのセグメント木:節点ごとに区間の凸包を持つ 区間 [1, 2] の凸包には頂点 2 の直線が残る 入るとき O(log N) 節点に追加、出るとき巻き戻し 根: 0 頂点2: −4x+17 頂点3: −7x+29

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

ヒント1(方向性)

$\mathrm{dp}_u+p_v(D_v-D_u)=(-D_u)\cdot p_v+\mathrm{dp}_u+p_vD_v$。祖先 $u$ ごとの直線 $y=-D_u\,x+\mathrm{dp}_u$ を $x=p_v$ で評価した最小値が欲しい。$D$ は根から離れるほど大きいので、道に沿って直線の傾きは単調に減る。

ヒント2(アプローチ)

$D_v-D_u\le l_v$ を満たす祖先は、根からの道の後ろの方の連続した区間(深さ $d$ 以上 $\mathrm{dep}_v-1$ 以下)になる。$d$ は道の $D$ の列で二分探索。区間の最小値なので、深さの添字でセグメント木を作り、各節点に「その深さの範囲の直線の凸包」を持たせる。

ヒント3(誘導)
  • DFS で頂点 $v$ に入るとき、深さ $k=\mathrm{dep}_v$ を含むセグメント木の節点(葉から根まで $O(\log N)$ 個)すべての凸包に直線を足す
  • 傾きが単調なので、各凸包への追加は「末尾から消える直線の数を二分探索で求め、その位置を上書きして長さを縮める」でよい。上書きした位置・元の直線・元の長さをログに積み、$v$ を出るときに戻す
  • 問い合わせは区間 $[d,k-1]$ を $O(\log N)$ 個の節点に分け、各凸包で $x=p_v$ の最小を二分探索

模範解答 (Python)

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


def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    par = [0] * (n + 1)
    W = [0] * (n + 1)
    P = [0] * (n + 1)
    Q = [0] * (n + 1)
    Lim = [0] * (n + 1)
    ch = [[] for _ in range(n + 1)]
    pos = 1
    for v in range(2, n + 1):
        f, s, p, q, l = (int(data[pos + i]) for i in range(5))
        pos += 5
        par[v] = f; W[v] = s; P[v] = p; Q[v] = q; Lim[v] = l
        ch[f].append(v)
    # 深さ(頂点数)の最大を求めてセグ木の大きさを決める
    dep = [0] * (n + 1)
    order = [1]
    for u in order:
        for v in ch[u]:
            dep[v] = dep[u] + 1
            order.append(v)
    H = max(dep) + 1
    size = 1
    while size < H:
        size <<= 1
    HA = [[] for _ in range(2 * size)]   # 節点ごとの凸包:傾き(-D_u、追加順に減少)
    HB = [[] for _ in range(2 * size)]   # 切片 dp_u
    TOP = [0] * (2 * size)
    log = []                              # (節点, 位置, 元の傾き, 元の切片, 元の top)
    pathD = [0] * H                       # 根からの道:深さ i の頂点の D
    D = [0] * (n + 1)
    dp = [0] * (n + 1)

    def insert(k, a, b):
        node = size + k
        while node:
            A = HA[node]; B = HB[node]; t = TOP[node]
            # 末尾から不要になる直線を二分探索で:位置 p の直線が (p-1, 新) に挟まれて不要か
            lo, hi = 1, t
            while lo < hi:
                mid = (lo + hi) >> 1
                a1 = A[mid - 1]; b1 = B[mid - 1]
                if (b - b1) * (a1 - A[mid]) <= (B[mid] - b1) * (a1 - a):
                    hi = mid
                else:
                    lo = mid + 1
            p = lo if t > 0 else 0
            if p < len(A):
                log.append((node, p, A[p], B[p], t))
                A[p] = a; B[p] = b
            else:
                log.append((node, p, None, None, t))
                A.append(a); B.append(b)
            TOP[node] = p + 1
            node >>= 1

    def hull_min(node, x):
        A = HA[node]; B = HB[node]
        lo, hi = 0, TOP[node] - 1
        while lo < hi:
            mid = (lo + hi) >> 1
            if A[mid] * x + B[mid] <= A[mid + 1] * x + B[mid + 1]:
                hi = mid
            else:
                lo = mid + 1
        return A[lo] * x + B[lo]

    def query(l, r, x):            # 深さ添字 [l, r] の直線の x での最小
        best = None
        l += size; r += size + 1
        while l < r:
            if l & 1:
                v = hull_min(l, x)
                if best is None or v < best:
                    best = v
                l += 1
            if r & 1:
                r -= 1
                v = hull_min(r, x)
                if best is None or v < best:
                    best = v
            l >>= 1; r >>= 1
        return best

    # 反復 DFS:入るとき計算して挿入、出るとき巻き戻す
    stack = [1]
    while stack:
        u = stack.pop()
        if u < 0:
            keep = ~u
            while len(log) > keep:
                node, p, a, b, t = log.pop()
                if a is None:
                    HA[node].pop(); HB[node].pop()
                else:
                    HA[node][p] = a; HB[node][p] = b
                TOP[node] = t
            continue
        k = dep[u]
        if u != 1:
            D[u] = D[par[u]] + W[u]
            # D_u - D_anc <= Lim[u] となる最も浅い深さ添字を二分探索
            need = D[u] - Lim[u]
            lo, hi = 0, k - 1
            while lo < hi:
                mid = (lo + hi) >> 1
                if pathD[mid] >= need:
                    hi = mid
                else:
                    lo = mid + 1
            dp[u] = query(lo, k - 1, P[u]) + P[u] * D[u] + Q[u]
        pathD[k] = D[u]
        stack.append(~len(log))
        insert(k, -D[u], dp[u])
        stack.extend(ch[u])
    sys.stdout.write("\n".join(map(str, dp[2:])) + "\n")


main()

計算量: 1 頂点につき、挿入は $O(\log N)$ 個の節点で二分探索 $O(\log N)$、問い合わせも同じで、全体 $O(N\log^2N)$。記憶量は、凸包の配列の合計とログがそれぞれ $O(N\log N)$。実測(Python 3.14、Apple M5 Pro、起動込み)で一本道 $N=10^5$ が 1.31 s・381 MB、$N=2\times10^5$ では乱択木 0.73 s・191 MB、親を直前 3 頂点から選ぶ細長い木 2.64 s・460 MB、一本道 3.10 s・777 MB、ほうき型 2.74 s・467 MB。一本道の記憶量が大きいので制約を $N\le10^5$ にした。

Step-by-Step 解説

1直線にして、使える祖先が区間になることを見る

祖先 $u$ を直線 $\ell_u(x)=-D_u\,x+\mathrm{dp}_u$ にすると $\mathrm{dp}_v=\min_u\ell_u(p_v)+p_vD_v+q_v$。条件 $D_v-D_u\le l_v$ は $D_u\ge D_v-l_v$ で、道の上の $D$ は深さとともに増えるので、使える祖先は「深さ $d$ 以上」の連続した区間。$d$ は道の $D$ の配列 pathD を二分探索して求める。

2なぜ道全体の凸包では駄目か

凸包は「どの $x$ でも最小にならない直線」を捨てる。ところが、捨てた理由が区間の外の直線(例: 根の直線)なら、区間の中だけで見るとその直線が最小になりうる。入力例 1 の頂点 5 では、頂点 2 の直線 $-4x+17$ が根の $0x+0$ と頂点 3 の $-7x+29$ に挟まれて凸包から消えるが、根を除くと $x=2$ で頂点 2 が最小(9 対 15)。「凸包を作ってから区間外を捨てる」は 600 入力中 118 で誤った(実測)。区間ごとの凸包が必要になる。

3深さのセグメント木

深さ $0..H-1$ を葉とするセグメント木を作り、節点 $x$ は「その深さの範囲にいる、今の道の上の頂点の直線」の凸包を持つ。頂点 $v$(深さ $k$)に入るときは、葉 $k$ から根までの節点すべてに $\ell_v$ を足す。区間 $[d,k-1]$ の問い合わせは、普通のセグメント木の区間分割で得た $O(\log N)$ 個の節点の凸包をそれぞれ二分探索する。

4各節点での追加は「1 か所上書き」

各節点の凸包にも、道の順(=傾きが減る順)で直線が来る。だから Day178-Q3 と同じく、末尾から「新しい直線と 1 つ前の直線に挟まれて不要になる」直線が連続して消えるだけで、その境目は二分探索で見つかる。消す代わりに境目の位置を新しい直線で上書きし、長さ TOP を境目+1 にする。元の直線と元の TOP をログに積めば、出るときに戻せる。

5交差の判定を整数で

直線 1(傾き $a_1$)・直線 2($a_2$)・新しい直線($a$)で $a_1>a_2>a$ のとき、直線 2 が不要 $\iff$ $(b-b_1)(a_1-a_2)\le(b_2-b_1)(a_1-a)$。分母を払った整数の比較なので誤差がない。値は $|b|\le$ 約 $10^5\cdot(10^6\cdot2\times10^{11}+10^{12})$ と大きいが、Python の整数なので桁あふれはない(C++ なら __int128 か浮動小数の工夫が要る)。

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

案計算量採否理由
祖先を全部見る$O(N\cdot\text{深さ})$検算用のみ一本道 $N=10^4$ で 4.36 s(実測)。$10^5$ は約 100 倍
上限を無視(Day178-Q3 の方法そのまま)$O(N\log N)$不採用(誤り)1200 入力中 711 で誤り(実測)
親だけを見る$O(N)$不採用(誤り)1200 入力中 762 で誤り(実測)
道全体の凸包を作ってから区間外を捨てる$O(N\log N)$不採用(誤り)600 入力中 118 で誤り(実測、Step 2)
各節点に Li Chao 木(Day179-Q3 の方法)$O(N\log^2N)$不採用(未実装)傾きが単調なら凸包の二分探索で足り、Li Chao 木の節点を節点ごとに持つより記憶が小さいと判断。傾きが単調でない版なら必要
点分治+凸包(NOI2014 の有名解と記憶)$O(N\log^2N)$不採用巻き戻しが要らない代わりに、重心ごとに「重心より上の祖先」と「部分木の問い合わせ」を並べ替える手間がある。Python で書き比べてはいない
深さのセグメント木+節点ごとの凸包の巻き戻し(本解)$O(N\log^2N)$採用$N=10^5$ 一本道で 1.31 s

前提が変わったら

  • 傾き $-D_u$ が単調でない(例えば料金の係数が祖先 $u$ 側にある Day179-Q3 の形に上限を付けた版)なら、節点ごとの凸包を Li Chao 木に替える
  • $N=2\times10^5$ が必要なら、一本道で 777 MB になるログを減らす工夫(ログを整数 1 つに詰める、節点の配列を 1 本にまとめる)が要る。C++ なら問題にならない量
  • 問い合わせの $x=p_v$ が道に沿って単調なら、各節点で二分探索の代わりにポインタを進める方法が考えられるが、巻き戻しと両立させるには工夫が要る

よくあるミス

ミス原因正しい書き方
道全体の凸包から区間外を捨てる「凸包に残った直線だけ見ればよい」を区間にも当てはめる区間ごとの凸包(入力例 1 の頂点 5)
セグメント木の節点の凸包を、巻き戻さずに使い続ける兄弟の部分木で同じ深さの添字が再利用されることを忘れる出るときにログで戻す
区間の左端を深さでなく距離で二分探索して添字を取り違えるpathD の添字=深さ、を混同pathD[mid] >= D_v - l_v を満たす最小の深さ
最初の頂点(凸包が空)で二分探索の範囲が壊れるTOP=0 の扱い空なら位置 0 に置く
再帰 DFS深さ $10^5$入る・出る(ログの長さ)を積む反復 DFS

次のステップ

  • 発展1: 傾きが単調でない版(祖先側の係数 $C_u$)に上限 $l_v$ を付け、各節点を Li Chao 木にして本解と速さ・記憶量を比べる
  • 発展2: 点分治+凸包で同じ問題を解き、本解と $N=10^5$ の一本道・ほうき型で比べる
  • 発展3: ログを 1 本の整数配列に詰めて、$N=2\times10^5$ の一本道を 400 MB 以下にできるか測る

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

  • 全探索との一致(実測): $N=2..40$、木の形 4 種(一本道・乱択・細長い・ほうき型)、$s,p,q,l$ の範囲をそれぞれ 3〜5 通りから選んだ 1200 入力で、祖先を全部見る全探索と本解が全件一致。$N=10^4$ の一本道 1 入力でも一致
  • 誤った方法(実測、同じ 1200 入力): 上限を無視 711、親だけ 762 で誤り。道全体の凸包から区間外を捨てる方法は別の 600 入力中 118 で誤り
  • 時間(実測、起動込み、$s,p\le10^6$、$q\le10^{12}$、$l\le s+10^{11}$): $N=5\times10^4$ 一本道 0.59 s・194 MB、$N=10^5$ 一本道 1.31 s・381 MB、$N=2\times10^5$ 乱択 0.73 s・191 MB、細長い 2.64 s・460 MB、一本道 3.10 s・777 MB、ほうき型 2.74 s・467 MB。全探索は一本道 $N=10^4$ で 4.36 s
  • 扱っていない範囲: NOI2014 の原典の確認、点分治版・Li Chao 版の実装と比較、記憶量の削減

自己評価

自分の回答

気づき・メモ