迷路解析・改良

改良してみた。

Pointという正格データ型を作り、Array、Set、Sequenceというデータ型を駆使してみた。けど、時間を測ってみると、それほど替わりはない(どちらにせよ0.005秒とかだけど)。

また、Data.Unambライブラリを使ってみたかったので、簡単な並列化、'S'から'G'へと'G'から'S'への二経路を探索するようにしてみた。

2Coreでうまくスレッドが機能すれば、より早いほうで結論をだせるけど、いわば逆向きのほとんど同じ計算を2スレッドでやっているだけなので、1Coreだとか、スレッドがうまく機能しないだとかだと、本来的には効率が落ちているかも。

でも、僕の手元では、うまく機能しているっぽい。

-- Labyrinth.hs
module Main where

import Data.Array (Array, Ix, array, (!))
import Data.Function (on)
import Data.Unamb
import Data.Set (Set, empty, member, insert, fromList)
import Data.Sequence (Seq, ViewL(..), viewl, singleton, (|>))
import System (getArgs, exitFailure)

import Debug.Trace

data Point = Point !Int !Int
  deriving (Eq, Show, Ix)

instance Ord Point where
  compare (Point x1 y1) (Point x2 y2) =
    let yc = compare y1 y2
    in if yc == EQ then compare x1 x2 else yc

main = do
  args <- getArgs
  if length args /= 1 && length args /= 2
    then putStrLn "Usage: Labyrinth imput [output]" >> exitFailure
    else return ()

  input <- readFile (args!!0)
  let (list, start, goal, range) = parse input
      labyrinth = array (Point 0 0 , range) list

  path <- search labyrinth start goal
          `amb` search labyrinth goal start

  let output = layout list path

  if length args == 2
    then writeFile (args!!1) output
    else putStrLn output

parse :: String -> ([(Point, Char)], Point, Point, Point)
parse l = build l [] (Point 0 0) (Point 0 0) 0 0 0 0
    where
      build :: String -> [(Point, Char)]
               -> Point -> Point
               -> Int -> Int -> Int -> Int
               -> ([(Point, Char)], Point, Point, Point)
      build [] pmap start goal x y mx my = (pmap, start, goal, Point mx my)
      build (h:t) pmap start goal x y mx my
        | h == 'S'  = build t newmap pos   goal (x+1) y    nmx nmy
        | h == 'G'  = build t newmap start pos  (x+1) y    nmx nmy
        | h == '\n' = build t newmap start goal  0   (y+1) nmx nmy
        | otherwise = build t newmap start goal (x+1) y    nmx nmy
        where
          pos = Point x y
          newmap = (pos, h) : pmap
          nmx = max x mx
          nmy = max y my

search :: Array Point Char -> Point -> Point -> [Point]
search labyrinth start goal = search' empty (singleton [start])
  where
    search' :: Set Point -> Seq [Point] -> [Point]
    search' checked queue 
      | pos == goal              = path
      | (member pos checked)     = search' checked rest
      | labyrinth!pos == '*'     = search' (insert pos checked) rest
      | otherwise                = search' (insert pos checked) next
      where
        ((:<) path@(pos@(Point x y):_) rest) = viewl queue
        next = rest
               |> (Point  x   (y+1) : path)
               |> (Point  x   (y-1) : path)
               |> (Point (x+1) y    : path)
               |> (Point (x-1) y    : path)

layout :: [(Point,Char)] -> [Point] -> String
layout list path = foldl (flip $ (:).snd.conv) [] list
  where
    path' = fromList path
    conv t@(pos, c)
      | c == ' ' && member pos path' = (pos, '$')
      | otherwise                    = t

search'が経路探索の本体部分:

    search' :: Set Point -> Seq [Point] -> [Point]
    search' checked queue 
      | pos == goal              = path
      | (member pos checked)     = search' checked rest
      | labyrinth!pos == '*'     = search' (insert pos checked) rest
      | otherwise                = search' (insert pos checked) next
      where
        ((:<) path@(pos@(Point x y):_) rest) = viewl queue
        next = rest
               |> ...

何て綺麗な末尾再帰

path、pos、x、yの値をとっている部分が読みにくい。読みやくすくは出きるけど、これが1番よく呼び出される部分だから、一気にパターンマッチでとったほうが早くなるかなと思って、こうした。


入力データを解析している関数parseは複雑:

parse :: String -> ([(Point, Char)], Point, Point, Point)
parse l = build l [] (Point 0 0) (Point 0 0) 0 0 0 0
    where
      build :: String -> [(Point, Char)]
               -> Point -> Point
               -> Int -> Int -> Int -> Int
               -> ([(Point, Char)], Point, Point, Point)
      build [] pmap start goal x y mx my = (pmap, start, goal, Point mx my)
      build (h:t) pmap start goal x y mx my
        | h == 'S'  = build t newmap pos   goal (x+1) y    nmx nmy
        | h == 'G'  = build t newmap start pos  (x+1) y    nmx nmy
        | h == '\n' = build t newmap start goal  0   (y+1) nmx nmy
        | otherwise = build t newmap start goal (x+1) y    nmx nmy
        where ...

文字列([Char])を末尾再帰で解析し、最終的に返す(1)それぞれの文字の位置のリスト、(2)Sの位置、(3)Gの位置、(4)配列にするときのサイズの四つの情報と、計算のために必要な情報の全部明示的に持ち運んでいるため、関数引数が長くなっている。

これは、手続き型言語ならグローバル変数でも使うところ。Stateモナドを使えば綺麗になるかな?

あと連結するためのリストも持ち運んでいるから、文字列が逆向きになる。再帰部分から文字を返させて連結していけば、逆向きにはならないんだけど、連結させる文字だけを他の返り値から分離させるのが面倒なのと、そうしたら末尾再帰にならないので、これはこれで良いか。


それを、最後の出力関数layoutでもう一度ひっくり返している:

layout :: [(Point,Char)] -> [Point] -> String
layout list path = foldl (flip $ (:).snd.conv) [] list
  where
    path' = fromList path
    conv t@(pos, c)
      | c == ' ' && member pos path' = (pos, '$')
      | otherwise                    = t

「foldl (flip $ (:).snd.conv)」の部分がそう。実質的に、評判の悪いreverse関数を二回使って、最初と同じ順番で(ほぼ)同内容のリストを得ているのと同じ。ただし、今の実装では、入力部parseでも、出力部layoutでも全要素の走査が必要なので、二回reverseでも問題はない。

ただ、これも手続き型言語なら、走査なんかせずに確保しているデータ構造を破壊的に変更して終わり、となるところ。haskellでも少なくともlayoutでの全走査は必須ではないので、その辺はもっと改良できる余地がある*1


全体的に、計算の主要部分は綺麗な末尾再帰でいかにも関数型言語的に書け、しかもコンパクトになっている。トークンの数とかからいえば、同じ内容をこれ以上短く書ける言語はそうないのではないか、と思う。

でも、入出力部で、手続き型言語に比べてコードが間延びしているし、手続き型言語以上に考えなくてはいけない感じ。


最後に平行化をしている部分:

  path <- search labyrinth start goal
          `amb` search labyrinth goal start

これだけ。このamb関数一つが、中置演算子のように書かれている関数たった一つが、平行化、スレッドの生成と先に結論の出たほうを採用するというロジックを実現している。

すごくない? 使ってみたいですよね、これ。Data.Unambライブラリ。

*1:それで速度が向上しても、プログラム全体の処理コストと比較すれば微々たるものだと思うけど。