問題
高さ $A_1,A_2,\dots,A_N$ のビルが $N$ 棟ある(同じ高さのビルがあってもよいが、ビルどうしは区別する)。$N$ 棟すべてを一列に並べる並べ方 $N!$ 通りのうち、隣り合うビルの高さの差の絶対値の総和
$$\sum_{k=1}^{N-1}|h_k-h_{k+1}|$$
が $L$ 以下になるものの個数を $998244353$ で割った余りを求めよ。
入力形式
N L
A_1 A_2 ... A_N
制約
| # | 条件 |
|---|---|
| 1 | $1\le N\le100$ |
| 2 | $0\le L\le1000$ |
| 3 | $1\le A_i\le1000$ |
| 4 | 入力はすべて整数 |
入出力例
入力例1
3 5
1 3 5出力例1
2($1,3,5$ と $5,3,1$ の和が 4。$3$ を端に置くと和は 6)
入力例2
4 10
3 6 2 9出力例2
6入力例3
5 8
1 2 2 4 5出力例3
68(高さ 2 のビルが 2 棟あり、区別して数える。全 120 通りの全探索で確認)
背景と出典
この形の問題は JOI 春合宿か JOI Open の「摩天楼(Skyscraper)」として知られる、と私は記憶している($N\le100$、$L\le1000$、$\bmod 10^9+7$ だったはず。原典は本セッションでは未確認。本問は法を変えた自作の言い換え)。解法は Day169-Q3 と同じ連結成分 DP(挿入 DP)で、変わるのは前払いの量だけ。
高さをソートして $a_1\le a_2\le\dots\le a_N$ とすると、
$$|h_k-h_{k+1}|=\sum_{t:\ \min\le a_t<\max}(a_{t+1}-a_t)$$
なので、総和は $\sum_t (a_{t+1}-a_t)\cdot c_t$($c_t$ は「小さい方から $t$ 個」と「残り」が隣り合う対の数)。小さい方から $t$ 個を置いた時点のかたまりの数を $j$、配列の端に固定したかたまりの数を $e$ とすると $c_t=2j-e$。次の値を置く前に $(a_{t+1}-a_t)(2j-e)$ を前払いすると、和を最後まで持ち越さずに済む。
概念図
ヒント(クリックで段階的に開示)
ヒント1(方向性)
左から並べると、和が「直前のビルの高さ」に依存して状態が増える。高さを小さい順に置いていき、置いた値どうしの相対順序と「かたまり」の数だけを持つ。
ヒント2(アプローチ)
状態 $(e,j,s)$: 端に固定したかたまり $e\in\{0,1,2\}$、かたまり $j$ 個、前払い済みの和 $s\le L$。$v$ 番目の値を置く前に $s$ に $(a_v-a_{v-1})(2j-e)$ を足す。置き方は Day169-Q3 と同じ 5 種(新しいかたまりを端以外/端に、片側にくっつける/端側の外側にくっつけて端にする、2 つをつなぐ)。
ヒント3(誘導)
前払いの量が $L$ を超えたら、その状態ごと捨てる。差が 0(同じ高さ)のときは前払いも 0 で、同じ高さのビルは区別して数えるので、遷移の通り数はそのまま。最後は $e=2,j=1$ の $s=0..L$ を全部足す。
模範解答 (Python)
模範解答を表示する(まず自力で解いてから)
import sys
MOD = 998244353
def main():
data = sys.stdin.buffer.read().split()
n, lim = int(data[0]), int(data[1])
a = sorted(int(x) for x in data[2:2 + n])
if n == 1:
print(1)
return
L = lim + 1
zero = [0] * L
# dp[e][j][s]: 小さい方から v 個を置き、成分 j 個、端に固定した成分 e 個、前払い済みの和 s の通り数
dp = [[None] * (n + 2) for _ in range(3)]
dp[0][1] = [1] + [0] * lim # 最小の値を端以外に置く
dp[1][1] = [2] + [0] * lim # 最小の値を左端か右端に置く
def add(dst, e, j, row, c):
if c <= 0:
return
t = dst[e][j]
if t is None:
dst[e][j] = [c * b for b in row]
else:
dst[e][j] = [x + c * b for x, b in zip(t, row)]
for v in range(2, n + 1): # v 個目の値 a[v-1] を置く
gap = a[v - 1] - a[v - 2] # 1 つ前の値との差。開いた側 1 つにつき gap を前払い
nd = [[None] * (n + 2) for _ in range(3)]
jmax = n - v + 1
for e in range(3):
for j in range(1, n + 1):
row = dp[e][j]
if row is None:
continue
c = (2 * j - e) * gap
if c > lim:
continue
if c:
row = zero[:c] + row[:L - c]
if j + 1 <= jmax:
add(nd, e, j + 1, row, j + 1 - e) # 新しい成分を端以外のすき間に
if e < 2:
add(nd, e + 1, j + 1, row, 2 - e) # 新しい成分を端に
if j <= jmax:
add(nd, e, j, row, 2 * j - e) # 成分の開いた側にくっつける
if e < 2:
add(nd, e + 1, j, row, 2 - e) # 端側の成分の外側にくっつけて端にする
if j >= 2 and j - 1 <= jmax:
add(nd, e, j - 1, row, j - 1) # 隣り合う 2 成分をつなぐ
for e in range(3):
for j in range(n + 2):
if nd[e][j] is not None:
nd[e][j] = [x % MOD for x in nd[e][j]]
dp = nd
row = dp[2][1]
print(sum(row) % MOD if row is not None else 0)
main()
計算量: 状態 $3\times N\times(L+1)$ を $N$ 回更新、各状態の遷移は定数個で $O(N^2L)$。$j\le N-v+1$ と「前払いが $L$ を超えたら捨てる」で実際はずっと少ない。$N=100,L=1000$ で 1.43〜1.58 s(高さの範囲を 5 種に振って、Python 3.14.7、M1 Mac、実測)。高さの差が大きいと前払いで早く捨てられ、0.72 s まで下がる。
Step-by-Step 解説
高さを数直線に並べ、隣り合う 2 つの高さの間の「段」$[a_t,a_{t+1})$ を考える。隣り合うビルの組 1 つが段を跨ぐたびに、和に段の幅 $a_{t+1}-a_t$ が加わる。
小さい方から $t$ 個を置いた時点のかたまりは、最終的には間に大きい値が挟まって隔てられる。かたまりの左右の境界のうち、配列の端に固定されていない側は必ず大きい値と隣り合う。だから跨ぐ回数は $2j-e$。
差が 0 の段は前払いが 0 になるだけ。ビルを区別して数えるので、遷移の通り数(どのすき間に置くか)は値が同じでも変わらない。ランダム 400 件のうち同じ高さを含む 172 件も全探索と一致(実測)。
$s$ は増える一方なので、$L$ を超えた分は二度と戻らない。前払いの量 $c$ が $L$ を超えたら状態ごと捨て、超えない分は配列をずらすときに末尾を切る。
迷った他案と採らなかった理由
| 案 | 計算量 | 採否 | 理由 |
|---|---|---|---|
| 全順列の列挙 | $O(N!\cdot N)$ | 検算用のみ | $N\le8$ のランダム 400 件の正解に使用 |
| 左から置く bit DP(使ったビル集合+最後のビル+和) | $O(2^NN^2L)$ | 不採用 | $N=100$ では不可能 |
| 和を最後に計算する挿入 DP(前払いしない) | 状態に「開いた側の高さ」が要る | 不採用 | 開いた側が後で誰と隣り合うか分からず、状態が閉じない |
| 前払いの挿入 DP(本解) | $O(N^2L)$ | 採用 | 400 件一致、$N=100,L=1000$ で 1.6 s 以下 |
| numpy で $s$ 方向をまとめる | 同じ | 不採用 | 本セッションの環境に numpy が無い。$L=10^4$ に広げるならこちら |
前提が変わったら:
- 「和がちょうど $L$」なら最後の和を取らず $s=L$ の 1 つだけ(Day169-Q3 型)
- $L$ が $10^5$ になると $O(N^2L)$ が重い。$L\ge\max$ 和なら全部 $N!$ なので、和の最大を先に計算して打ち切る、numpy で配列演算にする、などで対処
- ビルを区別しない(同じ高さを同一視)なら、最後に同じ高さの個数の階乗で割る($998244353$ は素数なので逆元が取れる)
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
| 高さをソートしない | 入力順のまま差を取る | ソートして小さい順に置く(193/400 誤答) |
| 前払いを $2j\times$ 差 にする | 端に固定したかたまりの外側を数える | $(2j-e)\times$差(184/400 誤答) |
| 前払いを 1 段 1 にする | Day169-Q3(値が $1..N$)をそのまま使う | 段の幅を掛ける(184/400 誤答) |
| $s>L$ の状態を残す | 後で減ると思う | 和は増える一方なので捨てる |
| 最後に $s=L$ だけを取る | 「ちょうど」と「以下」の取り違え | $s=0..L$ を全部足す |
次のステップ
- 発展1: 高さが同じビルを区別しない版を、最後に階乗の逆元で割って解き、全探索で確かめる
- 発展2: 「左端のビルと右端のビルが指定されている」版(端の扱いが変わる)
- 発展3: 原典の JOI「摩天楼」の制約・法を確かめ、本解で通るか試す(本セッションでは未確認)
検証ログ(本セッションで実行した数値)
- 全順列との照合(実測): $N\le8$、高さの上限を 3/10/1000 に振り、$L$ を $0$ から(高さの幅 $\times N$)までランダムに選んだ 400 件で不一致 0。うち同じ高さを含むものが 172 件
- 誤った方法(実測): 同じ 400 件で、ソートしない 193 件、前払いを $2j\times$差 184 件、前払いを 1 段 1 にする 184 件誤答
- 全部数える場合の照合(実測): $N=100$、高さ 1〜20、$L=1000$ のとき答えが $100!\bmod998244353=35305197$ と一致(和の最大は「大きい半分の和×2−小さい半分の和×2」でおよそ 1000 と見積もれる=導出。全並びが $L$ 以下になる)。同じ入力で $L=0$ は 0、$L=100,300,\dots,900$ は $100!\bmod p$ と異なる値(実測)
- 入力例(実測): 例 1
2、例 26、例 368(例 1・3 は全探索でも同じ値) - 実行時間(実測): $N=100,L=1000$ で高さの上限 20/30/50/100 がそれぞれ 1.52/1.51/1.57/1.58 s、高さ $\lfloor i/3\rfloor+1$ の列が 1.43 s、上限 1000 が 0.72 s
- 扱っていない範囲: 原典の JOI 問題の確認、$L>1000$ の高速化、ビルを区別しない版