2次元配列を scan する ― Haskellプログラミングの一風景
この記事は、Haskell Advent Calendar 2024 13日目の記事です。
はじめに
プログラミングをしている様子を傍から見るのは面白いものです。少くとも、この記事の筆者はそう感じています。いろいろな気付きがあって楽しいものです。
ここでは、シンプルなプログラミングのお題をめぐって、あれこれプログラミングしてみます。Haskellプログラミングのエキスパートにとっては他愛もないものでしょう。それでも、「えぇー、それ、自分とは違う」ところがあれば、あるいは、「あらら、自分ならもっとうまくやる」なんてことがあれば、それはそれで、ツッコんで楽しめると思います(たぶん)。
プログラミングのお題
の行列が与えられ、 10\times 10 成分から右か下に向って成分を足しながら進んでいくとき、 (1,1) 成分へ至る道のうち最も和が小さいものを求める(以下略) (i,j)
(haskell-jp Slack に投稿されたコメントより)
素朴な実装
minPath0
このお題を素朴にminPath0
として実装してみましょう。
まずは仕様を型で表現しましょう。
minPath0 :: Array (Int,Int) Int -- 与えられた行列
-> (Int,Int) -- 指定された到達点
-> Int -- 到達点までの最小和
この型シグネチャーを見つめながら、実装を考えてみましょう。
与えられた行列はsa
、指定された到達点は(i,j)
としましょう。
minPath0 sa (i,j) = undefined
点(i,j)
に到達する直前には、点(i-1,j)
または点(i,j-1)
にいたはず。到達点までの最小和は、直前点までの最小和の大きくない方の値と到達点の値との和になります。
minPath0 sa (i,j) = min
(minPath0 sa (i-1,j)) -- 点(i-1,j)までの最小和
(minPath0 sa (i,j-1)) -- 点(i,j-1)までの最小和
+
sa ! (i,j) -- 到達点の値
ただし、出発点(1,1)
の場合は直前点は上にも左にもなく、上辺にある点(1,j)
の場合は直前点は左だけ、左辺にある点(i,1)
の場合は直前点は上だけです。これを定義に追加すると以下のとおりになります。
minPath0 sa (1,1) = sa ! (1,1)
minPath0 sa (1,j) = minPath0 sa (1,j-1) + sa ! (1,j)
minPath0 sa (i,1) = minPath0 sa (i-1,1) + sa ! (i,1)
minPath0 sa (i,j) = minPath0 sa (i-1,j) `min` minPath0 sa (i,j-1) + sa ! (i,j)
この実装は、問題をそのまま素朴に表現したものですね。少くとも、これで求まる値は正しい値であると容易に想像できると思います。
実際の計算
では、実際に具体的に計算してみましょう。minPath0
に与える行列をsample
として定義しましょう。
件のコメントでは以下のようなものを例として挙げてありました。
sample :: Array (Int,Int) Int
sample = listArray ((1,1),(10,10)) [i+j | i <- [1..10], j <- [1..10]]
中身を表示してみましょう。
stack exec -- ghc -e 'elems sample' src/MinPath.hs
[2,3,4,5,6,7,8,9,10,11,3,4,5,6,7,8,9,10,11,12,4,5,6,7,8,9,10,11,12,13,5,6,7,8,9,10,11,12,13,14,6,7,8,9,10,11,12,13,14,15,7,8,9,10,11,12,13,14,15,16,8,9,10,11,12,13,14,15,16,17,9,10,11,12,13,14,15,16,17,18,10,11,12,13,14,15,16,17,18,19,11,12,13,14,15,16,17,18,19,20]
見やすいように、ここでは手動で^^;10要素ずつ改行すると、
[2,3,4,5,6,7,8,9,10,11
,3,4,5,6,7,8,9,10,11,12
,4,5,6,7,8,9,10,11,12,13
,5,6,7,8,9,10,11,12,13,14
,6,7,8,9,10,11,12,13,14,15
,7,8,9,10,11,12,13,14,15,16
,8,9,10,11,12,13,14,15,16,17
,9,10,11,12,13,14,15,16,17,18
,10,11,12,13,14,15,16,17,18,19
,11,12,13,14,15,16,17,18,19,20]
これを見るとわかるように(中身を見るまでもなく、配列の作り方をみればわかりますが)、これは、正しい経路であれば、どの経路をとおっても、その軌跡は同じリストになり、和は同じになってしまいます。1つの例としてはなりたちますが、特例中の特例なので、もうすこし、散けたものを作りましょう。
散けた要素をもつ配列の生成
とりあえず、[1 .. n]
をシャッフルしたリストをn
個用意する、shuffled
を定義しよう。
module Shuffled where
import Data.List
import System.Random -- random パッケージ
import List.Shuffle -- list-shuffle パッケージ
shuffled :: Int -> IO [[Int]]
shuffled n = take n . unfoldr phi <$> getStdGen where
bs = [1 .. n]
phi = Just . shuffle bs
これで、
stack exec -- ghc -e 'shuffled 10' src/Shuffled.hs
[[8,9,6,10,5,4,2,7,3,1],[1,8,9,5,2,4,3,6,7,10],[3,7,2,5,4,8,6,10,1,9],[5,1,7,6,9,4,3,8,2,10],[6,9,2,3,1,8,10,4,7,5],[10,5,8,3,1,9,2,7,4,6],[3,7,9,6,2,4,1,8,10,5],[9,3,2,1,8,7,6,5,4,10],[6,9,2,1,8,10,5,7,4,3],[2,6,3,10,7,1,4,9,8,5]]
これをもとにsample
を作成しましょう。
sample :: Array (Int,Int) Int
sample = listArray ((1,1),(10,10)) (concat bss) where
bss = [[8,9,6,10,5,4,2,7,3,1]
,[1,8,9,5,2,4,3,6,7,10]
,[3,7,2,5,4,8,6,10,1,9]
,[5,1,7,6,9,4,3,8,2,10]
,[6,9,2,3,1,8,10,4,7,5]
,[10,5,8,3,1,9,2,7,4,6]
,[3,7,9,6,2,4,1,8,10,5]
,[9,3,2,1,8,7,6,5,4,10]
,[6,9,2,1,8,10,5,7,4,3]
,[2,6,3,10,7,1,4,9,8,5]]
では、これを使って、右下の点(10,10)
での最小和を求めてみましょう。ついでに簡単に計算時間も測ることにしましょう。
% time stack exec -- ghc -e 'minPath0 sample (10,10)' src/MinPath.hs
結果は、以下のとおり。
66
--
Program : stack exec -- ghc -e 'minPath0 sample (10,10)' src/MinPath.hs
CPU : 98%
user : 0.383s
system : 0.114s
total : 0.506s
--
もうすこし、大きいサイズ
genSample :: Int -> IO ()
genSample n = writeFile "sample.txt"
. (++"\n")
. show
. listArray ((1,1),(n,n)) . concat =<< shuffled n
getSapmle :: IO (Array (Int,Int) Int)
getSample = read <$> readFile "sample.txt"
genSample 100
で
% stack exec -- ghc -e 'genSample 100' src/MinPath.hs
これを getSample
で読み込んで使用します。
% for i in {10..15}
for> time stack exec -- ghc -e "flip minPath0 (${i},${i}) <$> getSample" src/MinPath.hs
832
--
Program : stack exec -- ghc -e "flip minPath0 (${i},${i}) <$> getSample" src/MinPath.hs
CPU : 97%
user : 0.412s
system : 0.098s
total : 0.521s
--
922
--
Program : stack exec -- ghc -e "flip minPath0 (${i},${i}) <$> getSample" src/MinPath.hs
CPU : 98%
user : 0.577s
system : 0.092s
total : 0.682s
--
989
--
Program : stack exec -- ghc -e "flip minPath0 (${i},${i}) <$> getSample" src/MinPath.hs
CPU : 99%
user : 1.242s
system : 0.097s
total : 1.351s
--
1152
--
Program : stack exec -- ghc -e "flip minPath0 (${i},${i}) <$> getSample" src/MinPath.hs
CPU : 99%
user : 3.788s
system : 0.109s
total : 3.904s
--
1149
--
Program : stack exec -- ghc -e "flip minPath0 (${i},${i}) <$> getSample" src/MinPath.hs
CPU : 100%
user : 13.356s
system : 0.110s
total : 13.460s
--
1282
--
Program : stack exec -- ghc -e "flip minPath0 (${i},${i}) <$> getSample" src/MinPath.hs
CPU : 100%
user : 51.578s
system : 0.152s
total : 51.667s
--
さすがに遅いですね。計算時間が、指数オーダーになっているようです。
原因は、minPath0
に、ツリー構造再帰がある(下線部)からですね。
minPath0 sa (1,1) = sa ! (1,1)
minPath0 sa (1,j) = minPath0 sa (1,j-1) + sa ! (1,j)
minPath0 sa (i,1) = minPath0 sa (i-1,1) + sa ! (i,1)
minPath0 sa (i,j) = minPath0 sa (i-1,j) `min` minPath0 sa (i,j-1) + sa ! (i,j)
-- ^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^
このminPath0
の定義は仕様としては正しい定義ですが、実用には不向きです。
ただ改善の余地はあります。4つめの等式の右辺にあらわれた分岐の枝をさらに一段展開すると、
minPath0 sa (i,j) = (minPath0 sa (i-2,j) `min` minPath0 sa (i-1,j-1) + sa ! (i-1,j))
`min` -- ^^^^^^^^^^^^^^^^^^^^^
(minPath0 sa (i,j-2) `min` minPath0 sa (i-1,j-1) + sa ! (i,j-1))
+ -- ^^^^^^^^^^^^^^^^^^^^^
sa ! (i,j)
下線部の関数適用式が重なっています。なんども同じ計算が行われることになるので、これを1回ですませられるようにします。それには元の配列sa
のすべての点までのminPath
の値で構成された、もとのsa
と同じディメンションの配列ta
を構成します。これが作成できれば、(i,j)
におけるminPath
の値は、ta ! (i,j)
になります。
minPath :: Array (Int,Int) -> (Int,Int) -> Int
minPath sa (i,j) = ta ! (i,j) where
ta = listArray (bounds sa) (phi <$> assocs sa)
phi = \ case
((1,1), s) -> s
((1,j), s) -> ta ! (1,j-1) + s
((i,1), s) -> ta ! (i-1,1) + s
((i,j), s) -> (ta ! (i,j-1)) `min` (ta ! (i-1,j)) + s
minPath0
の再帰適用minPath0 sa (i,j-1)
が、ta
のインデックスによる参照ta ! (i,j-1)
に、minPath0 sa (i,j-1)
が、ta ! (i,j-1)
に、代っています。早速ためしてみましょう。
% for i in {10..15}
for> time stack exec -- ghc -e "flip minPath (${i},${i}) <$> getSample" src/MinPath.hs
832
--
Program : stack exec -- ghc -e "flip minPath (${i},${i}) <$> getSample" src/MinPath.hs
CPU : 98%
user : 0.363s
system : 0.093s
total : 0.462s
--
922
--
Program : stack exec -- ghc -e "flip minPath (${i},${i}) <$> getSample" src/MinPath.hs
CPU : 96%
user : 0.361s
system : 0.087s
total : 0.463s
--
989
--
Program : stack exec -- ghc -e "flip minPath (${i},${i}) <$> getSample" src/MinPath.hs
CPU : 98%
user : 0.350s
system : 0.094s
total : 0.453s
--
1152
--
Program : stack exec -- ghc -e "flip minPath (${i},${i}) <$> getSample" src/MinPath.hs
CPU : 96%
user : 0.336s
system : 0.097s
total : 0.447s
--
1149
--
Program : stack exec -- ghc -e "flip minPath (${i},${i}) <$> getSample" src/MinPath.hs
CPU : 97%
user : 0.345s
system : 0.099s
total : 0.455s
--
1282
--
Program : stack exec -- ghc -e "flip minPath (${i},${i}) <$> getSample" src/MinPath.hs
CPU : 98%
user : 0.353s
system : 0.091s
total : 0.453s
--
ほとんど、変化がありませんね。点minPath
の値を計算量オーダーが指数オーダーから、
minPath
は、指定の1点のみを計算するより、指定の点より前にある点もすべて求めてから、指定の点をインデックスアクセスするほうが効率がよいということになります。
このような計算構造は、1次元の例ですが、フィボナッチ数を求めるのにも(よりシンプルですが)現れます。
fib :: Array Int Integer -> Int -> Integer
fib sa n = ta ! n
where
ta = listArray (bounds sa) (phi <$> assocs sa)
phi = \ case
(0,j) -> j
(1,j) -> j
(i,_) -> ta ! (i-2) + ta ! (i-1)
nats :: Array Int Integer
nats = listArray (0,1000) [0 .. 1000]
% time stack exec -- ghc -e 'fib nats 1000' src/Fib.hs
43466557686937456435688527675040625802564660517371780402481729089536555417949051890403879840079255169295922593080322634775209689623239873322471161642996440906533187938298969649928516003704476137795166849228875
--
Program : stack exec -- ghc -e 'fib nats 1000' src/Fib.hs
CPU : 95%
user : 0.274s
system : 0.063s
total : 0.352s
--
scanArray
一般化: minPath
は配列sa :: Array (Int, Int) Int
を別の配列ta :: Array (Int,Int) Int
に変換して、ta
にインデックスアクセスするというものでした。この配列変換は蓄積演算をパラメータとして一般化できそうです。
scanArray :: (Ix i, Enum i)
=> (a -> b) -- 左上の点(直前点なし)
-> (b -> a -> b) -- 左辺または上辺上の点(直前点は(i,j-1)または(i-1,j)のどちらか1つ)
-> (b -> b -> b -> a -> b) -- 内部の点(直前点は(i-1,j-1)、(i,j-1)、(i-1,j)の3つ
-> Array (i,i) a
-> Array (i,i) b
scanArray f g h sa = ta where
ta = listArray (bounds sa) (phi <$> assocs sa)
phi = \ case
(ij@(i,j),a)
| ij == ij0 -> f a
| i == i0 -> g (ta ! second pred ij) a
| j == j0 -> g (ta ! first pred ij) a
| otherwise -> h (ta ! (pred *** pred) ij) (ta ! first pred ij) (ta ! second pred ij) a
where
ij0 = fst (bounds sa)
i0 = fst ij0
j0 = snd ij0
scanArray
を使うと minPath
は以下のように定義できます。
minPath2 :: Array (Int,Int) Int -> (Int,Int) -> Int
minPath2 = (scanArray f g h !) where
f = id
g = (+)
h _ a b c = a `min` b + c
% time stack exec -- ghc -e 'flip minPath2 (100,100) <$> getSample' src/MinPath.hs
5544
--
Program : stack exec -- ghc -e 'flip minPath2 (100,100) <$> getSample' src/MinPath.hs
CPU : 98%
user : 0.521s
system : 0.148s
total : 0.679s
--
minPath
が経路最小和でしたから、min
の部分をmax
に置きかえれば、経路最大和maxPath
ができあがります。
maxPath :: Array (Int,Int) Int -> (Int,Int) -> Int
maxPath = (scanArray f g h !) where
f = id
g = (+)
h _ a b c = a `max` b + c
scanArray
は、二次元の累積和の一般化とみなすことができるので、累積和行列を構成することもできます。
cumulativeSum2D :: Num a => Array (Int,Int) a -> Array (Int,Int) a
cumulativeSum2D = scanArray f g h where
f = id
g = (+)
h a b c d = subtract a (b + c + d)
まとめ
minPath
をプログラムするというところからはじめて、つれづれにプログラムして、汎用になりそうな、scanArray
に到達しました。
scanArray
は結構便利そうなので、どこかのパッケージですでに、似たもの、あるいは、より一般化されたものが、採録されていそうですね。
筆者の他愛のないプログラミングの楽しみの断片を紹介しました。計測、分析はおおざっぱです。「ふう〜ん、そうするんだ」なんて思っていただければ、幸いです。
今回、書きちらしたコードは、筆者の書き散らし箱(今回つくった^^;) においてあります。御笑覧ください。
Discussion