👊

ChatGPTも教えてくれない!? じゃんけんプログラムの作り方!

2023/04/10に公開
4

前々からはてなブログが面倒くさいと思っていましたが、いい加減移行することにしました。Zenn なら VSCode で Emacsキーバインドを使えるのでとてもいい感じですね。

さて、 おまけ: 複数人数のじゃんけんプログラム という10年以上前の私の記事があります。もともとは おまけ ではない記事があったのですが、現在はないみたいです。

というわけで、Zenn で記事を書いてみる練習として、10年越しのリバイバル、じゃんけんプログラムの作り方です。

最も単純: if 文で判定

こんな記事にたどり着く可能性があるのは学校で Java か C か Python あたりでじゃんけんプログラムを作成せよ、と言われた学生様でしょう。

以下のように、 if に勝利条件を全部書けば完璧です。というか、面倒なので Python でと言われた生徒は以下のリンクのファイルをそのまま提出すれば十分でしょう。

https://github.com/nodamushi/zenn-program/blob/main/src/py/rock_paper_scissors.py#L22-L32

if の代わりにパターンマッチ

if だと醜いと思う人は match-case 文 を使ってみるといいでしょう。

パターンマッチは今や古臭いJavaにすらあるので、Cを使ってない限りはだいたいどこでも使えます。

def determine_winner(player, computer):
    match (player, computer):
        case (Hand.グー, Hand.チョキ):
            return Result.WIN
        case (Hand.チョキ, Hand.パー):
            return Result.WIN
        case (Hand.パー, Hand.グー):
            return Result.WIN
        case (Hand.グー, Hand.パー):
            return Result.LOSE
        case (Hand.チョキ, Hand.グー):
            return Result.LOSE
        case (Hand.パー, Hand.チョキ):
            return Result.LOSE
        case _:
            return Result.DRAW

計算量を減らそう: 配列で判定

if 文で実装した場合は、引き分けの判定をするのに合計6回も比較をしないといけません。なんだかちょっと無駄な感じがしますね。

や、やめろー
if player == computer:
    return Result.DRAW
elif player == Hand.グー and computer == Hand.チョキ \
        or player == Hand.パー and computer == Hand.グー \
        or player == Hand.チョキ and computer == Hand.パー:
    return Result.WIN
else:
    return Result.LOSE

え、ちょっと短くなるのになんで長く書いたのかって?4回比較より6回の方がスピード差が出やすいかなって……

予め結果を計算しておいて配列として保存しておき、実行時には配列から結果を抜き出すだけにするといいかもしれません。

https://github.com/nodamushi/zenn-program/blob/d33fccf71e54e5f6b2a1cb45eff3ad1dfb3d9b73/src/py/rock_paper_scissors_table.py#L21-L31

複数人でじゃんけん

さて、ここからが10年前の記事の内容に入っていく本題です。

これまでのif分岐のやり方だと、プレイヤーが複数人になると作ることが難しくなります。複数人になった場合は、以下の手順で勝敗判定をすることになります。

  1. 場に出されている手をすべて集める
  2. 場に出されていた手の中から勝利手を求める。ない場合は引き分け。
  3. 勝利手を出していたプレイヤーが勝利

これを愚直に実装しても良いです。が、手が出されているかどうかを確認する変数をグーチョキパーで用意して、ifで分岐判断して…………って、正直実装が面倒くさいですね。

実はこれはビットを使うとサクッと簡単に実装できます。

コンピューターの値は実際には2進数で保存されていて、全て0と1で表現されています。例えば5という値は2進数で b101 です。

この0と1をビットといい、コンピュータの基本単位になっています。

複数の物事を表現する際に、もしそれが「True/False」「On/Off」「Yes/No」「有/無」で表現できるなら、Booleanの配列ではなく、ビットが活躍できる可能性があります。

今回の場合は、手順1で「場に出されている手をすべて集める」という処理を行います。これは「場にグーが有るか無いか、チョキが有るか無いか、パーが有るか無いかを調べる」と言い換えることができます。すなわち、ビットを活用できます。

グーチョキパーをビットで表して、数式で一発計算

まずは、最初のビット(bit0)が1ならグーが出ている、次のビット(bit1)が1ならチョキ、bit2が1ならパーが出ているとしましょう。つまり、値が2(b010)の場合はチョキだけが出ていることを表します。

この様に各ビットに各情報を割り当てるのは常套手段です。パターンとして覚えてしまいましょう。

手の情報をビットにしてしまうと、全部の手を集めるのは単に全部の論理和を取ればよいだけになります。

hands = 0
for player in players:
  hands |= player.value

さらに、手の情報をビットで表してしまうと、勝利してるかどうかを数式で表すことができます。チョキが勝っているかどうかは、チョキが1で、左隣のパーが1で、右隣のグーが0の場合ですね。つまり、式にすると以下のようになります。

(hands & 2 & (hands >> 1) & ~(hands << 1)) != 0

hands >> 1hands << 1 が突如出てきて混乱するかもしれません。 hands >> 1 はパーが1の判定で、~(hands << 1) がグーが0の判定になっています。もっとシンプルに hands == 6 でも判定できるのに何故この様に書いたのでしょうか?

じゃんけんは以下の図のようにループする関係にあります。この隣接する関係を表すためにビットシフトを使うと、いい感じに処理できるからなのです。


じゃんけんの勝敗関係

というのも、 & 2 の部分を1と4に書き換えれば、この式をそのままグーとパーにも適応できるのです(※後述)。つまり、以下のように & 2 の部分をくくりだして汎用化することが出来ます。

(hands & (hands >> 1) & ~(hands << 1)) &!= 0

これもパターンとして覚えておけばよいです。

さて、グー、パーについても同様に適応できると言いましたが、それは hands >> 1hands << 1 が3つのビットでループしてくれる場合に限ります。(例: b101 << 1 = b011b001 >> 1 = b100 になる)

実際はループしてくれません。ループするようにビット操作をしてもいいですが、実装するのが若干面倒です。

データが64bitを使い切ってる場合はループを実装するしかないですが、今回は3bitしか使わず、データに余裕があるので、以下のようにループ用の小細工を仕込んでおくと便利です。

最終的には、手の情報を以下のビットに配置します。

ビット 数値 (10進数) 数値 (2進数)
グー 1, (4) 18 b10010
チョキ 2 4 b00100
パー (0), 3 9 b01001

ちなみに、10年前の記事は一方のループだけを作って、引き分け判定は全ビット1か全ビット0かのどっちか、という判定をしていました。10年前と同じじゃ芸がないので変化を入れておきましたが、ぶっちゃけどっちでも大差ないです。

最終的な勝利判定コードは次のようになります。

https://github.com/nodamushi/zenn-program/blob/d33fccf71e54e5f6b2a1cb45eff3ad1dfb3d9b73/src/py/rock_paper_scissors_many.py#L15-L39

重要な部分は以下。

win_hand = hands & (2 + 4 + 8) & (hands >> 1) & ~(hands << 1)

チョキのときと同じですね。注意すべきは & (2 + 4 + 8) で、これはループ用のダミービットを0にするだけで、手の判定をするためにあるわけではありません。

手の判定をしているのは以下です。

elif (win_hand & player.value) != 0:

ChatGPT に聞いてみた

さて、タイトルですでにネタバレしてるんですが、 ChatGPT で「複数人数で行うじゃんけんプログラムを作成して」と聞いてみました。

ChatGPT3.5 に聞いてみた

出てきたコードはこちら💦ちがう、違うんだ…………

なぜ1対1を繰り返すんだ…………😭

https://github.com/nodamushi/zenn-program/blob/main/src/py/rock_paper_scissors_chatgpt3_5.py

ChatGPT 4に聞いてみた

ふぁー?何がしたいんだ?

https://github.com/nodamushi/zenn-program/blob/4b9dfa20804be8d58cd490f6c190063af33eaf16/src/py/rock_paper_scissors_chatgpt4.py#L13-L19

まさに無限ループ

Traceback (most recent call last):
  File "/volume/src/py/rock_paper_scissors_chatgpt4.py", line 36, in <module>
    play_rock_paper_scissors(num_players)
  File "/volume/src/py/rock_paper_scissors_chatgpt4.py", line 29, in play_rock_paper_scissors
    winners = find_winner(players)
  File "/volume/src/py/rock_paper_scissors_chatgpt4.py", line 19, in find_winner
    return find_winner(winners)
  File "/volume/src/py/rock_paper_scissors_chatgpt4.py", line 19, in find_winner
    return find_winner(winners)
  File "/volume/src/py/rock_paper_scissors_chatgpt4.py", line 19, in find_winner
    return find_winner(winners)
  [Previous line repeated 993 more times]
  File "/volume/src/py/rock_paper_scissors_chatgpt4.py", line 14, in find_winner
    max_hand = max(players, key=lambda x: x["hand"])
RecursionError: maximum recursion depth exceeded

んー、じゃんけんプログラムぐらいサクッと作って欲しいですね、ChatGPT!

最後に

記念すべき Zenn での記事第一弾は ChatGPT に聞くよりも素晴らしいものを残せたと思う。うむ。大体 Zenn の書き方の理解やGitHubの準備は出来たかな……。Draw.io は *.drawio.png で保存しておくのが良さげ。

そして、10年経っても、 Google 検索で「じゃんけん プログラム 複数人数」で数式判定してるのが出てこないのが不思議ですね。(ビットを使うところまでは出てくるんだけど、そこまで書くならちゃんとビット演算使ってあげなよ)

じゃんけんごときでビット演算なんて大げさと思うかもしれませんが、実はじゃんけんに限らず、色んな場面で使えます。
引き出しを増やしておくと、将来きっと役に立ちますよ。

リバイバルの本当の理由と思い出話

さて、この記事は前置きを短くするために、唐突に10年以上前の記事の話から始まりました。

私も10年前にこんな記事かいた事自体忘れてました。

なのに、なぜ突然10年前の記事を話題にしたかというと、Twitter でエゴサしてみたらこの記事について触れている方を見つけたのです。(いや、私のはてなブログなんて誰も見てないだろうし、もう消してもいいかな、とちょっと検索してみただけだってばよ。)

https://twitter.com/Kyo_s_s/status/1587087668600373248

この方はなんと他にも2回もこの記事をツイートしてくださっています。この記事のファンだ!

記事のタイトルが「おまけ」になっているのは、おまけではない「2人でのじゃんけん」の話を書いてたはずなのですが、現在のはてなブログには存在していません。元々 2011 年当時は FC2 ブログで書いてて、はてなブログに移動するときに日記は全部消して技術的な内容だけにしたのですが、そのときに移行し忘れたのでしょうね。

という訳で、Zenn第一回目の記事に決定しました。

当時なんでこんな記事を書いたのかというと、大学の美術部の先輩(別学科)が唐突に「じゃんけんプログラムの書き方を教えろ!」と課題引っ提げて私の家にやってきたからだったんですよね。名前をもう覚えてないんだけど、懐かしいなぁ。先輩、お元気でしょうか。記憶にありましたら当時の授業料として何か飯奢ってください😀

当時はビット演算でじゃんけんなんてただのギャグだと思っていました。でも、10年経って考えてみると、じゃんけんに限らず、ギャグだと思ってた変な解き方がパターンとなり、色んな場面で使えていたりします。何故か私はSIMD命令とかよりもビットを弄るのが好きだったのですが、そのおかげなのか社会人になってから突然 Verilog で RTL を書くことになっても抵抗はありませんでした。

単に課題を提出するだけなら、ネットのコードをコピればいいし、ChatGPT……はまだ怪しかったけど、誰かが作ったのものをそのまま提出すればいいと私個人は思います。道具を使う、他人を使うのも能力でしょう。

そういえば、人類って数千年前から脳容量が急激に減る方向に進化してるそうです。諸説ある理由の中には、文明の発達で頑張って記憶しなくても良くなったから、という説明があるそうです。それが真実だったとしても、文明を批判する人はあまりいないでしょう。古代人は現代人の脳の劣化ぶりに嘆くかもしれませんが、外部化できる脳の機能は外部化すればいいのです。

それでも、じゃんけんプログラムを、与えられた課題を自力で解こうとするのなら、是非いろいろな解き方を考えて遊んでみてください。

それはもしかしたら、10年後に振り返ったとき、あなたの力となっているかもしれません。

Discussion

ギャオスギャオス

解説ありがとうございます。大変目から鱗でした。
しかし実際試してこの部分winとloseが逆だったのですが気のせいでしょうか?
逆にして使うと上手く行ったので自分はそうしました。
elif (win_hand & player.value) != 0:
results.append(Result.WIN)
else:
results.append(Result.LOSE)

ちなみにnodamushi様のじゃんけん勝敗ロジックを拝借して作りました。超くだらないゲームです。(いやゲームですらない?)
https://wakuwakustudio.com/janken/

nodamushinodamushi

コメントありがとうございます。興味深いゲーム(?)も拝見しました。良いですね。

実際にソースコードも拝見させていただきましたが、私のものとビットの配置が異なるようです。

ギャオス様の定義:

	handsEnum = {
		rock: 18,
		paper: 4,
		scissors: 9
	};

私の定義:

# グー、チョキ、パーの手
class Hand(Enum):
    グー = 18
    チョキ = 4
    パー = 9

英語だと、 rock-papser-scissors で定義するのが自然ですが、私はブログ用に日本語で定義したので、逆になったのでしょうね。

さて、私の定義とチョキとパーの定義が逆になっているのでシフト方向が逆になります。私の式のシフト方向を逆にすると勝利手の判断ではなく、敗北手の判断になります。そのため、if文が逆になったのです。

ビット演算を多用する場合は、数値の定義に強く式が依存しますので、ご注意ください。

ちなみに、10年前の記事の数値を使った場合は一方向のシフトにしか対応できません。今回は0bit目と4bit目に小細工を入れているので双方向のシフトが可能です。

ギャオスギャオス

なんとそんな恥ずかしいミスをしてましたか。気付かなかったです。。
申し訳ございません。
わざわざ見てくださり、誠にありがとうございますm(_ _)m

nodamushinodamushi

いえ、文章構成を見直す良い機会になりました。ありがとうございます。