英語の壁
こんにちは。トコロテンです。
今回は、精進の話や得た知見の話ではなく、自分の悩みのようなものを書きたいと思います。
私は幼い頃(小学4年生くらい)からずっと情報の分野の魅力に惹かれて、大学生になる今まで情報に付き合ってきました。
情報という分野の歴史はまだまだ浅いですが、驚くほど奥が深く、それなのに身近で、大変面白い分野だと思います。
ただ、情報の世界にいると次々に新しい技術が生まれて変化について行くのが大変です。常に学び続けなければなりません。
最新の情報はインターネットで即座に広まり、基本的には誰でも見ることができるのですが、ここで問題があります。
情報の一次ソースが、多くの場合、英語であるということです。
私は、高校生の頃にしっかり英語を学ばなかったこともあって英語を満足に読み書きすることができません。
自分があまり知らない分野の英語が読めないのはもちろんのこと、自分が最も得意とする情報の分野で使われている英語でさえ読めないのは致命的な問題です。
大学に入って1年生の春に初めて受けたTOEICの点数も405点と壊滅的なものでした(これによって科目選択における人権を失いました)
これまでは日本語の情報だけで十分に事足りていましたが、自分のやりたいことが増え、さらに専門性が高くなるにつれて、日本語で得られる情報だけでは圧倒的に不足していると感じるようになりました。
Qiitaのなんちゃって技術記事を読んでる暇があったら公式ドキュメントに目を通した方が100倍有意義であるのは周知のことだと思いますが、公式ドキュメントもやはり多くの場合が英語で書かれており、今の私はGoogle翻訳に頼ってなんとなくで読むことしかできません。
インターネット上には私が必要とする情報が確かに存在していて、私でも自由に閲覧することができます。
しかし、いざそれを見てみても、自分の目の前にある情報が何を意味してるのかわかりません。悔しくて仕方がありません。
このままでは、これがキャップとなって頭打ちになるということがよくわかります。
もちろん、中学、高校、大学としっかり英語の授業は受けてきましたが、それだけでは全然足りないことはわかっていました。
また、英語がとても重要であるということも幼い頃から情報の世界に入り浸っている私は百も承知でした。
しかし、いつかしっかり勉強するだろうと思いながらもう大学2年生に上がってしまいました。
実際に使えなければ意味がないのに「英語がしっかり使えるようになった」という結果ではなく、「授業でしっかり勉強した」という過程だけに満足してしまい、やっただけ、実際には全然わからないということに目を瞑っていました。
こんな状態でも、恥ずかしながら「一流のエンジニアになりたい」という思いを幼い頃から常に持ちながら生活してきました。
Twitterを見ていると同世代のすごいエンジニアの方々がどんどん成長して、自分との差が開いて行くことに焦りを感じます。
もちろん、技術力で人と競うことが目的ではないのでこの焦りは、特定の人物に「◯◯に勝ちたい!負けたくない」という競争欲からくる焦りではありません。
他の方が自分に必要なものをしっかりと見定め、獲得して自分を高めている中、私は自分に本当に必要な物に目を瞑っていたということからくる焦りです。
精進することだけが全てではないことは分かっているし、技術力だけが私を測る指標でないこともわかります。
しかし、私が好きで好きでずっとやってきた情報という分野は、私が自分を測る際に一番大事な要素です。
一回、真剣に英語に取り組んで、まだ私の知らない情報の世界に足を踏み入れてみたいと思います。
AtCoder Beginner Contest 115 D - Christmas
問題
考察と解法
メリークリスマス!………ではないですね。 なぜクリスマスにハンバーガーなのかはわかりませんがとりあえず問題と真剣に向き合うことにします。
まずはじめに、ハンバーガーの生成規則について確認しておきます。
ハンバーガーは'P'
と'B'
からなる文字列で表現されます。
また、ハンバーガーにはレベルがあり、レベルLハンバーガーを表す文字列は以下のように再帰的に定義されています。
+
演算子は文字列の結合を表しています。
$$ h(L) = \left\{ \begin{array}{} "P" & (L =0) \\ "B" + h(L - 1) + "P" + h(L - 1) + "B" & (L \ge 1) \end{array} \right. $$
問題は、この定義から生成されるレベルバーガーの文字列の先頭からX文字目(1-indexed)までに含まれる'P'
の個数を数えるといったものです。
まずは、全探索する場合を考えます。
これは、レベルNバーガーの文字列を実際に生成して、その文字列の先頭から順にX文字目まで各文字を調べて'P'
の個数をカウントすればいいです。
そこで気になるのは、レベルNバーガーの文字列の長さです。なぜなら、全探索を行う場合、文字列を生成後に各文字を調べていくだけでも計算量が文字列の長さに比例するからです。
文字数が多すぎる場合、全探索は行えないことになります。したがって、全探索を行う前にレベルNバーガーの文字列の最大長について考えます。
レベルLバーガーの文字列長を表す関数はの定義より以下のようになります。
$$ |h(L)| = \left\{ \begin{array}{} 1 & (L =0) \\ |h(L - 1)| \times 2 + 3 & (L \ge 1) \end{array} \right. $$
このままではN=50(最大値)であるときのがわからないので漸化式から一般式を求めます。
$$ \begin{align} |h(L)| &= 2|h(L - 1)|+3 \\ &= 2(2|h(L - 2)|+3)+3 \\ &= 2^2|h(L - 2)| + 2^1 \cdot 3 + 2^0 \cdot 3 \\ &= 2^3|h(L - 3)| + 2^2 \cdot 3 + 2^1 \cdot 3 + 2^0 \cdot 3 \\ &= \vdots \\ &= 2^L|h(L - L)| + 3(2^0 + 2^1 + ... + 2^{L - 1}) \\ &= 2^L + 3(2^L - 1) \\ &= 2^L + 3 \cdot 2^L - 3 \\ &= 2^{L+2} - 3 \end{align} $$
が得られたのでを求めてみるととなることがわかり、全探索では一生計算が終わりません。 レベルNバーガーを生成してはいけません。あまりにも分厚すぎて手に負えません。悪あがきはやめて大人しく問題と真剣に向き合うことにします。
よく考えてみると、この問題で重要なのは'P'
の個数だけであるため、レベルNバーガーの具体的な文字列は知る必要がありません。
レベルNバーガーの中には'P'
と'B'
、さらにレベルN-1バーガーが含まれていることに着目します(それはそう)。
ここでレベルNバーガーの先頭からX番目までの文字の中の'P'
の個数を表す関数をとすると次のようになることがわかります。
$$ f(N, X) = \left\{ \begin{array}{} 1 & (N =0, X = 1) \cdots (1) \\ 0 & (N > 0, X = 1) \cdots (2) \\ f(N - 1, X - 1) & (N > 0, 1 < X < 2 + |h(N - 1)|) \cdots (3) \\ f(N - 1, |h(N - 1)|) + 1 & (N > 0, X = 2 + |h(N - 1)|) \cdots (4) \\ f(N - 1, |h(N - 1)|) + f(N - 1, X - 2 - |h(N - 1)|) + 1 & (N > 0, 2 + |h(N - 1)| < X < 3 + 2|h(N - 1)|) \cdots(5) \\ 2f(N - 1, |h(N - 1)|) + 1 & (N > 0, X = 3 + 2|h(N - 1)|) \cdots (6) \end{array} \right. $$
(1)の場合は自明であるため、それ以外について説明します。
- (2)は、レベル1以上のバーガーの左端は必ず
'B'
になるため0個となります。 - (3)は、XがレベルNバーガーに含まれる左側のレベルN-1バーガーの範囲内にあるときです。この時は、レベルN-1バーガーの中にしか
'P'
が存在しないため、レベルN - 1バーガーのX - 1番目までに含まれる'P'
の個数を数えます。 - (4)は、X番目の文字がレベルNバーガーを構成している中央の
'P'
(レベルN-1の'P'
ではありません)を指している時であるため、左側のレベルN-1バーガーに含まれる'P'
の個数+1個になります。 - (5)は、XがレベルNバーガーに含まれる右側のレベルN-1バーガーの範囲内にあるときです。この時は、左側と右側のレベルN-1バーガーの中にある
'P'
と(4)の場合の中央の'P'
が存在するため、左側のレベルN-1バーガーに含まれる全ての'P'
+ 右側のレベルN-1バーガーのX - 2 - |h(N - 1)|番目までに含まれる'P'
+1個になります。 - (6)は、XがレベルNバーガーの最後尾を指している場合です。この場合は左右のレベルN-1バーガーが対称であることを利用してレベルN-1バーガーの中に含まれる
'P'
の2倍の個数に(4)の場合の中央の'P'
の+1個になります。
上のような再帰関数を定義して呼び出すことでレベルNバーガーを生成することなくこの問題を解くことができます。
計算量
上記の再帰関数では、となった時に再帰を終了します。 また、(1)以外の場合は必ずNが1ずつ減少していくため、少なくともN回の再帰呼び出しでは終了します。 したがって、時間計算量はです。
実装上の注意点
最も分厚いレベル50バーガーは32bit整数の範囲には収まらないため、64bit整数の幅を持たせなければなりません。#define int long long
の会に入会することでオーバーフローを気にすることなく安心してこの問題を解くことができます。
ソースコード
#include <iostream> // #define int long longの会に入会 #define int long long using namespace std; int calcBurgerSize(int level) { return level ? 2 * calcBurgerSize(level - 1) + 3 : 1; } int countPatties(int level, int x) { if(level == 0) { return 1; } int smallSize = calcBurgerSize(level - 1); if(x == 1) { return 0; } else if(x < 2 + smallSize) { return countPatties(level - 1, x - 1); } else if(x == 2 + smallSize) { return countPatties(level - 1, smallSize) + 1; } else if(x < 3 + smallSize * 2) { return countPatties(level - 1, smallSize) + countPatties(level - 1, x - smallSize - 2) + 1; } else { return countPatties(level - 1, smallSize) * 2 + 1; } } signed main() { int n, x; cin >> n >> x; cout << countPatties(n, x) << endl; }
感想
クリスマスではないにも関わらずChristmasを解いてしまったことに罪悪感を感じてます。場合分けおじさんを体に宿すことでこの問題をACすることができます。
AtCoder Beginner Contest 102 C - Linear Approximation
問題
考察と解法
今回は結構考察パートが長めです。
問題としては以下の関数の値を最小化するbを求め、それを代入したの値を求めるといったものです。 は入力として与えられるのでbの値だけを考えれば良いです。
まず、(-1, -2, ..., -N)が定数になるため、これを事実上の定数であると合わせて、式変形を行います。
ここでとすると
となります。これは、全てのとbとの距離の総和を意味しています。bは自明にになりますが、の取る値がからであるため、いつも通り全探索では時間内に計算が終わりません。おとなしく問題と向き合いましょう。
また、ここで考えるのを簡単にするために、の各要素を昇順(小さい順)に並べ替えたものをとします。
これはもちろん
が成り立つため、全てのとbの距離の総和について考えることにします。
ここで、あるをだけ増加させた場合、は以下のようになります。
したがって、bをjだけ増加させた場合の関数の増加量は
(増加式)
となります。を最小化するためには、上式を最小化すればよいです。
右辺に着目すると
上の式が最小となるのは以下の2パターンです。
- かつ
- かつ
言い換えると、jの値を
- のとき
- のとき
とすることで関数の増加量を最小化できます。また、最小化した場合、(増加式)が0以下の値を取るため、が成り立ち、の最小値はとなります。
そして、の最小値、つまりの最小値は
- のとき
- のとき
となります。
また、の最小値は自明に全てのに対するの最小値であることから、
であり、はによっての適切な方を選べばよいことがわかったため、
となります。あとは、各についてを求める方法がわかればこの問題を解く事ができます。
ここで、の式を確認すると
より
Bが昇順に並んでいるという制約から
- なら
- なら
よって、
(一般式)
となります。とは予め累積和を用いて計算しておく事で各に対してで求める事ができます。
以上の(一般式)を全てのに対して計算した結果の最小値がこの問題の解となります。
計算量
- 各要素をソートするのに
- 累積和を計算するのに
- 全てのに対して(一般式)を計算するのに
1, 2, 3の操作は互いに独立しているため、全体での時間計算量はになります。
実装の注意点
この問題では、(一般式)が取りうる最大値を取った時に大体となるため、32ビットで表現できる数値の範囲を超えてしまいます。
したがって、殆どの環境で32ビットとなるint型
ではオーバーフローが発生します。C++ではlong long型
が64ビットの幅を持っているため、これを利用しましょう。
ソースコード
#include <iostream> #include <algorithm> #define int long long using namespace std; signed main() { int n; int a[200000]; int sum[200001] = { 0 }; cin >> n; for(int i = 0; i < n; ++i) { cin >> a[i]; // 予めAをA'に変換しておく a[i] -= i + 1; } // A'からBへの変換 sort(a, a + n); // 累積和を計算[) for(int i = 1; i <= n; ++i) { sum[i] = sum[i - 1] + a[i - 1]; } // f(B, b)の最小値 int d = 1LL << 60LL; for(int i = 0; i < n; ++i) { int b = a[i]; int leftD = (i * b) - (sum[i]); int rightD = (sum[n] - sum[i + 1]) - (n - 1 - i) * b; d = min(d, leftD + rightD); } cout << d << endl; }
感想
300点の問題にしては少し難しいと感じました。bの値の候補をいかに絞り込む事ができるかがこの問題の鍵でした。
AtCoder エクサウィザーズ 2019 C - Snuke the Wizard
問題
考察と解法
まず、全探索を行う場合を考えます。 1回の呪文ごとにゴーレムの移動をシミュレートするのに最大N体のゴーレムを移動させる必要があり、全部で呪文をQ回唱えるため、最悪時間計算量はとなります。 もちろんなので一生計算が終わりません。 マスをノード、ゴーレムの移動をエッジに見立てたグラフを考えたりもしましたが、あまり良い方法は思いつかなかったので悪あがきはやめて大人しく問題と向き合うことにします。
呪文について考えると、以下の性質がわかります。
- 呪文ごとに動く方向は異なるが、1回の呪文でゴーレムは1マス分しか移動しない。
- ある状態で同じマスに滞在しているゴーレムは必ずそれ以降は同じ動きになる。
上の2つの性質から、呪文をどのように唱えてもゴーレムが他のゴーレムを飛び越えられないことがないことがわかります。
つまり、ゴーレムの並び順は呪文をいくら唱えても初期状態から変化しません。
また、考えるのを簡単にするために、マスををに拡張します。
こうすることで、ゴーレムの消滅はiマス目のゴーレムの0かN+1番目のマスへの移動で表現できます。
以上のことから、Q回の呪文を唱える間に
- iマス目のゴーレムが0番目のマスに移動するならjマス目(0 < j < i)のゴーレムも0番目のマスに移動
- iマス目のゴーレムがN+1番目のマスに移動するならjマス目(i < j < N + 1)のゴーレムもN+1番目のマスに移動
することがわかります。 これをさらに抽象化して、以下の2つの関数を定義します。
- : 初期位置xマス目のゴーレムが呪文をQ回唱える間に0番目のマスに移動するなら1そうでないなら0
- : 初期位置xマス目のゴーレムが呪文をQ回唱える間にN+1番目のマスに移動するなら1そうでないなら0
するとは単調減少、は単調増加であることがわかります。 したがって、これらの関数はxの値で二分探索を行えます。 そこで、二分探索を用いてを満たす最大のxとを満たす最小のxを求め、それぞれをa, bとします。 これは初期位置がのゴーレムが消滅することを意味しているため、最終的な解はa + (N + 1 - b)となります。
実装の工夫
実は上で説明したようにf(x)とg(x)にわけて実装するのは少し面倒くさいです。 理由はf(x)が単調減少、g(x)が単調増加と、2つの関数がそれぞれ異なる性質を持つからです。 これは2パターンの二分探索を実装する必要があります。 そこで、少し楽をするために初期のマスの状態を逆順(ABC → CBA)にしたものと全てのを逆方向にしたもの('L' → 'R')を用意します。 すると、g(x)で求めようとしていた情報をf(x)で同じように求めることができます。 新たに用意したマスと呪文を利用して求めたaは元のマスと呪文を利用して求めた(N + 1 - b)と等しくなります。
計算量
a, bを求めるためにx(0 < x < N + 1)について二分探索を行うため、O()
f(x), g(x)を求めるのは、初期位置がxマス目のゴーレムの1体だけシミュレーションすればよいため、O()
したがって、全体での時間計算量はO()になります。
ソースコード
#include <iostream> #include <string> #include <vector> #include <algorithm> using namespace std; // x番目のゴーレムをk番目のマスに移動させられるかシミュレートして確認する bool checkLeft(int x, int k, string &s, vector<char> &t, vector<char> &d) { for(int i = 0; i < t.size(); ++i) { if(t[i] == s[x]) { x += (d[i] == 'L' ? -1 : 1); } if(s[x] == '_') { // マスの端に到達してもx == kが成り立つとは限らないことに注意する return x == k; } } return false; } int main() { int n, q; string s; cin >> n >> q; cin >> s; // 消滅判定を簡単にするためにマスの両端に'_'をくっつけておく s = "_" + s + "_"; vector<char > t(q); vector<vector<char> > d(2, vector<char>(q)); for(int i = 0; i < q; ++i) { cin >> t[i] >> d[0][i]; // マスの配置を反転させると操作も反転することに注意する d[1][i] = (d[0][i] == 'L' ? 'R' : 'L'); } int golem = n; for(int i = 0; i < 2; ++i) { // 0マス目に到達する最も右のゴーレムの位置を二分探索する int lb = 0, m, ub = n + 1; while(ub - lb > 1) { m = (ub + lb) / 2; (checkLeft(m, 0, s, t, d[i]) ? lb : ub) = m; } // lbの位置にいるゴーレムより左にいるゴーレムは消滅する golem -= lb; // 列を反転させてからもう一度同じ操作を行う reverse(s.begin(), s.end()); } cout << golem << endl; }
感想
初めて500点問題を自力で解くことができました。問題の中で単調増加性を発見した時には感動してため息が出てしまいました。
AtCoder Beginner Contest 112 D - Partition
問題
考察と解法
まずはじめに、すべてのがすべてのの最大公約数で割り切れることに気づきました(それはそう)。
を満たすの最大公約数をとするとであるから
自然数を用いて
(式1)
となり、はMの約数であることがわかります。
したがって、Mの約数をすべて列挙し、(式1)のに代入して満たすか確認すればよいです。
しかし、が最大公約数でなくても、公約数であるなら(式1)を満たすことがわかります。
例えば、の場合を考えると
となりの2つの場合があることがわかります。 (式1)を満たすをすべて求めて最大のものが真のとなるため、この場合はが解になります。 また、はMの約数でありながらでは(式1)を満たすことはできません。 この理由は、(式1)を以下のように変形するとわかります。
xはMの約数なので右辺は必ず整数値になります。 が自然数であるという制約から
(式2)
となり、では(式2)を満たさないため、条件を満たすを構成することができません。 逆に(式2)を満たすなら、とすることでを構成することができます。
以上のことをまとめると以下のアルゴリズムでこの問題を解くことができます。
アルゴリズム
- Mのすべての約数を列挙する
- Mの約数の中から(式2)を満たすxをすべて調べる
- 2で調べたxの中で最大のものを解とする
計算量
1の処理がO()
2の処理が多く見積もってO()
3は2を行う中で最大値を更新すればよいため、O(1)
1, 2の処理は独立しているため、全体の計算量はO()
ソースコード
#include <iostream> #include <vector> #include <algorithm> using namespace std; int main() { int n, m, gcd = 1; vector<int> divisor; cin >> n >> m; for(int i = 1; i * i <= m; ++i) { if(m % i == 0) { divisor.push_back(i); divisor.push_back(m / i); } } for(int i = 0; i < divisor.size(); ++i) { if(m / divisor[i] >= n) { gcd = max(gcd, divisor[i]); } } cout << gcd << endl; }
感想
数学っぽい問題が本当に苦手なので解けて嬉しいです。
AtCoder Beginner Contest 001 C - 風力測定
問題
考察と解法
この問題は以下の2つを達成することで解決できます。
- 風向の角度から方位を表す文字列(
N
,NNE
, ...,NNW
)への変換 - 風速から風力への変換
変換自体は特にアルゴリズムを要求されることがなく、問題文に示されたルールに従うだけであるから簡単です。 しかし、各変換を行う際にはそれぞれ面倒くさい点があります。それは以下の2つです。
- 方位
N
に対応する風向の角度の範囲が0.00度以上11.25未満
と348.75度以上360.00度未満
の2つの範囲がある - 風程から風速への変換、少数第2位の四捨五入で浮動小数点数の誤差が発生する場合がある
1については、方位に対する角度の範囲全てに11.25を足せばよいです。円を回すイメージです。
これによって、すべての方位に対する角度の範囲の始まりと終わりが360 / 16 = 20.5の倍数になります。
と同時に、N
に対応する角度の範囲が360.00度以上20.5未満
に集約されます。
あとは角度を20.5で割った商から方位を決定できます。
2については、すべての計算を整数のみで行えばよいです。
まず、風速の範囲が少数第1位までしか存在しないため、全て10倍して整数のみにします。
風力に変換する際には、少数第2位を四捨五入したあとの風速を10倍したものが範囲に収まるか確認すればよいです。
風速の計算ですが、まず与えられた風程を予め100倍しておき、それを60(秒)で割ります。
100倍してから除算することで、失われる可能性のある少数第2位までの計算を正確に行なえます。
あとはお決まりの四捨五入テクニックで5を足した結果を10で割れば変換完了です。
変換後の風速 = (1分間の風程 * 10 / 6 + 5) / 10;
ソースコード
#include <iostream> #include <algorithm> using namespace std; string degToDir(int deg) { static const string dir[16] = { "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW" }; static const int degPerDir = 2250; deg = (deg * 10 + 1125) % 36000; return dir[deg / degPerDir]; } int disToPower(int dis) { static const int levelBounds[13] = { 0, 3, 16, 34, 55, 80, 108, 139, 172, 208, 245, 285, 327 }; dis = (dis * 10 / 6 + 5) / 10; return upper_bound(levelBounds, levelBounds + 13, dis) - levelBounds - 1; } int main() { int deg, dis; string dir; int w; cin >> deg >> dis; dir = degToDir(deg); w = disToPower(dis); cout << (w == 0 ? "C" : dir) << " " << disToPower(dis) << endl; }
感想
よく考えたら風速から風力への変換時の誤差は風速の範囲の単位を風程に変換すれば消えるため、その後比較するだけでよかったです。