学习内容
函数是什么
库函数
自定义函数
函数参数
函数调用
函数的嵌套调用和链式访问
函数的声明和定义
函数递归
1. 函数是什么?
数学中我们常见到函数的概念。但是你了解C语言中的函数吗?
维基百科中对函数的定义:子程序
在计算机科学中,子程序(英语:Subroutine, procedure, function, routine, method, subprogram, callable unit),是一个大型程序中的某部分代码, 由一个或多个语句块组成。它负责完成某项特定任务,而且相较于其他代 码,具备相对的独立性。
一般会有输入参数并有返回值,提供对过程的封装和细节的隐藏。这些代码通常被集成为软件库。
简单的说就是:在C语言中,函数是一段完成特定任务的代码块,可以被多次调用和重复使用。它可以接受输入参数,处理这些参数,并返回一个值。函数的结构通常包括函数名、参数列表、函数体和返回类型。
2.C语言中函数的分类:
-
库函数
-
自定义函数
为什么会有库函数?
-
我们知道在我们学习C语言编程的时候,总是在一个代码编写完成之后迫不及待的想知道结果,想把这个结果打印到我们的屏幕上看看。这个时候我们会频繁的使用一个功能:将信息按照一定的格式打印到屏幕上(
printf
)。 -
在编程的过程中我们会频繁的做一些字符串的拷贝工作(
strcpy
)。 -
在编程是我们也计算,总是会计算n的k次方这样的运算(
pow
)。
像上面我们描述的基础功能,它们不是业务性的代码。我们在开发的过程中每个程序员都可能用的到,为了支持可移植性和提高程序的效率,所以C语言的基础库中提供了一系列类似的库函数,方便程序员进行软件开发。
那怎么学习库函数呢?
这里我们简单的看看:www.cplusplus.com
简单的总结,C语言常用的库函数都有:
-
IO函数
-
字符串操作函数
-
字符操作函数
-
内存操作函数
-
时间/日期函数
-
数学函数
-
其他库函数
我们参照文档,学习几个库函数:(然后使用文档来学习库函数)。
1.strcpy
官方文档介绍:https://cplusplus.com/reference/cstring/strcpy/?kw=strcpy
strcpy
是C语言中用于字符串复制的函数,用于将一个字符串的内容复制到另一个字符串中。它位于 <string.h>
头文件中。
语法:
char *strcpy(char *destination, const char *source);
destination
是要复制到的目标字符串指针。source
是要复制的源字符串指针。
使用方法示例:
#include<string.h>
int main()
{
char arr1[] = "abcdef";
char arr2[20] = { 0 };
strcpy(arr2, arr1);
printf("arr2=%s\n", arr2);
return 0;
}
注意:使用 strcpy
时,确保目标字符串有足够的空间来容纳源字符串的内容,以防止发生溢出。
2.memset
官方文档介绍:https://cplusplus.com/reference/cstring/memset/?kw=memset
memset
是C语言中用于将一块内存设置为特定值的函数,位于 <string.h>
头文件中。
语法:
void *memset(void *ptr, int value, size_t num);
ptr
是指向要填充的内存块的指针。value
是要设置的值,以int
类型表示。通常是字符的 ASCII 值,但实际上会被转换为unsigned char
。num
是要设置的字节数。size_t = unsigned int
使用方法示例:
#include<string.h>
int main()
{
char arr1[] = "hello word!";
memset(arr1, 'x', 5);
printf("%s\n", arr1); //前面5个字节(即hello)都会设置为x的值
return 0;
}
总结:
但是库函数必须知道的一个秘密就是:使用库函数,必须包含 #include
对应的头文件。
这里对照文档来学习上面几个库函数,目的是掌握库函数的使用方法。
2.1如何学会使用库函数?
需要全部记住吗?No
需要学会查询工具的使用:
MSDN(Microsoft Developer Network)
英文很重要。最起码得看懂文献。
2.2 自定义函数
如果库函数能干所有的事情,那还要程序员干什么?
所有更加重要的是自定义函数。
自定义函数和库函数一样,有函数名,返回值类型和函数参数。
但是不一样的是这些都是我们自己来设计。这给程序员一个很大的发挥空间。
函数的组成:
ret_type fun_name(params)
{
statement;//语句项
}
ret_type 返回类型
fun_name 函数名
params 函数参数
注:在 C 语言中,默认情况下,如果没有显式声明函数的返回类型,则函数会默认被假定为返回整型(int)。
我们举一个例子:
写一个函数可以找出两个整数中的最大值。
#include <stdio.h>
//get_max函数的设计
int get_max(int x, int y)
{
return (x > y) ? (x) : (y);
}
int main()
{
int num1 = 10;
int num2 = 20;
int max = get_max(num1, num2);
printf("max = %d\n", max);
return 0;
}
再举个例子:
写一个函数可以交换两个整形变量的内容。
#include <stdio.h>
//实现成函数,但是不能完成任务
// swapl在被调用的时候,实参传给形参,其实形参是实参的一份临时拷贝
//改变形参,不能改变实参
void Swap1(int x, int y)
{
int tmp = 0;
tmp = x;
x = y;
y = tmp;
}
//正确的版本
void Swap2(int* pa, int* pb)
{
int tmp = *pa; //tmp =a;
*pa = *pb;//a=b;
*pb = tmp;//b=tmp
}
int main()
{
int num1 = 1;
int num2 = 2;
Swap1(num1, num2);
printf("Swap1::num1 = %d num2 = %d\n", num1, num2);
Swap2(&num1, &num2);
printf("Swap2::num1 = %d num2 = %d\n", num1, num2);
return 0;
}
3. 函数的参数
3.1 实际参数(实参):
真实传给函数的参数,叫实参。
实参可以是:常量、变量、表达式、函数等。
无论实参是何种类型的量,在进行函数调用时,它们都必须有确定的值,以便把这些值传送给形参。
3.2 形式参数(形参):
形式参数是指函数名后括号中的变量,因为形式参数只有在函数被调用的过程中才实例化(分配内存单元),
所以叫形式参数。形式参数当函数调用完成之后就自动销毁了。因此形式参数只在函数中有效。
上面Swap1
和 Swap2
函数中的参数 x,y,px,py
都是形式参数。
在main函数中传给 Swap1 的 num1 、num2 和传给 Swap2 函数的 &num1 , &num2 是实际参数。
上图中可以看到 Swap1 函数在调用的时候, x , y 拥有自己的空间,同时拥有了和实参一模一样的内容。
所以我们可以简单的认为:形参实例化之后其实相当于实参的一份临时拷贝。
4. 函数的调用:
4.1 传值调用
函数的形参和实参分别占有不同内存块,对形参的修改不会影响实参。
4.2 传址调用
传址调用是把函数外部创建变量的内存地址传递给函数参数的一种调用函数的方式。
这种传参方式可以让函数和函数外边的变量建立起真正的联系,也就是函数内部可以直接操作函数外部的变量。
4.3 练习
写一个函数可以判断一个数是不是素数。
写一个函数判断一年是不是闰年。
写一个函数,实现一个整型有序数组的二分查找。
写一个函数,每调用一次这个函数,就会将 num 的值增加1。
1.写一个函数可以判断一个数是不是素数。
int is_prime(int n) //如果一个函数不写返回类型,默认返回int类型
{
int i = 0;
if (n < 2)
return 1;
for (i = 2; i < n; i++) n能否被2~√n整除 i<=sqrt(n) #include<math.h>
{
if (n % i == 0)
return 0;//不是素数
}
return 1; //是素数
}
int main()
{
int n;
printf("请输入一个数:\n");
scanf("%d", &n);
int ret = is_prime(n);
if (ret == 1)
printf("%d是素数\n",n);
else
printf("%d不是素数\n",n);
return 0;
}
2.写一个函数判断一年是不是闰年。
int is_leap_year(int n) //如果没有写返回类型,默认返回int
{
return((n % 4 == 0 && n % 100 != 0) || n % 400 == 0);//真就是1,假就是0;
}
int main()
{
int year = 0;
printf("请输入一个年份: ");
scanf("%d", &year);
int leap = is_leap_year(year);
if (leap == 1)
printf("%d年是闰年\n", year);
else
printf("%d年不是闰年\n", year);
return 0;
}
3.写一个函数,实现一个整型有序数组的二分查找。
int binary_search(int arry[], int key, int sz)
{
int left = 0;
int right = sz - 1;
while (left <= right)
{
int mid = left + (right - left) / 2; //left+(right-left)/2;防止溢出
if (arry[mid] < key)
left = mid + 1;
else if (arry[mid] > key)
right = mid - 1;
else
return mid;
}
return -1;
}
int main()
{
int arry[10] = { 1,2,3,4,5,6,7,8,9,10 };
printf("请输入你要查找的数: ");
int key = 0;
scanf("%d", &key);
int sz = sizeof(arry) / sizeof(arry[0]);//数组个数
//如果找到就返回下标,没找到就返回-1
//数组arr传参,实际传递的不是数组的本身
//仅仅传过去了数组首元素的地址
int ret = binary_search(arry, key, sz);
if (ret == -1)
printf("没找到!");
else
printf("找到了,下标为%d", ret);
return 0;
}
这时候就会有同学有疑问了,能不能把求数组大小放到函数内部,答案是不行!为什么呢?
这是因为在函数内部,arry
实际上只是一个指向数组首元素的指针,无法直接获取整个数组的大小。
说到这里,其实还有一种巧妙的方法求出数组的个数。因为二分查找有个特定的前提就是必须有序,那么我们就可以用一个特殊的值标记数组的末尾,而不是传递数组的大小。
int binary_search(int arry[], int key)
{
//int sz = sizeof(arry) / sizeof(arry[0]); 不能放在函数内部,是错误的写法
int index = 0; //标记
while (arry[index] != 10) {
index++;
}
int left = 0;
int right = index - 1;
while (left <= right)
{
int mid = left + (right - left) / 2; //left+(right-left)/2;防止溢出
if (arry[mid] < key)
left = mid + 1;
else if (arry[mid] > key)
right = mid - 1;
else
return mid;
}
return -1;
}
int main()
{
int arry[10] = { 1,2,3,4,5,6,7,8,9,10 };
printf("请输入你要查找的数: ");
int key = 0;
scanf("%d", &key);
//如果找到就返回下标,没找到就返回-1
int ret = binary_search(arry, key);
if (ret == -1)
printf("没找到!");
else
printf("找到了,下标为%d", ret);
return 0;
}
4.写一个函数,每调用一次这个函数,就会将 num 的值增加1。
void Add(int* p)
{
(*p)++;
}
int main()
{
int num;
num = 2;
printf("增加之前:%d\n", num);
Add(&num);
printf("增加之后:%d\n", num);
return 0;
}
5. 函数的嵌套调用和链式访问
函数和函数之间可以根据实际的需求进行组合的,也就是互相调用的。
5.1 嵌套调用
#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;
}
函数可以嵌套调用,但是不能嵌套定义。
5.2 链式访问
把一个函数的返回值作为另外一个函数的参数。
#include <stdio.h>
#include <string.h>
int main()
{
char arr[20] = "hello";
int ret = strlen(strcat(arr, "bit"));//strcat 把后面的字符串添加到arr中
printf("%d\n", ret);
return 0;
}
#include <stdio.h>
int main()
{
printf("%d", printf("%d", printf("%d", 43))); //打印结果是什么?
return 0;
}
查看官方文档,printf函数的返回值是什么,可以看到printf
函数的返回值是打印在屏幕上字符的个数。
所以我们就可以分析printf("%d", printf("%d", printf("%d", 43)));
的打印结果了。
- 内层
printf("%d", 43)
打印出数字 43,并返回 2,因为它是由两个字符组成的(一个是字符 ‘4’,另一个是字符 ‘3’)。 - 接着,中间的
printf("%d", 2)
打印出数字 2,并返回 1,因为它只是由一个字符组成。 - 最外层的
printf("%d", 1)
打印出数字 1,并返回 1,因为它也只是由一个字符组成。 - 最终输出 4321
第一个输出的数字是 43,表示内层 printf("%d", 43)
打印出的数字;
第二个输出的数字是 2,表示中间的 printf("%d", 2)
打印出的字符数;
第三个输出的数字是 1,表示最外层的 printf("%d", 1)
打印出的字符数。
6. 函数的声明和定义
6.1 函数声明:
告诉编译器有一个函数叫什么,参数是什么,返回类型是什么。但是具体是不是存在,函数声明决定不了。
函数的声明一般出现在函数的使用之前。要满足先声明后使用。
函数的声明一般要放在头文件中的
6.2 函数定义:
函数的定义是指函数的具体实现,交待函数的功能实现。
add.h的内容 ------>放置函数的声明
//函数的声明
int Add(int x, int y);
add.c的内容 -------->放置函数的实现
//函数Add的实现
int Add(int x, int y)
{
return x+y;
}
为什么要把函数的声明和定义放在不同的文件里?
- 方便项目协同合作,每个人负责不同的模块,提高效率.
- 可以隐藏和封装函数定义的细节,生成
lib
静态库(二进制文件)让别人看不懂,从而让别人不知道你的代码实现.
那如何把实现函数的add.c文件生成静态库呢?
创建一个新的add文件夹,创建add.h和add.c两个文件,分别实现函数的声明和定义。在左侧资源管理器右键add属性把配置类型改为静态库.lib
,然后点击生成解决方案。最后在add文件Debug中找到add.lib
文件,同时把add.h
文件两个复制到你要使用的该函数的文件代码路径下。
test.c的内容--------->负责调用add.lib
和add.h
文件
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include "add.h"
//怎么使用静态库呢?
#pragma comment(lib,"add.lib") //后面不要加分号
int main()
{
int a = 10;
int b = 20;
int ret = Add(a, b);
printf("%d\n", ret);
return 0;
}
7.函数递归
7.1 什么是递归?
程序调用自身的编程技巧称为递归( recursion)。
递归做为一种算法在程序设计语言中广泛应用。 一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解,递归策略。
只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。
递归的主要思考方式在于:把大事化小。
7.2递归的两个必要条件
存在限制条件,当满足这个限制条件的时候,递归便不再继续。
每次递归调用之后越来越接近这个限制条件。
7.2.1 练习1
接受一个整型值(无符号),按照顺序打印它的每一位。
例如:输入:1234,输出 1 2 3 4
参考代码:
#include <stdio.h>
//如1234,打印 1 2 3 4
// print(1234)
// print(123) 4
// print(12) 3 4
// print(1) 2 3 4
void Print(unsigned int n) //1234
{
if (n > 9)
{
Print(n / 10);//123 12 1
}
printf("%u ", n % 10);
}
int main()
{
unsigned int num = 0;
scanf("%u", &num);
Print(num);
return 0;
}
Print()
函数递归步骤如下图:
7.2.2 练习2
编写函数不允许创建临时变量,求字符串的长度。
参考代码:
我们先考虑用常规的方法求解字符串的长度。当然不能直接使用strlen
库函数,其实就是变相的让你模拟实现strlen
函数。
//编写函数,求字符串的长度
#include <stdio.h>
int my_strlen(char* arr)
{
int count = 0;//计算个数
while(*arr != '\0')
{
count++;
arr++;//地址加1,跳过一个字符
}
return count;
}
int main()
{
char arr[10] = "xixilove";
int len = my_strlen(arr);//数组名是首元素的地址
printf("%d", len);
return 0;
}
那如果不用临时变量,又该如何使用递归实现呢?其实就是递归中的拆分思路,把大的问题拆解。
因此我们画出如下求解字符串长度的流程图—假设求解字符串"abcdef"
的长度:
所以我们就可以写出对应的递归代码了:
int my_strlen(char* arr)
{
if (*arr != '\0')
return 1 + my_strlen(arr + 1);
else
return 0;
}
int main()
{
char arr[10] = "xixilove";
int len = my_strlen(arr);//数组名是首元素的地址
printf("%d", len);
return 0;
}
7.3 递归与迭代
递归是指一个函数在执行过程中调用自身的情况。它通过不断调用自己并缩小问题规模来解决问题,直到达到基本情况(递归终止条件),然后逐步返回并解决原始问题。递归一般比较直观,有时也更容易实现,但在某些情况下可能会导致堆栈溢出,因为每次调用都会占用内存空间。
迭代是使用循环结构重复执行一段代码,而不是通过函数调用自身。它通过迭代(重复)执行相同的代码块来解决问题,通常使用循环语句(如for
、while
)来控制流程。迭代通常不会像递归那样占用额外的内存,但在某些情况下可能不够直观或难以理解。
虽然它们是不同的概念,但递归和迭代在某些情况下可以相互转换,因为一些递归问题可以通过迭代方式解决,而一些迭代问题也可以使用递归方法解决。
下面我们通过一些练习来感受使用递归和迭代的区别。
1.练习1
求n的阶乘。(不考虑溢出)
代码实现:
// n的阶乘 递归的方式实现
int factorial(int n)
{
if (n <= 1)
return 1;
else
return n * factorial(n - 1);
}
int main()
{
int a = 0;
scanf("%d", &a);
int ret = factorial(a);
printf("ret =%d\n", ret);
return 0;
}
//for 循环方式实现n的阶乘
int main()
{
int i = 0;
int n = 0;
int ret = 1;
scanf("%d\n", &n);
for (i = 1; i <= n; i++)
{
ret *= i;
}
printf("ret =%d\n", ret);
return 0;
}
可以发现使用递归,代码极其简洁。
2.练习2
求第n个斐波那契数。(不考虑溢出)
斐波那契数列是一个数列,其中每个数字都是前两个数字的和。
斐波那契数列的定义如下:
F(0) = 0
F(1) = 1
F(n) = F(n-1) + F(n-2),其中 n > 1
根据这个定义,斐波那契数列的前几个数字是:0, 1, 1, 2, 3, 5, 8, 13, 21, …
代码实现:
int count = 0;
int Fib(int n)
{
if (n == 3) //当n=40
count++;//统计n=3递归被重复计算的次数,count=39088169,可见效率很低
if (n <= 2)
return 1;
else
return Fib(n - 1) + Fib(n - 2);
}
int main()
{
int a = 0;
scanf("%d", &a);
int ret = Fib(a);
printf("ret=%d\n", ret);
printf("count=%d\n", count);
return 0;
}
但是我们发现有问题:
-
在使用 Fib 这个函数的时候如果我们要计算第50个斐波那契数字的时候特别耗费时间。
-
使用 factorial 函数求10000的阶乘(不考虑结果的正确性),程序会崩溃。
为什么呢?
-
我们发现 Fib 函数在调用的过程中很多计算其实在一直重复。
-
这时输出看看count,是一个很大很大的值。
-
在调试 factorial 函数的时候,如果你的参数比较大,那就会报错:
stack overflow
(栈溢出)这样的信息。 -
系统分配给程序的栈空间是有限的,但是如果出现了死循环,或者(死递归),这样有可能导致一直开辟栈空间,
最终产生栈空间耗尽的情况,这样的现象我们称为栈溢出。
3.解决递归栈溢出
解决递归栈溢出一般有两种方式:
-
将递归改写成非递归(迭代)。
-
使用
static
对象替代non-static
局部对象。在递归函数设计中,可以使用 static 对象替代non-static
局部对象(即栈对象),这不仅可以减少每次递归调用和返回时产生和释放non-static
对象的开销,而且 static 对象还可以保存递归调用的中间状态,并且可为各个调用层所访问。
解释第二点:当在递归函数中定义一个非静态局部对象时,每次递归调用都会创建一个新的对象,存储在栈上。随着递归深度增加,这些对象会不断被创建和销毁,带来额外的开销。
相比之下,使用静态对象意味着这个对象只会在程序生命周期内创建一次,并且不会在函数调用结束时销毁。在递归函数中,静态对象能够保持其状态,因为它在递归调用之间保持存在。这样做可以减少创建和销毁对象的开销,并且允许不同递归层次之间共享和访问相同的对象状态。
因此我们可以采用上述两种方法来解决递归栈溢出问题。
采用非递归的方式来实现n
的阶乘和斐波那契数列。
//求n的阶乘
int factorial(int n)
{
int result = 1;
while (n > 1)
{
result *= n;
n--;
}
return result;
}
int main()
{
int n = 0;
scanf("%d", &n);
int ret = factorial(n);
printf("ret=%d\n", ret);
return 0;
}
//求斐波那契数列迭代方式
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("ret=%d\n", ret);
return 0;
}
采用static对象来求求n
的阶乘和斐波那契数列。
#include <stdio.h>
// 使用静态变量来保存阶乘的中间状态
int factorial(int n) {
// 静态变量,保存阶乘中间状态
static int result = 1;
if (n <= 1) {
return result;
}
else {
result *= n; // 计算阶乘
return factorial(n - 1); // 递归调用
}
}
int main() {
int number = 0;
scanf("%d", &number);
int result = factorial(number);
printf("%d 的阶乘为: %d\n", number, result);
return 0;
}
使用静态变量 result
来保存阶乘的中间状态。
每次递归调用时,result
都会乘以当前的 n
值,这样就能在递归调用过程中保留阶乘的状态。
#include <stdio.h>
// 使用静态变量来保存斐波那契数列的中间状态
int fibonacci(int n) {
// 静态变量,保存斐波那契数列中间状态
static int memo[100] = { 0 }; // 初始化静态数组用于存储计算结果
if (n <= 1) {
memo[n] = n;
return n;
}
else {
if (memo[n - 1] == 0) {
memo[n - 1] = fibonacci(n - 1);
}
if (memo[n - 2] == 0) {
memo[n - 2] = fibonacci(n - 2);
}
memo[n] = memo[n - 1] + memo[n - 2];
return memo[n];
}
}
int main() {
int number = 0;
scanf("%d", &number);
int result = fibonacci(number);
printf("斐波那契数列第%d个的结果: %d\n", number, result);
return 0;
}
使用静态数组 memo
来保存计算结果,以避免重复计算。在每次递归调用时,如果计算结果尚未被存储在 memo
数组中,则进行计算并存储结果。这样,在递归调用过程中可以共享并重复使用之前计算的结果,减少了计算量。
4.总结
-
许多问题是以递归的形式进行解释的,这只是因为它比非递归的形式更为清晰。
-
但是这些问题的迭代实现往往比递归实现效率更高,虽然代码的可读性稍微差些。
-
当一个问题相当复杂,难以用迭代实现时,此时递归实现的简洁性便可以补偿它所带来的运行时开销。