一切都应该尽可能地简单,但不要太简单 ——阿尔伯特·爱因斯坦
将复杂的问题简单化,抓到本质,能够让我们的编程更愉快。分治策略、动态规划都是如此,将复杂的问题分解成规模小的简单问题。本文介绍的递归,是很多算法都使用的一种方法,让复杂问题简单化,同时能让你的代码更加优雅。
调用栈
讲递归之前,不得不提到调用栈(Call Stack),因为调用栈:
- 使得递归成为可能
- 同时有助于理解递归的执行细节。
自定义函数,使得代码可以复用。从某种程度上说,一个程序的执行过程,就是无数个的函数相互调用,达到实现最终目标的旅程。而这些函数之间的调用关系,就用调用栈来描述,这也是“函数调用栈”前四个字的由来,最后一个字对应LIFO的数据结构栈。
函数需要区分定义(defined)和执行(executed, called)两种情形,以python为例:
- def语句定义一个函数,本质上和赋值语句没有区别,只是bind了一个name到一个value (使得定义递归函数成为可能)
- 函数真正执行时(called), 会创建一个Local Frame, 这个Local Frame维护着该函数内部的局部变量。
函数每执行一次,创建的一个Local Frame,所有的Local Frame使用函数调用栈(Call Stack, LIFO)维护,每个Local Frame对应该栈中的一个元素,里面记录着某个函数在某次执行时的一些局部变量(Local Variable, 函数的入参及函数内部定义的变量)。调用栈,让不同执行函数间的局部变量互相不干扰,使得程序能够正确执行。
函数调用栈在每个编程语言内部实现,使用起来很方便,但每次的函数调用,都会占用一定的内存。如果函数调用次数很多(一直push入栈),并一直不返回结果(没有pop出栈),即Call Stack很大,会占用大量的内存,可能会导致栈溢出,也就是著名的stack overflow。
很多编程比赛中,为追求速度的极致,很多选手采用C、C++语言,很多有经验的选手,通常都将大的数组放在main函数,用全局变量表示。如果放在某个函数内部做为局部变量,即使递归调用次数少,但也可能因为局部变量太大,产生栈溢出。
何为递归
递归,简单说就是一个函数,自己调用自己。
调用栈,使得递归成为可能。递归和其他函数调用并没有本质不同,都是执行到某行,建立新的栈帧,执行完后弹出返回原点继续执行。
递归函数实现中的一个常见模式,以基本情况开始(base case),后面跟着的是递归情况(recursive case)。
- 基本情况:定义了在输入是最简单输入数据下的求解,此时函数不调用自身,避免形成死循环。
- 递归情况:此时函数调用自身,将规模大的原问题(复杂)分解为规模小的子问题(简单)。
理解了调用栈,再理解递归就相当简单。举一个计算阶乘的例子:
![a1f66276fb5378cc182a48e726c48582.png](https://i-blog.csdnimg.cn/blog_migrate/db9fe2da7a8480f7e62d23d5d5708d7a.jpeg)
可以这么理解:
- CEO说(main函数对应的栈帧):总监, 给我算下f(3)
- 总监(f(3)函数对应的栈帧): 经理,给我算下f(2)
- 经理(f(2)函数对应的栈帧): 组长,给我算下f(1)
- 组长(f(1)函数对应的栈帧): 程序员,给我算下f(0)
- 程序员(f(0)函数对应的栈帧):报告组长,f(0) = 1;(f(0)执行完毕,销毁f(0)栈帧)
- 组长:报告经理,f(1) = 1*f(0) = 1,f(1)执行完毕,销毁f(1)栈帧
- 经理: 报告总监, f(2) = 2*f(1) = 2,f(2)执行完毕,销毁f(2)栈帧
- 总监:报告老大, f(3) = 3*f(2) = 6,f(3)执行完毕,销毁f(3)栈帧
- CEO总结: 干得漂亮,main()执行完毕,销毁main()栈帧,退出。
注意上述步骤的粗体部分: f(3) -> f(2) -> f(1) -> f(0) -> f(0) -> f(1) -> f(2) -> f(3), 明显的LIFO的栈结构。
尾递归: 不只要优雅,也要性能
递归让人又爱又恨。爱他的人说:递归让我的代码更优雅;恨他的人说,递归让性能更差。
也有人说,使用递归,代码更加优雅;但使用循环,性能更优。
如果说非递归是普通人,递归是优雅的天使,那么尾递归就是战斗天使,好看又能打。尾递归,是递归的一种特殊形式,需满足下面两个条件:
- 递归调用是整个函数体中最后执行的语句(即递归调用完成后,不需要再执行其他运算)
- 它的返回值不属于表达式的一部分
编译器可以利用尾递归的特性,生成优化的代码,猜测内部用循环的形式实现,而不是建立一个又一个的栈帧。(注:之所以能够优化,是因为调用在尾部,没有必要保存任何局部变量,可以通过循环实现)。
以1+2+3+…+n求累积和为例(不用阶乘,是因为书太大,即使long long也会溢出)。
源码如下:
![319706f2807384b5aaaf151c6e223405.png](https://i-blog.csdnimg.cn/blog_migrate/878bf0ced2fee59e89eb854598f0f7f4.jpeg)
Makefile如下:
![7587d054efb848c36ec733716a75f79b.png](https://i-blog.csdnimg.cn/blog_migrate/7b3cb2cbf3904581038f48eef23a1be4.jpeg)
测试结果如下:
usng optimimized cumsumtco_cumsum(999000)=499000999500cumsum(999000)=499000999500usng default cumsumSegmentation fault
- 默认编译选项: 即"g++ cumsum.cpp -o cumsum