競プロで WA を軽減したい

※十分な調査のもとに書かれた記事ではありません。私個人の経験が主なリソースです。

※いくつかの問題を実例として挙げますが、その問題のネタバレが含まれます。記事で言及する問題(順不同)は以下です。

動機

今後もプログラミング上達を応援して参ります。

目的

  • 競プロで使える一般的なバグ対策/解決策を紹介する。
    • アルゴリズム固有のバグは対象外です。該当アルゴリズムの解説記事を探すとよいと思います。
  • 競プロでの考察ミスを防ぐための対策を紹介する。
    • 典型アルゴリズムの適用の仕方については該当アルゴリズムの解説記事を探すとよいでしょう。

解説

問題文を読むとき

読み残し

人の話は最後まで聞きましょう。問題文も最後まで読みましょう。

例1:

問題: Aizu Competitive Programming Camp 2021 Day 1 C – 橋作り ( https://onlinejudge.u-aizu.ac.jp/services/room.html#ACPC2021Day1/problems/C )

$N-1 \leq M \leq \mathrm{min}(\frac{N(N-1)}{2} , N )$

(辺数) $\leq$ (頂点数) で連結なので、グラフは木またはなもりです。

そして、問題文は最初から読みましょう。

例2:

問題: yukicoder contest 309 F – Travel in Mitaru city 2 ( https://yukicoder.me/problems/no/1647 )

行番号に $x$ 、列番号に $y$ が用いられています。図を描いて考えると、求める数列の条件にある”行”と”列”を逆にして実装してしまう場合があります。

公開されているプログラムを利用するとき

その実装は正しいですか? verify されていますか?

例:

リンク先の記事ではハンガリアン法の実装例が紹介されていますが、 step2 で二部グラフの完全マッチングの存在を判定するときに正しくない貪欲法を用いているため、実装全体が正しくなくなっています。

利用目的はライブラリが正しく動作する範囲を超えていませんか?

例:

verify が $1 \geq n$ でのみ行われていました (verify用問題) 。現在は修正され、この問題は無くなっています ( commit 履歴の該当箇所 ) 。

ライブラリの使い方は正しいですか?

説明書を読みましょう。説明書がない場合はどうしようもないと言いたいですが、それでも使う場合は実際の出力を見て使い方を推測してから使いましょう。

考察するとき

もはやコーディング中ではありませんが……

変数の定義について

コーディング中の問題として後で述べます。

途中式をたくさん書く

書かないと間違いやすいですし、デバッグ中に途中式を再度考えることになります。

その式変形は正しいですか?

例:

AtCoder Beginner Contest 212 H – Nim Counting 解説

等比数列の部分和の公式を知っていますか?私は知っています。 $r=1$ の場合分けに気を付けましょう。(解説のコード中では関数 func が該当箇所です。)

コードを書くとき

コードの文法の間違いはすぐ修正

エディタが間違いを指摘したら、コードの内容をよく覚えているうちに修正してしまいましょう。

変数の意味を確認できるようにする

例:

【競技プログラミング】AtCoder典型90★3問題挑戦するぞ その3 #きりみんちゃん

(動画右上に表示されているチャットを見ると状況を理解しやすいです。)この問題では入力が $N \times N$ の配列で与えられており、動画ではプログラムの一部でその $2$ つの添え字が入れ替わっていたため、いくつかのテストケースで WA となっています。入力を受けた変数 a の意味は問題文で確認できますが、変数 array の意味は簡単に確認できないため、見つけにくいバグを発生させたと考えられます。

外から見てわかる対策として、

  • 変数の意味を確認できるように、考察メモまたはプログラム中のコメントを書く
  • 変数名を工夫する

が挙げられます。

また、動的計画法のテーブルは $2,3,4$ 次元になることが多く、定義の確認が難しくなりやすいです。

動的計画法のテーブルの定義を確認する

動的計画法のテーブルの定義の理解をはじめに深めておくと、はずれ方針や誤った漸化式の導出を防ぎます。

例1:

【競プロ】EDPCを解く

動画中の考察メモの”操作回数の期待値”はあいまいです。この後の実装過程は、貰う・配るや遷移方向を逆向きにして書き換えて実行を試すなど、アドリブ的で非効率的です。対策として、どこからどこまでの操作回数なのか、さらに期待値の表現(全体に対する寄与なのか条件を満たすことを仮定して導かれるものなのか)を一旦決めてから考察します。そのとき初めて解法の正当性を確認したり、適切な漸化式を求めたりできるようになります。

kyopro_friends さんによる解説では次のように定義されています。

$dp[c1][c2][c3]=($1個の皿が$c1$枚、2個の皿が$c2$枚、3個の皿が$c3$枚あるとき、全て食べるまでの操作回数の期待値$)$

https://kyopro-friends.hatenablog.com/entry/2019/01/12/231000

テーブルの定義に従って、寿司 $1$ 個の皿が $c1$ 枚、寿司 $2$ 個の皿が $c2$ 枚、寿司 $3$ 個の皿が $c3$ 枚ある状態を状態 $(c1,c2,c3)$ と表します。すると、

  • 状態 $(c1,c2,c3)$ $(1 \leq c1+c2+c3)$ から操作を始めると、次の操作で
    • $c1\neq 0$ の場合のみ、 $\frac{c1}{N}$ の確率で状態 $(c1-1,c2,c3)$ へ遷移し、それ以降は状態 $(c1-1,c2,c3)$ から操作を始める場合と同じである
    • $c2\neq 0$ の場合のみ、 $\frac{c2}{N}$ の確率で状態 $(c1+1,c2-1,c3)$ へ遷移し、それ以降は状態 $(c1+1,c2-1,c3)$ から操作を始める場合と同じである
    • $c3\neq 0$ の場合のみ、 $\frac{c3}{N}$ の確率で状態 $(c1,c2+1,c3-1)$ へ遷移し、それ以降は状態 $(c1,c2+1,c3-1)$ から操作を始める場合と同じである
    • $\frac{N-c1-c2-c3}{N}$ の確率で状態 $(c1,c2,c3)$ へ遷移し(留まり)、それ以降は状態 $(c1,c2,c3)$ から操作を始める場合と同じである
  • つまり、 $$\left. \begin{array}{l} dp[c1][c2][c3]=1&+&\frac{c1}{N}\cdot dp[c1-1][c2][c3]\\&+&\frac{c2}{N}\cdot dp[c1+1][c2-1][c3]\\&+&\frac{c3}{N}\cdot dp[c1][c2+1][c3-1]\\&+&\frac{N-c1-c2-c3}{N}\cdot dp[c1][c2][c3] \end{array} \right. (1 \leq c1+c2+c3)$$

と考察でき、正しい漸化式を導出しやすいと思います。

一方で、例えば $dp[c1][c2][c3]$ を初期状態つまり状態 $(X,Y,Z)$ から状態 $(c1,c2,c3)$ までの操作回数の期待値としようとすると、初期状態から操作を始めて状態 $(c1,c2,c3)$ を通らない場合があるため、テーブルを厳密に定義するのが難しくなります。しかし、例えば次のようにテーブルを定義すると漸化式を立てられます。

  • $prob[c1][c2][c3]=($ 初期状態から操作を繰り返して状態 $(c1,c2,c3)$ に至る確率 $)$
  • $e[c1][c2][c3]=($ 初期状態から操作を繰り返したときの次の値の期待値 $)$
    • 状態 $(c1,c2,c3)$ に至らない場合 $0$
    • 状態 $(c1,c2,c3)$ に至る場合、状態 $(c1,c2,c3)$ に至るまでの操作回数

AC コード

  • $prob[c1][c2][c3]=($ 初期状態から操作を繰り返して状態 $(c1,c2,c3)$ に至る確率 $)$
  • 状態 $(c1,c2,c3)$ から抜けるための操作回数の期待値が求まるので、その値に $prob[c1][c2][c3]$ を掛けたものの総和が答え

AC コード

例2:

サイシードプログラミングコンテスト2021(AtCoder Beginner Contest 219) H – Candles : Nachia によるユーザー解説

すると、計算量はともかく、$\mathrm{dpL}[l][r],\mathrm{dpR}[l][r]$ は $r-l$ の大きい順に求めることができます。

https://atcoder.jp/contests/abc219/editorial/2669

私が書いたものではありますが、動的計画法のテーブルを定義した後に考察を進める様子の例です。

適切にモジュール化

一般的な言葉でいうと、「困難は分割せよ」です。 Mathenachia では解法解説をいくつかのタイトルで分け、必要な部分だけを読めるように工夫しています。

(重実装の記事しか書いていないのでしょうか……?)

何段階かに分けて考察したものを、分けて実装しないのはもったいないです。分けて実装する場合について、細かい点ですが、

  • 分けた問題の入出力を考える必要があり、考察内容をより正確に理解できる
  • 関数に名前が付くので、デバッグの時に可読性がよい
  • 入力が単純な関数の場合は単体でテストでき、そうでなくてもその関数の時点の出力を取り出してデバッグの参考にできる(その仕組みが自然にできる)
  • 特有の利点がある再帰関数の実装が自然にできるようになる

という利点があります。

考察内容を正確に理解できているなら、実装を段階で分けることはあまりコストにならずに有効な手段になると思います。

場合分けを避ける

場合分けが減ると、デバッグのためのサンプル入力を作る手間が減ります。例えば「円環は $2$ 倍して列にする」があります(円環の切れ目をまたぐ場合分けが不要になります)。

一方、場合分けを減らす際に間違えることもあり、従ってバグのリスクにもなります。

同じ形のコードを書く

例:

yukicoder contest 315 F – Badugi の AC コード

これよりも上で述べた作戦では改善が難しい例です。解答の方針は、条件を満たす二部グラフを列挙し、それぞれについて同型なグラフの個数を二項係数などの積で表現し、足し合わせることです(コード中のコメントによる図は頂点と辺の関係が逆になっています)。各グラフについての計算式を ( $1$ つ以上の辺が隣接する頂点の選び方の個数 ) $\times$ ( グラフ中での役割の割り当て方の個数 ) $\times$ ( 辺の張り方の個数 ) の形式で表し、 $3$ 行で書かれているため、デバッグ中に式の意味を理解しやすくなっています。さらにコメントで図が添えられているため、どのような場合を考えているのかすぐに確認できます。

眼力デバッグをする時

特に経験が必要になると感じていますが、よくある例を知っているだけでも効率が上がるのではないでしょうか。

e869120 さんの記事Motsu_xe 競プロのhogeやfuga でバグの例が列挙されているので、 1 度見ておくとバグ発見の手掛かりにできます。これらの記事で紹介されなかったものでは、

  • 小数が必要な場面で整数型で除算してしまう( Python では逆もあり得ます)
  • 考察用紙から計算式を写すときに間違えている

も典型的です。

コード量が大きくなることを予想した場合、あらかじめ丁寧にコードを書くことも重要です。どうせバグを仕込むのですから(誇張表現です)、それを直す時間を短くするほうが効率的に実装できると考えられます。

例:

yukicoder contest 320 (Stray Bullet) E – [Cherry 3rd Tune D] 無言の言葉 WA コードyukicoder contest 320 (Stray Bullet) E – [Cherry 3rd Tune D] 無言の言葉 AC コード

まとめ

上で述べたように、問題を読むとき、考察するとき、解法を実装するとき、デバッグするとき、すべてにバグの軽減策は存在します。しかし、膨大なパターンの文章を読んで記憶しても、印象に残りません。得られる印象がほぼ最大なのは、自分でバグを起こしてしまったときに、苦しんで修正し、原因や解決方法を分析したときです。経験の印象の強さに従って自然に解決方法を覚えた結果、バグを作りにくくなります。「普通にコードを書いてたら、バグとか生まれなくないですか?」の本質はそこにあるのではないでしょうか。

しかし、今すぐできるバグ予防を考えるとその限りではないので、このような記事も役に立つと思います。

あとがき

競プロ YouTuber の活動により、近くに初心者プログラマがいない状態でも、初心者が競技プログラミングでつまづく実例を見つけることができるので、便利だと思っています。

タイトルとURLをコピーしました