[C]基础7.函数递归

  • 博客主页:算法歌者
  • 本篇专栏:[C]
  • 您的支持,是我的创作动力。


0、总结

image.png

1、什么是递归

递归:在C语言中,递归就是函数自己调用自己。
例1:最简单的C语言递归代码:

#include <stdio.h>
int main()
{
	printf("hehe\n");
	main();
	return 0;
}

上述代码仅演示递归基本形式,存在死递归问题,会导致栈溢出(Stack overflow)。
image.png

1.1 递归的思想

递归是将大问题转化为相似但规模较小的子问题求解,直至子问题无法再拆分。所以递归的思考方式是把大事化小的过程。递归中的递是递推的意思,归是回归的意思。

1.2 递归的限制条件

递归书写需满足两个必要条件:

  • 递归存在限制条件,当满足这个限制条件的时候,递归停止。
  • 每次递归调用之后越来越接近这个限制条件。

2、递归的举例

2.1 求n的阶乘

前置知识:
一个正整数的阶乘(factorial)是所有小于及等于该数的正整数的积,并且0的阶乘为1。
自然数n的阶乘写作
n!

题目:
计算n的阶乘(不考虑溢出),n的阶乘就是1~n的数字累积相乘。

2.1.1 分析和代码实现

n的阶乘的公式:n! = n * (n - 1)!

举例:
    5! = 5*4*3*2*1
    4! = 4*3*2*1
所以:
    5! = 5*4!

这种思路属于将大问题转化为相似但规模较小的问题来求解。
n == 0的时候,n的阶乘是1,其余n的阶乘都可以通过公式计算。
n的阶乘的递归公式如下:
image.png
我们可以定义函数Fact来求n的阶乘,其中Fact(n)表示求n的阶乘,而Fact(n-1)则表示求n-1的阶乘。
代码如下:

#include <stdio.h>
int Fact(int n)
{
	if (n == 0)
		return 1;
	else
		return n * Fact(n - 1);
}
int main()
{
	int n = 0;
	scanf("%d", &n);
	int ret = Fact(n);
	printf("%d\n", ret);
	return 0;
}

2.1.2 画图推演

image.png

2.2 顺序打印一个整数的每一位

题目:
输入一个整数m,打印这个按照顺序打印整数的每一位。

比如:
输入:1234 输出:1 2 3 4
输入:520 输出:5 2 0

2.2.1 分析和代码实现

首先思考的是,如何得到这个数的每一位?
如果n是1位数,n的每一位就是n自己。如果n是超过1位数,就需要拆分每一位。

1、从给定的数字n开始,不断地执行以下两步操作,直到n变为0:
使用%10操作获取n的最低位数字。
使用/10操作去掉n的最低位数字。
2、但这个问题是,得到的数字顺序是原始数字的逆序。

例如,对于数字1234:

  • 首先,1234 % 10得到4,1234 / 10得到123。
  • 然后,123 % 10得到3,123 / 10得到12。
  • 接着,12 % 10得到2,12 / 10得到1。
  • 最后,1 % 10得到1,1 / 10得到0,结束循环。
  • 所以,得到的数字序列是4, 3, 2, 1,这是1234的逆序。

我们发现一个数字的最低位是最容易得到的,通过%10就能得到。
那我们写一个函数Print来打印一个整数的每一位数字,从最高位到最低位依次打印。
例如,对于数字12345:

  • 首先,因为12345 > 9Print(12345)会调用Print(1234)
  • 然后,因为1234 > 9Print(1234)会调用Print(123)
  • 接着,因为123 > 9Print(123)会调用Print(12)
  • 因为12 > 9Print(12)会调用Print(1)
  • 1 <= 9是递归的终止条件,Print(1)打印1
  • 然后,控制返回到Print(12),打印2
  • 控制继续返回,依次打印345
  • 最终输出是:1 2 3 4 5

总结是:

    Print(12345) 			+ printf(5)
==>	Print(1234) 		+ printf(4)
==> Print(123) 		+ printf(3)
==> Print(12) 	+ printf(2)
==> Print(1) + printf(1)

代码如下:

#include <stdio.h>
void Print(int n)
{
	if (n > 9)
		Print(n / 10);
	printf("%d ", n % 10);
}
int main()
{
	int m = 0;
	scanf("%d", &m);
	Print(m);
	return 0;
}

2.2.2 画图推演

image.png

2.3 总结

1、通过前两个举例,会感受到写一个代码,虽然递归难以想到,但使用递归写出的代码会非常简单。往往一个代码使用递归可能就几行代码。如果把递归写成非递归(迭代)的方式,就需要十几行甚至几十行代码。
2、如果递归的不恰当书写,会导致一些无法接受的后果,那我们还是应该放弃使用递归,使用迭代解决问题。
3、后期学习数据结构的时候,经常会使用递归。
4、在面试(笔试)的时候,你用递归很快的解决了代码问题,遇到一些苛求的面试官,就会让你改成非递归。

3、递归与迭代

在C语言中,每次函数调用都会在栈区为其分配一块内存空间,称为栈帧,用于保存函数调用的局部变量。递归调用时,每层递归都会占用新的栈帧空间,直到递归结束才逐层释放。递归层次过深会导致栈帧空间消耗过多,可能引发栈溢出。为避免递归,可采用迭代(循环)的方式实现相同功能。

例如:计算n的阶乘。

int Fact(int n)
{
	int i = 0;
	int ret = 1;
	for (i = 1; i <= n; i++)
	{
		ret *= i;
	}
	return ret;
}

上述代码是能够完成任务,并且效率是比递归的方式更好。
事实上,许多问题倾向于用递归形式解释,因为递归比非递归更清晰。然而,这些问题的迭代实现通常效率更高。当问题极其复杂,难以用迭代实现时,递归的简洁性可以弥补其运行时的开销。

3.1 求第n个斐波那契数

我们可以举一个例子,来说明递归并非总是最佳选择。斐波那契数列的问题常常以递归的方式描述,描述如下:
image.png
看到这个公式,很容易写出代码:

#include <stdio.h>
int Fib(int n)
{
	if (n <= 2)
		return 1;
	else
		return Fib(n - 1) + Fib(n - 2);
}
int main()
{
	int n = 0;
	scanf("%d", &n);
	int ret = Fib(n);
	printf("%d\n", ret);
	return 0;
}

当我们n输入为50的时候,需要很长时间才能算出结果,这个计算所花费的时间,是我们很难接受的,这也说明递归的写法是非常低效的,那是为什么呢?
image.png
通过图片很容易发现,递归的过程中会有重复计算,递归层次越深,冗余计算就会越多。我们可以测试第3个斐波那契数列被重复计算多少次。如下:

#include <stdio.h>
int count = 0;
int Fib(int n)
{
	if (n == 3)
		// 统计第3个斐波那契数列被计算的次数
		count++;
	if (n <= 2)
		return 1;
	else
		return Fib(n - 1) + Fib(n - 2);
}
int main()
{
	int n = 0;
	scanf("%d", &n);
	int ret = Fib(n);
	printf("%d\n", ret);
	printf("\ncount = %d\n", count);
	return 0;
}

可以发现,在计算第40个斐波那契数列的时候,使用递归的方式,第3个斐波那契数列就被重复计算了39088169次,这些计算是非常冗余的。因此使用递归是不明智的,需要通过迭代的方式去解决。
image.png
斐波那契数列的前两个数都是1,且从第三个数开始,每个数都是前两个数的和。因此,我们可以从前往后,按照从小到大的顺序依次计算每个数。代码如下:

#include <stdio.h>
int Fib(int n)
{
	int a = 1;
	int b = 1;
	int c = 1;
	while (n >= 3)
	{
		c = a + b;
		a = b;
		b = c;
		n--;
	}
	return c;
}
int main()
{
	int n = 0;
	scanf("%d", &n);
	int ret = Fib(n);
	printf("%d\n", ret);
	return 0;
}

采用迭代的方式去实现这个代码,效率会有显著提升。尽管递归在某些情况下很实用,但它也可能带来一些问题。因此,我们不应过度依赖递归,适可而止就好。

4、拓展

4.1 青蛙跳台阶问题

题目:

一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个 n 级的台阶总共有多少种跳法(先后次序不同算不同的结果)。
数据范围:
1≤𝑛≤40 1≤n≤40
要求:时间复杂度:𝑂(𝑛),空间复杂度: 𝑂(1)

这个题有递归(自上而下)和迭代(自下而上)两种解法。

4.1.1 分析

按照笨方法进行统计,会发现:

没有台阶:f(0) = 1,青蛙跳到第0台阶的跳法只有一种,那就是不跳。
一个台阶:f(1) = 1,青蛙跳到第1台阶的跳法只有一种,那就是从第0级台阶直接跳到第1级台阶。
两个台阶:f(2) = 2
三个台阶:f(3) = 3
四个台阶:f(4) = 5

通过观察可以归纳得出f(n) = f(n-1) + f(n-2)这个公式,那么为什么这个公式经得起推敲呢?
我们可以这样的思考(自下而上思考):

  • 假设有4个台阶,青蛙每次可以选择跳1级或2级。
  • 如果青蛙首先跳1级到达第一个台阶,那么它还剩下3个台阶要跳。此时,以第一个台阶为新的起点,青蛙面对剩下的3个台阶,就有3种不同的跳跃方法可以到达顶端。
  • 另一方面,如果青蛙首先跳2级到达第二个台阶,那么它还剩下2个台阶。此时,以第二个台阶为新的起点,青蛙面对剩下的2个台阶,有2种不同的跳跃方法。
  • 因此,青蛙跳上4个台阶总共有2+3=5种方法。

可以这样思考,(自上而下思考):

  • 青蛙跳到第n级台阶的所有可能方式。这些方式可以分为两类。
  • 青蛙最后一步是从第n-1级台阶直接跳1级上来的。
  • 青蛙最后一步是从第n-2级台阶直接跳2级上来的。

image.png

  • 通过观察图像,我们可以很自然地得出结论:最后一步的实现是第一种方式与第二种方式的累加。
  • 注意的是,这两类方式是互斥的,即青蛙不可能同时从第n-1级和第n-2级跳到第n级。因此,青蛙跳到第n级台阶的总方法数就是这两类方法数之和。

4.1.2 代码

递归(自上而下)会重复计算,而迭代(自下而上),则可以在计算的过程中保存“跳到较低台阶的跳法数量”的计算结果。
在迭代过程中,可以通过先计算f(2) = f(1) + f(0),接着计算f(3) = f(2) + f(1),以此类推直到f(n)。在此过程中,每次计算都至少保存最后两个结果。由于这种方法利用了上一步的计算结果来快速得到下一步的结果,因此它体现了动态规划的思想。动态规划的核心就是利用已解决的子问题的解来构造原问题的解,从而避免重复计算,提高效率。
递归:

#include <stdio.h>
//递归
int Jump(int n)
{
	if (n <= 2)
		return n;
	else
		return Jump(n - 1) + Jump(n - 2);
}
int main(void)
{
	int n = 0;
	printf("请输入台阶的数量:");
	scanf("%d", &n);

	for (int i = 0; i < n; ++i)
	{
		printf("跳到第%d个台阶时有%d种跳法\n", i + 1, Jump(i + 1));
	}
	return 0;
}

运行结果:
image.png
迭代(动态规划):

#include <stdio.h>
//迭代
int Jump2(int n)
{
	int a = 1;
	int b = 2;
	int c = 1;
	int t = n;

	while (n > 2)
	{
		c = a + b;
		a = b;
		b = c;
		n--;
	}

	if (t <= 2)
		return n;
	else
		return c;
}

int main(void)
{
	int n = 0;
	printf("请输入台阶的数量:");
	scanf("%d", &n);

	for (int i = 0; i < n; ++i)
	{
		printf("跳到第%d个台阶时有%d种跳法\n", i + 1, Jump2(i + 1));
	}
	return 0;
}

运行结果:
image.png

4.2 汉诺塔问题

#include <stdio.h>
int count = 0;
void move(int n, char x, char y)
{
	count++;
	printf("第%d次:%d号圆盘:%c->%c\n", count, n, x, y);
}
void Hanoi(int n, char a, char b, char c)
{
	if (n == 1)
	{
		move(n, a, c);
	}
	else
	{
		Hanoi(n - 1, a, c, b);
		move(n, a, c);
		Hanoi(n - 1, b, a, c);
	}
}
int main(void)
{
	int n = 0;
	int a = 'A';
	int b = 'B';
	int c = 'C';
	printf("玩汉诺塔,请输入圆盘的个数:");
	scanf("%d", &n);
	Hanoi(n, a, b, c);
	printf("把A上的圆盘都移动到C上,总共移动了%d次", count);
	return 0;
}

运行结果:
image.png

4.3 尾递归求斐波那契数

尾递归:比线性递归多几个参数,这个参数是上一次调用函数得到的结果。
所以,关键在于,尾递归每次调用都在收集结果,避免了线性递归不收集结果只能依次展开消耗内存的坏处。
代码:

#include <stdio.h>  
int count = 0;
// 使用尾递归
long long fib_tail(int n, long long a, long long b) {
	if (n == 3)
		count++;
	if (n == 0) return a;
	if (n == 1) return b;
	return fib_tail(n - 1, b, a + b);
}
int main() {
	int n = 0;
	scanf("%d", &n);
    // n:要计算的斐波那契数列的项数。
    // a:斐波那契数列的第n-1项。
    // b:斐波那契数列的第n项。
	long long ret = fib_tail(n, 0, 1);
	printf("Fibonacci number at position %d is %lld\n", n, ret);
	printf("count = %d\n", count);
	return 0;
}

函数调用过程:
1、fib_tail(3,0,1)->fib_tail(2,1,1)
2、fib_tail(2,1,1)->fib_tail(1,1,2)
3、fib_tail(1,1,2)->返回2
运行结果:
image.png


完。

  • 18
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值