目录
- 一、函数介绍
- 1.1 函数是什么
- 1.2 C语言中函数的分类
- 1.3 函数原型
- 1.4 高内聚 低耦合
- 1.5 C语言main函数的位置
- 二、函数的参数
- 2.1 实参和形参
- 2.2 函数的参数(实参)可以是表达式
- 2.3 传值与传址(swap函数)
- 2.4 明确形参是实参的临时拷贝
- 2.5 void(如果不写函数返回值 默认是int)
- 2.6 有返回值的函数一定要都return
- 2.7 一道关于不写返回值的例题
- 2.8 在函数中 尽量少使用全局变量
- 2.9 当参数是`数组名`的时候 不能在函数内部计算元素个数
- 2.10 钻一个牛角尖:其实通过形参改变实参这句话是不严谨的
- 三、函数的调用与练习
- 3.1 传值调用和传址调用
- 3.2 判断一个数是不是素数
- 3.3 判断一年是不是闰年
- 3.4 实现一个整形有序数组的二分查找
- 3.5 写一个函数 每调用一次这个函数 就会将num的值增加1
- 四、函数的嵌套调用和链式访问
- 4.1 函数可以嵌套调用 但不能嵌套定义
- 4.2 链式访问
- 4.3 printf()函数的返回值
- 五、函数的声明和定义
- 5.1 函数声明和定义
- 5.2 保证先声明(定义)后使用
- 5.3 实际场景下的函数的声明和定义
- 5.4 模块化编程的优势
- 六、 函数递归与经典习题
- 补充:int的取值范围
- 6.1 什么是递归
- 6.2 递归的两个必要条件
- 6.3 死递归导致栈溢出(StackOverFlow)
- 6.4 按顺序打印一个无符号整型值的每一位
- 6.5 不允许创建临时变量 求字符串的长度
- 6.6 怎么找递归出口
- 七、迭代与递归
- 7.1 求n的阶乘 注意0!=1
- 7.2 求第n个斐波那契数
- 递归法(容易栈溢出)
- 迭代法
- 7.3 如何选择递归与迭代
- 7.4 函数栈帧简介
- 八、两个经典例题
- 8.1 青蛙跳台阶(动态规划的基本认识)
- 问题描述
- 递归
- 动态规划
- 补充:迭代法
- 8.2 汉诺塔
- 问题描述
- 思路
- 代码实现
- 思考:怎么判断一共要移动几次?(时间复杂度?)
- 九、函数栈帧的创建和销毁
- 压栈和出栈
- 9.1 说明
- 9.2 寄存器及栈区的使用方式
- 9.3 main函数其实也是被其他函数调用的
- 9.4 F10之后 转到反汇编开始观察
- S1:push ebp 压栈
- S2:mov ebp,esp
- S3:sub esp,0E4h->开辟main函数的函数栈帧
- S4:三次push
- S5:lea加载有效地址
- S6:默认初始化main函数的栈帧空间
- S7:开始给a分配空间(知道为什么局部变量不初始化是随机值了)
- S8:继续给b c分配空间
- 总结1:怎么创建局部变量的?
- 9.5 开始观察Add()
- A1:先传参b
- A2:再传参a
- 总结2:怎么传参的?(怎么和想象中不太一样?)
- A3:call-调用函数(理解函数是怎么返回的)
- 总结3 可以认为形参是在main函数的栈帧里创建的
- A4:进入Add之后 类似main 先开辟栈帧 然后初始化
- A5:给Add函数的局部变量z开辟空间
- A6:执行z = x + y
- A7:临时保存计算结果
- A8:三次pop
- A9:回收Add函数的栈帧
- A10:继续往下执行(之前存放的call指令下一条指令的地址派上用场了)
- A11:销毁形参
- A12:把A7的计算结果真正给到c
- 总结4:形参并不是是在Add函数内部创建的
- 总结5:Add()函数的整个调用流程
- 总结6:形参确实是实参的临时拷贝
- 总结7:什么叫销毁?
- 最后的总结
一、函数介绍
1.1 函数是什么
在计算机科学中 子程序(函数 function) 是一个大型程序中的某部分代码
由一个或多个语句块组成
它负责完成某项特定任务 而且相较于其他代码 具备相对的独立性
一般会有输入参数并有返回值 并封装了函数的过程 隐藏了实现细节
这些代码通常被集成为软件库(库函数)
1.2 C语言中函数的分类
- 分为库函数 和 自定义函数
库函数的好处
常见的库函数
1.3 函数原型
- 函数名 参数 返回值
1.4 高内聚 低耦合
- 让函数的功能 尽量独立 解耦
- 各模块之间尽量相互独立
1.5 C语言main函数的位置
- 第一章说了
main函数必须要有 有且仅有1个
- 对于位置是没有要求的
二、函数的参数
2.1 实参和形参
- 不管什么形式的形参 函数调用时
都要有确定的值
函数调用过程中才会给形参分配内存空间
不调用函数的时候 他们就是一段代码- 形参是
在函数调用的时候才会实例化
才开辟内存空间出函数的时候 形参就自动销毁了
形参只在函数中有效 和局部变量类似 所以他们都在栈区- 形参
实例化之后
相当于是实参的一份临时拷贝
实际参数(实参)
形式参数(形参)
2.2 函数的参数(实参)可以是表达式
函数的参数可以是表达式
也就是()括起来的 所以下面只有四个参数
前面两个是逗号表达式 表达式的值才作为参数
2.3 传值与传址(swap函数)
- 其实我可以说:
都是传值 都是临时拷贝 只不过一种值是数值 一种值是地址
值传递:
a b是实际参数 x y是形式参数
形参会申请自己的内存空间实参ab和形参xy使用的不是同一空间
本代码仅仅交换了形参x y
址传递:
其实形参仍然是实参的临时拷贝pa就是&a的一份临时拷贝 只不过pa和&a 都指向同一块内存空间 所以会影响到a的值
2.4 明确形参是实参的临时拷贝
2.3的图解 x y的确是形参的临时拷贝
下图 传参传的是&a 也就是a的地址
形参pa 照样是实参的一份临时拷贝
如下图 pa里放的是一个地址
只不过因为pa是地址 所以通过pa 可以找到a
但是再怎么折腾形参pa 也不会影响到实参&a
解引用*pa其实是远程影响到了a
再来看这个案例 我图片上说的有些复杂了 别看
其实本质就在于:形参一直都是实参的临时拷贝 这里是把a和b的地址临时拷贝给pa和pb了
然后我只是交换了形参pa和形参pb的值所以*pa找到b *pb找到a
函数里看上去是交换了 倒不如说我先打印的b 再打印的a
只要能理解"临时拷贝" 这里就很好理解 总感觉语言难以描述 但是想很容易想清楚
2.5 void(如果不写函数返回值 默认是int)
- 写了void 就表示
不需要参数 不要传参;没有返回值 不需要返回!!
- 不要去钻没有意义的牛角尖!!
- 作为写函数的人 如果你希望函数没有返回值或者不需要传参 就写上void
- 作为函数调用者 你看到了void 就遵守这个规则 不要钻牛角尖!!
- 函数的返回类型不写的话 默认是int类型 如果不需要返回值请写上void
下面我钻一下牛角尖:
下面的研究没啥意义 但凡规范一点 啥事没有 函数切忌写的模棱两可
如果函数没有规定形参的类型
不管传递什么 反正都不要
可以执行下去
如果函数规定形参是void 但还是传了个参数 不会报错 但是会报警告 无论如何 就算不报错 既然写了void 就不要传参给void
不规定函数的返回类型 不代表不返回任何东西
而默认返回int
当你不需要返回任何东西的时候请明确定义为void test() 规范!!!
2.6 有返回值的函数一定要都return
- 下面这个代码 假如我输入1 会打印yes 显然错误
- 错误的原因就在于 如果是闰年 返回1
如果不是闰年呢? 没写返回值!(未定义行为!不一定会返回什么)
int is_leap_year(int year)
{if ((year % 400 == 0) || ((year % 4 == 0) && (year % 100 != 0)))return 1;
}int main()
{int i = 0;scanf("%d", &i);int test = is_leap_year(i);if (is_leap_year(i) == 1)printf("yes\n");elseprintf("no\n");return 0;
}
-
所以也要学会看警告
-
具体错误可能在于 编译器借用了是闰年的return 1 因为我没有定义不是闰年返回什么 所以他也给我返回1
-
反正编译器报警告了 也要学会看警告
2.7 一道关于不写返回值的例题
下面这个代码
不写返回值 默认返回的int
但是代码里并没有return(未定义行为)
这时候返回的值取决于编译器的实现 大多数时候返回的是该函数执行完最后一条指令产生的结果
最终test()返回的其实就是printf的返回值—>也就是打印的字符的个数(这是VS2022的测试结果)
2.8 在函数中 尽量少使用全局变量
- 假如前面的2.3 把a b定义成全局变量 确实怎么都可以交换a b
- 但是 全局变量本身很危险 谁都可以用 跨文件只要声明了 也能用 很危险
- 其次 我们说函数有一个特点就是"模块化" 他仿佛是一个独立的模板 实现某个特点的功能 如果函数依赖全局变量 仿佛就失去了"独立性" 而依赖全局变量了
2.9 当参数是数组名
的时候 不能在函数内部计算元素个数
如果在函数内部用sizeof(arr)/sizeof(char) 结果恒为4/8
因为不论传参的形式是下面哪一种 本质是就是一个指针
既然在函数里 ch一定是一个指针 那么sizeof(指针) 就一定是4或者8
在指针那一章节已经详细论述过
2.10 钻一个牛角尖:其实通过形参改变实参这句话是不严谨的
总之是有点钻牛角尖了
如果是出现在选择题中 心中有谱就行
主要就是区分传值和传址的区别和共同点
之前提到:
test(&a) 这种传址调用的方式
仿佛可以通过形参改变实参
下面这句话似乎是对的 但是不够严谨
但是:
- 这里的实参是&a 也就是a的地址 实参不是a!!
- 形参其实还是实参的一份临时拷贝 也就是形参是&a的临时拷贝
- 如此一来 我们说:通过形参和实参 都能够找到同一个a 于是,通过形参可以远程操作a(
但a严谨说来并不是实参
)- 所以更严谨的说
通过形参可以改变实参所指向的对象
而不是实参本身
test(&a) 通过形参也就是a的地址 怎么去改变&a? 难道能把a的地址给换了?
严格来说 形参一直都是实参的临时拷贝 两者是独立的 只不过有的时候拷贝的是值 有的时候是地址
形参不可能直接改变实参本身 他们都是独立的空间 严格来说应该是:不管传址调用还是传值调用 改变形参不影响真正的实参本身
下面这句话肯定是对的 但不全面
三、函数的调用与练习
3.1 传值调用和传址调用
传值调用
传址调用
说明:
下面的练习的主要思路在考研C语言程序设计_编程题相关(持续更新)一文写过 本文主要是练习函数的写法
3.2 判断一个数是不是素数
- 一开始忘记#include<math.h>了 一直不打印 找了半天的错…
- return比break更彻底
#include<math.h>
#include<stdio.h>
#include<string.h>int isPrime(int num)
{//num是素数就返回1 否则返回0int j = 0;for (j = 2; j <= sqrt(num); j++){if (num % j == 0)return 0;}//因为这是return 所以能return1 肯定没有return0过return 1;
}
int main()
{int i = 0;for (i = 100; i <= 200; i++){if (isPrime(i))printf("%d ", i);}return 0;
}
3.3 判断一年是不是闰年
int isLeapYear(int year)
{return (year % 400 == 0) || ((year % 4 == 0) && (year % 100 != 0));
}
int main()
{int i = 0;for (i = 1000; i <= 2000; i++){if (isLeapYear(i))printf("%d ", i);}return 0;
}
3.4 实现一个整形有序数组的二分查找
这里犯了如下错误:
- mid = (left + right) / 2; 没有写在循环里!! 所以说
以后写while 别一上来就while 先把逻辑写好 再cv到while里!!!
return下标mid 不是return k
k要你return了干嘛的?不能在函数里用sizeof(arr)/sizeof(int)求len
经典的错误这个结果永远都是1或者2(取决于几位机器)
在函数里sizeof(arr)求的是一个指针变量的大小
- 我的思路是 找到了返回下标
找不到就返回0 这个设计就是错的 假如我要找的下标正好是0呢?
更正:既然找到了是返回下标 下标肯定是≥0 那找不到就返回-1- 还有
前提一定是有序数组啊 我一开始又给了无序的测试数组....
- 数组传参也可以写成int arr[ ]
但是本质还是int* arr 而且arr[1]本质上是*(arr+1)
//把找到的下标返回 找不到就返回-1
//也可以写成int arr[] 但是本质上是int* arr
int BinarySearch(int* arr, int k, int len)
{int left = 0;int right = len - 1;int mid = 0;//能不能= 想不通 举个例子不就想通了 //都指向同一个元素的时候 肯定还要再求一个mid去判断一下的 肯定要写=while (left <= right){mid = (left + right) / 2;if (arr[mid] < k)left = mid + 1;else if (arr[mid] > k)right = mid - 1;elsereturn mid;//返回的是下标 不是k}return -1;
}int main()
{int arr[] = { 1,2,3,4,5,6,7,8,9,10 };int len = sizeof(arr) / sizeof(arr[0]);int k = 10;int index = BinarySearch(arr, k, len);if (index != -1)printf("找到了 下标是:%d\n", index);elseprintf("查无此人\n");return 0;
}
3.5 写一个函数 每调用一次这个函数 就会将num的值增加1
本解法主要就是要知道传址调用的意义 此外还要注意一下优先级的问题
- *pnum = *pnum + 1; ok
- *(pnum)++; ok
*pnum++;no no no
- *pnum++ 等价于 *(pnum++) 因为++的优先级高 先应用于pnum 而且是后置++ 这么写相当于每次进去函数就解引用pnum 拿到num 然后啥也不干 再对pnum自增 让他成为野指针
- 关于优先级的问题 在第5章操作符会详细讨论
void func(int * pnum)
{//*pnum++ err(*pnum)++;
}int main()
{int num = 10;func(&num);printf("%d\n", num);func(&num);printf("%d\n", num);func(&num);printf("%d\n", num);return 0;
}
还有一种思路 可以不用指针
四、函数的嵌套调用和链式访问
4.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;
}
函数可以嵌套调用 但是不能嵌套定义
main函数也是个函数
4.2 链式访问
- 链式访问:把一个函数的返回值作为另外一个函数的参数
- 这就是一种链式访问 把strlen的返回值 作为printf的参数
- 这里要注意 先执行里面一层的函数(strlen) 再执行外面的
4.3 printf()函数的返回值
-
printf的返回值是
打印的字符的个数
比如用%d打印34 34有俩字符 3和4 返回值就是2
-
4.2说到了 先执行里面一层的函数 再执行外面的
-
所以打印顺序是:图上面写错了写错了 是打印4321
五、函数的声明和定义
5.1 函数声明和定义
- 声明可以告诉编译器
有一个函数叫什么 参数是什么 返回类型是什么
但是具体是不是真的存在这个函数 函数声明决定不了 - 函数的声明一般出现在函数的使用之前 要满足
先声明后使用
- 函数的声明一般要放在
头文件中
- 函数的定义是指函数的具体实现 交待函数的功能实现
5.2 保证先声明(定义)后使用
-
编译的时候 是从前往后扫描代码的
先用后定义 会报一个警告
-
声明一下 就不会报警告了
满足先声明后使用
-
函数声明里的x y 可以省略
-
其实
函数的定义也算是一种特殊的声明
既然说要先声明后使用 那建议定义的时候直接定义在使用之前
-
变量的声明和定义 在第一章已经提到了 也是类似的规则
-
虽然规则允许 但是为什么要这样写代码??? 这不是**吗 直接定义在前面不就行了
5.3 实际场景下的函数的声明和定义
● add.h:函数的声明
● add.c:函数的实现(定义)
● test.c:测试
5.4 模块化编程的优势
- 每个程序员分别去写自己的模块
模块之间相互独立
需要用到的时候调用一下即可 - 其实 之前已经提到过
我在test.c里extern int Add(int x,int y); 就可以跨文件使用add.c里的Add()了
为什么非要写一个add.h 然后include<“add.h”>呢?->这样写可以做到代码的隐藏
假设我希望把我写的程序卖给别人用 但是不希望别人知道我的源码
以add这个功能为例 我就可以写add.h+add.c 两个文件 然后编译生成一个静态库add.lib
这个文件就是.h和.c编译所生成的 是无法通过.lib看到源码的
然后就把add.lib和add.h卖给别人 别人只知道函数长什么样 怎么用 但是不知道函数的具体实现
买家导入静态库 就可以使用这个函数的功能了
但是不知道源代码
六、 函数递归与经典习题
补充:int的取值范围
- 这个知识点会在
后续第九章:数据的存储
详细介绍 - 这里就暂时记住如果
是有符号int
最大是21亿多 如果是无符号int
最大是42亿多 - 下面的题目暂时不考虑计算结果超过了42亿这种情况 而是学习递归的思想
6.1 什么是递归
程序或函数直接或间接调用自身的编程技巧称为递归(recursion)
- 递归通常把一个大型复杂的问题
层层转化为一个与原问题相似的规模较小
的问题来求解 - 递归策略只需
少量的程序
就可描述出解题过程所需要的多次重复计算 大大地减少了程序的代码量
- 递归的主要思考方式在于:大事化小 找到子问题+递归函数的出口
这就是一个递归 但是
是死递归
6.2 递归的两个必要条件
- 存在限制条件 当满足这个限制条件的时候 递归便不再继续
即递归要有出口
- 每次递归调用之后
越来越接近这个限制条件
- 我个人理解的递归是:
-
- 能判断出
一个问题确实是有N个类似的子问题组成
明确子问题是怎么样的
- 能判断出
-
假设出递归函数实现的功能
并顺着思路利用这个函数 写出代码
-
- 能确定递归出口
(明确在什么条件下才继续"递" 否则就开始"归")
- 能确定递归出口
-
每次函数调用都会更接近出口
在这种条件下 就可以用递归求解
6.3 死递归导致栈溢出(StackOverFlow)
6.4 按顺序打印一个无符号整型值的每一位
接收一个无符号整型值
按照顺序打印它的每一位
例如:
输入:1234
输出:1 2 3 4
确定子问题 假设PrintfNum()的功能是打印参数的每一位
- PrintfNum(1234) =
PrintfNum(123) + 打印4 =
PrintfNum(12) + 打印3 + 打印4 =
PrintfNum(1) + 打印2 + 打印3 + 打印4(达到递归出口)
=
打印1 + 打印2 + 打印3 + 打印4确定递归结束条件:要打印的数字只剩下个位了 也就是参数<10
也就是说参数>=10(或者>9)的时候 继续递归
void PrintNum(int num)
{//printf("%d ", num % 10); 写在前面就是倒序打印if (num > 9)//or if(num / 10 > 0)PrintNum(num / 10);//子问题//写在这里才是顺序打印printf("%d ", num % 10);
}
int main()
{int num = 2311516;PrintNum(num);return 0;
}
-
如果尝试迭代法的话 发现:只能倒序打印
-
看一下这道题一个错误的写法 多写了else:
6.5 不允许创建临时变量 求字符串的长度
解法1:迭代法
遗憾的是 用到了临时变量count
int MyStrlen(char* str)
{int count = 0;while (*str != '\0'){count++;str++;}return count;
}
解法2:递归法 但是还是创建了临时变量
这是我一开始想到的思路 但是没注意到不创建临时变量
其次 我一开我把int len = 0;定义在了下面注释的地方导致每次长度都会被清零 最终结果永远是1
int len = 0;
int StrLen(char* str)
{//int len = 0;if (*str != '\0'){StrLen(++str);len++;}return len;
}
解法3:递归法 而且不创建临时变量
注意
不要忘记return 0
如果没有这个return 0 当最后一次计算MyStrLen(‘\0’)的时候发现根本无路可走 成了未定义行为了
而且不写return 0不仅是语法上的错误 更是逻辑上的错误
本身本题的递归出口就是:遇到\0 就要返回0了
也就说每次传参都离\0越来越近
直到真的传了\0的地址进去 解引用发现是\0 就返回0并从此刻开始"归"
参考代码:
int MyStrlen(char* ch)
{//hello 1+ello 1+1+llo 1+1+1+lo 1+1+1+1+o 1+1+1+1+1+0if (*ch == '\0')//递归出口return 0;elsereturn 1 + MyStrlen(++ch);
}int main()
{char ch[] = "hello";printf("%d ", MyStrlen(ch));return 0;
}
或者这么写:
只要每次都传入str+1 离递归出来越来越近就ok
千万不可以写成后置++了 不然每次传递的都是首地址 死递归了
int MyStrLen(char* str)
{if (*str != '\0')return 1 + MyStrLen(str+1);elsereturn 0;
}
- 写成后置++ 如下图 就死递归了(看下面的
函数栈帧
) - 第一次调用MyStrlen(ch++)
后置++ 先使用 再++ 但是+的是这个函数栈帧的ch
- 第二次调用MyStrlen(ch++)
这个ch接收的是第一次先使用传进来的地址
虽然参数的名字是一样的 但是各自在不同的函数栈帧 也是后置++也是先使用 本质上都是同一个地址
- 每次递归调用的时候
虽然形参名字都叫ch 但其实是不同的ch
都是一份临时拷贝 所以每次都是首地址 永远不会接近递归出口 导致死递归
6.6 怎么找递归出口
我认为这个思想很重要 所以写了一小节
如果用递归
那么我每次都会把原问题 转换成一个相同逻辑的子问题+其他操作
我必须要思考 什么时候这个子问题就成最小的 无法再分裂出子问题了?
6.4:当只剩下个位数的时候 无法再分裂了
所以递归出口就是参数是个位数
6.5:当成空字符串了 就无法再分裂了
所以递归出口就是*str指向\0 这个时候要返回0 并开始"归"
七、迭代与递归
7.1 求n的阶乘 注意0!=1
一个注意点:0!=1
- 函数从哪里调用
return就回到那里去继续往下执行
- 理解代码执行流程:
函数A里在SP处调用函数B 从SP处开始函数A就"暂停"了 直到函数B执行完 再回到SP处继续执行A
- 假如求factorial(10000)
递归会栈溢出
迭代算的不对(超过int范围)
- 假如
递归的重复计算太多了 导致栈溢出
可以考虑用迭代 迭代最多算的不对 栈溢出可是程序崩溃了
int factorial_rec(int num)//递归
{//5!=5*4!=5*4*3!.... //分裂到1! 不可以再分出子问题 就可以归了if (num == 1 || num == 0)//or if(num<=1)return 1;elsereturn num * factorial_rec(num - 1);
}int factorial_ite(int num)//迭代
{int i = 0;int ret = 1;for (i = 1; i <= num; i++){ret *= i;}return ret;
}int main()
{//递归求n的阶乘int num = 0;printf("%d ", factorial_rec(num));return 0;
}
7.2 求第n个斐波那契数
递归法(容易栈溢出)
有了前面的铺垫 可以轻易写出这个的递归算法 但是代码简单就一定好吗?
NO!! 递归的写法 假如算fib(50)算的特别慢 但并没有栈溢出 一直在算
因为发生了大量的重复计算
如下图递归求第四十个fib数 第三个fib数被重复计算了快四千万次!!
int fib_rec(int index)
{if (index == 1 || index == 2)return 1;else //也就是求第3+个fibreturn fib_rec(index - 1) + fib_rec(index - 2);
}
迭代法
迭代法的思路:
要自己画图 找到关键步骤:
1 1 2 3 5 8 13 假设我要求第6个fib 也就是8
想象一下一开始的时候有两个指针指向1和1 然后每次循环都让他们往前移动一位数
这两个指针指向的内容变化过程是:
1 1->1 2->2 3->3 5->5 8 这个时候 8就出现了(假如是一个人口算 那他肯定也是这么算的 逐渐往后加)
也就是第二个指针所指向的内容 那么直接把他返回即可
迭代法 求fib(10000)
虽然结果不对 因为结果用int接收的
但是算的很快
下面提供三种不同的写法 思路都是一样的 就是实现过程不一样
- 写法1 for循环:
- 这是我的写法 自己
画图找规律
发现 求第3个 要循环1次 求第index个 要循环index-2次 - 能确定循环次数 于是决定用for循环
int fib_ite1(int index)
{//前两个直接定义出int first = 1;int second = 1;//计算得出第index个fib数 index>=3int index_num = 0;//循环结束条件是画图找规律得出for (int i = 1; i <= index - 2; i++){index_num = first + second;first = second;second = index_num;//second放的就是第index个fib}//直接返回就行了 如果index<3 返回的是我定义好的1和1//否则循环计算出来的return second;
}
写法2 while循环:
好像突然感觉到while和for的适用场景了
确定循环次数 用for; 当…时需要循环 用while因为while不是还可以翻译成当....时候
这里就顺着我的思路写代码:前两个都初始化成1当(while)要求第三个开始的fib数的时候 就进入循环计算得出
同时确定一下出循环的条件 就ok了
注意这里返回的是c 所以c必须初始化成1
返回b也一样 毕竟最后一次总会把b=c
但是如果返回b的话 c就不一定要初始化成1了
7.3 如何选择递归与迭代
- 在调试factorial函数的时候 如果你的参数比较大 那就会报错:stack overflow(栈溢出)
- 系统
分配给程序的栈空间是有限的
但是如果出现了死循环 或者死递归 这样有可能导致一直开辟栈空间 最终产生栈空间耗尽的情况 这样的现象我们称为栈溢出
那如何解决上述的问题:
- 将递归改写成非递归(迭代)
- 使用static对象替代nonstatic局部对象 在递归函数设计中 可以使用static对象替代nonstatic局部对象(即栈对象) 就是把本来占用栈空间的数据放到静态区
一定程度上可以缓解栈区的压力 但是这种操作的帮助是微乎其微的
(而且static对象还可以保存递归调用的中间状态)
- 递归用
栈区开销换简洁性和可读性
7.4 函数栈帧简介
- 配合着本文的6.5思考:
为什么MyStrlen(ch++)会导致死递归?
- 函数栈帧的底层细节十分复杂 本文暂时不做详细论述 只需要知道一些基本的原理 帮助理解递归即可
问题描述
:
也许会疑问:当n=2 最后一次调用Fac(n-1) 去执行Fac(1)
然后1返回给Fac(n-1) 继续执行return n*Fac(n-1)的时候 n为什么还是2?
因为每次函数调用
都会在栈区开辟各自的函数栈帧 他们都有各自的独立形参
互不影响
当Fac(1)调用结束(就是return了) 它的栈帧就销毁了
然后返回到Fac(2)的栈帧继续执行它的栈帧里的n自然还是2
当Fac(2)调用结束 它的栈帧也销毁了
以此类推 栈帧会依次销毁
main函数执行完 也会销毁
所以说死递归的时候 一直在向内存的栈区申请函数栈帧 就栈溢出了
如下图 最终会开辟这么多函数栈帧然后"归"的时候开始销毁
八、两个经典例题
8.1 青蛙跳台阶(动态规划的基本认识)
问题描述
一只青蛙
他可以一次跳一级台阶
或者一次跳两级台阶
假设青蛙要跳上n级台阶
请问一共有多少种跳法
?
递归
- 这道题和斐波那契数列十分类似 只不过斐波那契数列我们直接知道 第三个数开始 每个数就是前两个数之和
- 而这道题 青蛙跳上一级台阶 只有1种跳法 ;跳上二级台阶 有2种跳法
- 思考:跳上第n级(n>=3)呢?
- 假设我给定一个函数 frogJumps(int steps)
假设它的功能就是:可以计算出青蛙跳到steps级台阶有多少种跳法
- 假设青蛙第一次跳了1级 那么剩下的跳法就是frogJumps(n-1)
- 假设青蛙第一次跳了2级 那么剩下的跳法就是frogJumps(n-2)
- 所以所有的跳法就是frogJumps(n-1) + frogJumps(n-2) 当n>=3
- 只要有了递推公式 递归的代码其实很容易写出来
int frogJumps(int steps)
{if (steps <= 2)return steps;elsereturn frogJumps(steps - 1) + frogJumps(steps - 2);
}int main()
{printf("%d ", frogJumps(1));printf("%d ", frogJumps(2));printf("%d ", frogJumps(3));printf("%d ", frogJumps(4));printf("%d ", frogJumps(5));return 0;
}
动态规划
利用本题正好可以介绍一下动态规划
的基本思想 博主在后续的C语言专栏
会持续更新八大算法的思想和最基本的例题 希望对大家有所帮助~
本文在前面多次提到:
不管是用递归计算n!还是递归求第n个斐波那契数量
递归代码虽十分简洁 但是有一个很严重的问题:递归很容易产生大量的重复计算
但是实际上:
- 在计算10!的时候就已经产生9! 8! 7!..了
- 在计算第40个斐波那契数的时候 前面的斐波那契数其实也都已经计算过了
- 那么 该怎么把这些已经计算过的子问题 给利用起来 帮助计算下一个子问题呢?
这就引出了动态规划的一个典型的思想:利用上一次的计算结果 快速计算出下一步的结果
下面可以看看动态规划会怎么计算这道题:
- 台阶的级数n和几种跳法 其实是一 一对应的 --> 数组
#define N 10
int main()
{int arr[N] = { 0 };arr[0] = 1;arr[1] = 2;//前两个子问题的解直接给出//从第三个子问题开始(i=2) 计算得出for (int i = 2; i < N; i++)arr[i] = arr[i - 1] + arr[i - 2];//当循环结束 其实数组里已经放好了1~N级台阶对应的跳法for (int i = 0; i < N; i++)printf("青蛙跳上%d级台阶有%d种跳法\n", i + 1, arr[i]);return 0;
}
补充:迭代法
- 和
本文7.2
类似 - 只不过前两个数是1 2 所以无法直接return tmp 需要再加一个if判断
参考代码:
int Fib(int index)
{//如果index 1 2 直接返回即可if (index <= 2)return index;//如果能走到这里 就说明index>=3了 那就计算求出第index个数(也就是index级台阶有几种跳法)int a = 1;int b = 2;int tmp = 0;while (index >= 3)//3级 循环1次 把tmp返回;4级 循环2次 把tmp返回....{tmp = a + b;a = b;b = tmp;index--;}return tmp;
}int main()
{printf("%d\n", Fib(1));printf("%d\n", Fib(2));printf("%d\n", Fib(3));printf("%d\n", Fib(4));printf("%d\n", Fib(5));printf("%d\n", Fib(6));printf("%d\n", Fib(7));printf("%d\n", Fib(8));printf("%d\n", Fib(9));printf("%d\n", Fib(10));return 0;
}
8.2 汉诺塔
问题描述
将塔A的柱子移动到塔C
要求:
- 大的柱子只能在小的柱子下面
- 一次只能移动一个柱子
思路
这里的思路和本文6.2我个人理解的递归
非常类似 首先核心思路是:
想把A上的n个柱子移动到C
S1:先把A上面n-1个柱子移动到B
S2:再把A剩下的1个柱子移动到C
S3:最后把B上的n-1个柱子移动到C
- 本题显然可以分裂出子问题
移动n个柱子从A到C
和移动n-1个柱子从B到C
是类似的问题
- 假设 void Hanoi(int n, char source, char auxiliary, char target)的功能是:
把在source(源头塔)上的n个长方体 利用auxiliary(辅助塔) 移动到target(目标塔)
下面看代码的时候 不要受参数名影响(B有时候作为目标塔 有时候作为辅助塔 这取决于传参的顺序)
就这么理解:Hanoi()的功能是把第二个参数上的n个柱子 利用第三个参数 移动到第四个参数上去
- 递归的出口:只有一个柱子的时候 一个柱子直接移动就行了
- 每次调用都是一层一层往上 越来越接近1个柱子的情况
- move函数只是模拟了移动这个行为 move(‘A’,‘C’)就表示把A塔上第一个柱子移动到C塔上去
代码实现
当假设出函数的功能 同时有了子问题如何分裂的思路之后
直接顺着这个思路写下去 那就对了!! 正如我6.2所说的!!
void move(char ch1, char ch2)
{printf("%c----->%c\n", ch1, ch2);
}void Hanoi(int n, char source, char auxiliary, char target)
{//如果只有1个柱子 直接移动到目标塔就行if (n == 1){move(source, target);}//不止1个柱子 接收到的参数是A B Celse//n>=2{//S1:先把A上面n-1个柱子移动到BHanoi(n - 1, source, target, auxiliary);//S2:再把A剩下的1个柱子移动到Cmove(source, target);//S3:最后把B上的n-1个柱子移动到CHanoi(n - 1, auxiliary, source, target);}
}int main()
{Hanoi(3, 'A', 'B', 'C');return 0;
}
思考:怎么判断一共要移动几次?(时间复杂度?)
- 根据子问题的递推规则发现:
n个柱子移动次数是1+2*(n-1个柱子的移动次数)
- 进一步找规律发现:
n个柱子需要移动2^n-1
次 - 也就是说
时间复杂度是O(2^n)
其中n是盘子的数量
九、函数栈帧的创建和销毁
压栈和出栈
● push 压栈 在栈顶放一个元素
● pop esh 出栈 把栈顶的元素赋给esh
9.1 说明
在学习前面的内容 可能仍然存在很多困惑:
- 局部变量是如何创建的?
- 为什么局部变量的值不初始化就是随机值?
- 函数是怎么传参的?传参的顺序?
- 一直说形参是实参的临时拷贝 具体怎么实现的?
- 调用一个函数 具体过程是什么?
- 函数调用完 是怎么返回的?
在深入了解函数栈帧的创建和销毁
之后 以上问题都会迎刃而解
注意:
本小节使用如下代码讲解:
9.2 寄存器及栈区的使用方式
寄存器 是集成在CPU上的
和硬盘 内存之间是独立的- ebp esp看作
指针变量
里面放的是地址 - 正在调用哪个函数 ebp和esp维护的就是该函数的函数栈帧
- main函数被调用执行 也需要main函数的栈帧空间
- 栈区的使用方式:先使用高地址 再使用地地址(仿佛从栈顶往里东西 沉到栈底了)
- esp:栈顶指针
- ebp:栈底指针
9.3 main函数其实也是被其他函数调用的
- 然后下面就要调用Add()函数了
9.4 F10之后 转到反汇编开始观察
下面的Sn 就是在观察这部分代码怎么一步一步走的
可以对比观察
S1:push ebp 压栈
刚开始的样子:
push ebp:把ebp这个指针变量压栈 放在栈顶
与此同时 由于esp永远都指向栈顶
当ebp被压栈之后 esp也要往上走(也就是地址值会变低/变小) 直到他指向栈顶
S2:mov ebp,esp
也就是把esp的值赋给ebp 也就说这俩指针变量暂时指向同一块空间
S3:sub esp,0E4h->开辟main函数的函数栈帧
也就是esp = esp - 0E4h(八进制的228)
地址变小了 在图上 就是往上移动了
于是又维护了一个全新的函数栈帧 这其实就是main函数的栈帧
这其实就相当于:在栈区为main函数预开辟了一块空间
S4:三次push
之前说过了 esp永远指向栈顶
所以每次push一个元素到栈顶 esp就会往上移(其实是地址值变小)
S5:lea加载有效地址
先看看这个地址是啥 勾选上这个选项
原来是这样 下图圈起来的不是同一个意思吗?
那就是把S3esp指向的地址赋给edi
S6:默认初始化main函数的栈帧空间
从edi这个位置开始(刚好到ebp结束)
把39h这么多个 dword(相当于四个字节)
全都初始化成CCCCCCCCh
相当于把main预开辟的空间都改成0xCCCCCCCh
S7:开始给a分配空间(知道为什么局部变量不初始化是随机值了)
这表明把0Ah这个值 也就是10么 放到ebp-8的位置
也就是说 不赋值的话 a那块空间放的就是S6默认初始化的CCCCCCC
CCCCCCCvs2013的初始化方式
不同的编译器可能不一样
所以我们说是 “随机值”
S8:继续给b c分配空间
有可能有的编译器就是连着分配的 vs2013中间就隔了八个字节
如下图:
总结1:怎么创建局部变量的?
调用函数
先给函数栈帧开辟空间
然后给这部分栈帧全都初始化成CCCCCCCCC
然后再给函数里的局部变量分配空间 如果不初始化 就是CCCCCCCC
9.5 开始观察Add()
类似9.4的操作 下面开始观察下图的过程
A1:先传参b
把ebp-14h里放的值(也就是十进制的20) 赋给eax
然后push eax 也就是把eax压栈
从栈顶放一个eax 也就是20
同时esp往上移动
A2:再传参a
和A1同理
把ebp-8这个位置的值 也就是十进制的10 也就是a了 赋给ecx
然后push exc
把10压栈
同时esp往上移动
总结2:怎么传参的?(怎么和想象中不太一样?)
A1 A2 就是在传参!
而且好像是b先传 a后传 也就是从右向左传!
和我们想象中的好像不太一样?
没错!! 继续往下看!!
A3:call-调用函数(理解函数是怎么返回的)
请记住下面这个地址
然后按
F11
下图是按F11之前esp的值:
按完F11:
还记得前面让记住一个地址了吗
所以我按完F11 也就是执行call指令
不仅跳进去Add函数了 而且把call指令的下一条指令的地址
给压栈了
压栈之后 esp肯定要继续往上移的
思考:F11跳到Add函数里 执行完怎么回来?
所以才会把call执行下一条指令的地址先压栈
待会就通过它 等函数执行完毕 可以找回来
然后继续往下执行
总结3 可以认为形参是在main函数的栈帧里创建的
其实是先push
下图的exc eax 就可以看做形参
然后push完 esp又跑到上面去了
这个时候仍然可以认为esp和ebp维护的还是main的栈帧
所以main的栈帧貌似扩大了一点(从ebp到esp)
而形参是在main的栈帧里的
可以这么理解但是也可以认为形参是独立的空间
总之明白形参本质上不是在Add()函数的栈帧里创建的就对了! 因为这个时候还没调用Add呢!!
A4:进入Add之后 类似main 先开辟栈帧 然后初始化
此时epb还在维护main的栈底呢
所以一上来先push 把main函数的ebp压栈
同时esp肯定要继续往上移
这个动作就很像:我要开始开辟全新的栈帧了!!
后续的过程不再详述 和前面
S1~S6
完全一致 就是给Add函数开辟栈帧 完成初始化的
最终如下图:
A5:给Add函数的局部变量z开辟空间
下面的步骤和main函数里一开始int a b c是十分类似的
同样都是在ebp-8的位置开始开辟
不过这个时候的ebp已经被更新过了 现在维护的是Add函数的栈帧
都是在各种的空间开辟局部变量的内存空间
把ebp-8这个空间初始化成0 也就是z了
A6:执行z = x + y
x y是什么呢?? 先回看A1和A2
结合A1 A2 和 A5的图
ebp+8和ebp+12 就是往下找 正好找到了A1 A2的exc和eax
- 找到ebp+8 也就是A2的a(也就是10) 把10给eax
- 然后再给eax add上ebp+0Ch里的东西(也就是ebp+12) 也就找到了A1的b 也就是12
- 然后再把eax的值放到ebp-8里去 其实就是把相加的结果放到了abp-8(其实就是z的位置)
最终如下图:
z已经放好了计算结果30
A7:临时保存计算结果
前面已经把eax里的计算结果放到ebp-8里了 其实就是z
然后又把ebp-8的值 也就是z的值(30)
临时放到了eax寄存器里(全局的寄存器)
A8:三次pop
注意pop出栈之后
esp由于一直要指向栈顶
也会一直往下移(其实是地址值增大了)
A9:回收Add函数的栈帧
执行完mov:
把ebp的值赋给了esp 这样Add的esp和ebp都指向了main之前的ebp
执行完pop:
也就是把栈顶的元素(main之前的ebp) 赋给了Add的ebp
每次pop完之后 esp也要下移
这样一来
Add的ebp走到一开始main的ebp的位置了
Add的esp恰好也是一开始main的esp的位置
总结来说就是 现在的esp和ebp 维护的又是之前main函数的栈帧了
A10:继续往下执行(之前存放的call指令下一条指令的地址派上用场了)
ret这个指令会:
- 先pop一下(也就是把栈顶的元素弹出
栈顶那个元素 就是call指令的下一条指令的地址
) 同时esp下移- 然后跳到刚刚pop的地址去
也就跳到call下面的执行 开始继续执行了
- 也就是说Add函数调用完毕 还能找到回去的路 继续往下执行
请看A9的图 栈顶上正放着call的下一条指令的地址呢!
A11:销毁形参
找回call 继续往下执行
把esp+8
也就是在A9的图的基础上esp往下移动两个元素 正好跳过了两个形参
相当于把形参的空间还给操作系统了
A12:把A7的计算结果真正给到c
- 请回头看A7 计算结果 临时放到了eax寄存器里(全局的寄存器)
- 然后这里把eax的结果 又给了ebp-20h 其实就是c!
总结4:形参并不是是在Add函数内部创建的
从总结3可以看出
a和b应该属于扩大之后的main函数的栈帧
而Add()函数在计算z = x + y的时候是直接去访问到下面的a和b进行计算的 所以形参并不是在Add的函数栈帧创建的
不难看出:
Add的栈帧里只创建了一个z
然后利用扩大之后的main函数的栈帧里的a和b
计算出x+y的值 赋给z
再把z临时保存在寄存器eax中
总结5:Add()函数的整个调用流程
再来看这个代码
- 开辟main栈帧
- 分配 abc的空间 完成初始化
- 在进入Add之前 已经把a和b压栈了 而且先压的是b 再压的a
- 进入Add之后 开辟add的栈帧 初始化局部变量z
- 计算x+y的时候 往下找到之前已经压栈的a b 利用他们的值 计算出x+y的值(a给x用 b给y用 ) 然后赋给z
- 然后把z里的计算结果 临时保存在全局寄存器eax之中
- 至此 就要开始销毁Add函数的栈帧了 Add函数调用结束
- 继续找到call的下一条指令继续执行
- 先销毁形参 然后把eax中的计算结果赋给c
- 再往后就是打印c 然后开始销毁main函数的栈帧 和Add的过程非常相似
总结6:形参确实是实参的临时拷贝
虽然a’ b’不是在Add内部创建的
但是确实是给了形参x y使用的
所以就可以把a’ b’看做a b的形参
显然是临时拷贝
而且a’ b’ 和 a b完全是独立的空间
所以修改a’ b’肯定不会影响a b
总结7:什么叫销毁?
所以销毁不是真的给他毁了啊! 叫做操作系统回收更好!
计算机的销毁就是:这块内存空间 可以被别人使用/覆盖了
就在这里而言:如果不再被esp和ebp维护 就可能会被分配给别人使用 就算是被销毁/回收了
最后的总结
让我们回到一开始的问题
- 局部变量是如何创建的?:
先给函数分配完栈帧空间 然后初始化这块空间(这里是都初始化成CCCCCCCC) 然后给局部变量分配空间 如果不初始化 那就是随机值
- 为什么局部变量的值不初始化就是随机值?
上面已经回答
- 函数是怎么传参的?传参的顺序?
A1 A2的两次push 其实就是在传参(在创建两个临时变量) 而且是从右往左push的
- 一直说形参是实参的临时拷贝 具体怎么实现的? 看总结6
- 调用一个函数 具体过程是什么? 看总结5
- 函数调用完 是怎么返回的?
因为一开始就记住了call执行的下一条指令 所以执行ret指令的时候 pop一下就可以找回去
而返回值其实是利用eax全局寄存器返回的