Como melhorar a eficiência com a programação funcional?

20

Recentemente, eu passei pelo guia Aprenda um Haskell para o bem grande e, como prática, queria resolver o problema do Projeto Euler 5 com ele, que especifica:

Qual é o menor número positivo que é igualmente divisível por todos os números de 1 a 20?

Decidi primeiro escrever uma função determinando se um determinado número é divisível por esses números:

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

Depois calculei o menor usando head:

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

E finalmente escreveu a linha para exibir o resultado:

main = putStrLn $ show $ sm

Infelizmente, isso levou cerca de 30 segundos para terminar. Fazer o mesmo com os números de 1 a 10 gera um resultado quase imediatamente, mas, novamente, o resultado é muito menor que a solução de 1 a 20.

Eu o resolvi anteriormente em C e lá o resultado de 1 a 20 também foi calculado quase instantaneamente. Isso me leva a acreditar que estou entendendo mal como interpretar esse problema para Haskell. Examinei as soluções de outras pessoas e descobri o seguinte:

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

É justo, isso usa uma função interna, mas por que o resultado final é muito mais lento quando você faz isso sozinho? Os tutoriais por aí mostram como usar o Haskell, mas não vejo muita ajuda na transformação de algoritmos em código rápido.

Overv
fonte
6
Devo salientar que muitos dos problemas resolvidos de Euler têm pdfs ao lado deles, que abordam o problema de matemática. Você pode tentar ler esse pdf e implementar o algoritmo descrito em cada idioma e depois criar um perfil.

Respostas:

25

Primeiro, você precisa ter um binário otimizado, antes de pensar que o idioma é o problema. Leia o capítulo Criação de perfil e otimização no Real Wolrd Haskell. Vale ressaltar que, na maioria dos casos, a natureza de alto nível do idioma custa pelo menos parte do desempenho.

No entanto, observe que a outra solução não é mais rápida porque usa uma função interna, mas simplesmente porque utiliza um algoritmo muito mais rápido : para encontrar o múltiplo menos comum de um conjunto de números, você precisa encontrar apenas alguns GCDs. Compare isso com a sua solução, que percorre todos os números de 1 a foldl lcm [1..20]. Se você tentar com 30, a diferença entre os tempos de execução será ainda maior.

Dê uma olhada nas complexidades: seu algoritmo tem O(ans*N)tempo de execução, onde ansestá a resposta e Né o número até o qual você está verificando a divisibilidade (20 no seu caso).
O outro algoritmo executa Ntempos lcm, no entanto lcm(a,b) = a*b/gcd(a,b), e o GCD tem complexidade O(log(max(a,b))). Portanto, o segundo algoritmo tem complexidade O(N*log(ans)). Você pode julgar por si mesmo o que é mais rápido.

Então, para resumir:
Seu problema é seu algoritmo, não a linguagem.

Observe que existem linguagens especializadas, funcionais e focadas em programas pesados ​​em matemática, como o Mathematica, que para problemas focados em matemática é provavelmente mais rápido do que quase qualquer outra coisa. Possui uma biblioteca de funções muito otimizada e suporta o paradigma funcional (é certo que também suporta programação imperativa).

K.Steff
fonte
3
Recentemente, tive um problema de desempenho com um programa Haskell e percebi que havia compilado com as otimizações desativadas. Alternando a otimização para melhorar o desempenho em cerca de 10 vezes. Portanto, o mesmo programa escrito em C ainda era mais rápido, mas Haskell não era muito mais lento (cerca de 2, 3 vezes mais lento, o que eu acho que é um bom desempenho, considerando também que eu não havia tentado melhorar o código Haskell mais). Bottom line: perfil e otimização é uma boa sugestão. 1
Giorgio
3
Sinceramente, acho que você pode remover os dois primeiros parágrafos, eles realmente não respondem à pergunta e provavelmente são imprecisos (certamente tocam rápido com a terminologia, os idiomas não podem ter velocidade)
jk.
1
Você está dando uma resposta contraditória. Por um lado, você afirma que o OP "não entendeu nada mal" e que a lentidão é inerente a Haskell. Por outro lado, você mostra que a escolha do algoritmo importa! Sua resposta seria muito melhor se pulasse os dois primeiros parágrafos, que são um tanto contraditórios com o restante da resposta.
Andrés F.
2
Recebendo feedback de Andres F. e jk. Decidi reduzir os dois primeiros parágrafos a algumas frases. Obrigado pelos comentários
K.Steff
5

Meu primeiro pensamento foi que apenas os números divisíveis por todos os números primos <= 20 serão divisíveis por todos os números menores que 20. Portanto, você só precisa considerar números que são múltiplos de 2 * 3 * 5 * 7 * 11 * 13 * 17 * 19 . Essa solução verifica 1 / 9.699.690 tantos números quanto a abordagem da força bruta. Mas sua solução rápida-Haskell se sai melhor que isso.

Se eu entendo a solução "rápida Haskell", ela usa foldl1 para aplicar a função lcm (mínimo múltiplo comum) à lista de números de 1 a 20. Portanto, aplicaria lcm 1 2, produzindo 2. Em seguida, lcm 2 3 produz 6 Então lcm 6 4 rendendo 12, e assim por diante. Dessa forma, a função lcm é chamada apenas 19 vezes para fornecer sua resposta. Na notação Big O, são operações O (n-1) para chegar a uma solução.

Sua solução Haskell lenta percorre os números de 1 a 20 para cada número de 1 à sua solução. Se chamarmos a solução s, a solução lenta-Haskell executa operações O (s * n). Já sabemos que s é superior a 9 milhões, o que provavelmente explica a lentidão. Mesmo que todos atalhos atinjam a média da lista de números de 1 a 20, isso ainda é apenas O (s * n / 2).

A chamada headnão impede que você faça esses cálculos, eles precisam ser feitos para calcular a primeira solução.

Obrigado, essa foi uma pergunta interessante. Isso realmente ampliou meu conhecimento sobre Haskell. Eu não seria capaz de responder se não tivesse estudado algoritmos no outono passado.

GlenPeterson
fonte
Na verdade, a abordagem que você estava adotando com 2 * 3 * 5 * 7 * 11 * 13 * 17 * 19 provavelmente é pelo menos tão rápida quanto a solução baseada em lcm. O que você precisa especificamente é de 2 ^ 4 * 3 ^ 2 * 5 * 7 * 11 * 13 * 17 * 19. Como 2 ^ 4 é a maior potência de 2 menor ou igual a 20, e 3 ^ 2 é a maior potência de 3 menor ou igual a 20 e assim por diante.
ponto
@semicolon Embora definitivamente mais rápido que as outras alternativas discutidas, essa abordagem também requer uma lista pré-calculada de números primos, menor que o parâmetro de entrada. Se levarmos isso em tempo de execução (e, mais importante, no consumo de memória), esta abordagem, infelizmente, torna-se menos atraente
K.Steff
@ K.Steff Você está brincando comigo ... você precisa computar os primos até os 19 ... isso leva uma pequena fração de segundo. Sua afirmação faz absolutamente nenhum ZERO, o tempo total de execução da minha abordagem é incrivelmente pequeno, mesmo na geração principal. Ativei a criação de perfil e minha abordagem (em Haskell) obteve total time = 0.00 secs (0 ticks @ 1000 us, 1 processor)e total alloc = 51,504 bytes. O tempo de execução é uma proporção insignificante de segundo para nem ser registrado no criador de perfil.
ponto
@semicolon Eu deveria ter qualificado meu comentário, desculpe. Minha afirmação estava relacionada ao preço oculto do cálculo de todos os números primos até N - o eratóstenes ingênuo é O (N * log (N) * log (log (N))) e operações (O) - memória, o que significa que esta é a primeira componente do algoritmo que ficará sem memória ou tempo se N for realmente grande. Não fica muito melhor com a peneira de Atkin, então concluí que o algoritmo será menos atraente que o foldl lcm [1..N], que precisa de um número constante de bigints.
precisa saber é o seguinte
@ K.Steff Bem, acabei de testar os dois algoritmos. Para o meu algoritmo baseado em prime, o criador de perfis me forneceu (para n = 100.000): total time = 0.04 secse total alloc = 108,327,328 bytes. Para o outro algoritmo baseado em lcm, o profiler me deu: total time = 0.67 secse total alloc = 1,975,550,160 bytes. Para n = 1.000.000, obtive como base: total time = 1.21 secse total alloc = 8,846,768,456 bytes, e para lcm: total time = 61.12 secse total alloc = 200,846,380,808 bytes. Então, em outras palavras, você está errado, com base no prime é muito melhor.
ponto
1

Inicialmente, eu não estava pensando em escrever uma resposta. Mas fui informado de que depois que outro usuário fez a estranha afirmação de que simplesmente multiplicar os dois primeiros números primos era mais caro em termos de computação do que aplicar repetidamente lcm. Então, aqui estão os dois algoritmos e alguns benchmarks:

Meu algoritmo:

Algoritmo de geração principal, fornecendo uma lista infinita de números primos.

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

Agora, usando essa lista principal para calcular o resultado para alguns N:

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

Agora, o outro algoritmo baseado no lcm, que é bastante conciso, principalmente porque implementei a geração principal do zero (e não usei o algoritmo de compreensão da lista super concisa devido ao seu baixo desempenho), ao passo que lcmfoi simplesmente importado do Prelude.

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

Agora, para os benchmarks, o código que usei para cada um era simples: ( -prof -fprof-auto -O2então +RTS -p)

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

Para 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

Para 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

Para 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

Eu acho que os resultados falam por si.

O criador de perfil indica que a geração principal ocupa uma porcentagem cada vez menor do tempo de execução à medida que naumenta. Portanto, não é o gargalo, então podemos ignorá-lo por enquanto.

Isso significa que estamos realmente comparando a chamada em lcmque um argumento vai de 1 para ne o outro vai geometricamente de 1 para ans. Telefonar *com a mesma situação e o benefício adicional de pular todos os números não primos (assintoticamente de graça, devido à natureza mais cara *).

E é sabido que *é mais rápido do que lcm, pois lcmrequer aplicações repetidas de mode modé assintoticamente mais lento ( O(n^2)vs ~O(n^1.5)).

Portanto, os resultados acima e a breve análise do algoritmo devem tornar muito óbvio qual algoritmo é mais rápido.

ponto e vírgula
fonte