惯用高效的 Haskell 追加?

Posted

技术标签:

【中文标题】惯用高效的 Haskell 追加?【英文标题】:Idiomatic efficient Haskell append? 【发布时间】:2011-07-08 11:31:06 【问题描述】:

List 和 cons 运算符 (:) 在 Haskell 中非常常见。缺点是我们的朋友。但有时我想添加到列表的末尾。

xs `append` x = xs ++ [x]

遗憾的是,这不是实现它的有效方法。

我在 Haskell 中写了 Pascal's triangle,但我不得不使用 ++ [x] 反成语:

ptri = [1] : mkptri ptri
mkptri (row:rows) = newRow : mkptri rows
    where newRow = zipWith (+) row (0:row) ++ [1]

恕我直言,这是一个可爱易读的帕斯卡三角形,但反成语让我感到厌烦。有人可以向我解释(理想情况下,为我指出一个好的教程)对于您想要有效地追加到末尾的情况,惯用的数据结构是什么?我希望这种数据结构及其方法具有近似列表的美感。或者,或者,向我解释为什么这个反成语实际上对这种情况并没有那么糟糕(如果你认为是这样的话)。


[编辑] 我最喜欢的答案是Data.Sequence,它确实具有“近乎列表般的美感”。不知道我对所需的操作严格性有何看法。欢迎提供进一步的建议和不同的想法。

import Data.Sequence ((|>), (<|), zipWith, singleton)
import Prelude hiding (zipWith)

ptri = singleton 1 : mkptri ptri

mkptri (seq:seqs) = newRow : mkptri seqs
    where newRow = zipWith (+) seq (0 <| seq) |> 1

现在我们只需要 List 是一个类,这样其他结构就可以使用它的方法,比如zipWith,而不需要从 Prelude 中隐藏它,或者限定它。 :P

【问题讨论】:

List 不是一个类,但 ListLike 是。还有一个 Data.Sequence 实例可用。 hackage.haskell.org/package/ListLike-3.0.1 随机烦恼:我首先尝试写newRow = 1 &lt;| zipWith (+) seq (drop 1 seq) |&gt; 1,恕我直言,通过在每一行的两端显式显示1来表达帕斯卡三角形非常漂亮。可悲的是,我收到了这个错误:cannot mix '&lt;|' [infixr 5] and '|&gt;' [infixl 5] in the same infix expression 第二个newRow 非常漂亮。我使用 ZipLists 进行了一次不太成功的尝试(我正在计划更通用的东西,但它太复杂了)hpaste.org/44613/pascals_ziplist。使用 she 预处理器的成语方括号和一些自制的组合器,它看起来像这样:pascalsNextLine old = 1 &lt;&amp; (| tail' old + init' old |) &amp;&gt; 1 知道您的这个想法是否有什么可以概括的东西会很有趣。 看来|&gt;&lt;| 的优先级相同,所以它们不能并排。我想知道是否有办法改变它,而不会破坏其他文件,按每个文件排序。 我也想知道这个。我的一个想法是使用 O(1) cons 作为附加,然后使用 O(n) reverse 结果列表。另请参阅:mapreverse 的实现之间的对比。 【参考方案1】:

标准Sequence 的“两端”加法为 O(1),一般连接为 O(log(min(n1,n2))):

http://hackage.haskell.org/packages/archive/containers/latest/doc/html/Data-Sequence.html

与列表的区别在于 Sequence 是严格的

【讨论】:

Data.Sequence 的新用户最沮丧的事情之一是它导出的函数并不多。您需要利用 Functor、Foldable、Monoid 和 Traversable 实例来访问许多常见操作。 @John:是的,但这在很多方面都是一种美德。了解这些操作后,您几乎可以在任何数据结构上使用它们。【参考方案2】:

Chris Okasaki 设计了一个队列来解决这个问题。见他论文的第 15 页 http://www.cs.cmu.edu/~rwh/theses/okasaki.pdf

您可能需要稍微调整代码,但是使用一些反向和保留两个列表可以让您平均而言更高效地工作。

另外,有人在 monad 阅读器中放置了一些列表代码,具有高效的操作。我承认,我并没有真正遵循它,但我想如果我集中注意力,我就能弄清楚。原来是 Monad Reader 第 17 期中的 Douglas M. Auclair http://themonadreader.files.wordpress.com/2011/01/issue17.pdf


我意识到上述答案并没有直接解决这个问题。所以,为了咯咯笑,这是我的递归答案。随意撕开它——它不漂亮。

import Data.List 

ptri = [1] : mkptri ptri

mkptri :: [[Int]] -> [[Int]]
mkptri (xs:ys) =  mkptri' xs : mkptri ys

mkptri' :: [Int] -> [Int]
mkptri' xs = 1 : mkptri'' xs

mkptri'' :: [Int] -> [Int]
mkptri'' [x]        = [x]
mkptri'' (x:y:rest) = (x + y):mkptri'' (y:rest)

【讨论】:

【参考方案3】:

根据您的用例,ShowS 方法(通过函数组合附加)可能有用。

【讨论】:

鉴于使用的确切算法,我会使用ShowS 方法。但无论如何,它只会是一个常数因子的改进。构建有问题的行已经是 O(n)。添加另一个 O(n) 步骤不会使情况变得更糟。【参考方案4】:

像这种显式递归这样的东西可以避免你追加“反成语”。虽然,我认为它不像你的例子那么清楚。

ptri = []:mkptri ptri
mkptri (xs:ys) = pZip xs (0:xs) : mkptri ys
    where pZip (x:xs) (y:ys) = x+y : pZip xs ys
          pZip [] _ = [1]

【讨论】:

+1 聪明的回答。你是对的,它不太清楚,但它确实避免了反成语,并且它尽可能高效(afaik)。不过,不是“接受”的答案,因为我希望有一个通用的解决方案。你实际上可以写最后一行pZip _ _ = [1]【参考方案5】:

我写了一个@geekosaur 的ShowS 方法的例子。您可以在prelude 中看到许多ShowS 的示例。

ptri = []:mkptri ptri
mkptri (xs:ys) = (newRow xs []) : mkptri ys

newRow :: [Int] -> [Int] -> [Int]
newRow xs = listS (zipWith (+) xs (0:xs)) . (1:)

listS :: [a] -> [a] -> [a]
listS [] = id
listS (x:xs) = (x:) . listS xs

[edit] 按照@Dan 的想法,我用 zipWithS 重写了 newRow。

newRow :: [Int] -> [Int] -> [Int]
newRow xs = zipWithS (+) xs (0:xs) . (1:)

zipWithS :: (a -> b -> c) -> [a] -> [b] -> [c] -> [c]
zipWithS z (a:as) (b:bs) xs =  z a b : zipWithS z as bs xs
zipWithS _ _ _ xs = xs

【讨论】:

好吧,也许我只是笨,但我看不出listS++ 之间的区别,given the definition of ++ 是一样的,只是少了一点无意义:(++): : [a] -> [a] -> [a] (++) [] ys = ys (++) (x:xs) ys = x : xs ++ ys 或者你可以创建zipWithS,因为zipWith 无论如何都必须遍历每个项目。 不,我很笨。现在我意识到这些是一样的!以及前奏中的showString = (++)。所以 newRow 可以是newRow xs = ((zipWith (+) xs (0:xs)) ++). (1:) @Dan:我喜欢这个主意。重点是流(连接 [a]->[a] 而不是列表)是一个习惯用法,我在很多地方都看到过。【参考方案6】:

如果你只想要廉价的 append (concat) 和 snoc (cons at the right),那么 Hughes 列表(在 Hackage 上也称为 DList)是最容易实现的。如果您想知道它们是如何工作的,请查看 Andy Gill 和 Graham Hutton 的第一篇 Worker Wrapper 论文,John Hughes 的原始论文似乎不在网上。正如其他人在上面所说的,ShowS 是一个 String 专门的 Hughes 列表/DList。

JoinList 需要做更多的工作来实现。这是一棵二叉树,但有一个列表 API - concat 和 snoc 很便宜,你可以合理地 fmap 它:Hackage 上的 DList 有一个仿函数实例,但我认为它不应该有 - 仿函数实例必须变形进出一个常规列表。如果你想要一个 JoinList,那么你需要自己推出一个 - Hackage 上的那个是我的,它效率不高,也写得不好。

Data.Sequence 具有高效的 cons 和 snoc,并且适用于其他操作 - 获取、删除等 JoinList 较慢的操作。因为 Data.Sequence 的内部手指树实现必须平衡树,所以 append 比它的 JoinList 等效项工作更多。在实践中,因为 Data.Sequence 写得更好,我希望它仍然优于我的 JoinList 追加。

【讨论】:

【参考方案7】:

如果您正在寻找通用解决方案,那么如何:

mapOnto :: [b] -> (a -> b) -> [a] -> [b]
mapOnto bs f = foldr ((:).f) bs

这给出了 map 的一个简单的替代定义:

map = mapOnto []

我们可以对其他基于 foldr 的函数进行类似的定义,例如 zipWith:

zipOntoWith :: [c] -> (a -> b -> c) -> [a] -> [b] -> [c]
zipOntoWith cs f = foldr step (const cs)
  where step x g [] = cs
        step x g (y:ys) = f x y : g ys

再次相当容易地导出 zipWith 和 zip:

zipWith = zipOntoWith []
zip = zipWith (\a b -> (a,b))

现在,如果我们使用这些通用功能,您的实现 变得很容易:

ptri :: (Num a) => [[a]]
ptri = [] : map mkptri ptri
  where mkptri xs = zipOntoWith [1] (+) xs (0:xs)

【讨论】:

【参考方案8】:

另一种方法是通过使用无限列表来完全避免串联:

ptri = zipWith take [0,1..] ptri'
  where ptri' = iterate stepRow $ repeat 0
        stepRow row = 1 : zipWith (+) row (tail row)

【讨论】:

你可以写成:ptri = zipWith take [1..] . iterate ((zipWith (+) &lt;*&gt; tail) . (0:)) $ 1 : repeat 0(假设你已经导入了Control.Applicative)。另一个有趣的方法是:ptri = zipWith take [1..] . transpose . zipWith (++) (iterate (0 :) []) . iterate (scanl1 (+)) $ repeat 1【参考方案9】:

我不一定会称您的代码为“反 idomatic”。通常,越清晰越好,即使这意味着要牺牲几个时钟周期。

在您的特定情况下,最后的追加实际上并没有改变 big-O 时间复杂度!评估表达式

zipWith (+) xs (0:xs) ++ [1]

将花费时间成正比length xs 并且没有花哨的序列数据结构会改变这一点。如果有的话,只有常数因子会受到影响。

【讨论】:

非常正确,每行的总工作量仍然是 O(n)。不过,我喜欢 Data.Sequence 解决方案,因为它允许相同的清晰度(或者,我会说,稍微更清晰),并且它还减少了每行必须完成的 O(n) 工作量。跨度> 正如 lpsmith 上面指出的那样,在这种情况下,您甚至不会丢失任何时钟周期。 GHC 足够聪明,可以将它们优化掉。【参考方案10】:

请记住,看起来很差的渐近性实际上可能不是,因为您使用的是一种惰性语言。在严格的语言中,以这种方式附加到链表的末尾总是 O(n)。在惰性语言中,只有当您实际遍历到列表末尾时,它才是 O(n),在这种情况下,无论如何您都会花费 O(n) 的努力。所以在很多情况下,懒惰可以拯救你。

这不是保证...例如,k 个追加后跟一个遍历仍然会在 O(nk) 中运行,而它本来可以是 O(n+k)。但它确实改变了一些情况。当立即强制执行结果时,根据其渐近复杂性来考虑单个操作的性能并不总能给出正确的答案。

【讨论】:

【参考方案11】:

在您的帕斯卡三角代码中,++ [x] 实际上不是问题。由于无论如何您都必须在 ++ 的左侧生成一个新列表,因此您的算法本质上是二次的;你不能仅仅通过避免 ++ 来使其渐近更快。

此外,在这种特殊情况下,当您编译 -O2 时,GHC 的列表融合规则(应该)消除 ++ 通常会创建的列表副本。这是因为 zipWith 是一个好的生产者,而 ++ 在它的第一个参数中是一个好的消费者。您可以在GHC User's Guide 中了解这些优化。

【讨论】:

+1 酷,我想像这样的事情可能是真的。很好的链接。【参考方案12】:

您可以将列表表示为从 [] 构建列表的函数

list1, list2 :: [Integer] -> [Integer]
list1 = \xs -> 1 : 2 : 3 : xs
list2 = \xs -> 4 : 5 : 6 : xs

然后您可以轻松附加列表并添加到任一端。

list1 . list2 $ [] -> [1,2,3,4,5,6]
list2 . list1 $ [] -> [4,5,6,1,2,3]
(7:) . list1 . (8:) . list2 $ [9] -> [7,1,2,3,8,4,5,6,9]

您可以重写 zipWith 以返回这些部分列表:

zipWith' _ [] _ = id
zipWith' _ _ [] = id
zipWith' f (x:xs) (y:ys) = (f x y :) . zipWith' f xs ys

现在你可以把 ptri 写成:

ptri = [] : mkptri ptri
mkptri (xs:yss) = newRow : mkptri yss
    where newRow = zipWith' (+) xs (0:xs) [1]

更进一步,这是一个更对称的单线:

ptri = ([] : ) . map ($ []) . iterate (\x -> zipWith' (+) (x [0]) (0 : x [])) $ (1:)

或者这更简单:

ptri = [] : iterate (\x -> 1 : zipWith' (+) (tail x) x [1]) [1]

或者没有 zipWith'(mapAccumR 在 Data.List 中):

ptri = [] : iterate (uncurry (:) . mapAccumR (\x x' -> (x', x+x')) 0) [1]

【讨论】:

以上是关于惯用高效的 Haskell 追加?的主要内容,如果未能解决你的问题,请参考以下文章

学习惯用 Haskell 的资源(eta 缩减、符号中缀运算符、库等)[关闭]

用于简化递归的惯用 Haskell 代码

Haskell 应用习语?

Haskell:高效累加器

在 Haskell 中实现一个高效的滑动窗口算法

Haskell 中的内存高效字符串