递归从学C语言的时候就在看,时至今日仍然在看,这次干脆整理到位。
1.了解递归
1.1 定义:
递归算法应用的场景是要解决的问题和其子问题具有相似性的时候,通过直接或间接的调用自己求出问题解的方法。
它是通过解决一个问题的更小实例来解决一个大的问题的解的算法。
递归算法有两个过程,一是调用过程,二是向上传递结果的过程。
1.2 相对于循环迭代,递归的优点?
-
简洁性:递归可以用更少的代码表达复杂的逻辑,使代码更加简洁。
-
可读性:递归可以更直观地表达问题的逻辑,使得代码更易于理解和维护。
-
数学归纳法:递归是基于数学归纳法的,可以更自然地解决问题,特别是对于处理树形结构或者递归定义的问题。
-
分治思想:递归可以很自然地体现出分治思想,将问题分解为子问题,然后合并子问题的解。
-
解决一些问题更加直观:有些问题本身就天然适合递归解决,如阶乘、斐波那契数列等。
-
减少重复代码:递归可以避免写出重复的代码,提高了代码的重用性。
1.3 递归的分类
递归分为 直接递归 和 间接递归 :
直接递归:函数直接调用自身实现递归,也是最常见的形式。直接递归中有一种可以实现递归优化的,特殊的递归形式——尾递归,我会在举例中提到。
间接递归:两个及以上的函数之间相互调用,最终形成一个循环链。
//两个函数相互调用形成循环链的【间接递归】:
bool is_BBC(int n){
if(n==1)
return true;
else
return is_CCTV(N-1);
}
bool is_CCTv(int n){
if(n==0)
return false;
else
return is_BBC;
}
1.4 掌握递归的必要性
其实这个疑问在学习了数据结构这门课后,就不应该存在了。除了上面提到的优点外,其必要性其实主要体现在:
-
在数据结构中,递归直接定义了包括二叉树、广义表在内的数据结构形式!从这点看,递归是学习数据结构及算法必须掌握的内容。
-
函数定义,如斐波那契函数(计算斐波那契数列的第n项的函数)直接是按递归定义的。
-
在某些算法问题中,用递归实现可以使代码更加简洁易懂,减少代码复杂度。
当然,以上只是客观为主的观点,递归必要性的体现远不止这三点'。
1.5 递归使用注意事项
递归的本质就是函数不断调用自身。递归函数每次调用自身时,系统都会为其新调用的函数分配内存,用于存储局部变量、调用地址和其它信息。而这个过程是与栈联系在一起的,即分配的内存区域称为“栈帧空间“,这个空间只有触发递归终止条件后才会随着函数返回而释放。前面说了,递归过程分为“递” 和 “归” 两个过程。所以就是:
递:即 入栈过程,程序执行”递“的动作时,就是各种数据进行 压栈 的过程。
归:即 出栈过程,触发递归终止条件后,内存空间随着函数的 return 而不断释放的过程,即 出栈。
因为递归过程是与 栈 打交道,所以自然也就涉及到堆栈溢出 的潜在风险,所以在写递归时要控制其深度。
2. 如何实现递归?
2.1 递归函数的构成:
递归函数包含两部分:
-
找出递归终止条件(边界条件):这是一个非递归的情况,它指明了递归(准确的来说应该是”递“何时停止)应该在何时停止。在递归函数中,通常会检查是否满足某些条件,如果满足,就不再进行递归调用,而是返回一个特定的值(开始”归“的过程)或执行其他操作。
-
递归情况:这是递归函数调用自身的部分。在这里,根据某些条件递归调用同样的函数,但是针对一个更小的问题规模(参数变化上实现)。
2.2 阶乘举例
以最简单的计算阶乘的函数为例:
2.2.1 构建分析
第一步:找到递归终止条件。计算的是n的阶乘,拆分是一个”由大到小“的过程,所以参数”递“到最小的时候,参数应该为”1“ (因为阶乘是 123...n,从1开始的),所以当参数”递“到 1 的时候,递归的”递”就该停止了。所以当参数n为1时,开始返回(出栈,开始“归”的过程),此时阶乘的运算才是正式展开。
第二步:只看目前的参数和下一个要“递”下去的参数之间的关系,我们求的是阶乘,所以它们俩之间是相乘的关系 所以关系式是:
n * factorial(n-1)
全过程如下图所示:
2.2.2 代码实现
C语言代码如下:
int factorial(int n){
//递归终止条件
if(n == 1)
return 1;
# 递归函数
else
return n*factorial(n-1);
}
2.3 尾递归优化
2.3.1 什么是尾递归?
对于尾递归的阐述也挺多的,但我个人总结了一下,就是 省略了”归“过程的递归。
2.3.2 如何实现尾递归?
就是在递归的过程中(也就是递归函数多增加一个参数)增加一个变量res,用于存储并处理每一次递归调用(”递“)前的参数。简单说就是,在”递“的同时完成了”归“的操作过程。
对应求阶乘代码如下:
int tailFactorial(int n,int res){
//终止条件不变
if(n==1)
return res; //但返回时不再是返回”上一次递归“,
else //而是将返回结果直接一次性返回。
return tailFactorial( (n-1) , (res*n) );
//形如:【return 尾递归函数(<一般递归中的递归参数>,<在递归调用(递)前,操作并存储当前参数>)】
//对于求阶乘这个问题而言,“操作”就是相邻的递归参数之间相乘,所以res*n记录了一路”递“过来的这些数的 乘积
//所以”递“完后直接返回的 res 就是这些递归参数的乘积,而不用在返回到上一级递归,运算,再上一级,运算......这种重复.
//所以说是省略了”归“的过程。
}
2.3.3 尾递归注意事项:
虽然尾递归的确实现了递归优化,但实际使用时因编译器以及语言自身的情况,还是存在不支持尾递归优化的情况,如Python,所以这种情况下即便函数是尾递归的形式,但依然会存在栈溢出的问题。
2.4
2.4 日常练习递归小技巧:
递归这块想要熟练掌握的最好方式是在理解其基础知识后,再进行各种各样的手搓练习,相信假以时日我们都会有明显的进步!一下是日常练习的一些小技巧,希望能帮到你。
-
确保基本情况的正确性:基本情况的定义对于递归函数的正确性至关重要,因此你需要确保它是正确的。
-
找到递归关系:找到函数调用自身的规律,并且通过参数的变化来确保问题规模在递归调用中不断减小,最终满足基本情况。
-
确保递归终止:递归函数应该能够在某个时刻终止,避免出现无限循环最终栈溢出。
-
调试和测试:尽可能地通过调试和测试来验证递归函数的正确性,确保它的行为符合预期。
此外,也别忘了递归函数通常对于解决树形结构、分治算法以及动态规划等问题非常有效,因此在这些领域中更容易找到适合递归的算法。