Day 161-Q3 — 2026-09-26 — 挿入 DP(連結成分 DP)でジグザグ順列を数える — 端点固定つき(CEOI 2016「Kangaroo」)

2026-09-26 赤色 Master (Phase 8+) ★★★★★★★★★ 高度 DP(焦点帯 M1)。順列を「小さい数から順に、まだつながっていない塊として置いていく」状態に落とし、位置を決めずに数える

問題

$1,2,\dots,N$ の順列 $p_1,p_2,\dots,p_N$ のうち、次をすべて満たすものの個数を $10^9+7$ で割った余りを出力せよ。

  • $p_1=s$、$p_N=f$
  • 両端以外のすべての $i$($2\le i\le N-1$)について、$p_i$ は両隣より大きいか、両隣より小さい(上下が交互に入れ替わる「ジグザグ」)

入力形式

N s f

制約

#条件
1$2\le N\le 2000$
2$1\le s,f\le N$、$s\ne f$

入出力例

入力例1

4 2 3

出力例1

2

入力例2

7 3 6

出力例2

14

入力例3

2000 1 2000

出力例3

677229625

(例1: $(2,1,4,3)$ と $(2,4,1,3)$ の 2 個)

この問題は CEOI 2016 の「Kangaroo」(oj.uz などに収録)と同じ内容。本セッションでは原文を参照しておらず、入力例1 は $N\le9$ の全順列探索で確かめた値。

概念図:塊をつないで順列を作る

N=4, s=2, f=3: 小さい数から置き、塊(連結成分)をつないでいく 順列 (2,1,4,3) 1 1: 新しい塊 塊の数 j = 1 → 2 1 2(=s): 左端の塊にくっつく 塊の数 j = 1 → 2 1 3 3(=f): 右端に新しい塊 塊の数 j = 2 → 2 1 4 3 4: 2 塊をつなぐ(山) 塊の数 j = 1 順列 (2,4,1,3) 1 1: 新しい塊 塊の数 j = 1 → 2 1 2(=s): 左端に新しい塊 塊の数 j = 2 → 2 1 3 3(=f): 右端の塊にくっつく 塊の数 j = 2 → 2 4 1 3 4: 2 塊をつなぐ(山) 塊の数 j = 1 端以外の数の置き方は 2 通り: 新しい塊(谷)× (j − [i>s] − [i>f])、2 塊をつなぐ(山)× j 塊の端にくっつける置き方は、片側が小さく片側が大きくなるので禁止(s, f だけは例外)

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

ヒント1(方向性)

位置 $1..N$ を左から埋めると「どの数を使ったか」の集合が状態に入り $2^N$ になる。逆に 数を小さい順に 置くことを考える。数 $i$ を置く時点で、置いた数はまだ最終的な並びで隣り合っているとは限らない。

ヒント2(アプローチ)

$1..i$ を置いた時点の状態を「隣同士であることが確定した塊(連結成分)の個数 $j$」で表す。塊と塊の間には、あとで より大きい数 が入る。数 $i$(端以外)の置き方は 2 通りだけ:

  • 新しい塊として隙間に置く → 後から両隣に大きい数が来るので $i$ は 谷
  • 2 つの塊の間に置いてつなぐ → 両隣は既に置いた小さい数なので $i$ は 山

塊の端に「くっつける」置き方は、$i$ の片側が小さく片側が大きくなりジグザグを壊すので 禁止。

ヒント3(誘導)

dp[j] を $1..i$ を置いて塊が $j$ 個の場合の数とする。

  • $i=s$ または $i=f$: 左端(右端)に新しい塊を作る dp[j-1]、または左端(右端)の塊の外側にくっつく dp[j]
  • それ以外: 新しい塊 dp[j-1] * (j - [i>s] - [i>f])、つなぐ dp[j+1] * j

答えは $N$ 個置き終えて塊が 1 個 dp[1]。

模範解答 (Python)

模範解答を表示する(まず自力で解いてから)
import sys

MOD = 10**9 + 7


def solve(n, cs, cf):
    # dp[j] = 1..i を置き終えたとき、塊(まだつながっていない連結成分)が j 個ある並べ方の数
    dp = [0] * (n + 2)
    dp[0] = 1
    for i in range(1, n + 1):
        nd = [0] * (n + 2)
        if i == cs or i == cf:
            # 端に固定された数: 端に新しい塊を作る / 端の塊の外側にくっつく
            for j in range(1, i + 1):
                nd[j] = (dp[j - 1] + dp[j]) % MOD
        else:
            fixed = (i > cs) + (i > cf)      # 既に置かれた端(その外側には置けない)
            for j in range(1, i + 1):
                # 新しい塊として隙間に置く(i は両隣より小さい谷になる)
                v = dp[j - 1] * (j - fixed) if j > fixed else 0
                # 2 つの塊の間に置いて 1 つにつなぐ(i は両隣より大きい山になる)
                v += dp[j + 1] * j
                nd[j] = v % MOD
        dp = nd
    return dp[1]


def main():
    n, cs, cf = map(int, sys.stdin.read().split())
    print(solve(n, cs, cf))


main()

計算量: $O(N^2)$ 時間、$O(N)$ 空間。実測で $N=2000$ が 0.27 s、$N=5000$ が 2.94 s。

Step-by-Step 解説

1なぜ「小さい数から」置くのか

ジグザグの条件は「$p_i$ が両隣より大きいか小さいか」で、比べる相手との 大小だけ が効く。小さい数から置けば、ある数を置いた瞬間に「既に置いた数はすべて自分より小さい、後から来る数はすべて大きい」が確定する。これで谷(両隣が後から来る)と山(両隣が既に置いた数)を置いた瞬間に決められる。

2塊の数だけ覚えればよい理由

最終的な並びで、既に置いた数同士が隣り合うかどうかだけが問題で、塊の 中身や位置 はもう変わらない。塊の間には必ず 1 個以上の大きい数が入る。よって状態は塊の個数 $j$ で足り、$N\times N$ の表で済む。

3係数の意味

塊が $j-1$ 個あるところに新しい塊を作るとき、置ける隙間は $j$ か所(塊の間 $j-2$ か所+左右の外側 2 か所)。ただし $s$ が既に置かれていれば、$s$ を含む塊の左側には何も置けないので 1 減る。$f$ も同様。よって係数は $j-[i>s]-[i>f]$。
つなぐ場合は塊が $j+1$ 個の間の $j$ か所のどこかに置く。$s,f$ の外側は隙間ではないので減らさない。

4端の数 $s,f$ の扱い

$s$ は必ず左端。新しい塊を左端に作るか、既にある左端の塊の 左側にくっつける。くっつけても $s$ は端なのでジグザグの条件は課されない(端は両隣を持たない)。したがって係数は 1。

5検算(実測)

$N\le9$ の全 $(s,f)$ の組 240 通りで全順列探索と一致。さらに、$(s,f)$ について合計すると「ジグザグ順列の総数」$2E_N$($E_N$ は Euler のジグザグ数、OEIS A000111: $1,1,1,2,5,16,61,272,1385,7936$)になるはずで、$N=2..9$ の合計 $2,4,10,32,122,544,2770,15872$ は $2E_N$ と一致した。

迷った他案と採らなかった理由

案計算量採否理由
全順列$O(N!\,N)$検算用のみ$N=9$ で 36 万通り
位置を左から埋めるビット DP$O(2^N N^2)$不採用「どの数を使ったか」が状態に要る。$N\approx18$ が限界
「残りの数の中で何番目か」の相対順位 DP(Entringer 数の漸化式)$O(N^2)$不採用端点固定なしのジグザグ数え上げでは定番で、同じ $O(N^2)$。ただし $s,f$ の両方を固定すると、左端の相対順位と右端の値を同時に追う必要があり式が込み入る
挿入 DP(連結成分 DP、本解)$O(N^2)$採用「隣が小さいか大きいか」の条件を、置き方 2 種類(新しい塊/つなぐ)で自然に表せる。端点の固定は係数を 1 ずつ減らすだけ

前提が変わったら: 「隣との差が $K$ 以下」のような 値の差 の条件は、塊の数だけでは表せない(差の合計を状態に足す。JOI Open 2016「Skyscraper」がこの型として知られる。本セッションでは原文未参照)。「$p_i<p_{i+1}$ か $>$ かが文字列で指定される」型(例: EDPC T「Permutation」)は相対順位 DP の方が素直で $O(N^2)$(累積和で)になる。

よくあるミス

ミス原因正しい書き方
新しい塊の係数を $j$ のままにする置かれた端 $s,f$ の外側には置けない$j-[i>s]-[i>f]$。入力例1 では 2 と正しく出るのに、$N\le9$ の 240 組中 178 組で誤る(実測)
塊の端にくっつける遷移を入れる片側が小さく片側が大きい数はジグザグの中間に来られない端以外の数は「新しい塊」「つなぐ」の 2 種類だけ
$s,f$ で「くっつく」を禁止する$s,f$ は端なので条件が課されない$s,f$ は dp[j-1] + dp[j]
j > fixed の確認を忘れ負の係数を掛ける塊が 1 個で $s,f$ の両方が置かれていると、新しい塊を置ける場所が無いif j > fixed else 0
答えを sum(dp) にする全部つながった状態だけが 1 本の順列dp[1]

次のステップ

  • 発展1: 「$|p_i-p_{i+1}|$ の総和が $L$ 以下」の順列を数える(Skyscraper 型)。塊の数に加えて、差の合計を「まだ埋まっていない隙間の数 × 次の値の差」で前払いする
  • 発展2: 本問を $N\le10^5$ にするには、$s,f$ を固定しない場合の Euler ジグザグ数が $\sec x+\tan x$ の係数であることを使い、形式的冪級数で扱う方法がある
  • 扱わなかった範囲: $s=f$ の扱い(本問では禁止)、円順列版

検証ログ(本セッションで実行した数値)

  • 全順列探索との一致(実測): $N=2..9$ の全 $(s,f)$、240 組で不一致 0
  • ジグザグ総数(実測): $(s,f)$ で合計した値 $2,4,10,32,122,544,2770,15872$($N=2..9$)が $2E_N$(OEIS A000111 の値の 2 倍)と一致
  • 係数を $j$ のままにした誤り(実測): 入力例1 は 2 で通るが、240 組中 178 組で不一致
  • 入力例1・2・3 の出力 2、14、677229625 を実行で確認
  • 性能(実測): $N=500$ 0.02 s、$N=1000$ 0.07 s、$N=2000$ 0.27 s、$N=5000$ 2.94 s
  • 出所ラベル: 数値は実測(Python 3.14.7)。CEOI 2016 の出題であることと OEIS の数列は文献の記述で、本セッションでは原文を参照していない(数列の値は全探索で独立に再現)
  • 検証スクリプト: スクラッチパッドの d161/q3.py・q3_test.py

自己評価

自分の回答

気づき・メモ