我写了一个计算Collatz序列的函数,但这个函数的执行时间会因为初始值不同而有很大的不同。显然这与"记忆化"有关,但我很难理解它是什么以及如何工作。不幸的是,HaskellWiki上相关文章以及它链接的论文都被证明不易克服。它们讨论高度晦涩难懂的树构造相对性能的细节,而我错过的可能只是这些源所忽略的一些非常基本、非常琐碎的点。
以下是代码,是一个完整的程序,可以直接编译和执行。
--然后使用ghc -O2编译只需大约4秒钟,但如果没有ghc -O2,我活不到看到它完成。
--使用ghc -prof -fprof-auto -O2查看成本中心的详细信息,发现第一个版本进入collatz大约一亿次,而修补后的版本只有大约一百五十万次。这一定是加速的原因,但我很难理解这个神奇内部工作方式。我的最佳想法是,我们用O(log n) map查找替换了一部分昂贵的递归调用,但我不知道它是否正确以及为什么它如此依赖某些可怜的编译器标志,而在我看来,这样的性能波动应该完全由语言决定。
--我可以解释一下这里发生了什么,以及为什么ghc -O2和普通的ghc构建之间的性能差异如此巨大吗?
--附言:Stack Overflow的其他地方强调了实现自动记忆化的两个要求:
--将要记忆化的函数制作为顶级名称。 --将要记忆化的函数制作为单态化函数。
--根据这些要求,我重新构建了remocollatz,如下所示:
-- 在不到4秒的时间内运行。 我不明白为什么在这里表现良好的ghc记忆化功能比我的愚蠢表格慢了近3倍。
以下是代码,是一个完整的程序,可以直接编译和执行。
module Main where
import Data.Function
import Data.List (maximumBy)
size :: (Integral a) => a
size = 10 ^ 6
-- Nail the basics.
collatz :: Integral a => a -> a
collatz n | even n = n `div` 2
| otherwise = n * 3 + 1
recollatz :: Integral a => a -> a
recollatz = fix $ \f x -> if (x /= 1)
then f (collatz x)
else x
-- Now, I want to do the counting with a tuple monad.
mocollatz :: Integral b => b -> ([b], b)
mocollatz n = ([n], collatz n)
remocollatz :: Integral a => a -> ([a], a)
remocollatz = fix $ \f x -> if x /= 1
then f =<< mocollatz x
else return x
-- Trivialities.
collatzLength :: Integral a => a -> Int
collatzLength x = (length . fst $ (remocollatz x)) + 1
collatzPairs :: Integral a => a -> [(a, Int)]
collatzPairs n = zip [1..n] (collatzLength <$> [1..n])
longestCollatz :: Integral a => a -> (a, Int)
longestCollatz n = maximumBy order $ collatzPairs n
where
order :: Ord b => (a, b) -> (a, b) -> Ordering
order x y = snd x `compare` snd y
main :: IO ()
main = print $ longestCollatz size
使用 ghc -O2
编译后,获取任何小于 size
的起始点所得到的最长 Collatz 序列的长度和种子需要大约 17 秒左右,如果不使用 ghc -O2
则需要约 22 秒。
现在,如果我进行以下更改:
diff --git a/Main.hs b/Main.hs
index c78ad95..9607fe0 100644
--- a/Main.hs
+++ b/Main.hs
@@ -1,6 +1,7 @@
module Main where
import Data.Function
+import qualified Data.Map.Lazy as M
import Data.List (maximumBy)
size :: (Integral a) => a
@@ -22,10 +23,15 @@ recollatz = fix $ \f x -> if (x /= 1)
mocollatz :: Integral b => b -> ([b], b)
mocollatz n = ([n], collatz n)
-remocollatz :: Integral a => a -> ([a], a)
-remocollatz = fix $ \f x -> if x /= 1
- then f =<< mocollatz x
- else return x
+remocollatz :: (Num a, Integral b) => b -> ([b], a)
+remocollatz 1 = return 1
+remocollatz x = case M.lookup x (table mutate) of
+ Nothing -> mutate x
+ Just y -> y
+ where mutate x = remocollatz =<< mocollatz x
+
+table :: (Ord a, Integral a) => (a -> b) -> M.Map a b
+table f = M.fromList [ (x, f x) | x <- [1..size] ]
-- Trivialities.
--然后使用ghc -O2编译只需大约4秒钟,但如果没有ghc -O2,我活不到看到它完成。
--使用ghc -prof -fprof-auto -O2查看成本中心的详细信息,发现第一个版本进入collatz大约一亿次,而修补后的版本只有大约一百五十万次。这一定是加速的原因,但我很难理解这个神奇内部工作方式。我的最佳想法是,我们用O(log n) map查找替换了一部分昂贵的递归调用,但我不知道它是否正确以及为什么它如此依赖某些可怜的编译器标志,而在我看来,这样的性能波动应该完全由语言决定。
--我可以解释一下这里发生了什么,以及为什么ghc -O2和普通的ghc构建之间的性能差异如此巨大吗?
--附言:Stack Overflow的其他地方强调了实现自动记忆化的两个要求:
--将要记忆化的函数制作为顶级名称。 --将要记忆化的函数制作为单态化函数。
--根据这些要求,我重新构建了remocollatz,如下所示:
remocollatz :: Int -> ([Int], Int)
remocollatz 1 = return 1
remocollatz x = mutate x
mutate :: Int -> ([Int], Int)
mutate x = remocollatz =<< mocollatz x
现在它已经像顶层和单态化一样了。运行时间大约为11秒,而类似的单态化table
版本:
remocollatz :: Int -> ([Int], Int)
remocollatz 1 = return 1
remocollatz x = case M.lookup x (table mutate) of
Nothing -> mutate x
Just y -> y
mutate :: Int -> ([Int], Int)
mutate = \x -> remocollatz =<< mocollatz x
table :: (Int -> ([Int], Int)) -> M.Map Int ([Int], Int)
table f = M.fromList [ (x, f x) | x <- [1..size] ]
-- 在不到4秒的时间内运行。 我不明白为什么在这里表现良好的ghc记忆化功能比我的愚蠢表格慢了近3倍。
n
小于当前调用的每个数字。记忆化考拉兹猜想使用映射(通常)而不是列表,因为对考拉兹猜想的迭代调用不仅仅是n-1
和n-2
,而是一种相当不可预测的形式。最后,不要认为记忆化斐波那契数列就是魔法。如果操作不清楚,首先要理解它们。 - Thomas M. DuBuisson