簡體   English   中英

Haskell foldl'表現不佳(++)

[英]Haskell foldl' poor performance with (++)

我有這個代碼:

import Data.List

newList_bad  lst = foldl' (\acc x -> acc ++ [x*2]) [] lst
newList_good lst = foldl' (\acc x -> x*2 : acc) [] lst

這些函數返回列表,每個元素乘以2:

*Main> newList_bad [1..10]
[2,4,6,8,10,12,14,16,18,20]
*Main> newList_good [1..10]
[20,18,16,14,12,10,8,6,4,2]

在ghci:

*Main> sum $ newList_bad [1..15000]
225015000
(5.24 secs, 4767099960 bytes)
*Main> sum $ newList_good [1..15000]
225015000
(0.03 secs, 3190716 bytes)

為什么newList_bad函數的工作速度比newList_good慢200倍? 我知道這不是一個很好的解決方案。 但為什么這個無辜的代碼工作得如此之慢?

這是什么“4767099960字節”?? 對於那個簡單的操作,Haskell使用4 GiB ??

編譯后:

C:\1>ghc -O --make test.hs
C:\1>test.exe
225015000
Time for sum (newList_bad [1..15000]) is 4.445889s
225015000
Time for sum (newList_good [1..15000]) is 0.0025005s

關於這個問題存在很多困惑。 給出的通常原因是“在列表末尾重復附加需要重復遍歷列表,因此是O(n^2) ”。 但在嚴格的評估下,它只會如此簡單。 在懶惰的評估下,一切都應該被延遲,所以它引出了一個問題,即是否確實存在這些重復的遍歷和附加。 最后的添加是通過在前面消耗來觸發的,並且由於我們在前面消耗的列表越來越短,因此這些操作的確切時間是不清楚的。 因此,真正的答案更為微妙,並在懶惰評估下處理特定的減少步驟。

直接的罪魁禍首是foldl'只強制其累加器參數為弱頭正常形式 - 即直到暴露出非嚴格的構造函數。 這里涉及的功能是

(a:b)++c = a:(b++c)    -- does nothing with 'b', only pulls 'a' up
[]++c = c              -- so '++' only forces 1st elt from its left arg

foldl' f z [] = z
foldl' f z (x:xs) = let w=f z x in w `seq` foldl' f w xs

sum xs = sum_ xs 0     -- forces elts fom its arg one by one
sum_ [] a = a
sum_ (x:xs) a = sum_ xs (a+x)

實際的減少序列是( g = foldl' f

sum $ foldl' (\acc x-> acc++[x^2]) []          [a,b,c,d,e]
sum $ g  []                                    [a,b,c,d,e]
      g  [a^2]                                   [b,c,d,e]
      g  (a^2:([]++[b^2]))                         [c,d,e]
      g  (a^2:(([]++[b^2])++[c^2]))                  [d,e]
      g  (a^2:((([]++[b^2])++[c^2])++[d^2]))           [e]
      g  (a^2:(((([]++[b^2])++[c^2])++[d^2])++[e^2]))   []
sum $ (a^2:(((([]++[b^2])++[c^2])++[d^2])++[e^2]))

注意到目前為止我們只執行了O(n)步驟。 a^2立即可用於sum的消耗,但b^2不是。 我們留在這里用左邊嵌套的++表達式結構。 Daniel Fischer這個答案中最好地解釋了其余部分。 它的要點是,為了得到b^2 ,必須執行O(n-1)步驟 - 並且在此訪問之后留下的結構仍將是左嵌套的,因此下一次訪問將需要O(n-2)步驟,等等 - 經典的O(n^2)行為。 所以真正的原因是++ 並沒有強迫或重新安排其論點足以提高效率

這實際上是違反直覺的。 我們可以期待懶惰的評估在這里為我們神奇地“做”。 畢竟我們只是表達了將來 [x^2]添加到列表末尾的意圖,我們實際上並沒有立即這樣做。 因此,這里的時間是關閉的,但它可以做出正確的-就像我們訪問列表,新元素將被添加到它和消費向右走 ,如果時機是正確的:如果c^2將被添加到后面的列表b^2 (空間方式),比如說, 就在消耗之前(時間) b^2 ,遍歷/訪問將始終為O(1)

這是通過所謂的“差異列表”技術實現的:

newlist_dl lst = foldl' (\z x-> (z . (x^2 :)) ) id lst

如果你想一下,它看起來與你的++[x^2]版本完全相同。 它表達了相同的意圖,並且也留下了左嵌套結構。

正如Daniel Fischer在同一個答案中所解釋的那樣,差異是(.)在第一次被強制時,在O(n)步驟中將自身重新排列成右嵌套($)結構 1 ,之后每次訪問都是O(1)並且附加的時間是完全如上段所述的最佳,所以我們留下了整體O(n)行為。


1這是一種神奇的,但確實發生了。 :)

經典列表行為。

召回:

(:)  -- O(1) complexity
(++) -- O(n) complexity

所以你創建了一個O(n ^ 2)算法,而不是O(n)算法。

對於遞增附加到列表的常見情況,請嘗試使用dlist ,或者只是在結尾處反向。

用一些更大的視角補充其他答案:使用惰性列表,在返回列表的函數中使用foldl'通常是一個壞主意。 當您將列表縮減為嚴格(非惰性)標量值(例如,對列表求和)時, foldl'通常很有用。 但是當你建立一個列表作為結果時, foldr通常會更好,因為懶惰; :構造函數是惰性的,因此在實際需要之前不會計算列表的尾部。

在你的情況下:

newList_foldr lst = foldr (\x acc -> x*2 : acc) [] lst

這實際上與map (*2)

newList_foldr lst = map (*2) lst
map f lst = foldr (\x acc -> f x : acc) [] lst

評估(使用第一個,無map定義):

newList_foldr [1..10] 
  = foldr (\x acc -> x*2 : acc) [] [1..10]
  = foldr (\x acc -> x*2 : acc) [] (1:[2..10])
  = 1*2 : foldr (\x rest -> f x : acc) [] [2..10]

這是關於當newList [1..10]被強制時Haskell將評估的內容。 如果這個結果的消費者需要它,它只會進一步評估 - 並且只需要滿足消費者所需的一小部分。 例如:

firstElem [] = Nothing
firstElem (x:_) = Just x

firstElem (newList_foldr [1..10])
  -- firstElem only needs to evaluate newList [1..10] enough to determine 
  -- which of its subcases applies—empty list or pair.
  = firstElem (foldr (\x acc -> x*2 : acc) [] [1..10])
  = firstElem (foldr (\x acc -> x*2 : acc) [] (1:[2..10]))
  = firstElem (1*2 : foldr (\x rest -> f x : acc) [] [2..10])
  -- firstElem doesn't need the tail, so it's never computed!
  = Just (1*2)

這也意味着基於foldrnewList也可以使用無限列表:

newList_foldr [1..] = [2,4..]
firstElem (newList_foldr [1..]) = 2

另一方面,如果使用foldl' ,則必須始終計算整個列表,這也意味着您無法處理無限列表:

firstElem (newList_good [1..])    -- doesn't terminate

firstElem (newList_good [1..10])
  = firstElem (foldl' (\acc x -> x*2 : acc) [] [1..10])
  = firstElem (foldl' (\acc x -> x*2 : acc) [] (1:[2..10]))
  = firstElem (foldl' (\acc x -> x*2 : acc) [2] [2..10])
  -- we can't short circuit here because the [2] is "inside" the foldl', so 
  -- firstElem can't see it
  = firstElem (foldl' (\acc x -> x*2 : acc) [2] (2:[3..10]))
  = firstElem (foldl' (\acc x -> x*2 : acc) [4,2] [3..10])
    ...
  = firstElem (foldl' (\acc x -> x*2 : acc) [18,16,14,12,10,8,6,4,2] (10:[]))
  = firstElem (foldl' (\acc x -> x*2 : acc) [20,18,16,14,12,10,8,6,4,2] [])
  = firstElem [20,18,16,14,12,10,8,6,4,2]
  = firstElem (20:[18,16,14,12,10,8,6,4,2])
  = Just 20

基於foldr的算法采用4個步驟來計算firstElem_foldr (newList [1..10]) ,而基於foldl'的算法采用21步的順序。 更糟糕的是,4步是恆定成本,而21是與輸入列表的長度成比例 - firstElem (newList_good [1..150000])需要300,001步,而firstElem (newList_foldr [1..150000]需要5個步驟,就像firstElem (newList_foldr [1..]那樣。

還要注意firstElem (newList_foldr [1.10])在恆定空間和常量時間內運行(它必須;你需要的不僅僅是恆定時間來分配超過常量空間)。 foldl從嚴格語言-不言而喻“ foldl是尾遞歸和在恆定空間中運行, foldr不是尾遞歸和線性空間或更糟運行”在Haskell -is不正確的。

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM