【问题标题】:How can I convert this binary recursive function into a tail-recursive form?如何将此二进制递归函数转换为尾递归形式?
【发布时间】:2013-05-05 19:39:07
【问题描述】:

有一种明确的方法可以将二进制递归转换为尾递归,用于在函数下关闭的集合,即斐波那契数列的加法整数:

(使用 Haskell)

fib :: Int -> Int
fib n = fib' 0 1 n

fib' :: Int -> Int -> Int
fib' x y n
    | n < 1 = y  
    | otherwise = fib' y (x + y) (n - 1)

之所以可行,是因为我们有我们想要的值 y 和我们的操作 x + y,其中 x + y 返回一个整数,就像 y 一样。

但是,如果我想使用一个在函数下没有闭合的集合怎么办?我想采用一个函数,将一个列表拆分为两个列表,然后对这两个列表执行相同操作(即,像递归地创建二叉树),当另一个函数神奇地说何时停止查看结果拆分时,我会停止:

[1, 2, 3, 4, 5] -> [[1, 3, 4], [2, 5]] -> [[1, 3], [4], [2], [5]]

也就是说,

splitList :: [Int] -> [[Int]]
splitList intList
    | length intList < 2    = [intList]
    | magicFunction x y > 0 = splitList x ++ splitList y
    | otherwise             = [intList]
  where
    x = some sublist of intList
    y = the other sublist of intList

现在,如何将此二进制递归转换为尾递归?先前的方法不会显式工作,因为(Int + Int -&gt; Int 与输入相同)但(Split [Int] -/&gt; [[Int]] 与输入不同)。因此,累加器需要更改(我假设)。

【问题讨论】:

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


    【解决方案1】:

    您通常不希望在 Haskell 中使用 tail-recursion。您真正想要的是生产性核心递归(另请参阅this),描述SICP 中所谓的迭代过程

    您可以通过将初始输入包含在列表中来修复函数中的类型不一致。在你的例子中

    [1, 2, 3, 4, 5] -> [[1, 3, 4], [2, 5]] -> [[1, 3], [4], [2], [5]]
    

    只有第一个箭头不一致,所以改成

    [[1, 2, 3, 4, 5]] -> [[1, 3, 4], [2, 5]] -> [[1, 3], [4], [2], [5]]
    

    它说明了迭代应用concatMap splitList1的过程,其中

       splitList1 xs 
          | null $ drop 1 xs = [xs]
          | magic a b > 0    = [a,b]    -- (B)
          | otherwise        = [xs]
         where (a,b) = splitSomeHow xs
    

    如果在某个迭代中没有触发 (B) 案例,您想停止。

    (编辑:删除中间版本)

    但最好尽快生成准备好的输出部分:

    splitList :: [Int] -> [[Int]]
    splitList xs = g [xs]   -- explicate the stack
      where
        g []                  = []
        g (xs : t)
           | null $ drop 1 xs = xs : g t
           | magic a b > 0    = g (a : b : t)
           | otherwise        = xs : g t
         where (a,b) = splitSomeHow xs 
               -- magic a b = 1
               -- splitSomeHow = splitAt 2
    

    不要忘记使用-O2 标志进行编译。

    【讨论】:

    • 这个解决方案非常了解我通常不使用但应该使用的 Haskell 约定——一直到 Either 类型。我的问题是,尾递归和核心递归显然非常相似,即使在某种程度上是另一个的子集。在 Haskell 中哪个更好? Haskell 社区似乎倾向于使用尾递归来绕过任何堆栈大小溢出,而这种解决方案似乎只是非常松散的递归(直到语句暗示“while”类型的算法)。在这种情况下,哪个应该是标准做法(以最好地避免 thunk 积聚)?
    • Either 没什么特别的;我只需要使用“处理/不再处理”标签来标记列表元素。我们也可以为此使用(0,)(1,),尽管使用Either 似乎“更干净”。至于尾递归,这取决于。仅当您将无法按部分计算的整体计算时才更好-在这种情况下,TR比“保护递归”/“核心递归”更差。无论用什么术语,其本质都是以最少的堆栈使用获得迭代的在线过程,并尽快开始产生部分输出。这里的代码只是一个开始,前半步。
    • @user1104160 关键是拆分:您是否必须将整个输入拆分,或者类似于 splitAt 10 足够(这将消耗输入列表的 10 个元素并返回 (a,b) 其中b 是尚未访问的输入部分。现在,您的 magic 实际上是关键 - 它需要列表的其余部分,b。所以,问题变成了,它是否需要所有列表的其余部分,或者是否有足够数量的元素,并且您可以按输入列表的部分工作?如果没有,如果列表很大,则会占用大量内存。
    • magic 函数需要整个列表,这是绝对没有办法的。此外,列表非常庞大(它们是实际算法中的矩阵),充满了数百万行和数千列(虽然矩阵很稀疏,但很多列都是浓缩的)。因此,低堆栈和内存使用是目标,因此我认为优化是尾递归形式。
    • 是的,我有很多可用内存,而且矩阵很稀疏,因此可以非常有效地进行操作。
    【解决方案2】:

    有一个使 any 函数尾递归的通用技巧:以连续传递样式 (CPS) 重写它。 CPS 背后的基本思想是每个函数都有一个额外的参数——一个在完成时调用的函数。然后,原始函数不返回值,而是调用传入的函数。后一个函数称为“延续”,因为它继续将计算进行到下一步。

    为了说明这个想法,我将使用您的函数作为示例。注意类型签名以及代码结构的变化:

    splitListCPS :: [Int] -> ([[Int]] -> r) -> r
    splitListCPS intList cont
      | length intList < 2    = cont [intList]
      | magicFunction x y > 0 = splitListCPS x $ \ r₁ -> 
                                  splitListCPS y $ \ r₂ -> 
                                    cont $ r₁ ++ r₂
      | otherwise             = cont [intList]
    

    然后您可以将其包装成一个看起来很正常的函数,如下所示:

    splitList :: [Int] -> [[Int]]
    splitList intList = splitListCPS intList (\ r -> r)
    

    如果你遵循稍微复杂的逻辑,你会发现这两个函数是等价的。棘手的一点是递归情况。在那里,我们立即x 调用splitListCPS。函数\ r₁ -&gt; ... 告诉splitListCPS 完成后要做什么——在这种情况下,使用下一个参数(y) 调用splitListCPS。最后,一旦我们得到两个结果,我们只需将结果合并并将其传递到原始延续 (cont)。所以最后,我们得到了与最初相同的结果(即splitList x ++ splitList y),但我们没有返回它,而是使用延续。

    此外,如果您查看上面的代码,您会注意到所有递归调用都位于尾部位置。在每一步,我们的最后一个操作总是递归调用或使用延续。使用聪明的编译器,这种代码实际上可以相当高效。

    在某种意义上,这个技术其实和你为fib做的很相似;然而,我们不是维护一个累加器值,而是维护一个我们正在执行的计算的累加器。

    【讨论】:

    • 根据我的经验,GHC 中的多分支递归函数比它们的 CPS 或尾递归(带有显式堆栈)对应物更有效。例如,尝试比较这 3 种样式的 fib 40 的计算时间。
    • 我在想,这不是刚从煎锅里出来的吗?是的,我们得到了尾递归,但是我们没有将值保存在堆栈上,而是将它们作为 lambda 抽象保存在堆上,这实际上在运行时可能会更昂贵。
    • 我们的辅助 lambda 函数在这里是 ([[Int]] -> r),但是我的拆分函数将 [Int] 拆分为一个带有拆分 [[Int], [Int]] 的列表。那么我怎样才能拥有类型为 [[Int]] -> [Int] 的 (\r -> x)?
    • @haskelline 不幸的是,这个列表非常大,所以多分支递归会导致堆栈大小溢出,这就是为什么我需要将算法转换为尾递归,然后我希望可以严格累加器的爆炸模式(我猜这里是 lambda 函数)。
    • @user1104160:这是一个错字。我的意思是拥有(\ r -&gt; r) 或只是id 的功能。
    猜你喜欢
    • 1970-01-01
    • 2023-01-13
    • 1970-01-01
    • 2012-03-20
    • 2016-01-06
    • 2019-09-14
    • 2021-12-16
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多