AtCoder最難の伝説問題†World Tour Finals 2019(WTF2019)-E e†にトドメをさす!

みなさんは†AtCoder最難†と呼ばれる伝説の問題、「e」をご存じですか?
AtCoderのレーティング上位たった8人が集まり、究極の難しさの問題6問で競う、「World Tour Finals 2019」の最終問題です。
2020年3月13日現在もたった17人にしか解かれておらず、現在AtCoder史上最難といわれる問題です。
ここまで正解者数が少ない原因として、公式解説で詳細な説明がされておらず、非公式の解説ブログも非常に少ない(現在1件しかないと思われます)ことがあります。
今回は、ここでeの解説を執筆することで、eという問題に少しでも多くの人が挑戦できるようになることを願っています。
問題リンクはこちらです。

前提知識

  • 数Ⅲの積分範囲(基礎のみで大丈夫です)
  • テイラー展開・マクローリン展開の初歩的な知識

これだけで理解はできると思います。他になにか不足している前提知識があるようでしたら、コメント欄に質問してもらえると助かります。

問題概要

1 列に並んだ座席が M 個並んでいる。
ここに M 人の人が順に訪れ、次のような行動をする。

  • 両隣に人がいない席があれば、そのうち1つをランダムに選んで座る。
  • そのような席がなければ、どこにも座らないで立ち去る。

これがすべて終了した後、M 個の座席のうち連続する N 個を選んで写真を撮る。
このとき、この N 個の座席の人の有無が、与えられた文字列と同じようになる確率を考える。
この確率は M に依存するが、M が限りなく大きくなる時の確率の極限を求めよ。
ただし、この確率は、有理数 p,q,r を用いて

p+qe+re2

の形で表せることが証明できるので、この p,q,rmod109+7 で出力せよ。

入力例1

文字列の長さ 1,文字列が X のとき、確率は

1212e2

らしい。
もう、入力例の時点からどうしたらこれが出てくるのか謎ですね。
これが最難といわれる所以でもあります。
幸い、これに関する解説は公式の解説PDFにもあるので、理解はそこまで難しくないはずです。
ここでその証明をしてみましょう。

まず、座席の決め方を少し言い換えます。
0 から 1 までの任意の実数を、重複しないように、また、完全にランダムにすべての座席に割り当てます。i 番目の席の実数を Xi とします。
そして、座りに来た人はこの実数の小さい順に座席を見ていって、もし座れる席があればそこに座る、とします。
選ぶ実数のランダム性を考えれば、こう言い換えても結果は変わりません。
このとき、i 番目の座席に人が座る条件はなんでしょうか。
これは、i 番目から左右へ、 Xj が単調減少となる最長の部分列の長さがどちらも偶数となることです。
例えば、Xi2>Xi1<Xi のとき、Xi1 が先に埋まるため、Xi に人が座ることはありません。
一方、Xi3>Xi2<Xi1<Xi のとき、Xi2,Xi が埋まります。
このように、単調な列では一つ飛ばしで席の埋まり方が決まっていくため、左右どちらからも Xi の隣が埋まらないのが、Xi に人が座る必要十分条件です。
もちろん逆に、座らない条件は左右どちらかが奇数となることです。
これを利用して、最終的にある席が埋まる確率を計算してみましょう。
P(不等式)を、不等式が成り立つ確率とすると、Xi の左側の、最長の単調減少列の長さが偶数になる確率 E は、Xi=x のとき、包除原理を考えて、

E=1P(Xi1<Xi)+P(Xi2<Xi1<Xi)P(Xi3<Xi2<Xi1<Xi)=1x+x22!x33!+=ex

ex のマクローリン展開を考えれば、この変形ができます。
これが左右で成立しないといけないので、左右合わせた確率は e2x となります。
x の値は、閉区間 [0,1] の中からランダムに決まるので、答えは積分して求められます。

01e2xdx=1212e2

これで、入力例1の証明はできましたね。

解法の概要

入力例1での考察を発展させて、問題そのものを解くことを考えましょう。
左から順に Xi の値を決定していくことを考え、DPを行います。
ただし、もちろん文字列に状況が合致する確率は Xi に依存するので、Xi の関数として保持する必要があります。この方法は後に説明します。
ひとまず、DPの遷移式と初期値、答えの計算について考えましょう。

DPの定義

dp[i][j][k][l] を、「i 番目に割り当てた値から、文字列のうち閉区間 [0,i] が状況に合致し、かつ左側への最長の単調減少部分列の長さを 2 で割った余りが j になる確率を求める関数」とします。
ただし、この時右側への最長の単調減少部分列の長さも当然ここには関わってくるので、k,l で、右側への最長単調減少部分列の長さが偶数の時成り立つか、また、奇数の時成り立つかも持っておきます。
実はこれで遷移式が立てられるのです。次の項で詳しく考えましょう。

DPの初期値

ここはそこまで難しくないでしょう。

s[0]='X'の場合

添字については注意深く考えましょう。
左右ともに列の長さは偶数である必要があるので、

dp[0][0][1][0](x)=ex

です。

s[0]='-'の場合

左右どちらかの列の長さが奇数である必要があるので、

dp[0][0][0][1](x)=exdp[0][1][1][1](x)=1ex

です。

DPの遷移式

以下では煩雑さを避けるため、添え字が 0 のときと 1 のとき両方に対して適用されるような場合、[0/1] のように表記します。
場合分けして考えていきます。

s[i]='X' の場合

左右どちらの列の長さも偶数である必要があるので、遷移先は dp[i][0][1][0] のみです。
ここでさらに場合分けをします。
Xi1<Xi のとき、左側が偶数であるには、その一つ前では左側が奇数である必要があります。
また、遷移前の右側は自動的に偶数になります。
この関数が区間 [0,Xi) でどのような値をとるかについて考えることになるので、遷移式は次のようになります。

dp[i][0][1][0]+=0xdp[i1][1][1][0/1](y)dy

Xi1>Xi のとき、遷移後の左側は自動的に偶数になるので、遷移前の左側はどちらでもよいです。
遷移後の右側が偶数であることから、遷移前の右側は奇数でないといけないので、上と同様に、

dp[i][0][1][0]+=x1dp[i1][0/1][0/1][1](y)dy

s[i]='-' の場合

今度は逆に、左右どちらかの列の長さを奇数にしなければなりません。
このとき遷移先として考えられるのは、dp[i][0][0][1]dp[i][1][1][1] の2つです。
前と同様な場合分けをします。
Xi1<Xi のとき、さらに2パターンに分かれます。
遷移後に左が偶数なら、遷移前の左は奇数で、遷移後の右は奇数でなければいけません。
遷移前の右は自動的に偶数になります。

dp[i][0][0][1]+=0xdp[i1][1][1][0/1](y)dy

遷移後に左が奇数なら、右はどちらでもよいです。
遷移前の左は偶数であり、右は自動的に偶数になります。

dp[i][1][1][1]+=0xdp[i1][0][1][0/1](y)dy

Xi1>Xi のときは、遷移後の左は自動的に偶数になり、遷移前の右は偶数、遷移後の右は奇数である必要があります。遷移前の左はどちらでもよいです。

dp[i][0][0][1]+=x1dp[i1][0/1][1][0/1](y)dy

これで遷移式の計算は終わりです。お疲れさまでした。下にすべての遷移式をまとめておきます。

dp[i][0][1][0]+=0xdp[i1][1][1][0/1](y)dydp[i][0][1][0]+=x1dp[i1][0/1][0/1][1](y)dydp[i][0][0][1]+=0xdp[i1][1][1][0/1](y)dydp[i][1][1][1]+=0xdp[i1][0][1][0/1](y)dydp[i][0][0][1]+=x1dp[i1][0/1][1][0/1](y)dy

答えの計算

さて、遷移式と初期値が分かったのでDPの計算はできますが、答えが直接求められるわけではありません。
DPの計算が終わった後、最終的な答えは、座席のさらに右側に続く列の状況がどうなるかも加味して考えなければいけません。
文字列の一番右に割り当てる実数を [0,1] の間からとり、それより右に続く最長の単調減少部分列の長さが、偶数でも奇数でも構わない場合は 1 倍、偶数でなければいけない場合は ex 倍、奇数でなければいけない場合は 1ex 倍するので、最終的な答え A は、次のように計算できます。

A=01dp[N1][0/1][1][1](x)dx+01dp[N1][0/1][1][0](x)exdx+01dp[N1][0/1][0][1](x)(1ex)dx

あとは実装方針について考えていきましょう。

関数の保持

実は、DPの中で出てくる関数は、すべて N1 次以下の有理数係数多項式 p,q と有理数定数 c を用いて、

p(x)+q(x)e+cex

と表せます。
これを積分してみればなぜこうなるかわかると思います。
まず、[0,x] の積分区間で積分してみましょう。

0x{p(x)+q(x)e+cex}dx=[P(y)+e1Q(y)cey]0x=P(x)P(0)+e1{Q(x)Q(0)}cex+c={P(x)P(0)+c}+e1{Q(x)Q(0)}cex

ふう。見ていただければわかるとおり、積分の前と同じ構造になり、中括弧の中の多項式を一つにまとめれば全く同じように処理できますね。
では、次、[x,1] の積分区間でいきましょう。

x1{p(x)+q(x)e+cex}dx=[P(y)+e1Q(y)cey]x1=P(1)P(x)+e1{Q(1)Q(x)}ce1+cex={P(1)P(x)}+e1{Q(1)Q(x)c}+cex

疲れましたね。というのも、TEXモードでこの数式を書き上げるのは結構疲れるのです.
この計算で、2種類の積分区間でどれだけ積分を繰り返しても関数はこの形となることがわかりました。
つまり、DPの際には、2 つの多項式と、ひとつの定数だけを保持すればよいのです。
また、1度の遷移で高々1回の積分をするだけなので、多項式の次数は最終的にも高々 N 次で済みます。
これで、多項式の積分に O(N),DPの遷移に O(N) で、全体で O(N2) でDPの遷移までが完了しました。

あと一息

ここまでで終わった気になっていたそこのあなた。ここからももう1段階計算が要ります。
DP配列から答えを計算するには、次の積分が必要になります。

A=01dp[N1][0/1][1][1](x)dx+01dp[N1][0/1][1][0](x)exdx+01dp[N1][0/1][0][1](x)(1ex)dx

実は、多項式と ex の積の積分は結構難しいです。
もちろん部分積分法を用いて x の次数を落としていけば漸化式で答えが求められますが、初等的でもっと簡単な導出方法が、参考にした他の解説に記載されていたのでそれを使います。
0<|t|<1 に対し、

I=01etxexdx

とする。
積分してべき無限和(等比数列の無限和)に直すと、

I=01e(t1)xdx=[1t1e(t1)x]01=i=0(1et1)ti=i=0(1e1j=01j!tj)ti

一方で、テイラー展開を考えると、I は次のようにも表せる。

I=i=0tii!01xiexdx

テイラー展開の一意性から、両辺を結んで、

tii!01xiexdx=(1e1j=01j!tj)ti

結局、

01xiexdx=i!e1i!j=0i1j!

となる。
これを使って、多項式と ex の積の積分は当然 O(N2) で求められるので、DPパートも合わせて全体で O(N2) の計算量が達成できます。
定数倍は重めですが、実行時間制限は長いので余裕をもって通ると考えられます。
お疲れさまでした。

実装

さて、この問題は実装も結構重いです。
かなりコードは汚いかもしれませんが、自分の実装例を示しておきます。

マクロなど

使ったマクロなどを置いておきます。

#define rep(i,n) for(int i=0;i<(n);i++)
#define REP(i,n) for(int i=1;i<=(n);i++)
#define ll long long

剰余類クラス

これは他の問題でもよく使いますが、剰余類を扱うためのクラスを持っておきましょう。もちろん、有理数も法を定義した上で問題なく扱えます。
ただ、法が素数でない場合除算などができないので、注意しましょう。

constexpr int mod = 100000007;
class modInt {
    ll value;
public:
    modInt() : value(0) {}
    template<typename T>
    modInt(T value = 0) : value(value) {
        if (value < 0)value = -(-value % mod) + mod;
        this->value = value % mod;
    }
    inline operator int()const { return value; }
    inline modInt& operator+=(const modInt& x) {
        value += x.value;
        if (value >= mod)value -= mod;
        return *this;
    }
    inline modInt& operator++() {
        if (value == mod - 1)value = 0;
        else value++;
        return *this;
    }
    inline modInt operator-()const {
        return modInt(0) -= *this;
    }
    inline modInt& operator-=(const modInt& x) {
        value -= x.value;
        if (value < 0)value += mod;
        return *this;
    }
    inline modInt& operator--() {
        if (value == 0)value = mod - 1;
        else value--;
        return *this;
    }
    inline modInt& operator*=(const modInt& x) {
        value = value * x.value % mod;
        return *this;
    }
    inline modInt& operator/=(modInt rhs) {
        int exp = mod - 2;
        while (exp) {
            if (exp & 1)*this *= rhs;
            rhs *= rhs;
            exp >>= 1;
        }
        return *this;
    }
    template<typename T> modInt operator+(T x)const { return modInt(*this) += x; }
    template<typename T> modInt& operator+=(T x) { return operator+=(modInt(x)); }
    template<typename T> modInt operator-(T x)const { return modInt(*this) -= x; }
    template<typename T> modInt& operator-=(T x) { return operator-=(modInt(x)); }
    template<typename T> modInt operator*(T x)const { return modInt(*this) *= x; }
    template<typename T> modInt& operator*=(T x) { return operator*=(modInt(x)); }
    template<typename T> modInt operator/(T x)const { return modInt(*this) /= x; }
    template<typename T> modInt& operator/=(T x) { return operator/=(modInt(x)); }
};
istream& operator>>(istream& ist, modInt& x) {
    ll a;
    ist >> a;
    x = a;
    return ist;
}
modInt modpow(modInt a, int b) {
    if (!b)return modInt(1);
    if (b & 1)return modpow(a, b - 1) * a;
    modInt memo = modpow(a, b / 2);
    return memo * memo;
}

多項式クラス

まずは有理数係数多項式クラスを用意しましょう。積分をする機能もつけておくと便利です。
外から項をいじりやすくするためにメンバは全部publicにしています(本当はメソッドを通すなどするべき)
足し算の機能だけつけておきます。

class Polynomial {
public:
    vector<modInt> vec;
    Polynomial() {}
    Polynomial(const vector<modInt>& vec) :vec(vec) {}
    modInt get(const modInt& x) {
        modInt res = 0;
        rep(i, vec.size()) {
            res += modpow(x, i) * vec[i];
        }
        return res;
    }
    Polynomial integrate()const {
        vector<modInt> res = vec;
        res.emplace_back();
        for (int i = (int)res.size() - 2; i >= 0; i--)res[i + 1] = res[i] / (i + 1);
        res[0] = 0;
        return Polynomial(res);
    }
    Polynomial& operator+=(const Polynomial& rhs) {
        vec.resize(max(vec.size(), rhs.vec.size()));
        rep(i, rhs.vec.size())vec[i] += rhs.vec[i];
        return *this;
    }
};

関数クラス

DPに載せる関数もクラス化します。
Polynomial2つと、有理数の定数ひとつを持っておけばよいです。
ここにも足し算の機能はつけておきます。

class func {
public:
    Polynomial first, second;
    modInt third;
    func() {}
    func(const Polynomial& first, const Polynomial& second, const modInt& third) :first(first), second(second), third(third) {}
    void set(const Polynomial& first, const Polynomial& second, const modInt& third) {
        this->first = first;
        this->second = second;
        this->third = third;
    }
    func& operator+=(const func& f) {
        first += f.first;
        second += f.second;
        third += f.third;
        return *this;
    }
};

定積分メソッド

DPの遷移で使う定積分をメソッド化しておきます。
積分区間は高々2種類なので、bool値で管理します。
前に手計算した積分(下の式)をコードに写せばよいです。

0x{p(x)+q(x)e+cex}dx=[P(y)+e1Q(y)cey]0x=P(x)P(0)+e1{Q(x)Q(0)}cex+c={P(x)P(0)+c}+e1{Q(x)Q(0)}cexx1{p(x)+q(x)e+cex}dx=[P(y)+e1Q(y)cey]x1=P(1)P(x)+e1{Q(1)Q(x)}ce1+cex={P(1)P(x)}+e1{Q(1)Q(x)c}+cex
func dintegrate(const func& f, bool flag) {
    Polynomial p = f.first.integrate(), q = f.second.integrate();
    if (!flag) {
        p.vec[0] = f.third;
        return func(p, q, -f.third);
    }
    else {
        p.vec[0] -= p.get(1);
        rep(i, p.vec.size())p.vec[i] = -p.vec[i];
        q.vec[0] -= q.get(1);
        q.vec[0] += f.third;
        rep(i, q.vec.size())q.vec[i] = -q.vec[i];
        return func(p, q, f.third);
    }
}

グローバル変数定義

入力と前計算の変数をグローバルに定義しておきます。

int n;
string s;
func dp[1010][2][2][2];
modInt fact[1010], inv[1010];

入力と前計算

入力をして、最後の積分の計算に必要な、階乗とその逆元を前計算しておきます。

cin >> n >> s;
fact[0] = 1;
REP(i, n)fact[i] = fact[i - 1] * i;
inv[n] = modInt(1) / fact[n];
for (int i = n - 1; i >= 0; i--)inv[i] = inv[i + 1] * (i + 1);

DP

DPを実行します。実はここはそんなに実装重くないです。

if (s[0] == 'X')dp[0][0][1][0].set(vector<modInt>(), vector<modInt>(), 1);
else {
    dp[0][0][0][1].set(vector<modInt>(), vector<modInt>(), 1);
    dp[0][1][1][1].set(vector<modInt>(1, 1), vector<modInt>(), modInt(-1));
}
REP(i, n - 1) {
    if (s[i] == 'X') {
        dp[i][0][1][0] += dintegrate(dp[i - 1][1][1][0], 0);
        dp[i][0][1][0] += dintegrate(dp[i - 1][1][1][1], 0);
        dp[i][0][1][0] += dintegrate(dp[i - 1][0][0][1], 1);
        dp[i][0][1][0] += dintegrate(dp[i - 1][0][1][1], 1);
        dp[i][0][1][0] += dintegrate(dp[i - 1][1][0][1], 1);
        dp[i][0][1][0] += dintegrate(dp[i - 1][1][1][1], 1);
    }
    else {
        dp[i][0][0][1] += dintegrate(dp[i - 1][1][1][0], 0);
        dp[i][0][0][1] += dintegrate(dp[i - 1][1][1][1], 0);
        dp[i][1][1][1] += dintegrate(dp[i - 1][0][1][0], 0);
        dp[i][1][1][1] += dintegrate(dp[i - 1][0][1][1], 0);
        dp[i][0][0][1] += dintegrate(dp[i - 1][0][1][0], 1);
        dp[i][0][0][1] += dintegrate(dp[i - 1][0][1][1], 1);
        dp[i][0][0][1] += dintegrate(dp[i - 1][1][1][0], 1);
        dp[i][0][0][1] += dintegrate(dp[i - 1][1][1][1], 1);
    }
}

答えの計算

先ほど紹介した積分の計算をもとに、ひたすら計算を書き殴ります。
疲れます。

modInt a, b, c;
Polynomial p;

p = dp[n - 1][0][1][1].first.integrate();
a += p.get(1) - p.get(0);
p = dp[n - 1][0][1][1].second.integrate();
b += p.get(1) - p.get(0);
a += dp[n - 1][0][1][1].third;
b -= dp[n - 1][0][1][1].third;
p = dp[n - 1][1][1][1].first.integrate();
a += p.get(1) - p.get(0);
p = dp[n - 1][1][1][1].second.integrate();
b += p.get(1) - p.get(0);
a += dp[n - 1][1][1][1].third;
b -= dp[n - 1][1][1][1].third;

rep(i, dp[n - 1][0][1][0].first.vec.size()) {
    a += dp[n - 1][0][1][0].first.vec[i] * fact[i];
    rep(j, i + 1) {
        b -= dp[n - 1][0][1][0].first.vec[i] * fact[i] * inv[j];
    }
}
rep(i, dp[n - 1][0][1][0].second.vec.size()) {
    b += dp[n - 1][0][1][0].second.vec[i] * fact[i];
    rep(j, i + 1) {
        c -= dp[n - 1][0][1][0].second.vec[i] * fact[i] * inv[j];
    }
}
a += dp[n - 1][0][1][0].third / 2;
c -= dp[n - 1][0][1][0].third / 2;

rep(i, dp[n - 1][1][1][0].first.vec.size()) {
    a += dp[n - 1][1][1][0].first.vec[i] * fact[i];
    rep(j, i + 1) {
        b -= dp[n - 1][1][1][0].first.vec[i] * fact[i] * inv[j];
    }
}
rep(i, dp[n - 1][1][1][0].second.vec.size()) {
    b += dp[n - 1][1][1][0].second.vec[i] * fact[i];
    rep(j, i + 1) {
        c -= dp[n - 1][1][1][0].second.vec[i] * fact[i] * inv[j];
    }
}
a += dp[n - 1][1][1][0].third / 2;
c -= dp[n - 1][1][1][0].third / 2;

p = dp[n - 1][0][0][1].first.integrate();
a += p.get(1) - p.get(0);
p = dp[n - 1][0][0][1].second.integrate();
b += p.get(1) - p.get(0);
a += dp[n - 1][0][0][1].third;
b -= dp[n - 1][0][0][1].third;
p = dp[n - 1][1][0][1].first.integrate();
a += p.get(1) - p.get(0);
p = dp[n - 1][1][0][1].second.integrate();
b += p.get(1) - p.get(0);
a += dp[n - 1][1][0][1].third;
b -= dp[n - 1][1][0][1].third;

rep(i, dp[n - 1][0][0][1].first.vec.size()) {
    a -= dp[n - 1][0][0][1].first.vec[i] * fact[i];
    rep(j, i + 1) {
        b += dp[n - 1][0][0][1].first.vec[i] * fact[i] * inv[j];
    }
}
rep(i, dp[n - 1][0][0][1].second.vec.size()) {
    b -= dp[n - 1][0][0][1].second.vec[i] * fact[i];
    rep(j, i + 1) {
        c += dp[n - 1][0][0][1].second.vec[i] * fact[i] * inv[j];
    }
}
a -= dp[n - 1][0][0][1].third / 2;
c += dp[n - 1][0][0][1].third / 2;

rep(i, dp[n - 1][1][0][1].first.vec.size()) {
    a -= dp[n - 1][1][0][1].first.vec[i] * fact[i];
    rep(j, i + 1) {
        b += dp[n - 1][1][0][1].first.vec[i] * fact[i] * inv[j];
    }
}
rep(i, dp[n - 1][1][0][1].second.vec.size()) {
    b -= dp[n - 1][1][0][1].second.vec[i] * fact[i];
    rep(j, i + 1) {
        c += dp[n - 1][1][0][1].second.vec[i] * fact[i] * inv[j];
    }
}
a -= dp[n - 1][1][0][1].third / 2;
c += dp[n - 1][1][0][1].third / 2;

答えの出力

答えを出力して終わりです。実装お疲れさまでした。

cout << a << " " << b << " " << c << endl;

提出リンク

提出リンクはこちらです。

おわりに

さて、この記事を読み終えたあなたも、これでeが解けるはずです。
なんといってもAtCoder最難の問題です。きっと感動するに違いありません。
この問題が一人でも多くの人に解かれることを願っています。

参考

Kazune Takahashiさんの記事がたいへん参考になりました。ありがとうございます。
また、公式解説を用意してくださったAtCoder社の方々にも、この場を借りてお礼を申し上げます。

ageprocpp
kaageです。競プロの記事を書きます。
ユーザー登録して、Qiitaをもっと便利に使ってみませんか。
  1. あなたにマッチした記事をお届けします
    ユーザーやタグをフォローすることで、あなたが興味を持つ技術分野の情報をまとめてキャッチアップできます
  2. 便利な情報をあとで効率的に読み返せます
    気に入った記事を「ストック」することで、あとからすぐに検索できます
コメント

@Rho すみませんでした

入力例1の積分でdxが欠けていました by Rho 2020/03/16 02:37

とても参考になりました!!
dpの持ち方ですが、dp[場所][左の偶奇][右の偶奇]でもできませんか?

あなたもコメントしてみませんか :)
すでにアカウントを持っている方は
ユーザーは見つかりませんでした