【问题标题】:What is actually happening behind the recursive function for large number?大量递归函数背后实际发生了什么?
【发布时间】:2014-10-02 23:49:58
【问题描述】:

我在下面有一个递归函数。

int f(int n){
  if(n<1) return 1;
  else return f(n-1) + f(n-1);
}

当我用 f(0)、f(1) 等小数字调用函数时,它工作正常。

但是当我调用 f(50)f(80)f(100) 时,它只是等待并且没有显示输出.

我需要知道背后到底发生了什么?

【问题讨论】:

  • 您正在经历所需处理时间的指数级增长。对于这个特定的示例,您可以通过观察两个递归调用给出相同的结果来进行优化。
  • 在调试器中运行你的程序。
  • “它只是等待,没有显示输出”——你等了多少年?

标签: c recursion computer-science


【解决方案1】:

朴素递归

Recursion 由维基百科定义:

递归是以自相似的方式重复项目的过程。

您的程序正在解决数学问题recurrence relation

f(n) = f(n - 1) + f(n - 1)

通过调用自身,将f(n) 的较大问题分解成越来越小的块,然后将这些块分解成越来越小的块,依此类推。

当您致电f(0) 时发生了什么?因为在这种情况下参数n 为零,所以您的基本情况被触发并且递归链停止。这是一个非常简单的案例(n &lt; 1 也是如此):

    f(0)
     |
     1

f(1) 怎么样?稍微复杂一点,但不多:

    f(1)
  /     \
f(0) +  f(0) = 1 + 1 = 2

让我们尝试一些更大的东西,比如n = 5

             _____________f(5)___________
            /                            \
        ___f(4)____        +        ____f(4)____
       /           \               /            \
    f(3)    +     f(3)     +     f(3)     +    f(3)
   /   \         /   \          /    \        /    \
f(2) + f(2) + f(2) + f(2)  +  f(2) + f(2) + f(2) + f(2)
/ \    / \     / \    / \      / \    / \    / \    / \
...    ...     ...    ...      ...    ...    ...    ... = f(0) * 32 = 1 * 32 = 32

...所以,事实证明,手动创建文本树非常烦人。希望你现在已经明白了。也许,您甚至已经发现了这种模式:

f(0) = 1
f(1) = 2
f(2) = 4
f(3) = 8
f(4) = 16
f(5) = 32
...

一般:

f(n) = 2ⁿ

从数学上讲,这是一个指数方程。在 Big-O 术语中,这是一种在指数时间中运行的算法。用更通俗的话来说,这个算法真的很慢。

想想这里发生了什么:

  1. 函数调用的数量随着输入的大小呈指数增长。哎哟!

  2. 不仅算法的运行时间会受到影响,空间复杂度也会受到影响。具有讽刺意味的是,您在使用朴素递归时可能遇到的问题被称为堆栈溢出,其中函数调用堆栈溢出 有大量的函数调用并且可用的堆栈空间基本上用完了。双重哎哟!

  3. 不仅该函数的时间和空间复杂度随着输入呈指数增长,而且该算法还非常清楚地的方式完成了超出其需要的工作。每次执行f(n) 并且基本情况没有命中时会发生什么? f(n - 1) 被计算,两次。三重哎哟!

所以,很明显这个算法很糟糕。但是有什么办法呢?

通用子表达式消除

一种长期加速程序运行时间的优化被称为common subexpression elimination。这是一个非常快速和简单的优化实现,它消除了天真的版本进行的绝大多数函数调用。您需要做的就是意识到这一点:

return f(n - 1) + f(n - 1);

等价于:

return 2 * f(n - 1);

这样你的代码就变成了:

int f(int n)
{
    if(n < 1)
    {
        return 1;
    }

    else
    {
        return 2 * f(n-1);
    }
}

将此修订版与您的原始版本并排运行,并被运行时间之间的几个数量级差异所震撼!因为每次调用只进行一次递归调用,所以指数算法本质上变成了等效迭代方法的线性时间 (O(n)) 直接递归版本。

很酷吧?

附录:动态规划

虽然您的具体示例不需要像我最初认为的那样需要动态编程,但在谈论递归时,这仍然是一个非常有用的话题,因此我对这部分进行了重新设计,使其不那么做作以前是。此外,这部分是附录,因为我将在下面使用 语法。如果这激怒了任何人,我深表歉意,我只是不喜欢目前重新实现std::map 的想法(也许在将来......)。

也许你听说过dynamic programming。不,请不要畏缩!听起来很吓人,但事实并非如此。其实还是很厉害的!

非常简单地说,动态编程是一种智能的蛮力方法。这个想法是您memoize将先前计算的结果放入一个查找表中,以便如果您需要重新计算某些东西(并且使用某些算法,您正在做很多很多),答案只是一个恒定时间(O(1)!)查找。

我们以Fibonacci sequence 为例。斐波那契算法的标准、幼稚、普通的实现如下所示:

int fib(int n)
{
    if (n <= 1)
    {
        return n;
    }

    return fib(n - 1) + fib(n - 2);
}

以上是另一种指数时间 (O(2ⁿ)) 算法。然而,优化这个算法并不像以前那么简单,因为fib(n - 1) + fib(n - 2) 不能以完全相同的方式简化。然而,我们可以做的是添加一个数据结构,旨在允许对我们程序的预计算结果进行持续访问,并利用它来避免 ton 的冗余计算。因此优化后的版本是:

long long fib_dp(int n)
{
    if (lookup.find(n) != lookup.end())
    {
        return lookup[n];
    }

    else if (n <= 1)
    {
        return n;
    }

    lookup[n] = fib_dp(n - 1) + fib_dp(n - 2);
    return lookup[n];
}

添加一个查找表(实现为 std::map&lt;int, long long&gt;),稍微调整一下逻辑,然后将普通的int 值替换为long long 值,然后你就得到了一个版本斐波那契算法可以处理更大的n 值,快得多。说真的,自己尝试并比较。天真的算法可能需要 几小时(或几天,或更长时间)才能完成,而动态编程版本可以在 内完成。

所以...我希望所有这些都回答了您的问题(也许更多)。让我知道你是否还有其他人! :)

跟进: 只是为了确切地说明您的未简化表达式的效率有多低 - 就在我第一次提交这个问题的时候,我运行了这个程序的两个版本(简化版和天真的递归版本)在n = 50 的输入上背靠背。我的台式机包括一个 Intel i7-4770K,相关进程目前正在使用我 CPU 处理能力的 13% 左右。快速动态编程版本在几秒钟内完成,输出为1125899906842624。近 12 小时后,当我打字时,朴素的递归版本仍在工作。我想它的工作时间会更长(如果我允许的话!)。

感谢 Jim Balter 的所有更正,让我意识到动态编程是有用的,但在这里完全没有必要!像往常一样,我让事情变得比需要的复杂得多。 OP 不是今天在这里学习新东西的唯一人! :)

【讨论】:

  • 很好的答案,对问题所暗示的主题的详细、易于理解的解释! +1:D
  • "stack overflow" -- 堆栈深度是树的高度,对于 OP 的例子来说最多为 100。
  • 对于这个特定的例子,一个简单的“记忆”方法是注意两个子表达式是相同的,所以int memo = f(n-1); return memo + memo;或者只是return 2 * f(n-1);
  • “它的工作时间会更长” -- 每次调用 f 需要 1 纳秒,需要 13 天。
  • 使用2 * f(n-1),所有结果都将有所不同,因此您永远不会有查找命中。我认为您应该提到子表达式折叠和动态编程,因为两者都很有用,但要认识到这个特定示例不需要后者。
【解决方案2】:

它正在发生你告诉 C 做的事情,只是在 50100 这样的大数字上需要很长时间。此外,您的代码从不输出任何内容。

这将提高您的程序速度。

int f(int n)
{
if(n<1) return 1;
else return f(n-1) * 2;
}

由于x + xx * 2 相同。

希望这会有所帮助!

【讨论】:

  • OP 真正需要知道的一切。我的回答可能有点过头了。干得好,@cdonts!
【解决方案3】:

函数实际上返回2^n的值。所以在较小的情况下返回值很容易驻留在整数变量中。但是当“n”的值变得大于31左右时,整数返回类型无法返回该值,使其不显示任何输出。

【讨论】:

  • 正好在 n == 31,因为最大的正数是 (2^31)-1。执行int i = f(31) 需要一段时间然后返回 -(2^31)。
【解决方案4】:

让我们看看当你为x = 2x = 30 执行f(x) 时会发生什么:

f(2) = f(1) + f(1) = f(0) + f(0) + f(0) + f(0) = 1+1+1+1 = 4;

我们可以看到,对于x = 2,我们得到了一个相对较小的添加链。为了得到我们的结果,我们必须对函数进行 7 次评估。让我们看看当我们将x 设置为 30 时会发生什么:

f(30) = f(29) + f(29) = f(28) + f(28) + f(28) + f(28) = f(27) + ........ = 1+1+....+1 (2^30 times)

我们看到我们得到了一个非常长的加法链,我们必须多次评估函数 (sum (n=0 to 29) (2^n)) 次。这么多的调用导致程序如此缓慢。

【讨论】:

  • 得到一个想法2^301,073,741,8242^100 有 31 位数字
猜你喜欢
  • 2021-12-27
  • 1970-01-01
  • 2020-03-12
  • 1970-01-01
  • 2016-03-17
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2011-03-31
相关资源
最近更新 更多