将 CPS 和记忆化结合对 Fibonacci 稍有好处,但是否存在示例,其中 CPS 是防止超出堆栈限制且提高性能的最佳方法,而记忆化不是解决方案?
或:何时选择其中一个或两个的任何指南?
CPS作为编译器中的一种中间语言很有用,但在源语言层面上,它主要是一个工具,用于(1)编码复杂的控制流程(与性能无关),以及(2)将非尾调用消耗堆栈空间转换为分配延续的尾调用消耗堆空间。例如,当您编写以下代码时(未经测试):
let rec fib = function
| 0 | 1 -> 1
| n -> fib (n-1) + fib (n-2)
let rec fib_cps n k = match n with
| 0 | 1 -> k 1
| n -> fib_cps (n-1) (fun a -> fib_cps (n-2) (fun b -> k (a+b)))
前一个非尾递归的fib(n-2)
,它分配了一个新的堆栈帧,被转换为尾递归fib(n-2)(fun b -> k(a+b))
,它分配了闭包fun b -> k(a+b)
(在堆上)作为参数传递。
这并没有渐进地减少程序的内存使用量(一些进一步的特定领域优化可能会,但那是另外一回事)。你只是把堆栈空间换成了堆空间,在操作系统严重限制堆栈空间的系统上很有趣(对于一些 ML 的实现,如 SML/NJ,它们在堆上跟踪它们的调用栈而不是使用系统堆栈),并且可能因为额外的 GC 成本和潜在的局部性降低而导致性能下降。
CPS 变换不太可能大幅提高性能(虽然你的实现和运行时系统的细节可能会使其如此),并且是一个通用的变换,允许避免具有深度调用栈的递归函数的“堆栈溢出”问题。
n
”等)分解成几个严格更简单的“子问题”(递归子调用),并提供一些基本情况,以便立即解决问题。
对于任何递归函数(或问题的递归公式),您都可以观察到子问题空间的结构。哪些更简单的Fib(k)
实例将需要Fib(n)
返回其结果?这些实例又将需要哪些更简单的实例?
在一般情况下,这个子问题空间是一个图形(为了终止目的通常是无环的):有一些节点具有多个父节点,也就是它们是多个不同问题的子问题。例如,Fib(n-2)
既是Fib(n)
的子问题,也是Fib(n-2)
的子问题。这个图中的节点共享量取决于特定的问题/递归函数。在斐波那契的情况下,所有节点都在两个父节点之间共享,因此共享非常多。
let rec fib_pair = function
| 0 -> (1,1)
| n -> let (u,v) = fib_pair (n-1) in (v,u+v)
在算法界,使用某种形式的共享(通常通过存储结果的大表格)以避免子计算的无用重复是众所周知的技术,称为动态规划。当您意识到问题适合这种处理方式时(您注意到子问题之间的共享),这可以提供大量的性能优势。
这两者似乎大多是相互独立的。
有很多问题不适用于记忆化,因为子问题图结构没有任何共享(它是一棵树)。相反,CPS转换适用于所有递归函数,但本身并不会带来性能优势(除了可能由特定实现和运行时系统导致的潜在常数因素,尽管它们很可能使代码变得更慢而不是更快)。
有一些与 CPS 相关的优化技术,可以提高递归函数的性能。它们包括查看递归调用后剩余的计算(在直接 CPS 风格中会转换为函数),并找到另一种更高效的表示方法,避免系统性闭包分配。例如:
let rec length = function
| [] -> 0
| _::t -> 1 + length t
let rec length_cps li k = match li with
| [] -> k 0
| _::t -> length_cps t (fun a -> k (a + 1))
[_ + 1]
,具有简单的结构:它添加一个整数。不必将其表示为函数fun a -> k (a+1)
,而是可以将要添加的整数存储对应于该函数的多个应用程序,使k
成为整数而不是函数。let rec length_acc li k = match li with
| [] -> k + 0
| _::t -> length_acc t (k + 1)
_ + 1
是一个可结合的操作(如果你有几个嵌套的上下文,foo + 1 + 1 + 1
,从里面计算它们或从外面开始计算它们,((foo+1)+1)+1
或foo+(1+(1+1))
都是有效的)。以上优化可以用于所有这样的“可结合”上下文,围绕非尾调用。type length_cont =
| Linit
| Lcons of length_cont
let rec length_cps_defun li k = match li with
| [] -> length_cont_eval 0 k
| _::t -> length_cps_defun t (Lcons k)
and length_cont_eval acc = function
| Linit -> acc
| Lcons k -> length_cont_eval (acc+1) k
let length li = length_cps_defun li Linit
type fib_cont =
| Finit
| Fminus1 of int * fib_cont
| Fminus2 of fib_cont * int
let rec fib_cps_defun n k = match n with
| 0 | 1 -> fib_cont_eval 1 k
| n -> fib_cps_defun (n-1) (Fminus1 (n, k))
and fib_cont_eval acc = function
| Finit -> acc
| Fminus1 (n, k) -> fib_cps_defun (n-2) (Fminus2 (k, acc))
| Fminus2 (k, acc') -> fib_cont_eval (acc+acc') k
let fib n = fib_cps_defun n Finit
call
操作,然后是一个tail
操作,在这种情况下,就由JIT编译器来优化递归了。 - ildjarntail.call
操作被优化掉,而只是使用br
操作回到方法体的开头。 - Jack P.