【递归】:原理、应用与案例解析 ,助你深入理解递归核心思想

news2024/11/27 12:45:09

递归

1.基础简介

递归在计算机科学中,递归是一种解决计算问题的方法,其中解决方案取决于同一类问题的更小子集

例如 递归遍历环形链表

  • 基本情况(Base Case):基本情况是递归函数中最简单的情况,它们通常是递归终止的条件。在基本情况下,递归函数会返回一个明确的值,而不再进行递归调用。
  • 递归情况(Recursive Case):递归情况是递归函数中描述问题规模较大的情况。在递归情况下,函数会调用自身来解决规模更小的子问题,直到达到基本情况。

优点

  1. 简洁清晰:递归能够将复杂的问题简化成更小的子问题,使得代码更加清晰易懂。
  2. 问题建模:递归能够自然地将问题建模成递归结构,使得问题的解决变得更加直观。
  3. 提高代码复用性:通过递归,可以在不同的情景中复用相同的解决方案。

缺点

  1. 性能损耗:递归调用涉及函数的重复调用和堆栈的频繁使用,可能会导致性能下降。
  2. 内存消耗:每次递归调用都需要在堆栈中存储函数的调用信息,可能会导致堆栈溢出的问题。
  3. 难以理解和调试:复杂的递归调用可能会导致代码的难以理解和调试,特别是递归函数中存在多个递归调用时。

常用场景

  1. 树和图的遍历:树和图的结构天然适合递归的处理方式,如深度优先搜索(DFS)。
  2. 分治算法:许多分治算法,如归并排序和快速排序,都是通过递归实现的。
  3. 动态规划:动态规划问题中,递归可以帮助描述问题的递归结构,但通常需要使用记忆化搜索或者自底向上的迭代方式来提高性能。
  4. 排列组合问题:许多排列组合问题,如子集、组合、排列等,可以通过递归实现。
/**
 * 递归进行遍历
 * @param node   下一个节点
 * @param before 遍历前执行的方法
 * @param after  遍历后执行的方法
 * @deprecated  递归遍历,不建议使用,递归深度过大会导致栈溢出。建议使用迭代器,或者循环遍历,或者使用尾递归,或者使用栈
 * @see #loop(Consumer, Consumer)
 */
public void recursion(Node node, Consumer<Integer> before, Consumer<Integer> after){
    // 表示链表没有节点了,那么就退出(注意 环形链表的 末尾 不是null 而是头节点)
    if (node == sentinel){
        return;
    }
    // 反转位置就是逆序了
    before.accept(node.value);
    recursion(node.next, before, after);
    after.accept(node.value);
}
  1. 自己调用自己,说明每一个函数对应着一种解决方案,自己调用自己意味着解决方案是一样的或者说是有规律的
  2. 每次调用,函数处理的数据相对于上一次会缩减,而且最后会缩减至无需继续递归
  3. 内层函数调用(子集处理完成),外层函数才能调用完成

1.1.思路

首先需要确定自己的问题,能不能用递归的思路去解决

然后需要推导出递归的关系,父问题和子问题之间的关系, 以及递归的中止条件

f ( n ) = { 停止 , n = n u l l f ( n , n e x t ) , n ≠ n u l l f(n) = \begin{cases} 停止&, n = null \\ f(n,next)&, n \neq null \\ \end{cases} f(n)={停止f(n,next),n=null,n=null

  1. 深入到最里层的 叫做递
  2. 从最里层出来的叫做归
  3. 在递过程中,外层函数内的局部变量(以及参数方法)并未消失,归的时刻还会用到。

2.案例

2.1.案例1-求阶乘

@Test
@DisplayName("测试-递归-阶乘")
public void test1(){
    int factorial = factorial(5);
    logger.error("factorial :{}",factorial);
}


/**
 * 阶乘
 * @param value 阶乘的值
 * @return 阶乘的结果
 */
public int factorial(int value){
    // 递归的终止条件
    if(value ==1){
        return 1;
    }
    // 递归的公式 f(n) = n * f(n-1)
    return value * factorial(value-1);
}

在这里插入图片描述

2.2.案例2-字符串反转

  • 递:n从0开始,每次都从都头部对字符串进行分割,每次拼接的字符串只取第一位
  • 归:从 str.length() == 1开始归,从归开始拼接,自然是逆序的
# 思路
	递归的终止条件是字符串的长度为1, 递归的公式是 f(n) = f(n-1) + str.charAt(0) 从后往前拼接字符串
/**
 * 反向打印字符串序列
 * @param str 字符串
 * @return 反向打印的字符串
 */
public String reverse(String str){
    if(str.length() == 1){
        return str;
    }
    logger.error("str.substring(1) = {} , str.CharArt(0) = {}",str.substring(1),str.charAt(0));
    // substring(1) 从下标为1的位置开始截取字符串, chatAt(0) 获取下标为0的字符
    return reverse(str.substring(1)) + str.charAt(0);
}


@Test
@DisplayName("测试-递归-反向打印字符串序列")
public void test2(){
    String str = "abcdefg";

    String reverse = reverse(str);
    logger.error("reverse :{}",reverse);
}

在这里插入图片描述

2.3.案例3-递归二分查找

	/**
	 * 二分查找
	 * @param source 原始数组
	 * @param target 目标值
	 * @param left 左边界
	 * @param right 右边界
	 * @return 目标值的索引位置
	 */
	public int binaryFind(int source[],int target,int left,int right){
		// 先找到中间值
		int mid = (left + right) >>> 1;
		if (left > right){
			// 如果left > right 直接返回-1
			return -1;
		}
		if (source[mid] < target){
			// 如果中间值小于目标值,则在右边进行寻找
			return binaryFind(source,target,mid+1,right);
		} else if(source[mid] > target){
			// 如果中间值大于目标值 则在左边进行寻找
			return binaryFind(source,target,left,mid-1);
		} else {
			// 如果中间值等于目标值,则返回索引位置
			return mid;
		}
	}


	/**
	 * 二分查找
	 * @param source 原始数组
	 * @param target 目标值
	 * @return 目标值的索引位置
	 */
	public int search(int[] source,int target){
		// 二分查找 递归的终止条件是 left > right
		return binaryFind(source,target,0,source.length-1);
	}


	@Test
	@DisplayName("测试-递归-二分查找")
	public void test3(){
		int[] source = {1,2,3,4,5,6,7,8,9,10};
		int target = 3;
		int index = search(source,target);
		logger.error("index :{}",index);
	}

在这里插入图片描述

2.4.案例4-递归冒泡排序

递归冒泡排序原理:

递归冒泡排序是一种排序算法,它将数组分成已排序和未排序两部分。通过递归地比较相邻元素并交换它们的位置,每次递归都将未排序部分的最大元素移到已排序部分的末尾,直到整个数组有序。

实现思路:

  1. 初始化:将整个数组视为未排序部分。
  2. 递归调用:递归地调用 bubble_sort() 函数来处理未排序部分,直到未排序部分长度为0或1,排序结束。
  3. 比较与交换:在每次递归中,从数组开始处向后遍历,比较相邻元素。如果前一个元素大于后一个元素,则交换它们的位置。
  4. 更新未排序部分:记录每次交换的位置,即最后一次交换的索引,作为下一次递归的边界,确保下一次递归只需处理未排序部分的子数组。
  5. 终止条件:递归终止条件是未排序部分长度为0或1,表示数组已排序完成。

可优化的地方及优势:

  • 优化点:递归冒泡排序在每次递归中,对未排序部分进行了全遍历,可能导致效率较低,尤其是对于大型数组。
  • 优势:递归冒泡排序的主要优势在于其简洁易懂的实现方式,易于理解和实现。

实现突出重点:

  • 递归调用:通过递归调用 bubble_sort() 函数,将排序过程分解为子问题,直到基本情况(未排序部分长度为0或1)得到解决。
  • 边界更新:每次递归后,更新未排序部分的边界,使下一次递归只需处理未排序部分的子数组。
  • 终止条件:设定递归终止条件,确保排序过程能够正确结束。
# 思路
	比较相邻的元素。如果第一个比第二个大,就交换他们两个。
	对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。在这一点,最后的元素应该会是最大的数。
	针对所有的元素重复以上的步骤,除了最后一个。
	持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
# 控制
	1(递归)每次重新划分排序的区间,负责把已经排序的区间进行过滤
	2(循环)负责两两比较交换。

在这里插入图片描述

在这里插入图片描述

	/**
	 * 递归冒泡排序
	 * <ul>
	 *     <li>将数组划分成两部分,[0,j] [j+1,length - 1]</li>
	 *     <li>[0,j] 左边是未排序的部分</li>
	 *     <li>[j+1,length - 1] 右边是已经排序的部分</li>
	 *     <li>在未排序的区间内,相邻的两个元素比较,如果前一个元素大于后一个元素,那么交换位置</li>
	 *
	 * </ul>
	 * @param source
	 */
	public void sort (int [] source){
		bubble_sort(source,source.length-1);

	}

	/**
	 * 递归冒泡排序
	 * @param source 待排序的数组
	 * @param j 未排序的区间的起始位置
	 */
	public void bubble_sort(int [] source,int j){
		// 递归的终止条件是数组的长度为1 或者数组的长度为0
		if (j == 0){
			return;
		}
		// x充 未排序 和已经排序的分界线
		int x = 0;
		// 每次都是从0开始
		for (int i = 0; i < j;i++){
			// 如果前一个元素比后一个元素大,那么交换位置
			if (source[i] > source[i+1]){
				int temp = source[i];
				source[i] = source[i+1];
				source[i + 1] = temp;
				x = i;
			}
		}
		// 递归调用(因为每次最大值都会移动到最后,所以每次的排序区间都往前进行移动)
		bubble_sort(source,x-1);
	}



	@Test
	@DisplayName("测试-递归-冒泡排序")
	public void test4(){
		int[] source = {4,3,2,1,5,6,7};
		sort(source);
		logger.error("source :{}",source);
	}

在这里插入图片描述

2.5.案例5-插入排序

插入排序原理:

插入排序是一种直观的排序算法,类似于整理扑克牌。它从未排序的部分选取元素,并将其插入到已排序的序列中,直到所有元素都排好序为止。

实现思路:

这段代码使用递归来实现插入排序。在递归函数 insertion() 中,每次调用时,它从未排序的部分选择第一个元素 t = source[low],然后将其插入到已排序的序列中的适当位置。

具体实现过程如下:

  1. 从右向左遍历已排序的部分,找到第一个比待插入元素小的位置。
  2. 将比待插入元素大的元素往后移一位,为待插入元素腾出空间。
  3. 插入待排序元素到找到的位置,插入位置为 i + 1,其中 i 是最后一个比待插入元素小的元素的下标。

递归终止条件:

递归的终止条件是当 low 等于数组长度时,表示所有元素都已处理完成,无需继续排序。

在这里插入图片描述

在这里插入图片描述

/**
 * 插入排序
 * @param source 原始数组
 */
public void insert_sort(int[]source){
    // 递归调用插入排序 low 从1开始
    insertion(source,1);
}

/**
 * 插入排序
 * @param source 原始数组
 * @param low 未排序数据的起始位置
 */
private void insertion(int[]source,int low){
    // 递归的终止条件是 low == source.length
    if(low == source.length){
        return;
    }
    // 存储临时变量 (存放low指向的数据)
    int t = source[low];
    // 已经排序区域的指针
    int i = low -1;

    // 从右往左找,只要找到第一个比t小的就能确认插入位置
    while (i >=0 && source[i] > t ){
        // 如果没有找到插入位置 一直循环
        // 空出插入位置
        source[i+1] = source[i];
        i--;
    }
    // 找到了插入位置
    // 将t赋值给i +1 的位置就行了
    source[i + 1]  = t;

    insertion(source,low + 1);

}


@Test
@DisplayName("测试-递归-插入排序")
public void test5(){
    int[] source = {2,4,5,10,7,1};
    insert_sort(source);
    logger.error("source :{}",source);
}

在这里插入图片描述

多路递归

2.案例1-斐波那契数列

  • 每个递归只包含一个自身的调用,称之为single recursion
  • 如果每个递归函数包含多个自身的调用称为multi recursion

f ( n ) = { 0 , n = 0 1 , n = 1 f ( n − 1 ) + f ( n − 2 ) , n > 1 f(n) = \begin{cases} 0&, n = 0 \\ 1&, n = 1 \\ f(n-1)+f(n-2)&, n > 1 \\ \end{cases} f(n)= 01f(n1)+f(n2),n=0,n=1,n>1


	@Test
	@DisplayName("测试-递归-斐波那契数列")
	public void test1(){
		int factorial = factorial(10);
		logger.info("factorial:{}",factorial);
	}


	/**
	 * 斐波那契数列
	 * @param n 传入的参数
	 * @return 返回的结果
	 */
	public int factorial(int n){
		// 递归的出口,当n为0时,返回0,当n为1或者2时,返回
		if (n == 0){
			return 0;
		}
		if (n == 1  || n == 2){
			return 1;
		}
		// 依次往下递归
		return factorial(n-1) + factorial(n -2);
	}

在这里插入图片描述

递归爆栈

1.分析

在Java中,递归爆栈是指递归调用导致调用栈溢出的情况。在解释递归爆栈时,我们可以涉及到Java的内存模型和变量存储位置的分析。

1.1 Java 内存模型:

Java程序在运行时,内存被划分为不同的区域,其中涉及到:

  • 堆(Heap):用于存储对象实例,由Java垃圾回收器进行管理和清理。
  • 栈(Stack):每个线程都有自己的栈,用于存储局部变量、方法调用和部分对象引用。
  • 方法区(Method Area):存储类的结构信息、静态变量、常量等。
  • 程序计数器(Program Counter):记录线程执行的当前位置。

1.2. 递归的内存分析:

在Java中,每次方法调用都会在栈上分配一定的空间,包括方法的参数、局部变量和返回地址。当一个方法被调用时,会将当前方法的上下文(包括参数、局部变量等)推入栈中,当方法执行结束时,栈顶的帧会被弹出。

1.3. 递归爆栈的原因:

递归函数在调用自身时,会持续地将新的调用帧推入栈中,如果递归调用的深度过大,栈空间会耗尽,导致栈溢出错误。

1.4. 变量存储位置分析:

  • 局部变量(Local Variables):在方法执行时,局部变量存储在栈帧中,并且随着方法的结束而被销毁。
  • 实例变量(Instance Variables):实例变量存储在对象的堆内存中,随着对象的创建和销毁而分配和释放。
  • 静态变量(Static Variables):静态变量存储在方法区中,它们在类加载时被初始化,在程序结束时销毁。

2.代码

/**
 * 递归求和
 * @param n 传入的参数
 * @return 返回的结果
 */
public int add(int n){
    if (n == 1){
        return 1;
    }
    return add(n -1)  + n;
}


@Test
@DisplayName("测试-递归-递归求和")
public void test2(){
    int sum = add(11111110);
    logger.error("sum:{}",sum);
}

在这里插入图片描述

3.解决

# 目前只有C++ 和 scala 能针对尾递归优化,所以我们一般需要将递归转为循环来写
  • 尾调用
// 如果函数的最后一步,是调用一个函数,那么成为尾调用
function a(){
    return b();
}

// 下面这个 三段代码并不能称为尾调用
function a(){
    // 虽然调用了函数,但是又用到了外层函数的数值1
    return b() + 1;
}

function a(){
    // 最后异步并非调用函数
    const c = b() + 1
    return c;
}

function a(x){
  // 虽然调用了函数,但是又用到了外层函数的数值x
    return b() + x;
}

4.总结

递归爆栈的问题通常发生在递归调用的深度过大时,导致栈空间耗尽。通过合理控制递归调用深度、优化算法或者考虑使用迭代等方法可以避免这类问题,在Java中,局部变量和方法调用的栈帧管理是导致递归爆栈的关键因素之一。
递归是一种强大的问题解决工具,能够简化问题、提高代码的清晰度和可读性。然而,在使用递归时,需要注意避免潜在的性能问题和堆栈溢出问题。选择适当的场景和合适的算法,可以充分发挥递归的优势,提高程序的效率和可维护性。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1456439.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

代码随想录 Leetcode509. 斐波那契数

题目&#xff1a; 代码&#xff08;首刷自解 2024年2月19日&#xff09;&#xff1a; class Solution { public:int fib(int n) {if (n < 2) return n;/*三个数表示加法算式里的 加数 加数 和*//*初始化*/int leftVal 0;int rightVal 1;int sum 0;for (int i 2; i <…

循环队列|超详细|数据结构学习讲解与笔记

队列元素先进先出队列只允许在线性表的一端进行操作&#xff0c;是一种操作受限的线性表 队列的基本操作 InItQueue(&Q)初始化队列&#xff0c;构造一个空队列 QEmptyQueue(Q)队列判空FullQueue(Q)队列判满EnQueue(&Q , x)入队操作DeQueue(&Q , &x)出队操作G…

数据结构通讲

目录 集合源码详解 一、常见数据结构讲解 1. 线性数据结构 1.1 数组 1.2 队列 1.3 链表 1.3.1 单向链表 1.3.2 双向链表 1.4 栈 2. 非线性数据结构 2.1 树 2.2 二叉树 2.2.1 概念介绍 2.2.2 遍历操作 2.2.3 删除节点 2.2.4 查找局限性 2.2.5 AVL&#xff08; …

应急响应实战笔记02日志分析篇(4)

第4篇:MSSQL日志分析 常见的数据库攻击包括弱口令、SQL注入、提升权限、窃取备份等。对数据库日志进行分析&#xff0c;可以发现攻击行为&#xff0c;进一步还原攻击场景及追溯攻击源。 0x01 MSSQL日志分析 首先&#xff0c;MSSQL数据库应启用日志记录功能&#xff0c;默认配…

Ubuntu系统搭建HadSky论坛并结合内网穿透实现无公网ip远程访问

&#x1f49d;&#x1f49d;&#x1f49d;欢迎来到我的博客&#xff0c;很高兴能够在这里和您见面&#xff01;希望您在这里可以感受到一份轻松愉快的氛围&#xff0c;不仅可以获得有趣的内容和知识&#xff0c;也可以畅所欲言、分享您的想法和见解。 推荐:kwan 的首页,持续学…

消息队列(Message Queue)

目录 一、概念 二、消息队列使用场景 三、消息队列的两种模式 1.点对点模式 2.发布/订阅模式 四、常用消息队列介绍 1.RabbitMQ 1) 主要特性 2&#xff09;安装需要 3&#xff09;优点 4&#xff09;缺点 2.ActiveMQ 1&#xff09;主要特性 2) 安装需要 3&#xff09;优…

专业140+总分420+南京信息工程大学811信号与系统考研经验南信大电子信息与通信工程,真题,大纲,参考书

今年顺利被南信大电子信息录取&#xff0c;初试420&#xff0c;专业811信号与系统140&#xff08;Jenny老师辅导班上140很多&#xff0c;真是大佬云集&#xff09;&#xff0c;今年应该是南信大电子信息最卷的一年&#xff0c;复试线比往年提高了很多&#xff0c;录取平均分380…

Spring | Spring事务管理

目录&#xff1a; 1.Spring事务管理 “含义”2.Spring事务管理的 三个“核心接口” :2.1 PlatformTransactionManager 接口PlatformTransactionManager接口 (3个“事务操作方法”)PlatformTransactionManager接口的 “实现类” 2.2 TransactionDefinition 接口2.3 TransactionS…

flex布局、grid布局中的justify、align属性

一、grid中justify-content和justify-items的区别&#xff08;align同理&#xff09;&#xff08;flex中的justify-items属性无效&#xff09; justify-content:相对于父级&#xff0c;水平方向将整体进行对齐 justify-items:针对于每个格&#xff0c;将里面的内容进行对齐&a…

PAT (Basic Level) Practice | 数字分类

答案 #include <stdio.h> int main() {static int N, a, b[5], flag 0, c, flag1 0;scanf("%d", &N);for(int i 0; i < N; i) {scanf("%d", &a);if(a % 5 0) {if(a % 2 0) {b[0] a;}} else if(a % 5 1) {if(flag 0) {b[1] a;fl…

如何根据需求理解CPU、SoC和MCU的区别

在当今数字化的世界中&#xff0c;我们经常听到关于CPU、SoC和MCU的名词&#xff0c;它们都是计算机科学和电子工程领域中的重要组成部分。然而&#xff0c;这三者之间存在着明显的区别。本文将深入探讨CPU&#xff08;中央处理器&#xff09;、SoC&#xff08;系统芯片&#x…

如何使用Net2FTP部署本地Web网站并实现远程文件共享

文章目录 1.前言2. Net2FTP网站搭建2.1. Net2FTP下载和安装2.2. Net2FTP网页测试 3. cpolar内网穿透3.1.Cpolar云端设置3.2.Cpolar本地设置 4.公网访问测试5.结语 1.前言 文件传输可以说是互联网最主要的应用之一&#xff0c;特别是智能设备的大面积使用&#xff0c;无论是个人…

重启项目后Session依旧存在的问题研究

1. 背景 最近在开发对接智谱AI超拟人大模型&#xff0c;放了方便在pom.xml中引入了热部署相关的依赖。 <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-devtools</artifactId><scope>runtime</sco…

一元函数微分学【高数笔记】

1. 什么是微分&#xff1f;什么是微商&#xff1f; 2. 什么是函数的微分&#xff1f; 3. 在函数的微分中&#xff0c;有什么样的关系&#xff1f; 4. 一元函数的微分运用在什么题型中&#xff1f; 5. 什么是一元函数&#xff1f;

【JavaScript】实现下--刘谦春晚魔术:约瑟夫环的数学魅力!

2024年春晚刘谦的魔术堪称惊艳全场&#xff0c;那么他这个魔术实现的原理是什么呢&#xff1f;今天&#xff0c;就让咱们使用 JS 是实现这个魔术。 约瑟夫环问题简介&#x1f3b4;&#x1f50e; 约瑟夫环问题源自古罗马&#xff0c;由历史学家约瑟夫斯提出&#xff0c;而它的…

【STM32】硬件SPI读写W25Q64芯片

目录 基础知识回顾&#xff1a; SPI外设简介 SPI框图 主模式全双工连续传输 非连续传输 初始化SPI外设 核心代码 - 交换一个字节 硬件接线图 Code 程序配置过程 MySPI.c MySPI.h W25Q64.c W25Q64.h W25Q64_Ins.h main.c 基础知识回顾&#xff1a; 【STM32】SP…

Linux CentOS系统安装SQL Server并结合内网穿透实现公网访问本地数据

&#x1f525;博客主页&#xff1a; 小羊失眠啦. &#x1f3a5;系列专栏&#xff1a;《C语言》 《数据结构》 《C》 《Linux》 《Cpolar》 ❤️感谢大家点赞&#x1f44d;收藏⭐评论✍️ 前些天发现了一个巨牛的人工智能学习网站&#xff0c;通俗易懂&#xff0c;风趣幽默&…

Android---Retrofit实现网络请求:Java 版

简介 在 Android 开发中&#xff0c;网络请求是一个极为关键的部分。Retrofit 作为一个强大的网络请求库&#xff0c;能够简化开发流程&#xff0c;提供高效的网络请求能力。 Retrofit 是一个建立在 OkHttp 基础之上的网络请求库&#xff0c;能够将我们定义的 Java 接口转化为…

汽车网络安全--关于供应商网络安全能力维度的思考

目录 1.关于CSMS的理解 2.OEM如何评审供应商 2.1 质量评审 2.2 网络安全能力评审 3.小结 1.关于CSMS的理解 最近在和朋友们交流汽车网络安全趋势时&#xff0c;讨论最多的是供应商如何向OEM证明其网络安全能力。 这是很重要的一环&#xff0c;因为随着汽车网络安全相关强…

一文解千惑:3D PCB电路板功能分区的关键要素

随着科技的不断发展&#xff0c;3D PCB电路板已成为电子工程领域的新宠。与传统的平面电路板相比&#xff0c;3D PCB电路板具有更多的优势&#xff0c;如更高的集成度、更强的信号传输能力和更小的体积。然而&#xff0c;要充分利用3D PCB电路板的优点&#xff0c;功能分区的关…