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

· 浏览次数 : 4

小编点评

前言 算法中经常需要重复执行某个任务,本文将详细介绍两种实现方式:迭代与递归。本文基于 Java 语言。 一、迭代与递归 1. 迭代 迭代指的是在一定条件下,程序重复执行某段代码,直到条件不再满足。在 Java 中,可以通过 for 循环、while 循环等方式实现迭代。 1.1 for 循环 for 循环是最常见的迭代形式,适合在预先知道迭代次数时使用。例如,计算1 + 2 + ... + n的结果。 ```java public int forLoop(int n) { int result = 0; for (int i = 0; i <= n; i++) { result += i; } return result; } ``` 1.2 while 循环 while 循环也是实现迭代的方法,通过检查条件是否满足,决定是否继续执行。例如,计算1 + 2 + ... + n的结果。 ```java public int whileLoop(int n) { int result = 0; int i = 1; // 初始化条件变量 while (i <= n) { result += i; i++; // 更新条件变量 } return result; } ``` 1.3 嵌套循环 在嵌套循环中,外层循环控制循环次数, 内层循环控制循环顺序。例如,冒泡排序。 2. 递归 递归是一种算法策略,通过直接或间接地调用自身来解决问题,将大问题分解成更多的子问题。 2.1 递归简介 递归包含以下两个阶段: - 递:程序不断调用自身,通常传入更小或更简化的参数,直到达到终止条件。 - 归:触发终止条件后,程序从最深层的递归函数开始逐层返回,汇聚每一层的结果。 2.2 递归设计要素 - 终止条件:用于决定什么时候由“递”转“归”。 - 递归调用:对应“递”,函数调用自身。 - 返回结果:对应“归”,将当前递归层级的结果返回至上一层。 2.3 递归示例 以计算1 + 2 + ... + n结果为例:找重复、找变化、找出口。 2.3.1 示例代码 ```java public int recursion(int n) { if (n == 1) return 1; int result = recursion(n - 1); return result + n; } ``` 三、两者对比 3.1 时间效率与内存占用 - 时间效率:迭代通常效率较高,有固定的执行步骤,不会有额外的函数调用开销。 - 内存占用:递归涉及多次函数调用,每次都会产生新的栈帧,可能耗尽内存空间。 3.2 适用场景 - 适用问题:复杂系统调用栈行为难以模拟,如树、图、分治相关的问题。 - 代码特点:迭代适合简单问题,代码简单易读;递归适合子问题分解的问题,但结构可能较复杂。 3.3 实践建议 - 权衡利弊:根据具体问题选择迭代或递归,避免不必要的递归调用。 - 易于理解:避免陷入递归的层层逻辑,将其作为整体看待。 参考文献: 靳宇栋. Hello 算法.

正文

前言

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


本文基于 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 算法.

与迭代与递归--你被递归搞晕过吗?相似的内容:

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

前言 算法中会经常遇见重复执行某个任务,那么如何实现呢,本文将详细介绍两种实现方式,迭代与递归。 本文基于 Java 语言。 一、迭代 迭代(iteration),就是说程序会在一定条件下重复执行某段代码,直到条件不再满足。 在 Java 语言中,可以理解为就是循环遍历,Java 中有多种遍历方式,

周而复始,往复循环,递归、尾递归算法与无限极层级结构的探究和使用(Golang1.18)

所有人都听过这样一个歌谣:从前有座山,山里有座庙,庙里有个和尚在讲故事:从前有座山。。。。,虽然这个歌谣并没有一个递归边界条件跳出循环,但无疑地,这是递归算法最朴素的落地实现,本次我们使用Golang1.18回溯递归与迭代算法的落地场景应用。 递归思想与实现 递归思想并非是鲜为人知的高级概念,只不过

C++算法之旅、09 力扣篇 | 常见面试笔试题(上)算法小白专用

算法学习笔记,记录容易忘记的知识点和难题。详解时空复杂度、50道常见面试笔试题,包括数组、单链表、栈、队列、字符串、哈希表、二叉树、递归、迭代、分治类型题目,均带思路与C++题解

NumPy 数组迭代与合并详解

NumPy 数组迭代 NumPy 数组迭代是访问和处理数组元素的重要方法。它允许您逐个或成组地遍历数组元素。 基本迭代 我们可以使用 Python 的基本 for 循环来迭代 NumPy 数组。 一维数组迭代: import numpy as np arr = np.array([1, 2, 3,

3.0 Python 迭代器与生成器

当我们需要处理一个大量的数据集合时,一次性将其全部读入内存并处理可能会导致内存溢出。此时,我们可以采用迭代器`Iterator`和生成器`Generator`的方法,逐个地处理数据,从而避免内存溢出的问题。迭代器是一个可以逐个访问元素的对象,它实现了`python`的迭代协议,即实现了`__iter__()`和`__next__()`方法。通过调用`__next__()`方法,我们可以逐个访问迭代

10.1 C++ STL 模板适配与迭代器

STL(Standard Template Library)标准模板库提供了模板适配器和迭代器等重要概念,为开发者提供了高效、灵活和方便的编程工具。模板适配器是指一组模板类或函数,它们提供一种适配机制,使得现有的模板能够适应新的需求。而迭代器则是STL中的令一种重要的概念,它是一个抽象化的数据访问机制,通过迭代器可以遍历STL容器中的元素。适配器与迭代器两者的紧密配合,使得开发者能够高效地处理容器

7.1 C++ STL 非变易查找算法

C++ STL 中的非变易算法(Non-modifying Algorithms)是指那些不会修改容器内容的算法,是C++提供的一组模板函数,该系列函数不会修改原序列中的数据,而是对数据进行处理、查找、计算等操作,并通过迭代器实现了对序列元素的遍历与访问。由于迭代器与算法是解耦的,因此非变易算法可以广泛地应用于各种容器上,提供了极高的通用性和灵活性。

ArchKeeper (开篇):架构守护平台的问题与理念

在敏捷开发环境下,系统通过迭代增量的交付价值,系统架构也是如此。团队不可能在项目之初就建立完美的系统架构,系统架构应该随着系统迭代不断演进。架构演进和架构腐化是看待架构的不同视角:架构腐化着眼于现状,架构演进侧重于未来架构腐化不可避免,随着时间流转腐化现象必然发生。而我们需要做的是:通过某种方式及早发现和修正

Django测试与持续集成:从入门到精通

title: Django测试与持续集成:从入门到精通 date: 2024/5/18 16:38:41 updated: 2024/5/18 16:38:41 categories: 后端开发 tags: Django 测试 CI/CD 优化 部署 监控 迭代 第1章:Django测试基础 1.1

[转帖]Redis Scan 原理解析与踩坑

https://www.lixueduan.com/posts/redis/redis-scan/ 主要分析了 Redis Scan 命令基本使用和具体实现,包括 Count 参数与 Scan 总耗时的关系,以及核心的逆二进制迭代算法分析。 1. 概述 由于 Redis 是单线程在处理用户的命令,而