【问题标题】:Recursive to Tail recursive递归到尾递归
【发布时间】:2017-11-11 14:06:03
【问题描述】:

我正在编写遍历以找到道路中最长的路径。这段代码的神奇部分是段。Next 指的是应用了特定逻辑的 LINQ,例如不重新访问已访问的节点。因此,旅行中的瑕疵不要指出超出范围。

我正在尝试做的是减少堆栈上的调用次数,因为有时路径可能长达 5000 次。我知道我必须使这个递归调用尾递归。

public static IEnumerable<Segment> FindLongestPath(Segment segment)
{
    var rv = new List<Segment> {segment};
    var longestPathLength = 0;
    var longestNextPaths = Enumerable.Empty<Segment>();

    foreach (var n in segment.Next)
    {
        var paths = FindLongestPath(n);
        var length = paths.Sum(p => p.LengthMeters);
        if (length > longestPathLength)
        {
            longestPathLength = length;
            longestNextPaths = paths;
        }
    }

    rv.AddRange(longestNextPaths);

    return rv;
}

如何使这个递归调用成为尾递归?我知道我可能必须在旅行时维护IEnumerable&lt;Segment&gt;,但我只是没有把头缠在它身上。

【问题讨论】:

  • 我认为您需要在 var paths = FindLongestPath(n) 调用之后,将 n 添加到“已检查所有子路由”ienumberable 中,您将在之前检查 n 是否存在FindLongestPath 并继续循环(如果是)。当然,这也必须传递到 findlongestpath 中
  • 我注意到,您的方法不仅可能会破坏堆栈,而且如果没有,它产生的冗余列表的数量是巨大的。您最好在这里制作路径不可变堆栈,然后您可以将新元素推送到其中而无需重新分配和复制主体。

标签: c# traversal


【解决方案1】:

使用尾递归执行此操作会很棘手,因为您需要以委托人的身份交出延续,以便进行后递归处理。对于不熟悉函数式风格的人来说,这段代码看起来很糟糕。

您的主要动机似乎不是破坏调用堆栈。您可以通过采用非递归方法来减少“大脑燃烧”。使用显式的Queue&lt;T&gt;/Stack&lt;T&gt;(取决于您是要先遍历深度还是广度),而不是从非尾递归方法调用中获得的隐式堆栈意味着您的堆栈仅受可用内存的限制。

这应该让你开始走这条路:

public static IEnumerable<Segment> FindLongestPath(Segment segment)
{
    var queue = new Queue<Segment>(); //or a Stack<Segment> with Push and Pop
    queue.Enqueue(segment);

    while(queue.Any())
    {
        var currentSegment = queue.Dequeue();
        foreach(var seg in currentSegment.Next)
        {
            queue.Enqueue(seg);
        }
        //process currentSegment
    }
}

【讨论】:

  • 啊,我什至没有想到用堆栈数据类型替换调用堆栈。如果没有其他人有任何其他建议或意见,我会在几个小时内考虑这是一个解决方案
  • @CuriousDeveloper:这绝对是正确的方法;有关这方面的更多信息,您可以查看显式堆栈和队列如何用于深度优先和广度优先遍历。
  • @CuriousDeveloper 我也对CPS recursion 解决方案感兴趣,但作为一个受害的父母,今天我也很难理解它。
  • @EricLippert 你回答的这个question of mine 让我思考......我正在考虑提出一个问题,但是看到你在这里......我知道状态机的成本是高,但我认为awaityield 可以用来使延续不那么像延续。这行得通吗?如果行得通,它会是尾递归吗?
  • @spender:理论上,是的;通过巧妙地使用等待,您将获得“无堆栈递归”。在实践中,存在一些困难。 await检查返回的任务是否完成;如果是,那么它就会继续运行,然后你就拥有了正常的递归算法,但需要付出很多额外的成本。如果没有完成,那么是什么导致它在未来完成?
【解决方案2】:

spender 给出的答案是无需递归即可解决此问题的实用方法:使用显式堆栈或队列作为助手。

最初的问题和花费者,在评论中,想知道如何分别以尾递归风格和延续传递风格来做这个算法。 (CPS 是一种编程风格,其中每次调用都是尾调用。)

为了让您了解该算法的 CPS 版本的外观,让我 (1) 大大简化问题,以及 (2) 用 ML 而不是 C# 编写解决方案。简化的问题是:

  • 函数children 接受一个节点并生成一组子节点。
  • 函数cost 给出了遍历单个节点的成本。
  • 给出的问题是找到最大成本路径的成本。

首先,ML 中一个简单的非 CPS 解决方案:

let rec maximum_path_cost node =
  let rec aux nodes max =
    match nodes with
    | [] -> max
    | head :: tail -> 
       let c = maximum_path_cost head in
       let new_max = if c > max then c else max in
       aux tail new_max
  in
  (cost node) + (aux (children node) 0)

简而言之:我们使用递归辅助函数模拟一个循环,该函数累积迄今为止看到的最大值。循环条件是“列表是否为空?”如果是,那么结果是迄今为止看到的最大值;如果不是,那么我们计算当前项(列表的头部)的成本,将其与最大值进行比较,然后在尾部运行循环。

注意 aux 是尾递归的,但 maximum_path_cost 不是。

在延续传递风格中,maximum_path_cost 采用延续——在这种情况下,是一个采用 int 的函数——并且需要使用其结果调用该函数,而不是返回。我们会让 aux 做同样的事情。

为简单起见,我们不会将成本和子代转换为 CPS。

let rec maximum_path_cost node continuation =
  let rec aux nodes max aux_continuation =
    match nodes with
    | [] -> aux_continuation max
    | head :: tail ->
       let mpcc c = 
         let new_max = if c > max then c else max in
         aux tail new_max aux_continuation
       in
       maximum_path_cost head mpcc
  in
  let ac result =
    continuation ((cost node) + result) 
  in
    aux (children node) 0 ac

我知道很难用你的大脑来理解它,但如果你通读它,它应该是有道理的。我们做的第一件事是调用 aux 与孩子和当前最大值为零;第一次调用 aux 的延续是什么?将其结果添加到头部的成本中,并将其传递给 maximum_path_cost 的延续。我们什么时候这样做?当我们用完整个子节点列表并且没有剩余子节点时。

将其翻译成 C# 并让 C# 保证尾递归留作练习。 :)

【讨论】:

    猜你喜欢
    • 2018-03-17
    • 2016-03-21
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2011-03-03
    • 2011-11-14
    • 1970-01-01
    相关资源
    最近更新 更多