web-dev-qa-db-ja.com

foldlは末尾再帰なので、foldrはfoldlよりも速く実行されるのはなぜですか?

Foldlとfoldrをテストしたかった。私が見てきたことから、テール再帰の最適化のために、可能な場合は常にfoldr over foldrを使用する必要があります。

意味あり。しかし、このテストを実行した後、私は混乱しています:

foldr(timeコマンドを使用すると0.057秒かかります):

a::a -> [a] -> [a]
a x = ([x] ++ )

main = putStrLn(show ( sum (foldr a [] [0.. 100000])))

foldl(timeコマンドを使用すると0.089秒かかります):

b::[b] -> b -> [b]
b xs = ( ++ xs). (\y->[y])

main = putStrLn(show ( sum (foldl b [] [0.. 100000])))

この例が取るに足らないことは明らかですが、foldrがfoldlに勝っている理由について混乱しています。これはfoldlが勝つ明確なケースではないでしょうか?

69
Ori

遅延評価の世界へようこそ。

厳密な評価の観点から考えると、foldlは末尾再帰であるため、foldlは「良好」に見え、foldrは「不良」に見えますが、foldrはスタックにタワーを構築して、最後のアイテムを最初に処理できるようにする必要があります。

ただし、遅延評価はテーブルを回します。たとえば、map関数の定義を見てみましょう。

_map :: (a -> b) -> [a] -> [b]
map _ []     = []
map f (x:xs) = f x : map f xs
_

Haskellが厳密な評価を使用した場合、これは最初にテールを計算してから、アイテムを(リスト内のすべてのアイテムに対して)先頭に追加する必要があるため、あまり良くありません。それを効率的に行う唯一の方法は、要素を逆に構築することだと思われます。

ただし、Haskellの遅延評価のおかげで、このマップ関数は実際には効率的です。 Haskellのリストはジェネレーターと考えることができます。このマップ関数は、入力リストの最初の項目にfを適用することにより、最初の項目を生成します。 2番目のアイテムが必要な場合は、同じことをもう一度行います(余分なスペースを使用しません)。

mapfoldrで記述できることがわかります。

_map f xs = foldr (\x ys -> f x : ys) [] xs
_

見ただけではわかりませんが、foldrがfに最初の引数をすぐに与えることができるため、遅延評価が発生します。

_foldr f z []     = z
foldr f z (x:xs) = f x (foldr f z xs)
_

fで定義されたmapは、最初のパラメーターのみを使用して結果リストの最初の項目を返すことができるため、折りたたみは定数空間で遅延して動作できます。

さて、遅延評価はかえって戻ります。たとえば、sum [1..1000000]を実行してみてください。スタックオーバーフローが発生します。なぜそうすべきなのでしょうか?左から右に評価するだけですよね?

Haskellによる評価方法を見てみましょう。

_foldl f z []     = z
foldl f z (x:xs) = foldl f (f z x) xs

sum = foldl (+) 0

sum [1..1000000] = foldl (+) 0 [1..1000000]
                 = foldl (+) ((+) 0 1) [2..1000000]
                 = foldl (+) ((+) ((+) 0 1) 2) [3..1000000]
                 = foldl (+) ((+) ((+) ((+) 0 1) 2) 3) [4..1000000]
                   ...
                 = (+) ((+) ((+) (...) 999999) 1000000)
_

Haskellは面倒なので、追加を実行できません。代わりに、数値を強制的に取得する必要がある未評価のサンクのタワーが作成されます。スタックオーバーフローは、すべてのサンクを評価するために深く再帰する必要があるため、この評価中に発生します。

幸い、厳密に動作する_foldl'_と呼ばれる特別な関数がData.Listにあります。 foldl' (+) 0 [1..1000000]はスタックオーバーフローしません。 (注:テストではfoldlを_foldl'_に置き換えてみましたが、実際には実行が遅くなりました。)

93
Joey Adams

編集:この問題をもう一度見てみると、現在の説明はすべてやや不十分だと思うので、もっと長い説明を書きました。

違いは、foldlfoldrがリダクション関数をどのように適用するかです。 foldrのケースを見ると、次のように展開できます。

foldr (\x -> [x] ++ ) [] [0..10000]
[0] ++ foldr a [] [1..10000]
[0] ++ ([1] ++ foldr a [] [2..10000])
...

このリストはsumによって処理され、次のように使用されます。

sum = foldl' (+) 0
foldl' (+) 0 ([0] ++ ([1] ++ ... ++ [10000]))
foldl' (+) 0 (0 : [1] ++ ... ++ [10000])     -- get head of list from '++' definition
foldl' (+) 0 ([1] ++ [2] ++ ... ++ [10000])  -- add accumulator and head of list
foldl' (+) 0 (1 : [2] ++ ... ++ [10000])
foldl' (+) 1 ([2] ++ ... ++ [10000])
...

リストの連結の詳細は省略しましたが、これが削減の進行方法です。重要な部分は、リストの走査を最小限に抑えるためにすべてが処理されることです。 foldrはリストを1回だけトラバースします。連結では、リストの連続的なトラバーサルは必要ありません。sumは、最終的に1つのパスでリストを消費します。重要な点として、リストの先頭は_​​foldrからsumまですぐに利用できるため、sumはすぐに動作を開始でき、値は生成時にgc化できます。 vectorなどのフュージョンフレームワークを使用すると、中間リストでさえも融合される可能性があります。

これをfoldl関数と比較してください。

b xs = ( ++xs) . (\y->[y])
foldl b [] [0..10000]
foldl b ( [0] ++ [] ) [1..10000]
foldl b ( [1] ++ ([0] ++ []) ) [2..10000]
foldl b ( [2] ++ ([1] ++ ([0] ++ [])) ) [3..10000]
...

リストの先頭は、foldlが終了するまで使用できないことに注意してください。つまり、sumが機能し始める前に、リスト全体をメモリ内に構築する必要があります。これは全体的に効率がはるかに低くなります。 +RTS -sを使用して2つのバージョンを実行すると、foldlバージョンのガベージコレクションのパフォーマンスが悲惨になります。

これは、foldl'が役に立たない場合にも当てはまります。追加されたfoldl'の厳密性は、中間リストの作成方法を変更しません。リストの先頭はfoldl 'が終了するまで利用できないため、foldrを使用する場合よりも結果が遅くなります。

次のルールを使用して、foldの最適な選択を決定します

  • reductionである折りたたみの場合は、foldl'を使用します(たとえば、これが唯一の/最後のトラバーサルになります)
  • それ以外の場合は、foldrを使用します。
  • foldlは使用しないでください。

ほとんどの場合、foldrが最適な折りたたみ関数です。これは、走査方向がリストの遅延評価に最適であるためです。また、無限リストを処理できる唯一のものでもあります。 foldl'をさらに厳密にすると、場合によってはより速くなる場合がありますが、これは、その構造の使用方法と遅延の度合いによって異なります。

26
John L

私が何かを逃していない限り、誰もが実際にこれについて本当の答えを実際に言ったことはないと思います(真実であり、反対投票で歓迎されるかもしれません)。

この場合の最大の違いは、foldrが次のようにリストを作成することです。

[0] ++([1] ++([2] ++(... ++ [1000000])))

一方、foldlは次のようにリストを作成します。

((([0] ++ [1])++ [2])++ ...)++ [999888])++ [999999])++ [1000000]

微妙な違いはありますが、foldrバージョンでは++の左側の引数として常に1つのリスト要素しかありません。 foldlバージョンでは、++の左側の引数に最大999999の要素があります(平均約500000)。ただし、右側の引数には1つの要素しかありません。

ただし、++は、左引数リスト全体を最後まで調べ、最後の要素を右引数の最初の要素に再ポイントする必要があるため、左引数のサイズに比例して時間がかかります(せいぜい、おそらくそれは実際にコピーを行う必要があります)。正しい引数リストは変更されていないので、それがどれほど大きくてもかまいません。

そのため、foldlバージョンの方がはるかに遅くなります。私の意見では、怠惰とは何の関係もありません。

8
Clinton

問題は、末尾再帰の最適化がメモリの最適化であり、実行時間の最適化ではないことです。

末尾再帰の最適化により、各再帰呼び出しの値を覚えておく必要がなくなります。

したがって、foldlは実際には「良好」であり、foldrは「不良」です。

たとえば、foldrとfoldlの定義を検討します。

foldl f z [] = z
foldl f z (x:xs) = foldl f (z `f` x) xs

foldr f z [] = z
foldr f z (x:xs) = x `f` (foldr f z xs)

これが "foldl(+)0 [1,2,3]"という式の評価方法です。

foldl (+) 0 [1, 2, 3]
foldl (+) (0+1) [2, 3]
foldl (+) ((0+1)+2) [3]
foldl (+) (((0+1)+2)+3) [ ]
(((0+1)+2)+3)
((1+2)+3)
(3+3)
6

Foldlは値0、1、2 ...を覚えていませんが、式全体を(((0 + 1)+2)+3)を引数として遅延して渡し、最後の評価まで評価しません。 foldl。基本ケースに到達し、2番目のパラメーター(z)として渡された値を返しますが、まだ評価されていません。

一方、foldrは次のように機能します。

foldr (+) 0 [1, 2, 3]
1 + (foldr (+) 0 [2, 3])
1 + (2 + (foldr (+) 0 [3]))
1 + (2 + (3 + (foldr (+) 0 [])))
1 + (2 + (3 + 0)))
1 + (2 + 3)
1 + 5
6

ここでの重要な違いは、foldlが最後の呼び出しで式全体を評価するので、記憶された値、foldr noに到達するために戻る必要がなくなることです。 foldrは呼び出しごとに1つの整数を記憶し、呼び出しごとに加算を実行します。

Foldrとfoldlは必ずしも同等ではないことに注意してください。たとえば、抱擁でこの式を計算してみてください:

foldr (&&) True (False:(repeat True))

foldl (&&) True (False:(repeat True))

foldrとfoldlは、説明されている特定の条件下でのみ同等です here

(私の悪い英語でごめんなさい)

7
matiascelasco

Aの場合、[0.. 100000]リストをすぐに展開して、foldrが最後の要素から開始できるようにする必要があります。次に、物を折りたたむと、中間結果は

[100000]
[99999, 100000]
[99998, 99999, 100000]
...
[0.. 100000] -- i.e., the original list

このリストの値は誰も変更できないため(Haskellは純粋な関数型言語)、コンパイラーは値を自由に再利用できます。 [99999, 100000]のような中間値は、個別のリストではなく、拡張された[0.. 100000]リストへの単なるポインタにすることもできます。

Bについては、中間値を見てください。

[0]
[0, 1]
[0, 1, 2]
...
[0, 1, ..., 99999]
[0.. 100000]

リストの最後を変更すると、それを指す他の値も変更されているため、これらの中間リストはそれぞれ再利用できません。つまり、メモリを構築するのに時間がかかる余分なリストをたくさん作成していることになります。したがって、この場合、中間値であるこれらのリストの割り当てと入力に多くの時間を費やします。

リストのコピーを作成しているだけなので、リスト全体を展開することから始め、次にポインターをリストの後ろから前に移動し続けるだけなので、実行が速くなります。

2
Harold L

foldlfoldrもテール最適化されていません。 foldl'のみです。

しかし、あなたの場合、++foldl'と一緒に使用すると、++を連続して評価すると、アキュムレータのトラバースが何度も繰り返されるため、あまりお勧めできません。