【问题标题】:How does one write efficient Dynamic Programming algorithms in Haskell?如何在 Haskell 中编写高效的动态规划算法?
【发布时间】:2012-06-09 23:41:18
【问题描述】:

我一直在玩 Haskell 中的动态编程。实际上,我在该主题上看到的每个教程都提供了相同的、非常优雅的算法,该算法基于记忆化和 Array 类型的惰性。受这些示例的启发,我编写了以下算法作为测试:

-- 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 秒来计算 pascal 1000,这比等效的未优化 C++ 程序慢了大约 160 倍。并且差距只会随着更大的投入而扩大。

似乎我已经尝试了上述代码的所有可能排列,以及建议的替代方案,如 data-memocombinators 库,它们的性能都相同或更差。我没有尝试过的一件事是 ST Monad,我确信它可以让程序运行的速度只比 C 版本慢一点。但我真的很想用惯用的 Haskell 来写它,我不明白为什么惯用的版本效率如此之低。我有两个问题:

  1. 为什么上面的代码效率这么低?这似乎是对矩阵的简单迭代,每个条目都有一个算术运算。显然 Haskell 在幕后做了一些我不理解的事情。

  2. 有没有一种方法可以在不牺牲其无状态、递归公式(相对于在圣莫纳德)?

非常感谢。

编辑:使用的数组模块是标准的Data.Array

【问题讨论】:

  • 你使用的是哪个数组模块?
  • 如果只使用 "f (i,j) = (f (i, j-1) + f (i-1,j))" 并完全放弃 p,性能如何比较?我不明白通过 p 应该如何提供帮助,尽管我承认我对 Haskell 不是很有经验。
  • @DGH:数组的重点是每个结果只计算一次。如果没有数组,算法将是蛮力 - 而不是 DP。
  • 我注意到的第一件事是免费的元组,而不是多参数函数。
  • @LouisWasserman 是的,但实际上这没有区别(在我的不科学测量中)。我还没有看过核心,但我希望 GHC 能够优化它们。

标签: performance algorithm haskell functional-programming


【解决方案1】:

诀窍是考虑如何一次编写整个该死的算法,然后使用未装箱的向量作为支持数据类型。例如,以下代码在我的机器上的运行速度比您的代码快 20 倍:

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 函数调用你的和我的,参数为4000;这些分别在10.42s0.54s 中运行。当然,我相信你知道,它们都被使用更好算法的版本吹出了水面(0.00s):

pascal' :: Integer -> Integer
pascal :: Int -> Int
pascal' n = product [n+1..n*2] `div` product [2..n]
pascal = fromIntegral . (`mod` 1000000) . pascal' . fromIntegral

【讨论】:

    【解决方案2】:

    1 为什么上面的代码效率这么低?这似乎是对矩阵的简单迭代,每个条目都有一个算术运算。显然 Haskell 在幕后做了一些我不理解的事情。

    问题在于代码将 thunk 写入数组。然后,当读取条目(n,n) 时,对 thunk 的评估再次在整个数组中跳跃,重复直到最终找到不需要进一步递归的值。这会导致大量不必要的分配和效率低下。

    C++ 代码没有这个问题,值被写入并直接读取,无需进一步评估。就像STUArray 一样。有没有

    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 有没有一种方法可以在不牺牲其无状态递归公式的情况下提高效率(最多是 C 程序运行时间的 10-15 倍)(相对于在 ST Monad 中使用可变数组的实现) )?

    我不知道有一个。但可能有。

    附录:

    一旦使用STUArrays 或未装箱的Vectors,与等效的C 实现仍然存在显着差异。原因是 gcc 用乘法、移位和减法的组合替换了%(即使没有优化),因为模数是已知的。在 Haskell 中手动做同样的事情(因为 GHC 还没有这样做),

    -- 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 + 29,999 = 30,009。 :) “难道”(...runSTU...)“真的看起来很糟糕吗?” 是的。我更愿意用 C/pythonic 表示法编写,并让 Haskell 自己找出它的一元翻译。它确实找出了类型,为什么不知道单子呢?参见例如这个mutable map-based python primes generator 以获得清晰的语法。想象一下 Haskell 单子代码翻译会是什么样子。
    • 好吧,@Will,如果编译器能够自己弄清楚如何使代码高效,那肯定会很方便。但这不会很快发生。因此,暂时,您必须提供帮助。 Python 的东西也遇到了同样的问题,它又好又短,但是当涉及到生产时,它的速度很慢(也许使用 CPython 或 PyPy 的狗要小得多,不知道他们能做什么)。如果你想要它快,你必须告诉编译器如何更详细地用任何语言来做。
    【解决方案3】:

    嗯,算法可以设计得更好一点。使用 vector 包并巧妙地一次只在内存中保留一行,我们可以以不同的方式获得一些惯用的东西:

    {-# 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.04 秒内运行(对于 n = 4000),在 0.26 秒内运行。
    • 这也符合我的评估;很抱歉造成混乱。
    • 好吧,我不能说“在这个”是特别明确的。
    猜你喜欢
    • 2011-06-26
    • 2022-06-16
    • 2012-03-26
    • 2013-11-27
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多