惯用高效的 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 <| zipWith (+) seq (drop 1 seq) |> 1
,恕我直言,通过在每一行的两端显式显示1来表达帕斯卡三角形非常漂亮。可悲的是,我收到了这个错误:cannot mix '<|' [infixr 5] and '|>' [infixl 5] in the same infix expression
第二个newRow
非常漂亮。我使用 ZipLists 进行了一次不太成功的尝试(我正在计划更通用的东西,但它太复杂了)hpaste.org/44613/pascals_ziplist。使用 she
预处理器的成语方括号和一些自制的组合器,它看起来像这样:pascalsNextLine old = 1 <& (| tail' old + init' old |) &> 1
知道您的这个想法是否有什么可以概括的东西会很有趣。
看来|>
和<|
的优先级相同,所以它们不能并排。我想知道是否有办法改变它,而不会破坏其他文件,按每个文件排序。
我也想知道这个。我的一个想法是使用 O(1) cons
作为附加,然后使用 O(n) reverse
结果列表。另请参阅:map
与 reverse
的实现之间的对比。
【参考方案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 (+) <*> 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 追加?的主要内容,如果未能解决你的问题,请参考以下文章