Haskell:二叉树深度的尾递归版本

时间:2014-01-18 14:08:58

标签: haskell recursion tree binary-tree tail-recursion

首先,我有两个不同的实现,我认为是正确的,并且已经对它们进行了分析并认为它们具有相同的性能:

depth::Tree a -> Int
depth Empty        = 0
depth (Branch b l r) = 1 + max (depth l) (depth r)


depthTailRec::Tree a -> Int
depthTailRec = depthTR 0 where
           depthTR d Empty          = d 
           depthTR d (Branch b l r) = let dl = depthTR (d+1) l; dr = depthTR (d+1) r in max dl dr 

我只是想知道是不是人们都在谈论尾递归如何有利于性能?很多问题都在我脑海中浮现:

  1. 如何让深度功能更快?
  2. 我读到了关于Haskell的懒惰如何减少尾递归的需要,是真的吗?
  3. 真的是每个递归都可以转换成尾递归吗?
  4. 最后,尾递归可以更快,更节省空间,因为它可以变成循环,从而减少了推送和弹出堆栈的需要,我的理解是对吗?

3 个答案:

答案 0 :(得分:34)

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语句进行了简化)并重新安排了一下

depthTR d (Branch _ l r) = max (depthTR (d+1) r) (depthTR (d+1) l)

所以你看到返回之前调用的最后一个函数是max,这意味着这也不是尾递归。

2。你怎么能让它尾递归?

您可以使用延续传递样式创建尾递归函数。您可以传入一个函数(称为 continuation ),而不是重新编写函数以获取状态或累加器,该函数是一个指令,用于处理计算的值 - 即不是立即执行返回到调用者,将您计算的任何值传递给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。那是吗?

(注意本节从this previous question的答案中得出。)

没有!这个"技巧"只将问题推回到其他地方。我们现在有一个尾递归函数来代替使用大量堆栈空间的非尾递归函数,它会吞噬thunks(未应用的函数),这可能会占用大量空间。幸运的是,我们不需要使用任意函数 - 事实上,只有三种

  1. \dl -> go r (\dr -> k (1 + max dl dr))(使用自由变量rk
  2. \dr -> k (1 + max dl dr)(包含自由变量kdl
  3. id(没有自由变量)
  4. 由于只有有限数量的函数,我们可以将它们表示为数据

    data Fun a = FunL (Tree a) (Fun a)  -- the fields are 'r' and 'k'
               | FunR  Int     (Fun a)  -- the fields are 'dl' and 'k'
               | 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
    

    请注意,goeval都会在尾部位置调用goeval - 因此它们是一对互相尾递归< / em>功能。因此,我们将使用延续传递样式的函数版本转换为使用数据表示延续的函数,并使用一对相互递归的函数来解释该数据。

    4。听起来真的很复杂

    嗯,我想是的。可是等等!我们可以简化它!如果您查看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首先进行探索这一事实,而右侧树始终被推送到调用堆栈上稍后再探讨。当达到eval分支并且可以丢弃时,参数只会从调用堆栈(Empty)中弹出。

    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的答案中的部分尾递归函数,这将有助于树木非常不平衡的情况。

答案 1 :(得分:4)

部分尾递归版本就是这样:

depth d Empty = d
depth d (Branch _ l Empty) = depth (d+1) l
depth d (Branch _ Empty r) = depth (d+1) r
depth d (Branch _ l r)     = max (depth (d+1) l) (depth (d+1) r)

请注意,在这种情况下,尾部递归(与Chris'答案中更复杂的完整案例相反)只是为了跳过不完整的分支。

但是,假设树木的深度至多是一个两位数,这应该足够了。事实上,如果你正确平衡你的树,这应该没问题。如果你的树OTOH用于退化到列表中,那么这已经有助于避免堆栈溢出(这是一个我没有证明的假设,但对于一个完全退化的树没有2非空的分支肯定是这样的儿童)。

尾递归并不是一种美德。只有这样才重要,如果我们不想用命令式编程语言中的简单循环来爆炸堆栈。

答案 2 :(得分:2)

到你的3.,是的,例如通过使用CPS技术(如Chris的回答所示);

到你的4.,正确。

到你的2.,lazy corecursive breadth-first tree traversal我们自然会得到一个类似于Chris的最后一个解决方案(即他的#5。,深度优先的遍历堆栈),即使没有调用max

treedepth :: Tree a -> Int
treedepth tree = fst $ last queue
  where
    queue = (0,tree) : gen 1 queue

    gen  0   p                     = []
    gen len ((d,Empty)        : p) =                     gen (len-1) p 
    gen len ((d,Branch _ l r) : p) = (d+1,l) : (d+1,r) : gen (len+1) p 

尽管两种变体在最坏的情况下都具有O(n)的空间复杂度,但最坏的情况本身是不同的,并且相反:最退化的树是深度的最坏情况 - 广度优先(BFT)的第一次遍历(DFT)和最佳情况(空格);同样,最平衡的树是DFT的最佳情况,也是BFT的最差情况。