Grundy数 その2
前回はGrundy数とは何かを説明しました。Grundy数に関する基本的な求め方は前の記事をご覧ください。augusuto04.hatenablog.com
ただ、これだけではあまり多くの問題に使えないのでもう少し発展的な使い方を紹介します。
Nim
私は知りませんでしたが、有名なゲームらしいですね。ルールは次の通り。
ここで、上のような場合を(4, 2, 1)と今回は表します。
とりあえず各山についてGrundy数を求める
まずは、各山についてGrundy数を求めます。
もし、0枚の山があるとするとここからは遷移できないのでGrundy数は0。
1枚の山からは0枚の山に遷移できるのでこれ以外で最小は1。
2枚の山からは0, 1枚の山に遷移できるのでこれ以外で最小は2。
このように、今回は山にあるコインの枚数とGrundy数は等しくなる。
つまり、今回の例におけるGrundy数は次の通り。
コインの枚数 | 4 | 2 | 1 |
---|---|---|---|
Grundy数 | 4 | 2 | 1 |
全体について考える
では、今回は(4, 2, 1)と3つの山がありますが、この状態におけるGrundy数はどうやって求めればいいのでしょうか。結論からいいます。4 xor 2 xor 1を計算すればよいのです。つまり、この状態におけるGrundy数は7である。と言うことで今までのようにGrundy数が0より大きいので最適な行動をとれば先手の勝ちとなります。なお、xorってなんだって方はこちらをどうぞ。augusuto04.hatenablog.com
では、なぜこれでよいのでしょうか。(4, 2, 1)から遷移できるすべての状態おいて同じようにしてGrundy数を求めると次のようになります。
状態 | Grundy数 |
---|---|
(3, 2, 1) | 0 |
(2, 2, 1) | 1 |
(1, 2, 1) | 2 |
(0, 2, 1) | 3 |
(3, 1, 1) | 3 |
(3, 0, 1) | 2 |
(3, 2, 0) | 1 |
このようにして、Grundy数が0になるような場合か必ず存在します。では、Grundy数が0である(3, 2, 1)からの遷移を次は考えてみます。
状態 | Grundy数 |
---|---|
(2, 2, 1) | 1 |
(1, 2, 1) | 2 |
(0, 2, 1) | 3 |
(3, 1, 1) | 3 |
(3, 0, 1) | 2 |
(3, 2, 0) | 1 |
このようにGrundy数が0になるような遷移は存在しません。前回と同じように次のことが言えます。
→相手が最適な行動をとれば負け
→自分が最適な行動をとれば勝ち
思ったよりも書きたいことが多い・・・。次の記事で実際にコードを紹介して終了にします。augusuto04.hatenablog.com
Grundy数 その1
Grundy numberについての勉強をしたので、そのまとめ(自分用のメモでもある)。ちなみにNimberとも言うみたいですね。実際にいくつかのゲームを考えながら説明をしていきます。最後の結論まで少し長いです。
そもそもGrundy数ってなんだろう
具体例は後で出すとして、とりあえずGrundy数の求め方から紹介します。
えーと・・・。これだけでは良く分かりませんね(笑)。じゃあ実際にあるゲームを考えてみます。30を言ったら負けゲーム
以前、伊東家の食卓というテレビ番組で紹介されたゲームです。ルールは次の通り。
先手は1から初め、30を言った人が負け。
とりあえずGrundy数の求め方
このゲームにおけるGrundy数をまずは計算してみます。
次に言う数 | 1 | 2 | ・・・ | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
---|---|---|---|---|---|---|---|---|---|---|---|---|
Grundy数 | 1 | 0 | ・・・ | 0 | 3 | 2 | 1 | 0 | 3 | 2 | 1 | 0 |
まずは30から考えます。ここから遷移(たどり着く)数はないので最小の0以上の整数は0。
29からは30に遷移でき、このGrundy数は0である。よってこれ以外の最小の0以上の整数は1。
28からは29, 30に遷移でき、これらのGrundy数である1, 0を除く最小の0以上の整数は2。
27からは28, 29, 30に遷移でき、これらのGrundy数である2, 1, 0を除く最小の0以上の整数は3。
26からは27, 28, 29に遷移でき(3つまでしか数は言えないため30へは遷移できない)、これらのGrundy数である3, 2, 1を除く最小の0以上の整数は0。
これを繰り返し、すべてのGrundy数を求めます。
で、ここから何が分かるの?
実は自分の手番におけるGrundy数を見えると、そこからお互いが最適な行動をしたときの勝敗が分かるのです!Grundy数が0ならばそのときに手番の人は負けます。Grundy数が0より大きければそのときに手番の人が勝ちます。ではなぜなのでしょうか。次の2つの場合について考えます。
- Grundy数が0のとき
そこから遷移できる状態のGrundy数はすべて0より大きいです。
- Grundy数が0より大きいとき
そこから必ずGrundy数を0にするように遷移出来ます。
つまりは次の画像のような状態になります。
なので、Grundy数が0ならば相手が最適な行動をとれば負け、Grundy数が0でなければ自分が最適な行動をとれば勝ちなのである。よってこのゲームでは先手が必勝であることが分かる。
長くなってしまったので次の記事に続きます。
augusuto04.hatenablog.com
WL-Algorithm
AtCoderで出題されていて、topcoderでも紹介していたのでちょっとまとめてみました。2人が最適な行動を行ったとき、勝つか負けるかを判定する単純なアルゴリズムです。
WL-AlgorithmというとWang-Landau Algorithmを指すことが多いようなのですが、今回はtopcoderで紹介されていたこの名前で呼びます。
考え方
すごくシンプルな考え方です。
function isWinning(今の位置){ for(現在位置からのすべての行動x) if(isWinning(x)は負け) return 勝ち; return 負け; }
- 相手を必ず負けさせるような手が存在するなら現在の位置では勝ち決定。
→isWinning(x)が負けのとき
- そのような手がないないのであれば負け。
これを勝敗が決定する最後の手から考えていくと先手必勝か後手必勝かを判定出来ます。
実践例
今回はAtCoder Regular Contest 038のB-マス目と駒について考えます。
今の位置は(r, c)で表します(rは現在の行、cは現在の列)。現在位置からの全ての行動は(r + 1, c), (r + 1, c + 1), (r, c + 1)である(右、右下、下に移動可能)。するとコードは次のようになります。
string grid[]; //マス目の各列 int H, W; //Hは行数、Wは列数 bool isWinning(int r, int c){ if(r >= H || c >= W || grid[r][c] == '#') //この位置が盤外または障害物ならば勝ち扱い return true; if(isWinning(r + 1, c) == false) return true; if(isWinning(r + 1, c + 1) == false) return true; if(isWinning(r, c + 1) == false) return true; return false; }
盤外と障害物の対処方法を加えていますがそれ以外は先ほどの考え方と変わりません。ただし、これだと計算量が多いのでメモ化をして改善したコードは次の通り。
string grid[MAX]; int H, W, m[MAX][MAX]; //mがメモ bool isWinning(int r, int c){ if(r >= H || c >= W || grid[r][c] == '#') return true; if(m[r][c] != -1) return m[r][c]; bool result = false; if(isWinning(r + 1, c) == false) result = true; if(isWinning(r + 1, c + 1) == false) result = true; if(isWinning(r, c + 1) == false) result = true; return m[r][c] = result; }
既に計算済みならばメモを残して再計算しないように工夫をしているだけです。
AtCoder Regular Contest 038
ゴールデンウィークはコンテストに集中するチャンス!
とウキウキでしたが惨敗でした。
A-カードと兄妹 100点
さすがにこのレベルの問題は簡単。
B - マス目と駒 0点
C - 茶碗と豆 0点
D - 有向グラフと数 0点
全部似たような問題なんですかね。3問とも2人でゲームをしてそれぞれが最適な行動をする問題でした。ちょっと調べてみてMini-Max法とかAlpha-Beta法が出てきたのでその辺りを勉強してみます。
ってな訳で209位で惨敗。ってか分からなさすぎて途中でやめてHackerRankの問題解いてました。そっちはだんだんとDynamic Programmingが理解できてきて解ける問題が増えています。
101 Hack April'15
勉強を始めてから約3週間、初めてコンテストに出場しました!
その結果がこちら。
Rat Race (Easy) 20/20点
a / bがintになることを忘れるという凡ミスをして最初は焦りましたが、楽勝!
Contest Strategy (Difficult) 0/80点
結構時間を使って考えたのだが解けず。正直悔しい。
Grand Tour (Advanced) 3.85/100点
最近覚えた幅優先探索が使えそうだなと思ったけど実装できず、部分点だけで終了。
Introduction to Topology (Advanced) 0/120点
問題を読んだだけで解かず。
合計点は83.85点で122/ 812位でした。まだまだ勉強しないといけないですね。
ただ一ヶ月ほどでここまで来られたので、少し達成感はありました。次も頑張るぞ!
累乗計算の高速化
HackerRankのAntiPalindromic Stringsを解くときにどうしても累乗計算がO(n)になってしまいタイムアウトになってしまっていたところ、凄くキレイで高速なアルゴリズムを発見!
高速な累乗計算 - あどけない話
これを使ってみました。
単純なxのn乗をmを法として求めるアルゴリズム
long myPow(long x, long n, long m){ long result = 1; for(int i = 0; i < n; i++){ result *= x; result = result % m; } return result; }
改善後のアルゴリズム
ここから、累乗計算の方法を改良していきましょう。その例として、を計算してみます。
long myPow(long x, long n, long m){ if(n == 0) return 1; if(n % 2 == 0) return myPow(x * x % m, n / 2, m); else return x * myPow(x, n - 1, m) % m; }
これでO(log n)になり、バッチリ解くことが出来ました。ただ、また新しい記事を発見。
累乗を求めるプログラムの速度計測 - ak毎日ブログ
どうやら再帰関数を使うと関数の呼び出しで遅くなるからfor文を使った方が速いらしい。なるほど、勉強になります。