問題
頂点 $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(方向性)
$\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 版の実装と比較、記憶量の削減