理解递归

时间:2009-04-04 20:10:02

标签: algorithm recursion tail-recursion

我在学校理解递归时遇到了很大麻烦。每当教授谈论它时,我似乎都能得到它,但是一旦我自己尝试它就会彻底打动我的大脑。

我试图彻夜解决河内的塔楼并完全引起我的注意。我的教科书在递归时只有大约30页,所以它不太有用。有没有人知道有助于澄清这个主题的书籍或资源?

20 个答案:

答案 0 :(得分:579)

答案 1 :(得分:35)

你的大脑爆炸了,因为它进入了无限的递归。这是一个常见的初学者错误。

信不信由你,你已经理解了递归,你只是被一个普通但错误的隐喻所拖累:一个带有进出的东西的小盒子。

思考而不是任务或程序,例如“在网上找到更多关于递归的信息”。这是递归的,你没有问题。要完成此任务,您可以:

a) Read a Google's result page for "recursion"
b) Once you've read it, follow the first link on it and...
a.1)Read that new page about recursion 
b.1)Once you've read it, follow the first link on it and...
a.2)Read that new page about recursion 
b.2)Once you've read it, follow the first link on it and...

正如你所看到的,你一直在做递归的东西很长一段时间没有任何问题。

你会继续做多久这个任务?永远直到你的大脑爆炸?当然不是,只要您认为自己完成了任务,就会停在指定的位置。

当你要求“在网上找到更多关于递归的信息”时,没有必要指明这一点,因为你是一个人,你可以自己推断。

计算机无法推断插孔,因此您必须包含一个明确的结尾:“了解有关网上递归的更多信息,直到您理解它或您已阅读最多10页”。

您还推断出,您应该从Google的结果页面开始“递归”,再一次,这是计算机无法做到的事情。我们的递归任务的完整描述还必须包括一个明确的起点:

“了解更多有关网上递归的信息,直到您了解它或者您已阅读最多10页并且从www.google.com/search?q=recursion开始

为了理解整件事,我建议你试试这些书:

  • Common Lisp:对符号计算的温和介绍。这是递归的最可爱的非数学解释。
  • 小阴谋家。

答案 2 :(得分:24)

要了解递归,您只需查看洗发水瓶的标签:

function repeat()
{
   rinse();
   lather();
   repeat();
}

问题在于没有终止条件,递归将无限期重复,或者直到你用完洗发水或热水(外部终止条件,类似于吹掉堆栈)。

答案 3 :(得分:11)

如果你想要一本能够用简单的术语解释递归的书,请看看Douglas Hofstadter的Gödel,Escher,Bach:A Eternal Golden Braid ,特别是第5章。除了递归之外,它还能以一种可理解的方式解释计算机科学和数学中的许多复杂概念,其中一个解释基于另一个。如果您以前没有太多接触过这些概念,那么它可能是一本非常令人兴奋的书。

答案 4 :(得分:9)

这更像是一个抱怨而不是一个问题。你有关于递归的更具体的问题吗?就像乘法一样,这不是人们写的很多东西。

说到乘法,想一想。

问题:

什么是* b?

答案:

如果b为1,则为a。 否则,它是+ a *(b-1)。

什么是*(b-1)?请参阅上述问题以获得解决方法。

答案 5 :(得分:8)

实际上,您使用递归来降低手头问题的复杂性。您应用递归直到达到可以轻松解决的简单基本情况。有了这个,你可以解决最后一个递归步骤。通过这一切所有其他递归步骤可以解决原始问题。

答案 6 :(得分:8)

我认为这个非常简单的方法可以帮助你理解递归。该方法将调用自身直到某个条件为真,然后返回:

function writeNumbers( aNumber ){
 write(aNumber);
 if( aNumber > 0 ){
  writeNumbers( aNumber - 1 );
 }
 else{
  return;
 }
}

此功能将打印出您要输入的第一个数字中的所有数字,直到0.这样:

writeNumbers( 10 );
//This wil write: 10 9 8 7 6 5 4 3 2 1 0
//and then stop because aNumber is no longer larger then 0
低音发生的是writeNumbers(10)将写入10然后调用writeNumbers(9),它将写入9然后调用writeNumber(8)等。直到writeNumbers(1)写入1然后调用writeNumbers(0)将写0对接不会调用writeNumbers(-1);

此代码基本上与:

相同
for(i=10; i>0; i--){
 write(i);
}

那么为什么使用你可能会问的递归,如果for循环基本上相同。那么你大多使用递归,你必须嵌套for循环但不知道它们嵌套的深度。例如,从嵌套数组中打印出项目时:

var nestedArray = Array('Im a string', 
                        Array('Im a string nested in an array', 'me too!'),
                        'Im a string again',
                        Array('More nesting!',
                              Array('nested even more!')
                              ),
                        'Im the last string');
function printArrayItems( stringOrArray ){
 if(typeof stringOrArray === 'Array'){
   for(i=0; i<stringOrArray.length; i++){ 
     printArrayItems( stringOrArray[i] );
   }
 }
 else{
   write( stringOrArray );
 }
}

printArrayItems( stringOrArray );
//this will write:
//'Im a string' 'Im a string nested in an array' 'me too' 'Im a string again'
//'More nesting' 'Nested even more' 'Im the last string'

这个函数可以采用一个可以嵌套到100个级别的数组,而你编写一个for循环则需要你嵌套100次:

for(i=0; i<nestedArray.length; i++){
 if(typeof nestedArray[i] == 'Array'){
  for(a=0; i<nestedArray[i].length; a++){
   if(typeof nestedArray[i][a] == 'Array'){
    for(b=0; b<nestedArray[i][a].length; b++){
     //This would be enough for the nestedAaray we have now, but you would have
     //to nest the for loops even more if you would nest the array another level
     write( nestedArray[i][a][b] );
    }//end for b
   }//endif typeod nestedArray[i][a] == 'Array'
   else{ write( nestedArray[i][a] ); }
  }//end for a
 }//endif typeod nestedArray[i] == 'Array'
 else{ write( nestedArray[i] ); }
}//end for i

正如您所看到的,递归方法要好得多。

答案 7 :(得分:5)

我会尝试用一个例子来解释它。

你知道什么是n!手段?如果不是:http://en.wikipedia.org/wiki/Factorial

3! = 1 * 2 * 3 = 6

这里有一些伪代码

function factorial(n) {
  if (n==0) return 1
  else return (n * factorial(n-1))
}

让我们试一试:

factorial(3)

是n 0?

没有!

所以我们深入研究了我们的递归:

3 * factorial(3-1)

3-1 = 2

是2 == 0?

没有!

所以我们更深入!     3 * 2 *阶乘(2-1) 2-1 = 1

是1 == 0?

没有!

所以我们更深入!     3 * 2 * 1 *阶乘(1-1) 1-1 = 0

是0 == 0?

是!

我们有一个简单的案例

所以我们有     3 * 2 * 1 * 1 = 6

我希望帮助你

答案 8 :(得分:5)

递归

方法A,调用方法A调用方法A.最终,这些方法A中的一个不会调用和退出,但它是递归,因为有些东西会自行调用。

我希望打印出硬盘上每个文件夹名称的递归示例:(在c#中)

public void PrintFolderNames(DirectoryInfo directory)
{
    Console.WriteLine(directory.Name);

    DirectoryInfo[] children = directory.GetDirectories();

    foreach(var child in children)
    {
        PrintFolderNames(child); // See we call ourself here...
    }
}

答案 9 :(得分:4)

http://javabat.com是一个有趣且令人兴奋的练习递归的地方。他们的例子开始相当轻松,并且通过广泛的工作(如果你想要那么远)。注意:他们的方法是通过练习来学习。这是一个递归函数,我写的只是替换for循环。

for循环:

public printBar(length)
{
  String holder = "";
  for (int index = 0; i < length; i++)
  {
    holder += "*"
  }
  return holder;
}

这是递归做同样的事情。 (注意我们重载第一个方法,以确保它像上面一样使用)。我们还有另一种方法来维护我们的索引(类似于for语句为你做的方式)。递归函数必须保持自己的索引。

public String printBar(int Length) // Method, to call the recursive function
{
  printBar(length, 0);
}

public String printBar(int length, int index) //Overloaded recursive method
{
  // To get a better idea of how this works without a for loop
  // you can also replace this if/else with the for loop and
  // operationally, it should do the same thing.
  if (index >= length)
    return "";
  else
    return "*" + printBar(length, index + 1); // Make recursive call
}

总而言之,递归是编写更少代码的好方法。在后面的printBar中注意到我们有一个if语句。如果我们已达到条件,我们将退出递归并返回上一个方法,返回上一个方法,等等。如果我发送了printBar(8),我得到********。我希望通过一个简单函数的例子来做与for循环相同的事情,这可能会有所帮助。你可以在Java Bat中练习更多。

答案 10 :(得分:4)

递归函数只是一个函数,可以根据需要多次调用自身。如果您需要多次处理某些内容,这很有用,但您不确定实际需要多少次。在某种程度上,您可以将递归函数视为一种循环。但是,就像一个循环一样,你需要指定进程被破坏的条件,否则它将变为无限。

答案 11 :(得分:4)

你在用哪本书?

实际上很好的算法的标准教科书是Cormen&amp;维斯特。我的经验是它很好地教授递归。

递归是编程中难以掌握的部分之一,虽然它需要本能,但它可以学习。但它确实需要一个好的描述,好的例子和好的插图。

另外,一般来说30页很多,单一编程语言中的30页令人困惑。在从一般书籍中理解递归之前,不要尝试学习C或Java中的递归。

答案 12 :(得分:3)

Common Lisp

中的简单递归示例

MYMAP将一个函数应用于列表中的每个元素。

1)空列表没有元素,所以我们返回空列表 - ()和NIL都是空列表。

2)将该函数应用于第一个列表,为列表的其余部分调用MYMAP(递归调用)并将两个结果合并到一个新列表中。

(DEFUN MYMAP (FUNCTION LIST)
  (IF (NULL LIST)
      ()
      (CONS (FUNCALL FUNCTION (FIRST LIST))
            (MYMAP FUNCTION (REST LIST)))))

让我们看一下追踪的执行情况。在输入函数时,将打印参数。在退出功能时,将打印结果。对于每个递归调用,输出将在级别上缩进。

此示例在列表中的每个数字上调用SIN函数(1 2 3 4)。

Command: (mymap 'sin '(1 2 3 4))

1 Enter MYMAP SIN (1 2 3 4)
| 2 Enter MYMAP SIN (2 3 4)
|   3 Enter MYMAP SIN (3 4)
|   | 4 Enter MYMAP SIN (4)
|   |   5 Enter MYMAP SIN NIL
|   |   5 Exit MYMAP NIL
|   | 4 Exit MYMAP (-0.75680256)
|   3 Exit MYMAP (0.14112002 -0.75680256)
| 2 Exit MYMAP (0.9092975 0.14112002 -0.75680256)
1 Exit MYMAP (0.841471 0.9092975 0.14112002 -0.75680256)

这是我们的结果

(0.841471 0.9092975 0.14112002 -0.75680256)

答案 13 :(得分:3)

建立递归函数的真正数学方法如下:

1:想象一下你有一个对f(n-1)正确的函数,构造f使得f(n)是正确的。 2:构建f,使f(1)正确。

这就是你如何证明函数是正确的,数学上的,并且它被称为Induction。它相当于具有不同的基本情况,或者对多个变量具有更复杂的功能)。它也等同于假设f(x)对于所有x都是正确的

现在换一个“简单”的例子。构建一个函数,可以确定是否可以使用5美分和7美分的硬币组合来制作x美分。例如,它可能有17美分乘2x5 + 1x7,但不可能有16美分。

现在假设你有一个函数可以告诉你是否可以创建x美分,只要x&lt; ñ。调用此函数can_create_coins_small。想象一下如何为n做函数应该相当简单。现在建立你的功能:

bool can_create_coins(int n)
{
    if (n >= 7 && can_create_coins_small(n-7))
        return true;
    else if (n >= 5 && can_create_coins_small(n-5))
        return true;
    else
        return false;
}

这里的技巧是要意识到can_create_coins适用于n的事实意味着你可以用can_create_coins_small替换can_create_coins,给出:

bool can_create_coins(int n)
{
    if (n >= 7 && can_create_coins(n-7))
        return true;
    else if (n >= 5 && can_create_coins(n-5))
        return true;
    else
        return false;
}

要做的最后一件事是有一个基本案例来阻止无限递归。请注意,如果您尝试创建0美分,那么可以通过没有硬币来实现。添加此条件会产生:

bool can_create_coins(int n)
{
    if (n == 0)
        return true;
    else if (n >= 7 && can_create_coins(n-7))
        return true;
    else if (n >= 5 && can_create_coins(n-5))
        return true;
    else
        return false;
}

可以证明,此函数将始终使用名为infinite descent的方法返回,但这不是必需的。你可以想象f(n)只调用较低的n值,并且最终总是达到0。

要使用此信息来解决您的河内塔问题,我认为诀窍是假设您有一个功能将n-1个平板电脑从a移动到b(对于任何a / b),试图移动n个表格a到b。

答案 14 :(得分:3)

为了解释一个六岁的孩子的递归,首先向一个五岁的孩子解释,然后等一年。

实际上,这是一个有用的反例,因为你的递归调用应该更简单,而不是更难。解释一个五岁的递归会更难,虽然你可以在0处停止递归,但你没有简单的解决方案来解释一个零岁的递归。

要解决使用递归的问题,首先将其细分为一个或多个更简单问题,您可以用同样的方法解决这些问题,然后当问题很简单,无需进一步递归即可解决,你可以回到更高的水平。

实际上,这是如何解决递归问题的递归定义。

答案 15 :(得分:3)

子项隐式使用递归,例如:

迪斯尼世界之旅

  

我们还在吗?(不)

     

我们在那里吗?(很快)

     

我们在那里吗?(几乎......)

     

我们在那里吗?(SHHHH)

     

我们还在吗?(!!!!!)

孩子何时入睡......

这个倒计时功能就是一个简单的例子:

function countdown()
      {
      return (arguments[0] > 0 ?
        (
        console.log(arguments[0]),countdown(arguments[0] - 1)) : 
        "done"
        );
      }
countdown(10);

适用于软件项目的

Hofstadter's Law也很重要。

  

根据乔姆斯基的说法,人类语言的本质是有限大脑产生他认为是无限语法的能力。这意味着他不仅意味着我们可以说什么没有上限,而且我们的语言句子数没有上限,任何特定句子的大小都没有上限。乔姆斯基声称,人类语言所有这些创造力的基础工具是递归:一个短语在同一类型的另一个短语内重现的能力。如果我说“约翰的兄弟的房子”,我有一个名词,“house”,它出现在一个名词短语“brother's house”中,而这个名词短语出现在另一个名词短语“John's's brother's house”中。这很有道理,而且它是人类语言的有趣属性。

<强>参考

答案 16 :(得分:2)

使用递归解决方案时,我总是尝试:

  • 首先建立基本案例,即 当在因子
  • 的解决方案中n = 1时
  • 尝试提出一般规则 对于其他案件

此外,还有不同类型的递归解决方案,这种分而治之的方法对分形和许多其他方法都很有用。

如果您可以先处理更简单的问题,那么它也会有所帮助。一些例子是求解阶乘和产生第n个斐波纳契数。

对于参考文献,我强烈推荐Robert Sedgewick的算法。

希望有所帮助。祝你好运。

答案 17 :(得分:2)

哎哟。去年我试图弄清楚河内的塔楼。关于TOH的棘手问题是它不是一个简单的递归示例 - 您有嵌套递归,这也会改变每次调用时塔的角色。我能让它变得有意义的唯一方法是在我的脑海中直观地想象戒指的运动,并用语言表达递归呼叫的内容。我会从一个戒指开始,然后是两个,然后是三个。我实际上是在网上订购了这款游戏。我花了大概两三天才开始绞尽脑汁。

答案 18 :(得分:1)

递归函数就像是在每次调用时压缩一点的弹簧。在每一步中,您都会在堆栈上放置一些信息(当前上下文)。当达到最后一步时,释放弹簧,立即收集所有值(上下文)!

不确定这个比喻是否有效......: - )

无论如何,除了经典的例子(因为效率低且容易扁平化的最差的例子,因子,斐波那契,河内...)这些都是有点人为的(我很少,如果有的话,在实际编程案例中使用它们) ,看到它真正被使用的地方很有趣。

一个非常常见的情况是走树(或图表,但树木更常见) 例如,文件夹层次结构:要列出文件,请迭代它们。如果找到子目录,列出文件的函数会调用自身,并将新文件夹作为参数。当从列出这个新文件夹(及其子文件夹!)返回时,它将恢复其上下文,到下一个文件(或文件夹)。
另一个具体案例是绘制GUI组件的层次结构时:通常有容器(如窗格)来保存可以是窗格的组件,或复合组件等。绘制例程递归调用每个组件的绘制功能,调用它所拥有的所有组件的paint函数等。

不确定我是否非常清楚,但我喜欢展示现实世界对教材的使用,因为这是我过去磕磕绊绊的事情。

答案 19 :(得分:1)

想一个工蜂。它试图制作蜂蜜。它完成了它的工作并期望其他工蜂可以制作剩余的蜂蜜。当蜂窝状物充满时,它会停止。

认为它是魔术。你有一个与你试图实现的功能同名的功能,当你给它子问题时,它会为你解决它,你唯一需要做的就是将你的部分解决方案与解决方案集成在一起给了你。

例如,我们想要计算列表的长度。让我们用magical_length调用我们的函数magical_length和我们的魔法助手 我们知道如果我们给出没有第一个元素的子列表,它将通过magic给我们子列表的长度。那么我们唯一需要考虑的是如何将这些信息与我们的工作相结合。第一个元素的长度是1,magic_counter给出了子列表n-1的长度,因此总长度是(n-1)+ 1 - > Ñ

int magical_length( list )
  sublist = rest_of_the_list( list )
  sublist_length = magical_length( sublist ) // you can think this function as magical and given to you
  return 1 + sublist_length

然而,这个答案是不完整的,因为我们没有考虑如果我们给出一个空列表会发生什么。我们认为列表中我们总是至少有一个元素。因此,如果给出一个空列表并且答案显然是0,我们需要考虑应该是什么答案。所以将这些信息添加到我们的函数中,这称为基本/边缘条件。

int magical_length( list )
  if ( list is empty) then
    return 0
  else
    sublist_length = magical_length( sublist ) // you can think this function as magical and given to you
    return 1 + sublist_length