1. 为什么你的函数不是尾递归的?
一个递归函数要是尾递归,所有的递归调用都必须在尾位置。如果一个函数是在返回前被调用的最后一个函数,则它处于尾位置。在你的第一个例子中:
depth (Branch _ l r) = 1 + max (depth l) (depth r)
这相当于
depth (Branch _ l r) = (+) 1 (max (depth l) (depth r))
在该函数返回前调用的最后一个函数是
(+)
,因此这不是尾递归。在您的第二个示例中,您有:
depthTR d (Branch _ l r) = let dl = depthTR (d+1) l
dr = depthTR (d+1) r
in max dl dr
这相当于(一旦您重新将所有的let
语句转换为lambda表达式并稍作调整)。
depthTR d (Branch _ l r) = max (depthTR (d+1) r) (depthTR (d+1) l)
现在,在返回之前调用的最后一个函数是
max
,这意味着它也不是尾递归。
2. 如何使其成为尾递归?
您可以使用传递风格使函数成为尾递归。与其重新编写函数以接受状态或累加器,不如传递一个函数(称为continuation),该函数是对计算值执行的指令 - 即,而不是立即返回给调用者,将计算出的任何值传递给续集。这是将任何函数转换为尾递归函数的简单技巧,即使需要多次调用自身的函数(如depth
)也可以。它看起来像这样。
depth t = go t id
where
go Empty k = k 0
go (Branch _ l r) k = go l $ \dl ->
go r $ \dr ->
k (1 + max dl dr)
现在您可以看到,在
go
返回之前调用的最后一个函数是它自己的
go
函数,因此此函数是尾递归的。
3. 就这样了吗?
(注意,本节内容摘自
这个先前的问题的答案。)
不!这个“技巧”只是把问题推到别的地方。我们现在有了一个吃掉thunks(未应用函数)的尾递归函数,而这些thunks可能会占用大量空间。幸运的是,我们不需要使用任意函数——实际上,只有三种类型:
1.
\dl -> go r (\dr -> k (1 + max dl dr))
(使用自由变量
r
和
k
)
2.
\dr -> k (1 + max dl dr)
(使用自由变量
k
和
dl
)
3.
id
(没有自由变量)
由于只有有限数量的函数,我们可以将它们表示为数据。
data Fun a = FunL (Tree a) (Fun a)
| FunR Int (Fun a)
| FunId
我们需要编写一个名为
eval
的函数,以告诉我们如何在特定参数下评估这些“函数”。现在,您可以将函数重新编写为:
depth t = go t FunId
where
go Empty k = eval k 0
go (Branch _ l r) k = go l (FunL r k)
eval (FunL r k) d = go r (FunR d k)
eval (FunR dl k) d = eval k (1 + max dl d)
eval (FunId) d = d
请注意,
go
和
eval
都调用了
go
或
eval
,处于尾位置 - 因此它们是一对相互尾递归的函数。因此,我们将使用延续传递样式的函数版本转换为使用数据表示延续,并使用一对相互递归的函数来解释该数据。
2. 这听起来真的很复杂
嗯,我想确实如此。但等等!我们可以简化它!如果您查看
Fun a
数据类型,您会发现它实际上只是一个列表,其中每个元素都是要计算深度的
Tree a
,或者是表示我们已经计算出的深度的
Int
。
注意到这一点的好处是什么?嗯,这个列表实际上代表了前一节中连续的延续链的
调用堆栈。将新项目推送到列表上就是将新参数推送到调用堆栈上!因此,您可以写:
depth t = go t []
where
go Empty k = eval k 0
go (Branch _ l r) k = go l (Left r : k)
eval (Left r : k) d = go r (Right d : k)
eval (Right dl : k) d = eval k (1 + max dl d)
eval [] d = d
每个新的参数都被推到调用栈上,类型为
Either (Tree a) Int
。随着函数递归,它们不断地将新的参数推到栈上,这些参数要么是要探索的新树(每次调用
go
时),要么是迄今为止找到的最大深度(每次调用
eval
时)。
这种调用策略表示树的深度优先遍历,因为你可以看到左树总是首先被
go
探索,而右树总是被推到调用栈中以稍后探索。只有在到达
Empty
分支并且可以丢弃时,才会从调用栈中弹出参数(在
eval
中)。
5. 好的...还有什么别的吗?
嗯,一旦你注意到可以将传递续延算法转化为模拟调用栈并深度优先遍历树的版本,你可能会想知道是否有一种更简单的算法,它可以深度优先遍历树,并跟踪迄今为止遇到的最大深度。
实际上,是有的。诀窍是保持一个尚未探索的分支列表,以及它们的深度,并跟踪到目前为止看到的最大深度。它看起来像这样
depth t = go 0 [(0,t)]
where
go depth [] = depth
go depth (t:ts) = case t of
(d, Empty) -> go (max depth d) ts
(d, Branch _ l r) -> go (max depth d) ((d+1,l):(d+1,r):ts)
我认为在保证其尾递归的限制下,这个函数已经足够简单了。
6. 那么我应该使用它吗?
老实说,你原来的非尾递归版本可能已经很好了。新版本并没有更加节省空间(它们总是需要存储下一个要处理的树的列表),但它们有一个优点,就是将要处理的树存储在堆上,而不是在栈上,堆上有更多的空间。
你可能想看一下Ingo答案中的部分尾递归函数,它可以在树极不平衡的情况下提供帮助。
depthTailRec
中必须进行的最后一个函数调用以获得结果不是depthTailRec
而是max
。 - Ingo