结合记忆和尾递归
Posted
技术标签:
【中文标题】结合记忆和尾递归【英文标题】:Combine memoization and tail-recursion 【发布时间】:2011-03-28 10:17:40 【问题描述】:是否有可能以某种方式?我目前正在学习 F#,并且理解这两个概念,但似乎无法将它们结合起来。
假设我有以下memoize
函数(来自Real-World Functional Programming):
let memoize f = let cache = new Dictionary<_, _>()
(fun x -> match cache.TryGetValue(x) with
| true, y -> y
| _ -> let v = f(x)
cache.Add(x, v)
v)
以及下面的factorial
函数:
let rec factorial(x) = if (x = 0) then 1 else x * factorial(x - 1)
记忆factorial
并不太难,使其尾递归也不是:
let rec memoizedFactorial =
memoize (fun x -> if (x = 0) then 1 else x * memoizedFactorial(x - 1))
let tailRecursiveFactorial(x) =
let rec factorialUtil(x, res) = if (x = 0)
then res
else let newRes = x * res
factorialUtil(x - 1, newRes)
factorialUtil(x, 1)
但是你能把记忆和尾递归结合起来吗?我做了一些尝试,但似乎无法让它工作。或者这根本不可能?
【问题讨论】:
一般来说,在函数式编程中,它是可能的,斐波那契数是通常的例子。 【参考方案1】:与往常一样,延续会产生优雅的尾调用解决方案:
open System.Collections.Generic
let cache = Dictionary<_,_>() // TODO move inside
let memoizedTRFactorial =
let rec fac n k = // must make tailcalls to k
match cache.TryGetValue(n) with
| true, r -> k r
| _ ->
if n=0 then
k 1
else
fac (n-1) (fun r1 ->
printfn "multiplying by %d" n //***
let r = r1 * n
cache.Add(n,r)
k r)
fun n -> fac n id
printfn "---"
let r = memoizedTRFactorial 4
printfn "%d" r
for KeyValue(k,v) in cache do
printfn "%d: %d" k v
printfn "---"
let r2 = memoizedTRFactorial 5
printfn "%d" r2
printfn "---"
// comment out *** line, then run this
//let r3 = memoizedTRFactorial 100000
//printfn "%d" r3
有两种测试。首先,这个演示调用 F(4) 可以随意缓存 F(4), F(3), F(2), F(1)。
然后,注释掉***
printf 并取消注释最终测试(并在发布模式下编译)以表明它没有 ***(它正确使用尾调用)。
也许我会概括出'memoize'并在接下来的'fib'上演示它......
编辑
好的,我认为这是下一步,将记忆化与阶乘分离:
open System.Collections.Generic
let cache = Dictionary<_,_>() // TODO move inside
let memoize fGuts n =
let rec newFunc n k = // must make tailcalls to k
match cache.TryGetValue(n) with
| true, r -> k r
| _ ->
fGuts n (fun r ->
cache.Add(n,r)
k r) newFunc
newFunc n id
let TRFactorialGuts n k memoGuts =
if n=0 then
k 1
else
memoGuts (n-1) (fun r1 ->
printfn "multiplying by %d" n //***
let r = r1 * n
k r)
let memoizedTRFactorial = memoize TRFactorialGuts
printfn "---"
let r = memoizedTRFactorial 4
printfn "%d" r
for KeyValue(k,v) in cache do
printfn "%d: %d" k v
printfn "---"
let r2 = memoizedTRFactorial 5
printfn "%d" r2
printfn "---"
// comment out *** line, then run this
//let r3 = memoizedTRFactorial 100000
//printfn "%d" r3
编辑
好的,这是一个完全通用的版本,似乎可行。
open System.Collections.Generic
let memoize fGuts =
let cache = Dictionary<_,_>()
let rec newFunc n k = // must make tailcalls to k
match cache.TryGetValue(n) with
| true, r -> k r
| _ ->
fGuts n (fun r ->
cache.Add(n,r)
k r) newFunc
cache, (fun n -> newFunc n id)
let TRFactorialGuts n k memoGuts =
if n=0 then
k 1
else
memoGuts (n-1) (fun r1 ->
printfn "multiplying by %d" n //***
let r = r1 * n
k r)
let facCache,memoizedTRFactorial = memoize TRFactorialGuts
printfn "---"
let r = memoizedTRFactorial 4
printfn "%d" r
for KeyValue(k,v) in facCache do
printfn "%d: %d" k v
printfn "---"
let r2 = memoizedTRFactorial 5
printfn "%d" r2
printfn "---"
// comment out *** line, then run this
//let r3 = memoizedTRFactorial 100000
//printfn "%d" r3
let TRFibGuts n k memoGuts =
if n=0 || n=1 then
k 1
else
memoGuts (n-1) (fun r1 ->
memoGuts (n-2) (fun r2 ->
printfn "adding %d+%d" r1 r2 //%%%
let r = r1+r2
k r))
let fibCache, memoizedTRFib = memoize TRFibGuts
printfn "---"
let r5 = memoizedTRFib 4
printfn "%d" r5
for KeyValue(k,v) in fibCache do
printfn "%d: %d" k v
printfn "---"
let r6 = memoizedTRFib 5
printfn "%d" r6
printfn "---"
// comment out %%% line, then run this
//let r7 = memoizedTRFib 100000
//printfn "%d" r7
【讨论】:
是的,我可能会尝试写博客。我也不是很明白,我已经做够了,我的手指知道如何输入有效的代码:) 我应该尝试写博客,因为这将迫使我的大脑充分理解它以表达什么哎呀我在做。 我见过一种类似的技术用于折叠树木。 这会用堆分配的闭包替换堆栈帧吗?如果是这样,它似乎类似于 Mitya 的解决方案,即向前传递参数列表直到知道结果。堆栈比堆更宝贵,但是,这可能会使用大量内存并且可能会用完大值......对吗? @Jason:是的,这会在每个 lambda (fun
) 处分配闭包,但是(与 @Mitya 不同)它们是线性分配和释放的,因此这里的内存使用是恒定的,而不是线性的(模不断增长的缓存)。我认为,使用模式对于 GC 来说也几乎是最佳的,因为有很多短期的、小的分配。所以我认为堆内存压力在这里不是问题,尽管我没有对其进行分析来验证。
@Brian,不:您分配的每个闭包都引用了先前的延续闭包(您所有的“乐趣-> ..”引用 k)。因此,当您深入研究 TRFactGuts/memoize 时,您会积累一系列闭包,每个闭包都引用了前一个闭包。该链的长度为 n。当您到达基本情况并最终调用延续 (k 1
) 时,闭包链将开始展开。同样,通常 CPS 所做的只是将您的堆栈移动到堆上 - CPS 永远不会减少您的空间需求。【参考方案2】:
记忆尾递归函数的困境当然是当尾递归函数时
let f x =
......
f x1
调用自身,不允许对递归调用的结果做任何事情,包括将其放入缓存中。棘手;所以,我们能做些什么?
这里的关键见解是,由于不允许递归函数对递归调用的结果做任何事情,因此递归调用的所有参数的结果将是相同的!因此如果递归调用跟踪是这样的
f x0 -> f x1 -> f x2 -> f x3 -> ... -> f xN -> res
那么对于 x0,x1,...,xN 中的所有 x,f x
的结果将是相同的,即 res。因此,递归函数的最后一次调用,即非递归调用,知道所有先前值的结果——它可以缓存它们。您唯一需要做的就是将访问值列表传递给它。以下是它可能会寻找阶乘:
let cache = Dictionary<_,_>()
let rec fact0 l ((n,res) as arg) =
let commitToCache r =
l |> List.iter (fun a -> cache.Add(a,r))
match cache.TryGetValue(arg) with
| true, cachedResult -> commitToCache cachedResult; cachedResult
| false, _ ->
if n = 1 then
commitToCache res
cache.Add(arg, res)
res
else
fact0 (arg::l) (n-1, n*res)
let fact n = fact0 [] (n,1)
但是等等!看 - fact0
的 l
参数包含对 fact0
的递归调用的所有参数 - 就像堆栈在非尾递归版本中一样!这是完全正确的。通过将“堆栈帧列表”从堆栈移动到堆并将递归调用结果的“后处理”转换为遍历该数据结构,任何非尾递归算法都可以转换为尾递归算法。
实用说明:上面的阶乘示例说明了一种通用技术。它是非常无用的 - 对于阶乘函数,它足以缓存*** fact n
结果,因为对特定 n 的 fact n
的计算只会命中一系列唯一的 (n,res) 参数对fact0 - 如果 (n,1) 尚未缓存,则不会调用 fact0 对。
请注意,在此示例中,当我们从非尾递归阶乘变为尾递归阶乘时,我们利用了乘法是关联和交换的这一事实 - 尾递归阶乘执行与非尾递归阶乘不同的乘法集-尾递归之一。
事实上,存在一种从非尾递归算法到尾递归算法的通用技术,它产生了一个等效于 tee 的算法。这种技术被称为“连续传递变换”。走这条路,您可以采用非尾递归记忆阶乘,并通过几乎机械变换获得尾递归记忆阶乘。有关此方法的说明,请参见 Brian 的回答。
【讨论】:
非常感谢您提供如此详尽的解释。我需要一些时间来理解你所说的一切:) 如果我理解正确,你会在这个实现中失去对中间值的记忆。不完全正确,它们被缓存但不能使用。如果我先调用“事实 4”然后调用“事实 5”,第二次调用将不得不重新计算所有内容,而无法使用“事实 4”的结果。 @Ronald 是的,这是正确的。如果您尝试记住教科书的尾递归阶乘,这就是您得到的,因为该实现在为 (n,1) 调用时,永远不会为 (k,1) 或 (k,1) 跟踪中的任何其他对调用自身。要获得缓存重用,您必须更改“尾递归”阶乘的方式。【参考方案3】:我不确定是否有更简单的方法可以做到这一点,但一种方法是创建一个记忆 y 组合器:
let memoY f =
let cache = Dictionary<_,_>()
let rec fn x =
match cache.TryGetValue(x) with
| true,y -> y
| _ -> let v = f fn x
cache.Add(x,v)
v
fn
然后,您可以使用此组合符代替“let rec”,第一个参数表示要递归调用的函数:
let tailRecFact =
let factHelper fact (x, res) =
printfn "%i,%i" x res
if x = 0 then res
else fact (x-1, x*res)
let memoized = memoY factHelper
fun x -> memoized (x,1)
编辑
正如 Mitya 所指出的,memoY
不保留备忘录的尾递归属性。这是一个修改后的组合器,它使用异常和可变状态来记忆任何递归函数而不会溢出堆栈(即使原始函数本身不是尾递归的!):
let memoY f =
let cache = Dictionary<_,_>()
fun x ->
let l = ResizeArray([x])
while l.Count <> 0 do
let v = l.[l.Count - 1]
if cache.ContainsKey(v) then l.RemoveAt(l.Count - 1)
else
try
cache.[v] <- f (fun x ->
if cache.ContainsKey(x) then cache.[x]
else
l.Add(x)
failwith "Need to recurse") v
with _ -> ()
cache.[x]
不幸的是,插入到每个递归调用中的机制有些沉重,因此需要深度递归的非记忆输入的性能可能会有点慢。但是,与其他一些解决方案相比,这样做的好处是它需要对递归函数的自然表达式进行相当小的更改:
let fib = memoY (fun fib n ->
printfn "%i" n;
if n <= 1 then n
else (fib (n-1)) + (fib (n-2)))
let _ = fib 5000
编辑
我将稍微扩展一下这与其他解决方案的比较。这种技术利用了异常提供了一个辅助通道这一事实:'a -> 'b
类型的函数实际上不需要返回'b
类型的值,而是可以通过异常退出。如果返回类型明确包含指示失败的附加值,我们就不需要使用异常。当然,为此我们可以使用'b option
作为函数的返回类型。这将导致以下记忆组合器:
let memoO f =
let cache = Dictionary<_,_>()
fun x ->
let l = ResizeArray([x])
while l.Count <> 0 do
let v = l.[l.Count - 1]
if cache.ContainsKey v then l.RemoveAt(l.Count - 1)
else
match f(fun x -> if cache.ContainsKey x then Some(cache.[x]) else l.Add(x); None) v with
| Some(r) -> cache.[v] <- r;
| None -> ()
cache.[x]
以前,我们的记忆过程是这样的:
fun fib n ->
printfn "%i" n;
if n <= 1 then n
else (fib (n-1)) + (fib (n-2))
|> memoY
现在,我们需要合并fib
应该返回int option
而不是int
的事实。给定一个适合option
类型的工作流,可以这样写:
fun fib n -> option
printfn "%i" n
if n <= 1 then return n
else
let! x = fib (n-1)
let! y = fib (n-2)
return x + y
|> memoO
但是,如果我们愿意更改第一个参数的返回类型(在这种情况下从 int
到 int option
),我们不妨一路走下去,只在返回类型中使用延续,就像在布赖恩的解决方案中一样。以下是他的定义的变体:
let memoC f =
let cache = Dictionary<_,_>()
let rec fn n k =
match cache.TryGetValue(n) with
| true, r -> k r
| _ ->
f fn n (fun r ->
cache.Add(n,r)
k r)
fun n -> fn n id
同样,如果我们有一个合适的计算表达式来构建 CPS 函数,我们可以像这样定义我们的递归函数:
fun fib n -> cps
printfn "%i" n
if n <= 1 then return n
else
let! x = fib (n-1)
let! y = fib (n-2)
return x + y
|> memoC
这与 Brian 所做的完全一样,但我发现这里的语法更容易理解。为了实现这一点,我们只需要以下两个定义:
type CpsBuilder() =
member this.Return x k = k x
member this.Bind(m,f) k = m (fun a -> f a k)
let cps = CpsBuilder()
【讨论】:
-1,我不考虑这种记忆,因为它只存储最终结果,而不是一路上的所有阶乘。 @gradbot - 它存储一路上的所有结果;然而,由于尾递归实现的性质,这些结果不会在不同的顶层计算中重用。也就是说,在计算 3! 时,我们存储 (3,1)、(2,3) 和 (1,6) 的结果,但在计算 4!我们存储 (4,1)、(3,4)、(2,12) 和 (1,24) 的结果,其中没有一个已经出现在 3!计算。 @kvb 很有趣,那你能用斐波那契数列做到这一点吗?在当前情况下,它没有提供任何性能优势。 -1。这个实现不是尾递归的。自己试试tailRecFact 100000
失败了。具体来说,从fn
到f
的调用不是尾递归的。
@gradbot - 是的,它适用于任何递归函数,包括斐波那契数列。然而,正如 Mitya 所指出的,我最初的解决方案没有保留尾递归行为。我添加了一个提供另一种解决方案的编辑。【参考方案4】:
我写了一个测试来可视化记忆。每个点都是一个递归调用。
......720 // factorial 6
......720 // factorial 6
.....120 // factorial 5
......720 // memoizedFactorial 6
720 // memoizedFactorial 6
120 // memoizedFactorial 5
......720 // tailRecFact 6
720 // tailRecFact 6
.....120 // tailRecFact 5
......720 // tailRecursiveMemoizedFactorial 6
720 // tailRecursiveMemoizedFactorial 6
.....120 // tailRecursiveMemoizedFactorial 5
kvb 的解决方案返回的结果与这个函数一样直接记忆。
let tailRecursiveMemoizedFactorial =
memoize
(fun x ->
let rec factorialUtil x res =
if x = 0 then
res
else
printf "."
let newRes = x * res
factorialUtil (x - 1) newRes
factorialUtil x 1
)
测试源代码。
open System.Collections.Generic
let memoize f =
let cache = new Dictionary<_, _>()
(fun x ->
match cache.TryGetValue(x) with
| true, y -> y
| _ ->
let v = f(x)
cache.Add(x, v)
v)
let rec factorial(x) =
if (x = 0) then
1
else
printf "."
x * factorial(x - 1)
let rec memoizedFactorial =
memoize (
fun x ->
if (x = 0) then
1
else
printf "."
x * memoizedFactorial(x - 1))
let memoY f =
let cache = Dictionary<_,_>()
let rec fn x =
match cache.TryGetValue(x) with
| true,y -> y
| _ -> let v = f fn x
cache.Add(x,v)
v
fn
let tailRecFact =
let factHelper fact (x, res) =
if x = 0 then
res
else
printf "."
fact (x-1, x*res)
let memoized = memoY factHelper
fun x -> memoized (x,1)
let tailRecursiveMemoizedFactorial =
memoize
(fun x ->
let rec factorialUtil x res =
if x = 0 then
res
else
printf "."
let newRes = x * res
factorialUtil (x - 1) newRes
factorialUtil x 1
)
factorial 6 |> printfn "%A"
factorial 6 |> printfn "%A"
factorial 5 |> printfn "%A\n"
memoizedFactorial 6 |> printfn "%A"
memoizedFactorial 6 |> printfn "%A"
memoizedFactorial 5 |> printfn "%A\n"
tailRecFact 6 |> printfn "%A"
tailRecFact 6 |> printfn "%A"
tailRecFact 5 |> printfn "%A\n"
tailRecursiveMemoizedFactorial 6 |> printfn "%A"
tailRecursiveMemoizedFactorial 6 |> printfn "%A"
tailRecursiveMemoizedFactorial 5 |> printfn "%A\n"
System.Console.ReadLine() |> ignore
【讨论】:
感谢您为这个答案付出了这么多努力。但是真的不可能将'memoizedFactorial'的记忆与尾递归结合起来吗?在 'memoizedFactorial' 中,中间结果被记忆,以后可以重用。当添加尾递归时,突然只记住最终结果。导致“tailRecursiveMemoizedFactorial 5”的完全重新计算(我们已经计算过了)。 @Ronald 不客气。我认为布赖恩或其他人发布正确答案只是时间问题。我必须工作,否则我会再试一次。这也是一道很棒的算法题。【参考方案5】:如果通过 y 的相互尾递归没有创建堆栈帧,那应该可以工作:
let rec y f x = f (y f) x
let memoize (d:System.Collections.Generic.Dictionary<_,_>) f n =
if d.ContainsKey n then d.[n]
else d.Add(n, f n);d.[n]
let rec factorialucps factorial' n cont =
if n = 0I then cont(1I) else factorial' (n-1I) (fun k -> cont (n*k))
let factorialdpcps =
let d = System.Collections.Generic.Dictionary<_, _>()
fun n -> y (factorialucps >> fun f n -> memoize d f n ) n id
factorialdpcps 15I //1307674368000
【讨论】:
以上是关于结合记忆和尾递归的主要内容,如果未能解决你的问题,请参考以下文章