- 用模式
- 同一问题的结构变形
- 多阶段组合
- 分解子问题
- 执行过程
- 进程空间
- 函数的入栈与出栈
- 栈可以模拟任何递归过程
- 递归数据类型
递归
行路难,归去难!
开元二十三年(735年)的长安,唐玄宗亲临五凤楼,恩赐百姓宴饮狂欢,还让三百里之内的地方官带歌舞团进京,在楼前表演竞技。
就在这一年,二十四岁的杜甫考场失利,身上的盘缠抵不上长安的物价,也没有亲友可以长久依靠。
于是,如同现在去一线城市打拼却拼不出一丝光明的年轻人一样,把身上仅有的盘缠去旅行…
走到山东时,看见高耸的泰山。那雄浑的景色让他心旌摇荡,还处在山脚时便被山势激起豪情而写下了《望岳》。
- 会当凌绝顶,一览众山小。
与生活中平常视角极其不同的是:登上泰山,眼前的一切都便的微不足道了。不仅激发杜甫的热血,还有、还有告诉我们,种种碎屑的苦闷只是因为站的太低、看得太近…
那么,有没有打开眼界的方法呢 … …
人天然的思维方式叫正向思维,在算法里这种正向思维被称为 “递推”(Iterative)。
递推是人本能的正向思维,我们小时候学习数数,从1、2、3 ··· 100 ··· 10000 ··· ∞,就是典型的递推。
这种学习过程是循序渐进,自底向上,由易到难,由小到大,由局部到整体,出发点是正向的:
- 小学时,学习解方程,学习解一元方程(一个未知数x)
- 中学时,学习解方程,学习解二元(俩个未知数x, y)、三元方程(三个未知数x, y, z)
- 大学时,学习解方程,学习解任意未知数方程(线性方程组)、齐次方程、微分方程
而计算机天生就是为大数处理而制造的,自顶而下,习惯从整体到局部、层层分解的思维,所以,计算机的正向思维被称为 “递归”(recursion)。
递归在计算机里是一种编程技巧,通过函数(方法)直接或间接调用自身来实现。
初学者之所以看得懂递归,却不会写,可能是没有转换好视角,事先都没有从整体上规划,就投入到细节里面啦。
严格来说,递归应该不是算法设计,而是算法实现范畴的内容,因为它并不属于任何一种算法模式。
具体的算法设计,如分治、回溯、动态规划,如果从递归角度来说,基本相同,就是小细节不同。
分治、回溯、动态规划都是递归的分支,面对递归问题,就是找问题的重复性,重复性有最近重复性、最优重复性。
- 最优重复性就是动态递推;
- 最近重复性根据重复性怎么构造以及怎么分解,就有什么分治、或者是回溯,或者其它。
但其实都是递归,只是细节不同。
面对人生,我们一直都是递推的想,走一步,再走一步。
也许,对人生并没有具体的规划,也不知道自己想要什么。
这时候可以用递归的想,从整体出发,这样生活几年后的风险会在哪里?
在预期风险发生前,改变您的方向,您就会知道自己目前需要做什么了。
引导思路
递归的设计套路:
- 自顶而下看,函数功能是什么:忽略次要要素(代码怎么写),首先认真想想这个函数是做什么的,可以完成什么功能?
- 找重复性:分析原问题、子问题之间的关系。
第一种方法,有些题目可以用函数关系式描述当前的局面,比如寻找 f(n) 与 f(n-1) 之间的关系,f(n) 从哪里来,f(n) 到哪里去;f(n-1) 从哪里来,f(n-1) 到哪里去?这是有明显的来龙去脉的,这种递归程序就是把来龙去脉给描述出来。
另一种方法,题目研究的对象本身,有自相似性,也是类似的写一个递归,但这个对递归的功底要求会高很多,需要对递归有深刻的理解。 - 结束条件:问题规模小的什么程度可以直接得出解?
比如,你有一个任务,把家族提升到一个新高度。
那这个函数要做什么?跳出文化的轮回。
找重复性,从哪里来,到哪里去?生活中,绝大多数根深蒂固的观念是从父母那里了解,而父母呢,又是从父母的父母那里了解的,父母的父母又是从父母的父母的父母那里了解的。
结束条件?如果你想把家族提升到一个新高度,你首先得与之匹配的,更广阔的眼界,更宽容、更积极和更正面的心态。
- 并且让自己长期保持积极热情的高昂状态,不断与世界深度链接。跳出文化的轮回,帮助后代也形成良好的观念。至此,足以影响一个家族。
或者,先治其国;欲治其国者,先齐其家;欲齐其家者,先修其身;欲修其身者,先正其心;……心正而后身修,身修而后家齐,家齐而后国治,国治而后天下平。
大意是说:
- 古代那些要使美德彰明于天下的人,要先治理好他的国家;
- 要 治理好国 的人,要先整顿好自己的家;
- 要 整顿好家 的人,要先进行自我修养;
- 要进行 自我修养 的人,要先端正他的思想;
- ……
- 思想端正了,自我修养完善;
- 自我修养 完善了,家整顿有序;
- 家整顿好 了,国家安定繁荣;
- 国家安定 繁荣了,天下平定。
大约是这么一个过程:
- 首先是撤退。你撤退到密室里,不受功名利禄影响,思考一些最根本的问题:哲学、人生、宇宙、数学之类。
思考的过程中,你会觉醒,你的自我内核会扩大。这体现在你获得了更多的视角,你能体验到各种人、各种事情,你能去思考一些像数学和自然科学这样老百姓一般不思考的东西。 - 你会产生渴望。你会觉得以前的自己太幼稚了,你想要变聪明、变成好人,你想追求真理,想去谦卑地做成一些事情。
这时候因为学习让你有了“严肃”这个美德,你知道什么重要什么不重要,你就允许自己被学习所改变。
你就不再回避那些悲惨的现实,能够接受现实。
你把自己和他人视为整体,对人类充满同情。这时候,你才算是一个能可靠地改变世界的人。
递归其实就是“倒推”算法,倒推的思路经常会出现在递归、动态规划等类型问题中。
递归其实就是“倒逼”策略,每天都只有 24 小时,其实我们的大脑本身就是个大数据库,里面存储大量的原有信息,每天还在持续的接收外界的新信息。如果不把要做的重要的事情单独列出来的话,我们很难从纷繁杂乱的信息中随时调取要做的事情。
列清单,是在大脑中建立索引,作用相当于图书的目录,可以根据目录中的页码快速找到所需的内容直击目标,另一方面在清单上也可以利用二分法划分出轻重缓急,按照要事第一的原则做事,这样会最大限度的提高做事的整体效率。
但因为各种意外因素,不少时间总会浪费在某些琐事上,而到最后不得不临时修改计划,最后影响结果。而使用倒逼方法做计划,我们会倾向于只写上最重要的目标,在倒推至这个目标的前一步骤时,得出的也会是该步骤的最重要目标,依此类推,整个计划都只是在做每一个步骤的最重要环节,因而能保证最终的完成质量。
倒逼,是用意志力向自己的能力底线不断发冲锋的过程。能用倒逼提升自己,注定是是个自律、高自尊且看得起自己的人。
设计模版
我们写的递归代码,就是四步:
- 终止条件;
- 当前层逻辑处理;
- 递归进入下一层;
- 清理当前层状态(有些时候函数状态需要清理、恢复)。
void recursion( int Depth, int param_1, int param_2, ... ){
// 终止条件
if(Depth > max_Depth){
do sth...
return;
}
// 当前层逻辑处理
do sth...
// 递归进入下一层
recursion(Depth + 1, p1, p2, ...);
// 清理当前层状态(清理操作)
do sth...
}
我们按照模版写就好了,但看他们对递归熟悉的人,代码会比模版还要简洁,因为他们把 终止条件、当前层逻辑处理、递归进入下一层 、清理当前层状态 巧妙的结合起来了。比如:
// 模版写法:
void print(int n){
// 结束条件
if (n < 0)
return;
// 当前层逻辑
printf("%d ", n);
// 注意⚠️:printf 是输出 n,不是调用 print 函数
// 进入下一层
print(n - 1);
}
// 巧妙结合:
void print(int n){
if (n > 0) {
printf("%d ", n);
// 注意⚠️:printf 是输出 n,不是调用 print 函数
print(n - 1);
}
}
递归类型
递归分为直接、间接俩种方式。
直接递归:函数是从自身内部调用自身。
int sum(int n){
if( n <= 1 )
return n;
return n + sum(n-1);
}
间接递归:多个函数相互调用。
void a(int v){
if( v < 0 )
return;
b(-- v);
}
void b(int v){
a(-- v);
}
直接递归又分为:
- 尾递归;
- 头递归;
- 树递归;
- 嵌套递归;
尾递归
如果一个递归函数调用自己并且该递归调用是该函数中的最后一条语句,则称为尾递归。例如:
void print(int n){
// 结束条件
if (n < 0)
return;
// 当前层逻辑
printf("%d ", n);
// 注意⚠️:printf 是输出 n,不是调用 print 函数
// 进入下一层
print(n - 1);
// 尾递归:函数中最后一条语句,且调用的是自己
}
假如我们调用 print() 时,n = 3。
- 时间复杂度:
- 空间复杂度: (编译器优化会把尾递归从 变成 )
头递归
如果递归函数本身调用并且该递归调用是该函数中的第一条语句,则称为头递归。例如:
void print(int n){
if (n > 0){
print(n - 1);
// 头递归:函数中第一条语句
printf("%d ", n);
// 注意⚠️:printf 是输出 n,不是调用 print 函数
}
}
假如我们调用 print() 时,n = 3。
- 时间复杂度:
- 空间复杂度:
树递归
要了解树递归,首先让我们了解线性递归。
如果一个递归函数调用了一次,则称为线性递归。
否则,如果递归函数多次调用自身,则称为树递归。例如:
void print(int n){
if (n > 0) {
printf("%d ", n);
// 注意⚠️:printf 是输出 n,不是调用 print 函数
// 第一次调用
print(n - 1);
// 第二次调用
print(n - 1);
}
}
假如我们调用 print() 时,n = 3。
- 时间复杂度:
- 空间复杂度:
嵌套递归
在递归中,递归函数将作为递归调用传递参数。这意味着“递归内部递归”。例如:
int print(int n){
if (n > 100)
return n - 10;
return print( print(n + 11) );
// 递归函数将参数作为递归调用或递归传递到递归内部
}
假如我们调用 print() 时,n = 95。
- 时间复杂度:
- 空间复杂度:
递归技巧
… …
静态计数
在编写递归代码时,为避免栈溢出可以采用静态计数控制:
void recursion(int para){
static int call_level = 0;
// 结束条件
if(call_level > max_level)
// call_level 的层数取决于函数堆栈帧大小和最大堆栈大小
return;
// 当前层逻辑
call_level ++;
// 层数加一
// 进入下一层
recursive(para);
// 状态清理
call_level--;
}
尾递归优化
尾递归,可以用 goto 循环结构来代替:
// 优化前:
void print(int n){
// 结束条件
if (n < 0)
return;
// 当前层逻辑
printf("%d\n", n); // 输出 n
// 进入下一层
print(n-1);
}
// 优化后:
void print(int n){
start:
// 结束条件
if (n < 0)
return;
// 当前层逻辑
printf("%d\n", n); // 输出 n
// 进入下一层
n = n - 1;
goto start;
}
尾递归完全可以避免爆栈,因为每一个函数在调用下一个函数之前,都能做到先把当前自己占用的栈给先释放了,尾递归的调用链上可以做到只有一个函数在使用栈,因此可以无限地调用!
这并不是所有编程语言都支持,有些语言,比如说 Python, 尾递归的写法在 Python 上就没有任何作用,该爆的时候还是会爆,而 C/C++ 是支持的 ~
重复使用参数
某些时候,程序可以重复使用一个参数代替一组参数,这样空间复杂度会从线性降为常数。比如:
void log(int n){
if(n < 1)
return;
log(n - 1);
int v = n + 10;
printf("%d ", v);
}
// 改进后
void log(int n){
for(int i = 1; i <= n; ++ i)
printf("%d ", i + 10);
// 用一个 i 就可以代替一组 log(n - 1)
}
常用模式
介绍一些常用的递归实现模式,即有助于理解别人写的递归程序,也有助于自己设计递归程序。
同一问题的结构变形
每次递归调用的时候,递归触发机制传递的参数还是原问题的参数,范围没有变化,只是要处理的侧重点和位置发生了变化。
通常用了暴力搜索,特别是那些不知道有多少次的循环。例如深度优先搜索、回溯:
void dfs(GRAPH g, Node i) {
// 处理节点 i
Visit(i);
// 终止条件:当节点 i 没有连通点的时候,那个 for 循环没啥可做的,自然就直接结束了
for( i节点的每一个连接点node[k] ) {
dfs(g, node[k]); // 图还是那个 g,只是 i 不一样
}
}
无论是哪一级递归调用,递归触发机制传递给自身递归调用的参数不变,变化的只是表示具体是哪个结点的参数,通过后面这个参数的变化,将图上的 N 个结点都遍历了一遍,实现了对所有工作站的穷举处理。
这种递归模式常常用于穷举法,那我们写暴力搜索算法时,就是关注一下它们是怎么通过递归函数的参数控制递归子结构的,使得递归子结构能够对同一个问题的不同位置做同样的逻辑处理。
多阶段组合
当一个问题的解决需要多个操作步骤,每个操作步骤完成问题的一部分,当所有的操作都完成后问题才能解决。
对于这种情况,如果每个操作步骤逻辑流程一样,也可以考虑用递归方法设计算法实现。
当遇到这样的问题时,其递归程序的实现呈现一种多阶段组合的模式。
比如,我们从 1 ~ 9 共 9 个数字中任选四个不同的数组成一个排列。
// 检测数字是否重复
bool IsNumberUsed(int num[], int idx, int i) {
for (int k = 0; k < idx; k++) {
if (num[k] == i) {
return true;
}
}
return false;
}
// 递归实现
void EnumNumber(int num[], int idx) {
// 终止条件
if (idx > 3) // 枚举的有效 idx 是 0-3(4个),超过 3 了就是退出条件
return;
// 对当前 idx 位置用数字 1-9 尝试
for (int i = 1; i <= 9; i++) {
if ( !IsNumberUsed(num, idx, i) ) { // 当前枚举的数字与其他已经确定的位置上的数字是否有重复
num[idx] = i;
EnumNumber(num, idx + 1);
// 继续枚举下一个位置
}
}
}
触发递归调用时的关键处理是 idx + 1 的操作,意味着要委托“自身”对当前位置 idx 的下一个位置上的数字进行同样的枚举尝试。
这个问题的解决需要多个操作步骤,每个操作步骤完成问题的一部分,当所有的操作都完成后问题才能解决。
当遇到这样的问题时,其递归程序的实现呈现一种多阶段组合的模式,这种模式的递归函数结构上类似上面的“同一问题的结构变形模式”,但是这逻辑结构上组合的特点明显。
分解子问题
分治意味着要分解子问题,而子问题就是一种天然的递归子结构,用递归方法处理分治法就很自然。
分解子问题模式的递归程序,其递归函数的参数需要被设计成能够反映问题的规模的一组参数。
比如快速排序算法,就需要给出当前要排序的序列起始位置和结束位置( p 和 r 两个参数):
void quick_sort(int *arElem, int p, int r) {
if(p < r) {
int mid = partion(arElem, p, r);
quick_sort(arElem, p, mid - 1); // 当前子问题的规模,从 p 到 mid - 1
quick_sort(arElem, mid + 1, r); // 当前子问题的规模,从 mid + 1 到 r
}
}
可有时候,子问题不是通过范围来描述的,而是数据自身的规模(数据位数)发生了变化。
学习这些模式的目的就是为了能够灵活运用这些模式,实在不行,能起到比着葫芦画瓢的作用,那也是极好的。
执行过程
进程空间
- 进程空间-视频教程:http://edu.nzhsoft.cn/index/mulitcourse/free.html?id=12#12
- 变量的作用域、存储空间、生命周期-视频教程:https://ke.qq.com/course/242707?taid=1573177801290771
函数的入栈与出栈
- 函数的入栈与出栈-视频教程:http://edu.nzhsoft.cn/index/mulitcourse/free.html?id=18#18
栈可以模拟任何递归过程
递归,就是借用系统栈(堆栈),不断地把中间状态放到堆栈中(压栈或入栈),然后再出来(弹出栈或出栈)。
那把递归转非递归,有一个万能的方法:自己维护一个栈,保存参数、局部变量。
递归数据类型
- 置换:从基于的某一置换中删除首元素后,你会得到以剩下元素的置换,所以置换是递归数据类型。
- 子集:集合 的所有子集包含着一个 的子集,并可通过删除的,使得子集凸显出来,所以子集是递归数据类型。
- 树:删除树的根你会得到什么?一堆更小的树,删除树的叶子你会得到什么?一颗略小的树,所以树是递归数据类型。
- 图:删除图中任意一点,你会得到一个更小的图。将图的割开,你会得到什么?俩个更小的图和一束被切断的边,所以图是递归数据类型。
- 点集:取一团点,再画一条线将其一分为二。现在你便拥有更小的俩团点,所以点集是递归数据类型。
- 多边形:对一个具有 个顶点的简单多边形,在其俩个不相邻顶点间插入一内弦,则将原多变形切成俩个更小的多边形,所以多边形是递归数据类型。
- 串:从串中删除首字符,你会得到什么?一个更短的串,所以串是递归数据类型。
还有很多递归数据类型,比如算法表达式()这个表达式让编译器怎么解析呢?通过递归分解会得到很多解决问题的算法。