TERRYのブログ

最近は競技プログラミングにお熱。

Sponsored Link

モノグサプログラミングコンテスト(AHC009)解説

モノグサプログラミングコンテスト(AtCoder Heuristic Contest 009)お疲れ様でした。7,547,834,181点で1位です。ratedコンテストで1位を取ったのは初めてなのでとても嬉しいです。

いつものように解説記事を書きます。

f:id:terry_u16:20220327125528p:plain
ビジュアライザ (seed=0)

問題概要

忘れっぽい高橋くんをできるだけ高確率で出社させてね。

atcoder.jp

説明のため、以下の語句・文字を定義しておきます。

語句・文字 意味
操作列 解として出力する文字列
N 地図の一辺の長さ (N=20)
T 操作列の最大長 (T=200)
t ターン数 (1\le t \le T)
i マップの縦方向座標 (0\le i \le N-1)
j マップの横方向座標 (0\le j \le N-1)
p 高橋くんが操作列の t 文字目を忘れる確率 (0.1\le p \le 0.5)

前提知識

  • ダイクストラ法
  • 期待値DP
  • 焼きなまし

方針

焼けるので焼きます。*1

こういう手順を構築する系の問題は解の一部を変更するとその後の動きがガラッと変わるため焼くのが困難なことが多いのですが、今回の問題はビジュアライザを見ても分かるように高橋くんがどんどんぼやけていくため、評価関数が滑らかになって意外と焼けます。

p が大きいほどぼやけやすいため、そういうケースだと焼きなましが比較的強かったのではないでしょうか。逆に p が小さいケースだとぼやけづらく評価関数がガタガタになるため、そういうケースだと貪欲やビームサーチの方が強かったかもしれません。

解法の流れは以下の通りです。

  1. 初期解を生成する。
    1. スタートからゴールまでダイクストラ法で最短経路を求め、操作列を構築する。このとき、辺の重みはランダムに決定する。
    2. 操作列のうちいくつかの文字をランダムにダブらせる。
    3. a.~b.を複数回繰り返し、最も良かったものを初期解とする。
  2. 焼きなましで解を改善する。

f:id:terry_u16:20220327182124g:plain
ビジュアライザ (seed=1 : 68,441,516点)

解説

初期解の生成

焼きなましをするにしてもまっさらな状態から操作列を構築していくのは絶望的なので、まずは初期解を作ります。

今回の問題は焼きなましの結果が初期解に大きく左右される*2ので、できるだけ良い初期解を作りたいです。そのためランダム性を入れて初期解を多数構築し、最も良いものを選ぶことにします。

一旦「高橋くんは確率 pt 文字目を忘れる」という点を無視し、ダイクストラ法でスタートからゴールまでの最短経路を求めます。この時、多様性を持たせるために辺の重みは[10,30]から毎回ランダムに決定します。最短経路が求められたら復元を行い、ひとまずの操作列とします。

操作列が求められましたが、高橋くんがこの操作列の通りに動く確率は、操作列の長さを L として (1-p)^L しかありません。そのため、いくつかの文字をダブらせることでより高確率でオフィスにたどり着けるようにします。例えば高橋くんが確率 0.5 で文字を忘れてしまう場合、全ての文字を 2 個ずつダブらせればそこそこ良い感じになりそうです。*3 この考え方を拡張し、操作列の長さが \mathrm{round}(L/(1-p)) となるように文字をダブらせることにします。ダブらせる文字はとりあえずランダムに選びます。

以上の初期解生成を 0.1 秒間ほど*4 繰り返し、最も良いものを初期解にします。評価基準には問題文で与えられるスコアをそのまま使い、これは期待値DPを使って求めます。

DP[t][i][j]:= t ターン目に高橋くんが座標 (i, j) にいる確率

として t の昇順に求めていけばよいです。計算量は O(LN^2) となります。*5

焼きなましによる改善

残り時間いっぱい焼きなましを行います。比較的シンプルな焼きなましです。近傍は以下の5つを採用しました。採用確率は一様としています。

  • ランダムな位置 t の文字をコピーし、位置 t+1 に挿入する
  • ランダムな位置 t にランダムな文字を挿入する
  • ランダムな位置 t の文字を削除する
  • ランダムな位置 t の文字を別のランダムな文字に変更する
  • ランダムな位置 t の文字を削除し、ランダムな位置  t' に挿入する

スコア計算は初期解生成時のものと同じです。かしこい高速化は思い付かなかったので、頑張って定数倍高速化しました。*6 最終的に 1.1\times 10^4 回ほど回っています。

atcoder.jp

考えたこと・やったこと

コンテスト中に考えたこと・やったことを時系列順に並べました。ここだけ常体です。長いので流し読み推奨。

f:id:terry_u16:20220327184202p:plain
AtCoder Marathon Replay

BFSを書く(~0h41m)

スコアを期待値で計算させるのは珍しい。とりあえずDPすれば求められそう。

壁に向かって進み続ければ収束しそうだけど、ターン数がかかる上に上手く行かないケースも出てきそう。焼けそうなので焼きたいが、スコア計算にO(TN^2)かかるので重い。高速化もパッとは思い浮かばない。まあDPの遷移自体は軽いから数千回くらいは回るだろうし焼いてみるか。

初期解がないとどうしようもないので、とりあえずBFSで最短経路を求めて初期解にする。一旦BFSまで完成させたところで、後ろに適当に文字を付け足して提出。0.56G点で80位。さすがにこれだけじゃ弱いね。

01BFSを書く(~0h50m)

曲がる回数を少なくしたい気持ちになり、曲がったときだけコストがかかる01BFSを実装。明らかに弱くなったのでコミットログだけ残してrevert。

スコア計算を実装(~1h15m)

焼きなましには当然スコア計算が必要なので実装する。公式ツールからコピペもできるけど、高速化が肝になりそうなので自分で書いた。

焼きなましを書く(~1h36m)

とりあえず雑に焼く。近傍は追加・削除・変更・交換の4つ。当初の想定通り4000回くらい回った。5.65G点で29位。上位陣は7G以上出しているのでまだこれだと弱い。順位表を見るとアルゴ勢が結構いるので単純な焼きなましが強そう?

コピー近傍を追加(~2h00m)

疲れたので、休憩がてらローカルテスト環境を整備する。テストを回してみると、スコアが1%も取れていないテストケースがいくつかある。 p が大きくてゴールにほとんどたどり着けず、評価関数が平らになって全然改善されないままになってしまってそう。

よく考えると確率 p で動けなくなるんだから、その分余分に文字をダブらせてあげた方がよさそう。文字をコピーして重複させる近傍を追加して、操作列の長さが100未満の場合はこの近傍だけを選ぶようにした。7.11Gで7位。良さそう。

初期解の時点でダブらせる(~2h26m)

焼きなましに入る前にダブらせてあげても良さそう。100文字までダブらせるのはやりすぎ感があるので、 \mathrm{round}(L/(1-p)) までにする。提出すると7.09Gであまり変わらず。まあやってることはそんなに変わらないか。

ちなみに01BFSを復活させたりもしたが、やっぱり悪化したので戻した。

初期解で乱択ダイクストラ(~2h43m)

BFSの最短距離が一番良い初期解となるとは限らないので、なんとかして多様性を持たせたいと思っていた。雑に辺の重みをランダムにしてダイクストラするのが早そう。とりあえず[1, 3]から乱択。実装すると7.26Gで6位。良い感じ。

このあたりから手元で改善しようがしまいが5分に1回ジャッジに投げ続ける。

高速化(~3h08m)

高速化する価値があるか見極めるため、TLを5倍くらいにしてみたところ1%改善した。ということでスコア計算の高速化に着手する。

条件分岐を減らしたり添え字アクセス回数を減らしたりunsafeで配列の境界チェックを消したりしたところ、10000回くらい回るようになった。提出すると7.35Gで4位。

仕上げ(~4h00m)

残り1時間弱で仕上げを行った。やったことは以下。

  • 挿入近傍の追加
  • 弱い近傍の削除
  • 乱択ダイクストラの範囲調整
  • 焼きなましの温度調整
  • 高速化
  • 乱数ガチャ

残り1分の提出で7.46Gから7.54Gに跳ねて1位を取れてしまい、しばらく何が起こったのか理解できなかった。1つ前の提出で手元では改善しているにもかかわらずスコアが伸びなくてガッカリしていたので、下振れと上振れを両方引いたのかもしれない。

感想

スコアが期待値で計算される問題は初めてだったのですが、運要素が排除できてとても良いですね。ビジュアライザの表示も特に違和感はありませんでした。

短期コンはひさしぶりなので上手く時間配分ができるか心配だったのですが、序盤でとりあえずベース解法を作ってその結果を見ながら改善することができ、かなり理想的な展開にできたと感じています。長期短期と良い感じの感覚を掴みつつあるので、この感覚を忘れないようにしたいです。

今年に入ってから異常なまでに好調で、今回でレートも2797まで上がりました。次でレッドコーダーになりたいですね。

*1:焼きなましができるときは貪欲より焼きなましの方が強いことが多いです。もちろん銀の弾丸ではないのですが。

*2:まず良い本筋の解を作って、取りこぼしたものを焼きなましで拾っていくと捉えると良いかもしれません。

*3:壁にぶつかったときはそれ以上動けなくなるので、もしかしたらもう少し多くても良いかもしれません。

*4:500回ちょっと回りました。

*5:よく分からないという人は公式ツール(ローカル版)のsrc/lib.rsを読むとよいです。Rustですが、雰囲気は掴めるかなと思います。

*6:wataさんによると前と後ろからDPすると高速に差分計算できるらしいです。確かに……。