Qualcuno può spiegare il concetto alla base della memorizzazione di Haskell?


12

(nota che sto ponendo la domanda qui perché riguarda la meccanica concettuale di essa, piuttosto che un problema di codifica)

Stavo lavorando a un piccolo programma, che utilizzava una sequenza di numeri di fibonacci nella sua equasion, ma ho notato che se ho superato un certo numero è diventato dolorosamente lento, googling un po 'mi sono imbattuto in una tecnica in Haskell conosciuta come Memoization, hanno mostrato il funzionamento del codice in questo modo:

-- Traditional implementation of fibonacci, hangs after about 30
slow_fib :: Int -> Integer
slow_fib 0 = 0
slow_fib 1 = 1
slow_fib n = slow_fib (n-2) + slow_fib (n-1)

-- Memorized variant is near instant even after 10000
memoized_fib :: Int -> Integer
memoized_fib = (map fib [0 ..] !!)
   where fib 0 = 0
         fib 1 = 1
         fib n = memoized_fib (n-2) + memoized_fib (n-1)

Quindi la mia domanda per voi ragazzi è: come o piuttosto perché funziona?

È perché in qualche modo riesce a scorrere la maggior parte dell'elenco prima che il calcolo raggiunga? Ma se haskell è pigro, non c'è davvero alcun calcolo che deve recuperare ... Quindi, come funziona?


1
potresti chiarire con cosa intendi the calculation catches up? A proposito, la memoization non è specifica di haskell: en.wikipedia.org/wiki/Memoization
Simon Bergot

vedi la mia spiegazione sotto la risposta di Killan
Electric Coffee il

2
Adoro la tua domanda; solo una breve nota: la tecnica si chiama memo i zation, non memo ri zation.
Racheet,

Risposte:


11

Giusto per spiegare i meccanismi alla base della reale memorizzazione,

memo_fib = (map fib [1..] !!)

produce un elenco di "thunk", calcoli non valutati. Pensa a questi come regali non aperti, finché non li tocchiamo, non correranno.

Ora, una volta valutato un thunk, non lo valutiamo mai più. Questa è in realtà l'unica forma di mutazione nell'hashell "normale", i thunks mutano una volta valutati per diventare valori concreti.

Quindi, tornando al tuo codice, hai un elenco di thunk e fai ancora questa ricorsione dell'albero, ma ricerchi usando l'elenco e una volta valutato un elemento nell'elenco, non viene più calcolato. Pertanto, evitiamo la ricorsione dell'albero nella funzione ingenua fib.

Come nota tangenzialmente interessante, questo è particolarmente veloce su una serie di numeri di fibonnaci calcolati poiché tale elenco viene valutato solo una volta, il che significa che se si calcola memo_fib 10000due volte, la seconda volta dovrebbe essere istantanea. Questo perché Haskell ha valutato gli argomenti in base alle funzioni una sola volta e stai usando un'applicazione parziale invece di una lambda.

TLDR: memorizzando i calcoli in un elenco, ogni elemento dell'elenco viene valutato una volta, pertanto ogni numero di fibonnacci viene calcolato esattamente una volta nell'intero programma.

visualizzazione:

 [THUNK_1, THUNK_2, THUNK_3, THUNK_4, THUNK_5]
 -- Evaluating THUNK_5
 [THUNK_1, THUNK_2, THUNK_3, THUNK_4, THUNK_3 + THUNK_4]
 [THUNK_1, THUNK_2, THUNK_1 + THUNK_2, THUNK_4, THUNK_3 + THUNK_4]
 [1, 1, 1 + 1, THUNK_4, THUNK_3 + THUNK_4]
 [1, 1, 2, THUNK_4, 2 + THUNK4]
 [1, 1, 2, 1 + 2, 2 + THUNK_4]
 [1, 1, 2, 3, 2 + 3]
 [1, 1, 2, 3, 5]

Quindi puoi vedere come la valutazione THUNK_4è molto più veloce poiché le sue sottoespressioni sono già state valutate.


potresti fornire un esempio di come si comportano i valori nell'elenco per una breve sequenza? Penso che possa aggiungere alla visualizzazione di come dovrebbe funzionare ... E mentre è vero che se chiamo memo_fiblo stesso valore due volte, la seconda volta sarà istantanea, ma se lo chiamo con un valore 1 superiore, ci vuole ancora un'eternità per valutare (come diciamo dal 30 al 31)
Electric Coffee

@ElectricCoffee Aggiunto
Daniel Gratzer

@ElectricCoffee No, non lo farà da allora memo_fib 29e memo_fib 30sono già stati valutati, ci vorrà esattamente il tempo necessario per aggiungere quei due numeri :) Una volta che qualcosa è stato valutato, rimane valutato.
Daniel Gratzer,

1
@ElectricCoffee La tua ricorsione deve passare in rassegna l'elenco, altrimenti non otterrai alcuna performance
Daniel Gratzer,

2
@ElectricCoffee Sì. ma il 31 ° elemento dell'elenco non utilizza i calcoli passati, stai memorizzando sì, ma in un modo abbastanza inutile .. I calcoli che si ripetono non vengono calcolati due volte, ma hai comunque la ricorsione dell'albero per ogni nuovo valore che è molto, molto lento
Daniel Gratzer il

1

Il punto della memoizzazione non è mai quello di calcolare la stessa funzione due volte: questo è estremamente utile per accelerare i calcoli che sono puramente funzionali, cioè senza effetti collaterali, perché per quelli il processo può essere completamente automatizzato senza influire sulla correttezza. Ciò è particolarmente necessario per funzioni come fibo, che portano alla ricorsione dell'albero , cioè allo sforzo esponenziale, se attuate in modo ingenuo. (Questo è uno dei motivi per cui i numeri di Fibonacci sono in realtà un pessimo esempio per insegnare la ricorsione - quasi tutte le implementazioni demo che trovate in tutorial o libri sono inutilizzabili per valori di input di grandi dimensioni.)

Se segui il flusso di esecuzione, vedrai che nel secondo caso, il valore per fib xsarà sempre disponibile quando fib x+1viene eseguito e il sistema di runtime sarà in grado di leggerlo semplicemente dalla memoria anziché tramite un'altra chiamata ricorsiva, mentre il la prima soluzione tenta di calcolare la soluzione più grande prima che siano disponibili i risultati per valori più piccoli. Questo in definitiva perché l'iteratore [0..n]viene valutato da sinistra a destra e quindi inizierà con 0, mentre la ricorsione nel primo esempio inizia con ne solo allora chiede informazioni n-1. Questo è ciò che porta a molte, molte chiamate di funzione duplicate non necessarie.


oh capisco il punto, semplicemente non ho capito come funziona, come da quello che posso vedere nel codice, è che quando scrivi memorized_fib 20per esempio, in realtà stai solo scrivendo map fib [0..] !! 20, avrebbe comunque bisogno di calcolare il intera gamma di numeri fino a 20 o mi sto perdendo qualcosa qui?
Caffè elettrico

1
Sì, ma solo una volta per ogni numero. L'implementazione ingenua calcola fib 2così spesso che ti farà girare la testa - vai avanti, scrivi la pelliccia dell'albero delle chiamate solo un piccolo valore come n==5. Non dimenticherai mai più la memoizzazione dopo aver visto cosa ti salva.
Kilian Foth,

@ElectricCoffee: Sì, calcolerà fib da 1 a 20. Non ottieni nulla da quella chiamata. Ora prova a calcolare fib 21 e vedrai che invece di calcolare 1-21, puoi semplicemente calcolare 21 perché hai già calcolato 1-20 e non devi farlo di nuovo.
Phoshi,

Sto cercando di scrivere la struttura ad albero delle chiamate n = 5, e al momento ho raggiunto il punto in cui n == 3, finora tutto bene, ma forse è solo la mia mente imperativa a pensarlo, ma non significa solo che n == 3, ottieni map fib [0..]!!3? che poi va nel fib nramo del programma ... dove ottengo esattamente il vantaggio dei dati pre-calcolati?
Caffè elettrico

1
No, memoized_fibva bene. È slow_fibche ti farà piangere se lo rintracci.
Kilian Foth,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.