問題
$1,2,\dots,N$ の順列 $p$ のうち、
$$\sum_{i=1}^{N-1}|p_i-p_{i+1}|=K$$
を満たすものの個数を $998244353$ で割った余りを求めよ。
入力形式
N K
制約
| # | 条件 |
|---|---|
| 1 | $1\le N\le50$ |
| 2 | $0\le K\le1250$ |
| 3 | 入力はすべて整数 |
入出力例
入力例1
3 3出力例1
4($132,213,231,312$ の 4 つ。$123$ と $321$ は和が 2)
入力例2
4 5出力例2
12入力例3
10 49出力例3
1152($N=10$ で和の最大は $49=\lfloor N^2/2\rfloor-1$。最大を取る順列が 1152 個ある)
背景と出典
「値を小さい順(または大きい順)に挿入し、いまある連結成分の個数を状態に持つ」DP は、日本の競技プログラミングで挿入 DP や連結成分 DP、英語圏で *connected component DP* と呼ばれる(私の理解。特定の初出は未確認)。同じ型の有名問題に CEOI 2016 *Kangaroo*(ジグザグ順列の数え上げ)や JOI の「摩天楼(Skyscraper)」(隣接差の和が $L$ 以下)がある、と記憶している(どちらも原典は未確認)。
この問題の中核は、$|p_i-p_{i+1}|$ という位置の関係を、値の順に組み立てる DP で扱える形に直すこと。
$$|a-b|=\#\{t: \min(a,b)\le t<\max(a,b)\}$$
なので、総和は $\sum_{t=1}^{N-1}c_t$($c_t$ は「値 $t$ 以下と値 $t+1$ 以上が隣り合う対の数」)。値 $1..t$ を置き終えた時点で、各かたまりの開いた側(配列の端に固定されていない側)は、必ず後で $t+1$ 以上の値と隣り合う。だから $c_t=2j-e$($j$ はかたまりの数、$e$ は端に固定したかたまりの数)で、置くたびに和へ前払いできる。
概念図
ヒント(クリックで段階的に開示)
ヒント1(方向性)
順列を左から作ると、前の値との差がすべての値に依存して状態が爆発する。値を 1, 2, …, N の順に置いていく。置いた値どうしは最終的な配列での相対順序だけを決め、間に後から大きい値が入る。いま置いた値が作る「隣り合うことが確定したかたまり」の数 $j$ を状態に持つ。
ヒント2(アプローチ)
状態 $(j,e,s)$: かたまり $j$ 個、配列の左端か右端に固定したかたまり $e\in\{0,1,2\}$ 個、前払い済みの和 $s$。値 $v$ を置く前に $s$ に $2j-e$ を足す。置き方は 5 通り。
| 置き方 | 通り数 | 遷移 |
|---|---|---|
| 新しいかたまりを端以外のすき間に作る | $j+1-e$ | $j\to j+1$ |
| 新しいかたまりを端に作る | $2-e$ | $j\to j+1,\ e\to e+1$ |
| かたまりの片側(端でない側)にくっつける | $2j-e$ | $j$ のまま |
| 端側のかたまりの外側にくっつけて端にする | $2-e$ | $e\to e+1$ |
| 隣り合う 2 つのかたまりを $v$ でつなぐ | $j-1$ | $j\to j-1$ |
ヒント3(誘導)
最後は $j=1,e=2$(1 つのかたまりが両端に届いている)の $s=K$ が答え。値 $v$ を置いた後に残っている値は $N-v$ 個で、1 つの値はかたまりを高々 1 つ減らすから、$j>N-v+1$ の状態は捨ててよい。$s>K$ も捨てる。
模範解答 (Python)
模範解答を表示する(まず自力で解いてから)
import sys
MOD = 998244353
def main():
n, k = map(int, sys.stdin.read().split())
if n == 1:
print(1 if k == 0 else 0)
return
L = k + 1
zero = [0] * L
# dp[e][j]: 長さ L のリスト。値 1..v を置き、連結成分 j 個、端に固定した成分 e 個、
# 確定した和が s の通り数を dp[e][j][s] に持つ(None は全部 0)
dp = [[None] * (n + 2) for _ in range(3)]
dp[0][1] = [1] + [0] * k # 値 1 を端以外に置く
dp[1][1] = [2] + [0] * k # 値 1 を左端か右端に置く
def add(dst, e, j, row, c):
# dst[e][j] += c * row
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] = [a + c * b for a, b in zip(t, row)]
for v in range(2, n + 1):
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
# 値 v-1 と v の間を跨ぐ隣接対 = 成分の開いた側の数 2j - e を和に足す
c = 2 * j - e
if c > k:
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] = [a % MOD for a in nd[e][j]]
dp = nd
row = dp[2][1]
print(row[k] % MOD if row is not None else 0)
main()
計算量: 状態 $N\times N\times3\times(K+1)$、遷移は 1 状態あたり定数個で $O(N^2K)$。$j\le\min(v,N-v+1)$ の刈り込みで実際の $j$ は半分以下。リスト内包表記で 1 行ずつ足すので、Python でも $N=50,K=1249$ が 0.59 s(実測、Python 3.14.7、M1 Mac)。
Step-by-Step 解説
$|a-b|$ は「$a,b$ の間にある半整数 $t+0.5$ の個数」。全隣接対で足すと、$\sum_t c_t$。$c_t$ は値 $1..t$ を置いた時点のかたまりの形だけで決まる、というのがこの DP の要。
値 $1..t$ でできたかたまりは、最終的な配列では間に $t+1$ 以上の値が挟まって隔てられる。各かたまりの左右の境界のうち、配列の端に固定されていない側は、必ず $t+1$ 以上の値と隣り合う。境界は全部で $2j$、端に固定されたものが $e$ 個なので $2j-e$。最後の値を置いた後は $j=1,e=2$ で $0$ になり、つじつまが合う。
端を区別しないと、「両端の外側」も開いた側として数えてしまう。$c=2j$(端を無視)にすると、$N\le9$ の 159 通りの $(N,K)$ のうち 112 通りで誤答(実測)。
「端側のかたまりの外側にくっつけて端にする」を忘れると、159 通り中 104 通りで誤答(実測)。新しいかたまりを作る・くっつける・つなぐ、の 3 種に、それぞれ「端にするか」を掛け合わせると数えやすい(つなぐ操作は端にならない)。
$j>N-v+1$ は残りの値で 1 つにまとめられない。$s>K$ は捨てる。前者で状態の $j$ が三角形に、後者で $s$ が $K$ までに収まる。
迷った他案と採らなかった理由
| 案 | 計算量 | 採否 | 理由 |
|---|---|---|---|
| 全順列の列挙 | $O(N!\cdot N)$ | 検算用のみ | $N\le9$ の全分布を作り照合に使った |
| 左から位置順に置く bit DP(使った値の集合+最後の値+和) | $O(2^NN^2K)$ | 不採用 | $N=50$ では不可能 |
| 挿入 DP(本解) | $O(N^2K)$ | 採用 | $N\le9$ の全 159 通りと一致、$\sum_K$ が $N!$ に一致 |
| numpy で $s$ 方向をベクトル化 | 同じ | 不採用 | 本セッションの環境に numpy が無かった。AtCoder では使えるので、$N=100$ に広げるならこちら |
| 和の母関数を多項式として掛ける | 同じ | 不採用 | 遷移の係数が $j,e$ に依存するので、結局 $(j,e)$ ごとの多項式を持つことになり本解と同じ |
前提が変わったら:
- $N=100,K=5000$ なら純 Python で 8.6 s(実測)。numpy で $s$ 方向をまとめるか、PyPy にする
- 「和が $K$ 以下」(JOI 摩天楼型)なら最後に $s\le K$ を足し合わせるだけ。値が任意の整数列なら、ソートして隣の値との差 $\times(2j-e)$ を前払いする
- ジグザグ(上下交互)の順列なら、くっつける遷移が禁止され、新しいかたまりを作るかつなぐだけになる(Kangaroo 型)
よくあるミス
| ミス | 原因 | 正しい書き方 |
|---|---|---|
| 前払いを $2j$ にする | 端に固定したかたまりの外側を数える | $2j-e$(112/159 誤答) |
| 「端側の外側にくっつけて端にする」を忘れる | 端を作るのは新しいかたまりだけと思う | $2-e$ 通りを足す(104/159 誤答) |
| 新しいかたまりのすき間を $j+1$ にする | 端に固定した側の外にも置けると思う | $j+1-e$ |
| 前払いを値を置いた後にする | $c_t$ の添字がずれる | 値 $v$ を置く前に、$v-1$ までの形で $2j-e$ |
| $N=1$ を特別扱いしない | 和が空 | $K=0$ なら 1、それ以外 0 |
次のステップ
- 発展1: 値が任意の整数 $a_1\le\dots\le a_N$ の並べ替えで、隣接差の和が $L$ 以下の数(JOI 摩天楼型)
- 発展2: 始点と終点が指定されたジグザグ順列の数(Kangaroo 型)を同じ枠組みで
- 発展3: 和の最大が $\lfloor N^2/2\rfloor-1$ になることを、本 DP の $s$ の上限から証明する($N\le10$ は実測で一致)
検証ログ(本セッションで実行した数値)
- 全順列との照合(実測): $N=1..9$ のそれぞれで全順列を列挙して和の分布を作り、$K=0$ から最大+2 までの全 159 通りでプログラムの出力が一致
- 総数の照合(実測): $N=50$ で $K=0..1249$ の通り数の合計が $50!\bmod998244353=700438304$ に一致
- 誤った方法(実測): 前払いを $2j$ にすると 112/159、端にくっつける遷移を抜くと 104/159 誤答
- 和の最大(実測): $N=1..10$ で $\lfloor N^2/2\rfloor-1$($N=1$ は 0)。$N=10,K=49$ が 1152、$K=48$ が 2304
- 入力例(実測): 例 1
4、例 212、例 31152 - 実行時間(実測): $N=50,K=1249$ が 0.59 s、$N=50,K=700$ が 0.53 s、$N=60,K=1799$ が 1.6 s、$N=80,K=3199$ が 4.0 s、$N=100,K=4999$ が 8.6 s(これが $N\le50$ にした理由)
- 扱っていない範囲: 和の最大の式の証明、$N>50$ の高速化(numpy・PyPy は未測定)、Kangaroo・摩天楼の原典