在Haskell中记忆化?


136

有关如何有效地解决Haskell中以下函数的所有问题的大量建议 (n > 108)

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

我已经在Haskell中看到了用于记忆斐波那契数的记忆示例,其中涉及(懒惰地)计算直到所需n的所有斐波那契数。但是在这种情况下,对于给定的n,我们只需要计算很少的中间结果。

谢谢


110
仅从某种意义上说,这是我在家做的工作:-)
Angel de Vicente 2010年

Answers:


256

我们可以通过建立一个可以在亚线性时间内建立索引的结构来非常有效地做到这一点。

但首先,

{-# LANGUAGE BangPatterns #-}

import Data.Function (fix)

让我们定义f,但是让它使用“开放递归”而不是直接调用自身。

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)

你可以得到一个unmemoized f使用fix f

这将使您通过调用来测试f对小数值表示的含义f,例如: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)

然后,我们将定义一个方法来索引,所以我们可以找到索引节点nO(log 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

事实上,它是如此之快,你可以去通过,并取代IntInteger以上并获得大的离谱的答案几乎瞬间

*Main> fastest_f' 1230891823091823018203123
93721573993600178112200489

*Main> fastest_f' 12308918230918230182031231231293810923
11097012733777002208302545289166620866358

3
我尝试了这段代码,有趣的是,f_faster似乎比f慢。我猜这些列表引用确实放慢了速度。nat和index的定义对我来说似乎很神秘,因此我添加了自己的答案,这可能会使事情变得更清楚。
Pitarou 2012年

5
无限列表的情况下必须处理一个链表111111111个项目。树的情况是处理log n *达到的节点数。
爱德华·KMETT

2
也就是说,列表版本必须为列表中的所有节点创建thunk,而树版本则避免创建很多节点。
汤姆·埃利斯

7
我知道这是一个比较老的帖子,但是不应f_treewhere子句中定义它以避免在调用之间将不必要的路径保存在树中?
dfeuer

17
将其填充到CAF中的原因是,您可以在通话中获得备忘录。如果我要记的电话费用很高,那么我可能会将其留在CAF中,因此此处显示了此技术。在实际的应用程序中,当然要记住永久性存储的收益和成本。但是,鉴于问题在于如何实现记忆,我认为使用故意避免在通话中记忆的技术来回答是一种误导,如果没有别的,那么这里的评论将向人们指出一个微妙的事实。;)
爱德华KMETT

17

爱德华的答案是如此美妙,我已经复制了它,并提供了以开放递归形式记忆函数的实现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

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 : ...

因此,计算是相当懒惰的。该程序知道for的一些值f !! 8等于g 8,但不知道是什么g 8


谢谢你这个 您将如何创建和使用二维解决方案空间?那会是列表列表吗?和g n m = (something with) f!!a!!b
vikingsteve 2014年

1
当然可以。对于一个真正的解决方案,不过,我可能会使用一个记忆化图书馆,像memocombinators
风铃草

不幸的是它是O(n ^ 2)。
Qumeric

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始终为true一样,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'

谢谢。我正在记住一个多元函数,这确实帮助我确定了索引和nat到底在做什么。
Kittsil

8

正如Edward Kmett的回答所述,要加快处理速度,您需要缓存昂贵的计算并能够快速访问它们。

为了使函数保持非单调,构建无限懒惰树并使用适当的索引方法(如先前文章中所示)的解决方案可以实现该目标。如果您放弃了该函数的非一元性,则可以将Haskell中可用的标准关联容器与“状态式”单子(例如State或ST)结合使用。

尽管主要缺点是获得了非一元函数,但您不必自己再为结构建立索引,而可以使用关联容器的标准实现。

为此,您首先需要重新编写函数以接受任何类型的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'

除了持久性数据结构,您还可以结合ST monad尝试使用可变数据结构(例如Data.HashTable):

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.Map和Data.HashTable相比,使用Data.HashMap的实现实际上要好一些(大约20%)。

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


2
GHC在围绕不变结构进行优化方面做得非常出色。C的直觉并不总是会成功。
约翰·泰瑞

3

几年后,我研究了这一点,并意识到有一种简单的方法可以使用线性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))。


2
因此它是一种生成性(corecursive?)或动态编程的解决方案。像通常的斐波那契一样,每个生成的值都占用O(1)时间。大!而且,EKMETT的解决方案就像对数的大斐波那契一样,可以更快地获取大量数字,而跳过很多中间值。这是对的吗?
内斯

也许它更接近汉明数字,将三个后向指针插入正在生成的序列中,并且每个指针沿其前进的速度不同。真的很漂亮
内斯

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))

2

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

余因子出公共子树到一个共同的父(f(n/4)之间共享f(n/2)f(n/4),和f(n/6)之间共享f(2)f(3))。通过将它们保存为父变量中的单个变量,子树的计算只需完成一次。

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.

该代码不容易扩展到通用的记忆功能(至少,我不知道该怎么做),您确实必须考虑子问题如何重叠,但是该策略应该适用于通用的多个非整数参数。(我认为这是两个字符串参数。)

每次计算后,便笺将被丢弃。(再次,我正在考虑两个字符串参数。)

我不知道这是否比其他答案更有效。从技术上来说,每次查找仅需执行一个或两个步骤(“看孩子或孩子的孩子”),但是可能会占用大量额外的内存。

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

编辑:现在应该正确地共享子孩子,但是我意识到这个问题有很多不平凡的共享:n/2/2/2并且n/3/3可能是相同的。这个问题不适合我的策略。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.