F - Bomb Game 2 / トヨタ自動車プログラミングコンテスト2023#8 (AtCoder Beginner Contest 333)
問題
人の人が一列に並んでおり、人 は先頭から 番目に並んでいる。 以下の操作を人が1人になるまで行う。
- 先頭に並んでいる人を、確率 で列から取り除き、確率 で列の最後尾に移す。
人 について、人 が最後の1人になる確率を で求めよ。
補足
この問題では、求める確率が有理数であることが保証される。
また、確率を既約分数で と表したときに、 が で割り切れないことが保証される。
このときに、 が成立する整数 (ただし、 )が一意に定まるので、これを求めよ。
入力
最初の1行に、整数 が与えられる。
条件
- 実行時間制限: 2s
- メモリ制限: 1024MB
解法
まず、分数を で表すために、フェルマーの小定理 *1 を用います。
問題文に記載の通り、解答となる確率が のとき、 は ( とおきます) と互いに素となります。 従って、 が成立することから、便宜的に とすることができます。
よって、求める値 については、 ということになります。
さてここからは、この問題で求めるべき確率について考えます。
この問題では、「最後尾に移す」という操作を引き続ければ、何回でも操作を行うことができてしまいます。 そのため、例えば「人 が最後の1人になるまでに行った操作が 回である確率」のようなものを求めていく方法では、プログラム上で正しい有理数の答えは得られません。
そこで、まずは確率 を、「 人の人が並んでいるときに、人 が最後まで残る確率」として定義します。 これを用いて、「 人が並んでいるときに、人 が先頭になる確率」はどのように表せるか考えます。
まずは、人 が操作される直前に、人 以前の人たちが脱落しているのか最後尾にいるのかについて考えます。
人 以前にいる 人にについて、このうち 人が列に残らず脱落したとすると、人 が先頭になったときには列に 人が残っていることになります。
従って、先程の を用いると、この状況になった際に、先頭の人 が最後まで列に残る確率は として表せます。
また、 人から 人が脱落する確率については、脱落する確率が であり、最後尾に移される確率が であることから、 \begin{align} \frac{(i - 1)!}{j! (i - 1 - j) !} \cdot {\left( \frac{1}{2} \right)}^{j} \cdot {\left( \frac{1}{2} \right)}^{i - 1 - j} \ = \ _{i - 1}\mathrm{C}_{j} {\left( \frac{1}{2} \right)}^{i-1} \end{align} として計算ができます。
以上から、「人 の最初の出番直前に 人が残っていて、そこから人 が最後まで残る確率」は、 \begin{align} _{i - 1}\mathrm{C}_{j} {\left( \frac{1}{2} \right)}^{i-1} {p}_{n - j} \end{align} となります。
この状況を図で整理すると以下のようになります。
図に示されている通り、 の値は の範囲であることから、 人並んでいるときに人 が最後の1人になる確率は、 \begin{align} {\sum_{j = 0}^{i - 1}} \ & {_{i - 1}\mathrm{C}_{j}} {\left( \frac{1}{2} \right)}^{i-1} {p}_{n - j} \\ = & {_{i - 1}\mathrm{C}_{0}} {\left( \frac{1}{2} \right)}^{i-1} {p}_{n} + {_{i - 1}\mathrm{C}_{1}} {\left( \frac{1}{2} \right)}^{i-1} {p}_{n - 1} + \cdots + {_{i - 1}\mathrm{C}_{i - 1}} {\left( \frac{1}{2} \right)}^{i-1} {p}_{n - i + 1} \end{align} として計算することができます。
以上から、人 が最後の1人になる確率は、
- が最後の1人になる確率:
- が最後の1人になる確率:
- が最後の1人になる確率:
というように計算されます。 いずれかの人が必ず最後の1人になることから、この値の総和は必ず となります。
ここで、便宜的に として以上のことを整理すると、 \begin{align} \sum_{i = 1}^{n} \sum_{j = 0}^{i - 1} {_{i - 1}\mathrm{C}_{j}} {\left( \frac{1}{2} \right)}^{i-1} {p}_{n - j} = 1 \end{align} が成立すると言えます。
この式の左辺に対して の係数に当たる部分を として定義します。
すなわち、
\begin{align}
\sum_{j = 0}^{n - 1} {c}_{n, j} \ {p}_{n - j} = 1
\end{align}
となるような を定義すると、先程の整理から
\begin{align}
{c}_{n, j} = {_{j}\mathrm{C}_{j}} {\left( \frac{1}{2} \right)}^{j} + {_{j + 1}\mathrm{C}_{j}} {\left( \frac{1}{2} \right)}^{j + 1} + \cdots + {_{n - 1}\mathrm{C}_{j}} {\left( \frac{1}{2} \right)}^{n - 1}
\end{align}
と言えます。
ここで行列の長さが のときの、 の係数に当たる部分である の値について考えると、 \begin{align} {c}_{n + 1, j} & = {_{j}\mathrm{C}_{j}} {\left( \frac{1}{2} \right)}^{j} + {_{j + 1}\mathrm{C}_{j}} {\left( \frac{1}{2} \right)}^{j + 1} + \cdots + {_{n - 1}\mathrm{C}_{j}} {\left( \frac{1}{2} \right)}^{n - 1} + {_{n}\mathrm{C}_{j}} {\left( \frac{1}{2} \right)}^{n} \\ & = {c}_{n, j} + {_{n}\mathrm{C}_{j}} {\left( \frac{1}{2} \right)}^{n} \end{align} として整理できます。
また、
\begin{align}
{c}_{n, n - 1} = {_{n - 1}\mathrm{C}_{n - 1}}{\left( \frac{1}{2} \right)}^{n - 1}
\end{align}
であることから、 である に対して として定めると、
\begin{align}
{c}_{n + 1, n} = {_{n}\mathrm{C}_{n}}{\left( \frac{1}{2} \right)}^{n} = {c}_{n, n - 1} + {_{n}\mathrm{C}_{n}}{\left( \frac{1}{2} \right)}^{n}
\end{align}
と整理できるので、先ほどの漸化式が成立します。
ここで、行列の長さが のときについては、必ず人 が最後の1人になり、 が成立することから、 となります。
初期値と漸化式があることから、 の値が求まることになります。
従って、 の値が全て求まっていると仮定すると、 \begin{align} \sum_{j = 0}^{i - 1} {c}_{i, j} \ {p}_{i - j} = 1 \end{align} より、 \begin{align} {c}_{i, 0} \ {p}_{i} = 1 - \sum_{j = 1}^{i - 1} {c}_{i, j} {p}_{i - j} \end{align} が成立するので、 が求まることになります。
よって、 であることから、 に対して の値を求め、それをもとに の値が求まることになります。
の値が既に求まっているとすると、 のそれぞれの値は で求めることができます。 その上で、 の値は で求まるため、 の値が求まるまでに の計算量が必要になります。
この の値が求められると、先程の通り人 が最後になる確率は \begin{align} {\sum_{j = 0}^{i - 1}} \ {_{i - 1}\mathrm{C}_{j}} {\left( \frac{1}{2} \right)}^{i-1} {p}_{n - j} \end{align} によって求められるので、すべての人の確率についても で求められます。
ここで、 の値について、 \begin{align} {_{n}\mathrm{C}_{k}} = \frac{n \times \cdots \times (n - k + 1)}{i \times \cdots \times 1} \end{align} であるので、 \begin{align} {_{n}\mathrm{C}_{k + 1}} = \frac{n \times \cdots \times (n - k + 1) \times (n - k)}{(i + 1) \times i \times \cdots \times 1} = \frac{n - k}{i + 1} \cdot {_{n}\mathrm{C}_{k}} \end{align} と整理できます。 これを用いることにより、それぞれの の値を求めることができます。
以上から、大体 の計算量ですべての確率を求めることができ、 なので、これは実行時間制限に間に合うと言えます。
ソースコード
まず、 の値を高速に求める my_pow()
関数を以下のように実装しました。
const ll MOD2 = 998244353; ll my_pow(ll x, ll y) { if (y == 0) { return 1; // 0乗は必ず 1 なので、1を返す } if (y % 2 == 0) { return my_pow(x * x % MOD2, y / 2); // y が偶数のときは (x ^ 2)^(y / 2) の値が答えとなる } else { return x * my_pow(x * x % MOD2, y / 2) % MOD2; // y が奇数のときは x * (x ^ 2)^(y / 2) の値が答えとなる } }
この my_pow()
関数がある状態で、 の値を求める frac()
関数を以下のように実装しました。
ll frac(ll a, ll b) { return a * my_pow(b, MOD2 - 2) % MOD2; // (1 / b) は mod m にて b ^ (m - 2) と同等であることを利用する }
以上の関数が実装された状態で、答えを出力する部分を main()
関数の中に直接実装しました。
int n; ll p[3005], ncr[3005][3005], coef[3005][3005]; // ncr は nCr の値を保存し、 coef は c[i][j] の値を保存する int main() { // 予め 0 から 3000 までの nCr の値を計算する for (ll i = 0; i <= 3000; i++) { ncr[i][0] = 1; // nC0 = 1 であることを初期状態として持っておく for (ll j = 1; j <= i; j++) { ncr[i][j] = frac(i - j + 1, j) * ncr[i][j - 1] % MOD2; // 漸化式通りに値の更新をする } } // 1 から 3000 までの coef[i][j] の値を求めておく for (ll i = 1; i <= 3000; i++) { ll exp = my_pow(frac(1, 2), i - 1); // (1 / 2)^(i - 1) の値を予め計算しておく for (ll j = 0; j <= i - 1; j++) { coef[i][j] = (coef[i - 1][j] + ncr[i - 1][j] * exp) % MOD2; // 漸化式通りに値の更新をする } } // 1 から 3000 までの p[i] の値を順に計算する for (ll i = 1; i <= 3000; i++) { ll res = 1; // 方程式の右辺の値をもつ for (ll j = 1; j <= i - 1; j++) { res -= (p[i - j] * coef[i][j]) % MOD2; // 方程式通りに p[i - j] * coef[i][j] を引いていく if (res < 0) { res += MOD2; // 値が負になった場合は、 998244353 を足して0以上にする } } p[i] = frac(res, coef[i][0]); // p[i] の値を求める } cin >> n; // n の値を入力する // i = 1, ..., n の順に、それぞれ人 i が最後の1人になる確率を求める for (ll i = 1; i <= n; i++) { ll ans = 0; // 答えとなる値 ll exp = my_pow(frac(1, 2), i - 1); // (1 / 2)^(i - 1) の値を求めておく // j = 0, ..., i - 1 の順に値を足していき、確率を求める for (ll j = 0; j <= i - 1; j++) { ll now = (ncr[i - 1][j] * exp) % MOD2; now = (now * p[n - j]) % MOD2; // 現在の j での値を now として求める ans = (ans + now) % MOD2; // ans の値の更新 } cout << ans; // ans の値の出力 if (i == n) { cout << endl; // i = n のときは改行する } else { cout << " "; // i = n でないときは空白区切りで表示する } } return 0; }
感想
時間こそかかりましたが、自力で解くことができてとても嬉しいです。
公式解説 *2 ではもっと簡単に解説されていますが、自分の思いついた方法はこれだったので、このように記載しています。
いざコンテストにてこの問題が出たときに、おそらくこの方法で考えていたので、制限時間に間に合うかの勝負になっていたと思います。