有人可以解释Haskell记忆化背后的概念吗?


12

(请注意,我将问题放在这里是因为它是关于它的概念机制,而不是编码问题)

我当时正在开发一个小型程序,该程序在其等式中使用了斐波那契数列,但是我注意到,如果超过一定数,它会变得非常缓慢,在谷歌上徘徊了一段时间,我偶然发现了Haskell中一种称为的技术Memoization,他们展示了这样的代码:

-- Traditional implementation of fibonacci, hangs after about 30
slow_fib :: Int -> Integer
slow_fib 0 = 0
slow_fib 1 = 1
slow_fib n = slow_fib (n-2) + slow_fib (n-1)

-- Memorized variant is near instant even after 10000
memoized_fib :: Int -> Integer
memoized_fib = (map fib [0 ..] !!)
   where fib 0 = 0
         fib 1 = 1
         fib n = memoized_fib (n-2) + memoized_fib (n-1)

所以我对你们的问题是,为什么或如何运作?

是因为它以某种方式设法在计算赶上之前遍历了大多数列表吗?但是,如果haskell很懒惰,那么实际上并不需要进行任何计算...那么它如何工作?


1
您能说明一下什么意思the calculation catches up吗?顺便说一句,记忆并不是特定于haskell的:en.wikipedia.org/wiki/Memoization
Simon Bergot 2013年

看到我在Killan的回答下的解释
电动咖啡

2
爱你的问题;只是一个快速注:该技术被称为备忘录矩阵特殊积,不备忘录矩阵特殊积。
拉切特(Racheet)2013年

Answers:


11

只是为了解释实际记忆背后的机制,

memo_fib = (map fib [1..] !!)

产生未分类计算的“ thunks”列表。将它们视为未打开的礼物,只要我们不触摸它们,它们就不会运行。

现在,一旦我们评估了重击,就再也不会评估。实际上,这是“正常” haskell中唯一的突变形式,暴徒一旦被评估为具体值,就会突变。

回到您的代码,您将获得一个thunk列表,并且仍然进行此树递归,但是您使用该列表进行递归,并且一旦对列表中的一个元素进行求值,就永远不会再进行计算。因此,我们避免了朴素的fib函数中的树递归。

作为切线有趣的注释,这在计算一系列斐波那契数字时特别快,因为该列表仅被评估一次,这意味着如果您计算memo_fib 10000两次,则第二次应该是瞬时的。这是因为Haskell只对函数的参数求值一次,而您使用的是部分应用程序而不是lambda。

TLDR:通过将计算结果存储在列表中,列表中的每个元素都会被评估一次,因此,在整个程序中,每个斐波那契数都将被精确地计算一次。

可视化:

 [THUNK_1, THUNK_2, THUNK_3, THUNK_4, THUNK_5]
 -- Evaluating THUNK_5
 [THUNK_1, THUNK_2, THUNK_3, THUNK_4, THUNK_3 + THUNK_4]
 [THUNK_1, THUNK_2, THUNK_1 + THUNK_2, THUNK_4, THUNK_3 + THUNK_4]
 [1, 1, 1 + 1, THUNK_4, THUNK_3 + THUNK_4]
 [1, 1, 2, THUNK_4, 2 + THUNK4]
 [1, 1, 2, 1 + 2, 2 + THUNK_4]
 [1, 1, 2, 3, 2 + 3]
 [1, 1, 2, 3, 5]

因此,您可以看到评估THUNK_4的速度更快,因为它的子表达式已经被评估了。


您能否提供一个示例,说明列表中的值在短序列中的行为?我认为这可能会增加它应该如何工作的可视化效果……而且的确,如果我memo_fib用相同的值两次调用,第二次将是即时的,但是如果我使用较高的1值调用,它将是第二次。仍然需要永远评估(例如说从30变到31)
电动咖啡

@ElectricCoffee添加
Daniel Gratzer

@ElectricCoffee不,不会memo_fib 29memo_fib 30并且已经过评估,将花费与添加这两个数字完全相同的时间:)一旦评估了某些内容,它就一直处于逃避状态。
Daniel Gratzer 2013年

1
@ElectricCoffee您的递归必须遍历整个列表,否则您将无法获得任何表现
Daniel Gratzer 2013年

2
@ElectricCoffee是的。但是列表的第31个元素没有使用过去的计算,只是记住是,但是以一种非常无用的方式来记录。重复的计算不会被计算两次,但是对于每个新值,您仍然具有树递归非常非常慢
Daniel Gratzer 2013年

1

记忆的重点是永远不要两次计算相同的函数-这对于加速纯函数的计算(即无副作用)非常有用,因为对于那些函数而言,该过程可以完全自动化而不影响正确性。对于像这样的函数,在天真地实现时fibo会导致树递归(即指数级的努力),这尤其必要。(这就是为什么斐波那契数实际上不是一个很好的递归教学例子的原因-您在教程或书籍中发现的几乎所有演示实现都不适用于较大的输入值。)

如果跟踪执行流,您会发现在第二种情况下,for的值fib xfib x+1执行时将始终可用,并且运行时系统将能够简单地从内存中读取它,而无需通过另一个递归调用,而第一个解决方案尝试在获得较小值的结果之前计算较大的解决方案。最终这是因为迭代器[0..n]是从左到右求值的,因此将从那里开始0,而第一个例子中的递归从那里开始,n然后才问一下n-1。这就是导致许多很多不必要的重复函数调用的原因。


哦,我了解它的意义,我只是不了解它是如何工作的,就像从代码中看到的那样memorized_fib 20,例如,当您编写代码时,实际上您只是在编写map fib [0..] !! 20,它仍然需要计算整个数字范围(最多20个),还是我在这里错过了一些东西?
电动咖啡

1
是的,但每个数字只能一次。天真的实现fib 2如此频繁地进行计算,它将使您的头脑旋转,将调用树毛写下一个很小的值,例如n==5。一旦您了解了备忘录可为您节省的能量,您将永远不会忘记它。
Kilian Foth 2013年

@ElectricCoffee:是的,它将计算出1到20的fib。您不会从该通话中获得任何收益。现在尝试计算fib 21,您会看到不用计算1-21,而只需计算21,因为您已经计算了1-20,并且不需要再次进行计算。
Phoshi 2013年

我正在尝试写下for的调用树n = 5,目前n == 3为止,到目前为止,到目前为止还不错,但是也许这是我的当务之急,但并不意味着for n == 3map fib [0..]!!3?哪一个进入fib n程序的分支……我从哪里确切地获得预计算数据的好处?
电动咖啡

1
不,memoized_fib很好。这slow_fib会让你流泪,如果你跟踪它。
Kilian Foth 2013年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.