Day 082-Q4 — 区間DP + 凸包トリック融合(区間最小コスト分割 $O(N^2 \to KN \log N)$)

2026-07-05 赤色 Master / Phase 8+ ★★★★★★★★★ Li Chao Tree・CHT・DP最適化・区間分割

問題

長さ $N$ の数列 $a_1, \ldots, a_N$ を $K$ 個の連続する区間に分割する。各区間 $[l, r]$ のコストを $\text{cost}(l, r) = (S_r - S_{l-1})^2$ とする($S_i = \sum_{j=1}^{i} a_j$)。コストの総和を最小化せよ。

制約

パラメータ範囲備考
$N$$1 \le N \le 10^5$数列長
$K$$1 \le K \le N$分割数
$a_i$$1 \le a_i \le 10^4$各要素

入出力例

入力例1

6 3
3 1 4 1 5 9

入力例2

5 2
1 2 3 4 5

出力例1

97

出力例2

85

概念図: CHT による DP 最適化

直線群の最小値 → Li Chao Tree で $O(\log C)$ クエリ x = S[i] y l=0 l=1 l=2 下包絡線(最小値)= dp[j][i] query(S[i]) S[i] $dp[j][i] = S_i^2 + \min_l \{ \underbrace{(dp[j-1][l] + S_l^2)}_{\text{切片}} + (-2 S_l) \cdot S_i \}$ ← 直線 $y = (-2S_l)x + (dp[j-1][l]+S_l^2)$ の $x=S_i$ での最小値 Li Chao Tree に各 $l$ の直線を追加し, 各 $i$ でクエリ → $O(KN \log C)$

ヒント

ヒント1(方向性)

素直な DP は $dp[i][j] = i$ 番目まで $j$ 区間に分割した最小コスト、遷移は $O(N)$ なので全体 $O(KN^2)$。これを CHT(Convex Hull Trick)で $O(KN \log N)$ に落とす。

ヒント2(アプローチ)

$$dp[j][i] = \min_{l < i} \{ dp[j-1][l] + (S_i - S_l)^2 \}$$

展開すると $= (dp[j-1][l] + S_l^2) - 2 S_l \cdot S_i + S_i^2$。これは $f(l) = dp[j-1][l] + S_l^2$(切片)、傾き $-2 S_l$、$x = S_i$ の一次関数群の最小値問題。

ヒント3(誘導)
for j in range(1, K+1):
    lct = LCTree(0, S[N]+1)
    lct.add(-2 * S[0], prev[0] + S[0]**2)  # l=0 を追加
    for i in range(1, N+1):
        val = lct.query(S[i])               # dp[j][i] = val + S[i]^2
        curr[i] = val + S[i]**2 if val < inf else inf
        if prev[i] < inf:
            lct.add(-2 * S[i], prev[i] + S[i]**2)  # l=i を追加
    prev = curr

模範解答

import sys
from math import inf
input = sys.stdin.readline

def solve():
    N, K = map(int, input().split())
    a = list(map(int, input().split()))
    S = [0] * (N+1)
    for i in range(N): S[i+1] = S[i] + a[i]

    MAX_VAL = S[N] + 1

    class LCTree:
        def __init__(self, lo, hi):
            self.lo = lo; self.hi = hi
            self.lines = {}

        def _add(self, node, lo, hi, m, b):
            mid = (lo + hi) >> 1
            if node not in self.lines:
                self.lines[node] = (m, b); return
            om, ob = self.lines[node]
            left_new = m * lo + b < om * lo + ob
            mid_new  = m * mid + b < om * mid + ob
            if mid_new:
                self.lines[node] = (m, b); m, b = om, ob
            if lo == hi: return
            if left_new != mid_new:
                self._add(2*node, lo, mid, m, b)
            else:
                self._add(2*node+1, mid+1, hi, m, b)

        def add(self, m, b):
            self._add(1, self.lo, self.hi, m, b)

        def _query(self, node, lo, hi, x):
            if node not in self.lines: return inf
            res = self.lines[node][0]*x + self.lines[node][1]
            if lo == hi: return res
            mid = (lo + hi) >> 1
            if x <= mid:
                return min(res, self._query(2*node, lo, mid, x))
            else:
                return min(res, self._query(2*node+1, mid+1, hi, x))

        def query(self, x):
            return self._query(1, self.lo, self.hi, x)

    prev = [inf] * (N+1)
    prev[0] = 0

    for j in range(1, K+1):
        lct = LCTree(0, MAX_VAL)
        lct.add(-2 * S[0], prev[0] + S[0]**2)
        curr = [inf] * (N+1)
        for i in range(1, N+1):
            val = lct.query(S[i])
            if val < inf:
                curr[i] = val + S[i]**2
            if prev[i] < inf:
                lct.add(-2 * S[i], prev[i] + S[i]**2)
        prev = curr

    print(prev[N])

solve()

Step-by-Step 解説

Step 1: DP の定式化

$dp[j][i]$: 最初の $i$ 要素を $j$ 区間に分割した最小コスト。

$$dp[j][i] = \min_{0 \le l < i} \left\{ dp[j-1][l] + (S_i - S_l)^2 \right\}$$

Step 2: 遷移の変形(CHT 適用準備)

$(S_i - S_l)^2 = S_i^2 - 2 S_i S_l + S_l^2$ と展開すると:

$$dp[j][i] = S_i^2 + \min_{l} \underbrace{(dp[j-1][l] + S_l^2)}_{\text{切片 } b_l} + \underbrace{(-2 S_l)}_{\text{傾き } m_l} \cdot S_i$$

Step 3: Li Chao Tree での高速化

$l$ を増やしながら直線 $(m_l, b_l)$ を Li Chao Tree に追加し、各 $i$ でクエリを $O(\log C)$ で処理。全体 $O(KN \log C)$。$S_i$ が単調増加なら傾き単調版 CHT(deque)で $O(KN)$ も可能。

Step 4: 空間最適化

prev = [inf] * (N+1); prev[0] = 0
for j in range(1, K+1):
    curr = [inf] * (N+1)
    # ... 計算後 prev = curr
# prev[N] が答え

よくあるミス

ミス原因正しい書き方
dp[0][i≠0] = 0 に初期化0区間分割は i=0 のみ有効prev[0]=0; prev[i]=inf for i>0
傾き単調を仮定してしまう$S_l$ は単調だが問題によっては非単調Li Chao Tree で汎用的に対応
$S_i$ の範囲外でクエリLCTree のレンジ外LCTree(0, S[N]+1) と余裕を持たせる
j=1 の初期化で l=0 の直線を忘れるループ前に追加が必要ループ前に lct.add(-2*S[0], prev[0]+S[0]**2)

次のステップ

  • 発展問題: Aliens Trick(WQS 二分探索)で $K$ をパラメータ化し $O(N \log N \log C)$
  • 参考: $\text{cost}(l,r) = c \cdot (r-l+1)$ との複合コスト(Knuth 最適化との組み合わせ)

自己評価

理解度: / /

自分の回答:

気づき・メモ: