Day 170-Q3 — 2026-10-05 | 摩天楼の並べ方(高さが任意の値の挿入 DP。値を小さい順に置き、隣の値との差 × 開いた側の数を前払いして「差の和が L 以下」を数える)

2026-10-05 赤色 Master (Phase 8+) ★★★★★★★★★ 高度 DP(焦点帯 M1)。Day169-Q3 は値が $1..N$ の順列で、前払いは 1 段につき「開いた側の数」だった。今回は高さが任意(重複あり)なので、前払いはソート後の隣の値との差 × 開いた側の数になる。和の上限 $L$ が状態の 3 つ目の軸になる

問題

高さ $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 2 4 5 を小さい順に置く。次の値を置く前に(段の幅)×(開いた側の数 2j−e)を前払い 12, 245 幅 1幅 0幅 2幅 1 例: 1, 2, 2 を置いた時点の並び(□ は後で大きい値が入る場所、| は配列の端) | [2 1] □ [2] □ かたまり j=2、端に固定 e=1 → 開いた側 2j−e = 3 値 4 を置く前に 幅 2 × 3 = 6 を前払い(4 以上と隣り合う境界が 3 か所確定) 遷移 5 種(Day169-Q3 と同じ): 新しいかたまり(端以外 j+1−e 通り/端 2−e 通り)、 片側にくっつける 2j−e 通り、端側の外側にくっつけて端にする 2−e 通り、隣り合う 2 つをつなぐ j−1 通り 同じ高さは幅 0 で前払いなし。ビルは区別するので通り数はそのまま。和が L を超えたら捨てる

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

ヒント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 解説

1差の和を「段の幅 × 跨ぐ回数」に分ける

高さを数直線に並べ、隣り合う 2 つの高さの間の「段」$[a_t,a_{t+1})$ を考える。隣り合うビルの組 1 つが段を跨ぐたびに、和に段の幅 $a_{t+1}-a_t$ が加わる。

2跨ぐ回数は「小さい方から $t$ 個」の形だけで決まる

小さい方から $t$ 個を置いた時点のかたまりは、最終的には間に大きい値が挟まって隔てられる。かたまりの左右の境界のうち、配列の端に固定されていない側は必ず大きい値と隣り合う。だから跨ぐ回数は $2j-e$。

3同じ高さがあっても壊れない

差が 0 の段は前払いが 0 になるだけ。ビルを区別して数えるので、遷移の通り数(どのすき間に置くか)は値が同じでも変わらない。ランダム 400 件のうち同じ高さを含む 172 件も全探索と一致(実測)。

4$L$ で刈る

$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、例 2 6、例 3 68(例 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$ の高速化、ビルを区別しない版

自己評価

自分の回答

気づき・メモ