Memoization w Haskell?

Wszelkie wskazówki, jak skutecznie rozwiązać następującą funkcję w Haskell, dla dużych liczb (n > 108)

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

Widziałem przykłady memoizacji w Haskell do rozwiązania Fibonacciego liczb, co wiązało się z obliczaniem (leniwie) wszystkich liczb Fibonacciego do wymaganego N. Ale w tym przypadku, dla danego n, musimy tylko Oblicz bardzo niewiele wyników pośrednich.

Dzięki

Author: Chetan, 2010-07-09

8 answers

Możemy to zrobić bardzo efektywnie, tworząc strukturę, którą możemy indeksować w czasie sublinearnym.

Ale najpierw,

{-# LANGUAGE BangPatterns #-}

import Data.Function (fix)

Zdefiniujmy f, Ale niech używa 'open recursion' zamiast wywoływać się bezpośrednio.

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)

Możesz uzyskać niezemoizowany f używając fix f

To pozwoli Ci sprawdzić, że f robi to, co masz na myśli dla małych wartości f przez wywołanie, na przykład: fix f 123 = 144

Możemy to zapamiętać przez definiowanie:

f_list :: [Int]
f_list = map (f faster_f) [0..]

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

To działa passably dobrze i zastępuje to, co miało zająć O(N^3) czas czymś, co zapamiętuje pośrednie wyniki.

Ale wciąż potrzeba liniowego czasu, aby zindeksować, aby znaleźć zapamiętaną odpowiedź dla mf. Oznacza to, że wyniki takie jak:

*Main Data.List> faster_f 123801
248604

Są tolerowane, ale wynik nie skaluje się znacznie lepiej niż to. Stać nas na więcej!

Najpierw zdefiniujmy nieskończone drzewo:

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)

I wtedy będziemy zdefiniuj sposób indeksowania do niego, abyśmy mogli znaleźć węzeł z indeksem n w o (log n) czas zamiast:

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

... i możemy znaleźć drzewo pełne liczb naturalnych, aby było wygodne, więc nie musimy bawić się tymi indeksami: {]}

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

Ponieważ możemy indeksować, możesz po prostu przekonwertować drzewo na listę:

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

Możesz sprawdzić dotychczasową pracę sprawdzając, czy toList nats daje [0..]

Teraz,

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

fastest_f :: Int -> Int
fastest_f = index f_tree

Działa tak jak z powyższą listą, ale zamiast wziąć czas liniowy, aby znaleźć każdy węzeł, może ścigać go w czasie logarytmicznym.

Wynik jest znacznie szybszy:

*Main> fastest_f 12380192300
67652175206

*Main> fastest_f 12793129379123
120695231674999

W rzeczywistości jest o wiele szybciej, że można przejść i zastąpić Int z Integer powyżej i uzyskać śmiesznie Duże odpowiedzi niemal natychmiast

*Main> fastest_f' 1230891823091823018203123
93721573993600178112200489

*Main> fastest_f' 12308918230918230182031231231293810923
11097012733777002208302545289166620866358
 229
Author: Edward KMETT,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2017-11-28 06:07:45

Odpowiedź Edwarda jest tak cudownym klejnotem, że zduplikowałem ją i dostarczyłem implementacji memoList i memoTree kombinatorów, które zapamiętują funkcję w formie otwartej rekurencyjnej.

{-# 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
 16
Author: Tom Ellis,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2017-05-23 12:26:36

Nie najskuteczniejszy sposób, ale robi memoize:

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

Podczas żądania f !! 144 sprawdza się, czy f !! 143 istnieje, ale jego dokładna wartość nie jest obliczana. Nadal jest to jakiś nieznany wynik obliczeń. Tylko dokładne obliczone wartości są potrzebne.

Więc początkowo, jeśli chodzi o to, ile zostało obliczone, program nic nie wie.

f = .... 

Kiedy wykonamy żądanie f !! 12, zaczyna ono dopasowywać wzór:

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

Teraz to rozpoczyna obliczanie

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

To rekurencyjnie powoduje kolejne zapotrzebowanie na f, więc obliczamy

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

Teraz możemy zrobić backup

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

Co oznacza, że program już wie:

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

Ciąg dalszy:

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

Co oznacza, że program już wie:

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

Teraz kontynuujemy nasze obliczenia 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

Co oznacza, że program już wie:

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

Teraz kontynuujemy nasze obliczenia 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

Co oznacza, że program już wie:

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

Więc obliczenia są wykonywane dość leniwie. Program wie, że istnieje jakaś wartość f !! 8, która jest równa g 8, ale nie ma pojęcia, czym jest g 8.

 12
Author: rampion,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2010-07-08 22:13:58

Jak stwierdzono w odpowiedzi Edwarda Kmetta, aby przyspieszyć wszystko, musisz buforować kosztowne obliczenia i mieć do nich szybki dostęp.

Aby funkcja nie była monadyczna, rozwiązanie budowania nieskończonego leniwego drzewa z odpowiednim sposobem indeksowania go (jak pokazano w poprzednich postach) spełnia ten cel. Jeśli zrezygnujesz z niemonadowego charakteru funkcji, możesz użyć standardowych kontenerów asocjacyjnych dostępnych w Haskell w połączeniu z monadami "stanowymi" (takimi jak stan lub ST).

Podczas gdy główną wadą jest to, że otrzymujesz funkcję niemonadyczną, nie musisz już sam indeksować struktury i możesz po prostu korzystać ze standardowych implementacji kontenerów asocjacyjnych.

Aby to zrobić, musisz najpierw napisać ponownie funkcję, aby zaakceptować dowolny rodzaj monady:

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)

Dla Twoich testów, nadal możesz zdefiniować funkcję, która nie robi memoizacji za pomocą danych.Funkcja.fix, chociaż jest nieco bardziej wyrazisty:

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

Możesz wtedy użyć State monad w połączenie z danymi.Mapa przyspieszająca:

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'

Dzięki niewielkim zmianom można dostosować kod do pracy z danymi.HashMap zamiast:

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'

Zamiast trwałych struktur danych, możesz również wypróbować zmienne struktury danych (takie jak dane.HashTable) w połączeniu ze 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'

W porównaniu z implementacją bez żadnej memoizacji, każda z tych implementacji pozwala na uzyskanie wyników w mikro-sekundach, zamiast czekać kilka sekund.

Używając kryterium jako benchmark, mogłem zauważyć, że implementacja z danymi.HashMap faktycznie wypadł nieco lepiej (około 20%) niż dane.Mapa i dane.HashTable, dla których czasy były bardzo podobne.

Wyniki benchmarka uznałem za nieco zaskakujące. Początkowo miałem wrażenie, że HashTable przewyższy implementację HashMap, ponieważ jest zmienna. W tej ostatniej implementacji może być ukryty pewien defekt wydajności.

 8
Author: Quentin,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2015-05-16 08:48:22

To jest dodatek do doskonałej odpowiedzi Edwarda Kmetta.

Kiedy próbowałem jego kodu, definicje nats i index wydawały się dość tajemnicze, więc napisałem alternatywną wersję, która okazała się łatwiejsza do zrozumienia.

Definiuję index i nats w kategoriach index' i nats'.

index' t n jest zdefiniowany w zakresie [1..]. (Przypomnijmy, że {[11] } jest zdefiniowany w zakresie [0..].) Działa przeszukiwanie drzewa traktując n jako ciąg bitów i czytając bity w odwrotnej kolejności. Jeśli bit jest 1, to pobiera gałąź prawej ręki. Jeśli bit jest 0, przyjmuje gałąź lewej ręki. Zatrzymuje się, gdy osiągnie ostatni bit (który musi być 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'

Tak jak nats jest zdefiniowane dla index tak, że index nats n == n jest zawsze prawdziwe, nats' jest zdefiniowane dla 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

Teraz nats i index są po prostu nats' i index', ale z wartościami przesuniętymi o 1:

index t n = index' t (n+1)
nats = fmap (\n -> n-1) nats'
 7
Author: Pitarou,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2012-06-16 04:59:35

Kilka lat później, spojrzałem na to i zdałem sobie sprawę, że istnieje prosty sposób na zapamiętanie tego w czasie liniowym za pomocą zipWith i funkcji pomocniczej:

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

dilate posiada właściwość handy dilate n xs !! i == xs !! div i n.

Więc, przypuśćmy, że otrzymamy f (0), upraszcza to obliczenie do

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

Wyglądający bardzo podobnie do naszego oryginalnego opisu problemu i dający liniowe rozwiązanie (sum $ take n fs zajmie O (n)).

 3
Author: rampion,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2015-10-11 03:03:57

Kolejny dodatek do odpowiedzi Edwarda Kmetta: samodzielny przykład:

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 

Użyj go w następujący sposób, aby zapamiętać funkcję z pojedynczą liczbą całkowitą arg (np.]}

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

Tylko wartości dla nieujemnych argumentów będą buforowane.

Aby również buforować wartości ujemnych argumentów, użyj memoInt, zdefiniowanego w następujący sposób:

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

Do wartości bufora dla funkcji z dwoma argumentami całkowitymi użyj memoIntInt, zdefiniowanego w następujący sposób:

memoIntInt f = memoInt (\n -> memoInt (f n))
 2
Author: Neal Young,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2014-01-26 01:38:28

Rozwiązanie bez indeksowania, a nie oparte na Edwardzie KMETCIE.]}

Uwzględniam wspólne podzbiory do wspólnego rodzica (f(n/4) jest dzielone pomiędzy f(n/2) i f(n/4), A f(n/6) jest dzielone między f(2) i f(3)). Zapisując je jako pojedynczą zmienną w rodzicu, obliczenie poddrzewa jest wykonywane raz.

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.

Kod nie łatwo rozszerza się na ogólną funkcję memoizacji (przynajmniej nie wiedziałbym, jak to zrobić), a naprawdę musisz pomyśleć, jak podproblemy nakładają się na siebie, ale strategia powinna działać dla ogólnych wielu nie-całkowitych parametrów. (Wymyśliłem to dla dwóch parametrów strunowych.)

Notatka jest odrzucana po każdym obliczeniu. (Znowu myślałem o dwóch parametrach strunowych.)

Nie wiem, czy to jest bardziej skuteczne niż inne odpowiedzi. Każde wyszukiwanie jest technicznie tylko jednym lub dwoma krokami ("spójrz na swoje dziecko lub dziecko dziecka"), ale może być dużo dodatkowego wykorzystania pamięci.

Edit: To rozwiązanie jeszcze się nie zgadza. Podział jest niekompletny.

Edit: teraz powinno to być dzielenie się dziećmi, ale zdałem sobie sprawę, że ten problem ma wiele nietrywialnego dzielenia się: n/2/2/2 i n/3/3 mogą być takie same. Problem nie pasuje do mojej strategii.

 2
Author: leewz,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2016-04-14 02:10:32