问题 项目Euler 14:与C和memoization相比的性能


我正在努力 项目欧拉问题14

我用一个编码不好的程序解决了这个问题,没有记忆 386 运行5秒钟(见编辑)。

这里是:

step :: (Integer, Int) -> Integer -> (Integer, Int)
step (i, m) n   | nextValue > m         = (n, nextValue)
                | otherwise             = (i, m)
                where nextValue = syr n 1

syr :: Integer -> Int -> Int
syr 1 acc   = acc
syr x acc   | even x    = syr (x `div` 2) (acc + 1)
            | otherwise = syr (3 * x + 1) (acc + 1)

p14 = foldl step (0, 0) [500000..999999]

我的问题是关于这个问题的线程中的几条评论,其中提到程序的执行时间<1秒如下(C代码,项目euler论坛用户的信用)  对于代码 - 注意:我没有检查执行时间实际上是如上所述):

#include <stdio.h>


int main(int argc, char **argv) {
    int longest = 0;
    int terms = 0;
    int i;
    unsigned long j;
    for (i = 1; i <= 1000000; i++) {
        j = i;
        int this_terms = 1;
        while (j != 1) {
            this_terms++;
            if (this_terms > terms) {
                terms = this_terms;
                longest = i;
            }
            if (j % 2 == 0) {
                j = j / 2;
            } else {
                j = 3 * j + 1;
            }
        }
    }
    printf("longest: %d (%d)\n", longest, terms);
    return 0;
}

在谈到算法时,对我来说,这些程序有点相同。

所以我想知道为什么会有这么大的差异?或者我们的两种算法之间是否存在任何可以证明性能的x6因素的差异?

顺便说一句,我目前正试图用memoization来实现这个算法,但是对我来说有点迷失,用命令式语言实现它更容易(而且我还没有操作monad所以我不能使用这个范例) 。因此,如果您有任何适合初学者学习备忘录的好教程,我会很高兴(我遇到的那些不够详细或者不在我的联盟中)。

注意:我通过Prolog来进行声明性范例,并且仍处于发现Haskell的早期阶段,所以我可能会错过重要的事情。

注2:欢迎任何关于我的代码的一般建议。

编辑: 感谢delnan的帮助,我编译了程序,它现在运行5秒钟,所以我现在主要寻找关于memoization的提示(即使仍然欢迎有关现有x6差距的想法)。


3739
2018-03-18 18:29


起源

好吧,更换 ghci 同 ghc -O 应该是一个明智的选择,必然会缩小差距。无论它有多有效,尝试它可能是一个好主意,如果只是为了回答臭名昭着的“你尝试了什么?”评论;)更不用说尝试更容易和更快,而不是带来记忆。
@delnan:我的问题是我仍然是Haskell的新手,但仍然没有看到如何正确编译程序。或者更准确地说,我看了看,然后看到了 do 被用过了,以为我以后会回去,当时我会在monad上工作。但是我在阅读这个问题时也做了同样的评论,所以我觉得有点愚蠢:p - m09
为了让你开始,你可能会逃脱 main = print p14 并编译为 ghc -O p14.hs。
@delnan:谢谢,它现在运行大约5秒钟,所以这绝对是主要的区别。我建议你发表你的评论作为答案,这样我至少可以赞成它!我将精确编辑,我现在正在寻找主要关于memoization的答案。 - m09
嗯,5x仍然是一个非常大的差异,我没有提供关于memoization的任何东西。我建议你只需用新的运行时更新问题,删除GHCi参考,然后继续询问memoization和其他优化。我更愿意看到一些代表的答案:)


答案:


在用优化编译之后,C程序仍然存在一些差异

  • 你用 div虽然C程序使用机器分割(截断)[但任何自尊的C编译器将其转换为移位,因此使其更快],这将是 quot 在哈斯克尔;这使运行时间缩短了约15%。
  • C程序使用固定宽度的64位(甚至32位,但它只是运气得到正确的答案,因为一些中间值超过32位范围)整数,Haskell程序使用任意精度 Integer秒。如果你有64位 Int在你的GHC(Windows以外的64位操作系统)中,替换 Integer 同 Int。这样可以将运行时间缩短约3倍。如果您使用的是32位系统,那么运气不好,GHC不会在那里使用本机64位指令,这些操作是作为C调用实现的,但仍然相当慢。

对于备忘录,您可以将其外包给hackage上的一个memoisation包,我唯一记得的是 数据memocombinators,但还有其他人。或者你可以自己做,例如保留以前计算的值的地图 - 这将最有效 State 单子,

import Control.Monad.State.Strict
import qualified Data.Map as Map
import Data.Map (Map, singleton)

type Memo = Map Integer Int

syr :: Integer -> State Memo Int
syr n = do
    mb <- gets (Map.lookup n)
    case mb of
      Just l -> return l
      Nothing -> do
          let m = if even n then n `quot` 2 else 3*n+1
          l <- syr m
          let l' = l+1
          modify (Map.insert n l')
          return l'

solve :: Integer -> Int -> Integer -> State Memo (Integer,Int)
solve maxi len start
    | len > 1000000 = return (maxi,len)
    | otherwise = do
         l <- syr start
         if len < l
             then solve start l (start+1)
             else solve maxi len (start+1)

p14 :: (Integer,Int)
p14 = evalState (solve 0 0 500000) (singleton 1 1)

但这可能不会获得太多(即使你已经添加了必要的严格性)。麻烦在于查找 Map不太便宜,插入相对昂贵。

另一种方法是为查找保留一个可变数组。代码变得更加复杂,因为您必须为要缓存的值选择合理的上限(应该不比起始值的界限大很多)并处理落在记忆范围之外的序列部分。但是数组查找和写入速度很快。如果你有64位 Ints,下面的代码运行得相当快,这里需要0.03s的限制为100万,0.33s限制为1000万,相应的(尽可能合理)C代码运行0.018 resp。 0.2S。

module Main (main) where

import System.Environment (getArgs)
import Data.Array.ST
import Data.Array.Base
import Control.Monad.ST
import Data.Bits
import Data.Int

main :: IO ()
main = do
    args <- getArgs
    let bd = case args of
               a:_ -> read a
               _   -> 100000
    print $ collMax bd

next :: Int -> Int
next n
    | n .&. 1 == 0  = n `unsafeShiftR` 1
    | otherwise     = 3*n + 1

collMax :: Int -> (Int,Int16)
collMax upper = runST $ do
    arr <- newArray (0,upper) 0 :: ST s (STUArray s Int Int16)
    let go l m
            | upper < m = go (l+1) $ next m
            | otherwise = do
                l' <- unsafeRead arr m
                case l' of
                  0 -> do
                      l'' <- go 1 $ next m
                      unsafeWrite arr m (l'' + 1)
                      return (l+l'')
                  _ -> return (l+l'-1)
        collect mi ml i
            | upper < i = return (mi, ml)
            | otherwise = do
                l <- go 1 i
                if l > ml
                  then collect i l (i+1)
                  else collect mi ml (i+1)
    unsafeWrite arr 1 1
    collect 1 1 2

9
2018-03-18 18:57



unsigned long 不一定是64位。我认为 Word 应始终与...相同 unsigned long 与GHC。 - ehird
@Daniel Fischer:遗憾的是我现在正在使用Windows机器,所以 Int 类型是不够的。该 quot 修改在这里没有帮助,执行时间是一样的。谢谢你的答案,你肯定是对的 Integer秒。 - m09
@ehird正确, unsigned long 不一定是64位。但如果该程序有效,那就是在作者的机器上。啊,当然,它不一定是,但如果它是32位,那就是运气。 - Daniel Fischer
@DanielFischer:感谢您对答案的扩展,添加的部分确实是一个非常有洞察力的读物。 - m09


好吧,C程序使用 unsigned long但是 Integer 可以存储任意大整数(它是一个 BIGNUM)。如果你导入 Data.Word,那你就可以用了 Word,这是一个机器字大小的无符号整数。

更换后 Integer 同 Word,并使用 ghc -O2 和 gcc -O3,C程序运行0.72秒,而Haskell程序运行1.92秒。 2.6x也不错。然而, ghc -O2 并不总是有帮助,这是它没有的程序之一!只使用 -O正如您所做的那样,将运行时间降低到1.90秒。

我试过更换 div 同 quot (它使用与C相同的除法类型;它们仅在负输入上有所不同),但奇怪的是它实际上使Haskell程序对我来说运行得稍慢。

你应该能够加快速度 syr 功能的帮助 这个以前的Stack Overflow问题 我回答了同样的Project Euler问题。


4
2018-03-18 18:58



好吧,即使你的其他答案没有修改,你的 Word 修改完成了工作:现在减少到1秒。这绝对是一个 Integer 开销问题。谢谢! - m09
@Mog注意用32位 Words,你有溢出,而且你得到正确的答案真的很幸运。 - Daniel Fischer
@Mog:如果您使用的是32位平台,请尝试使用 Word64;它的开销可能比 Integer。 - ehird
@Mog其中一个序列中出现的最大数字是56991483520,起始值为704511.幸运的是,没有任何溢出导致更长的序列,或者序列最长的序列不会溢出。 - Daniel Fischer
@Daniel Fischer:好吧,我不打算长时间使用Windows,因为如果我暴露在外,它往往会对我造成脑损伤。我会很快得到一个fedora运行,不会被那些讨厌的32位困扰 Ints:d - m09


在我当前的系统(32位Core2Duo)上,您的Haskell代码,包括答案中给出的所有优化, 0.8s 编译和 1.2s 跑步。

您可以将运行时转移到编译时,并将运行时间减少到接近零。

module Euler14 where

import Data.Word
import Language.Haskell.TH

terms :: Word -> Word
terms n = countTerms n 0
  where
    countTerms 1 acc             = acc + 1
    countTerms n acc | even n    = countTerms (n `div` 2) (acc + 1)
                     | otherwise = countTerms (3 * n + 1) (acc + 1)

longestT :: Word -> Word -> (Word, Word) 
longestT mi mx = find mi mx (0, 0)
  where
      find mi mx (ct,cn) | mi == mx  = if ct > terms mi then (ct,cn) else (terms mi, mi)
                         | otherwise = find (mi + 1) mx
                                       (if ct > terms mi then (ct,cn) else (terms mi, mi))

longest :: Word -> Word -> ExpQ
longest mi mx = return $ TupE [LitE (IntegerL (fromIntegral a)),
                               LitE (IntegerL (fromIntegral b))]
  where
    (a,b) = longestT mi mx

{-# LANGUAGE TemplateHaskell #-}
import Euler14

main = print $(longest 500000 999999)

在我的系统上它需要 2.3s 编译这个,但运行时间下降到 0.003s。编译时功能执行(CTFE)是您在C / C ++中无法做到的。我所知道的唯一支持CTFE的编程语言是 D编程语言。只是为了完整,C代码需要 0.1s 编译和 0.7s 跑步。


2
2018-04-03 21:12



在许多情况下,您可以避免编写包装器 longest 你自己而是使用 lift 从 Language.Haskell.TH.Syntax 根据计算结果构造一个文字表达式。 (但令人讨厌的是,没有 Lift 实例 Word,因此在这种情况下需要进行一些调整才能使其工作)。 - hammar
@hammar感谢您的提示。我是模板Haskell的新手:-) - Arlen
有趣的东西,谢谢:) - m09