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

前言

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


本文基于 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. 找出口:当返回值为 1 时,终止条件就是 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. 找出口: 当返回值为 1 或 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 算法.

posted @ 2024-06-07 16:23  fuxing.  阅读(531)  评论(0编辑  收藏  举报