前言
前面的一篇文章里,提及了递归的概念,写博文的时候脑海里就跳出了之前遇到的一道题目-青蛙爬楼梯
,题目大概是这样:
一只青蛙要从底部跳上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(n−1).
这个定义在逻辑上是成立的,因为它首先定义了
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\,.
∀n∈Z,g(n)=g(n−1)+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(n−1) 种,达到 B情况 的走法有 f ( n − 2 ) f(n-2) f(n−2) 种。那么可以得到:
f ( n ) = f ( n − 1 ) + f ( n − 2 ) f(n) = f(n-1) + f(n-2) f(n)=f(n−1)+f(n−2)
当然,当 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(n−1)+f(n−2),(n≥2)
编码实现
根据上文梳理出的模型,我们很容易就能写出以下函数:
int recursive(int n){
if (1 == n)
return 1;
if (2 == n)
return 2;
return recursive(n-1) + recursive(n-2);
}
结果
此处设置阶梯数为10,测试结果如下
咋看起来很完美,再测测就扎心了。
当阶梯数为50时已经发现已经超出整型可显示范围了
这个好办,long int
、long 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。
需要注意的是,这里的堆、栈准确而言应该叫堆内存、栈内存;需要和数据结构中的堆、栈区分开来。
-
数据结构中的堆和栈的重点在于数据的存放方式为 FIFO 和 LIFO 。
-
而堆内存、栈内存属于操作系统的范畴,是指内存空间 6。
- 堆内存。按需申请、动态分配;例如 C 中的
malloc( )
。由于内存中的空闲空间并不是连续的,而是不同程序占用了不同的一块一块内存,即使是同一个程序也可能占用了不同地方的多块内存。操作系统中则会对这些空间进行统一的管理,在应用程序提出申请时,就会从堆中按照一定算法找出一块可用内存,标记占用空间等信息之后返回其起始地址给程序。在程序结束之前,操作系统不会删除已经申请的内存,而是要靠程序主动提出释放的请求(free( )
、delete( )
),如果使用后忘记释放,就会造成内存泄漏。因此堆基本上可以理解为当前可以使用的空闲内存,但是其申请和释放都要程序员自己写代码管理。 - 栈内存。其大小在编译期时由编译器参数决定,用于局部变量的存放或者函数调用栈的保存。在 C 中如果声明一个局部变量,它存放的地方就在栈中,而当这个局部变量离开其作用域之后,所占用的内存则会被自动释放,因此在 C 中局部变量也叫自动变量。栈的另一个作用则是保存函数调用栈,这时和数据结构的栈就有关系了。在函数调用过程中,常常会多层甚至递归调用。每一个函数调用都有各自的局部变量值和返回值,每一次函数调用其实是先将当前函数的状态压栈,然后在栈顶开辟新空间用于保存新的函数状态,接下来才是函数执行。当函数执行完毕之后,栈先进后出的特性使得后调用的函数先返回,这样可以保证返回值的有序传递,也保证函数现场可以按顺序恢复。操作系统的栈在内存中由高地址向低地址增长,也即低地址为栈顶,高地址为栈底。这就导致了栈的空间有限制,一旦局部变量申请过多(例如开个超大数组),或者函数调用太深(例如递归太多次),那么就会导致栈溢出(
Stack Overflow
),操作系统这时候就会直接把程序杀掉。
- 堆内存。按需申请、动态分配;例如 C 中的
函数调用过程
归纳一下函数调用过程,可概括为以下五个步骤:
- 开辟空间用于保存新的函数状态。
- 实参赋值给形参。
- 函数体执行。
- 释放空间。
- 返回。
实际上,上面的过程可作为简单理解,若你觉得 意(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 n−2
直接编码实现
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)