Day 169-Q3 — 2026-10-04 | 隣接差の総和がちょうど K の順列(連結成分 DP=挿入 DP。値を小さい順に置き、成分の「開いた側」の数で和を前払いする)

2026-10-04 赤色 Master (Phase 8+) ★★★★★★★★★ 高度 DP(焦点帯 M1)。順列を位置ではなく値の小さい順に組み立て、いま並んでいる「かたまり(連結成分)」の個数と、配列の両端に固定したかたまりの数を状態に持つ。$|p_i-p_{i+1}|$ の和は「値 $v$ と $v+1$ の間を跨ぐ隣接対の数」の和に書き換え、置くたびに前払いする

問題

$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$ は端に固定したかたまりの数)で、置くたびに和へ前払いできる。

概念図

順列 2 4 1 3(隣接差の和 2+3+2 = 7)を、値の小さい順に置いて作るv=11j=1, e=0新しい成分(中)v=221j=2, e=1新しい成分を左端に置く前に前払い +2(2j−e = 2·1−0)v=3213j=2, e=2[1] にくっつけ右端に置く前に前払い +3(2·2−1)v=42413j=1, e=22 成分をつなぐ置く前に前払い +2(2·2−2)前払いの合計 2+3+2 = 7 = 実際の和。最後は j=1, e=2(1 つの成分が両端に届く)で 0 になる2j−e = 成分の「開いた側」の数 = 値 v−1 以下と v 以上が隣り合う対の数

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

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

1和を「跨ぐ対の数」に書き換える

$|a-b|$ は「$a,b$ の間にある半整数 $t+0.5$ の個数」。全隣接対で足すと、$\sum_t c_t$。$c_t$ は値 $1..t$ を置いた時点のかたまりの形だけで決まる、というのがこの DP の要。

2なぜ $c_t=2j-e$ か

値 $1..t$ でできたかたまりは、最終的な配列では間に $t+1$ 以上の値が挟まって隔てられる。各かたまりの左右の境界のうち、配列の端に固定されていない側は、必ず $t+1$ 以上の値と隣り合う。境界は全部で $2j$、端に固定されたものが $e$ 個なので $2j-e$。最後の値を置いた後は $j=1,e=2$ で $0$ になり、つじつまが合う。

3端を状態に持つ理由

端を区別しないと、「両端の外側」も開いた側として数えてしまう。$c=2j$(端を無視)にすると、$N\le9$ の 159 通りの $(N,K)$ のうち 112 通りで誤答(実測)。

45 つの遷移を漏れなく

「端側のかたまりの外側にくっつけて端にする」を忘れると、159 通り中 104 通りで誤答(実測)。新しいかたまりを作る・くっつける・つなぐ、の 3 種に、それぞれ「端にするか」を掛け合わせると数えやすい(つなぐ操作は端にならない)。

5刈り込み

$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、例 2 12、例 3 1152
  • 実行時間(実測): $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・摩天楼の原典

自己評価

自分の回答

気づき・メモ