Ruby Numo (NUmerical MOdule) のgnuplot-demo/misc/lifegame のLifeGameクラスを見たとき、とてもシンプルで不思議なコードに引き込まれました。
「これだけのコードでどうやって生死の判定をしているのだろう?」と、
とても興味を持ったところで少し調べてみると「畳み込み演算」らしいことが分かりました。盤面の生死の状態を画素として捉えて、畳み込みフィルタによる画像処理を行うイメージです。好奇心の冷めないうちに、観察の記録をこの iRuby Notebook に書き留めておきます。
次のコードは class LifeGame からクラスの部分を書き出したものです。盤面の初期化(initialize メソッド)と更新(update メソッド)のみのシンプルな構成です。
require 'numo/narray'
class LifeGame
def initialize(nx,ny,m)
@data = Numo::UInt8.zeros(ny,nx)
@data[m..ny-1-m,m..nx-1-m] = Numo::UInt8.new(ny-2*m,nx-2*m).rand(2)
@step = 0
end
def update
b = Numo::UInt8.zeros(*@data.shape)
b[1..-2,1..-2] =
@data[0..-3,0..-3] + @data[0..-3,1..-2] + @data[0..-3,2..-1] +
@data[1..-2,0..-3] + @data[1..-2,2..-1] +
@data[2..-1,0..-3] + @data[2..-1,1..-2] + @data[2..-1,2..-1]
@data.store((b.eq 3) | ((b.eq 2) & Numo::Bit.cast(@data)))
@step += 1
end
attr_reader :data,:step
end
盤面の初期化はinitializeメソッドで行います。以降は小さな盤面を作成し、確認用のコードを直書きしながら確認していきます。
require 'numo/narray'
require 'numo/gnuplot'
require "base64"
data = Numo::UInt8.zeros(6,6)
0は死、1は生の状態を表します。実際のクラスでは盤面の初期状態を乱数によって生成していますが、ここでは1世代目の初期値として「ビーコン」と呼ばれる周期が2の振動子を設定します。
data[0..5,0..5] = [[0,0,0,0,0,0],
[0,1,1,0,0,0],
[0,1,1,0,0,0],
[0,0,0,1,1,0],
[0,0,0,1,1,0],
[0,0,0,0,0,0]]
[[0, 0, 0, 0, 0, 0], [0, 1, 1, 0, 0, 0], [0, 1, 1, 0, 0, 0], [0, 0, 0, 1, 1, 0], [0, 0, 0, 1, 1, 0], [0, 0, 0, 0, 0, 0]]
updateメソッドでは、畳み込み演算用データ b を、 data と同じサイズで初期化。
b = Numo::UInt8.zeros(*data.shape)
畳み込み演算によって 8 方向のデータを畳み込み、セルの生死判定用の数値を作成しています。ここでは配列のビューを指定して操作が行われています。
b[1..-2,1..-2] =
data[0..-3,0..-3] + data[0..-3,1..-2] + data[0..-3,2..-1] +
data[1..-2,0..-3] + data[1..-2,2..-1] +
data[2..-1,0..-3] + data[2..-1,1..-2] + data[2..-1,2..-1]
生死判定用の数値を格納する場所は、実際の盤面サイズより、ひと回り小さな範囲に格納されます。
b
ここで畳み込み演算の結果から2世代目のセルの生死判定を行い、生存データのみを盤面に書き込んでいます。
この1行だけで盤面全体の生死判定ができるとは、なんとシンプルで素敵なことでしょう。
data.store((b.eq 3) | ((b.eq 2) & Numo::Bit.cast(data)))
再びupdateメソッドによって3世代目のセルの生死判定を行います。「ビーコン」は2周期の振動子なので、ここで元の状態に戻ります。
b = Numo::UInt8.zeros(*data.shape)
b[1..-2,1..-2] =
data[0..-3,0..-3] + data[0..-3,1..-2] + data[0..-3,2..-1] +
data[1..-2,0..-3] + data[1..-2,2..-1] +
data[2..-1,0..-3] + data[2..-1,1..-2] + data[2..-1,2..-1]
data.store((b.eq 3) | ((b.eq 2) & Numo::Bit.cast(data)))
以後のupdateによって、2周期の振動子はこの状態を繰り返します。
b = Numo::UInt8.zeros(*data.shape)
b[1..-2,1..-2] =
data[0..-3,0..-3] + data[0..-3,1..-2] + data[0..-3,2..-1] +
data[1..-2,0..-3] + data[1..-2,2..-1] +
data[2..-1,0..-3] + data[2..-1,1..-2] + data[2..-1,2..-1]
data.store((b.eq 3) | ((b.eq 2) & Numo::Bit.cast(data)))
次の世代が存在する条件はつぎの2つです。
(b.eq 3)
(Numo::Bit.cast(data) & ((b.eq 2) | (b.eq 3)))
それ以外は次世代では生存できない。(死の状態)
これらの条件から、storeする条件式はつぎのようになるはずです。
(b.eq 3) | (Numo::Bit.cast(data) & ((b.eq 2) | (b.eq 3)))
ところが実際のコードは少し違います。
@data.store((b.eq 3) | ((b.eq 2) & Numo::Bit.cast(@data)))
確認のためカルノ図を描いてみます。
図を描きやすいように、予め各条件をa, b, c
に置き換えます。
A = Numo::Bit.cast(data)
B = b.eq 2
C = b.eq 3
C | (A & (B | C))
真理値表は次のようになります。
A | B | C | OUT |
---|---|---|---|
0 | 0 | 0 | 0 |
0 | 0 | 1 | 1 |
0 | 1 | 0 | 0 |
0 | 1 | 1 | 1 |
1 | 0 | 0 | 0 |
1 | 0 | 1 | 1 |
1 | 1 | 0 | 1 |
1 | 1 | 1 | 1 |
真理値表を元にカルノ図を描くと
A∖BC | ¯B | ¯B | B | B |
---|---|---|---|---|
0 | 0 | 1 | 1 | 0 |
1 | 0 | 1 | 1 | 1 |
¯C | C | C | ¯C |
上記のカルノ図を使って論理圧縮を行うと次のようになります。
圧縮前の C | (A & (B | C)) は
圧縮後に C | (A & B) となる。
ここで置き換えを元に戻すと (b.eq 3) | (Numo::Bit.cast(data) & (b.eq 2)) となり
この条件は、実際のコードの
@data.store((b.eq 3) | ((b.eq 2) & Numo::Bit.cast(@data)))
と同じ結果になることがわかります。
require 'numo/narray'
require 'numo/gnuplot'
require "base64"
class LifeGameTest
def initialize(nx,ny,m)
@data = Numo::UInt8.zeros(ny,nx)
@data[0..5,0..5] = [[0,0,0,0,0,0],
[0,1,1,0,0,0],
[0,1,1,0,0,0],
[0,0,0,1,1,0],
[0,0,0,1,1,0],
[0,0,0,0,0,0]]
#@data[m..ny-1-m,m..nx-1-m] = Numo::UInt8.new(ny-2*m,nx-2*m).rand(2)
@step = 0
end
def update
b = Numo::UInt8.zeros(*@data.shape)
b[1..-2,1..-2] =
@data[0..-3,0..-3] + @data[0..-3,1..-2] + @data[0..-3,2..-1] +
@data[1..-2,0..-3] + @data[1..-2,2..-1] +
@data[2..-1,0..-3] + @data[2..-1,1..-2] + @data[2..-1,2..-1]
@data.store((b.eq 3) | ((b.eq 2) & Numo::Bit.cast(@data)))
@step += 1
end
attr_reader :data,:step
end
nx,ny = 6, 6 # don't modify
life = LifeGameTest.new(nx,ny,1)
filename = "lifegameTest.gif"
Numo.gnuplot do
reset
set output: filename
set term: "gif", animate:true, delay:50, size:[500,500]
set :nokey
set size: {ratio:1.0*ny/nx}
set xrange: -1..nx
set yrange: -1..ny
unset :colorbox
set palette_defined:'(0 "white", 1 "green")'
10.times do |i|
life.update if i > 0
set title:"lifegame step=#{i}"
plot life.data, flipy:true, with:"image"
end
set :output
end
img = Base64.strict_encode64(File.binread(filename))
IRuby.html %Q(<img src="data:image/gif;base64,#{img}" />)