180
136

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

「え!? ifもforも使わずにライフゲームの実装を!? 」「できらぁ!!」

Last updated at Posted at 2018-07-07

この前Coderetreatというイベントに参加してライフゲームを実装した際、チャレンジ目標に「条件分岐禁止」や「ループ禁止」があった。
イベント中はやれなかったので、今回挑戦してみる。

ライフゲームってのはこんな感じのやつ。
lifegame.gif

#課題

  • ライフゲームを実装する
    (ルールの詳細はリンク先参照、一部抜粋)

ライフゲームのルール
ライフゲームでは初期状態のみでその後の状態が決定される。碁盤のような格子があり、一つの格子はセル(細胞)と呼ばれる。各セルには8つの近傍のセルがある (ムーア近傍) 。各セルには「生」と「死」の2つの状態があり、あるセルの次のステップ(世代)の状態は周囲の8つのセルの今の世代における状態により決定される。
セルの生死は次のルールに従う。
誕生 死んでいるセルに隣接する生きたセルがちょうど3つあれば、次の世代が誕生する。
生存 生きているセルに隣接する生きたセルが2つか3つならば、次の世代でも生存する。
過疎 生きているセルに隣接する生きたセルが1つ以下ならば、過疎により死滅する。
過密 生きているセルに隣接する生きたセルが4つ以上ならば、過密により死滅する。

  • 今回は、ライフゲームにおける盤上の左右と上下はつながっている仕様とした。

    • つまり、盤上の一番左上のセルの左上のセルは、盤上の一番右下のセルになる。
  • Coderetreatだと使用言語は自由

#制約
実装する上での制約事項。Coderetreatのアクティビティより。

  • ループ禁止:no_good_tone1:
    • for,while等による繰り返し構文を使わない
    • (独自追加)pythonのmapも禁止
  • 条件分岐禁止:japanese_ogre:
    • if,while,switch、三項演算子等による分岐を使わない
    • (独自追加)pythonのfilterも禁止
  • 1メソッド4行以下:smiling_imp:

※他に「不変オブジェクトしか使ってはならない」の制約もあったけど、今回は見送り。

#まずは普通に実装
まずは制約を考えずに普通にライフゲームを実装してみる。
言語はpythonを選択。

ライフゲームクラスは以下のメソッドを持つ。

  • コンストラクタ - 盤面の縦と横の長さを受け取り盤面を初期生成する。初期状態ではすべてのセルは死亡とする。
  • set_alive(x,y) - x,yで指定されたセルを生存に設定する
  • board - 現在の盤面のセルの状態を二次元配列で返す。セルの状態は、生存している場合は1、死亡している場合は0。
  • next_generation() - 盤面のすべてのセルの状態を変化させ、次の世代に進める。

##テストコード
Coderetreatの趣旨に従って、最初にテストコードを作成。
ライフゲームクラスを生成し、世代を進めたときに、生成された盤面が期待値と一致していることをテストする。

import unittest
from lifegame import LifeGame

class LifeGameTest(unittest.TestCase):
    def test_lifeGame(self):
        #初期状態生成
        lg =LifeGame(4,4)
        for p in [[0,1],[0,3],[1,2],[2,0],[2,1],[3,2],[3,3]]:
            lg.set_alive(p[0],p[1]);
        assert lg.board == [[0,1,0,1],[0,0,1,0],[1,1,0,0],[0,0,1,1]],"初期生成エラー"
        #1世代進めて結果をテスト
        lg.next_generation()
        assert lg.board == [[1,1,0,1],[0,0,1,1],[1,1,0,0],[0,0,0,1]],"世代1エラー"
        #もう1世代進めて結果をテスト
        lg.next_generation()
        assert lg.board == [[0,1,0,0],[0,0,0,0],[1,1,0,0],[0,0,0,1]],"世代2エラー"

if __name__ == "__main__":
    unittest.main()

##普通の実装コード:hatching_chick:
制約を考えずに実装した場合のコード。
やはり普通に考えるとif/forは使ってしまう。

ALIVE = 1 #生存
DEAD = 0 #死亡

class LifeGame():
    # セルを初期化
    def __init__(self, x_len,y_len):
        self.x_len = x_len
        self.y_len = y_len
        self.board = [[DEAD for x in range(x_len)] for y in range(y_len)]

    # セルに生存を設定
    def set_alive(self,y,x):
        self.board[y][x] = ALIVE

    # 次の世代に進める
    def next_generation(self):
        new_board = []
        for y in range(len(self.board)):
            row = []
            for x in range(len(self.board[y])):
                row.append(self.judge_status(y,x))
            new_board.append(row)
        self.board = new_board
   
    # セルの次の世代の状態を判定して返す
    def judge_status(self,y,x):
        around_alive = self.count_alive(y,x);
        if self.board[y][x] == ALIVE:
            if 1 < around_alive < 4:
                return 1
            else:
                return 0
        else:
            if around_alive == 3:
                return 1
            else:
                return 0

    # 周囲の生存セルを返す
    def count_alive(self,y,x):
        around = []
        for dy in range(-1,2):
             for dx in range(-1,2):
                if not (dy ==0 and dx == 0):
                     cell = self.board[(y+dy)%self.y_len][(x+dx)%self.x_len]
                     around.append(cell)
        return sum(around)

出力例

テストケースが無事通ったので、適当なメイン処理を作って標準出力に表示してみる。

import random
import os
import time
import sys
from lifegame import LifeGame

def main():
    # 25×25の盤を生成
    lg =LifeGame(25,25)
    # ランダムに生存セルを設定
    for p in range(80):
        x = int((random.random()*100) %25)
        y = int((random.random()*100) %25)
        lg.set_alive(x,y);

    for cycle in range(100):
        print_board(lg)
        lg.next_generation()
        time.sleep(0.3)

def print_board(lg):
    board_str = "" 
    for y in lg.board:
        for x in y:
           # 生存なら■、死亡なら□で表示
           board_str += "" if x == 1 else ""
        board_str +="\n"
    os.system('cls')
    print(board_str)

if __name__ == "__main__":
    main()

出力結果
lifegame.gif

標準出力しているだけなので、コンソールの表示更新のタイミングによってはチラついてしまう。
まぁこれでテストコードと動作確認用のメインが作れた。

制約を守ったコードへの修正

ここから本番。
先ほどのコードをif/forを使わない形に修正していく。

1.初期化部分

self.board = [[DEAD for x in range(x_len)] for y in range(y_len)]

指定されたサイズの二次元配列を生成する処理だが、二重forループを使ってしまっているため、別の方法を考える。
まず、繰り返し処理については関数の再帰呼び出しを使えばforを使わずに実現できると気づく。

ただし、普通に再帰呼び出しを行ってしまうと、ifが使えないため無限ループとなってしまう。
そこでifについては、辞書型とラムダ式の組み合わせに置き換えることにする。
例をあげると

if a > 0:
	print("case 1")
else:
	print("case 2")

上記は、以下のように変えることでifを使わずに同じことができる。

# 辞書にBooleanキーに対応する関数を登録
dic = {True:lambda:print("case 1"),False:lambda:print("case 2")}
# 条件式の結果(Boolean)で辞書から関数を取得
func = dic[a > 0]
# 関数を呼び出し
func()

よって、初期化部分のコードは以下のようにすることで、if/forを使わないで実現が可能。

self.board = self.create_board([])
#再帰でボード初期化
def create_board(self,l):
    l.append([DEAD] * self.x_len)
    return {True:self.create_board,False:lambda x:x}[len(l) < self.y_len](l)
  • 余談:最初は[[0]*x_len]*y_lenで簡単じゃーんと思ってたけどそんなことなかった
    いい勉強になった。

2.状態判定部分

def judge_status(self,y,x):
    around_alive = self.count_alive(y,x);
    if self.board[y][x] == ALIVE:
        if 1 < around_alive < 4:
            return 1
        else:
            return 0
    else:
        if around_alive == 3:
            return 1
        else:
            return 0

ifを4つも使ってしまっている。
まずは判定は1つにまとまれるのでまとめる。
加えて、int(<Boolean>)で0/1に変換できることを利用すると、以下のように変えられる。

def judge_status(self,y,x):
    around_alive = self.count_alive(y,x);
    return int((self.board[y][x] == ALIVE and 1 < around_alive < 4 ) 
               or (self.board[y][x] == DEAD and around_alive == 3))

ifを使わない方が、ライン数が少なくて見やすいコードになった:sunny:

制約を守った実装コード:rooster:

同じようにして全体を修正して、最終的に制約(条件分岐禁止、ループ禁止、1メソッド4行以下)をすべて守ったコードを作ることができた。

ALIVE = 1 #生存
DEAD = 0 #死滅

class LifeGame():
    # セルの初期化
    def __init__(self, x_len,y_len):
        self.x_len,self.y_len,self.size = x_len,y_len,x_len*y_len
        self.board = self.create_board([])

    #再帰でボード初期化
    def create_board(self,l):
        l.append([DEAD] * self.x_len)
        return {True:self.create_board,False:lambda x:x}[len(l) < self.y_len](l)
	
    # セルに生存を設定
    def set_alive(self,y,x):
        self.board[y][x] = ALIVE

    # 次の世代に進める
    def next_generation(self):
        self.board = self.next(0,self.create_board([]))

    # 再帰で次世代の各セルを更新
    def next(self,i,next_board):
        x,y = i%self.x_len,int(i/self.y_len)
        next_board[y][x] = self.judge_status(y,x)
        return {True:self.next,False:lambda *x:x[1]}[i+1 < self.size](i+1,next_board)

    # セルの次の状態を判定して返す
    def judge_status(self,y,x):
        num = self.count_alive(y,x,0,0) - self.board[y][x];
        return int((self.board[y][x] == ALIVE and 1 < num < 4 ) 
                   or (self.board[y][x] == DEAD and num == 3))

    # 再帰で3×3の範囲の生存セルの数を返す
    def count_alive(self,y,x,i,num):
        ty = (y+int(i/3)-1)%self.y_len
        tx = (x+i%3-1)%self.x_len
        num += self.board[ty][tx]
        return {True:self.count_alive,False:lambda *x:x[3]}[i+1 < 9](y,x,i+1,num)

#まとめ

  • 感想として、if/forを使わなくても、それほど可読性が落ちないことに驚いた。
  • むしろ使わないほうが、インデントがないので全体がスッキリしている。
  • 全体のコード量も47行から40行に減っている。
  • ただまぁ再帰を多用しているのでパフォーマンス的には悪いかもしれない…
  • 制約を課してコーディングすることで自分の引き出しが増えるのを感じた。
  • 普段は脳死でif/forを使ってしまっているため、他の方法がないかを調べることで、言語についての知識が増えた。
  • これでキーボードのfが壊れても実装できる(嬉しいか?)

もし他にもこんな方法もあるよってのをご存知の方は、ぜひコメントで教えて頂ければ幸いです :eggplant:

180
136
5

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
180
136

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?