我在学校了解递归时遇到了很大的麻烦。每当教授在谈论它时,我似乎都明白了,但是只要我自己尝试一下,它就会完全让我震惊。
我整夜试图解决河内塔楼,这完全让我震惊。我的教科书只有大约30页的递归,所以它不太有用。有谁知道可以帮助阐明这一主题的书籍或资源?
我在学校了解递归时遇到了很大的麻烦。每当教授在谈论它时,我似乎都明白了,但是只要我自己尝试一下,它就会完全让我震惊。
我整夜试图解决河内塔楼,这完全让我震惊。我的教科书只有大约30页的递归,所以它不太有用。有谁知道可以帮助阐明这一主题的书籍或资源?
Answers:
您如何清空装有五朵花的花瓶?
答案:如果花瓶不是空的,则取出一朵花,然后倒空包含四朵花的花瓶。
您如何清空装有四朵花的花瓶?
答:如果花瓶不是空的,则取出一朵花,然后倒空包含三朵花的花瓶。
您如何清空装有三朵花的花瓶?
答:如果花瓶不是空的,则取出一朵花,然后倒空包含两朵花的花瓶。
您如何清空装有两朵花的花瓶?
答:如果花瓶不是空的,则取出一朵花,然后倒空装有一朵花的花瓶。
您如何清空装有一朵花的花瓶?
答:如果花瓶不是空的,则取出一朵花,然后倒空一个没有花的花瓶。
如何清空没有花的花瓶?
答:如果花瓶不是空的,您会拿出一朵花,但是花瓶是空的,这样就完成了。
那是重复的。让我们概括一下:
您如何清空装有N朵鲜花的花瓶?
答:如果花瓶不是空的,则取出一朵花,然后清空包含N-1朵花的花瓶。
嗯,我们可以在代码中看到吗?
void emptyVase( int flowersInVase ) {
if( flowersInVase > 0 ) {
// take one flower and
emptyVase( flowersInVase - 1 ) ;
} else {
// the vase is empty, nothing to do
}
}
嗯,难道我们不是刚刚在for循环中做到了吗?
为什么可以,递归可以用迭代代替,但是递归通常更优雅。
让我们谈谈树木。在计算机科学中,树是由节点组成的结构,其中每个节点都有一定数量的子节点,这些子节点也为节点,或者为null。一个二叉树是由它们拥有完全节点树2个子女,通常被称为“左”和“右”; 再次,子级可以是节点,也可以为null。一个根本的是,没有任何其他节点的子节点。
想象一个节点,除了其子节点外,还有一个值,一个数字,并想象我们希望对树中的所有值求和。
为了对任何一个节点中的值求和,我们将节点本身的值添加到其左子节点的值(如果有)和其右子节点的值(如果有)中。现在回想一下,子元素(如果不为null)也是节点。
因此,要对左子节点求和,我们会将子节点本身的值添加到其左子节点(如果有)的值以及其右子节点(如果有)的值。
因此,要对左子节点的左子节点的值求和,我们将子节点本身的值与其左子节点的值(如果有)和其右子节点的值(如果有)相加。
也许您已经预料到了我要做什么,并希望看到一些代码?好:
struct node {
node* left;
node* right;
int value;
} ;
int sumNode( node* root ) {
// if there is no tree, its sum is zero
if( root == null ) {
return 0 ;
} else { // there is a tree
return root->value + sumNode( root->left ) + sumNode( root->right ) ;
}
}
请注意,我们没有明确测试子节点是否为null或节点,而是使递归函数为空节点返回零。
假设我们有一棵看起来像这样的树(数字是值,斜杠指向子级,@表示指针指向空):
5
/ \
4 3
/\ /\
2 1 @ @
/\ /\
@@ @@
如果我们在根节点(值为5的节点)上调用sumNode,将返回:
return root->value + sumNode( root->left ) + sumNode( root->right ) ;
return 5 + sumNode( node-with-value-4 ) + sumNode( node-with-value-3 ) ;
让我们将其扩展到位。在所有看到sumNode的地方,都将用return语句的扩展替换它:
sumNode( node-with-value-5);
return root->value + sumNode( root->left ) + sumNode( root->right ) ;
return 5 + sumNode( node-with-value-4 ) + sumNode( node-with-value-3 ) ;
return 5 + 4 + sumNode( node-with-value-2 ) + sumNode( node-with-value-1 )
+ sumNode( node-with-value-3 ) ;
return 5 + 4
+ 2 + sumNode(null ) + sumNode( null )
+ sumNode( node-with-value-1 )
+ sumNode( node-with-value-3 ) ;
return 5 + 4
+ 2 + 0 + 0
+ sumNode( node-with-value-1 )
+ sumNode( node-with-value-3 ) ;
return 5 + 4
+ 2 + 0 + 0
+ 1 + sumNode(null ) + sumNode( null )
+ sumNode( node-with-value-3 ) ;
return 5 + 4
+ 2 + 0 + 0
+ 1 + 0 + 0
+ sumNode( node-with-value-3 ) ;
return 5 + 4
+ 2 + 0 + 0
+ 1 + 0 + 0
+ 3 + sumNode(null ) + sumNode( null ) ;
return 5 + 4
+ 2 + 0 + 0
+ 1 + 0 + 0
+ 3 + 0 + 0 ;
return 5 + 4
+ 2 + 0 + 0
+ 1 + 0 + 0
+ 3 ;
return 5 + 4
+ 2 + 0 + 0
+ 1
+ 3 ;
return 5 + 4
+ 2
+ 1
+ 3 ;
return 5 + 4
+ 3
+ 3 ;
return 5 + 7
+ 3 ;
return 5 + 10 ;
return 15 ;
现在,通过将其视为复合模板的重复应用,看看我们如何征服了任意深度和“分支”结构?每次通过sumNode函数时,我们仅使用一个if / then分支来处理单个节点,并使用两个简单的return语句直接将它们写成我们的规范?
How to sum a node:
If a node is null
its sum is zero
otherwise
its sum is its value
plus the sum of its left child node
plus the sum of its right child node
这就是递归的力量。
上面的花瓶示例是尾递归的示例。尾递归的全部意思是,在递归函数中,如果我们递归(也就是说,如果再次调用该函数),那就是我们要做的最后一件事。
树的例子不是尾递归的,因为即使我们做的最后一件事是递归右孩子,但在这样做之前,我们递归了左孩子。
实际上,我们调用子级并添加当前节点值的顺序根本没有关系,因为加法是可交换的。
现在,让我们看一下顺序重要的操作。我们将使用节点的二叉树,但是这次保留的值将是字符,而不是数字。
我们的树将具有一个特殊的属性,即对于任何节点,它的字符都在(按字母顺序)位于其左子节点所保持的字符之后,并且在其(字符按字母顺序)于其右子节点所保持的字符之后。
我们要做的是按字母顺序打印树。考虑到树的特殊属性,这很容易做到。我们只打印左孩子,然后打印节点的字符,然后是右孩子。
我们不只是想打印Willy-nilly,所以我们将传递函数以进行打印。这将是一个带有print(char)函数的对象;我们不必担心它是如何工作的,只需要在调用print时,它将在某处打印一些内容即可。
让我们在代码中看到:
struct node {
node* left;
node* right;
char value;
} ;
// don't worry about this code
class Printer {
private ostream& out;
Printer( ostream& o ) :out(o) {}
void print( char c ) { out << c; }
}
// worry about this code
int printNode( node* root, Printer& printer ) {
// if there is no tree, do nothing
if( root == null ) {
return ;
} else { // there is a tree
printNode( root->left, printer );
printer.print( value );
printNode( root->right, printer );
}
Printer printer( std::cout ) ;
node* root = makeTree() ; // this function returns a tree, somehow
printNode( root, printer );
除了重要的操作顺序之外,此示例还说明了我们可以将事物传递给递归函数。我们要做的唯一一件事就是确保在每次递归调用时,我们都继续传递它。我们向该函数传递了一个节点指针和一个打印机,并且在每个递归调用中,我们都将它们传递给“ down”。
现在,如果我们的树看起来像这样:
k
/ \
h n
/\ /\
a j @ @
/\ /\
@@ i@
/\
@@
我们将打印什么?
From k, we go left to
h, where we go left to
a, where we go left to
null, where we do nothing and so
we return to a, where we print 'a' and then go right to
null, where we do nothing and so
we return to a and are done, so
we return to h, where we print 'h' and then go right to
j, where we go left to
i, where we go left to
null, where we do nothing and so
we return to i, where we print 'i' and then go right to
null, where we do nothing and so
we return to i and are done, so
we return to j, where we print 'j' and then go right to
null, where we do nothing and so
we return to j and are done, so
we return to h and are done, so
we return to k, where we print 'k' and then go right to
n where we go left to
null, where we do nothing and so
we return to n, where we print 'n' and then go right to
null, where we do nothing and so
we return to n and are done, so
we return to k and are done, so we return to the caller
因此,如果我们只看打印的行:
we return to a, where we print 'a' and then go right to
we return to h, where we print 'h' and then go right to
we return to i, where we print 'i' and then go right to
we return to j, where we print 'j' and then go right to
we return to k, where we print 'k' and then go right to
we return to n, where we print 'n' and then go right to
我们看到我们印刷了“ ahijkn”,的确按字母顺序排列。
仅通过了解如何按字母顺序打印单个节点,我们就可以按字母顺序打印整个树。只是(因为我们的树具有将值按字母顺序排列的后面的值的顺序排序的特殊属性)知道在打印节点的值之前先打印左子节点,然后在打印节点的值后再打印右子节点。
这就是递归的力量:通过只知道如何做一部分的整体(并且知道何时停止递归),能够完成全部工作。
回顾大多数语言中,运算符|| (“或”)在其第一个操作数为true时发生短路,一般的递归函数为:
void recurse() { doWeStop() || recurse(); }
吕克M评论:
因此,应该为这种答案创建一个徽章。恭喜你!
谢谢,卢克!但是,实际上,因为我编辑了这个答案四次以上(添加最后一个示例,但主要是为了纠正错别字和修饰它-在小巧的上网本键盘上打字很困难),所以我无法获得更多的分数。这在某种程度上使我不愿为将来的答案投入太多精力。
请参阅我对此的评论:https : //stackoverflow.com/questions/128434/what-are-community-wiki-posts-in-stackoverflow/718699#718699
您的大脑炸毁了,因为它无限循环。这是一个常见的初学者错误。
信不信由你,您已经了解了递归,只是被一个常见但有错误的功能隐喻所拖累:一个装满东西的小盒子。
而不是思考任务或过程,例如“更多地了解网络递归”。这是递归的,您没有问题。要完成此任务,您可以:
a)阅读Google的“递归”结果页 b)阅读后,请点击它的第一个链接,然后... a.1)阅读有关递归的新页面 b.1)阅读后,请点击它的第一个链接,然后... a.2)阅读有关递归的新页面 b.2)阅读后,请点击它的第一个链接,然后...
如您所见,您从事递归操作已经很长时间了,没有任何问题。
您将继续执行该任务多长时间?永远直到你的大脑炸毁?当然不是,只要您相信自己已完成任务,就会停在给定的位置。
当您要求“查找有关网络递归的更多信息”时,无需指定此项,因为您是人,可以自己推断。
计算机无法推断出插孔,因此您必须包括一个明确的结尾:“在网络上查找有关递归的更多信息,除非您了解它,否则您最多阅读10页 ”。
您还推断出应该从Google的“递归”结果页开始,这也是计算机无法做到的。对递归任务的完整描述还必须包括一个明确的起点:
“在网络上找到有关递归的更多信息,直到您了解它为止,或者您最多阅读了10页并从www.google.com/search?q=recursion开始 ”
要研究整个问题,建议您尝试以下任何书籍:
要了解递归,您所要做的就是查看洗发水瓶的标签:
function repeat()
{
rinse();
lather();
repeat();
}
这样做的问题是没有终止条件,并且递归将无限期重复,或者直到您用完洗发水或热水(外部终止条件,类似于吹干堆栈)为止。
rinse()
之后lather()
我认为这个非常简单的方法应该可以帮助您理解递归。该方法将自行调用,直到满足特定条件为止,然后返回:
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循环是否基本相同。好吧,当您必须为循环嵌套而又不知道嵌套的深度时,通常会使用递归。例如,当从嵌套数组中打印出项目时:
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
如您所见,递归方法要好得多。
实际上,您使用递归来减少手头问题的复杂性。应用递归,直到达到可以轻松解决的简单基本情况。这样,您可以解决最后的递归步骤。并通过所有其他递归步骤解决您的原始问题。
我将举一个例子来解释。
你知道什么!手段?如果不是,请访问: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
希望对你有帮助
递归
方法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...
}
}
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中进行更多练习。
查看构建递归函数的真正数学方法如下:
1:假设您有一个对f(n-1)正确的函数,请构建f使f(n)正确。2:构建f,以使f(1)正确。
这样可以在数学上证明该函数是正确的,称为归纳法。它等效于具有不同的基本情况,或更复杂的功能涉及多个变量)。也等同于想象f(x)对于所有x都是正确的
现在来看一个“简单”的例子。建立一个函数,该函数可以确定是否有可能使5分和7分的硬币组合成x分。例如,按2x5 + 1x7可以有17美分,但不可能有16美分。
现在,假设您有一个函数告诉您是否有可能创建x cents(只要x <n)。调用此函数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替代can_create_coins_small,给出:
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;
}
可以证明,使用称为无限下降的方法,该函数将始终返回,但这在此不是必需的。您可以想象f(n)仅调用n的较低值,并且最终总是达到0。
要使用此信息来解决您的河内塔问题,我认为诀窍是假设您具有将n-1个平板电脑从a移至b(对于任何a / b)的功能,试图将n个表格从a移至b 。
Common Lisp中的简单递归示例:
MYMAP将函数应用于列表中的每个元素。
1)空列表没有元素,因此我们返回空列表-()和NIL均为空列表。
2)将函数应用于第一个列表,为列表的其余部分调用MYMAP(递归调用),并将两个结果合并为一个新列表。
(DEFUN MYMAP (FUNCTION LIST)
(IF (NULL LIST)
()
(CONS (FUNCALL FUNCTION (FIRST LIST))
(MYMAP FUNCTION (REST LIST)))))
让我们看一下跟踪的执行。输入功能时,将打印参数。退出功能时,将打印结果。对于每个递归调用,输出将在级别上缩进。
本示例在列表(1 2 3 4)中的每个数字上调用SIN函数。
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)
子级隐式使用递归,例如:
我们到了吗?
我们到了吗?(很快)
我们到了吗(差不多...)
我们到了吗(SHHHH)
我们到了吗?(!!!!!)
这时孩子睡着了...
此倒计时功能是一个简单的示例:
function countdown()
{
return (arguments[0] > 0 ?
(
console.log(arguments[0]),countdown(arguments[0] - 1)) :
"done"
);
}
countdown(10);
适用于软件项目的霍夫施塔特定律也很重要。
根据乔姆斯基的说法,人类语言的本质是有限的大脑产生他认为是无限语法的能力。这样,他不仅意味着我们可以说的内容没有上限,而且我们的语言所拥有的句子数量没有上限,任何特定句子的大小也没有上限。乔姆斯基声称,人类语言所有这些创造力的基础工具是递归:递归一个短语在另一个相同类型的短语中的能力。如果我说“约翰兄弟的房子”,我有一个名词“房子”,它出现在名词短语“兄弟的房子”中,而该名词短语出现在另一个名词短语“约翰的兄弟房子”中。这很有意义,而且
参考资料
使用递归解决方案时,我总是尝试:
另外,递归解决方案的类型也不同,存在分而治之的方法,对分形和许多其他方法很有用。
如果您可以先解决更简单的问题,这也将有所帮助。一些示例正在求解阶乘并生成第n个斐波那契数。
作为参考,我强烈推荐Robert Sedgewick的Algorithms。
希望能有所帮助。祝好运。
递归函数就像是弹簧,您在每次调用时都会压缩一点。在每个步骤中,您都将一些信息(当前上下文)放在堆栈上。到达最后一步时,弹簧松开,立即收集所有值(上下文)!
不确定这个比喻是否有效... :-)
无论如何,除了经典的例子(因式子效率低下且易于弄平,阶乘是最糟糕的例子,斐波那契,河内...)-这是人为的(我很少,如果有的话,在实际的编程案例中很少使用它们),有趣的地方是真正使用它。
一个很常见的情况是走一棵树(或一棵图,但一般来说树是更常见的)。
例如,文件夹层次结构:要列出文件,请对其进行迭代。如果找到子目录,则列出文件的函数会使用新文件夹作为参数来调用自身。从列出此新文件夹(及其子文件夹!)回来时,它将恢复其上下文,直到下一个文件(或文件夹)。
另一个具体情况是在绘制GUI组件的层次结构时:通常有容器(如窗格)来容纳也可以是窗格的组件或复合组件等。绘制例程递归地调用每个组件的绘制函数,调用其拥有的所有组件的绘画功能,等等。
不知道我是否很清楚,但是我想展示现实世界中对教材的使用,因为这是我过去曾绊脚石。
想想工蜂。它试图做蜂蜜。它会尽力而为,并期望其他工蜂能够吸收其余的蜂蜜。当蜂窝装满时,它会停止。
认为它是魔术。您有一个与要尝试实现的函数同名的函数,当您将其赋予子问题时,它会为您解决该问题,而您唯一需要做的就是将零件的解决方案与它的解决方案集成在一起给你。
例如,我们要计算列表的长度。让我们调用函数magical_length和带有magical_length的魔术助手我们知道,如果给不具有第一个元素的子列表,它将通过魔术给我们提供子列表的长度。然后,我们唯一需要考虑的就是如何将这些信息与我们的工作相结合。第一个元素的长度为1,magic_counter为我们提供了子列表n-1的长度,因此总长度为(n-1)+ 1-> n
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