Day 092-Q5 — Convex Hull Trick(分割コスト最小化DP)

2026-07-15 赤色 Master / Phase 8+ ★★★★★★★★★ CHT・DP高速化・$O(N)$

問題

数直線上の $N$ 点、座標は狭義単調増加 $x_1 < x_2 < \dots < x_N$。連続する何個かのグループに分割する。グループ $x_p, \dots, x_q$ のコストは $(x_q - x_p)^2 + K$。全体コストの総和を最小化せよ。

$$dp[i] = \min_{0 \le j < i}\ dp[j] + (x_i - x_{j+1})^2 + K,\qquad dp[0]=0$$

制約

パラメータ範囲備考
$N$$1 \le N \le 2\times10^5$点の数
$K$$1 \le K \le 10^9$グループ固定費
$x_i$$1 \le x_1 < \dots < x_N \le 10^9$狭義単調増加

入出力例

入力例1

4 5
1 2 10 11

出力例1

12

$\{1,2\}$ と $\{10,11\}$: $(2-1)^2+5 + (11-10)^2+5 = 6+6 = 12$ が最小。

概念図

各 $j$ を直線 $y=(-2x_{j+1})X + (dp[j]+x_{j+1}^2)$ とし、$X=x_i$ の最小値 X y 直線 j=0 直線 j=1 直線 j=2 下側凸包(lower envelope)の最小値だけが有効 → 不要な直線は除去 傾き $-2x_{j+1}$ は単調減少 / クエリ $x_i$ は単調増加 → ポインタ前進 $O(N)$

ヒント

ヒント1(方向性)

$dp[i]=\min_{j<i} dp[j]+(x_i-x_{j+1})^2+K$。素朴には $O(N^2)$。二乗を展開して直線の最小値問題にできないか。

ヒント2(アプローチ)

$(x_i-x_{j+1})^2 = x_i^2 - 2x_i x_{j+1} + x_{j+1}^2$。$j$ ごとに傾き $-2x_{j+1}$・切片 $dp[j]+x_{j+1}^2$ の直線を考え、$X=x_i$ の最小値を求める。

ヒント3(ほぼ答え)
dp[i] = query(x[i]) + x[i]**2 + K
add_line(-2*x[i+1], dp[i] + x[i+1]**2)
# 傾き単調減少 & クエリ単調増加 → 単調CHT で O(N)

模範解答

import sys

class CHT:
    """min クエリ・傾き単調減少・クエリ点単調増加の単調CHT"""
    def __init__(self):
        self.m = []
        self.c = []
        self.ptr = 0

    def _bad(self, i1, i2, i3):
        # 直線 i2 が不要か(i1,i3 の交点が i1,i2 の交点より左)
        return (self.c[i3] - self.c[i1]) * (self.m[i1] - self.m[i2]) <= \
               (self.c[i2] - self.c[i1]) * (self.m[i1] - self.m[i3])

    def add(self, m, c):
        self.m.append(m); self.c.append(c)
        while len(self.m) >= 3 and self._bad(len(self.m) - 3, len(self.m) - 2, len(self.m) - 1):
            self.m.pop(-2); self.c.pop(-2)

    def _f(self, i, x):
        return self.m[i] * x + self.c[i]

    def query(self, x):
        if self.ptr >= len(self.m):
            self.ptr = len(self.m) - 1
        while self.ptr + 1 < len(self.m) and self._f(self.ptr + 1, x) <= self._f(self.ptr, x):
            self.ptr += 1
        return self._f(self.ptr, x)

def main():
    data = sys.stdin.buffer.read().split()
    n = int(data[0]); K = int(data[1])
    x = [0] + [int(data[2 + i]) for i in range(n)]   # 1-indexed

    dp = [0] * (n + 1)
    cht = CHT()
    cht.add(-2 * x[1], dp[0] + x[1] * x[1])           # j = 0
    for i in range(1, n + 1):
        dp[i] = cht.query(x[i]) + x[i] * x[i] + K
        if i < n:
            cht.add(-2 * x[i + 1], dp[i] + x[i + 1] * x[i + 1])  # j = i
    print(dp[n])

main()

計算量 $O(N)$。傾き・クエリ点がともに単調なので pop も追加も償却 $O(1)$。

Step-by-Step 解説

Step 1: DP 定義

$dp[i]$ は先頭 $i$ 点の最小分割コスト、$dp[0]=0$。最後のグループを $[j+1, i]$ とすると遷移は $dp[j]+(x_i-x_{j+1})^2+K$。

Step 2: 二乗展開で直線化

$X=x_i$ を固定変数とみなすと、各 $j$ は傾き $-2x_{j+1}$・切片 $dp[j]+x_{j+1}^2$ の直線。$dp[i]$ は「直線群の $X=x_i$ での最小値 $+ x_i^2 + K$」。

Step 3: 単調CHT(下側凸包)

傾きが単調減少で追加されるので、追加時に不要になった中間直線を末尾から取り除き下側凸包を保つ(_bad 判定)。

Step 4: クエリ点単調でポインタ前進

$x_i$ が増加するので最適直線の添字も単調前進。ptr を進めるだけで最小直線が得られる(二分探索不要)。

よくあるミス

ミス原因正しい書き方
_bad の不等号向き傾きの符号を誤る傾き減少・min では上式の <=
直線を追加する $j$ の範囲$j=N$ で $x_{N+1}$ 参照if i < n のときのみ追加
クエリ点が非単調一般座標を混在非単調なら Li Chao Tree
オーバーフロー他言語で long long 忘れPython は多倍長で安全

次のステップ

  • 発展: 傾き・クエリが非単調なら Li Chao Tree($O(N\log C)$)
  • 発展: monotone/Monge 性があれば分割統治DP・Knuth 最適化も比較
  • 次回予告: Master Level ローテーション継続

自己評価

理解度: / /

自分の回答:

気づき・メモ: