首页 > 其他分享 >迭代与递归--你被递归搞晕过吗?

迭代与递归--你被递归搞晕过吗?

时间:2024-06-07 16:36:10浏览次数:21  
标签:arr 调用 递归 迭代 -- int 循环

前言

算法中会经常遇见重复执行某个任务,那么如何实现呢,本文将详细介绍两种实现方式,迭代与递归。


本文基于 Java 语言。

一、迭代

迭代(iteration),就是说程序会在一定条件下重复执行某段代码,直到条件不再满足。

在 Java 语言中,可以理解为就是循环遍历,Java 中有多种遍历方式,如 for 循环、while 循环。接下来讲解如何进行代码实现。

1. for 循环

这个是最常见的迭代形式之一,适合在预先知道迭代次数(遍历次数)时使用

比如,我们通过 for 循环计算1 + 2 + ... + n的结果。

public  int forLoop(int n){
    int result = 0;
    for (int i = 0; i <= n; i++) {
        result += i;
    }
    return result;
}

流程图如下:

图片源自 Hello 算法

2. while 循环

与 for 循环类似,while 循环也是一种实现迭代的方法。在 while 循环中,程序每轮都会先检查条件,如果条件为真,则继续执行,否则就结束循环。

比如,我们通过 while 循环计算1 + 2 + ... + n的结果。

public int whileLoop(int n) {
    int result = 0;
    int i = 1; // 初始化条件变量

    while (i <= n) {
        result += i;
        i++; // 更新条件变量
    }
    return result;
}

3. 嵌套循环

我们可以在一个循环结构内嵌套另一个循环结构,每一次嵌套都是一次“升维”,将会使时间复杂度提高至“立方关系”、“四次方关系”,以此类推。

下面以冒泡排序为例,原理是从后两两对比,更小的往前放。数组内位置交换,不懂得话看一下我总结的另一篇文字--《位运算详解》

public class FuXing {
    public static void main (String[] args) {
        int[] arr =  {9,6,1,5,2,3,4,7,8};
        System.out.println("排序后:" + Arrays.toString( bubbleSort(arr)));
    }

    /**
     * 冒泡排序
     */
    public static void bubbleSort (int[] arr){
        // 过滤无序排序的数组
        if(arr == null || arr.length < 2){
            return;
        }
        // 倒序遍历所有的数
        for (int i = arr.length - 1; i > 0; i--) {
            //两两比较,更小的往前放
            for (int j = 0; j < i; j++) {
                if(arr[j] > arr[j+1]){
                    swap(arr,j,j+1);
                }
            }
        }
    }

    /**
     * 数组内位置交换
     */
    public static void swap(int[] arr,int i ,int j){
        arr[i] ^= arr[j];
        arr[j] ^= arr[i];
        arr[i] ^= arr[j];
    }
}

二、递归

1. 简介

递归(recursion)是一种算法策略,通过直接或者间接地调用自身来解决问题,将大问题分解成更多的子问题,主要解决同一大类问题。

从数据结构角度看,递归天然适合处理链表、树和图的相关问题,因为它们非常适合用分治思想进行分析。

主要包含两个阶段:

  1. 递: 程序不断深入地调用自身,通常传入更小或更简化的参数,直到达到“终止条件”。
  2. 归: 触发“终止条件”后,程序从最深层的递归函数开始逐层返回,汇聚每一层的结果。

递归的三个重要要素(须记住):

  1. 终止条件: 用于决定什么时候由“递”转“归”。
  2. 递归调用: 对应“递”,函数调用自身。
  3. 返回结果: 对应“归”,将当前递归层级的结果返回至上一层

2. 如何设计递归

在写一些递归算法时,应该如何去操作呢?这里分享一点个人经验,当我们需要写一个递归程序时:

  1. 找重复: 找到的相同的子问题;
  2. 找变化: 聚焦于某一个子问题,查看变化的量,通常会作为参数,这时可定义函数体;;
  3. 找出口: 也就是找终止条件。

我们需要明确递归函数本身是为了做什么,返回值是什么,从最终想要的答案出发,逐步寻找上一层的答案,并且用它们构造当前层的答案。

比如,我们通过递归计算1 + 2 + ... + n结果。

  1. 找重复:n 的累加 = n + (n - 1)的累加,n - 1 就是原问题的重复,即子问题;
  2. 找变化:聚焦于某一个子问题,这里的变化就是 n 的自减,递归参数就是 n - 1;
  3. 找出口:终止条件就是 n = 1,n 为 1 时无法计算阶乘。

代码如下:

public int recursion (int n) {
     //终止条件
     if (n == 1) return 1;
     //递:递归调用
     int result = recursion(n - 1);
     //归:返回结果
     return result + n;
 }

流程图如下:

image.png

图片源自 Hello 算法

3. 调用栈

在 Java 中,递归函数每次调用自身时,JVM 都会为新开启的函数分配内存,以存储局部变量、调用地址和其他信息等。也就是在 Java 虚拟机栈中新生成一个栈帧。

因为栈空间是随着函数结果返回才会释放,所以递归通常比迭代更加消耗内存空间。且调用递归函数会产生额外的开销,因此时间效率上也会受影响。过深的递归操作,可能导致栈内存溢出。

image.png

4. 尾部递归

如果函数在返回前的最后一步才进行递归调用,则该函数可以被编译器或解释器优化,使其在空间效率上与迭代相当。这种情况被称为尾递归(tail recursion)。

递归方式 说明
普通递归 当函数返回到上一层级的函数后,需要继续执行代码,因此系统需要保存上一层调用的上下文。
尾递归 递归调用是函数返回前的最后一个操作,这意味着函数返回到上一层级后,无须继续执行其他操作,因此系统无须保存上一层函数的上下文。

比如,还是通过递归计算1 + 2 + ... + n结果。尾部递归的求和操作是在“递”的过程中执行的,“归”的过程只需层层返回。而普通递归每层返回后都要再执行一次求和操作。

代码如下:

public int tailRecursion (int n, int result) {
    // 终止条件
    if (n == 0) return res;
    
    // 尾递归调用
    return tailRecursion(n - 1, res + n);
 }

流程图如下:

image.png

图片源自 Hello 算法

5. 递归树

当处理与“分治”相关的算法问题时,递归往往比迭代的思路更加直观、代码更加易读。以“斐波那契数列”为例。

斐波那契数列是指这样一个数列:0,1,1,2,3,5,8,13,21,34… 这个数列从第3项开始 ,每一项都等于前两项之和,求该数列的第 n 个数字。

回顾一下上面说的方法,进行设计递归函数:

1. 找重复: 原问题的重复,即子问题。我们设斐波那契数列的第 n 个数字为 f(n),可得:

  • f(3) = f(2) + f(1) = 0
  • f(4) = f(3) + f(2) = 2
  • f(n) = f(n - 1) + f(n - 2)

这里每一项都等于前两项之和

2. 找变化: 聚焦于某一个子问题,查看变化的量,会作为参数,这时可定义函数体。

如,f(n) = f(n - 1) + f(n - 2),这里的变化量有两个,n -1 和 n - 2,即分别做为入参。

函数体如下:

int fib(int n) {
    // 递归调用 f(n) = f(n-1) + f(n-2)
    int res = fib(n - 1) + fib(n - 2);
    
    // 返回结果 f(n)
    return res;
}

**3. 找出口:**终止条件就是 n = 1 或 n = 2,n 为 1 或 2 时无法拆分为子问题,则完整函数如下。

int fib(int n) {
    // 终止条件 f(1) = 0, f(2) = 1
    if (n == 1 || n == 2) return n - 1;
    // 递归调用 f(n) = f(n-1) + f(n-2)
    int res = fib(n - 1) + fib(n - 2);
    // 返回结果 f(n)
    return res;
}

观察以上代码,我们在函数内递归调用了两个函数,这意味着从一个调用产生了两个调用分支。如下图所示,这样不断递归调用下去,最终将产生一棵层数为 n 的递归树(recursion tree)。

image.png

图片源自 Hello 算法

6. 如何避免陷入递归

不知道你有没有被递归逻辑搞晕的经历,递归调用步骤中,经常会有很多深层操作,所以我们可能会想看看每一层的返回值,我们就会一层层深入的去思考下一步的逻辑,随着思考层数加深,就会觉得递归越来越困难,心态就崩了。

递归不像迭代是正向思维,是一个逆向思维的过程,很多情况其实并不好理解,也不清楚什么时候该用,很容易被层层嵌套搞晕。

image.png

那么如何解决这种问题呢?我们可以这么理解,迭代是正向思维,从已知去推未知,也就是从最开始的已知的基础步骤,不断的重复去累计,直到任务完成。

递归是未知推已知,是将原问题分解成多个子问题,我们并不知道子问题的解,所以需要不断地通过递归分解,直到分解成基本的已知的解,最后在统一起来。

我们被搞晕的主要因素还是忍不住的跟随者递归函数去不断的深入思考,要想避免这种情况。只要我们不再探究它内部的深层操作,将所有的递归调用的操作当成一个整体,相信它自己就能完成使命

比如,我们需要把大象装进冰箱,一共需要几步?是不是只要打开冰箱门,把大象放进去,然后关掉冰箱门。我们把大象放进冰箱当作终止条件,而递归调用过程当作把大象,我们并不需要知道大象怎么放进冰箱的,即我们不需要知道递归是怎么执行的。

image.png

这样我们在看一些递归算法时,可以避免陷入循环的递归逻辑中。

三、两者对比

对比维度 迭代 递归
实现方式 循环结构 函数调用自身
时间效率 效率通常较高,无函数调用开销 每次函数调用都会产生开销
内存使用 通常使用固定大小的内存空间 累积函数调用可能使用大量的栈空间
适用问题 适用于简单循环任务,代码直观、可读性好 适用于子问题分解,如树、图、分治、回溯等,代码结构简洁、清晰

尽管迭代和递归在很多情况下可以互相转化,但不一定值得这样做,有以下两点原因:

  1. 转化后的代码可能更加难以理解,可读性更差。
  2. 对于某些复杂问题,模拟系统调用栈的行为可能非常困难。

总之,选择迭代还是递归取决于特定问题的性质。在编程实践中,权衡两者的优劣并根据情境选择合适的方法至关重要。


参考:

[1] 靳宇栋. Hello 算法.

标签:arr,调用,递归,迭代,--,int,循环
From: https://www.cnblogs.com/fuxing/p/18237432

相关文章

  • 软件测试笔试题
    1、负载测试(LoadTest):负载测试是一种性能测试,指数据在超负荷环境中运行,程序是否能够承担。关注点:howmuch2、压力测试(StressTest):压力测试(又叫强度测试)也是一种性能测试,它在系统资源特别低的情况下软件系统运行情况,目的是找到系统在哪里失效以及如何失效的地方。3、极限测试E......
  • linux系统-umask详解
    转自:https://blog.csdn.net/kld230/article/details/134508978  umask(userfile-creatiopnmodemask)是linux中的一个命令,用于为用户文件创建权限掩码,语法“umask[-S][权限掩码]”;其中,“权限掩码”是由3个八进制的数字所组成,将现有的存取权限减掉权限掩码后,即可产生建立文......
  • 点分治 学习笔记
    引入在点分治的过程中,它的遍历顺序会遍历每棵子树的重心,而这棵由重心生成的树会产生一棵新的树,便是点分树。常用来解决树上与树的形态无关的路径问题。过程如下图,它的点分树是它自己。因此可以看出一棵树的点分树可能是它本身。性质因为点分树\(\mathcal{O}(\logn)\)......
  • java 常用的辅助类:CountDownLatch,CyclicBarrier,Semaphore
    java常用的辅助类:1.CountDownLatch减法计数器2.CyclicBarrier加法计数器3.Semaphore同一时刻只允许固定(3)个线程执行,完成后另外固定(3)个线程再继续执行1.CountDownLatch:减法计数器.等待所有的执行完成CountDownLatchcountDownLatch=newCountDownLatch(5);//减法计数......
  • 不凡学院笔记
    Vue前端性能难题Vue前端性能问题通常涉及到如何优化组件渲染性能,减少不必要的DOM更新,以及处理大量数据的渲染和滚动性能。以下是一些常见的Vue前端性能问题及其解决方案:避免在v-for循环中使用key,除非你明确知道元素的交互方式。解决方案:使用唯一且稳定的ID作为key。避免在模板......
  • 计算机网络基础
    什么是计算机网络计算机网络是指两台或更多的计算机组成的网络,在同一个网络中,任意两台计算机都可以直接通信,因为所有计算机都需要遵循同一种网络协议。若计算机各自通讯协议不统一,则无法进行通讯网络编程中两个主要问题如何准确定位网络中的一台或多台主机找到主机之后如何进......
  • 视频监控管理平台LntonCVS视频汇聚平台充电桩视频监控应用方案
    随着新能源汽车的广泛使用,公众对充电设施的安全性和可靠性日益重视。为了提高充电桩的安全管理和站点运营效率,LntonCVS公司推出了一套全面的新能源汽车充电桩视频监控与管理解决方案。该方案通过安装高分辨率摄像头,对充电桩及其周边区域进行不间断监控,确保充电环境的安全无虞......
  • C++数据结构之:哈希表Hash
    摘要:  it人员无论是使用哪种高级语言开发东东,想要更高效有层次的开发程序的话都躲不开三件套:数据结构,算法和设计模式。数据结构是相互之间存在一种或多种特定关系的数据元素的集合,即带“结构”的数据元素的集合,“结构”就是指数据元素之间存在的关系,分为逻辑结构和存储......
  • 使用Verdaccio创建一个本地私有库,并应用
    安装verdaccio       npminstall-gverdaccio直接verdaccio启动    可以先右上角登录 然后先使用npmcreatevite@latest然后创建属于自己的一个vue3项目  vite-project(随便起了个名)npmi一下npmrundev 跑起来看看然后创建下列文件夹......
  • RainBond 制作应用并上架【以ElasticSearch为例】
    文章目录安装ElasticSearch集群第1步:添加组件第2步:查看组件第3步:访问组件制作ElasticSearch组件准备工作ElasticSearch集群原理尝试Helm安装ES集群RainBond制作ES思路源代码Dockerfiledocker-entrypoint.shelasticsearch.yml......