递归?从那只爬楼青蛙说起...

前言

前面的一篇文章里,提及了递归的概念,写博文的时候脑海里就跳出了之前遇到的一道题目-青蛙爬楼梯,题目大概是这样:

一只青蛙要从底部跳上n层高的楼梯,每次只能跳一阶或两阶楼梯,总共有多少种走法?

初遇题目时,思维较为混乱;当时所能想到方法是从结束条件往前推,然后就把它当成数学题找规律去了…并没有意识到可以使用递归去解决这类问题。那么什么是递归呢?

递归思想

递归定义

大部分的递归定义都由三个部分构成:基本情况的定义递归法则递归结束条件。如果定义的对象是无限的,那么可以省略第三个部分(递归结束条件)。比如说,可以用递归定义的方式来定义如下的一个自然数集上的函数 f f f :
f ( 0 ) = 1   . ∀ n > 0 , f ( n ) = n × f ( n − 1 )   . f(0) = 1\,. \\ \quad\forall n > 0,f(n) = n\times f(n-1)\,. f(0)=1.n>0,f(n)=n×f(n1).
这个定义在逻辑上是成立的,因为它首先定义了 f f f 在最小的自然数 0上的取值,接下来对每个大于零的自然数 n n n ,只要重复有限多次定义的过程,最终就会回到对0的定义上。这样定义出的函数 f f f 就是阶乘函数 1

而当我们把基本情况的定义去掉后,它便成了一个循环定义。
如下例:定义建立在整数集上的函数 g g g
∀ n ∈ Z , g ( n ) = g ( n − 1 ) + 1   . \quad\forall n \in\mathbb Z, g(n) = g(n-1)+1\,. nZ,g(n)=g(n1)+1.
则我们永远无法确定 g g g 的取值,这便是循环定义

小结

简而言之,我们能找出一个模型,其包含以下两个条件,即可以用递归的思想解决:

  • 递推条件:能把问题分解成规模更小,但和原问题有着类似解法的问题。
  • 终止条件:必须存在可到达的中止条件 (否则就是循环定义了)

这里需要注意的是:递归并不是简单的自己调用自己,它是一种分析和解决问题的方法和思想,这种思想叫做 分治 ,而递归只是其实现的一种方法。这类思想解决的典型问题有汉诺塔问题,斐波那契数列,二分查找问题,快速排序问题等 2

在这里插入图片描述

青蛙爬楼梯

问题描述

回到开头描述的问题:

一只青蛙要从底部跳上 n 层 高的楼梯,每次只能跳一阶或两阶楼梯,总共有多少种走法?

解题思路

假设青蛙跳 n 层 楼梯的走法为 f ( n ) f(n) f(n) ,按照最后一步的走法划分,可将 f ( n ) f(n) f(n) 分为以下两类:

  • A:最后一步跳了一级楼梯
  • B:最后一步跳了两级楼梯

而达到 A情况 的走法有 f ( n − 1 ) f(n-1) f(n1) 种,达到 B情况 的走法有 f ( n − 2 ) f(n-2) f(n2) 种。那么可以得到:

f ( n ) = f ( n − 1 ) + f ( n − 2 ) f(n) = f(n-1) + f(n-2) f(n)=f(n1)+f(n2)

当然,当 n = 1 n = 1 n=1 时,显而易见 f ( n ) = 1 f(n) = 1 f(n)=1 ;当 n = 2 n = 2 n=2 时, f ( n ) = 2 f(n) = 2 f(n)=2

因此可归纳为以下模型:
f ( n ) = { 1 , ( n = 1 ) 2 , ( n = 2 ) f ( n − 1 ) + f ( n − 2 ) , ( n ≥ 2 ) f(n) = \left\{ \begin{array}{c} 1,(n = 1)\\ 2,(n = 2)\\ f(n-1) + f(n-2),(n \ge 2 )\\ \end{array} \right. f(n)=1(n=1)2(n=2)f(n1)+f(n2)(n2)

编码实现

根据上文梳理出的模型,我们很容易就能写出以下函数:

int recursive(int n){
    if (1 == n)
        return 1;
    if (2 == n)
        return 2;
    return recursive(n-1) + recursive(n-2);
}

结果

此处设置阶梯数为10,测试结果如下

在这里插入图片描述
咋看起来很完美,再测测就扎心了。

当阶梯数为50时已经发现已经超出整型可显示范围了
这个好办,long intlong long int 解决

当阶梯数为100时已经计算不出来了…
随着阶梯数的增加,计算的时间越来越长(高耗低效);而且还存在 栈溢出 的风险。

剖析

这里先解释为什么会导致栈溢出,而想弄清楚这个问题,需要对内存模型有一定的了解;如果这部分内容你已经熟悉了,可以直接跳过~

C语言内存模型

下图是一个典型的C内存空间分布 3
在这里插入图片描述
下面简单过一下上图所出现的几个区域 4

  • (heap):用来存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减。当进程调用 malloc() 分配内存时,新分配的内存就被动态添加到堆上,当进程调用 free()释放内存时,会从堆中剔除。一般由程序员分配和释放,若程序员不释放,程序结束时有可能由OS回收。

  • (stack):存放程序中的局部变量(但不包括static声明的变量,static变量放在数据段中)。同时,在函数被调用时,栈用来传递参数和返回值。

  • BSS段(Block Started by Symbol): 又称未初始化数据区,用来存放程序中未初始化的全局变量的内存区域。

  • 数据段(data segment): 用来存放程序中已初始化的静态变量(包括全局静态变量和局部静态变量)和常量数据(如字符串常量)的内存区域 5

  • 代码段(text segment): 用来存放程序执行代码的内存区域。通常,代码区是可共享的(即另外的执行程序可以调用它),因为对于频繁被执行的程序,只需要在内存中有一份代码即可。代码区通常是只读的,使其只读的原因是防止程序意外地修改了它的指令。另外,代码区还规划了局部变量的相关信息 5

需要注意的是,这里的堆、栈准确而言应该叫堆内存栈内存;需要和数据结构中的堆、栈区分开来。

  • 数据结构中的堆和栈的重点在于数据的存放方式为 FIFOLIFO

  • 而堆内存、栈内存属于操作系统的范畴,是指内存空间 6

    • 堆内存。按需申请、动态分配;例如 C 中的 malloc( ) 。由于内存中的空闲空间并不是连续的,而是不同程序占用了不同的一块一块内存,即使是同一个程序也可能占用了不同地方的多块内存。操作系统中则会对这些空间进行统一的管理,在应用程序提出申请时,就会从堆中按照一定算法找出一块可用内存,标记占用空间等信息之后返回其起始地址给程序。在程序结束之前,操作系统不会删除已经申请的内存,而是要靠程序主动提出释放的请求(free( )delete( )),如果使用后忘记释放,就会造成内存泄漏。因此堆基本上可以理解为当前可以使用的空闲内存,但是其申请和释放都要程序员自己写代码管理。
    • 栈内存。其大小在编译期时由编译器参数决定,用于局部变量的存放或者函数调用栈的保存。在 C 中如果声明一个局部变量,它存放的地方就在栈中,而当这个局部变量离开其作用域之后,所占用的内存则会被自动释放,因此在 C 中局部变量也叫自动变量。栈的另一个作用则是保存函数调用栈,这时和数据结构的栈就有关系了。在函数调用过程中,常常会多层甚至递归调用。每一个函数调用都有各自的局部变量值和返回值,每一次函数调用其实是先将当前函数的状态压栈,然后在栈顶开辟新空间用于保存新的函数状态,接下来才是函数执行。当函数执行完毕之后,栈先进后出的特性使得后调用的函数先返回,这样可以保证返回值的有序传递,也保证函数现场可以按顺序恢复。操作系统的栈在内存中由高地址向低地址增长,也即低地址为栈顶,高地址为栈底。这就导致了栈的空间有限制,一旦局部变量申请过多(例如开个超大数组),或者函数调用太深(例如递归太多次),那么就会导致栈溢出(Stack Overflow),操作系统这时候就会直接把程序杀掉。

函数调用过程

归纳一下函数调用过程,可概括为以下五个步骤:

  • 开辟空间用于保存新的函数状态。
  • 实参赋值给形参。
  • 函数体执行。
  • 释放空间。
  • 返回。

实际上,上面的过程可作为简单理解,若你觉得 意(reng)犹(you)未(yu)尽(li)

可以翻看下面几篇较硬核的详细解析:

既然递归太深很容易出现这样的问题,那么我们该如何避免呢?这就涉及到其优化。

递归的优化

缓存策略

我们先来看一下,如果需要计算 f ( 5 ) f(5) f(5),按照上面的算法:

f ( 5 ) = f ( 4 ) + f ( 3 ) f(5)=f(4)+f(3) f(5)=f(4)+f(3)
f ( 4 ) = f ( 3 ) + f ( 2 ) f(4)=f(3)+f(2) f(4)=f(3)+f(2)
f ( 3 ) = f ( 2 ) + f ( 1 ) f(3)=f(2)+f(1) f(3)=f(2)+f(1)
f ( 3 ) = f ( 2 ) + f ( 1 ) f(3)=f(2)+f(1) f(3)=f(2)+f(1)

可以发现这个 f ( 3 ) f(3) f(3)会计算两遍。那么我们是否可以避免重复计算呢?

答案是肯定的。

对于重复计算的问题,我们可以采用缓存的策略,将计算过的结果缓存起来,每次计算前先查找是否有缓存,有的话就直接返回缓存值,仅当不存在缓存时再调用递归算法。

long long int cache[100] = {0};

long long int recursive(int n){

    if (cache[n] != 0)
    {
       return cache[n];
    }
    
    if (1 == n || 2 == n)
    {
        cache[n] = n;
        return n;
    }
    else
    {
        cache[n] = recursive(n-1) + recursive(n-2);
        return cache[n];
    }
}

测试一下,这回可以很快的跑出 f ( 50 ) f(50) f(50)

在这里插入图片描述
由于 f ( 100 ) f(100) f(100)long long int 的数据类型下仍然溢出,这里截取 f ( 99 ) f(99) f(99)运行结果。

在这里插入图片描述

递归转非递归

我们总是在夜半三更时憧憬阳光,在艳阳高照里向往月色。

仅采用一种方法并不能满足我,为此还需尝试其他可能。

我们再观察 f ( 5 ) f(5) f(5)的计算过程:

f ( 5 ) = f ( 4 ) + f ( 3 ) f(5)=f(4)+f(3) f(5)=f(4)+f(3)
f ( 4 ) = f ( 3 ) + f ( 2 ) f(4)=f(3)+f(2) f(4)=f(3)+f(2)
f ( 3 ) = f ( 2 ) + f ( 1 ) f(3)=f(2)+f(1) f(3)=f(2)+f(1)
f ( 2 ) = 2 f(2)=2 f(2)=2
f ( 1 ) = 1 f(1)=1 f(1)=1

可以发现:
计算一个新值 f ( 5 ) f(5) f(5)依赖于 f ( 4 ) f(4) f(4) f ( 3 ) f(3) f(3)
计算 f ( 4 ) f(4) f(4)依赖于 f ( 3 ) f(3) f(3) f ( 2 ) f(2) f(2)
计算 f ( 3 ) f(3) f(3)依赖于 f ( 2 ) f(2) f(2) f ( 1 ) f(1) f(1)
f ( 2 ) f(2) f(2) f ( 1 ) f(1) f(1)已知。

即我们可以尝试以下计算流程:

  • f ( n ) f(n) f(n) ≤ 2 时,直接返回。
  • f ( n ) f(n) f(n) > 2 时,先计算 f ( 2 ) + f ( 1 ) f(2)+f(1) f(2)+f(1)得到 f ( 3 ) f(3) f(3),再计算 f ( 3 ) + f ( 2 ) f(3)+f(2) f(3)+f(2)得到 f ( 4 ) f(4) f(4)
  • 不难发现求 f ( n ) f(n) f(n)需计算次数为 n − 2 n-2 n2

直接编码实现

long long int recursive(int n){

    long long int item_l = 2;
    long long int item_r = 1;
    long long int re = 0;

    if (n <= 2)
    {
        return n;
    }

    for (int i = n-2; i > 0; i--)
    {
        re = item_l + item_r;
        item_r = item_l;
        item_l = re;
    }

    return re;  
    
}

测试一下
在这里插入图片描述

尾递归

时常看到有人调侃递归的优化,别问,问就尾递归!

那么尾递归是什么东西呢?它是一种特殊的尾调用。

尾调用是指一个函数里的最后一个动作是一个函数调用的情形:即这个调用的返回值直接被当前函数返回的情形 7

function f(x){
  return g(x);
}

如果所有函数都是尾调用,那么完全可以做到每次执行时,调用记录只有一项,这将大大节省内存 8

函数调用自身,称为递归。如果尾调用自身,就称为尾递归。

对于尾递归来说,由于只存在一个调用记录,所以永远不会发生"栈溢出"错误。
在这里插入图片描述
那么我们该怎么实现呢?

尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身。做到这一点的方法,就是把所有用到的内部变量改写成函数的参数 8

基于以上方法论指导

long long int recursive(int n,long long int item_l,long long int item_r){

    if (1 == n)
        return item_r;
    else if (2 == n)
        return item_l;

    else
        return recursive(n-1,item_l+item_r,item_l);
    
}

//recursive(50,2,1)

在这里插入图片描述

参考鸣谢


  1. 维基百科 - 递归定义 ↩︎

  2. 漫谈递归转非递归 ↩︎

  3. C专家编程 ↩︎

  4. C语言内存模型详解 ↩︎

  5. 深入理解C语言内存管理 ↩︎ ↩︎

  6. howard- 什么是堆?什么是栈?他们之间有什么区别和联系? ↩︎

  7. 维基百科-尾调用 ↩︎

  8. 阮一峰 - 尾调用优化 ↩︎ ↩︎

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值