Educational Codeforces Round 23
参加しました、3完でした。Cはもうちょっと早く解けたかったな、という感想ですがなかなか難しいものです。
Dは結構考えたんですが全然分からず、解説見て勉強になりました。典型問題らしいです。
A.o
B.o
C.xo
そういえば今回はpythonじゃなくて個人的に流行のD言語で参加しました。なので掲載コードもD言語です。
ブログタイトル詐欺みたいになってますが、そんなに見てる人いないだろうしいいかな。
pythonのコード期待してる人がもしいたら申し訳ないですが。
A. Treasure Hunt
問題概要
あなたは2次元座標上で(x1, y1)にいる、(x2, y2)に行きたい。
移動手段は謎のポーションで、それを使うと現在位置から相対位置で(x, y), (-x, y), (x, -y), (-x, -y)ずれたところのいずれかの場所に行ける。
このポーションは無限に使えると仮定してよい。(x1, y1)から(x2, y2)に行けるか判定せよ。
感想
現在位置は(0, 0)と仮定しても一般性を失わないので、(0, 0)から(a, b)に行けるか判定する問題だと思うことにします。
とりあえず、aがxの倍数とか、bがyの倍数じゃないと行けなさそうだなっていうのは分かります。実際そうでないときは行けません。
問題はこの条件が必要十分条件であるか、つまり「aがxの倍数かつbがy倍数ならば行ける、そうでないなら行けない」と言えるかということですが、自信が無いですよね。
実際この問題の例2がそれを否定してくれる例になっています。aがxの倍数でbがyの倍数なのにNOらしいです。
こういうのは考えてても仕方ないのでグラフに書いてみるといいと思います。原点から幅優先探索的にポツポツと行ける点を描いてみると何となく法則が見えてきたり来なかったりします。
ちょっと図を作るのがめんどくさいので省略しますが、適当に作ってみると行ける点が互い違い、というか何か周期2でズレてたりするのが分かるかと思います。
周期2が何となく見えるので偶奇が必要になりそう、という発想ができたら、x/aとy/bの偶奇が一致してないと行けなさそうだということも分かって、これが必要十分条件になることが何となく分かります。
なんで?ってことですが、a/xしてb/yするってことは1つのステップで行けるところが(+1, +1), (+1, -1), (-1, +1), (-1, -1)であると考えることに等しくなって、この移動方法は偶奇を保存しながら進むからです。
って後付で説明するのは簡単(それでもちょっと難しい気がする)ですが、最初のサブミットに17分かかってるのでAの割に難しかったです。
というわけでコードです
import std.stdio, std.string, std.conv, std.algorithm; import std.range, std.array, std.container, std.math, std.typecons; int x1, y1, x2, y2; int x, y; void main() { scan(x1, y1, x2, y2); scan(x, y); if (abs(x1 - x2) % x || abs(y1 - y2) % y) { writeln("NO"); return; } int xd = abs(x1 - x2) / x; int yd = abs(y1 - y2) / y; if ((xd - yd) & 1) { writeln("NO"); } else { writeln("YES"); } } void scan(T...)(ref T args) { auto line = readln.split; foreach (ref arg; args) { arg = line.front.to!(typeof(arg)); line.popFront; } assert(line.empty); }
pythonに慣れているとかっこがいっぱいあるのが若干気になりますが、慣れてくると気にならなくなると思ってます。
ここで定義してるscanってのが結構便利で可変長引数で変数が受け取れて、それらの型に合わせていい感じに標準入力から値を読み込んで保存してくれる奴です。他の人が使ってたのを丸パクリしました。
テンプレートってやつらしいですが、正直あんまり良く分かってません。けど便利です。
他のC++のコードとか見るとマクロだらけだったりしますが、D言語はそんなにマクロとか使わなくてもいいので結構スッキリ書けて良い感じです。
B. Makes And The Product
問題概要
要素数nの配列aが与えられる。a[i]*a[j]*a[k]が最小値となるような3つ組(i, j, k)(i < j < k)が何通りあるかを求めよ。
制約
- 3 <= n <= 10^5
- 1 <= a[i] <= 10^9
感想
愚直に全ての3つ組(i, j, k)について試して数え上げようとするとこれはO(n^3)になってしまい明らかに間に合いそうにありません。
でも3つの積a[i]*a[j]*a[k]が最小値になるときってどんなとき?と考えると、a[i] > 0ですから明らかに小さい順に3つ取ってきてそれを掛け合わせたときだな、と分かるとそんなに難しくはなさそうです。計算量に余裕があるのでとりあえずソートしておきます。
ただ、単純に、例えばa[0] < a[1] < a[2] < a[3] < ... みたいになってれば明らかに1通りしかありませんが、a[0] = a[1] = a[2] = a[3] = ... < a[j] < ...みたいになってたりするとちょっとめんどくさそうです。
ここは最小値が何個あるかで場合分けしてしまうのがよさそうです、そんなに数も多くないですから。
ここでaに含まれるa[2]の数をxと置いておきます。理由は見ていけば分かります。
最小値が1個だけのとき
つまりa[0] < a[1] ...のときですがこれは次に小さい要素に依存します。
a[1] < a[2]なら、最後に入るのはa[2]と同じ値であれば組合せとしては何でもよいので答えはxになります。
a[1] = a[2]なら、最後の2つの選び方が何個あるか、ということになり、これは組合せとしてxC2、つまりx * (x - 1) / 2個あります。
最小値が2個だけのとき
このときは、a[0] = a[1] < a[2]となっています、これは最後に入るのがa[2]と同じ値のものであればよいというさっきと同じ論法でx通りになります。
最小値が3個以上あるとき
このとき、a[0] = a[1] = a[2] ...となっています。これは最初の3つの値と同じ値であればどの3つの組合せでもよいので、xC3、つまりx * (x - 1) * (x - 2) / 6通りになります。
というわけで、入力された配列を最初にソートしておいて、次にa[2]の数を数えて、最後は上の場合分けに沿って答えを求めればよい、ということになります。
「最小値を取るのは小さいほうから3つ取ったとき」ということはほぼ明らかですから、後は考えられる全ての場合を考えて答えを求めればよいという感じなので、個人的にはAより簡単かなと思いました。
ソースコードはこんな感じです。
import std.stdio, std.string, std.conv, std.algorithm; import std.range, std.array, std.container, std.math, std.typecons; int n; int[] a; void main() { scan(n); a = readln.split.to!(int[]); a.sort(); long ans, x; x = a.count(a[2]); if (a[0] < a[1]) { if (a[1] < a[2]) { ans = x; } else { ans = x * (x - 1) / 2; } } else if (a[1] < a[2]) { ans = x; } else { ans = x * (x - 1) * (x - 2) / 6; } writeln(ans); } void scan(T...)(ref T args) { auto line = readln.split; foreach (ref arg; args) { arg = line.front.to!(typeof(arg)); line.popFront; } assert(line.empty); }
int型ではオーバーフローする可能性があるのでlong型を使わなければいけないことに注意しないといけませんね。
python使ってたら多倍長整数なのでオーバーフローとかほぼ何も考えずにやってたので、その辺は少し慣れないと大変そうです。今はよくオーバーフローさせてバグを生みだして辛い思いをしています。
ちなみにこの問題はソートしなくても小さい方から3つの値だけ分かればよいので、O(N)で解くこともできるんですがちょっとめんどくさいですね。計算量的にO(NlogN)でも余裕なのでO(N)で解こうとしなくてもいいんですが。
そういえばAtcoderで似たような問題を解いたことがありました。ABC057-Dですね。
D: Maximum Average Sets - AtCoder Beginner Contest 057 | AtCoder
C. Really Big Numbers
問題概要
正整数xがx - (xを十進数で表したときの桁和) >= sであるとき、xをほんとに大きい数と呼ぶことにする。正整数n, sが与えられるので、n以下の正整数にほんとに大きい数がいくつあるか答えよ。
制約
- 1 <= n, s <= 10^18
感想
まず制約を見て総当たりで数え上げはとても無理だということは分かります。
実は最近桁DPというものを覚えたので、それを応用できないかと最初に考えました。18桁の桁和って高々18 * 9程度にしかならないので何かそれを状態に持たせるといいんじゃないか、みたいな。
dp[今まで見た桁数][n未満フラグ][ここまでの桁和]で数え上げるみたいな。でも最終的に桁和がmの整数が~個ありました、みたいな情報だけ分かってもどうしようもないんですよね。元の整数が何だったか分からないと判定できないので。だからこれはダメそうでした。
それで、まあ仕方ないのでとりあえず規則性見つけてみるかと思って、1~30ぐらいで考えると、10増えると(それ - それの桁)が9増えるんですよね。だから(s + 8) / 9 * 10以上の整数は全部オッケーかあ、みたいに考えて適当に出したら不正解でした。
「あれ?」って思ってバグかなあと思ってたんですが、localで適当にpythonで愚直コード書いて見てみると、9増えるのは下1ケタが繰り上がったときだけで、99 -> 100みたいなときは+18でした。さらに999 -> 1000のときは+27だったり。
ここで結構うわーってなってこれを数学的にどう処理したらいいんだ……みたいなこと考えて思考停止して停滞モードになりました。
でも別に数学的に解を求める必要は無いんですよね、これ数学コンテストじゃないから。
(それー桁和)の結果をよく観察してみると、これは単調増加関数になってます。ということは、二分探索が使えるじゃん!って気づくとなんだーって感じで二分探索書いて通しました(ほんとはそっからさらにバグらせてアレな感じでしたけど……)
このとき結構焦ってたので単調性を示せなかったのですが、落ち着いて考えるとそれ自体が+1されたとき、桁上りが無かったら桁和も+1で変化無し、桁上りがあると例えば09->10なら桁和は-8されて、099->100なら桁和が-17されて、となってるので(それー桁和)は桁上りした回数*9増えるので単調増加関数であることが分かりますね。
というわけで無事二分探索で正しく解が求まることが分かりました。
import std.stdio, std.string, std.conv, std.algorithm; import std.range, std.array, std.container, std.math, std.typecons; long n, s; void main() { scan(n, s); if (calcdif(n) < s) { writeln(0); return; } long top, btm, mid; top = n; btm = 0; while (top - btm > 1) { mid = btm + (top - btm) / 2; if (calcdif(mid) >= s) { top = mid; } else { btm = mid; } } long ans = n - top + 1; writeln(ans); } long calcdif(long x) { return x - digitsum(x); } unittest { assert(calcdif(0) == 0); assert(calcdif(1) == 0); assert(calcdif(9) == 0); assert(calcdif(10) == 9); assert(calcdif(13) == 9); assert(calcdif(34) == 27); assert(calcdif(99) == 81); assert(calcdif(100) == 99); assert(calcdif(315) == 306); assert(calcdif(1000) == 999); } long digitsum(long x) { return x > 0 ? digitsum(x / 10) + x % 10 : 0; } unittest { assert(digitsum(123) == 6); assert(digitsum(0) == 0); assert(digitsum(1) == 1); assert(digitsum(100) == 1); assert(digitsum(375826) == 31); assert(digitsum(98) == 17); } void scan(T...)(ref T args) { auto line = readln.split; foreach (ref arg; args) { arg = line.front.to!(typeof(arg)); line.popFront; } assert(line.empty); }
D言語はunittestっていうのが書けて、コンパイルするときに
dmd -unittest main.d
みたいにオプションをつけると実行時にunittestでassertチェックしてくれます。
関数作ってそれがバグってるとどの処理でバグったか分からなくなり面倒なことになるので、関数を作ったらunittestを書く、ってのは結構いいと思います。ただtest作るのも面倒なのでどこまでちゃんとやるかはトレードオフですが……
もちろん向こうの環境で実行されるときはオプション無しでコンパイルされるので書いたunittestは消さなくても大丈夫です(ブログとかに乗せるときは消したほうが見やすいと思いますが、ここではD言語の紹介も兼ねて)
あ、そういえば二分探索の書き方もオーバーフローが起こらないような書き方に直してますね。python感覚で普通に
mid = (top + btm) / 2
って書いちゃうとtop側にどんどん収束していくような場合、topの初期値が大きすぎるとオーバーフローすることがあるみたいですね。
なので
mid = btm + (top - btm) / 2
って書いたほうがオーバーフローが確実に起こらずに良い書き方みたいです。
D. Imbalanced Array
問題概要
要素数nの配列aが与えられる。配列内の区間[a[l], a[l+1], ..., a[r]]に対して、(その区間内の最大値 - その区間内の最小値)をその区間のimbalance valueと呼ぶことにする。
aの全ての区間のimbalance valueの総和を求めよ。
制約
- 1 <= n <= 10^6
- 1 <= a[i] <= 10^6
感想
愚直に全ての区間のimbalance valueを求めて足しあげようとすると最低でもO(n^2)はかかります、これではTLEしてしまいます。
こういうのを考えるときの定跡(?)として、区間に着目するのではなく点に着目する、つまり各要素が何回足し引きされるのか、を考えて上手い事処理するとO(n)とかO(nlogn)で解けそうです。
区間に着目するのではなく点に着目して計算量を落とすという考え方の類題としては例えばこんなのとか、区間じゃなくて部分集合になってますが点に着目するというのは一緒です
http://codeforces.com/contest/810/problem/C
じゃあ各要素が何回足し引きされるのか考えてみます。
例えば足される回数は何回ぐらいでしょうか?
imbalance valueの定義からして、その要素を含む区間でかつその要素が最大値となる区間の個数であることは分かります。
例えば a = [3, 1, 4, 1, 5, 9, 2, 6, 5] だったとして、a[2] = 4が最大値となる区間は何個あるか数えてみると
[4], [1, 4], [4, 1], [1, 4, 1], [3, 1, 4], [3, 1, 4, 1]
で6通りあるので、a[2]は6回足されることが分かりました。
この個数をなるべく速く求めることが目的です。
もうちょっと考えるとその要素を含む区間の個数は(左端の選び方)×(右端の選び方)で求められることが分かります。
a[2] = 4だったら、左端は0, 1, 2の3通りあり、右端は2, 3の2通りあるので3 * 2 = 6通りで、確かに一致しています。
この(左端の選び方)は要素iから左に動かしていってa[i]が最大値であり続けられる境界が分かれば後は引き算で求められることが分かります。右端ならその逆です。
実際はa[i]と同じ要素が出てきたときどうすればいいか考えなきゃいけませんがimbalance valueを考えるときに「複数個あるときは最も左端の最大値を取る」と暗に定義しておけば、左端に伸ばしていくときはa[i]と等しい要素が出てきた時点でアウト、右端に伸ばしていくときはa[i]と等しい要素が出てきてもまだ伸ばせる、と考えればいいことが分かります。
……とまあ、このあたりまではコンテスト中に考察できたのですが問題はその境界をどうやって速く求めるか?っていう話ですよね。ほんとに愚直に伸ばしていったらO(n^2)かかってしまうので意味がありません。
それで何か累積maxとか取って二分探索すればO(nlogn)でいける?みたいに考えてたんですが、普通に間違っており、どうしようもありませんでした。
考察的にはかなりいいとこまで行ってるのでオーバーしてもちょっと考えたりしたんですが、何かセグ木とか使うのかなあ……とか考えていい感じの答えが分からなかったので解説を見ました。
するとどうやらセグ木とかは全然使う必要が無く、スタックを使うだけでいけるらしいです。
各要素iについてそっから左に伸ばしていって初めてa[i]が最大値で無くなるところを格納する配列をleftmax[i]とします。アルゴリズムは以下のような感じです。スタックは添え字を詰めていくために使うものです。
- i = 0, 1, 2, ..., n - 1と左端から順に見ていく
- i = 0のとき、空のスタックに0をプッシュして、leftmin[0] = -1とする
- i > 0のとき、以下の一連の手順を繰り返す
- スタックが空でないとき、そのスタックの一番上に入ってる添え字をjとしてa[j]とa[i]を比較する、もしa[j] <= a[i]ならばスタックからjをポップしてa[j] > a[i]となるかスタックが空になるまで繰り返す
- 上の操作が終わった後、スタックの状態を見て空ならleftmin[i] = -1とし、そうでないなら、スタックの一番上に入ってる添え字jを対応付ける, leftmin[i] = j
- 最後にスタックにiをプッシュして、次の要素に移る
とこういう感じになってます。このアルゴリズムで簡単にleftminが求まってしまいます。計算量は各添え字は1回プッシュされ、高々1回ポップされる程度なので、n回ループでO(n)、スタックの操作でもO(n)なので全体でO(n)の計算量となっています。
右端がどこまで伸ばせるかを知りたいなら配列を逆順に読んでいくものに書き換えるだけです(ただしポップする条件の不等号が微妙に変わってa[j] >= a[i]になることに注意してください、これはimbalance valueの定義をするときに「複数の最大値がある場合は最も左端のものを取る」としたからです)
頭の中で考えただけではちょっとこれでなんで求まるのか分かりづらいですが、小さ目のケースを作って試してみると何をやっているのか分かると思います。
import std.stdio, std.string, std.conv, std.algorithm; import std.range, std.array, std.container, std.math, std.typecons; int n; int[] a; void main() { scan(n); a = readln.split.to!(int[]); int[] leftmin = new int[](n); auto st = Stack!int(n + 10); foreach (i ; 0 .. n) { while (!st.empty && a[st.top] > a[i]) st.pop; leftmin[i] = (st.empty ? -1 : st.top); st.push(i); } st.clear; int[] rightmin = new int[](n); foreach_reverse (i ; 0 .. n) { while (!st.empty && a[st.top] >= a[i]) st.pop; rightmin[i] = (st.empty ? n : st.top); st.push(i); } st.clear; int[] leftmax = new int[](n); foreach (i ; 0 .. n) { while (!st.empty && a[st.top] < a[i]) st.pop; leftmax[i] = (st.empty ? -1 : st.top); st.push(i); } st.clear; int[] rightmax = new int[](n); foreach_reverse (i ; 0 .. n) { while (!st.empty && a[st.top] <= a[i]) st.pop; rightmax[i] = (st.empty ? n : st.top); st.push(i); } long ans; foreach (i ; 0 .. n) { ans += 1L * (i - leftmax[i]) * (rightmax[i] - i) * a[i]; ans -= 1L * (i - leftmin[i]) * (rightmin[i] - i) * a[i]; } writeln(ans); } void scan(T...)(ref T args) { auto line = readln.split; foreach (ref arg; args) { arg = line.front.to!(typeof(arg)); line.popFront; } assert(line.empty); } struct Stack(T) { private: int N, peek; T[] data; public: this(int size) { N = size; data = new T[](N); } bool empty() @property { return peek == 0; } bool full() @property { return peek == N; } void push(T x) @property { assert(!full); data[peek++] = x; } void pop() @property { assert(!empty); --peek; } T top() @property { return data[peek - 1]; } void clear() @property { peek = 0; } int length() @property { return peek; } }
同じことを4回やってるのでちょっと長いですね、関数とか使って上手く書ける人は書いたらいいと思います。不等号とか読み順が逆になったりで微妙に関数化しにくい気がするんですがこの辺も何か上手くできるんですかね。分かりません。
これスマートなアルゴリズムだなーって思ったんですが、実はこれは典型問題らしくて、一般的にStock Span Problemというらしいです。他の人のソースコードをちらちら見てたら一番上にStock Span Problemというコメントがありました。
www.geeksforgeeks.org
見てみると確かに全く同じ問題であることが分かります。勉強になりました。
ちなみにD言語でスタックを使いたいときはわざわざ構造体作らなくてもSListってのがあってそれをスタックとして使うのが普通っぽいんですが、何か知らないですけどSListは結構遅くなっちゃうんですよね。キューも同じでDListってのがあるんですけどやっぱりこれも作ったほうが速くなります。何か公式にはDListのinsertはO(logN)って書いてあるんですけどそのせいですかね、普通はO(1)なんじゃないかと思っているんですけど、ど素人なので分かりません。分かる人がいたら教えてください。
久しぶりにブログ更新しました。
なんかこのブログこどふぉの記事の割合が多い気がします。Atcoderは日本語だし解説記事はもちろん解説動画まで上がるのであんまりブログ書くモチベにならないんでしょうかね。
後単純にAtcoderの問題のほうが難しいのでそもそもブログで解説できるほど習熟してないという面もありますね。