Fonksiyonel programlama ile verimlilik nasıl artırılır?


20

Geçenlerde Büyük İyi için Haskell Öğrenin kılavuzundan geçiyorum ve uygulama olarak Project Euler Problem 5'i onunla çözmek istedim , bu da şunları belirtiyor:

1'den 20'ye kadar olan tüm sayılarla eşit olarak bölünebilen en küçük pozitif sayı nedir?

Önce belirli bir sayının bu sayılarla bölünebilir olup olmadığını belirleyen bir işlev yazmaya karar verdim:

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

Sonra kullanarak en küçük olanı hesapladım head:

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

Sonunda sonucu görüntülemek için satır yazdı:

main = putStrLn $ show $ sm

Ne yazık ki bu işlemin tamamlanması yaklaşık 30 saniye sürdü. Aynı şeyi 1 ila 10 sayıları ile yapmak neredeyse bir sonuç verir, ancak daha sonra sonuç 1 ila 20'lik çözeltiden çok daha küçüktür.

Daha önce C'de çözdüm ve orada 1'den 20'ye kadar olan sonuç neredeyse anında hesaplandı. Bu beni Haskell için bu sorunu nasıl yorumlayacağımı yanlış anladığımı düşünmeye itiyor. Başkalarının çözümlerini inceledim ve buldum:

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

Yeterince adil, bu yerleşik bir işlev kullanıyor, ancak sonuç bunu kendiniz yaparken neden bu kadar yavaş? Orada öğreticiler Haskell nasıl kullanılacağını söyler, ancak algoritmaları hızlı koda dönüştürme konusunda çok yardım görmüyorum.


6
Çözülmüş Euler problemlerinin çoğunun yanında matematik problemini ele alan pdf'ler olduğunu belirtmeliyim. Bu pdf'i okumaya çalışabilir ve her dilde açıklanan algoritmayı uygulayabilir ve daha sonra profil oluşturabilirsiniz.

Yanıtlar:


25

Öncelikle, dilin sorun olduğunu düşünmeden önce, optimize edilmiş bir ikili dosyaya sahip olduğunuzdan emin olmanız gerekir. Real Wolrd Haskell'deki Profil oluşturma ve optimizasyon bölümünü okuyun . Çoğu durumda, dilin üst düzey doğasının size en azından performansın bir kısmına mal olduğunu belirtmek gerekir.

Ancak, diğer çözüm not olduğunu değil daha hızlı kullandığı çünkü bir fonksiyonu dahili, ancak kullanır çünkü bir çok daha hızlı algoritma : Yalnızca birkaç GCDs bulmalıyız sayı kümesinin en küçük ortak katı bulmak için. Bunu, 1'den 1'e kadar olan tüm sayılar arasında geçiş yapan çözümünüzle karşılaştırın foldl lcm [1..20]. 30 ile denerseniz, çalışma zamanları arasındaki fark daha da büyük olacaktır.

Karmaşıklıklara bir göz atın: algoritmanızın O(ans*N)çalışma zamanı vardır, anscevap nerede ve Nbölünebilirliği kontrol ettiğiniz sayıdır (sizin durumunuzda 20).
Diğer algoritması uygular Nkez lcm, ancak lcm(a,b) = a*b/gcd(a,b)ve OBEB karmaşıklığı O(log(max(a,b))). Bu nedenle ikinci algoritmanın karmaşıklığı vardır O(N*log(ans)). Kendiniz için daha hızlı karar verebilirsiniz.

Özetlemek gerekirse:
Sorununuz dil değil algoritmanızdır.

Mathematica gibi matematik ağırlıklı programlara hem işlevsel hem de odaklanmış özel diller olduğunu ve matematik odaklı problemler için muhtemelen neredeyse her şeyden daha hızlı olduğunu unutmayın. Çok optimize edilmiş bir fonksiyon kütüphanesine sahiptir ve fonksiyonel paradigmayı destekler (kuşkusuz zorunlu programlamayı da destekler).


3
Kısa bir süre önce bir Haskell programıyla ilgili bir performans sorunum vardı ve ardından optimizasyonları kapalı olarak derlediğimi fark ettim. Artırılmış performansta anahtarlama optimizasyonu yaklaşık 10 kat. Bu yüzden C ile yazılmış aynı program hala daha hızlıydı, ancak Haskell çok daha yavaş değildi (Haskell kodunu daha fazla geliştirmeye çalışmamış olduğumu düşünerek, iyi bir performans olduğunu düşünüyorum, yaklaşık 2, 3 kat daha yavaş). Alt satır: profil oluşturma ve optimizasyon iyi bir öneri. +1
Giorgio

3
dürüstçe, ilk iki paragrafı kaldırabileceğinizi düşünüyorlar, gerçekten soruya cevap vermiyorlar ve muhtemelen yanlışlar (kesinlikle terminoloji ile hızlı ve gevşek oynuyorlar, diller bir hıza sahip olamazlar)
jk.

1
Çelişkili bir cevap veriyorsunuz. Bir yandan, OP'nin "hiçbir şeyi yanlış anlamadığını" ve yavaşlığın Haskell'e özgü olduğunu iddia ediyorsunuz. Öte yandan, algoritma seçiminin önemli olduğunu gösteriyorsunuz! Cevabın geri kalanıyla biraz çelişkili olan ilk iki paragrafı atlarsa cevabınız çok daha iyi olurdu.
Andres

2
Andres F. ve jk. İlk iki paragrafı birkaç cümleye indirmeye karar verdim. Yorumlar için teşekkürler
K.Steff

5

İlk düşüncem, sadece tüm primler tarafından bölünebilen sayılar <= 20, 20'den küçük tüm sayılarla bölünebilir olacaktı, bu yüzden sadece 2 * 3 * 5 * 7 * 11 * 13 * 17 * 19 katları olan sayıları göz önünde bulundurmanız gerekir. . Böyle bir çözüm, kaba kuvvet yaklaşımı kadar 1 / 9.699.690 sayısını kontrol eder. Ancak hızlı Haskell çözümünüz bundan daha iyisini yapar.

"Hızlı Haskell" çözümünü anlarsam, lcm (en az ortak çoklu) işlevini 1'den 20'ye kadar olan sayı listesine uygulamak için foldl1 kullanır. Bu nedenle lcm 1 2, 2'yi verir. Sonra lcm 2 3 verim 6 Sonra lcm 6 4 12 verir, vb. Bu şekilde, lcm fonksiyonu cevabınızı vermek için sadece 19 kez çağrılır. Big O notasyonunda, bir çözüme varmak için O (n-1) işlemleri yapılır.

Yavaş Haskell çözümünüz, 1'den çözümünüze kadar her sayı için 1-20 sayılarından geçer. Çözüm s'yi çağırırsak, yavaş Haskell çözümü O (s * n) işlemlerini gerçekleştirir. S'nin 9 milyondan fazla olduğunu zaten biliyoruz, bu yüzden muhtemelen yavaşlığı açıklıyor. Tüm kısayollar ve 1-20 sayıları listesinde ortalama bir yarı yol alsa bile, bu sadece O (s * n / 2).

Aramak headsizi bu hesaplamaları yapmaktan kurtarmaz, ilk çözümü hesaplamak için yapılması gerekir.

Teşekkürler, bu ilginç bir soruydu. Haskell bilgimi gerçekten uzattı. Geçen sonbaharda algoritma okumamış olsaydım hiç cevap veremezdim.


Aslında 2 * 3 * 5 * 7 * 11 * 13 * 17 * 19 ile yaklaştığınız yaklaşım muhtemelen en az lcm tabanlı çözümdür. Özellikle ihtiyacınız olan şey 2 ^ 4 * 3 ^ 2 * 5 * 7 * 11 * 13 * 17 * 19. Çünkü 2 ^ 4, 20'den az veya 20'ye eşit en büyük güçtür ve 3 ^ 2 en büyük güçtür 3'ten 20'ye eşit veya daha az, vb.
noktalı virgül

@semicolon Tartışılan diğer alternatiflerden kesinlikle daha hızlı olmakla birlikte, bu yaklaşım aynı zamanda giriş parametresinden daha küçük, önceden hesaplanmış bir prim listesi gerektirir. Çalışma zamanında (ve daha da önemlisi, bellek ayak izinde) bunu
hesaba katarsak

@ K.Steff Şaka mı yapıyorsun ... 19'a kadar asal bilgisayarları hazırlamalısın ... bu saniyenin küçük bir kısmını alır. İfadeniz kesinlikle SIFIR anlamlıdır, yaklaşımımın toplam çalışma süresi, asal nesil ile bile inanılmaz derecede küçüktür. Profil oluşturmayı etkinleştirdim ve yaklaşımım (Haskell'de) var total time = 0.00 secs (0 ticks @ 1000 us, 1 processor)ve total alloc = 51,504 bytes. Çalışma zamanı, profil oluşturucuya kaydolmamak için bir saniyenin yeterince önemsiz bir oranıdır.
noktalı virgül

@semicolon Yorumumu onaylamalıydım, üzgünüm. İfadem, N'ye kadar olan tüm primerleri hesaplamanın gizli fiyatıyla ilgiliydi - naif Eratosthenes O (N * log (N) * log (log (N))) işlemleri ve O (N) hafızasıdır, bu da ilk N gerçekten büyükse bellek veya zamanın tükeneceği algoritmanın bileşeni. Atkin foldl lcm [1..N]elekiyle çok daha iyi olmuyor, bu yüzden algoritmanın sabit sayıda bigint'e ihtiyaç duyduğundan daha az çekici olacağı sonucuna vardım.
K.Steff

@ K.Steff Her iki algoritmayı da test ettim. Başbakan tabanlı algoritmam için profiler bana verdi (n = 100.000 için): total time = 0.04 secsve total alloc = 108,327,328 bytes. Diğer lcm tabanlı algoritma için profiler bana şunu verdi: total time = 0.67 secsve total alloc = 1,975,550,160 bytes. N = 1,000,000 için asal temelli: total time = 1.21 secsve total alloc = 8,846,768,456 bytes, lcm temelli için: total time = 61.12 secsve total alloc = 200,846,380,808 bytes. Yani başka bir deyişle, yanılıyorsunuz, asal temelli çok daha iyi.
noktalı virgül

1

Başlangıçta bir cevap yazmayı planlamıyordum. Ancak, başka bir kullanıcıya, ilk çift primerlerin çoğaltılmasının, tekrar tekrar uygulandıktan sonra daha hesaplama açısından pahalı olduğunu garip bir iddia yaptıktan sonra söylendi lcm. İşte iki algoritma ve bazı kriterler:

Algoritmam:

Asal nesil algoritma, bana sonsuz bir asal listesi veriyor.

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

Şimdi, bazıları için sonucu hesaplamak için bu ana listeyi kullanın N:

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

İtirafta oldukça özlü olan diğer lcm tabanlı algoritma, çoğunlukla sıfırdan asal nesil uyguladığım (ve düşük performansından dolayı süper özlü liste anlama algoritmasını kullanmadığım için) lcmbasitçe ithal edildi Prelude.

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

Şimdi kıyaslamalar için, her biri için kullandığım kod basitti: ( -prof -fprof-auto -O2sonra +RTS -p)

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

İçin 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

İçin 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

İçin 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

Bence sonuçlar kendilerine hitap ediyor.

Profil oluşturucu, ana üretimin çalışma süresinin narttıkça daha küçük ve daha küçük bir yüzdesini aldığını belirtir . Yani bu darboğaz değil, bu yüzden şimdilik görmezden gelebiliriz.

Bu, lcmbir argümanın 1'den 1'e n, diğerinin geometrik olarak 1'den gittiği yeri çağırmayı gerçekten karşılaştırdığımız anlamına gelir ans. *Aynı durumla çağırmak ve her asal olmayan numarayı atlamanın ek faydası (daha pahalı doğası nedeniyle asimptotik olarak ücretsiz *).

Ve iyi bilinmektedir *daha hızlı olduğu lcmgibi lcmtekrarlanan uygulamalar gerektirir modve modasimptotik yavaş (olduğu O(n^2)vs ~O(n^1.5)).

Dolayısıyla, yukarıdaki sonuçlar ve kısa algoritma analizi hangi algoritmanın daha hızlı olduğunu açıkça ortaya koymalıdır.

Sitemizi kullandığınızda şunları okuyup anladığınızı kabul etmiş olursunuz: Çerez Politikası ve Gizlilik Politikası.
Licensed under cc by-sa 3.0 with attribution required.