如何在Haskell中编写高效的动态编程算法?

Iva*_*rov 22 algorithm performance haskell functional-programming

我一直在玩Haskell中的动态编程.实际上,我在这个主题上看到的每个教程都提供了基于memoization和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 
Run Code Online (Sandbox Code Playgroud)

我唯一的问题是效率.即使使用GHC的-O2,该程序也需要1.6秒的计算时间pascal 1000,这比同等的未经优化的C++程序慢约160倍.而且只有更大的投入才能扩大差距.

看起来我已经尝试了上述代码的所有可能的排列,以及像data-memocombinators库这样的建议替代方案,并且它们都具有相同或更差的性能.我没有尝试过的一件事就是ST Monad,我确信它可以让程序运行得比C版慢得多.但我真的很想用惯用的Haskell写它,我不明白为什么惯用版本效率低下.我有两个问题:

  1. 为什么上面的代码效率低下?它似乎是通过矩阵的直接迭代,在每个条目处进行算术运算.显然,Haskell正在做一些我不了解的幕后工作.

  2. 有没有办法让它更有效率(最多是C程序运行时的10-15倍)而不牺牲其无状态的递归公式(相对于ST Monad中使用可变数组的实现)?

非常感谢.

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

Lou*_*man 17

那么,算法可以设计得更好一些.使用这个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
Run Code Online (Sandbox Code Playgroud)

非常紧密地优化,特别是因为该vector软件包包含一些相当巧妙的技巧来透明地优化以惯用风格​​编写的数组操作.


Dan*_*her 9

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
Run Code Online (Sandbox Code Playgroud)

真的好看吗?

2有没有办法使它更有效率(最多是C程序运行时的10-15倍)而不牺牲其无状态的递归公式(相对于ST Monad中使用可变数组的实现)?

我不知道一个.但可能会有.

附录:

一旦使用STUArrays或unboxed 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)
Run Code Online (Sandbox Code Playgroud)

获得与C相同的Haskell版本

  • 答案解释_why_方法很慢.我认为理解这一点非常重要. (3认同)

Dan*_*ner 9

诀窍是考虑如何立即编写整个该死的算法,然后使用未装箱的向量作为后备数据类型.例如,以下在我的机器上运行速度比代码快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))
Run Code Online (Sandbox Code Playgroud)

然后我写了两个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
Run Code Online (Sandbox Code Playgroud)