AtCoder Heuristic Contest 069「Picnic」に参加した。最終結果は 相対スコア 1,792,248,223,137 = 満点の 89.612% / 911 人中 153 位(暫定は 145 位 / 981 人)。10 日間で 18 回提出し、124 の版を作った。

この記事は、前半が本番の解法、後半が会期終了後の話 ── 見つけたバグと、10 日間それに気づけなかった理由になる。

後半で書くのは、問題文が「置ける」と言っているより狭い条件を if に書いていたという話だ。初日に書いて、124 版のうち 123 版でそのまま生き残っていた。条文どおりに緩めた瞬間に、最終提出に対して +1.19%。10 日かけて積み上げた解に、それだけの一手が残っていたことになる。

面白いのは、その 1 行がどの A/B 比較にも現れなかった理由が、計測を信頼できるものにしていた規律そのものだった、というところにある。

取り組み方

このコンテストは Claude Code と組んで取り組んだ。方針を決め、何を測るかを選び、採否を判断したのは私で、実装・計測・結果の整理は Claude Code に任せた。

このコンテストでは、生成 AI の利用規約が定められていた。参加時に適用されたのは 20250616 版で、要点は次のとおりであった(問題文が指定していた指示文を、こちらで訳して 3 点に絞ったもの)。

  • 解答プログラムを実行したあと、その実行結果に基づいて解答・方針・コードを改変したり改善したりしてはならない ── 利用者が新しく明示的に指示した場合を除く。
  • 実行結果・ログ・スコアなどを報告することはできる。ただし報告したらそこで停止し、利用者の新しい指示を待たなければならない
  • ここでいう「解答プログラム」は、この問題を解く目的で作られた(作りかけの)プログラムすべてを指す。人が書いたか生成 AI が書いたかは問わない。

つまりエージェントに「回して勝手に良くしておいて」と言えない。「走らせる → スコアを見る → 次の一手を決める」のループを、毎回人間が閉じる必要がある。結果として、10 日間の判断はすべて私の側に残った。会期が終わったあとの延長戦ではこの規律を外している(そこだけは自律ループで回した)。

この記事も Claude Code が作業ログとソースコードから起草し、私が監修した。数字はすべて測定記録から引いている。

作業の土台にしているのは自作のテンプレート WaTeR-7/ahc-template(CC0)で、new.sh を叩くとコンテストごとの独立リポジトリが生える。掃引・paired 統計・byte 一致照合のスクリプト一式と、AI エージェント向けの作業規約、過去回の知見置き場が入っている。この記事で出てくる測定の作法は、だいたいそこに書いてあるものだ。

問題

N×NN \times N のマス目で表される公園がある。各マスは芝生で、池はクラスタ状に生成される。芝生が連結しているとは限らない。

そこへ MM 個のグループが、到着時刻 SiS_i の昇順に 1 つずつやってくる。各グループは到着時刻 SiS_i・退去時刻 TiT_i・人数 PiP_i・基本支払額 ViV_i を持つ。管理者であるこちらは、到着のたびに

  1. 退去したグループの利用料を受け取り、
  2. (任意)利用中のグループを有料で別の場所へ動かし、
  3. 到着したグループに連結な芝生 PiP_i マスを割り当てるか、断る

を決める。一度受け入れたら TiT_i まで解除できない。断るのは無料で副作用もない。

利用料は領域の形の良さ CC で決まる。CC は、PP マスの領域の境界線の総延長を LL として

C=4PLC = \frac{4\sqrt{P}}{L}

と定義される。利用料 feeCC を用いて

feei=round(Vi×Ci)\mathrm{fee}_i = \mathrm{round}(V_i \times C_i)

となる。CC正方形に近いほど 1 に近く、細長い形や穴のある形ほど小さい。しかも CiC_i滞在中に占めたすべての位置での最小値なので、いったん良い形で置いても、あとで悪い形へ動かすと下がってしまう。

P = 12, L = 14P = 12, L = 26C = 0.990C = 0.533
図1: 同じ 12 マスでも、形が崩れると fee はほぼ半分になる。芝生の面積ではなく「まるさ」を売っている

移動には金がかかる。グループ jj を 1 回動かすごとに支払う額を move_cost と呼ぶことにすると

move_costj=max(round(Vj×R), 1)\mathrm{move\_cost}_j = \max(\mathrm{round}(V_j \times R),\ 1)

である。RR は移動費の高さを決めるケースごとの定数で、ケースによって 100 倍の幅がある(VjV_j の 0.1% で動かせる回もあれば、10% 取られる回もある)。移動は複数のグループを同時に行えて、全員がいったん領域を空けてから配置し直す形になるので、2 つのグループの位置を入れ替えるような手も打てる。

全グループが退去したあとの所持金が絶対スコアになる。所持金は 0 から始まり、受け入れたグループの fee で増え、行った移動の move_cost で減る。負になった場合は 0 に切り上げられる。

abs_score=max(ifeeijmove_costj, 0)\mathrm{abs\_score} = \max\Bigl(\sum_i \mathrm{fee}_i - \sum_j \mathrm{move\_cost}_j,\ 0\Bigr)

評価は相対評価で、各ケース「自分の絶対スコア / 全参加者の最大絶対スコア」を 10910^9 倍した値の和で順位が決まる。

制約は次のとおり。

公園N=50N = 5050×5050 \times 50)。池は最大 900 マス
グループ数M=1000M = 1000
到着・退去0Si<Ti1000000 \le S_i < T_i \le 100000、かつ Si<Si+1S_i < S_{i+1}(到着は時刻順)
人数4Pi1504 \le P_i \le 150
基本支払額0<Vi1080 < V_i \le 10^8
移動コスト係数0.001R0.10.001 \le R \le 0.1
実行時間 / メモリ2 秒 / 1024 MiB
テストケース数暫定 50 / システムテスト 2000

1000 ターンあるので、1 ターンあたりの予算は 2 ms になる。

個人的に、この問題設定はとても良いと思っている。ピクニックの場所取りが細長かったり穴だらけだったりしたら嫌だ、というのは誰でも分かる感覚で、それが C=4P/LC = 4\sqrt{P}/L というたった 1 つの量に落ちている。しかもその量が問題の核になっている ── ただ面積を敷き詰めるだけなら箱詰めの話で終わるところが、「まるく保つ」が入った瞬間に、簡単な「正解」が消えてしまう。まるくしようとすれば取りこぼし、詰め込めば形が崩れ、しかも受け入れた瞬間に取り消せない。どこから手をつけても、これが最適だと言い切れる解法が書けない。 ヒューリスティックの奥深さがよく出た設定だった。

この問題がどういう形をしているか

実装を始める前に、いつも構造を確認する。ここで効いたのは 3 つだった。

① 解全体を保持できない。 未来のグループは(分布しか)分からず、決めたことは撤回できない。焼きなましやビームサーチを掛ける対象そのものが存在しない(過去の判断を並べ直すことができない)。探索の使い所は「1 ターンの中の候補選択」と「パラメータのオフライン掃引」の 2 つに限られる。

② スコアは金額の単純な和。 足し引きするだけで、対数も比も挟まっていない(外側の max\max は、破産しないかぎり効かない)。大きい VV が支配的で、代理指標を作る必要がない(距離のような量を最適化しなくてよい)。

③ 一度の受入は「PiP_i マス × 滞在時間」ぶんの資源を拘束する。 副作用は断片化で、これが後のターンの CC を下げる。ここから、この問題はセル×時間という資源の在庫管理だと読める。次に書く受入制御は、この読みをそのまま式にしたものだ。

Vi=Pi×(TiSi)0.9×2gauss(0,0.8)V_i = P_i \times (T_i - S_i)^{0.9} \times 2^{\mathrm{gauss}(0, 0.8)} という生成式になっている。ここでセル時間あたりの単価dens と呼ぶことにすると

densi=ViCPi(TiSi)    C(TiSi)0.12gauss(0,0.8)\mathrm{dens}_i = \frac{V_i \, C}{P_i \, (T_i - S_i)} \;\approx\; C \cdot (T_i-S_i)^{-0.1} \cdot 2^{\mathrm{gauss}(0,\,0.8)}

である(CC はそのグループに与える領域のコンパクト度)。つまり 2gauss2^{\mathrm{gauss}} の分散が支配する(±2σ で 3 倍以上ばらつく)。単価の高い客だけ拾う余地が大きい、ということでもある。

本番の解法

以下は最終提出(単一ファイル 3000 行ほど)の中身を、1 ターンの処理順に沿って書く。効果の大きい順ではない。

機構を足すときは毎回、「新しい機構を無効化した設定で、前の版と出力が byte 単位まで一致する」ことを確かめてから有効化した。これが通れば、以後の差は新しい機構だけに帰属できる。この規律にはあとで 1 章を割く。

50×50の公園が埋まった様子。色の違う領域が敷き詰められ、黒い池のマスが領域の中に取り込まれている

図2: システムテストの 0009 番のケース(池の塊が 207 個 = 池が散らばった盤面)で、芝生の 84.6% が埋まったところ。色ごとに 1 グループ、濃紺が池。長方形・池を巻き込んだ塊・隙間を縫う自由形が混ざっている

0. 枠組み ── 1 ターン貪欲

構造の①から、枠組みは「各ターンで、そのターンの得点と盤面への影響だけを見て決める」に決まる。以下の 5 つは全部その中の話になる。

1. 受入制御 ── 取れる客を全部取ってはいけない

素直に書くと「置ける限り全部受け入れる」になるが、これは大きく負ける。公園は有限で、安い客に長時間居座られると、後から来る高い客が入らない。

そこでセル×時間に価格 λ\lambda を付ける。到着済みの dens の分布から qq 分位を取って λ\lambda とし、

densi  =  ViCPi(TiSi)    λ\mathrm{dens}_i \;=\; \frac{V_i \, C}{P_i \, (T_i - S_i)} \;\geq\; \lambda

を満たす客だけ受け入れる。ここで dens に入れる CC は「実際に置ける形でのコンパクト度」なので、崩れた形しか作れない客は自動的に弾かれる。分位 qq は過需要の度合いで動かし、閾値には現在の占有率も掛ける(混んでいるほど強気にする)。

分位 qq は 0.7 付近が頂点で、上げても下げても落ちる。ただし占有率の係数と併用すると最適点が 0.5 付近まで下がった ── どちらも閾値を上げる方向なので食い合う。この「併用すると単独の最適値が動く」は、以降も何度も踏むことになった。

さらに終盤には割引が要る。SiS_i は昇順なので、将来の到着が尽きたあとの占有時間には、奪い合う相手がいない。資源量を Pimin(TiSi, ΘSi)P_i \cdot \min(T_i - S_i,\ \Theta - S_i) に置き換えると、素直に効いた(Θ\Theta は 95000 付近が最良で、時間の上限 100000 ではない ── 最後の 5% だけを無料にする強さが正しかった)。

なめらかに割り引く形も試したが全滅した。到着は時間の 99% まで続くので、「終盤」は本当に最後の数 % に限らないといけない。

2. 形をどう作るか

受け入れると決めたら、LL をできるだけ小さくする PP マスの連結領域を探す。3 つの族を、周長の枝刈り付きで順に走らせる。

(a) 長方形+部分行。 PP マスを w×hw \times h の外接矩形に収め、h1h-1 行は幅いっぱい、余りを部分行として置く。周長は 2(w+h)2(w+h) で、部分行を上端に置こうが下端に置こうが、行内のどこにずらそうが変わらないw+hw+h の昇順に列挙し、理論最小 LminL_{\min} に届いたら打ち切る。

下端・左寄せ下端・1 ずらす上端・右寄せL = 16L = 16L = 16どれを選んでも自分の fee は同じ ── 違うのは盤面に残る隙間だけ
図3: P = 13 を 5×3 に収めた 3 変種。薄い枠が外接矩形で、この枠が後半の主役になる

(b) 自由形(blob)。 長方形が LminL_{\min} に届かないターンだけ、任意の連結領域を育てる。空きからの距離が大きい場所に種を 384 個ばらまき、各種から 1 マスずつ「周長の増分が最小の方向」へ伸ばす。池だらけの盤面では長方形の入る場所がそもそも無いので、ここが最初の大きな段差になった。

育ちかけの領域と、次に足せる 3 マス213d = 2 → ΔL = 0d = 1 → ΔL = +2d = 3 → ΔL = −2 ← ここへ伸ばすΔL = 4 − 2d(d = 領域に接している辺の数)
図4: 1 マス足すときの周長の増分は ΔL = 4 − 2d。これを最小にする方向へ伸ばすというのは、要するにへこみから埋めるということだ。種を 384 個ばらまいて、それぞれをこの規則で育てる

種の数は掃引で決めた。16 から 24、32、48 と増やすほど単調に伸び続け、最終的に 384 まで上げている。「候補を増やせば伸びる」ことを最初に確認できた場所でもある。

(c) 箱テンプレート ── 出来上がった解に効いた最大の一手。 これは指摘されて初めて気づいた。「1 マスずつ育てる」は問題の要請ではなく、実装の選択だった。

w×hw \times h の箱を取り、そこから池と利用中のマスを抜き、残った空きが PP より多ければ削って PP マスに揃える。中に池が入っていてよいのがポイントで、局所的に育てる blob では作れない形が作れる。

① 箱を取る② 池と利用中を抜く③ 余りを削る箱 5×5 = 25 マス空き 22 マス(> P)P = 20、周長 28 → 24濃紺 = 池 / 灰 = 利用中の別グループ / 破線 = 削ったマス
図5: 池を含んだままの箱を取り、余りを腕の先から削る。削った 2 マスはどちらも次数 1 なので、周長はむしろ 28 → 24 に下がっている

削る順序は周長の増分で決まる。L=4P2EL = 4P - 2EEE は領域内部の隣接辺の数)なので、次数 dd のマスを 1 つ削ると

ΔL=2d4\Delta L = 2d - 4
削るマスの次数 dd1(腕の先)2(角)3(辺の途中)4(内部)
ΔL\Delta L−2(得)0(タダ)+2+4

次数の小さい順に削れば、障害物が箱の角付近に寄っているかぎり周長は悪化しない。 blob の増分 42d4 - 2d と符号が逆なだけの同じ式で、育てるときは接している辺が多いマスから足し、削るときは少ないマスから削ることになる。

実装する前に天井を測った。同じ seed で「1 ターン内の 1-opt」「ビームサーチ」「箱テンプレート」を比べると

天井(同じ 200 ケース・同じ版に対する伸び)計測にかかった時間
1-opt+0.230%軽い
ビーム 幅 4+1.501%328 秒
ビーム 幅 16+2.003%1568 秒
箱テンプレート+1.958%322 秒

まったく別の 2 つの族が同じ +2.0% に収束した。 これは「いまの空きの中で最良の PP マス連結領域」の真の最適に、どちらもほぼ届いているという状況証拠になる。そして箱は 1/5 のコストでそこへ着く ── ビームを実装する理由が消えた。

ただし箱には落とし穴があった。天井の測定では作業量に上限を置かずに回していたので、そのまま実 TL で有効にすると内部の安全弁(自分で時計を見て打ち切る仕組み。発火すると以後の到着が全部拒否になる)が 100 ケース中 55 で発火して壊滅した。箱に使ってよい作業量(内部のカウンタ)に上限を置き、その値を掃引で決めた。上限を 3 倍にすると弁が出はじめて符号が反転するという断崖があり、その手前に落ち着けている。

3. どこに置くか ── 100% 未来への投資

最小周長の候補は毎ターン 96 個から 939 個ある。どこに置いてもそのターンの fee は同じなので、位置の選択は純粋に後続ターンへの投資になる。最初の版は「左上から見つかった最初の場所」で、この自由度を丸ごと捨てていた。

置いたあとの盤面を測る量を足していった。

中身
露出領域の境界のうち空きマスに面した辺の数。小さいほど隙間を残さない
ΔT\sum \lvert \Delta T \rvert接する利用中グループとの退去時刻の差。近い者どうしを固めるとまとめて空く
潰す窓k×kk \times k が全部空きな始点をいくつ潰すか(k=4k=4
未来の窓τ\tau 以内に帰る客のマスも空きとみなして数えた窓

どれも既存の 2 次元累積和から候補 1 つあたり O(1)O(1) で出る。この 4 つでいちばん大きいのは露出で、単独で残りの 3 つを合わせたのと同じくらいの寄与があった。

窓の kk は掃引が支配的で、PP の中央値が 51(一辺 7 相当)なのに k=4k=4 が最良だった。「大きな窓を守る」より「小さな窓を数多く守る」ほうが効く。

最後の「未来の窓」は、盤面の質を測る量が全部『現在形』だったことに気づいて足したものだ。もうすぐ帰る客のマスは、実質もう空いている。

4. 詰め直す(repack)

それでも LminL_{\min} が取れないターンは、利用中のグループを有料で動かして場所を空ける

ここに、この問題で一番きれいな「思い込み」があった。

CC は滞在中の最小値なので、移動後の周長が今以下なら CC は変わらない。この事実は正しい。ところが実装では、これが LnewLoldL_{\text{new}} \leq L_{\text{old}} という絶対の制約として書かれていた。同じ事実は「悪化してもよい。ただし VjΔCV_j \cdot \Delta C を費用として払え」という価格にもできる。

書き換えたら、repack 族の天井が 2.5 倍になった。実 TL で測っても大きく、その時点での最大の一手になった。しかも時間コストはほぼゼロだった(成立が増えるぶん候補ループが早く打ち切れる)。

同じ族でもう 1 つ効いたのが採算の門で、「新しい feemove_cost − 既存客の fee の悪化」が閾値を超えるときだけ動かす、という当たり前の会計を入れることで、これも大きく伸びた。逆に門を外して repack を増やす方向ははっきり悪化する「もっと動かす」レバーは全部ダメ、「1 回の移動が成立する確率を上げる」レバーは全部効く、という形になった。

5. 赤字は「面積」ではなく「連結」

置けなかったターンを全部リプレイして、そのときの盤面を測った。すると

  • 空きマスは要求 PP5.94 倍ある
  • しかし最大の連結成分は 0.76 倍しかない

公園の盤面。色つきの領域が敷き詰められているが、白い空きマスが全体に散らばって見える

同じ盤面で、空きマスだけを赤く塗ったもの。赤は盤面全体に散っているが、どの塊も小さい

図6: その 1 例。同じケースの 643 ターン目で、P = 150 の客が来て置けなかった瞬間。上は誰がどこに居るか、下は同じ盤面の空きマスを赤く塗ったもの。このターンでは空きが要求の 3.07 倍あるのに、72 個の成分に割れていて最大でも 0.34 倍しかない

面積は足りている。足りないのは連結だ。ここから出てくる手は、既存の repack とは別物になる。

空き成分マージ救済: 1 つも置けなかったターンに、「この客をどければ、隣り合った空き成分がつながって PP に届く」利用中グループを探し、move_cost の安い順に試す。マージした領域に自由形を育て、受入制御を通し、どけた相手を(周長悪化の課金つきで)再配置し、採算が合えば確定する。

調整に一度も使っていない 1200 ケースで、標準誤差の 12.6 倍の差がついた。時間コストは実質ゼロ(発火するのが失敗ターンだけなので)。苦しいケースに集中して効くのも狙いどおりで、いちばん詰まる盤面では大きく伸び、いちばん楽な盤面では一度も発火せず完全に不変だった。

最終提出はシステムテスト 2000 ケースで 1591 ms(制限時間の 79.5%)・失格 0、相対スコアは満点の 89.612%911 人中 153 位だった。

延長戦

会期が終わり、順位が確定したあとで、前提が 2 つ変わった。

① 全参加者 × 全ケースのスコア行列が公開される。 img.atcoder.jp/ahc_standings/ahc069/result.csv1128 人 × 2000 ケースの得点行列が、ログイン不要で置かれる。input.csv にはシステムテスト 2000 ケースの seed 値そのものが入っているので、本番と同一のケースをローカルで再現できる

つまり分母を推定する必要がなくなり、ローカルの評価がそのまま最終順位になる。実際、最終提出と同値の版をジャッジ相当の条件で 2000 ケース回すと 146,067,047,574(89.561% / 154 位相当) で、真値 146,150,983,489(89.612% / 153 位)と 0.06% 差で一致した。以降の測定はそのまま順位として読める。

② 現在地がはっきりする。

自分の絶対スコア合計146,150,983,489
各ケースの最大の合計(1128 人ぶんの上包絡)162,348,430,641
自分 / 上包絡90.02%
首位 / 上包絡99.11%

上包絡は「全員のいいとこ取り」なので誰も届かないが、首位は 99.11% を取っている ⇒ 到達不能分は 0.89% しかない。自分との差 10.1% は、ほぼ全部が実在する伸びしろだった。

per-case で見ると、自分の順位は中央値 159 位(最良 69 位・最悪 295 位)。特定の入力で大崩れしているのではなく、2000 ケースのどれを取っても平凡だった。「苦手な入力を潰す」型の改善に伸びしろが無いことの直接の確認になっている。

条文より狭い if

そこで、赤字の実体を測り直した。受け入れた客が理想の形で置けていたら得られた額と、実額の比を PP の帯別に出す(2 ケースで測った。下は素直なほうのケースの内訳で、池が散らばったほうはどの帯もこれより悪い)。

PP の帯実現率その帯が担ぐ fee の割合
4–201.0008%
21–450.97021%
46–800.87531%
81–1500.66841%

fee の 4 割を担ぐ大口が、理想形の 2/3 しか実現できていない。 小口はすでに理想なので、小口向けのレバーには伸びしろが無い。

大口が崩れる理由は幾何で、P=100P = 100 なら LminL_{\min} を取るのに 10×10 の空きが要る。占有率 74% の盤面にそんな塊はまず無い。では、長方形が置けると判定される条件は本当に正しいか?

ここでソースを読み直して、見つけた。長方形の判定は

// 外接矩形 w×h が全部空きか(ii は空きの2次元累積和なので O(1))
let free_box = |x, y, w, h| { ... == w * h };

だった。しかし図3 のとおり、この形が実際に使うのは PP マスだけで、外接矩形の残り s=whPs = wh - P マスは使わない。問題文が要求しているのは「割り当てるマスが空いていること」だけなので、使わないマスまで空きを要求するのは、条文より狭い

初日に書いた判定問題文が言っていること外接矩形の 15 マスが全部空き実際に使う 13 マスが空き✗ 池が 1 マス入るので却下✓ 置ける(L = 16 で fee も同じ)濃紺 = 池、破線 = この形が使わないマス
図7: 使わないマスにある池 1 つで、置けるはずの位置が捨てられていた。盤面が詰まるほど、この差は大きくなる

条文どおりに緩めると、時間制限を外した 1000 ケースの予備測定で +0.683%、本番と同じ 2000 ケース・同じ時間制限で +1.185% ± 0.057%(20.8se)(この節の数字はすべて、最終提出と同値の版を基準にした同一ケースどうしの差)。

さらに副産物があった。図3 の「部分行を行内でずらす」変種は、本番中に一度実装して 効果なしと結論していた(否定の棚に記録してある)。ところがその否定は「外接矩形が全部空き」という前提のもとでだけ真だった。当時のオフセットは位置評価しか動かさなかったが、fit を緩めるとオフセットは可否そのものを変える。再走させたら、単独 +0.683% が +1.086% に伸びた。

延長戦の 1 日で入れた改善を全部足すと、ジャッジ相当の条件で 89.561% → 90.763%Δlog +1.3614% ± 0.0433、31.5se)、投影順位は 154 位相当 → 129 位相当になった。

なぜ気付けなかったか ── byte 一致の罠

なぜ byte 一致を取っていたか

10 日間で 124 の版を作り、数十本の機構と数十通りの設定を捌いた。この規模になると、スコアが動いたときに「何が動かしたのか」が分からなくなるのが最大のリスクになる。新しい機構を入れたつもりが、同時に触った別の場所が効いていた、という取り違えは、その後の判断を全部汚す。

そこですべての新しいレバーは既定 OFF で入れ、有効化する前に

  1. レバーを OFF にした新しい版と、
  2. 一つ前の版と

を同じ 20 ケースで走らせ、出力が 1 バイトも違わないことを確認する。ここが通れば、以後どんな差が出てもそれは新しい機構だけに帰属できる。実際、10 世代ぶんのレバーを全部 OFF にすると 10 版前の出力にそのまま戻る、という連鎖が最後まで生きていた。

この規律は本当によく効いた。特にリファクタと高速化では、出力が変わらないことがそのまま「壊していない」ことの証明になる ── 実際、この照合の過程で、すでに提出してしまっていた版のバグを見つけている(速度だけを変えた版を照合するときは、壁時計の締切を無効化してから比べる必要がある。速くなった版は、時計が絡む限り必ず出力が変わるからだ)。

この検査を通す方法は、実質ひとつしかない。既存のコードを 1 行も触らず、分岐を足すことだ。既存の経路に手を入れれば、OFF にしても出力は一致しなくなる。

すると何が起きるか。初日に書いた核が、以後の全分岐の共通項として凍る。

この fit 条件は、まさにそうなった。124 版のうち 123 版で 1 文字も変わらないまま、10 日間を生き延びている。そして A/B 比較というのは差分を見る道具なので、全部の版に共通しているものは、原理的にどの比較にも現れない。何十回もの採否判断を積み上げても、この 1 行にだけは一度も光が当たらなかった。

ほかの検査も、揃ってすり抜けた

  • 出力の合法性検査(1 手ずつ、領域が連結か・重なっていないか・周長は合っているかを検査する)は全部通る。当然で、こちらが置いたものは合法だった。「置けたはずなのに捨てた候補」は、出力に現れない。
  • 設計上の選択を書き出したメモにも載っていなかった。載せそこねたというより、この条件を「問題の性質」の語調で書いていたからだ。「矩形は空き矩形にしか置けない」と書けば、それは前提であって選択には見えない。実装の選択を問題の性質として書いた瞬間、それはどの A/B にも写らなくなる。
  • プロファイルや診断カウンタも、置いた後の盤面しか数えていない。「候補が何個あったか」は数えていたが、「本当は候補だったのに弾いた数」を数えるカウンタは、存在しないものを数えることになるので、そもそも思いつかない。

つまり、計測を信頼できるものにしていた規律が、そのまま核を検査の外に出す働きをしていた。これは規律が悪いのではない。規律が守ってくれる範囲の外側に、別の検査が要るということだ。私の場合それは、10 日間で一度もやらなかった「可否を決める if を、問題文の該当箇所と並べて 1 行ずつ読む」だった。

学び ── バグを 10 日間抱えたまま走っていた

前回の MM166 の記事では「問題固有の構造を探索に組み込めたかどうかで差がつく」と書いたが、今回はもっと手前で転んでいる。初日に狭すぎる条件を書いたこと自体は、大したミスではない。問題は、それが 10 日間そのまま走り続けたことだ。

理由は前章のとおりで、つまるところ私のやり方は「差分を良くする」ことに最適化されていて、土台が正しいかを問い直す口が開いていない。差分の改善は手数が無限にあって、しかも一つ一つがそれらしく見えるので、いくらでも時間を吸う。土台の再点検はそうは見えない。だから後回しのまま会期が終わる。

だから対策も「気をつける」ではなく、ループの中に必ず実行する手順として置くしかない。

  • 可否を決める if を、条文と 1 対 1 で突き合わせる。 レバーを何本か試したら 1 回、と回数で決めて実行する。ずれる向きは決まっていて、実装は都合のよい必要条件を足す方向 = 条文より狭い側へずれる。「制約が許す最も強い操作は何か」と「条文が要求する最も弱い条件は何か」は、同じ照合の裏表だ。
  • 核を書き換えるときは、byte 一致ではなく同値検査に切り替える。 旧実装を残し、実走行中に同じ入力で両方を評価して assert!(旧 == 新) を通す。byte 比較では「違う」しか分からないが、これならどの入力でどう食い違ったかが出る。実際、延長戦で位置評価を一般形に書き直したときにこれで食い違いを検出した ── しかも食い違いそのものが発見だった(fit を緩めると、外接矩形の使わないマスに他の客が居られるようになる。旧式はそのマスを露出でしか見ておらず、退去時刻の項に数えていなかった)。
  • 否定的な結果は「前提つき」で記録する。 「A は効かない」ではなく「B が無い状態では A は効かない」と書く。今回、本番の序盤に出した否定が、終了後に前提ごとひっくり返って +0.4% になった。記録していても、前提が変わったときに再監査しなければ死蔵になる。
  • 天井は「その時点の版」に対する量。 機構を入れたら天井も動く。実装せずに温めていた候補の効果は、別の機構を入れたあとに測り直す(ビームの天井 +2.0% は、箱テンプレートを入れたあとには +0.28% しか残っていなかった)。

そしてもうひとつ。「もう手が無い」と感じたときの第一の行き先は、リファクタと監査だった。今回いちばん大きな一手はそこから出ている。手札が尽きたように見えるのは、たいてい探索が収束したからではなく、見ていない場所が残っているからだ。

おわりに ── 体力勝負でもある

会期は 7/31 から 8/10 までの 10 日間で、その真ん中の 8/4 に院試があった。8/2〜8/3 は完全に中断している(そちらの顛末は別の記事に書いた)。

試験が終わってからは、ほとんど PC の前から動いていない。大きな掃引を投げて、終わるまでのあいだにたまに寝て、起きて結果を見て次を投げる、という生活だった。最後のほうはさすがに精神的にきつかった。

長期の AHC は体力勝負の側面がある、というのは聞いてはいたけれど、本当にそうだった。私はたまたま夏休みで、10 日のうち院試を除いた全部を投げ込めた。本業がある人は、いったいいつこれをやっているんだろう。