递归从入门到精通

递归——初学者学习语言的最大障碍之一

递归是真的难懂,我每次都是绕进去,然后绕不出来,一个程序能做到这样,我服!!!

我们看一段文字先欣赏一下递归的轮廓,如果看完不懂,那就看完全文后再看这段文字!

对递归最恰当的比喻,就是查词典。我们查词典的过程,本身就是递归。想象用一本纯英文词典查单词,要查某一个单词的意思,翻到这个单词时,看解释,发现解释中有一个单词不认识,所以,无法明白这个要查的单词是什么意思;这时,再用这本词典(函数本身)查那个不认识的单词,又发现查的第2个单词的解释中又有一个单词不认识,那么,又再用这本词典查第3个不认识的单词,这样,一个一个查下去,直到解释中所有单词都认识,这样就到底了,就明白了最后一个单词是什么意思,然后一层一层倒回来,就知道我最初想查的第1个单词是什么意思了,问题就解决了。

为了理解透这个知识点,我打算分几个角度讲述这一知识点,力争面面俱到,图文并茂。

 

首先,我们先理解一下递归的概念。

递归(Recursion):函数调用自己。

递归算法解决问题的特点:   

  1. 递归就是方法里调用自身。   
  2. 在使用递增归策略时,必须有一个明确的递归结束条件,称为递归出口。                 
  3. 递归算法解题通常显得很简洁,但递归算法解题的运行效率较低。所以一般不提倡用递归算法设计程序。
  4. 在递归调用的过程当中系统为每一层的返回点、局部量等开辟了栈来存储。递归次数过多容易造成栈溢出等,所以一般不提倡用递归算法设计程序。

 

好吧,一下子说这些概念也没意思,我们来讲解一下递归的过程吧。

具体地说,如果递归函数调用自己,则被调用的函数也将调用自己,这将无限循环下去,除非代码中包含终止调用链的内容。通常的方法将递归调用放在if语句中。例如,void类型的递归函数recurs()的代码如下:

void recurs(argumentlist){
	statements1
	if(test)      //test最终为false,调用链将断开
		recurs(arguments)
	statements2
} 

下面我们用文字再现这段代码块的内容。只要if语句为true,每个recurs()调用都将执行statement1,然后再调用recurs(),而不会执行statements2 。当前调用结束后,程序控制权将返回给调用它的recurs(),而该recurs()将执行其statements2部分,然后结束,并将控制权返回给前一个调用,依次类推。

光有文字说服力不够,我们来看一个类似此递归的过程图:

因此,如果recurs()进行了5次递归调用,则第一个statements1部分将按函数调用的顺序执行5次,然后statements2部分将以与函数调用相反的顺序执行5次。进入5层递归后,程序将沿进入的路径返回。下面这段程序演示了这种行为:

// recur.cpp -- using recursion
#include <iostream>
void countdown(int n);

int main()
{
    countdown(4);           // call the recursive function
    return 0;
}

void countdown(int n)
{
    using namespace std;
    cout << "Counting down ... " << n << endl;
    if (n > 0)
        countdown(n-1);     // function calls itself
    cout << n << ": Kaboom!\n";
}

该程序的输出如下:

Counting down ... 4    < level 1;adding levels of recursion

Counting down ... 3    < level 2

Counting down ... 2    < level 3

Counting down ... 1    < level 4

Counting down ... 0    < level 5;final recursive call(调用)

0: Kaboom!          < level 5;beginning to back out

1: Kaboom!          < level 4

2: Kaboom!          < level 3

3: Kaboom!          < level 2

4: Kaboom!            < level 1

注意,每个递归调用都创建自己的一套变量,因此当程序到达第5次调用时,将有5个独立的n变量,其中每个变量的值都不同。

我们修改一下上段的程序来验证这一观点。

// recur.cpp -- using recursion
#include <iostream>
void countdown(int n);

int main()
{
    countdown(4);           // call the recursive function
    return 0;
}

void countdown(int n)
{
    using namespace std;
    cout << "Counting down ... " << n << " (n at " << &n << ")" << endl;
    if (n > 0)
        countdown(n-1);     // function calls itself
    cout << n << ": Kaboom!" << "          (n at " << &n << ")" <<endl;
}
View Code

 程序输出:

注意,在一个内存单元(内存地址为0x6cfed0),存储的n值为4;在另一个内存单元(内存地址为0012FD34),存储的n值为3;等等。另外,注意到Counting down阶段和Kaboom阶段的相同层级,n地址相同。

上面的程序中只有一个递归,而我们在创建二叉树或其他操作时往往用到两个递归,那么我们再介绍一下包含多个递归调用的递归

在需要将一项工作不断分为两项较小的、类似的工作时,递归非常有用。例如,请考虑使用这种方法来绘制标尺的情况。标出两端,找到中点并将其标出。然后将同样的操作作用于标尺的左半部分和右半部分。如果要进一步细分,可将同样的操作用于当前的每一部分。递归方法有时被称为分而治之策略。

下面这段程序使用递归函数subdivide()演示了上面讲的方法,该函数使用一个字符串,该字符串除两端为 | 字符外,其他全部为空格。main函数使用循环调用subdivide()函数6次,每次将递归层编号加1,并打印得到的字符串。这样,每行输出表示一层递归。

// ruler.cpp -- using recursion to subdivide a ruler
#include <iostream>
using namespace std; const int Len = 66; const int Divs = 6; void subdivide(char ar[], int low, int high, int level); int main() { char ruler[Len]; int i; for (i = 1; i < Len - 2; i++) ruler[i] = ' '; ruler[Len - 1] = '\0'; int max = Len - 2; int min = 0; ruler[min] = ruler[max] = '|'; cout << ruler << endl; for (i = 1; i <= Divs; i++){ subdivide(ruler,min,max, i); cout << ruler << endl; for (int j = 1; j < Len - 2; j++) ruler[j] = ' '; // reset to blank ruler } return 0; } void subdivide(char ar[], int low, int high, int level) { if (level == 0) return; int mid = (high + low) / 2; ar[mid] = '|'; subdivide(ar, low, mid, level - 1); subdivide(ar, mid, high, level - 1); }

subdivide()函数使用变量level来控制递归层。函数调用自身时,将把level减1,当level为0时,该函数将不再调用自己。注意,subdivide()调用自己两次,一次针对左半部分,另一次针对右半部分最初的中点被用作一次调用的右端点另一次调用的左端点。请注意,调用次数将呈几何级数增长,也就是说,调用一次导致两个调用,然后导致4个调用,再导致8个调用,依次类推。这就是6层调用能够填充64个元素的原因(26=64)。这将不断导致函数调用数(以及存储的变量数)翻倍,因此如果要求的递归层次很多,这种递归方式将是一种糟糕的选择;然而,如果递归层次较少,这将是一种精致的而简单的选择。

程序输出:

 上面具体讲解了递归的过程和实现细节,我们似乎有一种似懂非懂的feeling,这就对了,递归这么难理解的东西,休想这么快掌握。

下面,我们再探讨递归的细节。

常常有书本将递归定义为死循环,比如像这样的定义:

递归:

参见“递归”。

虽然我们能明白这个定义的暗含出口是“我们懂了递归的定义不再看了”,但是文字上还是一个死循环,当然,这也算递归的一种——无限递归。

虽然上面说是死循环,但是循环和递归完全不是一个概念:递归是要层层嵌套,而循环只是一次次的重复而已。它们的层次不一样。

我们来讨论一般的递归求解思路:

  • 写出递归函数也就是要处理好递归的3个主要的点:

    a)出口条件,即递归“什么时候结束”,这个通常在递归函数的开始就写好;

    b) 如何由"情况n" 变化到"情况n+1",也就是非出口情况,也就是一般情况——"正在"递归中的情况;

    c) 初始条件,也就是这个递归调用以什么样的初始条件开始

可以说,上述a,b,c三个条件组成了我们的递归函数。解决好上述3点,也就很容易地写出一个递归函数。剩下的就是去学习学习“数学归纳法”,请自己google之,不需要你成为归纳法专家,但只需要认证体会它的思路,对于你理解和创造递归函数有很大帮助。

下面引用一个大牛对学习递归的理解:

首先是思想方法上要转变,不要试图解决问题(这是常规的思考方式),而应该“鼠目寸光”地只想解决一点点,要点是,解决一点点之后,剩下来的问题还是原来的问题,但规模要比原问题小了。
思想和语言是密切相关的,所以问题的提法也很重要。一个问题这样提可能感觉很难写出递归,换种提法,可能就写出来了。
平时就要注意用递归的方式思考,譬如什么是链表?以递归来看就是一个指针,指向一个含有链表的指针。一旦你用这种方式看待链表,你会发现写链表的代码非常容易。反之,则非常容易拖泥带水。
从代码层面看,递归就是函数的循环,所以只要透彻理解函数,写递归代码没什么难的。

如果到了现在,你还是不能吃透递归的过程,那么只能出绝招了:

在用递归的时候,将它当成一个黑盒子,不要老是想着递归的方法,而是只是把递归函数当成一个普通的调用函数,对这个函数,你需要知道他的输入与输出。

比如最简单的n的阶乘的递归实现,代码如下:

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

你在写这个函数的时候,把函数定义部分代码蒙起来,主要看这部分代码:

if(n == 1){ 		// 如果求1的阶乘,那么就直接返回
	return n;
} 
else{ 				// n! = n * (n-1)! 这个数学等式
    return n * factorial(n-1);
}

那这样看代码,你懂了吗?
如果你觉得还不懂的话,且听我下面说完。

不谈性能上面的影响,递归的使用能让你的代码变得简单清晰,逻辑变得易懂。就比如下面这段代码,用非递归的方式实现阶乘:

function factorial (n){
	var result = 1;
	for (var i = n; i > 0; i--){
    	result *= i;
  	}
  	return result;
}
  这个函数与前面的递归函数求值是一样的,但是在函数逻辑上面差别非常大。
  在递归函数里面,运用了一部分的数学逻辑在里面,能清晰看见数学等式,而非递归实现里面数学逻辑其实是隐藏起来的,是编码人员在知道数学逻辑的情况下实现,而没有体现在代码层面上。这样在数学逻辑变动的时候,维护起来是不太好的。
 
 
下面我们来补充递归最后一方面的知识点。递归与栈。
  看了《算法竞赛入门经典》后,刘汝佳对认识递归给出了他的见解。要想学会递归,必先学会调用栈。
我们学习for循环时的方法是,多演示程序执行的过程,把注意力集中在“当前代码行”的转移和变量值的变化。这是一个很不错的学习方法,对于我们学习函数同样适用,只是要增加一项内容——调用栈。
  调用栈描述的是函数之间的调用关系。它由多个栈帧组成,每个栈帧对应着一个未运行完的函数。栈帧中保存了该函数的返回地址局部变量,因而不仅能在函数执行完毕后找到正确的返回地址,还很自然地保证了不同函数间的局部变量互不相干(因为不同函数对应着不同的栈帧)。
小结:C语言用调用栈来描述函数之间的调用关系。调用栈由栈帧组成,每个栈帧对应着一个未运行完的函数。在gdb中可以用backtrace(简称bt)命令打印所有栈帧信息。若要用p命令打印一个非当前栈帧的局部变量,可以用frame命令选择另一个栈帧。
//gdb是一个功能强大的源码级调试器,虽然是基于命令的文本界面,但运用熟练后非常方便。
我们看这一段即将被调试的程序:
#include<stdio.h>

void swap(int a, int b){
    int t = a;  a = b;  b = t;
}

int main(){
    int a = 3, b = 4;
    swap(3,4);
    printf("%d %d\n",a,b);
    return 0;
}
被调试的程序

  我们调试该程序时,除了关心“当前代码行”和变量的变化之外,再看看调用栈的变化。特别是在执行完swap函数的主体但还没有返回main函数之前,先看一下swap和mian函数所对应的栈帧中a和b的值

下面给出用gdp完成上述操作的命令和结果:

第一步:编译程序。

gcc swap.c -std=c99 -g

生成可执行程序a.exe。编译选项-g告诉编译器生成调试信息。编译选项-std=c99告诉编译器按照C99标准编译代码。

第二步:运行gdb。

gdb a.exe

这样,gdb在运行时会自动装入刚才生成的可执行程序

第三步:查看源码。

(gdb) l

1  #include<stdio.h>

2  void swap(int a, int b){

3    int t = a; a = b; b = t;

4  }

5

6  int main(){

7    int a = 3, b = 4;

8    swap(3,4);

9    printf("%d %d\n",a,b);

10    return 0;

这里(gdb)是gdb的提示符字母l是输入的命令,为list(列出程序清单)的缩写。正如代码所示,swap函数的最后一行是第4行,当执行到这一行时,swap函数的主体已经结束,但函数还没有返回
第四步:加断点并运行。
(gdb) b 4
Breakpoint 1 at 0x401308: file swap.c, line 4.
(gdb) r
Starting program: D:\a.exe
Breakpoint 1, swap (a=4,b=3) at swap.c:4
4  }
其中,b命令把断点设在了第4行r命令运行程序,之后碰到了断点并停止
第五步:查看调用栈。
(gdb) bt
#0  swap (a=4,b=3) at swap.c:4
#1  0x00401356 in main () at swap.c:8
(gdb) p a
$1 = 4
(gdb) p b
$2 = 3
(gdb) up
#1  0x00401356 in main () at swap.c:8
8    swap(3,4);
(gdb) p a
$3 = 3
(gdb) p b
$4 = 4
  这一步是关键。根据bt命令,调用栈中包含两个栈帧:#0和#1,其中0号是当前栈帧——swap函数,1号是其“上一个”栈帧——main函数。这里甚至能看到swap函数的返回地址0x00401356,尽管不明确其具体含义。
  使用p命令可以打印变量值。首先查看当前栈帧中a和b的值,分别等于4和3——则正是用三变量法交换后的结果。接下来用up命令选择上一个栈帧,再次使用p命令查看a和b的值,这次却得到了3和4,为main函数中的a和b。前面讲过,在函数调用时,a、b只起到了“计算实参”的作用。但实参被赋值到形参之后,main函数中的a和b也完成了它们的使命。swap函数甚至无法知道main函数中也有着和形参同名的a和b变量,当然也就无法对其进行修改。最后要用q命令退出gdb
之所以要详细地解释调用栈和栈帧,是因为理解它们对今后的学习和编程是至关重要的,特别是递归——初学者学习语言的最大障碍之一,调用栈将有助于理解。
了解了上一个程序不能成功交换两数,我们再来一个正确的程序:
#include<stdio.h>
void swap(int* a, int* b){
    int t = *a;  *a = *b;  *b = t;
}

int main(){
    int a = 3, b = 4;
    swap(&a,&b);
    printf("%d %d\n",a,b);
    return 0;
}
修改后的程序
我们再用gdb来调试这个程序,它的前四步和上面是一样的,我们可直接看调用栈。
(gdb) bt
#0  swap (a=0x22ff74,b=0x22ff70) at swap2.c:4
#1  0x0040135c in main () at swap2.c:8
(gdb) p a
$1 = (int *) 0x22ff74
(gdb) p b
$2 = (int *) 0x22ff70
(gdb) p *a
$3 = 4
(gdb) p *b
$4 = 3
(gdb) up
#1  0x0040135c in main () at swap2.c:8
8    swap(&a,&b);
(gdb) p a
$5 = 4
(gdb) p b
$6 = 3
(gdb) p &a
$7 = (int *) 0x22ff74
(gdb) p &b
$8 = (int *) 0x22ff70
  在打印a和b的值时,得到了诡异的结果——(int *)0x22ff74和(int *)0x22ff70。数值0x22ff74和0x22ff70是两个地址(以0x开头的整数以十六进制表示,在这里暂时不需了解细节),而前面的(int *)表明a和b是指向int类型的指针。在swap程序中,a和b都是局部变量,在函数执行完毕以后就不复存在了,但是a和b里保存的地址却依然有效——它们是main函数中的局部变量a和b的地址。在main函数执行完毕之前,这两个地址将始终有效并且分别指向main函数的局部变量a和b。程序交换的是*a和*b,也就是main函数中的局部变量a和b。
 
讲完了调用栈,我们便可以轻松地进入递归的学习了。
关于递归,我们首先看递归定义。
递归的定义如下:
  递归:
  参见“递归”。
  什么?这个定义什么也没有说啊!好吧,改一下:
  递归:
  如果还是没明白递归是什么意思,参见“递归”。
到这句话我们就懂了,看来递归就是“自己用到自己”的意思。这个定义显然比上一个要好些,因为当我们终于明白递归是什么意思后,就不必继续“参见”下去了。但请记住,递归的含义比这要广泛。
  A经理:“这事不归我管,去找B经理。”于是你去找B经理。
  B经理:“这事不归我管,去找A经理。”于是你又回到A经理这儿。
接下来发生的事情就不难想到了。只要两个经理的说辞不变,你又始终听话,你将会永远往返于两个经理之间。这叫做无限递归。尽管在这里,A经理并没有让你找他自己,但还是回到了他这里。换句话说,“间接地用到自己”也算递归。
递归,即函数可以直接或间接地调用自己。但要注意为递归函数编写终止条件,否则将产生无限递归。
 
下面我们看看调用栈对递归的支持。
看一道数学函数的递归定义,阶乘函数f(n)=n!可以定义为:
对应的程序为:
#include<stdio.h>
int f(int n){
    return n == 0 ? 1 : f(n-1)*n;
}

int main(){
    printf("%d\n",f(3));
    return 0;
}
尽管从概念上可以理解阶乘的递归定义,但在C语言中函数为什么真的可以“自己调用自己”呢?下面我们借助gdb来调试这段程序。
首先bf命令设置断点——除了可以按行号设置外,也可以直接给出函数名,断点将设置在函数的开头。下面用r命令运行程序,并在断点处停下来。接下来用s命令单步执行:
(gdb) r
Starting program: C:\a.exe
 
Breakpoint 1, f (n=3) factorial.c:3
3   return n == 0 ? 1 : f(n-1)*n;
(gdb) s
 
Breakpoint 1, f (n=2) factorial.c:3
3   return n == 0 ? 1 : f(n-1)*n;
(gdb) s
 
Breakpoint 1, f (n=1) factorial.c:3
3   return n == 0 ? 1 : f(n-1)*n;
(gdb) s
 
Breakpoint 1, f (n=0) factorial.c:3
3   return n == 0 ? 1 : f(n-1)*n;
(gdb) s
4    }
看到了吗?在第一个断点处,n=3(3是main函数中的调用参数),接下来将调用f(3-1),即f(2),因此单步一次后显示n=2。由于n==0仍然不成立,继续递归调用,直到n=0。这时不再递归调用了,执行一次s命令以后会到达函数的结束位置。
接下来做什么?没错!好好看看下面的调用栈吧!
(gdb) bt
#0 f (n=0) at factorial.c:4
#1 0x00401308 in f (n=1) at factorial.c:3
#2 0x00401308 in f (n=2) at factorial.c:3
#3 0x00401308 in f (n=3) at factorial.c:3
#4 0x00401359 in main () at factorial.c:6
(gdb) s
4    }
(gdb) bt
#0 f (n=1) at factorial.c:4
#1 0x00401308 in f (n=2) at factorial.c:3
#2 0x00401308 in f (n=3) at factorial.c:3
#3 0x00401359 in main () at factorial.c:6
(gdb) s
4    }
(gdb) bt
#0 f (n=2) at factorial.c:4
#1 0x00401308 in f (n=3) at factorial.c:3
#2 0x00401359 in main () at factorial.c:6
(gdb) s
4    }
(gdb) bt
#0 f (n=3) at factorial.c:4
#1 0x00401359 in main () at factorial.c:6
(gdb) s
6
main () at factorial.c:7
7  return 0;
(gdb) bt
#0 main () at factorial.c:7
  每次执行完s指令,都会有一层递归调用终止,直到返回main函数。事实上,如果在递归调用初期查看调用栈,则会发现每次递归调用都会多一个栈帧——和普通的函数调用并没有什么不同。确实如此。由于使用了调用栈,C语言自然支持了递归。在C语言的函数中,调用自己和调用其它函数并没有什么本质区别,都是建立新栈帧,传递参数并修改当前代码行。在函数体执行完毕后删除栈帧,处理返回值并修改当前代码行。
下面举个例子加强理解。
皇帝(拥有main函数的栈帧):大臣,你给我算一下f(3)。
大臣(拥有f(3)的栈帧):知府,你给我算一下f(2)。
知府(拥有f(2)的栈帧):县令,你给我算一下f(1)。
县令(拥有f(1)的栈帧):师爷,你给我算一下f(0)。
师爷(拥有f(0)的栈帧):回老爷,f(0)=1。
县令(心算f(1)=f(0)*1=1):回知府大人,f(1)=1。
知府(心算f(2)=f(1)*2=2):回大人,f(2)=2。
大臣(心算f(3)=f(2)*3=6):回皇上,f(3)=6。
皇帝满意了。
  通过这个例子可以说明一些问题。递归调用时新建了一个栈帧,并且跳转到了函数开头处执行,就好比皇帝找大臣、大臣找知府这样的过程。尽管同一时刻可以有多个栈帧(皇帝、大臣、知府同时处于“等待下级回话”的状态),但“当前代码行”只有一个。
  如果理解了这个比喻但仍不理解调用栈,不必强求,知道递归为什么能正常工作即可。设计递归程序的重点在于给下级安排工作
最后补充:调用栈并不储存在可执行文件中,而是在运行时创建。调用栈所在的段称为堆栈段,它有自己的大小,如果调用次数多了,就会产生若干个栈帧,便会发生越界,这种情况称为栈溢出。由于局部变量也是放在堆栈段的,所以局部变量太大也会造成栈溢出,这就是为什么要把较大的数组放在main函数外的原因。
 
最后,我们来做一道题加深对递归的理解。
【经典汉诺塔问题】
汉诺塔问题是源于印度一个古老传说的益智玩具。大梵天创造世界的时候做了三根金刚石柱子,在一根柱子上从下往上按照大小顺序摞着64片黄金圆盘。大梵天命令婆罗门把圆盘从下面开始按大小顺序重新摆放在另一根柱子上。并且规定,在小圆盘上不能放大圆盘,在三根柱子之间一次只能移动一个圆盘。

现在有n个圆盘从上往下从小到大叠在第一根柱子上,每次移动一个圆盘,现要把这些圆盘全部移动到第三根柱子(移动的时候始终只能小圆盘在大圆盘上面),请找出需要移动次数最少的方案。

【解决过程】

我们可以将问题简化描述为:n个盘子和3根柱子:A(源)、B(备用)、C(目的),盘子的大小不同且中间有一孔,可以将盘子“串”在柱子上,每个盘子只能放在比它大的盘子上面。起初,所有盘子在A柱上,问题是将盘子一个一个地从A柱子移动到C柱子。移动过程中,可以使用B柱,但盘子也只能放在比它大的盘子上面。

因此我们得出汉诺塔问题的以下几个限制条件:

  1. 在小圆盘上不能放大圆盘;
  2. 在三根柱子之间一回只能移动一个圆盘;
  3. 只能移动在最顶端的圆盘

首先,我们从简单的例子开始分析,然后再总结出一般规律。

当n = 1的时候,即此时只有一个盘子,那么直接将其移动至C即可。移动过程就是 A -> C

当n = 2的时候,这时候有两个盘子,那么在一开始移动的时候,我们需要借助B柱作为过渡的柱子,即将A柱最上面的那个小圆盘移至B柱,然后将A柱底下的圆盘移至C柱,最后将B柱的圆盘移至C柱即可。那么完整移动过程就是A -> B , A -> C , B -> C

当n = 3的时候,那么此时从上到下依次摆放着从小到大的三个圆盘,根据题目的限制条件:在小圆盘上不能放大圆盘,而且把圆盘从A柱移至C柱后,C柱圆盘的摆放情况和刚开始A柱的是一模一样的。所以呢,我们每次移至C柱的圆盘(移至C柱后不再移到其他柱子上去),必须是从大到小的,即一开始的时候,我们应该想办法把最大的圆盘移至C柱,然后再想办法将第二大的圆盘移至C柱......然后重复这样的过程,直到所有的圆盘都按照原来A柱摆放的样子移动到了C柱。

那么根据这样的思路,问题就来了:

如何才能够将最大的盘子移至C柱呢?

那么我们从问题入手,要将最大的盘子移至C柱,那么必然要先搬掉A柱上面的n-1个盘子,而C柱一开始的时候是作为目标柱的,所以我们可以用B柱作为"暂存"这n-1个盘子的过渡柱,当把这n-1的盘子移至B柱后,我们就可以把A柱最底下的盘子移至C柱了。

而接下来的问题是什么呢?

我们来看看现在各个柱子上盘子的情况,A柱上无盘子,而B柱从上到下依次摆放着从小到大的n-1个盘子,C柱上摆放着最大的那个盘子。

所以接下来的问题就显而易见了,那就是要把B柱这剩下的n-1个盘子移至C柱,而B柱作为过渡柱,那么我们需要借助A柱,将A柱作为新的"过渡"柱,将这n-1个盘子移至C柱

根据上面的分析,我们可以抽象得出这样的结论:

汉诺塔函数原型:

void Hanio(int n,char start_pos,char tran_pos,char end_pos)  

那么我们把n个盘子从A柱移动至C柱的问题可以表示为:

Hanio(n,A,B,C);

那么从上面的分析得出:

该问题可以分解成以下子问题:

第一步:将n-1个盘子从A柱移动至B柱(借助C柱为过渡柱

第二步:将A柱底下最大的盘子移动至C柱

第三步:将B柱的n-1个盘子移至C柱(借助A柱为过渡柱

因此完整代码如下所示:

#include<cstdio>
int i;    //记录步数
//i表示进行到的步数,将编号为n的盘子由from柱移动到to柱(目标柱)
void move(int n,char from,char to){
    printf("第%d步:将%d号盘子%c---->%c\n",i++,n,from,to);
}

//汉诺塔递归函数
//n表示要将多少个"圆盘"从起始柱子移动至目标柱子
//start_pos表示起始柱子,tran_pos表示过渡柱子,end_pos表示目标柱子
void Hanio(int n,char start_pos,char tran_pos,char end_pos){
    if(n==1){    //很明显,当n==1的时候,我们只需要直接将圆盘从起始柱子移至目标柱子即可.
        move(n,start_pos,end_pos);
    }
    else{
        Hanio(n-1,start_pos,end_pos,tran_pos);   //递归处理,一开始的时候,先将n-1个盘子移至过渡柱上
        move(n,start_pos,end_pos);                //然后再将底下的大盘子直接移至目标柱子即可
        Hanio(n-1,tran_pos,start_pos,end_pos);    //然后重复以上步骤,递归处理放在过渡柱上的n-1个盘子
                                                  //此时借助原来的起始柱作为过渡柱(因为起始柱已经空了)
    }
}
int main(){
    int n;
    while(scanf("%d",&n)==1&&n){
        i = 1;   //全局变量赋初始值
        Hanio(n,'1','2','3');
        printf("最后总的步数为%d\n",i-1);
    }
    return 0;
}
View Code

 运行结果如下:

从运行后的结果我们也可以发现:对于n个盘子,移动的总步数为2n - 1

体会:递归问题是个抽象的问题,因为人大脑堆栈是有限的,想象不出来运行效果,因此我们只需要枚举前几个实例,然后寻找其中规律即可。当n值增大时,只是复杂度发生了改变,实际上函数的递归调用还是一样的。慢慢来,说不定哪天自己理解透了呢。

 
 
最后搬运一篇别人的博客。
内容主要是他学习递归的过程。
//博客原文地址: 怎么更好地终极理解递归算法
  递归真是个奇妙的思维方式。对一些简单的递归问题,我总是惊叹于递归描述问题和编写代码的简洁。但是总感觉没能融会贯通地理解递归,有时尝试用大脑去深入“递归”,层次较深时便常产生进不去,出不来的感觉。这种状态也导致我很难灵活地运用递归解决问题。有一天,我看到一句英文:“To Iterate is Human, to Recurse, Divine.”中文译为:“人理解迭代,神理解递归。”然后,我心安理得地放弃了对递归的深入理解。直到看到王垠谈程序语言最精华的原理时提到了递归,并说递归比循环表达能力强很多,而且效率几乎一样。再次唤醒了我对递归的理解探索。

我首先在知乎上发现了下面两个例子,对比了递归和循环。

  递归:你打开面前这扇门,看到屋里面还有一扇门(这门可能跟前面打开的门一样大小(静),也可能门小了些(动)),你走过去,发现手中的钥匙还可以打开它,你推开门,发现里面还有一扇门,你继续打开,..., 若干次之后,你打开面前一扇门,发现只有一间屋子,没有门了。你开始原路返回,每走回一间屋子,你数一次,走到入口的时候,你可以回答出你到底用这钥匙开了几扇门。

  循环:你打开面前这扇门,看到屋里面还有一扇门,(这门可能跟前面打开的门一样大小(静),也可能门小了些(动)),你走过去,发现手中的钥匙还可以打开它,你推开门,发现里面还有一扇门,(前面门如果一样,这门也是一样,第二扇门如果相比第一扇门变小了,这扇门也比第二扇门变小了(动静如一,要么没有变化,要么同样的变化)),你继续打开这扇门,...,一直这样走下去。 入口处的人始终等不到你回去告诉他答案。

该用户这么总结到:递归就是有去(递去)有回(归来)。

具体来说,为什么可以”有去“? 

  这要求递归的问题需要是可以用同样的解题思路来回答除了规模大小不同其他完全一样的问题。

为什么可以”有回“?

  这要求这些问题不断从大到小,从近及远的过程中,会有一个终点,一个临界点,一个baseline,一个你到了那个点就不用再往更小,更远的地方走下去的点,然后从那个点开始,原路返回到原点。

上面的解释几乎回答了我已久的疑问:为什么我老是有递归没有真的在解决问题的感觉?

  因为递是描述问题,归是解决问题。而我的大脑容易被递占据,只往远方去了,连尽头都没走到,何谈回的来。

《漫谈递归:递归的思想》这篇文章将递归思想归纳为:

  递归的基本思想是把规模大的问题转化为规模小的相似的子问题来解决。在函数实现时,因为解决大问题的方法和解决小问题的方法往往是同一个方法,所以就产生了函数调用它自身的情况。另外这个解决问题的函数必须有明显的结束条件,这样就不会产生无限递归的情况了。

  需注意的是,规模大转化为规模小是核心思想,但递归并非是只做这步转化,而是把规模大的问题分解为规模小的子问题和可以在子问题解决的基础上剩余的可以自行解决的部分。而后者就是归的精髓所在,是在实际解决问题的过程。

我试图把我理解到递归思想用递归用程序表达出来,确定了三个要素:递 + 结束条件 + 归。

function recursion(大规模)
{
	if(end_condition){
    	end;     
	}
    else{     //先将问题全部描述展开,再由尽头“返回”依次解决每步中剩余部分的问题
        recursion(小规模);     //go;
        solve;                 //back;
    }
}
但是,我很容易发现这样描述遗漏了我经常会遇到的一种递归情况,比如递归遍历的二叉树的先序。
我将这种情况用如下递归程序表达出来:
function recursion(大规模)
{
	if(end_condition){
        end;     
    }
    else{     		//在将问题转换为子问题描述的每一步,都解决该步中剩余部分的问题。
        solve;                 //back;
        recursion(小规模);     //go;
    }
}

总结到这里,我突然发现递归是为了最能表达这种思想,所以用“递归”这个词,其实递归可以是“有去有回”,也可以是“有去无回”。但其根本是“由大往小地去,由近及远地去”。“递”是必需,“归”并非必需,依赖于要解决的问题,有的需要去的路上解决,有的需要回来的路上解决。有递无归的递归其实就是我们很容易理解的一种分治思想。

其实理解递归可能没有“归”,只有去(分治)的情况后,我们应该想到递归也许可以既不需要在“去”的路上解决问题,也不需要在“归”的路上解决问题,只需在路的尽头解决问题,即在满足停止条件时解决问题。递归的分治思想不一定是要把问题规模递归到最小,还可以是将问题递归穷举其所有的情形,这时通常递归的表达力体现在将无法书写的嵌套循环(不确定数量的嵌套循环)通过递归表达出来。

将这种递归情形用递归程序描述如下:

recursion()
{
    if(end_condition){
        solve;     
    }
    else{     //在将问题转换为子问题描述的每一步,都解决该步中剩余部分的问题。
        for(){ 
			recursion();     	//go
		}
    }
}

由这个例子,可以发现这种递归对递归函数参数出现了设计要求,即便递归到尽头,组合的字符串规模(长度)也没有变小,规模变小的是递归函数的一个参数。可见,这种变化似乎一下将递归的灵活性大大地扩展了,所谓的大规模转换为小规模需要有一个更为广义的理解了。

对递归的理解就暂时到这里了,可以看出文章中提到关于“打开一扇门”的递归例子来解释递归并不准确,例子只描述了递归的一种情况。而“递归就是有去(递去)有回(归来)”的论断同样不够准确。要为只读了文章前半部分的读者惋惜了。

我也给出自己对递归思想的总结吧:

递归的基本思想是广义地把规模大的问题转化为规模小的相似的子问题或者相似的子问题集合来解决。广义针对规模的,规模的缩小具体可以是指递归函数的参数,也可以是其参数之一。相似是指解决大问题的方法和解决小问题的方法往往是同一个方法,还可以是指解决子问题集的各子问题的方法是同一个方法。解决大问题的方法可以是由解决次规模问题的方法和解决剩余部分的方法组成,也可以是由一系列解决次规模问题的方法组成。

评论区:
提问:博主好,我认为博主的说法有些不妥,请指正!我认为,递归必然是有去有回的,这是由递归本身的性质所决定的,或者说,这是由递归的定义本身所决定的,因此不存在你所说的“有去无回”之类的情况。至于递归算法在解决问题的时机上,有可能是在递去过程中,也有可能是在归来过程中,还有可能在两个过程中一起解决。请指正~
评论:从程序的角度来看,递归确实是有去有回的。但是从解决具体问题的角度来看,可能递归在递的过程中,已经解决了问题,从而导致了归的非必需。
 
最后我说一句:
光理解递归没用,还需做大量的题目加强自己的认知。主要题目类型为DFS/BFS/树/动规
 

 

转载于:https://www.cnblogs.com/xzxl/p/7364515.html

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值