Come migliorare l'efficienza con la programmazione funzionale?

19

Recentemente ho esaminato la ti ho imparato una guida Haskell per il massimo e come pratica ho voluto risolvere Project Euler Problem 5 con esso, che specifica:

What is the smallest positive number that is evenly divisible by all of the numbers from 1 to 20?

Ho deciso di scrivere prima una funzione per determinare se un dato numero è divisibile con questi numeri:

divisable x = all (\y -> x 'mod' y == 0)[1..20]

Poi ho calcolato il più piccolo usando head :

sm = head [x | x <- [1..], divisable x]

E alla fine ha scritto la riga per visualizzare il risultato:

main = putStrLn $ show $ sm

Purtroppo ci sono voluti circa 30 secondi per terminare. Fare la stessa cosa con i numeri da 1 a 10 produce un risultato quasi immediatamente, ma poi di nuovo il risultato è molto più piccolo della soluzione da 1 a 20.

L'ho risolto prima in C e il risultato per 1 a 20 è stato calcolato anche quasi istantaneamente. Questo mi porta a credere che sto fraintendendo come interpretare questo problema per Haskell. Ho esaminato le soluzioni degli altri e ho trovato questo:

main = putStrLn $ show $ foldl1 lcm [1..20]

Abbastanza corretto, questo usa una funzione built-in, ma perché il risultato finale è molto più lento quando lo fai da solo? Le esercitazioni là fuori ti dicono come usare Haskell, ma non vedo molto aiuto con la trasformazione degli algoritmi in codice veloce.

    
posta Overv 28.01.2013 - 14:16
fonte

3 risposte

25

Per prima cosa devi assicurarti di avere un binario ottimizzato, prima di pensare che la lingua sia il problema. Leggi il capitolo Profilazione e ottimizzazione in Real Wolrd Haskell. Vale la pena notare che nella maggior parte dei casi la natura di alto livello della lingua ti costa almeno parte delle prestazioni.

Tuttavia, tieni presente che l'altra soluzione è non più veloce perché utilizza una funzione integrata, ma semplicemente perché utilizza un algoritmo molto più veloce : per trovare il minimo multiplo comune di una serie di numeri necessari per trovare solo alcuni GCD. Confronta questo con la tua soluzione, che passa da tutti i numeri da 1 a foldl lcm [1..20] . Se provi con 30, la differenza tra i runtime sarà ancora maggiore.

Dai un'occhiata alle complessità: il tuo algoritmo ha O(ans*N) runtime, dove ans è la risposta e N è il numero fino al quale stai verificando la divisibilità (20 nel tuo caso).
L'altro algoritmo esegue N volte lcm , tuttavia lcm(a,b) = a*b/gcd(a,b) e GCD ha complessità O(log(max(a,b))) . Pertanto il secondo algoritmo ha una complessità O(N*log(ans)) . Puoi giudicare tu stesso che è più veloce.

Quindi, per riassumere:
Il tuo problema è il tuo algoritmo, non la lingua.

Si noti che esistono linguaggi specializzati che sono sia funzionali che focalizzati su programmi matematici, come Mathematica, che per problemi di matematica è probabilmente più veloce di qualsiasi altra cosa. Ha una libreria di funzioni ottimizzata e supporta il paradigma funzionale (ammette anche che supporti la programmazione imperativa).

    
risposta data 28.01.2013 - 14:50
fonte
5

Il mio primo pensiero è che solo i numeri divisibili per tutti i numeri primi < = 20 saranno divisibili per tutti i numeri minori di 20. Quindi devi solo considerare i numeri che sono multipli di 2 * 3 * 5 * 7 * 11 * 13 * 17 * 19. Tale soluzione controlla 1 / 9,699,690 quanti numeri quanti l'approccio forza bruta. Ma la tua soluzione Haskell veloce fa meglio di così.

Se capisco la soluzione "Haskell veloce", usa foldl1 per applicare la funzione lcm (minimo comune multiplo) all'elenco dei numeri da 1 a 20. Quindi applicherebbe lcm 1 2, ottenendo 2. Quindi lcm 2 3 cedendo 6. Quindi lcm 6 4 ottenendo 12, e così via. In questo modo, la funzione lcm viene chiamata solo 19 volte per ottenere la risposta. Nella notazione Big O, si tratta di operazioni O (n-1) per arrivare a una soluzione.

La tua soluzione slow-Haskell passa attraverso i numeri 1-20 per ogni numero da 1 alla tua soluzione. Se chiamiamo solution s, allora la soluzione Haskell lento esegue operazioni O (s * n). Sappiamo già che s è più di 9 milioni, quindi probabilmente spiega la lentezza. Anche se tutte le scorciatoie e ottengono una media di metà della lista dei numeri 1-20, è ancora solo O (s * n / 2).

Chiamare head non ti salva dal fare questi calcoli, devono essere fatti per calcolare la prima soluzione.

Grazie, questa è stata una domanda interessante. Ha davvero allungato la mia conoscenza di Haskell. Non sarei in grado di rispondere affatto se non avessi studiato gli algoritmi lo scorso autunno.

    
risposta data 28.01.2013 - 15:08
fonte
1

Inizialmente non avevo intenzione di scrivere una risposta. Ma mi è stato detto dopo che un altro utente ha fatto la strana affermazione che semplicemente moltiplicando i primi numeri primi era più dispendioso dal punto di vista computazionale, quindi si applicava ripetutamente lcm . Quindi ecco i due algoritmi e alcuni benchmark:

Il mio algoritmo:

Algoritmo di prima generazione, che mi dà una lista infinita di numeri primi.

isPrime :: Int -> Bool
isPrime 1 = False
isPrime n = all ((/= 0) . mod n) (takeWhile ((<= n) . (^ 2)) primes)

toPrime :: Int -> Int
toPrime n 
    | isPrime n = n 
    | otherwise = toPrime (n + 1)

primes :: [Int]
primes = 2 : map (toPrime . (+ 1)) primes

Ora usando quell'elenco principale per calcolare il risultato per un po 'di N :

solvePrime :: Integer -> Integer
solvePrime n = foldl' (*) 1 $ takeWhile (<= n) (fromIntegral <$> primes)

Ora l'altro algoritmo basato su lcm, che è certamente abbastanza conciso, principalmente perché ho implementato la generazione primaria da zero (e non ho usato l'algoritmo di comprensione degli elenchi super conciso a causa delle sue scarse prestazioni) mentre lcm è stato semplicemente importato dal Prelude .

solveLcm :: Integer -> Integer
solveLcm n = foldl' (flip lcm) 1 [2 .. n]
-- Much slower without 'flip' on 'lcm'

Ora per i benchmark, il codice che ho usato per ciascuno era semplice: ( -prof -fprof-auto -O2 poi +RTS -p )

main :: IO ()
main = print $ solvePrime n
-- OR
main = print $ solveLcm n

Per n = 100,000 , solvePrime :

total time = 0.04 secs
total alloc = 108,327,328 bytes

vs solveLcm :

total time = 0.12 secs
total alloc = 117,842,152 bytes

Per n = 1,000,000 , solvePrime :

total time = 1.21 secs
total alloc = 8,846,768,456 bytes

vs solveLcm :

total time = 9.10 secs
total alloc = 8,963,508,416 bytes

Per n = 3,000,000 , solvePrime :

total time = 8.99 secs
total alloc = 74,790,070,088 bytes

vs solveLcm :

total time = 86.42 secs
total alloc = 75,145,302,416 bytes

Penso che i risultati parlano da soli.

Il profiler indica che la prima generazione occupa una percentuale sempre più piccola del tempo di esecuzione con incrementi di n . Quindi non è il collo di bottiglia, quindi possiamo ignorarlo per ora.

Questo significa che stiamo davvero confrontando chiamando lcm dove un argomento va da 1 a n , e l'altro va geometricamente da 1 a ans . Chiamare * con la stessa situazione e il vantaggio aggiuntivo di saltare tutti i numeri non primi (asintoticamente gratis, a causa della natura più costosa di * ).

Ed è risaputo che * è più veloce di lcm , in quanto lcm richiede applicazioni ripetute di mod e mod è asintoticamente più lento ( O(n^2) rispetto a ~O(n^1.5) ).

Quindi i risultati sopra riportati e la breve analisi dell'algoritmo dovrebbero rendere molto evidente quale algoritmo è più veloce.

    
risposta data 10.10.2016 - 09:37
fonte

Leggi altre domande sui tag