関数型プログラミングで効率を改善する方法は?


20

私は最近、Learn You a Haskell for Great Goodガイドを読んでいますが、練習として、それを使ってProject Euler Problem 5を解決したかったです。

1から20までのすべての数値で均等に割り切れる最小の正の数値は何ですか?

最初に、特定の数がこれらの数で割り切れるかどうかを判断する関数を作成することにしました。

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

次に、以下を使用して最小のものを計算しましたhead

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

そして最後に、結果を表示する行を書きました:

main = putStrLn $ show $ sm

残念ながら、これを完了するには約30秒かかりました。同じことを1〜10の数字で実行すると、すぐに結果が得られますが、その場合も1〜20の場合の結果よりもはるかに小さくなります。

Cで以前に解決し、1〜20の結果もほぼ瞬時に計算されました。これにより、この問題をHaskellで解釈する方法を誤解していると思うようになります。私は他の人の解決策に目を通し、これを見つけました:

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

結構、これは組み込み関数を使用しますが、それを自分で行うと最終結果がそれほど遅くなるのはなぜですか?そこにあるチュートリアルでは、Haskellの使用方法を説明していますが、アルゴリズムを高速コードに変換するのにあまり役立ちません。


6
解決済みのオイラー問題の多くには、数学の問題に対処するためのPDFが隣にあることを指摘する必要があります。そのPDFを読んで、各言語で説明されているアルゴリズムを実装してから、プロファイルを作成してみてください。

回答:


25

言語が問題であると考える前に、まず最適化されたバイナリがあることを確認する必要があります。Real Wolrd Haskell のプロファイルと最適化の章を読んでください。ほとんどの場合、言語の高レベルの性質により、パフォーマンスの少なくとも一部が犠牲になることに注意してください。

ただし、組み込み関数を使用するため、他のソリューションは高速ではありませんが、単純にはるかに高速なアルゴリズムを使用しているため、数セットの最小公倍数を見つけるためにいくつかのGCDを見つける必要があります。これを、1からのすべての数値を循環するソリューションと比較してくださいfoldl lcm [1..20]。30で試してみると、ランタイム間の差はさらに大きくなります。

複雑さを見てみましょう:アルゴリズムにはO(ans*N)ランタイムがあります。ここansで、答えNは分割可能性をチェックする数値です(この場合は20)。ただし、
他のアルゴリズムはN時間を実行し、GCDには複雑さがあります。したがって、2番目のアルゴリズムには複雑さがあります。あなたは自分で判断することができます。lcmlcm(a,b) = a*b/gcd(a,b)O(log(max(a,b)))O(N*log(ans))

要約
すると、問題は言語ではなくアルゴリズムです。

Mathematicaのように、数学に重点を置いたプログラムに機能的で焦点を合わせた特殊な言語があります。数学に焦点を当てた問題では、おそらく他のどの言語よりも高速です。非常に最適化された関数ライブラリを備えており、関数型パラダイムをサポートしています(確かに命令型プログラミングもサポートしています)。


3
最近、Haskellプログラムのパフォーマンスに問題があり、最適化をオフにしてコンパイルしたことに気付きました。パフォーマンスを約10倍に向上させて最適化を切り替えます。したがって、Cで書かれた同じプログラムはまだ高速でしたが、Haskellはそれほど遅くありませんでした(約2、3倍遅く、これはHaskellコードをさらに改善しようとしていないことを考えると、良いパフォーマンスだと思います)。結論:プロファイリングと最適化は良い提案です。+1
ジョルジオ

3
正直なところ、最初の2つの段落は削除できると思いますが、それらは質問に実際には答えず、おそらく不正確です(これらは確かに用語で高速でゆるく遊んでいます。言語には速度がありません)
jk。

1
あなたは矛盾した答えを与えています。一方では、OPは「何も誤解されていない」と主張し、その遅さはHaskellに固有のものであると主張します。一方、アルゴリズムの選択が重要であることを示しています!最初の2つの段落をスキップすると、答えははるかに良くなります。これは、残りの答えとは多少矛盾しています。
アンドレスF.

2
Andres F.とjkからフィードバックを受け取ります。最初の2つの段落を数文に減らすことにしました。コメントをありがとう
-K.Steff

5

私の最初の考えは、20以下のすべての素数で割り切れるのは、20以下のすべての素数で割り切れる数だけだということです。したがって、2 * 3 * 5 * 7 * 11 * 13 * 17 * 19の倍数である数のみを考慮する必要があります。 。このようなソリューションは、ブルートフォースアプローチと同じ数の1 / 9,699,690の数値をチェックします。しかし、高速Haskellソリューションはそれよりも優れています。

「高速Haskell」ソリューションを理解している場合、foldl1を使用してlcm(最小公倍数)関数を1から20までの数値のリストに適用します。したがって、lcm 1 2が適用され、2が得られます。次に、1を生成するlcm 6 4、12などが続きます。このように、lcm関数は、答えを得るために19回だけ呼び出されます。Big O表記では、ソリューションに到達するためのO(n-1)操作です。

スローハスケルソリューションは、1からソリューションまでのすべての数字に対して1から20の数字を通ります。ソリューションsを呼び出すと、slow-HaskellソリューションはO(s * n)操作を実行します。sが900万を超えることは既にわかっているので、おそらくそれが遅さを説明しています。すべてのショートカットが1〜20の数字のリストの平均の半分を取得しても、それはまだO(s * n / 2)だけです。

呼び出しheadは、これらの計算を行うことからあなたを救うわけではありません。最初の解を計算するために、それらを行う必要があります。

ありがとう、これは興味深い質問でした。Haskellの知識が本当に広がりました。昨秋にアルゴリズムを勉強していなかったら、私はまったく答えることができませんでした。


実際、2 * 3 * 5 * 7 * 11 * 13 * 17 * 19で得ていたアプローチは、少なくともlcmベースのソリューションと同じくらい速いでしょう。特に必要なのは、2 ^ 4 * 3 ^ 2 * 5 * 7 * 11 * 13 * 17 * 19.です。2^ 4は2の最大の20以下のパワーであり、3 ^ 2は最大のパワーです。 3以下、20以下など。
セミコロン

@semicolon議論した他の選択肢よりも確実に高速ですが、このアプローチでは、入力パラメータよりも小さい事前に計算された素数のリストも必要です。ランタイム(さらに重要なことに、メモリフットプリント)を
考慮に入れる

@ K.Steffあなたは私をからかっていますか... 19までの素数を計算する必要があります...それはほんの一瞬です。あなたの声明は絶対にゼロに理にかなっており、私のアプローチの総実行時間は素数世代であっても信じられないほど小さいです。私は、プロファイリングが有効と(ハスケルで)私のアプローチはなったtotal time = 0.00 secs (0 ticks @ 1000 us, 1 processor)total alloc = 51,504 bytes。ランタイムは、プロファイラーに登録することさえできない、ほんのわずかな秒の割合です。
セミコロン

@semicolonコメントを限定する必要がありましたが、ごめんなさい。私の声明は、Nまでのすべての素数を計算する隠れた価格に関連していました-素朴なエラトステネスはO(N * log(N)* log(log(N)))操作とO(N)メモリであり、これが最初であることを意味しますNが本当に大きい場合、メモリまたは時間を使い果たすアルゴリズムのコンポーネント。アトキンのふるいではそれほど良くならないので、アルゴリズムはfoldl lcm [1..N]、一定数のbigintを必要とするよりも魅力的ではないと結論付けました。
K.ステフ

@ K.Steffまあ、両方のアルゴリズムをテストしました。:私のプライムベースのアルゴリズムについてプロファイラーは、(のためのn =10万)くれた total time = 0.04 secstotal alloc = 108,327,328 bytes。他のlcmベースのアルゴリズムの場合、プロファイラーは私に与えた:total time = 0.67 secstotal alloc = 1,975,550,160 bytes。n = 1,000,000の場合、素数ベース:total time = 1.21 secsおよびtotal alloc = 8,846,768,456 bytes、およびlcmベース:total time = 61.12 secsおよびで取得しましたtotal alloc = 200,846,380,808 bytes。言い換えれば、あなたは間違っています、素数ベースははるかに優れています。
セミコロン

1

最初は答えを書くつもりはなかった。しかし、別のユーザーが、最初のカップルの素数を単純に乗算することは、繰り返し適用するよりも計算コストが高いという奇妙な主張をした後、私に言われましたlcm。そのため、2つのアルゴリズムといくつかのベンチマークを以下に示します。

私のアルゴリズム:

素数の無限リストを提供する素数生成アルゴリズム。

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

次に、その素数リストを使用して、一部の結果を計算しますN

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

今、確かに一方で、私は最初から素数生成を実現し(そして、そのパフォーマンスの低下にスーパー簡潔なリストの内包アルゴリズムを使用していない)主な理由は、かなり簡潔で、他のLCMベースのアルゴリズム、lcm単にから輸入されましたPrelude

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

ベンチマークでは、それぞれに使用したコードは単純でした:(-prof -fprof-auto -O2その後+RTS -p

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

n = 100,000solvePrime

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

solveLcm

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

n = 1,000,000solvePrime

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

solveLcm

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

n = 3,000,000solvePrime

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

solveLcm

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

結果はそれを物語っていると思います。

プロファイラーは、プライム生成が実行時間のn増加に応じてますます少ない割合を占めることを示します。それがボトルネックではないので、今のところは無視できます。

これはlcm、1つの引数が1からnになり、他の引数が幾何学的に1からになる呼び出しを実際に比較していることを意味しansます。*同じ状況で呼び出し、すべての非素数をスキップできるという追加の利点(より高価な性質のため、無料で漸近的に*)。

そして、よく知られている*よりも高速であるlcmとして、lcmの繰り返しのアプリケーションを必要とmodし、mod漸近的に遅く(あるO(n^2)~O(n^1.5))。

したがって、上記の結果と簡単なアルゴリズム分析により、どのアルゴリズムが高速であるかが非常に明確になります。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.