14. 递归(Recursion)

1 递归(Recursion)
  1. 递归:函数(方法)直接或间接调用自身。是一种常用的编程技巧
  2. 函数递归调用时,栈空间的变化
public static void main(String[] args) {
	sum(4);
}
private static int sum(int n) {
	if (n <= 1) return n; 
	return n + sum(n - 1);
}

在这里插入图片描述
3. 递归时,每次方法调用,都会新建一个栈帧对该方法中变量进行保存,因此随着递归的深度越深,会消耗大量的栈空间。而空间复杂度为某个方法临时占用存储空间大小,而栈空间是可以循环利用的,因此计算递归的空间复杂度时,应该着重观察递归方法同一时间最大能占用多大的栈空间,也就是主要应观察递归深度
4. 递归调用的空间复杂度 = 递归深度 * 每次调用所需的辅助空间
5. 如果递归调用没有终止,将会一直消耗栈空间,最终导致栈内存溢出(Stack Overflow)
6. 所以必需要有一个明确的结束递归的条件,也叫作边界条件、递归基
7. 注意:使用递归不是为了求得最优解,是为了简化解决问题的思路,代码会更加简洁。递归求出来的很有可能不是最优解,也有可能是最优解

2 递归的基本思想
  1. 拆解问题
    1. 把规模大的问题变成规模较小的同类型问题
    2. 规模较小的问题又不断变成规模更小的问题
    3. 规模小到一定程度可以直接得出它的解
  2. 求解
    1. 由最小规模问题的解得出较大规模问题的解
    2. 由较大规模问题的解不断得出规模更大问题的解
    3. 最后得出原来问题的解
  3. 凡是可以利用上述思想解决问题的,都可以尝试使用递归
    1. 很多链表、二叉树相关的问题都可以使用递归来解决
    2. 因为链表、二叉树本身就是递归的结构(链表中包含链表,二叉树中包含二叉树)
3 递归的使用套路
  1. 先明确要编写的递归函数的功能
  2. 明确原问题与子问题的关系,也就是寻找f(n) 与 f(n – 1)的关系
  3. 明确递归基(边界条件)
    1. 思考问题规模小到什么程度可以直接得出解,从而得到递归基
4 练习1 – 斐波那契数列
  1. fib0:时间复杂度O(2^n),空间复杂度O(n)
//1. 明确递归函数应该做什么:此处递归函数应该想得到第n个斐波那契数
int fib0(int n) {
	//3. 明确递归基:当n<=2 时,可以直接得出解,所以n<=2时,应直接返回解1
	if (n <= 2) return 1;
	//2. 明确f(n)与f(n-1)之间关系:发现f(n) = f(n - 1) + f(n - 2)
	return fib0(n - 1) + fib0(n - 2);
}
  1. fib1:记忆化,时间复杂度:O(n),空间复杂度:O(n)
//用数组存放计算过的结果,避免重复计算
int fib1(int n) {
	if (n <= 2) return 1;
	int[] array = new int[n + 1];
	array[1] = array[2] = 1;
	return fib1(n, array);
}

int fib1(int n, int[] array) {
	if (array[n] == 0) {
		array[n] = fib1(n - 1, array) + fib1(n - 2, array);
	}
	return array[n];
}
  1. fib2:去除递归调用,改为"自底向上”的计算过程,时间复杂度:O(n),空间复杂度:O(n)
int fib2(int n) {
	if (n <= 2) return 1;
	int[] array = new int[n + 1];
	array[1] = array[2] = 1;
	for (int i = 3; i <= n; i++) {
		array[i] = array[i - 1] + array[i - 2];
	}
	return array[n];
}
  1. fib3:发现每次运算只需要用到数组中的 2 个元素,所以可以使用滚动数组来优化,O(n),空间复杂度:O(1)
int fib3(int n) {
	if (n <= 2) return 1;
	int[] array = new int[2];
	array[0] = array[1] = 1;
	for (int i = 3; i <= n; i++) {
		//可以发现,下面表达式中,三个数组元素的下标是不断变化的,对于n=3,第一个下标为1,第二个下标为0,第三个下标为1
		//对于n=4,原来下标为0的会变为1,原来下标为1的会变为0
		//这就是滚动数组,可以使用一个表达式,每次将数组的下标+1,来存放新数据,效果就类似为数组元素赋值,当值满了,再从头开始赋值
		array[i % 2] = array[(i - 1) % 2] + array[(i - 2) % 2];
	}
	return array[n % 2];
}
  1. fib4:位运算取代模运算
int fib4(int n) {
	if (n <= 2) return 1;
	int[] array = new int[2];
	array[0] = array[1] = 1;
	for (int i = 3; i <= n; i++) {
		array[i & 1] = array[(i - 1) & 1] + array[(i - 2) & 1];
	}
	return array[n & 1];
}
  1. fib5:去掉数组,使用两个变量替代
  2. fib6:斐波那契数列恰好有线性代数解法,使用特征方程求解,时间复杂度、空间复杂度取决于 pow 函数(至少可以低至O(logn) )
5 练习2 – 上楼梯(跳台阶)
  1. 楼梯有 n 阶台阶,上楼可以一步上 1 阶,也可以一步上 2 阶,走完 n 阶台阶共有多少种不同的走法
    1. 假设 n 阶台阶有 f(n) 种走法,第 1 步有 2 种走法
      1. 如果上 1 阶,那就还剩 n – 1 阶,共 f(n – 1) 种走法
      2. 如果上 2 阶,那就还剩 n – 2 阶,共 f(n – 2) 种走法
    2. 所以 f(n) = f(n – 1) + f(n – 2)
  2. climbStairs
static int climbStairs(int n) {
	if (n <= 2) return n;
	return climbStairs(n - 1) + climbStairs(n - 2);
}
  1. climbStairs1:斐波那契数列几乎一样,因此优化思路也是一致的
static int climbStairs1(int n) {
	if (n <= 2) return n;
	int first = 1;
	int second = 2;
	int next = 0;
	for(int i=3;i<=n;i++) {
		next = first+second;
		first = second;
		second = next;
	}
	return next;
}
6 练习3 – 汉诺塔(Hanoi)
  1. 编程实现把 A 的 n 个盘子移动到 C(盘子编号是 [1, n] )
    1. 每次只能移动1个盘子
    2. 大盘子只能放在小盘子下面
  2. 思路
    1. 将 n – 1 个盘子从 A 移动到 B
    2. 将编号为 n 的盘子从 A 移动到 C
    3. 将 n – 1 个盘子从 B 移动到 C
    4. 步骤 ① ③ 明显是个递归调用
      在这里插入图片描述
      在这里插入图片描述
  3. Hanoi:时间复杂度O(2^n),空间复杂度O(n)
package com.mj;

public class Hanoi {
	public static void main(String[] args) {
		new Hanoi().hanoi(3, "A", "B", "C");
	}

	// 1. 明确递归函数功能:将n个盘子,以p2作为中间的柱子,从p1移动到p3
	void hanoi(int n, String p1, String p2, String p3) {
		// 3. 明确递归基:当n为1时,只需直接挪动第1号盘子,从p1到p3即可
		if (n == 1) {
			move(n, p1, p3);
			return;
		}
		// 2. 寻找f(n) 与 f(n – 1)的关系,
		// a. 挪动n个盘子,从p1到p3,就是先挪动n-1个盘子从p1到p2
		// b. 然后将第n号(注意不是n个)盘子,从p1挪到p3
		// c. 最后将n-1个盘子,从p2挪到p3
		hanoi(n - 1, p1, p3, p2);
		move(n, p1, p3);
		hanoi(n - 1, p2, p1, p3);
	}
	// T(n) = 2 * [2 * T(n - 2) + O(1)] + O(1)
	// T(n) = 2^2 * [2 * T(n - 3) + O(1)] + 2 * O(1) + O(1)
	// T(n) = 2^3 * T(n - 3) + (2^2 + 2^1 + 2^0) * O(1)
	// T(n) = 2^(n - 1) * O(1) + (2^(n-2) + ... + 2^2 + 2^1 + 2^0) * O(1)
	// T(n) = [2^(n - 1)+ 2^(n-2) + ... + 2^2 + 2^1 + 2^0] * O(1)
	// T(n) = (2^n - 1) * O(1)

	// 将第 no 号盘子从 from 移动到 to
	static void move(int no, String from, String to) {
		System.out.println("将" + no + "号盘子从" + from + "移动到" + to);
	}
}

7 递归转非递归
  1. 递归调用的过程中,会将每一次调用的参数、局部变量都保存在了对应的栈帧(Stack Frame)中
  2. 因此可以自己维护一个栈,来保存参数、局部变量。递归100%可以转换成非递归。但复杂度不会得到优化
  3. 在某些时候,也可以重复使用一组相同的变量来保存每个栈帧的内容,复杂度降为O(1)
  4. Test
package com.mj;

import java.util.Stack;

public class Test {
	public static void main(String[] args) {
		log(5);
		System.out.println("----------------------------------");
		logFrame(5);
		System.out.println("----------------------------------");
		logI(5);
	}

	static void log(int n) {
		if (n < 1)
			return;
		log(n - 1);
		int v = n + 10;
		System.out.println(v);
	}

	static class Frame {
		int n;

		Frame(int n) {
			this.n = n;
		}
	}

	static void logFrame(int n) {
		Stack<Frame> frames = new Stack<>();
		// 对递归代码前的代码,模拟入栈
		for (int i = n; i >= 1; i--) {
			// 我理解因为是模拟递归中栈帧的创建,所以自定义的Frame中,只要保存递归代码之前的局部变量(n)即可,递归代码之后的局部变量(v),一定是可以通过之前的局部变量获取到的
			frames.push(new Frame(i));
		}
		// 对递归代码后的代码,模拟出栈
		while (!frames.isEmpty()) {
			Frame frame = frames.pop();
			int v = frame.n + 10;
			System.out.println(v);
		}
	}

	// 只用i一个变量,存放栈帧中的局部变量,但要注意,访问i的顺序,需要和出栈的顺序相同,空间复杂度从 O(n)降到了 O(1)
	static void logI(int n) {
		// 直接模拟出栈的过程,栈帧中局部变保存在i中
		for (int i = 1; i <= n; i++) {
			System.out.println(i + 10);
		}
	}
}

8 尾调用(Tail Call)
  1. 尾调用指一个函数的最后一个动作是调用函数
  2. 如果最后一个动作是调用自身,称为尾递归(Tail Recursion),尾递归是尾调用的特殊情况
  3. 下面代码不是尾调用
//因为它最后1个动作是乘法
int factorial(int n){
	if(n<=1) return n;
	return n*factorial(n-1);
}

8.1 尾调用优化
  1. 一些编译器能对尾调用进行优化,以达到节省栈空间的目的
  2. 尾调用优化也叫做尾调用消除
    1. 优化后,不必为末尾的方法调用,重新创建栈帧,而是直接对其当前所在方法的栈帧进行改造后,使用当前方法栈帧
    2. 尾调用优化让位于尾位置的函数调用跟 goto 语句性能一样高
    3. 尾调用优化,会节省大量栈空间
  3. 尾递归的优化要比尾调用的优化容易很多
    1. 因为递归方法需要的栈空间与原方法所需栈空间完全相同,不需要调整栈帧大小
    2. Java虚拟机(JVM)只会对尾递归进行优化,但不会对尾调用进行优化
  4. 平时的递归代码可以考虑尽量使用尾递归的形式
  5. 为什么非尾调用无法优化
    1. 例如方法a中调用方法b
    2. 如果b不是尾调用,那么b后面还会用到b之前定义的局部变量
    3. 如果此时进行了优化,那么会导致b后面代码无法再访问到b之前的局部变量值,因为该值已被b中的变量覆盖
8.2 递归改造为尾递归
  1. 太过难想,只给出例子,证明确实能改造
package com.mj;

public class TailCall {

	public static void main(String[] args) {
		System.out.println(facttorial(4));
	}

	/**
	 * 1 * 2 * 3 * 4 * ... * (n - 1) * n
	 * 
	 * @param n
	 * @return
	 */
	static int facttorial(int n) {
		return facttorial(n, 1);
	}
	
	static int facttorial(int n, int result) {
		if (n <= 1)
			return result;
		return facttorial(n - 1, result * n);
	}

	static int facttorial1(int n) {
		if (n <= 1)
			return n;
		return n * facttorial(n - 1);
	}
}

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值