やおよろず

ゆるふわな内容を書いても良い。と言うか何でも良い。そんな自由度の高いブログをめざします。

ABC333 A-E を解く速度を振り返る

この記事は サステナブルなぴょこりんクラスタ Advent Calendar 2023 の為に書いたものです。

初めに

前回の続きです。
別ネタを準備しようと思ってたんですが間に合わなかったんだすまない。

軽く前回のおさらいをすると、
AtCoder で最近成績伸びないの、難しい問題を解く以前に簡単な問題を解く速度が足らなくない?
という話をしました。

今回は、前回参加した AtCoder ABC 333 を題材にして、
自分が問題を解いたときに何にどのくらいかけていたのか、
強い人はどうなってるのか、の分析をしていきたいと思います。

ABC 333 の自分の成績

これも前回のおさらいですが、ABC 333 の自分の成績は以下でした。

A問題B問題C問題D問題E問題F問題G問題
〇(0:51)〇(6:02)〇(15:29)〇(30:58)〇(44:41)(1×)××

誤答ペナルティ5分を足すと、この成績は5問を 49:41 で解いた成績となります。

1問平均10分、まぁそんなもんかなという気もしますが、これでは青コーダーにはなれんのだ、というのは前回も書いた通りです。

ABC333 A-E 問題振り返り

というわけで、今回はA-E問題の解いた様子を振り返りましょう。
こんなこともあろうかと当日解いていた様子はひっそり録画に収めています

youtu.be

これを今回は youtube 検索して見つけた強い方との内容と見くらべてみましょう。

www.youtube.com

雑に youtube 検索して見つけたもので比較をしましたが、
よく調べたらこのお方(こたつがめさん)はレッドコーダーで、
ABC333 の(レート対象外の人も含めた)総合順位は6位の方でした。やばすぎ。
まぁ、比較対象は高みにあればあるほど自分との差が浮き彫りになるというものなので、かえって良かったかもしれません。

お断り
この分析はこたつがめさんの許可を得ずに作成しています。
基本的に強い人の動きを参考しようというリスペクトの気持ちのみで作成していますが、
もしかしたら不快に思われる内容があるかもしれませんし、何かしら問題がある可能性はあるかもしれません。
問題があれば削除しますのでご連絡ください。
ここまでお断り

A 問題

A - Three Threes

問題要約

N (1~9) が与えられるので N を N 文字つなげた文字列を出力する

自分の解法要約

提出内容:https://atcoder.jp/contests/abc333/submissions/48533969

for 文で出すだけ

自分のタイムライン

コンテスト開始からの時間問題開始からの時間やったこと
00:1400:00ここまでで全問題をタブで開き、A問題を見始める
00:1700:03問題文を把握したのでコードを書き始める
00:3700:23最初のコンパイル
00:4100:27お試し実行開始。N=9 を試す
00:5100:38提出

まぁ51秒で提出してるのでこんなもんですよね。
実際のところ、テストもさぼったし、今回はかなりA問題を解くのは早かったと思います。

強い人のタイムライン

コンテスト開始からの時間問題開始からの時間やったこと
00:0400:00A問題を見始める
00:1900:15vim 起動、動作確認
00:2600:22提出準備開始
00:3300:27提出

比較した感想

vim で解いてる(  Д ) ゚ ゚

コード書き始めの時間は大差ないというのに、提出時間ですごく差が付きましたね。
こういうの vim では x@-pZZ でかけるらしい。みじかっ。
最近 vscode に乗り換えたけど自分は長らく vim を使ってたつもりではあるのですが、 これを真っ新な状態で書けるような覚え方はしていないし、この解法がぱっと出てきて一発で通せるというのには舌を巻きますね。 これが今の上級者の力か…

A問題の教訓:簡単な問題は vim など超ライトな方法で提出することを検討する

B 問題

B - Pentagon

問題要約

ABCDEの頂点からなる正五角形がある。
2つの線分を入力としてこの二つの線分の距離が等しいかどうかを判定せよ。

自分の解法要約

提出内容:https://atcoder.jp/contests/abc333/submissions/48542494

とりえる長さは辺の長さか対角線の長さの二種類しかない。
ABCDE を 012345 の数字と対応付け、入力を受け取ったら頂点の対応する数字の差を取ってみる。
差が1か4だったら辺の長さ、2か3だったら対角線の長さなので、
その結果を見比べれば良い。

自分のタイムライン

コンテスト開始からの時間問題開始からの時間やったこと
00:5300:00問題文読み始め
01:1600:23とりあえず入力受付部分だけ書きはじめる。(その後、少し悩む)
01:48-02:2000:55-01:27頂点を数字に割り当てて差を求めるコードを書く
02:36-03:0101:43-02:08差の値が常に正の数になるように線分の文字列をソート
03:26-04:1602:33-03:23差の値が2or3の時、1or4で決まる変数を設定
04:1603:23コンパイル。エラーをすぐなおす
04:2503:32コンパイル成功
04:2903:36テスト実行。結果がおかしいと気づいて考え始める
04:5103:58printf デバッグを書いて様子を見る。この後変数名ミスに気付く
05:1004:17変数名ミスを修正し再実行。まだおかしい。この後で定数の設定ミスに気付く
05:2404:31直ったのを確認
05:5805:05提出…の前に printf デバッグのコードを消して変な出力が出ないか確認するなど
06:0205:09提出

最初にコード書くまで大体3分半、デバッグに約1分、デバッグきれいにして提出するのに大体30秒、という感じですね。

強い人のタイムライン

コンテスト開始からの時間問題開始からの時間やったこと
00:4200:00B問題を見始める
00:5400:12問題文を理解する+A問題のジャッジ結果を確認する
01:0500:23vim 起動、ほどなくして実装開始
01:3600:54文字列受け取って頂点の数字の差をとり1 or 2 を返す関数を書く
01:4501:03一通りの実装が完成
01:5901:17テストケースでの通過を確認
02:1301:31手入力で追加で何通りかテストを試す
02:1801:36提出

うーん、はやい。すごい。

比較してみた考察

このレベルのコードで上級者は printf デバッグとか当然やってないんだよな。そりゃそう。
文字列を受け取ってから頂点の数字を取って 1 or 2 を書く関数をつくったのが印象的ですね。

int f()
{
   string S;
   cin >> S;
   int a=S[0]-'A', b=S[1]-'A';
   int d=abs(a-b);
   d = min(d,5-d);
   return d;
}

同じことを自分がやったコードが以下です

   string A,B;
   cin >> A >> B;
   sort(A.begin(), A.end());
   sort(B.begin(), B.end());
   int diff = (A[1] - 'A') - (A[0] - 'A');
   int diff2 = (B[1] - 'A') - (B[0] - 'A');
   int len = 1;
   if (diff == 2 || diff == 3)
      len = 2;
   int len2 = 1;
   if (diff2 == 2 || diff2 == 3)
   {
      len2 = 2;
   }

自分はとりあえず入力を変数にぶち込むとこを先に書いてしまいがちなので、
入力から始まる一連の処理を関数にする発想がなくてdiff1とかlen2 とか作りながらコピペコードを書いてしまうのですが、
このあたりを一つの関数にまとめることでバグを減らす効果がありそうです。
事実、自分がバグって困ったのは len2 とかの名前変更ミスとか len2 =1 に化けてたとかですし。
重複コードは廃すべきというのは理論上わかっているところではあるのですが、このまとめ方は思い浮かばなかったなぁ。

また、 値が1,4の時と2,3 の時をまとめる方法がわからなかった僕は diff==2 || diff==3 という格好悪い条件式にしていますが、
d = min(d,5-d) というスマートな書き方をしてるのもすごく賢いですね。

言われたら確かにそれでよいねってなるけれど、これを瞬時に出せる引き出しと速度があるのは強い…。

B問題の教訓:コードの重複を排除するテクはもっと身に着けられるところがありそう

C 問題

C - Repunit Trio

問題要約

10進数ですべての桁の数字が1である整数をレピュニットと呼ぶ。
3つのレピュニットで表せる整数のN番目 (N<=333)に小さいものを求めよ

自分の解法要約

提出内容:https://atcoder.jp/contests/abc333/submissions/48552335

N の数が小さいのである程度の数のレピュニットを配列で持ち、
for文を3つ回して和のリストを作ってソートし、N 番目を取る

自分のタイムライン

コンテスト開始からの時間問題開始からの時間やったこと
06:0300:00問題文読み始め
06:0900:06とりあえず入力を受け付けるコードを書きつつ考える
07:1101:08色々考えるも、同じ値のレピュニットを足してよいと聞いて少し悩む
08:0201:59そうはいってもfor文3つかけるので良いはずだと思う
08:2402:21レピュニット何個用意しよう…8^3 >333 だから8桁かな、と思う(間違い)
08:3802:35`lep[]={1,11,...11111111}` を用意
09:0102:58`for(i=0...8)for(j=0...8)for(k=0...8) v.add(lep[i]+lep[j]+lep[k])` と書き始める
09:4203:39`lep[0]+lep[1]+lep[0]` と `lep[1]+lep[0]+lep[0]` が重複して計算されるのまずいと気づく。とりあえず 8桁は9桁にする
10:2904:26lepの値はスカスカだし適当に重複排除するか…と思うも、 c++ でベクトルの重複排除の書き方を忘れたのでぐぐる
11:0405:011st compile、コンパイルエラーちょいなおす
11:1205:092nd compile、コンパイルできたのでテストを調べる
11:2605:23結果が一致しないので再考。
12:0806:059桁足らない説を予測してprintf してみるも空振りしたように感じる (本来この想定はあっていたが、printf の場所自体が悪かった)
12:3106:28そもそも `for(i=0...N)for(j=i...N)for(k=j...N)lep[i]+lep[j]+lep[k]` でいいから unique とか要らんわ、と思い書き直す
13:0006:57直すも結果がおかしかったり9桁で足りなかったり
14:2008:17sort を勢い余って消してるのを治しつつ lepを10桁で試す
14:5308:50lepを11桁で試す
15:1409:11lepを12桁で試して結果の一致を確認
15:2909:26printf コードをコメントアウトして提出

重複無しで足し算するなら for(i=0...N)for(j=i+1...N)for(k=j+1...N) だよね~ってのは割とよく見るのですが、
重複有りってなっただけでなぜか混乱して右往左往してしまったのがかなり反省点だなぁと思う。

強い人のタイムライン

コンテスト開始からの時間問題開始からの時間やったこと
02:2200:00C問題を見始める
03:0800:46vim 開く、こうか?というコードを書き始める
04:0801:46コードを書いてテストを始める
04:2001:58提出

2分かかってなくて草。

比較してみた考察

そもそも解き方が違うんですよね。
自分の考えた解き方は
https://atcoder.jp/contests/abc333/editorial/7982 で紹介されているものに近いですが、
強い人が採用した方法は
https://atcoder.jp/contests/abc333/editorial/7979 の解き方です。

肝としては レピュニットは1しかない数字なのだから3つ足しても各桁の値は最大で3までにしかならないし、
1の桁は必ず3だしこの数字は単調減少しかない、という話らしい。なるほど。
後者の解き方は自分は思い浮かばなかったんですが1分足らずでこの解法にたどり着くのは強いなぁ…。

それはそれとして。 自分の解き方でも editorial のページにある通りもっとコードは簡素にかけたはずなんですよね。
python では r = [int("1" * (i + 1)) for i in range(L)] と書いてるところを丁寧に手書きで作ってるところとか、
for 文の終端の値をハードコードしてるから逐一丹精込めて変えてるところとかに要領の悪さが出ていそうです
(そもそもそんなに変えるとは思わなかったので size とかとるのさぼったのが裏目った…)

また 11:26 時点で結果が合わないことから解き方を考え直して書き直していますが、
たぶん何かしらバグってるだけで 11:26 時点の解き方でも問題はなかったはずなので、
そういったタイムロスも響いていますね。

後は純粋に考察してからコード書き始めるまでも遅い。

C問題の教訓1:解法を変えると素早く解ける可能性がある
C問題の教訓2:{1,11,111...} みたいな定数のハードコードを極力なくしてきれいに書く努力をしたい

D 問題

D - Erase Leaves

問題要約

木(グラフ)構造が与えられる。
葉を除去する操作のみ認められてるとしたとき、頂点1を削除するまでに最小で何回操作が必要か?

自分の解法要約

提出内容:https://atcoder.jp/contests/abc333/submissions/48565924

頂点 n を削除する最小の操作数を ans[n]とするような配列を考えると

 ans_n = 1+ \displaystyle \sum_{k}^{nの子}ans_k

として再帰的に計算できるので、1 を root とした dfs で適当に計算する。

ただし、頂点1を削除するにあたっては子のうち一つを削除する必要はないので、
 ans_1 - max_{k}^{1の子} ( ans_k ) が答えになる。

また、そもそも 1 が葉であるときは ans[1以外] の値は計算されず上の式の値が変な値となるため、 このケースは特殊処理として1を返す。

自分のタイムライン

コンテスト開始からの時間問題開始からの時間やったこと
15:3000:00問題文読み始め
16:4101:11とりあえず入力を受け付けるコードを書きかける
17:3102:01ans[n] みたいなものがあればよいはずだな…と考える
18:0102:31枝を持つ構造体とか ans[n] とかをグローバル変数にお引越しし、再帰関数 rec で ans を計算しようとする
20:3505:051st compile, 色々エラー
21:4106:11dfs したいのに親もたどってしまう問題を修正。コンパイル通るが結果があわない
22:2106:51 printf デバッグしながら思い悩む
22:3407:04そもそも入力を受け取るところが不完全と気づく
22:5807:28修正完了したがまだ結果が合わない
23:4108:11ans[n] の計算結果が 1+ min(ans[k]) と間違ってることに気づく
24:3409:04ans[n]= 1+ sum(ans[k]) としたら ans[1] が答えにならなくない?と悩む
25:5610:26ans[1]-max(ans[k]) がこたえになるからいいか。と思ってコードを修正
26:3411:04直したがまだ答え合わない
27:5012:20ans[n] = sum(ans[k]) になってて +1 忘れてることに気づいて修正
28:2112:51だいぶよさそうだがテストケース2だけ落ちることに悩む
29:4514:15 ノード1が葉だと不都合があることに気づく。特殊処理を書く
30:5815:28 提出

強い人のタイムライン

コンテスト開始からの時間問題開始からの時間やったこと
04:2500:00D問題を見始める
05:1000:45コードを書き始める
05:4001:15入力受付部分済
05:5201:27あとはこの dfs を書けばOK、という実装ができる
06:0601:41 dfs 書き終わってテストをし始める
06:1301:48 提出

これも2分経ってなくて草

比較してみた考察

今回も似ているようで微妙に考え方が違いますね。 強い方の解法としては、
答えは min(N- (頂点1の隣接点を k としたとき、k から頂点1を経由せずに行ける点の集合(部分木)のサイズ)) といえるから、
この部分木を適宜 dfs で計算すればOK,というものでした。
1を削除した後で残ってる部分木のサイズさえわかればよいよね、的な。

この解法はノード1が葉の時の特殊処理が必要でない解法のため、
自分のようなデバッグに思い悩んでいないのが良いですね。

また、部分木を求める dfs は僕が ans を計算するときに書いた rec 関数と一緒ですが、書き方の練度と短さが段違いですね。

自分のコードだと

vector<int> ans(300000, INT_MAX);
vector<vector<int>> E(300000, vector<int>());

int rec(int cur, int p)
{
   if (E[cur].size() == 1)
   {
      // cout << cur << ":" << 1 << endl;
      return ans[cur] = 1;
   }
   else
   {
      ans[cur] = 1;
      for (int i = 0; i < E[cur].size(); i++)
      {
         if (E[cur][i] != p)
         {
            ans[cur] += rec(E[cur][i], cur);
         }
      }
      // cout << cur << ":" << ans[cur] << endl;
      return ans[cur];
   }
}

ですが、これを強い人に書かせると

vector<int> G[3<<17];
int dfs(int u, int p)
{
    int ch=1;
    for(int v:G[u]) if(v1=p) ch += dfs(v,u);
    return ch;
}

こうなる。

部分木のサイズを求める関数など頻出だからそりゃぁね、といわれたたらそうかもですが、
あまりに早くてビビりました。

  • 葉の場合の場合分けをする必要がそもそもない
  • n=1の結果しか必要ないので ans[N] みたいな変数定義とか詰め込みとかのコードは不要になる
    • 木のサイズがそこそこ大きくてなおかつ dfs(1), dfs(1の子), dfs(1の孫) とかを必要に応じて何度も計算する必要があるならあった方が良いけど。
  • そもそも for 文も int i = 0... みたいな書き方をしない方が速い

あたりがポイントかなぁと思います。

D問題の教訓:解をそのまま ans... みたいなテーブルに安易に入れずに言い換えを考えるとスマートにとける可能性がある
D問題の教訓2:部分木サイズの計算などよくある関数の記述方法はコンパクトに書く習慣づける (嫌ならスニペット化してもよい)

E 問題

E - Takahashi Quest

問題要約

以下のどっちかのイベントがトータルでN回起こる

  • タイプ x のポーションを発見する(とるか取らないか選択できる)
  • タイプ x のポーションで倒せるモンスターが現れる

モンスターを全部倒せなければ -1 を出力せよ。
モンスターを全部倒せるなら、道中の所持ポーション数の最大値を可能な限り小さくしたい。
その値と、各ポーション発見イベントで取る・取らないの行動を出力せよ。

自分の解法要約

提出内容: https://atcoder.jp/contests/abc333/submissions/48576060

イベントを逆順で見ていきつつ greedy で解く。

モンスターが出てきたら、対応するポーションは絶対にどこかで拾わなくてはならない。
道中の所持数の最大値を最も小さくするためには、
引き続きイベントを逆順で見たときに、対応ポーション一番最初に出てきたタイミングで拾えばよい。

そんな感じでポーションを拾う戦略を立てて、倒せないモンスターがいたら -1、
倒しきれるなら道中ポーションの最大値とポーション拾う・拾わないの結果を出力する。
道中ポーションの最大値は逆順で戦略を考えるなかでは計算しずらいので、正規のイベント順でシミュレーションする。

拾わなきゃいけないポーションの集合は重複を含みや挿入・検索・削除に O(N) かけたくないやつなので、
multiset を使って O(logN) で処理する。

自分のタイムライン

コンテスト開始からの時間問題開始からの時間やったこと
30:5900:00E問題開く
32:2101:22とりあえず入力受付部分を書き始める
33:1102:12multiset の削除ってどうかくっけ、をググる
34:5603:57大体わかったのでかきはじめ
37:0606:07逆順にたどって ms と行動選択を計算するとこまで完了。結果出力部書き始める
37:5406:551st compile、いろいろミスってる
38:1607:17コンパイル通るが結果違う(ここまで、最初の1行目にはとりあえず1を返せば良いと思っていた)
38:3107:32ポーション拾う数を答えるのか?と思ってコードを書く
39:1207:52書いたけどそれだとサンプルケースおかしくない?と気づく
40:0608:13出すべきは道中所持ポーション数の最大と気づく
41:1609:07ポーション取る取らない戦略は今のコードのままでよさそうと判断。道中所持ポーション数の最大値の出し方考える
42:3610:17愚直に最初からシミュレーションするコードを書き始める
42:5511:37コンパイル通ってテスト結果見る。拾う・拾わない戦略の結果が異なるが何種類か解がある代物なので放置
42:5511:56提出
42:5611:57F問題開く
43:0112:02とりあえず入力受付部分かく
43:1212:13E問題が Wrong Answer になってると気づく
43:2612:27サンプルでも落ちてる…だと…?
43:4912:50ポーション取る取らないの出力するとこ、モンスター登場ターンを飛ばすのを忘れて0を余計に出力してたと気づく
44:4113:42修正と再テストを完了して再提出

問題文の理解が中途半端なままコードを書いてめちゃくちゃもてあそばれてる感がありますね。

強い人のタイムライン

コンテスト開始からの時間問題開始からの時間やったこと
06:2700:00E問題を見始める
07:4801:21コードを書き始める
08:5702:30上から順にイベントを見ていき、積むパターンの出力まで終える
09:0002:33実装を書く
09:4403:17 結果出力あたりかく
10:0003:33 コンパイル通ってサンプルを使ったテストを始める
10:5004:23 提出

リアルタイムにみててどういう解法してるのか理解できなかったけど爆速で解いてて笑った。

比較してみた考察

今回も解き方は微妙に異なっていますね。

僕はイベントを逆順に見ることで「モンスターを倒すときに必要なポーションは最後に獲得する」を実現していましたが、
強い方の解法ではイベントを普通に上から順に処理しながら、各タイプのポーションがいつにあるかを vector で記録し、
必要となったら pop_back() でモンスターの登場前に最後にとれるポーションを使う、という実装をしていました。
また、この解き方だとモンスターが出るタイミングでポーションを拾う箇所が確定するので
最大所持数のカウントに難ありかとも思われましたが、imos 法(説明略)を使って計算を行っているようです。
もっとも、どちらにせよ for 文は2回書かないという点ではどっちも同じですが…

この辺りはまぁ…解く速度の観点ではそんなに違わないかな…という気もしますが、
multiset を使わないだけ計算量の観点では vector を使った方が強そうですね。

E 問題についてはどちらかといえば、
問題文を十分に読まずに勘違いして先に進んで手間取った所が響いたかなぁ、という気がします。

あとはなんかこう… imos 法とかを記述する練度が半端なくて
ノータイムでさらさら書いてるのが強いなぁと思いました。

E問題の教訓:問題文の誤読に気を付ける  

終わりに

というわけで今回は、 ABC 333 の A-E 問題を題材にして自分のタイムラインと強い人のタイムラインを比較し、
それぞれの問題を見比べた教訓を書き並べてみました。

ここで得られた教訓みたいなのは頭ではわかってはいる気でいたのですが、
実際に強まった人のプレイングを見るといかに実践するのが難しいのかが身に沁みますね。

たぶん今後も全盛期みたく ABC に毎回参加じゃ!みたいな勢いは出せないとは思うのですが、
参加は継続しながら、適度な研鑽の仕方を模索していきたいなぁと思います。