函数
- 1. 前言
- 2. 函数是什么
- 3. C语言中函数的分类
- 3.1 库函数
- 3.2 自定义函数
- 4. 函数的参数
- 4.1 实际参数(实参)
- 4.2 形式参数(形参)
- 5. 函数的调用
- 5.1 传值调用
- 5.2 传址调用
- 6. 阶段练习
- 6.1 打印100~200之间的素数
- 6.2 打印1000到2000之间的闰年
- 6.3 整型有序数组的二分查找
- 6.4 整型变量的自增
- 6.5 打印任意大小的乘法口诀表
- 7. 函数的嵌套调用和链式访问
- 7.1 嵌套调用
- 7.2 链式访问
- 8. 函数的声明和定义
- 8.1 函数的声明
- 8.2 函数的定义
- 9. 函数递归
- 9.1 什么是递归
- 9.2 递归的两个必要条件
- 10. 递归和迭代
- 11. 综合练习
- 11.1 逆置字符串
- 11.2 计算非负整数的每位之和
- 11.3 计算n的k次方(n,k都是整数)
1. 前言
大家好,我是努力学习游泳的鱼。我们终于进入函数的章节啦。有了函数,我们就可以实现更加复杂的功能了。
2. 函数是什么
函数用于完成一项特定的任务,具有相对的独立性。
函数一般有输入参数和返回值,提供对过程的封装和细节的隐藏。
3. C语言中函数的分类
3.1 库函数
库函数是C语言标准提供的。比如我们使用过的printf
,scanf
,strlen
等等。
早期的C是没有库函数的。假设程序员A某天想打印一些信息在屏幕上,他就写了个类似printf
函数的功能,假设是printf1
,程序员B为了完成同样的功能,写了个printf2
,程序员C又写了个printf3
……这会有几个问题:
- 代码冗余。
- 开发效率低。
- 不标准。
所以就有了这样一种想法:把常用的功能实现成函数,集成为软件库,由C语言直接提供。这就是库函数出现的原因。
C语言标准就可以规定库函数的标准。比如说,规定了strlen
函数的名字(strlen
),参数类型(const char* str
),返回值类型(int
),和功能(求str
指向的字符串的长度)。
这时候,不同的编译器厂商就可以根据C语言标准规定的样子来实现这些库函数。虽然实现的方式会有所差异,但是使用起来的效果是一样的。
我们如何学习库函数呢?下面我将带大家学习两个库函数。
首先推荐一个网站:cplusplus.com
比如,我们搜索strcpy
函数,搜出来的结果是这样的:
当然,你如果看不懂,可以使用网页翻译,但是还是建议看原文档。翻译后的结果仅供参考(你会发现一些不该翻译的地方也翻译了哈哈哈):
当然我们也可以在MSDN
软件里搜索strcpy
。
仔细阅读文档后,我们就明白了strcpy
的用法。strcpy
是用来拷贝字符串的,实际使用时只需要传源头和目的地的地址,其中目的地在前面(不要传反了!)。strcpy
会把源头的字符串拷贝到目的地去,拷贝的时候,源头字符串结尾的\0
也会被拷贝到目标空间。strcpy
会返回目的地字符串的地址。比如说,如果要把arr1
中的字符串拷贝到arr2
里去,可以这么写:strcpy(arr2, arr1);
我们可以简单写一段代码来验证一下。
#include <stdio.h>
#include <string.h>
int main()
{
char arr1[] = "abcdef";
char arr2[20] = {0};
strcpy(arr2, arr1);
printf("%s\n", arr2);
return 0;
}
输出结果:abcdef
但是上面的代码是无法验证是否把arr1
的\0
也拷贝过去了,因为arr2
里面放的全是0
,你不知道这个0
是本身就有的还是后来拷贝过去的。所以我们可以把arr2
改成一串X
,看看拷贝时有没有把\0
拷贝过去。
#include <stdio.h>
#include <string.h>
int main()
{
char arr1[] = "abcdef";
char arr2[20] = "XXXXXXXXXXXXX";
strcpy(arr2, arr1);
printf("arr1:%s\n", arr1);
printf("arr2:%s\n", arr2);
return 0;
}
可以看到,其中一个X
被\0
覆盖了,所以strcpy
确实会把字符串结尾\0
拷贝过去。
我们再来学习一个:memset
老规矩,先去cplusplus.com搜一搜。
再来欣赏下谷歌生草机带给我们的中文翻译。
memset
函数的形式是:void* memset(void* ptr, int value, size_t num);
该函数会把ptr
指向的内存的前num
个字节的内容设置成value
。函数执行完后会返回ptr
。
这里解释下size_t
,size_t
是一种无符号整型,也就是unsigned int
类型,sizeof
操作符返回的值得类型就是size_t
。
比如,如果我们创建一个字符数组arr
,并赋值为"Hello World!"
这个字符串,我们想把Hello
改成5个X
,就可以写:memset(arr, 'X', 5);
,函数执行完后,arr
里存的字符串就变成了"XXXXX World!"
。
memset
有两个特点:
- 设置内存的时候是以字节为单位的。
- 每个字节的内容都是一样的
value
。
对于库函数,有一点需要注意:
使用库函数,需要包含对应的头文件!
前面的strcpy
和memset
对应的头文件都是string.h
。
当然,我们还要学会使用各种查询工具来学习C语言里的库函数,除了前面的cplusplus.com之外,还有MSDN,C++官网(英文版),C++官网(中文版)等等。建议收藏以上网址,方便以后查询。
3.2 自定义函数
如果库函数能干所有的事情,那还要程序员干什么?
所以更加重要的是自定义函数!
自定义函数和库函数一样,有函数名,返回值类型和函数参数。
函数就相当于一个工厂,输入参数就相当于原材料,返回值就相当于经过工厂加工后的产品。
当然,在自定义函数中,这些都是我们自己来设计的。这就给程序员一个很大的发挥空间。
函数的组成如下:
返回类型 函数名(函数参数)
{
语句项;
}
比如说,写一个函数求两个整数的较大值。
#include <stdio.h>
int get_max(int x, int y)
{
return (x > y ? x : y);
}
int main()
{
int a = 0;
int b = 0;
scanf("%d %d", &a, &b);
int m = get_max(a, b);
printf("%d\n", m);
return 0;
}
上面的get_max
函数是如何工作的呢?
主函数里调用了get_max
,get_max(a, b);
,就相当于,叫get_max
函数,我给你两个整数a和b
,你给我把较大值求出来!于是get_max
函数表示,好滴,这就来!于是get_max
用int
类型的x
和y
接收了a
和b
,然后求出它们的较大值,接着返回一个int
类型的较大值。最后回到调用get_max
函数的地方,把返回值放到变量m
里。
再看一个例子:写一个函数来交换两个整形变量的内容。
#include <stdio.h>
void swap(int x, int y)
{
int tmp = x;
x = y;
y = tmp;
}
int main()
{
int a = 0;
int b = 0;
scanf("%d %d", &a, &b);
printf("交换前:a = %d, b = %d\n", a, b);
swap(a, b);
printf("交换后:a = %d, b = %d\n", a, b);
return 0;
}
以上代码中,我们把a
传给x
,把b
传给y
,接着在swap
函数里交换x
和y
,是不是就交换了a
和b
呢?
错!大错特错!
事实上,这个程序不会交换a
和b
,执行完后a
和b
并没有改变。比如说,你输入3
和5
后的结果是这样的。
此时程序出bug了!到底是哪里出问题了呢?
要明白这一点,首先要知道代码是如何执行的。代码一上来创建两个整型变量a和b
,并且输入两个值:3和5
。此时,会在内存中开辟两块空间,分别叫做a
和b
,a
里面放了个3
,b
里面放了个5
。接着打印出交换前a
和b
的值。然后调用swap
函数,把a
传给了x
,把b
传给了y
。这时,编译器会在内存中新开辟两块独立的空间,分别叫做x
和y
,x
和y
的空间与a
和b
的空间并没有联系,它们处在内存中4
个不同的位置。这点可以在调试中看出,a
,b
,x
,y
的地址两两之间都是不同的。
接下来swap
函数把x
和y
交换了。
但是a
和b
根本就不受影响呀!这就是swap
函数执行完后,a
和b
的值并没有交换的原因。
其中,a
和b
叫做实参,x
和y
叫做形参。
当实参传给形参的时候,形参是实参的一份临时拷贝,对形参的修改不会影响实参!
本质上,形参和实参是两块完全独立的空间,它们在空间上没有联系。那如何给它们建立联系呢?这就可以用到指针啦。
如果我们有一个变量a
,我们可以直接去修改它。
int a = 10;
a = 20; // 直接把a改成20
当然,也可以利用指针,间接修改它。
int a = 10;
int* pa = &a;
*pa = 20; // 利用指针间接把a修改成20
有了这个思路,在上面的问题里,我们可以把a
和b
的地址传给swap
函数,在swap
函数里对指针解引用,就可以找到a
和b
了。此时swap
函数和main
函数里的a
和b
就建立起了联系,通过指针的解引用,间接地修改了a
和b
。
#include <stdio.h>
void swap(int* pa, int* pb)
{
int tmp = *pa;
*pa = *pb;
*pb = tmp;
}
int main()
{
int a = 0;
int b = 0;
scanf("%d %d", &a, &b);
printf("交换前:a = %d, b = %d\n", a, b);
swap(&a, &b);
printf("交换后:a = %d, b = %d\n", a, b);
return 0;
}
4. 函数的参数
4.1 实际参数(实参)
真实传给函数的参数,叫实参。
实参可以是:常量,变量,表达式,函数等。
无论实参是何种类型的量,在进行函数调用时,它们都必须有确定的值,以便把这些值传送给形参。
如:
int Add(int x, int y)
{
return x + y;
}
int main()
{
Add(1, 2); // 常量作为实参
int a = 1;
int b = 2;
Add(a, b); // 变量作为实参
Add(1+2, a+b); // 表达式作为实参
Add(Add(1, 2), 3); // 函数作为实参
return 0;
}
4.2 形式参数(形参)
形式参数是指函数名后括号中的变量,因为形式参数只有在函数被调用的时候才实例化(分配内存单元),所以叫形式参数。形式参数在函数被调用时创建,当函数调用完成之后就自动销毁了。因此形式参数只在函数中有效。
形参实例化之后是实参的一份临时拷贝。
如:
int Add(int x, int y) // x和y就是形参
{
return x + y;
}
5. 函数的调用
5.1 传值调用
函数的形参和实参分别占有不同的内存块,对形参的修改不会影响实参。如前面的
get_max
和Add
。
5.2 传址调用
传址调用是把函数外部创建变量的内存地址传递给函数参数的一种调用函数的方式。如前面
swap
函数的正确写法。
这种传参方式,可以让函数和函数外边的变量建立起真正的联系,也就是函数内部可以直接操作函数外部的变量。
那什么时候使用传值调用,什么时候使用传址调用呢?
如前面的Add
,get_max
函数,我们只需要把实参进行一些计算,返回计算结果,而不需要修改实参,从而不需要实参和形参之间建立真正的联系,就使用传值调用。
如前面的swap
函数,由于我们要在函数里面修改实参,就需要形参和实参建立真正的联系,此时要使用传址调用,把变量的地址传过去,从而在函数里面通过解引用指针变量的方式,找到函数外面的值并修改。
6. 阶段练习
接下来的几道题,如果不用函数,大部分都在我的上一篇博客【C语言】分支和循环语句中详细讲解了,忘记的朋友可以去复习一下。本篇博客的所有题目均使用函数来实现。
6.1 打印100~200之间的素数
如何判断一个数是不是素数?这里简单说下思路:对于一个数n
,只需要产生2到(n的算术平方根)
之间的数,去试除n
,如果能够整除就不是素数,如果都不能整除就是素数。
我们可以考虑写一个函数is_prime
来判断素数,如果是素数就返回1
,不是就返回0
。很明显,如果2到(n的算术平方根)
之间有一个数能整除n
,就说明n
不是素数,直接返回0
。如果循环走完,还没有一个数能够整除n
,就说明n
是素数,此时返回1
即可。
#include <stdio.h>
#include <math.h>
int is_prime(int n)
{
int j = 0;
for (j = 2; j <= sqrt(n); j++)
{
if (n % j == 0)
{
return 0;
}
}
return 1;
}
int main()
{
int i = 0;
for (i = 100; i <= 200; i++)
{
if (is_prime(i) == 1)
{
printf("%d ", i);
}
}
return 0;
}
6.2 打印1000到2000之间的闰年
判断闰年的规则:
- 能被
4
整除但不能被100
整除是闰年。 - 能被
400
整除是闰年。
#include <stdio.h>
int is_leap_year(int y)
{
return ((y % 4 == 0) && (y % 100 != 0)) || (y % 400 == 0);
}
int main()
{
int y = 0;
for (y = 1000; y <= 2000; y++)
{
if (is_leap_year(y) == 1)
{
printf("%d ", y);
}
}
return 0;
}
6.3 整型有序数组的二分查找
二分查找,只需要每次找到中间的元素,如果比要查找的数大,就去左边找,反之就去右边找,直到找到为止。
需要注意的是,我们无法在函数内部计算整型数组元素的个数,所以要通过传参的方式告诉函数,数组有几个元素。
如果找到了,binary_search
函数就返回下标,如果找不到就返回-1
。
#include <stdio.h>
int binary_search(int arr[], int k, int sz)
{
int left = 0;
int right = sz - 1;
while (left <= right)
{
int mid = left + (right - left) / 2;
if (arr[mid] < k)
{
left = mid + 1;
}
else if (arr[mid] > k)
{
right = mid - 1;
}
else
{
return mid;
}
}
return -1; // 找不到了
}
int main()
{
int arr[] = { 1,2,3,4,5,6,7,8,9,10 };
int k = 0;
scanf("%d", &k);
int sz = sizeof(arr) / sizeof(arr[0]);
// 查找k
int ret = binary_search(arr, k, sz);
if (-1 == ret)
{
printf("找不到了\n");
}
else
{
printf("找到了,下标是:%d\n", ret);
}
return 0;
}
6.4 整型变量的自增
假设我们有一个整型变量int a = 0;
我们如何写一个函数让它自增呢?
由于我们需要改变a
,我们要使用传址调用。通过解引用的方式找到a
,让它自增。
#include <stdio.h>
void test(int* pa)
{
(*pa)++;
}
int main()
{
int a = 0;
printf("a = %d\n", a);
test(&a);
printf("a = %d\n", a);
test(&a);
printf("a = %d\n", a);
test(&a);
printf("a = %d\n", a);
return 0;
}
6.5 打印任意大小的乘法口诀表
如果你学会了打印九九乘法表,这道题也没什么难度啦。关键是两层循环,外层循环产生1
到n
的数字,内层循环产生1
到外层循环产生的数字
。
void print_table(int n)
{
int i = 1;
for (; i <= n; ++i)
{
int j = 1;
for (; j <= i; ++j)
{
printf("%d×%d=%d ", i, j, i * j);
}
printf("\n");
}
}
int main()
{
int n = 0;
scanf("%d", &n);
print_table(n);
return 0;
}
7. 函数的嵌套调用和链式访问
7.1 嵌套调用
函数是可以嵌套调用的。如a函数
调用b函数
,b函数
调用c函数
,c函数
再调用d函数
。
以下main
函数调用three_line
,three_line
调用new_line
。
#include <stdio.h>
void new_line()
{
printf("hehe\n");
}
void three_line()
{
int i = 0;
for (i=0; i<3; i++)
{
new_line();
}
}
int main()
{
three_line();
return 0;
}
但是函数不能嵌套定义!每一个函数都是独立的。
7.2 链式访问
把一个函数的返回值作为另一个函数的参数,叫做函数的链式访问。
如直接打印"abcdef"
这个字符串的长度,把strlen
的返回值作为printf
的参数。
#include <stdio.h>
#include <string.h>
int main()
{
printf("%d\n", strlen("abcdef"));
return 0;
}
有一段有趣的代码:
#include <stdio.h>
int main()
{
printf("%d", printf("%d", printf("%d", 43)));
return 0;
}
结果会输出4321
。为什么呢?
首先需要明白,printf
会返回打印的字符的个数。
对于上面的代码,第一个printf
要打印一个整型,打印多少呢?要打印第二个printf
的返回值。第二个printf
也要打印一个整型,打印多少呢?打印第三个printf
的返回值。而第三个printf
会打印一个整型,即43
。由于43
是两个字符,所以第三个printf
会返回2
。接着第二个printf
就把第三个printf
返回的2
打印出来,由于2
是一个字符,第二个printf
就返回了1
。最后第一个printf
就把第二个printf
返回的1
打印出来。最终屏幕上就打印出了4321
。
8. 函数的声明和定义
8.1 函数的声明
- 告诉编译器有一个函数叫什么,参数是什么,返回类型是什么。但是具体存不存在,函数声明决定不了。
- 函数的声明一般出现在函数的使用之前。要满足先声明后使用。
- 函数的声明一般要放在头文件中的。
8.2 函数的定义
函数的定义是指函数的具体实现,交代函数功能的实现。
我们经常在教科书上看到类似下面这种形式的代码。其中就包含了函数的声明和定义。
#include <stdio.h>
// 函数的声明
int Add(int x, int y);
int main()
{
int a = 10;
int b = 20;
int sum = Add(a, b); // 函数的使用
printf("sum = %d\n", sum);
return 0;
}
// 函数的定义
int Add(int x, int y)
{
return x + y;
}
注意,函数声明时,可以省略变量的名字,如上面的int Add(int x, int y);
也可以写成int Add(int, int);
但是,如果只有一个文件的话,把函数的定义放在使用之前,就不需要在使用之前声明了。
#include <stdio.h>
// 函数的定义
int Add(int x, int y)
{
return x + y;
}
int main()
{
int a = 10;
int b = 20;
int sum = Add(a, b); // 函数的使用
printf("sum = %d\n", sum);
return 0;
}
其实,如果实际搞软件开发,一般会把声明放在头文件(.h
)里,定义放在源文件(.c
)里,实际使用时只需要包含一下头文件就行了。
// add.h
int Add(int x, int y);
// add.c
int Add(int x, int y)
{
return x + y;
}
// test.c
#include "add.h" // 包含自己写的头文件,用双引号
#include <stdio.h> // 包含C语言库中的头文件,用尖括号
int main()
{
int sum = Add(10, 20);
printf("sum = %d\n", sum);
return 0;
}
但是!既然能在一个文件里写代码,为什么要这么麻烦,分成几个文件来写代码呢?
理由如下:假设一个公司中有50个程序员,大家共同开发同一个项目,难道50个人在同一个文件里写代码?这不就乱套了吗!
所以,就有了这样一种做法:假设要开发一个计算器,我们把这个项目拆分成几个独立的模块,分给不同的程序员来完成。比如,A程序员完成加法模块,B程序员完成减法模块,C程序员完成乘法模块,D程序员完成除法模块……此时,A写了add.h
和add.c
,B写了sub.h
和sub.c
,C写了mul.h
和mul.c
,D写了div.h
和div.c
……
如果我们想把大家的成果拼起来,只需要包含add.h
,sub.h
,mul.h
,div.h
等头文件,就能使用这些功能啦!
这就是分文件写代码的好处之一:方便协同开发。
但是!如果只是这一个好处的话,为什么要分成.h
和.c
呢?
假设你写了一个非常精妙的代码,假设就是前面的Add
函数。你可以让别人使用这段代码,但不想让别人知道代码是如何实现的。你就可以只暴露.h
文件,把.c
文件隐藏起来。由于.h
文件里有函数的声明,别人就知道了这个函数的函数名,参数和返回类型,再配合详细的文档和注释,就知道这个功能如何使用了。这样就做到了不暴露代码的实现,但同时让别人使用的效果。
那么问题又来了,如何做到这样的效果呢?
如果不想要暴露代码,就需要生成静态库(.lib
)。
这里用VS2022
来演示一下,如何生成静态库,并且导入到项目里。
首先新建一个项目叫做add
,新建头文件add.h
,新建源文件add.c
,并且写代码。
找到项目名(add
)->属性。
找到配置属性->常规->常规属性->配置类型,改成静态库.lib
,点击确定。
点击生成->生成解决方案。
可以看到,生成了一个静态库。
找到路径最后的add.lib
文件。
把它复制到一个新的项目中,就可以在这个新的项目中使用了。同时记得把add.h
也复制过来。
在新的项目中,找到头文件->添加->现有项。
添加add.h
,点击添加。
接着新建一个源文件test.c
,用于使用add
功能。
新建一个test.c
,点击添加。
在test.c
中写代码。
#include <stdio.h>
#include "add.h"
// 导入静态库
#pragma comment(lib, "add.lib")
int main()
{
int a = 0;
int b = 0;
scanf("%d %d", &a, &b);
int sum = Add(a, b);
printf("sum = %d\n", sum);
return 0;
}
其中,#pragma comment(lib, "add.lib")
是导入静态库的意思,也就是使用刚刚生成的静态库。
按住Ctrl+F5
运行。看看效果吧!
如果你想隐藏你写的核心代码,只需要通过上面的方式生成静态库,再把这个静态库给别人,同时给别人对应的头文件,别人看到头文件后就知道这个功能是如何使用的,每个函数是如何调用的,接着只需要包含你给他的头文件,再导入你给他的静态库,就可以使用你写的代码了。
当我们把函数的声明和定义分开放在.h
和.c
文件里,就可以只暴露.h
里面的代码,隐藏.c
里面的代码了。
其实,C语言提供的库函数(比如printf
,scanf
),我们也是只需要包含对应的头文件(比如stdio.h
),就可以使用了,这是因为编译器(如VS)默认导入了对应的静态库。
还有一个细节,我们自己写的头文件用双引号来引用#include "add.h"
,库函数对应的头文件用尖括号来引用#include <stdio.h>
,要分清楚两者的区别。
9. 函数递归
9.1 什么是递归
程序调用自身的编程技巧称为递归。
递归作为一种算法在程序设计语言中广泛应用。一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解,递归策略只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减小了程序的代码量。
递归的主要思考方式在于:把大事化小。
我可以写一个史上最简单的递归(虽然没啥用)。
int main()
{
main();
return 0;
}
直接在main
函数里调用自己,这就是史上最简单的递归,不可能比这更简单了。当然,这是个错误的示范,程序运行一会后会挂掉,这是由于栈溢出(大家先记住这个名词)。至于为什么,这点等会再讲。
我们先来看一个问题:如何按照顺序打印一个无符号整数的每一位?比如1234
,我们就打印1 2 3 4
。
如果按照已有的知识,我们不断%10
,/10
就可以拿到1234
的每一位(4 3 2 1
),存起来再倒着打印不就行了吗。
但是!这种方法太麻烦了。更简单的方法是用递归。
如果你还不了解递归,可以这么想:递归就是把大事化小。
我们想打印1234
的每一位,其中4
是最好打印的,因为1234%10=4
。所以问题就转化成:打印123
的每一位,再打印4
。其中1234/10=123
。
同理,我们想打印123
的每一位,其中3
是最好打印的,因为123%10=3
。所以问题就转化成:打印12
的每一位,再打印3
。其中123/10=12
。
我们想打印12
的每一位,其中2
是最好打印的,因为12%10=2
。所以问题就转化成:打印1
的每一位,再打印2
。其中12/10=1
。
我们想打印1
的每一位,……那就打印呗!这都已经是一位数了,直接打印就行了。
也就是说,打印n(n>9)
的每一位这个问题就被拆分成了,先打印n/10
的每一位,再打印n%10
。如果n<=9
,就直接打印就行了。
#include <stdio.h>
void print(int n)
{
if (n > 9)
{
print(n / 10);
}
printf("%d ", n % 10);
}
int main()
{
unsigned int num = 0;
scanf("%d", &num);
print(num);
return 0;
}
这段代码是如何执行的呢?
从主函数第一行开始执行,先创建一个无符号整数num
并初始化为0
,接着给num
输入一个数,假设输入1234
,接着调用print
函数,传给print
函数1234
。
递归第一层(n=1234
):由于1234>9
为真,会执行if
语句中的print(n / 10);
,也就是会调用print
函数,传给print
函数123
。
递归第二层(n=123
):由于123>9
为真,会执行if
语句中的print(n / 10);
,也就是会调用print
函数,传给print
函数12
。
递归第三层(n=12
):由于12>9
为真,会执行if
语句中的print(n / 10);
,也就是会调用print
函数,传给print
函数1
。
递归第四层(n=1
):由于1>9
为假,不会执行if
语句中的print(n / 10);
,就执行了printf("%d ", n % 10);
,打印1
。第四层print
函数执行完毕,回到第三层的print(n / 10);
后面。
递归第三层(n=12
):接着执行printf("%d ", n % 10);
,由于这一层n=12
,就打印2
。第三层print
函数执行完毕,回到第二层的print(n / 10);
后面。
递归第二层(n=123
):接着执行printf("%d ", n % 10);
,由于这一层n=123
,就打印3
。第二层print
函数执行完毕,回到第一层的print(n / 10);
后面。
递归第一层(n=123
):接着执行printf("%d ", n % 10);
,由于这一层n=1234
,就打印4
。第一层print
函数执行完毕,回到主函数。
看懂了吗?整体上,递归函数会一层一层递推下去,然后再一层一层回归。这里“递”就是递推的意思,“归”就是回归的意思。
每次向下递推一层,就会在内存的栈区上创建一块空间,作为这个函数的栈帧。每当一层函数执行结束,就会把这块栈帧销毁,内存中的这块空间就还给操作系统了。
那么,如果无线向下递推,就会不断在内存的栈区上创建栈帧,不断地开辟空间,如果不停止,栈空间就会耗干!当栈空间耗干时,就会报出一个错误:栈溢出。
所以,在前面的代码中,有一行判断是十分必要的:if (n > 9)
,如果没有这行判断,就会一直递归下去,最终导致栈溢出。所以递归的必须要有限制条件。
同时,每次递归调用时都是print(n / 10);
,这让1234
变成123
,接着变成12
,最后变成1
,就不满足if (n > 9)
了,停止递归。也就是说,每次递归调用,都要不断接近递归的限制条件,当满足这个限制条件的时候,递归便不再继续。
9.2 递归的两个必要条件
- 存在限制条件,当满足这个限制条件的时候,递归便不再继续。
- 每次递归调用之后越来越接近这个限制条件。
我们再来看一道题目:编写函数,不允许创建临时变量,求字符串的长度。
我们先忽略掉“不允许创建临时变量”这个限定条件,先讨论下如何求字符串的长度。
C语言提供了库函数strlen
,专门用来求字符串长度。
#include <stdio.h>
#include <string.h>
int main()
{
char arr[] = "abcdef";
int len = strlen(arr);
printf("len = %d\n", len);
return 0;
}
我们来实现一个my_strlen
函数来模拟strlen
的效果。当我们给my_strlen
函数传参时,看似传了数组arr
,实则传递的是数组arr
首元素的地址,也就是字符a
的地址,所以我们应该用一个字符指针来接收。
要求字符串的长度,其实就是求\0
之前字符的个数。可以定义一个计数器count
,如果发现一个字符不是\0
就让计数器自增,当遇到\0
时就停止,返回计数器的值。那如何拿到每一个字符呢?
传参传递的是字符指针,只要对它解引用,就可以拿到指针指向的字符。当统计完这个字符后,可以让指针+1
,这个指针就会跳过一个字符,指向下一个字符,以此类推。
所以,如果用循环的方法计算字符串长度,我们需要创建一个临时变量count
,实现如下:
#include <stdio.h>
#include <string.h>
int my_strlen(char* str)
{
int count = 0;
while (*str)
{
++count;
++str;
}
return count;
}
int main()
{
char arr[] = "abcd";
int len = my_strlen(arr);
printf("len = %d\n", len);
return 0;
}
但是这道题不允许创建临时变量呀,那就又要用到递归了。
应该如何思考呢?还是那句话:大事化小。
我们要求字符串"abcd"
的长度,只需要求"bcd"
的长度再加1
。
我们要求字符串"bcd"
的长度,只需要求"cd"
的长度再加1
。
我们要求字符串"cd"
的长度,只需要求"d"
的长度再加1
。
我们要求字符串"d"
的长度,只需要求空字符串的长度再加1
。
我们要求字符串空字符串的长度,……那就是0
呗,不用求了。
千言万语化作一句话:要求字符串str
(str
是一个字符指针,指向字符串的首字符)的长度,如果str
不是空字符串(即str
解引用后不是\0
),只需计算字符串(str+1
)的长度再加1
。如果str
是空字符串(即str
解引用后得到\0
),那长度就是0
。
如何理解标黄的这句话呢?假设str
指向字符串"abcd"
的首字符a
的地址,那么str+1
就跳过了一个字符,指向了b
。所以str+1
指向的字符串就是"bcd"
了。如果你还是有点蒙,可以把前面这一段再看一遍。
把以上的思路转换成代码如下:
#include <stdio.h>
#include <string.h>
int my_strlen(char* str)
{
if (*str)
return 1 + my_strlen(str + 1);
else
return 0;
}
int main()
{
char arr[] = "abcd";
int len = my_strlen(arr);
printf("len = %d\n", len);
return 0;
}
这个程序是如何执行的呢?
首先创建一个字符数组,并用字符串"abcd"
来初始化,接着调用my_strlen
函数,把"abcd"
传给它(本质上是传了字符a
的地址)。
第一层(str
指向的字符串是"abcd"
,本质上str
是a
的地址):由于str
解引用后是字符a
,不是\0
,故计算1 + my_strlen(str + 1)
,调用my_strlen
函数,把str+1
传给它,由于str
指向字符a
,str+1
指向字符b
,此时相当于传了字符串"bcd"
。
第二层(str
指向的字符串是"bcd"
,本质上str
是b
的地址):由于str
解引用后是字符b
,不是\0
,故计算1 + my_strlen(str + 1)
,调用my_strlen
函数,把str+1
传给它,由于str
指向字符b
,str+1
指向字符c
,此时相当于传了字符串"cd"
。
第三层(str
指向的字符串是"cd"
,本质上str
是c
的地址):由于str
解引用后是字符c
,不是\0
,故计算1 + my_strlen(str + 1)
,调用my_strlen
函数,把str+1
传给它,由于str
指向字符c
,str+1
指向字符d
,此时相当于传了字符串"d"
。
第四层(str
指向的字符串是"d"
,本质上str
是d
的地址):由于str
解引用后是字符d
,不是\0
,故计算1 + my_strlen(str + 1)
,调用my_strlen
函数,把str+1
传给它,由于str
指向字符d
,str+1
指向\0
,此时相当于传了空字符串。
第五层(str
指向的是空字符串,本质上str
是\0
的地址):由于str
解引用后是字符就是\0
,故返回0
到第四层的1 + my_strlen(str + 1)
处。
第四层(str
指向的字符串是"d"
,本质上str
是d
的地址):计算1+0
,得1
,故返回1
到第三层的1 + my_strlen(str + 1)
处。
第三层(str
指向的字符串是"cd"
,本质上str
是c
的地址):计算1+1
,得2
,故返回2
到第二层的1 + my_strlen(str + 1)
处。
第二层(str
指向的字符串是"bcd"
,本质上str
是b
的地址):计算1+2
,得3
,故返回3
到第一层的1 + my_strlen(str + 1)
处。
第一层(str
指向的字符串是"abcd"
,本质上str
是a
的地址):计算1+3
,得4
,故返回4
给主函数。并把4
赋值给len
。
10. 递归和迭代
前面我们用递归解决了一些问题。其实,对于同样的问题,我们有时候用递归,有时候用迭代。
那什么是迭代呢?其实,迭代可以理解为循环,因为循环就是一种迭代。
我们来看下一个问题:求n
的阶乘。
用循环来解决这个问题,只需要用for
循环产生1~n
的数,然后乘起来就行了。
#include <stdio.h>
int fac(int n)
{
int ret = 1;
int i = 1;
for (; i <= n; ++i)
{
ret *= i;
}
return ret;
}
int main()
{
int n = 0;
scanf("%d", &n);
int ret = fac(n);
printf("%d\n", ret);
return 0;
}
如果使用递归,也很简单。
当n<=1
时,fac(n)=1
;当n>1
时,fac(n)=n*fac(n-1)
。
#include <stdio.h>
int fac(int n)
{
if (n <= 1)
return 1;
else
return n * fac(n - 1);
}
int main()
{
int n = 0;
scanf("%d", &n);
int ret = fac(n);
printf("%d\n", ret);
return 0;
}
使用递归和迭代来解决这个问题都是可以的,没什么太大的差别。但是有些问题就不一定了。
再看下一个问题:求第n
个斐波那契数。
什么是斐波那契数呢?对于斐波那契数列,前两个数是1
,后面每个数都等于前面两个数的和。斐波那契数列的第n
项就是第n
个斐波那契数。
这题用递归简直太方便了。当n<=2
时,fib(n)=1
,当n>2
时,fib(n)=fib(n-1)+fib(n-2)
。
#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;
}
对于这个程序,假设我们输入50
,要等好久才能等到结果。
这是为什么呢?
当我们要计算fib(50)
时,就要计算fib(49)
和fib(48)
。(以下省略fib)
当我们要计算49
,就要计算48
和47
。要计算48
,就要计算47
和46
。
要计算48
,就要计算47
和46
。要计算47
,就要计算46
和45
。要计算47
,就要计算46
和45
。要计算46
,就要计算45
和44
。
……
有没有发现,一直到计算fib(3)
,需要的计算次数非常多,是指数级别增长的。这里面会有大量重复的计算,这就导致程序的效率非常低。学了时间复杂度,你就会明白,这个算法的时间复杂度是O(N2)。
如何解决这个问题呢?我们可以用迭代(循环)。计算出同样的结果,使用迭代(循环)的效率简直高太多了。
思路是:一个一个往后算,先算1+1=2
,再算1+2=3
,接着2+3=5
,以此类推。
一开始令a
和b
都是1
,计算a+b
,用c
保存计算结果,接着把b
赋值给a
,把c
赋值给b
,再计算a+b
,这样就能一个一个往后算了。
以上就是一个循环。那要循环几次呢?第三个斐波那契数要算1
次,第四个斐波那契数要算2
次,第五个要算3
次……所以从第三个开始,第n
个要算n-2
次。我们可以把循环条件设为n>2
,每次计算完后n
自减。最后返回c
就行了。
还有一个细节,由于n<=2
时不会进入循环,而前两个斐波那契数都是1
,所以把c
初始化成1
。
#include <stdio.h>
int fib(int n)
{
int a = 1;
int b = 1;
int c = 1;
while (n > 2)
{
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;
}
总结:当递归的写法比较简单,同时写出来后没有明显的问题,那就使用递归来解决问题。否则即使迭代(循环)的写法很复杂,也要使用迭代(循环)来解决问题。
接下来还有两个问题跟斐波那契问题很像。
先看汉诺塔问题。
汉诺塔是一个很经典的问题。简单来说,有三根柱子,从左到右,分别编号为A,B,C,在A上套有n
个盘子,从下到上依次减小,我们的任务是把所有的盘子移动到C上,每次移动的过程中,都必须保持上面的盘子比下面的小。
破题的关键就是把大事化小。假设有n
个盘子(n>1
),我们只需要把上面n-1
个盘子通过C柱移动到B柱上,接着把A柱剩下的一个盘子移动到C柱上,最后再把B柱上的n-1
个盘子通过A柱移动到C柱上。
所以这是一个递归。每次调用函数都会把n-1
传给自己,就把大事化小了。那么递归的限制条件是什么呢?很简单,当n=1
时,也就是说A柱上只有一个盘子了,我们就直接把这个盘子移动到C住上。而n>1
时就按照前面的思路递归,每次递归调用时n
都会-1
,总有一天n
会等于1
,从而停止递归。把上面的思路转化成代码如下:
#include <stdio.h>
//从pos1到pos2
void move(char pos1, char pos2)
{
printf("%c->%c ", pos1, pos2);
}
//n为盘子个数,pos123分别是起始位置,中转位置,目标位置
void Hanoi(int n, char pos1, char pos2, char pos3)
{
if (1 == n)
{
move(pos1, pos3);
}
else
{
Hanoi(n - 1, pos1, pos3, pos2);
move(pos1, pos3);
Hanoi(n - 1, pos2, pos1, pos3);
}
}
int main()
{
printf("1:");
Hanoi(1, 'A', 'B', 'C');
printf("\n");
printf("2:");
Hanoi(2, 'A', 'B', 'C');
printf("\n");
printf("3:");
Hanoi(3, 'A', 'B', 'C');
printf("\n");
printf("4:");
Hanoi(4, 'A', 'B', 'C');
printf("\n");
return 0;
}
大家可以找找次数的规律。1
个盘子时,需要挪动1
次;2
个盘子时,需要挪动3
次;3
个盘子时,需要挪动7
次;4
个盘子时,需要挪动15
次。所以n
个盘子需要挪动2n-1。所以,如果是64
个盘子呢?约264次!这可是一个天文数字!也就是说,当盘子的个数增加时,需要挪动的次数是以指数形式增加的。
这个程序也增加了我们对递归的理解。递归,就是把大事化小,而挪动一堆盘子的问题,每次都可以转化成一个更小的问题来解决,这样子就把一个庞大的问题一点点吃掉,最终变成一个非常简单的问题。这种解决问题的思路是耐人寻味的。
还有一点,推理问题时,我们可能会思考,这些盘子是怎么进一步一步挪过去的,但是解决问题的关键,是把一堆盘子当成一个整体,通过移动这个整体来实现把大事化小。当盘子个数比较少的时候,我们还可以在头脑中模拟整个过程,但是当盘子个数增多时,我们的大脑就无法完整的模拟整个过程了,这个时候就要用到整体的思维,也就是使用递归来解决问题。
补充一下,经典的青蛙跳台阶问题其实也跟斐波那契数列问题很类似。所谓青蛙跳台阶,就是一只青蛙在台阶底部往上跳,假设有n
个台阶,青蛙一次只能跳1
个或2
个台阶,请问跳上去有几种跳法?
这个问题非常简单。假设跳n
个台阶的跳法数是f(n)
,那么就分类讨论:如果第一次跳1
个台阶,剩下n-1
个台阶的跳法数是f(n-1)
,如果第一次跳2
个台阶,剩下n-2
个台阶的跳法数就是f(n-2)
,也就是说,f(n)=f(n-1)+f(n-2)
。这不就是斐波那契数列吗!而很容易得出,f(1)=1
,f(2)=2
,所以这个数列就是1,2,3,5,8
,…所以这个数列的第n
项就是斐波那契数列的第n+1
项,也就是说,只需要把n+1
传给上面讲解斐波那契数列写的代码的fib
函数中即可。
11. 综合练习
11.1 逆置字符串
我们可以使用循环,也可以使用递归来逆置一个字符串。
先说循环。假设有一个字符串,"abcdef"
,我们只需交换a
和f
,b
和e
,c
和d
,就把它逆置了。
这需要两个指针left
和right
,left
指向a
,right
指向f
,交换a
和f
后,left
向后走一格,right
向前走一格,以此类推。当left
在right
左边时,还有字符可以交换,当left
和right
相等时,就不用交换了。
#include <stdio.h>
#include <string.h>
void reverse_string(char* str)
{
int len = strlen(str);
char* left = str;
char* right = left + len - 1;
while (left < right)
{
char tmp = *left;
*left = *right;
*right = tmp;
++left;
--right;
}
}
int main()
{
char arr[] = "abcdef";
// 逆置字符串
reverse_string(arr);
printf("%s\n", arr);
return 0;
}
如果你对指针不太熟悉,还可以用数组下标来实现。
我们需要左下标left
和右下标right
,交换左右下标的元素,左下标向后走,右下标向前走,当左下标在右下标左边时,还有元素可以交换,否则就不需要交换了。
void reverse_string(char arr[])
{
int len = strlen(arr);
int left = 0;
int right = len - 1;
while (left < right)
{
char tmp = arr[left];
arr[left] = arr[right];
arr[right] = tmp;
++left;
--right;
}
}
再说递归实现。
仍然是大事化小的思路。逆序"abcdef"
,只需要先交换a
和f
,再逆序"bcde"
。逆序"bcde"
,只需要交换b
和e
,再逆序"cd"
。逆序"cd"
,只需要交换c
和d
,再逆序空字符串。逆序空字符串,那就别逆序了,都空了还逆序啥。
具体如何实现呢?首先要交换a
和f
,把a
放到一个临时变量tmp
里,把f
放到原来a
的位置,再把tmp
里的a
放到原来f
放的位置。但是,接下来怎么交换"bcde"
呢?此时从b
的位置向后看到的字符串是"bcdea"
(因为a
被放到后面了)呀。所以,我们不要急这把a
放到后面,而应该先把原来f
的位置改成\0
,此时从b
向后看就是"bcde"
了,对它逆序后,再把a
放后面。
但是不是任何情况下都要逆序中间的字符串的,当中间的字符串的长度是1
或者0
时就不需要再逆序了,也就是说,只有当中间的字符串的长度大于或等于2
时才需要逆序。
思路有了,我们还需要知道代码怎么写。以"abcdef"
为例,假设str
指向a
,字符串长度是len
(也就是6
),那么str+len-1
就指向了f
。我们对对应的指针解引用就能找到指向的字符了。
void reverse_string(char* str)
{
int len = strlen(str);
char tmp = *str;
*str = *(str + len - 1);
*(str + len - 1) = '\0';
// 逆序中间的字符串
if (strlen(str + 1) >= 2)
{
reverse_string(str + 1);
}
*(str + len - 1) = tmp;
}
11.2 计算非负整数的每位之和
如1729
,就计算1+7+2+9
,即19
。
这题仍然可以使用递归和非递归的方法。
如果使用非递归,只需要拿到每一位加起来就行了,非常简单。
如何拿到每一位呢?只需每次%10
拿到最后一位,再/10
去掉这一位。
#include <stdio.h>
int DigitSum(unsigned int n)
{
int sum = 0;
while (n)
{
sum += (n % 10);
n /= 10;
}
return sum;
}
int main()
{
unsigned int num = 0;
scanf("%d", &num);
int ret = DigitSum(num);
printf("%d\n", ret);
return 0;
}
如果要使用递归呢?假设num
是1729
,由于一个数的最后一位是最好拿的,只需%10
,所以我们就先计算172
的每位之和再加9
。如何计算172
的每位之和呢?只需计算17
的每位之和再加上2
。如何计算17
的每位之和呢?只需计算1
的每位之和再加上7
。如何计算1
的每位之和呢?1
都是一位数了,就别算了,它的每位之和就是它自己。
思路有了,具体的实现和前面的逆序数字非常像。如果是两位数以上就拆下最后一位,如果是一位数就返回它自己。
int DigitSum(unsigned int n)
{
if (n > 9)
{
return n % 10 + DigitSum(n / 10);
}
else
{
return n;
}
}
11.3 计算n的k次方(n,k都是整数)
仍然可以使用递归和非递归。
如果使用非递归,若k>0
,就产生k
个n
乘起来。若k
为0
,则返回1
。若k<0
,则取反后按k>0
计算,再取倒数。
#include <stdio.h>
double Power(int n, int k)
{
if (0 == k)
{
return 1;
}
else if (k > 0)
{
int ret = 1;
while (k--)
{
ret *= n;
}
return ret;
}
else
{
int ret = 1;
k = -k;
while (k--)
{
ret *= n;
}
return 1.0 / ret;
}
}
int main()
{
int n = 0;
int k = 0;
scanf("%d %d", &n, &k);
double ret = Power(n, k);
printf("%lf\n", ret);
return 0;
}
若使用递归,也很简单。若k=0
,直接返回1
。若k>0
,就返回n
的k-1
次方乘以n
。若k<0
,就返回n
的-k
次方的倒数。
double Power(int n, int k)
{
if (0 == k)
{
return 1;
}
else if (k > 0)
{
return Power(n, k - 1) * n;
}
else
{
return 1.0 / Power(n, -k);
}
}