Skip to content

ARC lessons learned4

zettsu-t edited this page Dec 15, 2024 · 3 revisions

AtCoder Regular Contest lessons learned 4

ARC Div.2にratedで参加します。

ARC 185-最新

ARC 185-A

ARC rated初参加だった。A,B 2完を42:34ノーペナで、初の青パフォである。

コードはこちら

両者の全カードの和は $S = N(N+1)$ である。最後の1ターンに注目する。それ以外は勝敗が確定した状態から後ろに考えれば同じである。

  • $S mod M = 0$ なら、Bobは最後の一枚を引かされるのでAliceの勝ちである
  • $S mod M > N$ なら、Aliceは最後に $1..N < M$ を出し、Bobは最後の一枚を引かされるのでAliceの勝ちである
  • それ以外 $0 < S mod M \leq N$ なら、Bobは最後に $1..N$ を出せるが、その前にカード出すAliceは場のカードの和が $M$ の倍数にならざるを得ない。よってBobの勝ちである。

コンテスト中の思考の過程を追う。

  • Grundy数かと思ったら、このゲームは可能手がすごく多い。
  • $1..N < M$ より、場のカードの和が $M$ の倍数になる=負けるような手をほぼ確実に回避できる。公式解説を読んで再認識したが、カードはそれぞれ異なるので手札が2枚以上あれば確実に負けを回避できる。
  • 最後の1ターンは出すカードに選択肢がない時である。ここで勝敗が決まるだろう。
  • 後手が詰む一つの状況は、すべてのカードが出切ったときで、 $N(N+1) mod M = 0$ である。
  • 後手が最後に出すカードを $S \in 1..N$ とする。 $N(N+1) - S mod M = 0$ なら後手がカードを出す前に、先手は場のカードの和が $M$ の倍数にならざるを得ない。条件を言い換えると、 $N(N+1) mod M \in 1..N$ である。
  • 同様の議論から、上記でなければ、先手は負けず後手が負ける。

上記をまとめると、 $1 \leq N(N+1) mod M \leq N$ なら答えは Bob 、そうでなければ Alice答え である。

今回は理詰めで提出したが、ゲーム木を構築して解を推測することもできる。

// 0 means the first player wins
Num visit_dfs(std::vector<Set>& hands, Num turn, Num accum, Num m) {
    const auto other = 1 - turn;
    if (hands.at(turn).empty()) {
        return other;
    }

    if (hands.at(turn).size() == 1) {
        const auto s = accum + *hands.at(turn).begin();
        return ((s % m) == 0) ? other : turn;
    }

    Vec vs;
    for(const auto& x : hands.at(turn)) {
        vs.push_back(x);
    }

    for(const auto& x : vs) {
        const auto s = accum + x;
        if ((s % m) == 0) {
            continue;
        }

        hands.at(turn).erase(x);
        const auto result = visit_dfs(hands, other, s, m);
        hands.at(turn).insert(x);

        if (turn == result) {
            return turn;
        }
    }

    return other;
}

void solve(std::istream& is, std::ostream& os) {
    const std::vector<std::string> players {"Alice", "Bob"};

    for(Num m{2}; m<=10; ++m) {
        for(Num n{1}; n<m; ++n) {
            std::vector<Set> hands(2);
            Num total = n * (n+1);

            for(Num i{0}; i<2; ++i) {
                for(Num j{1}; j<=n; ++j) {
                    hands.at(i).insert(j);
                }
            }

            const auto r = total % m;
            const auto r_in = (1 <= r) && (r <= n);
            os << "m=" << m << ", n=" << n <<
                ", mod=" << r << ":" << ", 1<=r<=n " <<
                players.at(r_in) << " " <<
                players.at(visit_dfs(hands, 0, 0, m)) << "\n";
        }
    }
}

ARC 185-B

コードはこちら

ゼロサムなので、全要素が平均値付近にそろっている状況を作れる。つまり全要素の差を高々1にする。具体的には、 $A$ の総和が $S$ として、少なくとも $V = \lfloor S / N \rfloor$ 、余り $R = S mod N$ を後ろから1ずつつけるので、 $B = (A_1, .., A_{N-R} = V, A_{N-R+1} .. A_N = V + 1)$ にできる。

後は操作に矛盾がないことを確かめる。 $i=1..N$ について、 $B_i - A_i$ の累積和が負になるような操作はできないので、そのような操作を見つけたら No 、見つからなければ Yes が答えである。

この方法は公式解説1と同じである。ARC早解きで勝てるなら不変量だろうと思っていたら、強運が向いた。

ARC 187-A

ARC rated 2回目は、120分掛けて一問も解けなかった。前回のように上手くはいかない。

コードはこちら

解けるとすればA問題しかなさそうというのは、問題文からも順位表からも分かったので、ひたすら問題に集中して悩みはなかった。

手番の途中で広義単調増加になったら即刻打ち切ってよい。 $N \leq 50$ なので毎回広義単調増加かどうか調べても大したコストにはならない。

隣り合う数 $A_i, A_{i+1}$ を2回入れ替えると、両方とも $K$ 増やせる。これを利用することで最後の数 $A_N$ 以外は広義単調増加にできる。ここまではすぐ思いつくのだが、問題は $A_N$ をどうするかである。 $A_N$ を手前に持ってくると $K$ 増えて堂々巡りになる、という状況以外 No はなさそうだが証明できない。

翌朝気が付いたことに、 $A_i, A_{i+1}, A_{i+2}$ ついて、 $(A_i, A_{i+1})$ , $(A_{i+1}, A_{i+2})$ をそれぞれ $2M$ 回入れ替えると、 $A_{i+1}$$A_{i}, A_{i+2}$ より $KM$ 多くできる。これによって $A_2..A_{N-1}$ にある数を飛びぬけた最大値にできる(このために $N &gt; 2$ である必要がある)。このことはコンテスト中に気が付いたが、活用方法が分からなかった。

ここまでくれば解法が分かる。まず入力が広義単調増加なら何もしない。 $N=2$ なら1回交換して広義単調増加なら答え、そうでなければ No である。

$N &gt; 2$ のときは、 $(A_{N-2}, A_{N-1})$ , $(A_{N-1}, A_N)$$4 \times (5000 &gt; 2500 \geq NK)$ 回入れ替えて増やす。次に $A_{N-1}, A_N$ を入れ替える。こうすると元々の $A_{N-1}$$A_N$ になり、他の数を十分たくさん足しても移動後の $A_N$ を超えないようになる。後は $i=1..(N-2)$ について、 $A_i \leq A_{i+1}$ なら何もせず、そうでなければ $(A_{i+1}, A_{i+2})$$A_i \leq A_{i+1}$ になるまで偶数回入れ替えて $K$ ずつ増やす。最初に思いたときは $A_2$ をバブルソートの要領で $A_N$移動した が、そこまで手間を掛ける必要はない。

コンテスト後に $N=2$ という記述がちらっと見えたが、自力ACとしておく。 $N=2$ のときの固定出力 Yes 1 1 を間違えていたが、最後の2 WA 1 msec だと自分で気が付いた。 $N &gt; 2$ なら常に解があると予想出来るが、そのことを具体的な解法に落とし込めなかった。半分手がかりが見えているこの問題は獲りたかった。

数列が広義単調増加かどうかは、 std::ranges::is_sorted で調べられる。返り値は狭義単調増加ではなく広義単調増加(非減少)かどうかである。

ARC 188-A

ARC rated 3回目、B問題 1完だった。コンテスト中に青diffをACしたのは初めて、26:17ノーペナで初の黄パフォである。最大瞬間18位から逃げ切った。A問題よりB問題の得点が高いことに助けられた。A問題は90分掛けたが全く分からなかった。

コードはこちら

DPだと思ったが、それ以上のことは分からなかった。

よい文字列の定義は、 A,B,C それぞれの出現回数の偶奇がそろっているもの、と言い換えられる。これを8状態として区別できる。公式解説にある通り偶奇が反転している状態を同一視して4状態に減らすことができる、と言うことに気が付かなかった。

ある状態に挟まれた文字列はよい文字列である。これは整数列の累積和について、 $modM$ が等しい区間の和つまり累積和の差は $modM = 0$ であることと似ている。このことに気がつかなかった。ここまで分かれば、状態 $i=0..3$$C_i$ 個のときに、 $C_i(C_i - 1)/2$ 通りの文字列を取れるのでこれを状態について組み合わせると、よい文字列の数になる。この先は公式解説にある通り多重ループを書いて数える。

公式解説にある状態遷移が巧妙である。 $state=1$$A$ を反転させる、 $state=2$$B$ を反転させる、 $state=3$$C$ を反転する代わりに $A,B$ を反転すると解釈できる。それ以外に何も反転していない、という状態 $state=0$ がある。

ARC 188-B

コードはこちら

丁寧に場合分けする。

$N = 2$ なら互いに初期位置を取って Yes である。

$N &gt; 2$ が偶数かつ互いに対称 $K = N/2$ なら、互いに初期位置を取ったらもうできることがないので No である。

$N$ が偶数の場合、 $H = N/2$ と置く。 $K &lt; H$ なら $K = N - K$ と読み替える(左右対称にしただけなので答えは変わらない)。Aliceの1回目は自分と対称の位置 $H$ 、Bobの1回目は直前に塗った頂点と対称の位置 $K + (K - H) = 2K - H$ 、Aliceの2回目は直前に塗った頂点と対称の位置 $N - (2K - H) = 2K - 3H$ を塗る(符号を反転しても答えは変わらない)。以後は原点を読み替えて同じことを繰り返す。よって $(2K-H,N)$ が互いに素かつ $(2K-3H,N)$ も互いに素なら、この繰り返しで全ての頂点を塗ることができるので Yes である。そうでなければ塗れない頂点があるので No である。

$N$ が奇数の場合、 $H = \lfloor N/2 \rfloor$ と置く。 $K &gt; H$ なら $K = N - K$ と読み替える。Alice, Bobの順番に $0, 2K, 4K, ...$ と塗る。 $(2K,N)$ が互いに素なら、この繰り返しで全頂点を塗ることができる。そうでなければ塗れない頂点がある。

以上で答えを網羅できた。公式解説と、 $N$ が偶数の場合の式は異なるが、同じことを言っているはずである。ユークリッドの互除法がそうであるように、2変数の最大公約数は互いの数を足し引きしても変わらないので、きれいな形に変形しなくてもそのままコードにすればACする。

コンテスト中の思考の過程を追う。

  • $N$ と何かが互いに素なら、同じことを繰り返してすべての頂点を塗れる。そうでなければ塗り残しがある。この何かを求めればいいと直観的にすぐわかる。
  • マルチテストケースなので、エッジケースを間違えると大量のWAが返ってきて、根本的に解法が間違っているのかそうでないのか分からない。よって最初にエッジケースを挙げて、それ以外の場合分けを丁寧に行う。
  • 題意を理解するために手を動かす。 $N = 2$ が特殊だと分かる。互いに対称な位置なら詰むのも分かる。
  • $N$ が偶数なら初手は自分の位置か対称な位置しかないので、とりあえず対称な位置に打って、直前に打った点の対称な位置に打つのを繰り返す。 $N$ が奇数の場合も同様に繰り返す。以後原点を読み替えるだけなので周期性があり、これで全頂点を塗れるだろう。
  • $gcd(N,x) = gcd(N,N-x)$ なので、 $K$$N - K$ に読み替えてもよい。これで場合分けが減る。符号を入れ替えても最大公約数は変わらないので、絶対値を取って場合分けを減らす。
  • これ以上場合分けは無いので提出したらACした。最大瞬間順位から想像するに、この時点で多くの方はA問題を解いている途中で、B問題を最初に取り組んだ方は少なかったのかもしれない。

ARC 189-A

コードはこちら

Div.2 になって400点問題が出たのに、120分掛けて一問も解けなかった。ARCはそれほど甘くない。

解になりえないものを除外する。両端は変えられないので $A_1 = 1$ または $A_N = N mod 2$ が成立しなければ答えは0通りである。

リバーシ操作は、両隣が異なる場所 $[L,R]$ を2か所選んで、両隣をそろえることに相当する。ただしこの2か所の間 $[L+1,R-1]$ はそろっていなければならない。ここで $A$ をランレングス圧縮する。

入力 1010... では、幅 $W$ のマス列で連続する両隣が異なる場所は $W-1$ 箇所である。あるラン $[L,R]$ の長さ $W = R - L + 1$ が偶数の場合を考える。 $W$ が偶数なら $W-1$ は奇数である。リバーシ操作は両隣が異なる場所を偶数個しか変えられないので、両隣が異なる場所を0個にするつまりランをそろえることはできない。よって偶数長のランがあれば答えは0通りである。

以下ラン長はすべて奇数とする。ラン長が1のランは何も操作しないので無視する。ラン $i$ をそろえるための操作回数 $C_i$ は必ず $C_i = (W_i - 1) / 2$ 回である。なぜならリバーシ操作によって、両隣が異なる場所は必ず2か所減るので操作回数は操作順序に依らず一定だからである。あるランをそろえる操作列の数 $P_i$ は実験すると $P_i = \prod_{j=1}^{C_i} (2j-1)$ 回とわかる。この証明は後で与える。

異なるランの操作について順列組み合わせを考える。 $S = \sum_i C_i$ とする。どのランについて操作するかの順列組み合わせは、ランを区別しない順列においてラン内の操作順序を無視することに等しいので、 $S! / \prod_i C_i!$ である。これにランの選び方 $\prod_i P_i$ を掛けたものが答えである。

$C$ を固定したときに $P_c = \prod_{j=1}^{C} (2j-1)$ となることの証明を与える。 $C = 1$ のとき $P_1 = 1$ になることは、 101 の真ん中を 1 で塗るリバーシ操作しかないので分かる( 010 を反転しても同じなので、以後 01 を入れ替えた場合は考えない)。 $C + 1$ について考える。両端を除いたマスの数は $2(C + 1) - 1$ 個である。最初に $2(C + 1) - 1$ マスのうち一つを選んでリバーシ操作し、残りの操作回数はそれぞれ $P_c$ 通りなので $P_{c+1} = (2(C_i + 1) - 1) \prod_{j=1}^{C_i} (2j-1) = \prod_{j=1}^{C_i+1} (2j-1)$ である。よって帰納的に示された。

公式解説は上記と同じことを言っている。特に、あるランの操作回数 $P_i$ についてさらっと書いているが、実験するまでこれが分からなかった。マスを反転して $A$ と同じにすることと、マスを反転して $A$ と逆にすることは実は対称なのだが、下記に書くように両者を区別したのが敗因である。

コンテスト中の思考の過程を追う。上記に書いた $P_i$ 以外は合っていたので実に惜しかった。

解になりえないものを除外する。両端は変えられないので $A_i = 1$ または $A_N = N mod 2$ が成立しなければ答えは0通りである。

$A$ をランレングス圧縮する。ラン $[l,r]$ のラン長 $W = r - l + 1$ は奇数でなければならない。そうでないと最終形の操作を実現できない。偶数長のランがあったら答えは0通りである。

ランの要素をすべてそろえることを考える。両端はそろっているので、両端の間に $W - 2$ 個の要素を変える。 $H = (W - 1) / 2$ 個要素は反転しており、 $K = W - 2 - H$ 個の要素は反転していない。

反転方法は二通りある。一つは $H$ 個のマスを順に反転させる。この方法は $H!$ 個ある。もう一つは $K$ 個のマスを反転し、残りを反転させる。 $W-2$ 個の要素について $DP[W-2]$ 通りあるなら、 $K \times DP[W-2]$ 通りある。ここが完全に間違っていて、間違いを直せないままコンテストが終わってしまった。入力例だけは通ってしまうので、間違いに気が付かなかった。

あるラン $i$ について反転操作を $H_i$ 回行う。すべてのランについては $S = \sum H_i$ 回の操作を行うので、異なるランの操作の組み合わせは、 $S! / \prod H_i!$ 通りである。

と思ったのだが、入力例以外が答えが全然合わない。実験コードを15分で書けば分かったことである。しかしそれ以外のことに気を取られ過ぎて、実験コードを書こうと思わなかったのは反省点である。以下にある通り、 10...11... に変える方法を総当たりで求める実験コードはそれほど複雑ではない。

using Pattern = std::bitset<32>;
using Seq = std::vector<std::pair<Num,Num>>;

Num visit_dfs(Pattern current, const Pattern goal, Num width, Seq& seq) {
    Num total {0};
    if (current == goal) {
        for(const auto& [l,r] : seq) {
            std::cout << l << ":" << r << " ";
        }
        std::cout << "\n";
        return 1;
    }

    for(Num left{0}; (left+1)<width; ++left) {
        bool stop {false};
        for(Num right{left+1}; !stop && right<width; ++right) {
            if (current[left] == current[right]) {
                stop = true;
                const auto s = right - left;
                if (s <= 1) {
                    break;
                }

                auto next = current;
                for(Num i{left+1}; i<right; ++i) {
                    next[i] = current[left];
                }

                if (current != next) {
                    seq.push_back(std::make_pair(left+1, right+1));
                    total += visit_dfs(next, goal, width, seq);
                    seq.pop_back();
                }
            }
        }
    }

    return total;
}

void full_search(std::istream& is, std::ostream& os) {
    Num width = 5;
    is >> width;

    Pattern current(0);
    for(Num i{0}; i<width; i+=2) {
        current.set(i);
    }

    Pattern goal(0);
    for(Num i{0}; i<width; ++i) {
        goal.set(i);
    }

    Seq seq;
    os << visit_dfs(current, goal, width, seq) << "\n";
}

int main(void) {
    full_search(std::cin, std::cout);
    return 0;
}

ARC 189-B

コードはこちら

コンテスト数日後に解けた。Diff 1889 の問題を解けたのは、次回に期待が持てる。コンテスト中は15分掛けて分からなかったので見限ったが、分かってしまえば実装は簡単である。

この問題の要点は、コマではなくコマの間隔を移動できることである。コマの間隔を移動を左から右に移動できることを示す。右から左に移動するのは左右対称にするだけなのでやはり可能である。

左の端のコマが原点 $X_1^0 = 0$ になるように、コマの座標から $X_0$ を引いておき、 $D_i^0 = X_{i+1}^0 - X_i^0$ とする。

どのコマがどこに移動したかは気にしない。元のコマがどこにあったかを気にせず、初期配置から $k$ 回操作後のコマを左から( $X$ の昇順に) $X_1^k, ... X_N^k$ と呼び、ここから導出したコマの間隔を $D_1^k, ... D_{N-1}^k$ とする。 $X_2^0, X_3^0$ に対して操作することは、 $D_1^0, D_2^0, D_3^0$$D_1^1=D_3^0, D_2^1=D_2^0, D_3^1=D_1^0$ に変えることである。

$X_2^1, X_3^1$ に対して操作することは元に戻す、 $D_1^1, D_2^1, D_3^1$$D_1^2=D_1^0, D_2^2=D_2^0, D_3^2=D_3^0$ に変えることである。 $X_4^1, X_5^1$ に対して操作することは、 $D_3^1, D_4^1, D_5^1$$D_3^2=D_5^1, D_4^2=D_4^1=D_4^0, D_5^2=D_3^1=D_1^0$ に変えることである。

ここから分かるのは操作1回で、 $D_j$$D_{j-2}$$D_{j+2}$ のどちらか一方と入れ替える、ということである。これはバブルソートの手順そのものである。よって $D_j^0$ において $j$ が偶数番の間隔の集合を任意の順番に並び替えることができる。 $j$ が奇数番の間隔の集合も同様である。

答えを最小にするには、間隔が少ないものほど左側に寄せればよい(間隔が大きいものが左側にあるなら、右にある間隔と入れ替えると答えを小さくできるので)。よって偶数番 $D_{2j}^0$ 、奇数番 $D_{2j+1}^0$ それぞれを昇順にソートする。

ソート後の $D^0$ からコマの位置 $X^{'}$ を累積和で求め、さらに $X^{'}$ の累積和を求めると答えになる。最初に原点の分だけ $NX_1$ を引いたので忘れずに足す。

この方法は公式解説と同じである。

ARC 189-D

コードはこちら

コンテスト中にはちらっと見たが解けそうも無いと諦め、その後4日ほど考えたが、全く答えが分からなかった。スライムは倍化することが計算量を決めるという発想に至らなかった。

スライムを大きくするのではなく、これ以上大きくならないという壁に注目するのは正しかった。しかし計算量の見積もりができず $O(N^2log(N))$ と思ってから先に進まなかった。この問題は ABC 368-G と同じ発想である。ただし ABC 368-G には答えが $10^{18}$ 以下と太字で示す親切さがあり、この問題はそう書いていないので見抜けなければならない。

この問題の2が次のABC 384に出た。こちらは緑diffのとっつきやすい問題だが、ペナルティを出してしまった。