Haskell中的记忆?

时间:2010-07-08 21:48:33

标签: haskell memoization

关于如何有效解决Haskell中的以下函数的任何指针,对于大数(n > 108)

f(n) = max(n, f(n/2) + f(n/3) + f(n/4))

我在Haskell中看到了用于解决fibonacci的memoization示例 数字,涉及计算(懒惰)所有斐波纳契数 达到要求的n。但在这种情况下,对于给定的n,我们只需要 计算很少的中间结果。

由于

8 个答案:

答案 0 :(得分:244)

我们可以通过制作一个可以在亚线性时间内索引的结构来非常有效地完成这项工作。

但首先,

{-# LANGUAGE BangPatterns #-}

import Data.Function (fix)

让我们定义f,但是让它使用'open recursion'而不是直接调用它自己。

f :: (Int -> Int) -> Int -> Int
f mf 0 = 0
f mf n = max n $ mf (n `div` 2) +
                 mf (n `div` 3) +
                 mf (n `div` 4)

您可以使用f

获取未展示的fix f

这样,您就可以通过调用来测试ff的小值的意义,例如:fix f 123 = 144

我们可以通过定义:

来记住这一点
f_list :: [Int]
f_list = map (f faster_f) [0..]

faster_f :: Int -> Int
faster_f n = f_list !! n

表现得非常好,并用记忆中间结果的东西取代了 O(n ^ 3)时间。

但是,为了找到mf的备忘答案,索引仍需要线性时间。这意味着结果如:

*Main Data.List> faster_f 123801
248604

是可以容忍的,但结果并没有比这更好。我们可以做得更好!

首先,让我们定义一个无限树:

data Tree a = Tree (Tree a) a (Tree a)
instance Functor Tree where
    fmap f (Tree l m r) = Tree (fmap f l) (f m) (fmap f r)

然后我们将定义一种索引方式,因此我们可以在 O(log n)时间内找到索引为n的节点:

index :: Tree a -> Int -> a
index (Tree _ m _) 0 = m
index (Tree l _ r) n = case (n - 1) `divMod` 2 of
    (q,0) -> index l q
    (q,1) -> index r q

...我们可能会发现一个充满自然数字的树很方便,所以我们不必乱用这些指数:

nats :: Tree Int
nats = go 0 1
    where
        go !n !s = Tree (go l s') n (go r s')
            where
                l = n + s
                r = l + s
                s' = s * 2

由于我们可以索引,您只需将树转换为列表:

toList :: Tree a -> [a]
toList as = map (index as) [0..]

您可以通过验证toList nats为您提供[0..]

来检查目前为止的工作

现在,

f_tree :: Tree Int
f_tree = fmap (f fastest_f) nats

fastest_f :: Int -> Int
fastest_f = index f_tree

与上面的列表一样工作,但不是花费线性时间来查找每个节点,而是可以在对数时间内追逐它。

结果相当快:

*Main> fastest_f 12380192300
67652175206

*Main> fastest_f 12793129379123
120695231674999

事实上,你可以通过以上Int取代Integer并快速获得可笑的大答案,速度快得多

*Main> fastest_f' 1230891823091823018203123
93721573993600178112200489

*Main> fastest_f' 12308918230918230182031231231293810923
11097012733777002208302545289166620866358

答案 1 :(得分:17)

Edward's answer是一个非常棒的宝石,我复制了它并提供了memoListmemoTree组合器的实现,这些组合器以开放递归的形式记忆函数。

{-# LANGUAGE BangPatterns #-}

import Data.Function (fix)

f :: (Integer -> Integer) -> Integer -> Integer
f mf 0 = 0
f mf n = max n $ mf (div n 2) +
                 mf (div n 3) +
                 mf (div n 4)


-- Memoizing using a list

-- The memoizing functionality depends on this being in eta reduced form!
memoList :: ((Integer -> Integer) -> Integer -> Integer) -> Integer -> Integer
memoList f = memoList_f
  where memoList_f = (memo !!) . fromInteger
        memo = map (f memoList_f) [0..]

faster_f :: Integer -> Integer
faster_f = memoList f


-- Memoizing using a tree

data Tree a = Tree (Tree a) a (Tree a)
instance Functor Tree where
    fmap f (Tree l m r) = Tree (fmap f l) (f m) (fmap f r)

index :: Tree a -> Integer -> a
index (Tree _ m _) 0 = m
index (Tree l _ r) n = case (n - 1) `divMod` 2 of
    (q,0) -> index l q
    (q,1) -> index r q

nats :: Tree Integer
nats = go 0 1
    where
        go !n !s = Tree (go l s') n (go r s')
            where
                l = n + s
                r = l + s
                s' = s * 2

toList :: Tree a -> [a]
toList as = map (index as) [0..]

-- The memoizing functionality depends on this being in eta reduced form!
memoTree :: ((Integer -> Integer) -> Integer -> Integer) -> Integer -> Integer
memoTree f = memoTree_f
  where memoTree_f = index memo
        memo = fmap (f memoTree_f) nats

fastest_f :: Integer -> Integer
fastest_f = memoTree f

答案 2 :(得分:12)

不是最有效的方式,但会记住:

f = 0 : [ g n | n <- [1..] ]
    where g n = max n $ f!!(n `div` 2) + f!!(n `div` 3) + f!!(n `div` 4)

在请求f !! 144时,会检查f !! 143是否存在,但不会计算其确切值。它仍然被设置为一些未知的计算结果。计算出的唯一精确值是需要的值。

所以最初,就计算了多少而言,程序一无所知。

f = .... 

当我们发出请求f !! 12时,它会开始进行一些模式匹配:

f = 0 : g 1 : g 2 : g 3 : g 4 : g 5 : g 6 : g 7 : g 8 : g 9 : g 10 : g 11 : g 12 : ...

现在开始计算

f !! 12 = g 12 = max 12 $ f!!6 + f!!4 + f!!3

这递归地对f提出另一个要求,所以我们计算

f !! 6 = g 6 = max 6 $ f !! 3 + f !! 2 + f !! 1
f !! 3 = g 3 = max 3 $ f !! 1 + f !! 1 + f !! 0
f !! 1 = g 1 = max 1 $ f !! 0 + f !! 0 + f !! 0
f !! 0 = 0

现在我们可以涓涓细流了一些

f !! 1 = g 1 = max 1 $ 0 + 0 + 0 = 1

这意味着该程序现在知道:

f = 0 : 1 : g 2 : g 3 : g 4 : g 5 : g 6 : g 7 : g 8 : g 9 : g 10 : g 11 : g 12 : ...

继续涓涓细流:

f !! 3 = g 3 = max 3 $ 1 + 1 + 0 = 3

这意味着该程序现在知道:

f = 0 : 1 : g 2 : 3 : g 4 : g 5 : g 6 : g 7 : g 8 : g 9 : g 10 : g 11 : g 12 : ...

现在我们继续计算f!!6

f !! 6 = g 6 = max 6 $ 3 + f !! 2 + 1
f !! 2 = g 2 = max 2 $ f !! 1 + f !! 0 + f !! 0 = max 2 $ 1 + 0 + 0 = 2
f !! 6 = g 6 = max 6 $ 3 + 2 + 1 = 6

这意味着该程序现在知道:

f = 0 : 1 : 2 : 3 : g 4 : g 5 : 6 : g 7 : g 8 : g 9 : g 10 : g 11 : g 12 : ...

现在我们继续计算f!!12

f !! 12 = g 12 = max 12 $ 6 + f!!4 + 3
f !! 4 = g 4 = max 4 $ f !! 2 + f !! 1 + f !! 1 = max 4 $ 2 + 1 + 1 = 4
f !! 12 = g 12 = max 12 $ 6 + 4 + 3 = 13

这意味着该程序现在知道:

f = 0 : 1 : 2 : 3 : 4 : g 5 : 6 : g 7 : g 8 : g 9 : g 10 : g 11 : 13 : ...

所以计算是相当懒惰的。该程序知道f !! 8的某些值存在,它等于g 8,但它不知道g 8是什么。

答案 3 :(得分:8)

这是Edward Kmett出色答案的附录。

当我尝试他的代码时,natsindex的定义似乎很神秘,所以我写了一个我觉得更容易理解的替代版本。

我根据indexnats来定义index'nats'

index' t n的定义范围为[1..]。 (回想一下,index t是在[0..]范围内定义的。)它可以通过将n视为一串位来搜索树,然后反向读取这些位。如果该位为1,则采用右侧分支。如果该位为0,则采用左侧分支。它到达最后一位(必须是1)时停止。

index' (Tree l m r) 1 = m
index' (Tree l m r) n = case n `divMod` 2 of
                          (n', 0) -> index' l n'
                          (n', 1) -> index' r n'

正如为nats定义了index,以便index nats n == n始终为真,nats'定义了index'

nats' = Tree l 1 r
  where
    l = fmap (\n -> n*2)     nats'
    r = fmap (\n -> n*2 + 1) nats'
    nats' = Tree l 1 r

现在,natsindex只是nats'index',但价值移动了1:

index t n = index' t (n+1)
nats = fmap (\n -> n-1) nats'

答案 4 :(得分:8)

正如Edward Kmett所说,为了加快速度,您需要缓存昂贵的计算并能够快速访问它们。

为了保持函数非monadic,构建无限懒惰树的解决方案,以及对其进行索引的适当方式(如前面的帖子中所示)实现了该目标。如果放弃函数的非monadic性质,可以将Haskell中可用的标准关联容器与“状态”monad(如State或ST)结合使用。

虽然主要的缺点是你得到一个非monadic函数,你不必再自己索引结构,并且只能使用关联容器的标准实现。

为此,首先需要重新编写函数来接受任何类型的monad:

fm :: (Integral a, Monad m) => (a -> m a) -> a -> m a
fm _    0 = return 0
fm recf n = do
   recs <- mapM recf $ div n <$> [2, 3, 4]
   return $ max n (sum recs)

对于您的测试,您仍然可以使用Data.Function.fix定义一个不进行任何记忆的函数,尽管它有点冗长:

noMemoF :: (Integral n) => n -> n
noMemoF = runIdentity . fix fm

然后,您可以将State monad与Data.Map结合使用来加快速度:

import qualified Data.Map.Strict as MS

withMemoStMap :: (Integral n) => n -> n
withMemoStMap n = evalState (fm recF n) MS.empty
   where
      recF i = do
         v <- MS.lookup i <$> get
         case v of
            Just v' -> return v' 
            Nothing -> do
               v' <- fm recF i
               modify $ MS.insert i v'
               return v'

通过微小的更改,您可以调整代码以使用Data.HashMap:

import qualified Data.HashMap.Strict as HMS

withMemoStHMap :: (Integral n, Hashable n) => n -> n
withMemoStHMap n = evalState (fm recF n) HMS.empty
   where
      recF i = do
         v <- HMS.lookup i <$> get
         case v of
            Just v' -> return v' 
            Nothing -> do
               v' <- fm recF i
               modify $ HMS.insert i v'
               return v'

您可以尝试将可变数据结构(如Data.HashTable)与ST monad结合使用,而不是持久数据结构:

import qualified Data.HashTable.ST.Linear as MHM

withMemoMutMap :: (Integral n, Hashable n) => n -> n
withMemoMutMap n = runST $
   do ht <- MHM.new
      recF ht n
   where
      recF ht i = do
         k <- MHM.lookup ht i
         case k of
            Just k' -> return k'
            Nothing -> do 
               k' <- fm (recF ht) i
               MHM.insert ht i k'
               return k'

与没有任何记忆的实现相比,任何这些实现​​都允许您在巨大的输入时以微秒为单位获得结果,而不必等待几秒钟。

使用Criterion作为基准,我可以观察到Data.HashMap的实现实际上比定时非常相似的Data.Map和Data.HashTable略好(大约20%)。

我发现基准测试的结果有点令人惊讶。我最初的感觉是HashTable的性能优于HashMap,因为它是可变的。在最后一个实现中可能隐藏了一些性能缺陷。

答案 5 :(得分:4)

几年后,我看到了这一点,并意识到使用zipWith和帮助函数在线性时间内记忆这是一个简单的方法:

dilate :: Int -> [x] -> [x]
dilate n xs = replicate n =<< xs

dilate具有dilate n xs !! i == xs !! div i n

的便利属性

因此,假设我们给出f(0),这将计算简化为

fs = f0 : zipWith max [1..] (tail $ fs#/2 .+. fs#/3 .+. fs#/4)
  where (.+.) = zipWith (+)
        infixl 6 .+.
        (#/) = flip dilate
        infixl 7 #/

看起来很像我们原来的问题描述,并提供线性解决方案(sum $ take n fs将采用O(n))。

答案 6 :(得分:2)

Edward Kmett答案的另一个附录:一个独立的例子:

data NatTrie v = NatTrie (NatTrie v) v (NatTrie v)

memo1 arg_to_index index_to_arg f = (\n -> index nats (arg_to_index n))
  where nats = go 0 1
        go i s = NatTrie (go (i+s) s') (f (index_to_arg i)) (go (i+s') s')
          where s' = 2*s
        index (NatTrie l v r) i
          | i <  0    = f (index_to_arg i)
          | i == 0    = v
          | otherwise = case (i-1) `divMod` 2 of
             (i',0) -> index l i'
             (i',1) -> index r i'

memoNat = memo1 id id 

按如下方式使用它来记忆具有单个整数arg的函数(例如fibonacci):

fib = memoNat f
  where f 0 = 0
        f 1 = 1
        f n = fib (n-1) + fib (n-2)

仅缓存非负参数的值。

要同时缓存负参数的值,请使用memoInt,定义如下:

memoInt = memo1 arg_to_index index_to_arg
  where arg_to_index n
         | n < 0     = -2*n
         | otherwise =  2*n + 1
        index_to_arg i = case i `divMod` 2 of
           (n,0) -> -n
           (n,1) ->  n

要使用两个整数参数缓存函数的值,请使用memoIntInt,定义如下:

memoIntInt f = memoInt (\n -> memoInt (f n))

答案 7 :(得分:2)

没有索引的解决方案,而不是基于Edward KMETT的。

我将公共子树分解为公共父级(f(n/4)f(n/2)之间共享f(n/4)f(n/6)和{{f(2)共享f(3) 1}})。通过将它们保存为父级中的单个变量,子树的计算可以完成一次。

data Tree a =
  Node {datum :: a, child2 :: Tree a, child3 :: Tree a}

f :: Int -> Int
f n = datum root
  where root = f' n Nothing Nothing


-- Pass in the arg
  -- and this node's lifted children (if any).
f' :: Integral a => a -> Maybe (Tree a) -> Maybe (Tree a)-> a
f' 0 _ _ = leaf
    where leaf = Node 0 leaf leaf
f' n m2 m3 = Node d c2 c3
  where
    d = if n < 12 then n
            else max n (d2 + d3 + d4)
    [n2,n3,n4,n6] = map (n `div`) [2,3,4,6]
    [d2,d3,d4,d6] = map datum [c2,c3,c4,c6]
    c2 = case m2 of    -- Check for a passed-in subtree before recursing.
      Just c2' -> c2'
      Nothing -> f' n2 Nothing (Just c6)
    c3 = case m3 of
      Just c3' -> c3'
      Nothing -> f' n3 (Just c6) Nothing
    c4 = child2 c2
    c6 = f' n6 Nothing Nothing

    main =
      print (f 123801)
      -- Should print 248604.

代码不容易扩展到一般的memoization函数(至少,我不知道怎么做),你真的必须考虑子问题如何重叠,但策略应该适用于一般的多个非整数参数。 (我想到了两个字符串参数。)

每次计算后都会丢弃备忘录。 (同样,我正在考虑两个字符串参数。)

我不知道这是否比其他答案更有效。每次查找在技术上只有一个或两个步骤(“看看你的孩子或你孩子的孩子”),但可能会有很多额外的内存使用。

编辑:此解决方案尚未正确。分享不完整。

编辑:现在应该正确分享子项,但我意识到这个问题有很多非常重要的共享:n/2/2/2n/3/3可能是相同的。这个问题不适合我的策略。