問題
数直線上に配送拠点が $N$ か所あり、$i$ 番目の座標は $x_i$(昇順)。給油所が $M$ か所あり、$j$ 番目の座標は $y_j$(昇順)、基本料金は $b_j$。
拠点 $i$ が給油所 $j$ を使う費用は $b_j+(x_i-y_j)^4$ である(距離の 4 乗は、遠いほど急に高くなる運搬費を表す)。
各拠点 $i$ について、費用の最小値と、最小を与える給油所の番号を出力せよ。最小を与える給油所が複数あるときは番号が最も小さいものを出力せよ。
入力形式
N M
x_1 x_2 ... x_N
y_1 y_2 ... y_M
b_1 b_2 ... b_M
出力形式: $N$ 行。$i$ 行目に拠点 $i$ の最小費用と給油所の番号を空白区切りで。
制約
| # | 条件 |
|---|---|
| 1 | $1\le N,M\le2\times10^5$ |
| 2 | $1\le x_1<x_2<\dots<x_N\le10^6$ |
| 3 | $1\le y_1<y_2<\dots<y_M\le10^6$ |
| 4 | $0\le b_j\le10^{18}$ |
| 5 | 入力はすべて整数 |
入出力例
入力例1
3 3
1 5 9
2 6 8
10 0 7出力例1
11 1
1 2
8 3(拠点 9 は、給油所 3 で $7+1^4=8$。給油所 2 は $0+3^4=81$)
入力例2
1 2
5
3 7
0 0出力例2
16 1(両方とも $2^4=16$ で同じ。番号の小さい 1 を出す)
背景と出典
SMAWK は Aggarwal・Klawe・Moran・Shor・Wilber(1987)の、全単調(totally monotone)行列の行最小を $O(N+M)$ で求めるアルゴリズム。名前は 5 人の頭文字(私の記憶。原典の論文は本セッションでは未確認)。
この問題の中核は 2 つ。
- 行列が Monge であることの根拠。$f$ が凸なら、$x,y$ が昇順のとき $f(x_i-y_j)$ は Monge($A_{ij}+A_{i+1,j+1}\le A_{i,j+1}+A_{i+1,j}$)。外側の 2 つ $x_{i+1}-y_j$(最大)と $x_i-y_{j+1}$(最小)の和は、内側の 2 つの和と等しく、凸関数では外側の方が大きいため。列ごとの定数 $b_j$ を足しても Monge のまま。乱数の 40×40 行列 20 個の隣接 2×2 全 30420 組で違反 0 件(実測)
- 同値のときに最も左を返すこと。最小の値だけでなく番号を問うので、比較の
<と<=を 1 か所でも取り違えると番号がずれる
$(x-y)^2$ なら Convex Hull Trick(直線の最小)で解けるが、4 乗は $x$ についての直線にならないので CHT は使えない。この 4 乗が、SMAWK か分割統治を選ぶ理由になる。
概念図
ヒント(クリックで段階的に開示)
ヒント1(方向性)
$N\times M$ 全部を見ると $4\times10^{10}$。行 $i$ の最小を与える列 $\mathrm{opt}(i)$ が $i$ について単調非減少なら、探す範囲を狭められる。$x$ が右に行くほど、使う給油所も右に寄るのは直感どおり。それを Monge 性で裏づける。
ヒント2(アプローチ)
分割統治: 真ん中の行の $\mathrm{opt}$ を全列で探し、上半分はその列以下、下半分はその列以上だけを探す。これで $O((N+M)\log N)$。
SMAWK はさらに 2 つの操作を組み合わせる。
- REDUCE: 列が行より多いとき、どの行の最小にもならない列をスタックで捨て、列を行数以下にする
- INTERPOLATE: 1 行おきの行だけを再帰で解き、残りの行は上下の答えの間だけを探す
ヒント3(誘導)
def rec(rows, cols):
st = []
for c in cols: # REDUCE
while st and f(rows[len(st)-1], st[-1]) > f(rows[len(st)-1], c):
st.pop() # 同値なら左の列を残す(strict >)
if len(st) < len(rows): st.append(c)
rec(rows[1::2], st) # 1 行おきを再帰
# INTERPOLATE: 偶数番目の行を、前後の奇数番目の行の答えの間で探す(strict < で左優先)模範解答 (Python)
模範解答を表示する(まず自力で解いてから)
import sys
def smawk(rows, cols, f):
# f(i, j) が全単調(各行の最も左の最小の列が行とともに増える)な行列の、各行の最も左の最小の列を返す
res = {}
def rec(rows, cols):
if not rows:
return
# REDUCE: 列を行数以下に減らす(どの行の最も左の最小にもならない列を捨てる)
st = []
for c in cols:
while st:
r = rows[len(st) - 1]
if f(r, st[-1]) > f(r, c):
st.pop()
else:
break
if len(st) < len(rows):
st.append(c)
# 偶数番目(0 始まりで奇数添字)の行だけを再帰で解く
rec(rows[1::2], st)
# INTERPOLATE: 残りの行は、上下の行の答えの間だけを探す
pos = {c: k for k, c in enumerate(st)}
k = 0
for i in range(0, len(rows), 2):
r = rows[i]
hi = pos[res[rows[i + 1]]] if i + 1 < len(rows) else len(st) - 1
best_c = st[k]
best_v = f(r, best_c)
while k < hi:
k += 1
v = f(r, st[k])
if v < best_v:
best_v = v
best_c = st[k]
res[r] = best_c
k = hi
rec(list(rows), list(cols))
return res
def main():
data = sys.stdin.buffer.read().split()
n, m = int(data[0]), int(data[1])
x = [int(v) for v in data[2:2 + n]]
y = [int(v) for v in data[2 + n:2 + n + m]]
b = [int(v) for v in data[2 + n + m:2 + n + 2 * m]]
def f(i, j):
d = x[i] - y[j]
d *= d
return b[j] + d * d
res = smawk(range(n), range(m), f)
out = []
for i in range(n):
j = res[i]
out.append(f"{f(i, j)} {j + 1}")
sys.stdout.write("\n".join(out) + "\n")
main()
計算量: $f$ の評価が $O(N+M)$ 回、再帰の深さ $O(\log N)$。値は最大で $10^{18}+(10^6)^4=2\times10^{24}$ 程度で、Python の整数なら溢れない(C++ なら __int128 か long double が要る)。実測(Python 3.14.7、M1 Mac)で $N=M=2\times10^5$ が 0.85〜1.12 s、同じ入力で分割統治は 1.64〜1.95 s。メモリは約 130〜143 MB(res を辞書で持つ分)。
Step-by-Step 解説
凸な $f$ について $f(x_i-y_j)$ が Monge であることは「背景」に書いた。確かめずに使うと、条件が壊れたとき黙って誤答する。比較として、凹な費用 $\lfloor\sqrt{|d|}\rfloor\cdot10^6$ に変えると、隣接 2×2 の 30420 組中 16194 組で四角不等式が破れた(実測)。凹の費用(遠くなるほど伸びが鈍る)では、SMAWK も分割統治も使えない。
スタックの深さ $k$ のとき、比較に使う行は rows[k-1]。新しい列 $c$ がその行でスタックの一番上の列より真に小さければ、一番上の列はどの行の「最も左の最小」にもならないので捨てる。同値のときは捨てない(左を残す)。スタックが行数に達したら、それ以上は積まない。これで列数は行数以下になる。
行を半分(奇数添字)にし、REDUCE 後の列で再帰する。行数が半分、列数も行数以下なので、全体で $T(n)=T(n/2)+O(n)=O(n)$。
偶数添字の行 $r$ の答えは、直前の行の答えと直後の行の答えの間にある(単調性)。この範囲を左から見て、真に小さいときだけ更新する(同値なら左が残る)。範囲は隣どうしで端を共有するだけなので、全体で $O(\text{列数})$。
REDUCE の比較を > から >= に変える(同値で左の列を捨てる)と、乱数 300 件中 58 件で番号が誤る(実測。値そのものは 300 件とも一致した)。最小値だけを問う問題では気づけない誤りなので、番号まで問う問題で検算する。
迷った他案と採らなかった理由
| 案 | 計算量 | 採否 | 理由 |
|---|---|---|---|
| 全探索 | $O(NM)$ | 検算用のみ | $4\times10^{10}$。乱数 500 件の照合に使った |
| 2 ポインタ(右が小さいうちは進める) | $O(N+M)$ | 不採用 | 行が単峰ではない($b_j$ が任意)ので局所最小で止まる。300 件中 121 件誤答(実測) |
| Convex Hull Trick | $O((N+M)\log)$ | 不採用 | 4 乗は $x$ について直線にならない |
| 分割統治 | $O((N+M)\log N)$ | 次点 | 実装が短く、本解と 500 件一致・大入力の出力もバイト一致。ただし 1.64〜1.95 s で SMAWK の約 1.8 倍遅かった(実測) |
| SMAWK(本解) | $O(N+M)$ | 採用 | 0.85〜1.12 s。同値の扱いが REDUCE と INTERPOLATE の 2 か所にあり、誤りやすい |
前提が変わったら:
- 費用が 2 乗なら CHT で十分(実装が最も短い)
- 最小値だけを問う(番号不要)なら、同値の扱いを気にせず分割統治でよい
- $A_{ij}$ が前の行の答えに依存する(オンラインの DP、例: $dp_i=\min_j\{dp_j+w(j,i)\}$)なら SMAWK はそのまま使えない。LARSCH か、分割統治を CDQ 型に組み直す(本セッションでは未実装)
- 費用が凹なら Monge が逆向き(行の最小が単調に減る、または単調でない)。四角不等式を先に検査する
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
| REDUCE で同値のとき左の列を捨てる | >= で比較 | >(58/300 で番号誤答) |
| 行ごとに 2 ポインタで局所最小を取る | 行が単峰だと思い込む | SMAWK か分割統治(121/300 誤答) |
| INTERPOLATE の上限を最後の列に固定 | 下の行の答えを使わない | 次の奇数行の答えの位置 hi まで |
| Monge 性を確かめずに使う | 「最小化なら単調」と思う | 凸な $f$ であることを確認(凹では 16194/30420 が違反) |
C++ で long long | $2\times10^{24}$ が溢れる | __int128(Python は不要) |
次のステップ
- 発展1: $dp_i=\min_{j<i}\{dp_j+(x_i-x_j)^4\}$ のオンライン版を、分割統治(CDQ)で解く
- 発展2: LARSCH アルゴリズム(オンラインの Monge 行最小)を調べる(本セッションでは未確認)
- 発展3: SMAWK の原典(Aggarwal ほか 1987)の計算量の証明を読む
検証ログ(本セッションで実行した数値)
- 全探索との照合(実測): $N,M\le30$、座標の範囲・$b_j$ の範囲を 4 種ずつ混ぜた乱数 500 件で、値と番号の両方が全探索・分割統治と全件一致。最小が複数ある行は 249 行含まれていた
- 大入力の照合(実測): $N=M=2\times10^5$ の 2 入力で、SMAWK と分割統治の出力がバイト単位で一致
- Monge 性(実測): 40×40 の乱数行列 20 個の隣接 2×2 全 30420 組で、4 乗の費用は違反 0、凹の費用 $\lfloor\sqrt{|d|}\rfloor\cdot10^6$ は 16194 組違反
- 誤った方法(実測): REDUCE を
>=にすると 300 件中 58 件で番号誤答(値は全件一致)、2 ポインタは 121 件誤答 - 入力例(実測): 例 1
11 11 28 3、例 216 1 - 実行時間(実測): $N=M=2\times10^5$ で SMAWK 0.85 s($b\le10^{18}$)・1.12 s($b\le10^6$)、分割統治 1.95 s・1.64 s。最大メモリ 129〜143 MB
- 扱っていない範囲: SMAWK の計算量の証明、オンライン版(LARSCH)、C++ での速度、$N,M>2\times10^5$、原典の確認