1. 题记
本文详解方法的递归都调用。对于一些复杂问题,递归有着出奇制胜的效果。
2. 递归的概念
递归是指在一个方法的定义中调用自身的编程技巧。就像是俄罗斯套娃,一个大娃娃里面嵌套着一个和它类似的小娃娃。通过不断地调用自身,直到满足某个特定的条件(称为递归终止条件)来结束递归过程。递归通常用于解决可以分解为相同结构的子问题的复杂问题。例如,计算阶乘、斐波那契数列等。
3. 递归的过程
递归的过程总结起来其实就是6个字:递过去,归回来,如下图所示,计算5的阶乘:
4. 递归的组成部分
4.1 递归调用
这是方法自身调用自身的部分。例如,在一个计算阶乘的递归方法中,n的阶乘(n!)可以定义为n * (n - 1)!,这里就包含了对自身的调用(计算(n - 1)!)。
4.2 递归终止条件:
这是递归必须具备的关键部分。如果没有终止条件,递归将会无限地进行下去,导致栈溢出错误(StackOverflowError)。例如,在计算阶乘的递归中,当n = 0或者n = 1时,规定n! = 1,这就是递归终止条件。
5.递归的实例
5.1 计算阶乘
- 阶乘的定义是:n! = n * (n - 1) * (n - 2) … 1,其中0! = 1。
用递归方法实现计算阶乘的代码如下:
public class Main {
public static int factorial(int n) {
if (n == 0 || n == 1) {
return 1;
} else {
return n * factorial(n - 1);
}
}
public static void main(String[] args) {
int result = factorial(5);
System.out.println("5! = " + result);
}
}
- 执行过程说明
在factorial方法中:
首先判断n是否为0或者1,如果是,就返回1,这是递归终止条件。
如果n大于1,就返回n乘以factorial(n - 1),这里factorial(n - 1)就是递归调用,通过不断地调用自身,每次n减 1,直到n等于0或者1。
例如,计算5!的过程如下:
factorial(5)会调用5 * factorial(4)。
factorial(4)会调用4 * factorial(3)。
factorial(3)会调用3 * factorial(2)。
factorial(2)会调用2 * factorial(1)。
当n = 1时,factorial(1)返回1,然后逐步回溯计算,得到5! = 5 * 4 * 3 * 2 * 1 = 120。
5.2 斐波那契数列
- 斐波那契数列的特点是:前两项是0和1,从第三项开始,每一项都等于前两项之和,即F(n)=F(n - 1)+F(n - 2),其中n > 1,F(0) = 0,F(1) = 1。
用递归方法实现计算斐波那契数列的第n项的代码如下:
public class Main {
public static int fibonacci(int n) {
if (n == 0) {
return 0;
} else if (n == 1) {
return 1;
} else {
return fibonacci(n - 1) + fibonacci(n - 2);
}
}
public static void main(String[] args) {
int result = fibonacci(6);
System.out.println("The 6th Fibonacci number is: " + result);
}
}
- 执行过程说明
在fibonacci方法中:
首先判断n是否为0或1,如果是,就返回n对应的斐波那契数(0或1),这是递归终止条件。
如果n大于1,就返回fibonacci(n - 1)+fibonacci(n - 2),这里包含了两次递归调用,分别计算第n - 1项和第n - 2项的斐波那契数,然后将它们相加。
例如,计算F(6)的过程如下:
fibonacci(6)会调用fibonacci(5)+fibonacci(4)。
fibonacci(5)会调用fibonacci(4)+fibonacci(3)。
以此类推,直到n等于0或1,然后逐步回溯计算,得到F(6)=8。
6. 递归算法的优缺点
6.1 优点
- 逻辑清晰简单
对于一些具有重复子结构的复杂问题,递归算法能够以非常直观和自然的方式来描述解决方案。例如,计算阶乘问题,用递归方式定义n! = n * (n - 1)!,其中n > 0且0! = 1,代码实现直接反映了这个数学定义,易于理解。
再比如处理树结构,如二叉树的遍历。二叉树的前序遍历(根节点、左子树、右子树)、中序遍历(左子树、根节点、右子树)和后序遍历(左子树、右子树、根节点),使用递归算法可以很简洁地实现。以下是二叉树前序遍历的递归代码示例:
class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int val) {
this.val = val;
this.left = null;
this.right = null;
}
}
class Main {
public static void preorderTraversal(TreeNode root) {
if (root!= null) {
System.out.print(root.val + " ");
preorderTraversal(root.left);
preorderTraversal(root.right);
}
}
public static void main(String[] args) {
TreeNode root = new TreeNode(1);
root.right = new TreeNode(2);
root.right.left = new TreeNode(3);
preorderTraversal(root);
}
}
- 问题分解能力强
递归能够将一个大型复杂问题分解为同类型的较小子问题。例如,在计算斐波那契数列时,第n个斐波那契数可以通过F(n)=F(n - 1)+F(n - 2)来计算(n > 1,F(0) = 0,F(1) = 1)。这将计算第n个斐波那契数的大问题,分解为计算第n - 1个和第n - 2个斐波那契数这两个子问题,而这两个子问题的求解方式与原问题完全相同。
这种分解方式使得问题的解决更具模块化,每个子问题的解决方法与原问题一致,便于程序的设计和实现。 - 代码量较少
对于某些问题,递归可以用较少的代码行数实现功能。因为它利用了方法自身的调用,避免了一些复杂的循环和状态管理。例如,在实现一个简单的文件系统目录遍历功能时,如果目录结构可以用树状结构表示,递归算法可以很简洁地遍历每个子目录和文件,而不需要手动维护复杂的栈来记录待遍历的目录路径。
6.2 缺点
- 性能开销大
递归涉及到频繁的方法调用,每次调用都需要在栈中保存当前方法的状态,包括局部变量、参数和返回地址等信息。这会带来额外的时间和空间开销。例如,在一个简单的递归函数中,每次调用都会进行入栈操作,函数返回时又要进行出栈操作。
对于一些深层次的递归,如深度很大的树遍历或者大规模的递归计算(如计算非常大的数的阶乘),这些栈操作的开销会累积,导致程序的执行速度变慢。 - 可能导致栈溢出
由于栈的大小是有限的,如果递归调用的深度超过了栈的容量,就会导致栈溢出(StackOverflowError)。例如,在没有正确设置递归终止条件或者递归深度过大的情况下,如一个错误的斐波那契数列递归实现没有正确处理终止条件,可能会无限地进行递归调用,很快就会耗尽栈空间。 - 理解难度对于部分人较高
尽管递归在某些情况下逻辑清晰,但对于一些初学者或者不熟悉递归概念的开发者来说,理解递归的执行过程可能会有一定的难度。尤其是当递归调用中涉及到复杂的状态变化或者多个递归调用(如斐波那契数列中的F(n)=F(n - 1)+F(n - 2)涉及两次递归调用)时,很难直观地想象出整个程序的执行流程和数据的变化情况。
7. 不适合用递归解决的问题
7.1 简单的迭代问题
当问题可以用简单的循环(如for循环、while循环)轻松解决时,使用递归可能会使问题变得复杂。例如,计算从 1 到n的整数之和。
用循环解决的代码如下:
public class Main {
public static int sum(int n) {
int result = 0;
for (int i = 1; i <= n; i++) {
result += i;
}
return result;
}
public static void main(String[] args) {
int n = 100;
int sumValue = sum(n);
System.out.println("The sum from 1 to " + n + " is: " + sumValue);
}
}
如果用递归解决这个问题,虽然也可以实现(例如,定义sum(n)=n + sum(n - 1),sum(1)=1),但会涉及到方法调用的开销,并且对于一些开发者来说,理解递归的过程可能比理解循环要复杂。
7.2 对性能要求极高的问题
递归会占用额外的栈空间,并且每次递归调用都有一定的时间开销用于方法调用和栈操作。对于性能要求极高的场景,如实时系统、高频交易系统中的核心计算部分等,递归可能不是一个好的选择。
例如,在一个高频交易系统中,需要快速计算大量的交易数据的简单统计信息,如移动平均值。如果使用递归算法来计算,可能会因为栈操作和方法调用的开销导致处理速度跟不上交易数据的流入速度,从而影响系统的性能。
7.3 大数据量且递归深度不可控的问题
由于栈空间是有限的,如果问题涉及的数据量很大,导致递归深度不可预测且可能很深,就很容易出现栈溢出的情况。例如,在处理大规模的图数据结构时,如果使用深度优先搜索(DFS)的递归实现来遍历图,当图的深度很大或者存在环时,递归调用可能会无限制地进行下去,除非有合适的终止条件和处理机制。
假设要遍历一个可能存在环的大型有向图,递归的深度优先搜索代码如下:
import java.util.ArrayList;
import java.util.List;
class Graph {
private int vertices;
private List<List<Integer>> adjList;
public Graph(int vertices) {
this.vertices = vertices;
adjList = new ArrayList<>();
for (int i = 0; i < vertices; i++) {
adjList.add(new ArrayList<>());
}
}
public void addEdge(int src, int dest) {
adjList.get(src).add(dest);
}
public void dfs(int vertex, boolean[] visited) {
visited[vertex] = true;
System.out.print(vertex + " ");
List<Integer> neighbours = adjList.get(vertex);
for (Integer neighbour : neighbours) {
if (!visited[neighbour]) {
dfs(neighbour, visited);
}
}
}
}
public class Main {
public static void main(String[] args) {
Graph graph = new Graph(7);
graph.addEdge(0, 1);
graph.addEdge(0, 2);
graph.addEdge(1, 3);
graph.addEdge(2, 4);
graph.addEdge(3, 5);
graph.addEdge(4, 5);
graph.addEdge(5, 6);
boolean[] visited = new boolean[7];
graph.dfs(0, visited);
}
}
在这个例子中,如果图结构发生变化,出现了环或者深度远超预期,就可能导致栈溢出。所以对于这种大数据量且递归深度不可控的图遍历问题,可能需要考虑非递归的解决方案,如使用队列实现的广度优先搜索(BFS)。
7.4 资源受限的环境问题
在一些资源受限的环境中,如嵌入式系统、移动设备等,栈空间相对有限。如果使用递归解决问题,很可能因为栈空间不足而导致程序崩溃。例如,在一个内存有限的物联网设备中,运行一个简单的传感器数据处理程序,若采用递归算法来处理可能存在复杂嵌套关系的数据,很容易耗尽设备的栈空间。
8. 递归算法的时间和空间复杂度
8.1 时间复杂度
- 定义与分析方法
时间复杂度用于衡量算法执行时间随输入规模增长的趋势。对于递归算法,分析时间复杂度通常需要建立递归关系式,也称为递推方程。这个方程描述了问题规模为n时的时间复杂度与规模更小的子问题(如n - 1、n - 2等)的时间复杂度之间的关系。 - 计算阶乘的时间复杂度
对于计算阶乘的递归函数factorial(n),其代码如下:
public static int factorial(int n) {
if (n == 0 || n == 1) {
return 1;
} else {
return n * factorial(n - 1);
}
}
设T(n)表示计算n的阶乘的时间复杂度。当n = 0或n = 1时,时间复杂度为O(1),因为只需要进行简单的比较和返回操作。当n > 1时,计算factorial(n)需要执行一次乘法操作(n * factorial(n - 1))和一次递归调用factorial(n - 1)。所以递推方程为T(n)=T(n - 1)+O(1),展开这个递推方程可得T(n)=O(n)。
- 斐波那契数列的时间复杂度
斐波那契数列的递归函数如下:
public static int fibonacci(int n) {
if (n == 0) {
return 0;
} else if (n == 1) {
return 1;
} else {
return fibonacci(n - 1) + fibonacci(n - 2);
}
}
设T(n)表示计算第n个斐波那契数的时间复杂度。当n = 0或n = 1时,时间复杂度为O(1)。当n > 1时,计算fibonacci(n)需要两次递归调用fibonacci(n - 1)和fibonacci(n - 2),以及一次加法操作。所以递推方程为T(n)=T(n - 1)+T(n - 2)+O(1)。这个时间复杂度是指数级的,约为O(2^n)。这是因为斐波那契数列的递归计算会产生大量的重复计算,例如计算fibonacci(5)会计算fibonacci(3)和fibonacci(4),而计算fibonacci(4)又会重新计算fibonacci(3)。
8.2 空间复杂度
- 定义与分析方法
空间复杂度衡量的是算法在运行过程中临时占用存储空间大小的量度。对于递归算法,主要考虑递归调用栈所占用的空间。每次递归调用都会在栈中创建一个新的栈帧,栈帧中存储了函数的参数、局部变量和返回地址等信息。 - 计算阶乘的空间复杂度
在计算阶乘的递归函数factorial(n)中,递归调用的最大深度为n(当n逐渐减小到0或1时结束递归)。每个栈帧占用的空间是固定的,主要是存储参数n和局部变量(如果有)。所以空间复杂度为O(n)。 - 斐波那契数列的空间复杂度
对于斐波那契数列的递归函数fibonacci(n),递归调用的最大深度也为n。在递归过程中,栈帧的数量随着n的增加而增加。因此,空间复杂度为O(n)。不过需要注意的是,由于斐波那契数列的递归计算存在大量重复计算,其时间复杂度很高,实际应用中可能会采用其他优化方法(如动态规划)来降低时间复杂度,同时也可能会改变空间复杂度的情况。
9. 总结:递归的注意事项
递归对于一些复杂问题,递归有着出奇制胜的效果。但是一定要注意以下事项:
- 确保基准情况存在:递归函数必须有一个或多个基准情况,以确保递归能够终止。
- 避免无限递归:递归调用必须逐渐逼近基准情况,否则将导致无限递归。
- 性能考虑:递归可能导致较大的栈消耗和重复计算,因此在某些情况下,可能需要使用其他算法(如动态规划)进行优化。
本文完。
码字不易,宝贵经验分享不易,请各位支持原创,转载注明出处,多多关注作者,家人们的点赞和关注是我笔耕不辍的动力。