Day 164-Q2 — 2026-10-02 | 移動速度に上限のある追跡(Slope Trick。左右のヒープに別々の遅延オフセットを持たせ、始点の固定は「壁」で表す)

2026-10-02 赤色 Master (Phase 8+) ★★★★★★★★★ DP の高速化(焦点帯 M1)。凸関数 $f_i(x)$ を折れ点の列で持つ Slope Trick。「$\min_{|y-x|\le d}$」という窓の最小は、**傾きが負の側の折れ点を左へ $d$、正の側を右へ $d$ ずらす**だけ。左右で別々の遅延オフセットを持つ

問題

数直線上の整数点を動く 1 人のプレイヤーがいる。時刻 $0$ に位置 $0$ にいる。時刻が 1 進むごとに位置を高々 1($-1,0,+1$)しか動かせない(位置は整数)。

$i=1,\dots,N$ について、時刻 $T_i$ に位置 $X_i$ へ旗が立つ。時刻 $T_i$ にいた位置を $p_i$ とすると、費用 $|p_i-X_i|$ がかかる。費用の総和 $\sum_{i=1}^{N}|p_i-X_i|$ の最小値を求めよ。

入力形式

N
T_1 X_1
...
T_N X_N

制約

#条件
1$1\le N\le 2\times10^5$
2$1\le T_1<T_2<\dots<T_N\le10^9$
3$-10^9\le X_i\le10^9$
4入力はすべて整数

入出力例

入力例1

3
1 5
2 -3
10 0

出力例1

7

(時刻 1 では $|p_1|\le1$ なので旗 5 まで 4 以上離れる。時刻 2 の旗 $-3$ へは、たとえば $p_1=-1,p_2=-2$ で費用 $6+1=7$。時刻 10 の旗 0 には余裕で戻れる)

入力例2

2
3 3
4 -3

出力例2

5

(時刻 3 に位置 3 へ着くのが精一杯で、時刻 4 には位置 2 まで戻れて $|2-(-3)|=5$。始点が自由なら 0 で済むので、「時刻 0 は位置 0」の固定が効いている例)

背景と出典

「凸関数を傾きの変わる点(折れ点)の列で持ち、左側を最大ヒープ・右側を最小ヒープで管理する」方法は日本の競プロで Slope Trick と呼ばれる。窓つきの移動制約の形は、私の記憶では AtCoder ABC217 H(Snuketoon)に近いが、問題文・制約は未確認で、本ファイルの設定は自分で決めたもの(始点を 0 に固定する点が違うと思われる)。本セッションでは外部資料を開いていない。正しさの根拠は、全位置を持つ素朴 DP との照合(下の検証ログ)。

概念図

窓の最小:最小値の区間が左右に d ずつ広がる(左の折れ点は −d、右の折れ点は +d) f L の最大 R の最小 g(x)=min f(y), |y−x|≤d addL −= d addR += d 始点 x=0 の固定は、折れ点 0 を N+1 個ずつ両方のヒープに置いた「壁」で表す(傾きの上限 N を超える) |x−a| の加算は (x−a)⁺ と (a−x)⁺ の 2 段階。最小値は max(0, top(L)−a) と max(0, a−top(R)) だけ増える

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

ヒント1(方向性)

$f_i(x)$ = 「時刻 $T_i$ に位置 $x$ にいるときの、ここまでの最小費用」とおく。移動制約は $f_i(x)=\min_{|y-x|\le d}f_{i-1}(y)+|x-X_i|$($d=T_i-T_{i-1}$)。位置の範囲は $10^9$ 以上あるので、位置ごとの表は持てない。

ヒント2(アプローチ)

$f$ は常に凸(凸関数への $|x-a|$ の加算と、窓の最小はどちらも凸を保つ)。凸な折れ線は「傾きが 1 ずつ変わる点」の並びで表せる。窓の最小 $\min_{|y-x|\le d}$ は、最小値を取る区間を左右に $d$ ずつ広げるだけで、傾きが負の側が左へ $d$、正の側が右へ $d$ ずれる。

ヒント3(誘導)

左の折れ点(傾きが負の側)を最大ヒープ $L$、右の折れ点を最小ヒープ $R$ に入れ、$L$ には全体の足し込み量 addL、$R$ には addR を持つ。$|x-a|$ を足すときは 2 段階: $(x-a)^+$ を足す → 最小値に $\max(0,\ \mathrm{top}(L)-a)$ を足して $a$ を $L$ に入れ、$L$ の最大を $R$ へ移す。次に $(a-x)^+$ を足す → 対称。始点が $0$ に固定される分は、初期に折れ点 $0$ を $N+1$ 個ずつ両方のヒープに入れて「傾き $\pm(N+1)$ の壁」にする。

模範解答 (Python)

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


def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0])
    # f(x): いまの時刻に位置 x にいる場合の、ここまでの最小コスト(凸)
    # 左の折れ点 L(最大ヒープ、符号反転で保持)、右の折れ点 R(最小ヒープ)。
    # 時刻 0 は x=0 に固定なので、傾き ±(n+1) の「壁」を折れ点 0 を n+1 個ずつ置いて表す
    L = [0] * (n + 1)
    R = [0] * (n + 1)
    addL = 0   # L の全折れ点にまとめて足す量
    addR = 0   # R の全折れ点にまとめて足す量
    ans = 0
    prev_t = 0
    for i in range(n):
        t = int(data[1 + 2 * i])
        x = int(data[2 + 2 * i])
        d = t - prev_t
        prev_t = t
        addL -= d            # min をとる窓 [-d,+d] で、左の折れ点は左へ d
        addR += d            # 右の折れ点は右へ d
        # |p - x| を足す。 (p-x)^+ と (x-p)^+ を順に足す
        lt = -L[0] + addL                    # L の最大
        if lt > x:
            ans += lt - x
        heappush(L, -(x - addL))
        v = -heappop(L) + addL               # L の最大を R へ移す
        heappush(R, v - addR)
        rt = R[0] + addR                     # R の最小
        if x > rt:
            ans += x - rt
        heappush(R, x - addR)
        v = heappop(R) + addR                # R の最小を L へ移す
        heappush(L, -(v - addL))
    print(ans)


main()

計算量: $O(N\log N)$。実測(Python 3.14.7、M1 Mac)で $N=2\times10^5$、$|X_i|\le10^9$ のランダム入力が 0.53 s($T_i$ が 1 ずつ増える最悪に近い入力でも 0.47 s)。

Step-by-Step 解説

1状態の定義

$f_0(x)=0\ (x=0),\ +\infty\ (x\ne0)$。$f_i(x)=\min_{|y-x|\le T_i-T_{i-1}}f_{i-1}(y)+|x-X_i|$。答えは $\min_x f_N(x)$。$f_i$ は全て凸な折れ線で、傾きは整数。

2窓の最小は折れ点を左右にずらす

凸関数 $f$ の最小値を取る区間を $[l,r]$ とする。$g(x)=\min_{|y-x|\le d}f(y)$ は、$[l-d,\ r+d]$ で最小値、その左では $f$ の左側(減少する部分)を左へ $d$ ずらした形、右では右側(増加する部分)を右へ $d$ ずらした形になる。つまり **左の折れ点は $-d$、右の折れ点は $+d$**。ヒープに入っている値を 1 つずつ書き換えず、addL -= d、addR += d と 1 回の足し算で済ませる。左右で別々のオフセットが必要なのが、通常の Slope Trick(全体に同じ量を足す)との違い。

3$|x-a|$ を足す 2 段階

$|x-a|=(x-a)^++(a-x)^+$。$(x-a)^+$ を足すと $a$ より右で傾きが 1 増える。最小値は、$L$ の最大 $\ell$ が $a$ より右にあれば $\ell-a$ だけ増える($\max(0,\ell-a)$)。折れ点 $a$ が増えて傾きが 1 増えた分、$L$ の最大 1 個が負側から正側へ移るので、$a$ を $L$ に入れてから $L$ の最大を取り出して $R$ へ移す。$(a-x)^+$ は左右を入れ替えて同じ。

4始点の固定は「壁」で表す

$f_0$ は $x=0$ 以外が $+\infty$。実際の傾きは高々 $N$(絶対値 1 を $N$ 回足すだけ)なので、折れ点 $0$ を $N+1$ 個ずつ置けば「傾き $N+1$ 以上の壁」と区別できない。壁の折れ点も窓の移動で左右にずれるので、時刻とともに壁の位置が $[-T,+T]$ に広がる。始点を自由にして壁を置かないと 500 件中 231 件で誤る(実測)。

5移動量を忘れる/片側だけずらす

ずらす量 $d$ を 0 にすると 493/500、右側だけずらして左側を動かさないと 393/500 で誤る(実測)。壁が 1 個だけだと 42/500、$N-1$ 個だと 13/500 で誤る(実測)。壁の個数は「傾きの上限 $N$ より 1 多い」ことが必要。

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

案計算量採否理由
位置を全部持つ DP($f$ の表)$O(N\cdot T_N)$検算用のみ$T_N=10^9$ には不可。検算に使った
位置を $X_i$ で圧縮した DP$O(N^2)$不採用窓があると最適な位置の候補が $X_j\pm$(時刻差)の形で増え、圧縮した点数が $O(N^2)$ になりうると見て採らなかった(未検証)
折れ点を平衡二分探索木に持つ$O(N\log N)$不採用動くのは最大値・最小値と全体のずらしだけで、ヒープで足りる
Slope Trick(左右別オフセットのヒープ、本解)$O(N\log N)$採用全位置 DP と 500 件全件一致
始点を自由にして壁を置かない$O(N\log N)$不採用500 件中 231 件誤答(「時刻 0 は位置 0」を無視)

前提が変わったら:

  • 時刻ごとに動ける距離が違う($|{\rm 移動}|\le v_i$)なら、$d=v_i$ をその区間の窓に置き換えるだけで同じ形
  • **コストが $|x-a|$ 以外の凸関数**($(x-a)^2$ など)なら折れ線ではなくなり、Slope Trick は使えない(Lagrange 乗数や凸最適化の枠)
  • 位置が整数に限らないときも同じ解。折れ点は整数のままで、ずらす量が実数になるだけ

よくあるミス

ミス原因正しい書き方
窓のずらしを 1 つのオフセットで済ます通常の Slope Trick の癖で全体に同じ量を足すaddL -= d と addR += d を別々に持つ(右のみ: 393/500 誤答)
壁を置かず始点を自由にする初期状態の $+\infty$ を表せない折れ点 0 を $N+1$ 個ずつ置く(231/500 誤答)
壁を 1 個にする「傾き無限大」を 1 個で表せると思う傾きの上限 $N$ を超える個数にする(1 個: 42/500、$N-1$ 個: 13/500 誤答)
$\max(0,\cdot)$ を付けず常に差を足す$\ell\le a$ のとき最小値が増えないことを忘れるif lt > x: ans += lt - x

次のステップ

  • 発展1: 各 $i$ で最適な位置 $p_i$ も復元する(ヒープを巻き戻す必要があり、Slope Trick の復元は一般に面倒。$f_i$ の最小点の区間を後ろ向きに辿る)
  • 発展2: 動ける距離が時刻ごとに違い、かつ位置に下限・上限がある版(壁の位置を外から与える)
  • 次回予告: 焦点帯 M1 の DP 最適化(Aliens)

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

  • 素朴 DP との照合(実測): 位置を $[-T_N,T_N]$ の全点で持つ DP と、$N\le8$、$d\le3$、$|X|\le8$ の 500 件でプログラム全体の出力が全件一致(答えが 0 の件は 30)
  • 誤った方法(実測): 始点自由(壁なし)231/500、移動量 0 のまま 493/500、右側だけずらす 393/500、壁 1 個 42/500、壁 $N-1$ 個 13/500 で誤答
  • 入力例(実測): 例 1 は 7、例 2 は 5(どちらも素朴 DP と一致)
  • 実行時間(実測): $N=2\times10^5$、$T_i$ の間隔 1〜3、$|X_i|\le10^9$ で 0.53 s。$T_i=i$ で 0.47 s
  • 扱っていない範囲: 位置の復元、ABC217 H の原文との突き合わせ、$f_i$ が非凸になる場合(コストが凸でない・移動範囲が不連続)

自己評価

自分の回答

気づき・メモ