How does one write efficient Dynamic Programming algorithms in Haskell?
我一直在哈斯克尔玩动态编程。实际上,我在这个主题上看到的每一个教程都给出了基于记忆化和数组类型的惰性的、非常优雅的算法。在这些例子的启发下,我编写了以下算法作为测试:
1 2 3 4 5 6 7 8 9 10
| -- pascal n returns the nth entry on the main diagonal of pascal's triangle
-- (mod a million for efficiency)
pascal :: Int -> Int
pascal n = p ! (n,n ) where
p = listArray ((0, 0), (n,n )) [f (i,j ) | i <- [0 .. n ], j <- [0 .. n ]]
f :: (Int, Int) -> Int
f (_, 0) = 1
f (0,_ ) = 1
f (i,j ) = (p ! (i, j -1) + p ! (i -1, j )) ` mod` 1000000 |
我唯一的问题是效率。即使使用GHC的-O2,这个程序也需要1.6秒来计算EDCOX1×0Ω,比等效的未优化C++程序慢约160倍。而这种差距只会随着输入量的增加而扩大。
似乎我已经尝试了上述代码的每一种可能的排列方式,以及像数据memocombinator库这样的建议备选方案,它们都有相同或更差的性能。我没有尝试过的一件事是圣莫纳德,我确信它可以使程序运行的速度比C版本稍慢。但我真的很想用惯用的haskell来写,我不明白为什么惯用的版本效率这么低。我有两个问题:
为什么上面的代码效率这么低?它看起来像是一个简单的矩阵迭代,每个条目都有一个算术运算。显然,哈斯克尔在幕后做了一些我不理解的事情。
有没有一种方法可以让它更高效(最多是C程序运行时的10-15倍),而不牺牲它的无状态递归公式(相对于使用圣莫纳德中可变数组的实现而言)?
谢谢。
编辑:使用的数组模块是标准数据。数组
- 用rem代替mod。
- 您使用的是哪个阵列模块?
- 如果只使用"f(i,j)=(f(i,j-1)+f(i-1,j))"和完全忽略p,那么性能如何比较?虽然我承认我对哈斯凯尔的经验不多,但我不明白通过P应该有什么帮助。
- @DGH:数组的点是只计算一次每个结果。如果没有数组,算法将是蛮力-而不是dp。
- 我注意到的第一件事是免费的元组,而不是多参数函数。
- @路易斯瓦瑟曼是的,但实际上没有区别(在我不科学的测量)。我没有研究核心,但我希望GHC能把它们优化掉。
- @谢谢,现在更合理了。对于任何人来说:"!"在最后一行有必要吗?是否有可能强制重新评估P中不需要重新计算的值?
- @在最后一行,!是数组索引运算符。
嗯,算法可以设计得更好一点。使用Vector包,并且一次只在内存中保留一行,我们可以以不同的方式获得惯用的内容:
1 2 3 4 5 6 7 8 9 10
| {-# LANGUAGE BangPatterns #-}
import Data.Vector.Unboxed
import Prelude hiding (replicate, tail, scanl)
pascal :: Int -> Int
pascal !n = go 1 ((replicate (n +1) 1) :: Vector Int) where
go !i !prevRow
| i <= n = go (i +1) (scanl f 1 (tail prevRow ))
| otherwise = prevRow ! n
f x y = (x + y ) ` rem` 1000000 |
这将非常严格地进行优化,尤其是因为Vector包包含一些相当巧妙的技巧,可以透明地优化以惯用风格编写的数组操作。
- 别忘了模量,这是最需要时间的。
- 嗯,嗯。我不相信在最初的实现中,模数比惰性的thunk开销花费了更多的时间,但是我同意它将成为这个实现中的瓶颈。
- 在原材料中,模量不是很大。但在处理相当优化的向量/stuarray算法时,它是。您的代码在0.04s内运行(n=4000),这里没有模量,在0.26s内运行。
- 这也符合我的评价;很抱歉弄混了。
- 好吧,我不能说"在这"是特别明确的。
- 哇,真令人印象深刻。虽然这会稍微改变算法,但它的运行速度只比我的机器上未优化的C慢50%。
- 当然这是哈斯克尔惯用语。诀窍通常是重写问题以向GHC公开尽可能多的优化机会;vector包和流融合特别擅长于此。
诀窍是考虑如何立即编写整个该死的算法,然后使用未装箱的向量作为您的备份数据类型。例如,以下在我的计算机上运行的速度大约是您的代码的20倍:
1 2 3 4 5 6 7 8
| import qualified Data.Vector.Unboxed as V
combine :: Int -> Int -> Int
combine x y = (x +y ) ` mod` 1000000
pascal n = V. last $ go n where
go 0 = V.replicate (n +1) 1
go m = V. scanl1 combine (go (m -1)) |
。
然后我写了两个main函数,分别在10.42s和0.54s中调用,参数为4000。当然,正如我确信你知道的,它们都会被吹出水面(0.00s版本),使用更好的算法:
1 Why is the above code so inefficient? It seems like a straightforward iteration through a matrix, with an arithmetic operation at each entry. Clearly Haskell is doing something behind the scenes I don't understand.
号
问题是代码向数组写入thunk。然后,当读取条目(n,n)时,thunk的计算会再次跳到数组上,直到最终找到不需要进一步递归的值为止。这会导致很多不必要的分配和效率低下。
C++代码没有这个问题,这些值被直接写入和读取,而不需要进一步的评估。就像发生在一个STUArray上一样。做
1 2 3 4 5 6 7 8
| p = runSTUArray $ do
arr <- newArray ((0, 0), (n,n )) 1
forM_ [1 .. n ] $ \i ->
forM_ [1 .. n ] $ \j -> do
a <- readArray arr (i,j -1)
b <- readArray arr (i -1,j )
writeArray arr (i,j ) $! (a +b ) ` rem` 1000000
return arr |
真的很难看?
2 Is there a way to make it much more efficient (at most 10-15 times the runtime of a C program) without sacrificing its stateless, recursive formulation (vis-a-vis an implementation using mutable arrays in the ST Monad)?
号
我不知道。但可能有。
附录:
一旦使用了STUArrays或unboxed Vectors,与等效的C实现仍然存在显著差异。原因是GCC用乘法、移位和减法(即使没有优化)的组合来代替%,因为模是已知的。在哈斯克尔也这样做(因为GHC还没有这样做)。
1 2 3 4 5
| -- fast modulo 1000000
-- for nonnegative Ints < 2^31
-- requires 64-bit Ints
fastMod :: Int -> Int
fastMod n = n - 1000000*((n *1125899907) `shiftR` 50) |
号
获取与C相同的haskell版本。
- 我认为这不是一个真正有用的答案。提问者说,他们知道stu方法会更有效,但想知道在教程中常用的方法是否会变得更有效。这个答案没有回答他的任何问题。我认为这是一个有趣的问题,因为程序运行非常缓慢。他所展示的技术如果能像现在这样慢的话,并不能给人多少信任。为了进行比较,我用相同的算法编写了一个Ruby版本,它的速度只有用-o2编译的GHC版本的两倍!
- 答案解释了为什么这种方法很慢。我认为这一点很重要。
- 是的,没错。我想这个问题的真正答案很可能是"使用listarray显示的技术天生效率低下",这是一个重要的观察结果(因为它使该技术对使用它的大多数问题都毫无用处)。
- +1+29999=30009。:)"难道"(…runstu…)看起来真的很糟糕吗?"对。我宁愿用C/Pythonic符号来写,让Haskell自己来计算它的一元翻译。它能找出类型,为什么不是单子?请参见例如,此基于可变映射的python primes generator以获得清晰的语法。想象一下haskell一元代码翻译会是什么样子。
- 好吧,@will,如果编译器能够自己找出如何使代码高效的话,那肯定是很方便的。但这不会很快发生。所以现在,你必须帮忙。Pythonthingy也有同样的问题,它又好又短,但是当它投入生产时,它的速度很慢(也许狗比cpython或pypy小得多,不知道他们能做什么)。如果你想要快速,你必须告诉编译器如何用任何语言更详细地完成它。
- 谢谢你的解释,丹尼尔,你说得对,stuarray代码看起来没那么糟糕。在我的机器上,它的运行速度比C慢8倍,比我的程序要好得多——我假设8倍是一元开销。我不想使用ST的原因是,我必须明确地给出执行顺序,正如其他评论者指出的那样,如果我想编写这种代码,最好使用命令式语言。我想知道是否有一种更实用的方法,而不会损失太多的效率,这就是data.vector。
- @Ivanvendrov是的,Vector包做了很多工作来提供一个功能性的接口,这个接口将成为引擎盖下的必要代码。这是一项伟大的工作。不过,您仍然明确地给出了执行顺序,但不太明显,也不太详细。对于8倍的差异,它可能是平台的一部分(在x86_64上,我不希望有这样的差异,ghc通常可以为它生成更好的代码),一部分是c(resp)中的%优化。C++,以及一个部分边界检查。这里的一元开销应该是0,应该完全优化掉。
- @我为你修改了丹尼尔的可怕代码。:)hpaste.org/69668
- @DanielFischer只是一个次要的信息,而"cpython"是"普通"的Python实现的名称(可从python.org等获得)。之所以称为"c python",是因为在讨论其他Python实现的上下文中,说"python"不明确,而解释器是在C中实现的。