Come migliorare l'efficienza con la programmazione funzionale?


20

Di recente ho seguito la guida Learn You a Haskell for Great Good e come pratica volevo risolvere il problema del Project Euler 5 con esso, che specifica:

Qual è il numero positivo più piccolo che è uniformemente divisibile per tutti i numeri da 1 a 20?

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

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

Quindi ho calcolato il più piccolo usando head:

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

E infine ha scritto la riga per visualizzare il risultato:

main = putStrLn $ show $ sm

Sfortunatamente, il completamento ha richiesto circa 30 secondi. Fare la stessa cosa con i numeri da 1 a 10 produce un risultato quasi immediatamente, ma anche in questo caso il risultato è molto più piccolo della soluzione da 1 a 20.

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

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

Abbastanza giusto, questo utilizza una funzione integrata, ma perché il risultato finale è molto più lento quando lo fai da solo? I tutorial là fuori ti spiegano come usare Haskell, ma non vedo molto aiuto nel trasformare gli algoritmi in codice veloce.


6
Vorrei sottolineare che molti dei problemi risolti di Euler hanno pdf accanto a loro che affrontano il problema di matematica. Potresti provare a leggere quel pdf e implementare l'algoritmo descritto in ogni lingua e quindi profilarlo.

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 alcune delle prestazioni.

Tuttavia, si noti 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 comune multiplo di un insieme di numeri devi solo trovare alcuni GCD. Confronta questo con la tua soluzione, che scorre attraverso tutti i numeri da 1 a foldl lcm [1..20]. Se provi con 30, la differenza tra i tempi di autonomia sarà ancora maggiore.

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

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

Nota che ci sono linguaggi specializzati che sono sia funzionali che focalizzati su programmi matematici pesanti, come Mathematica, che per problemi incentrati sulla matematica è probabilmente più veloce di qualsiasi altra cosa. Ha una libreria di funzioni molto ottimizzata e supporta il paradigma funzionale (è vero che supporta anche la programmazione imperativa).


3
Di recente ho avuto un problema di prestazioni con un programma Haskell e poi mi sono reso conto di aver compilato le ottimizzazioni disattivate. Commutazione dell'ottimizzazione sulle prestazioni potenziate di circa 10 volte. Quindi lo stesso programma scritto in C era ancora più veloce, ma Haskell non era molto più lento (circa 2, 3 volte più lento, che penso sia una buona prestazione, anche considerando che non avevo ancora provato a migliorare il codice Haskell). In conclusione: la profilazione e l'ottimizzazione sono un buon suggerimento. +1
Giorgio,

3
onestamente penso che potresti rimuovere i primi due paragrafi, non rispondono davvero alla domanda e sono probabilmente imprecisi (certamente giocano in modo veloce e sciolto con la terminologia, le lingue non possono avere una velocità)
jk.

1
Stai dando una risposta contraddittoria. Da un lato, affermi che il PO "non ha frainteso nulla" e che la lentezza è inerente a Haskell. D'altra parte, mostri che la scelta dell'algoritmo è importante! La tua risposta sarebbe molto migliore se salta i primi due paragrafi, che sono in qualche modo contraddittori con il resto della risposta.
Andres F.

2
Ricevendo feedback da Andres F. e jk. Ho deciso di ridurre i primi due paragrafi a poche frasi. Grazie per i commenti
K.Steff,

5

Il mio primo pensiero è stato che solo i numeri divisibili per tutti i numeri primi <= 20 saranno divisibili per tutti i numeri inferiori a 20. Quindi è necessario considerare solo numeri multipli di 2 * 3 * 5 * 7 * 11 * 13 * 17 * 19 . Tale soluzione controlla 1 / 9.699.690 di quanti numeri si avvicina alla forza bruta. Ma la tua soluzione fast-Haskell fa di meglio.

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

La tua soluzione slow Haskell passa attraverso i numeri 1-20 per ogni numero compreso tra 1 e la tua soluzione. Se chiamiamo soluzione s, allora la soluzione slow Haskell esegue operazioni O (s * n). Sappiamo già che s è oltre 9 milioni, quindi questo probabilmente spiega la lentezza. Anche se tutte le scorciatoie e ottengono una media a metà dell'elenco dei numeri 1-20, è comunque solo O (s * n / 2).

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

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


In realtà l'approccio che stavi arrivando con 2 * 3 * 5 * 7 * 11 * 13 * 17 * 19 probabilmente è almeno altrettanto veloce della soluzione basata su mcm. Ciò di cui hai specificamente bisogno è 2 ^ 4 * 3 ^ 2 * 5 * 7 * 11 * 13 * 17 * 19. Perché 2 ^ 4 è la più grande potenza di 2 in meno o uguale a 20, e 3 ^ 2 è la più grande potenza di 3 inferiore o uguale a 20 e così via.
punto

@semicolon Anche se decisamente più veloce delle altre alternative discusse, questo approccio richiede anche un elenco precalcolato di numeri primi, più piccolo del parametro di input. Se lo consideriamo nel runtime (e, soprattutto, nell'impronta della memoria), questo approccio purtroppo diventa meno attraente
K.Steff

@ K.Steff Mi stai prendendo in giro ... devi computerizzare i numeri primi fino alle 19 ... che richiede una piccola frazione di secondo. La tua affermazione ha un senso assolutamente ZERO, la durata totale del mio approccio è incredibilmente piccola anche con la generazione primaria. Ho abilitato la profilazione e il mio approccio (in Haskell) ha ottenuto total time = 0.00 secs (0 ticks @ 1000 us, 1 processor)e total alloc = 51,504 bytes. Il tempo di esecuzione è una percentuale abbastanza trascurabile di un secondo per non registrarsi nemmeno sul profiler.
punto

@semicolon Avrei dovuto qualificare il mio commento, mi dispiace. La mia affermazione era correlata al prezzo nascosto del calcolo di tutti i numeri primi fino a N: l'ingenua Eratostene è O (N * log (N) * log (log (N))) operazioni e O (N) memoria che significa che questo è il primo componente dell'algoritmo che si esaurirà la memoria o il tempo se N è davvero grande. Non è molto meglio con il setaccio di Atkin, quindi ho concluso che l'algoritmo sarà meno attraente di quello foldl lcm [1..N], che ha bisogno di un numero costante di origini.
K.Steff,

@ K.Steff Beh, ho appena testato entrambi gli algoritmi. Per il mio algoritmo basato su prime il profiler mi ha dato (per n = 100.000): total time = 0.04 secse total alloc = 108,327,328 bytes. Per l'altro algoritmo basato su mcm il profiler mi ha dato: total time = 0.67 secse total alloc = 1,975,550,160 bytes. Per n = 1.000.000 ho ottenuto per prime based: total time = 1.21 secse total alloc = 8,846,768,456 bytes, e per mcm based: total time = 61.12 secse total alloc = 200,846,380,808 bytes. Quindi, in altre parole, ti sbagli, la base Prime è molto meglio.
punto

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 moltiplicare i primi primi due era più costoso dal punto di vista computazionale quindi applicare ripetutamentelcm . Quindi ecco i due algoritmi e alcuni benchmark:

Il mio algoritmo:

Algoritmo di prima generazione, che mi dà un elenco infinito 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 alcuni N:

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

Ora l'altro algoritmo basato su mcm, che è certamente abbastanza conciso, principalmente perché ho implementato la generazione primaria da zero (e non ho usato l'algoritmo di comprensione dell'elenco super conciso a causa delle sue scarse prestazioni) mentre è lcmstato semplicemente importato da 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 -O2quindi +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 parlino da soli.

Il profiler indica che la generazione principale occupa una percentuale sempre più piccola del tempo di esecuzione man mano che naumenta. Quindi non è il collo di bottiglia, quindi possiamo ignorarlo per ora.

Ciò significa che stiamo davvero confrontando la chiamata in lcmcui un argomento va da 1 a n, e l'altro va geometricamente da 1 a ans. Chiamare *con la stessa situazione e l'ulteriore vantaggio di riuscire a saltare tutti i numeri non primi (asintoticamente gratuitamente, a causa della natura più costosa di *).

Ed è risaputo che *è più veloce di lcm, poiché lcmrichiede ripetute applicazioni di mod, ed modè asintoticamente più lento ( O(n^2)vs~O(n^1.5) ).

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

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.