C语言高级特性
C语言的高级部分。指针板块,需要理解计算机内存模型,需要格外注意。
函数
程序帮我们补充好的main函数:
int main() {
//这是定义函数
...
}
程序的入口点就是main函数,只需将的程序代码编写到主函数中就可以运行了,不够这个函数只是由来定义,而不是自己来调用。除了主函数之外,printf也是一个函数,不过这个函数是标准库中已经实现好了的:
printf("Hello World!");
//直接通过 函数名称(参数...) 的形式调用函数
函数的具体定义:
函数是完成特定任务的独立程序代码单元。
简单来说,函数是为了完成某件任务而生的,可能要完成某个任务并不是一行代码就可以搞定的,但是现在可能会遇到这种情况:
#include <stdio.h>
int main() {
int a = 10;
printf("H"); //比如下面这三行代码就是要做的任务
printf("A");
a += 10;
if(a > 20) {
printf("H"); //这里我们还需要执行这个任务
printf("A");
a += 10;
}
switch (a) {
case 30:
printf("H"); //这里又要执行这个任务
printf("A");
a += 10;
}
}
每次要做这个任务时,都要完完整整地将任务的每一行代码都写下来,如果我们的程序中多处都需要执行某个任务,每个地方都完整地写一遍,实在是太臃肿了
这时就可以考虑使用函数了,可以将我们的程序逻辑代码全部编写到函数中,当我们执行函数时,实际上执行的就是函数中的全部内容,也就是按照我们制定的规则执行对应的任务,每次需要做这个任务时,只需要调用函数即可。
- 创建和使用函数
创建一个函数是很简单的,格式如下:
返回值类型 函数名称([函数参数...]);
其中函数名称也是有要求的,并不是所有的字符都可以用作函数名称,它的命名规则与变量的命名规则基本一致。
函数不仅仅需要完成我们的任务,可能某些函数还需要告诉我们结果:比如我们之前认识的getchar函数,这个函数实际上返回了一个int值作为结果(也就是我们输入的字符)我们同样可以将函数返回的结果赋值给变量或是参与运算等等。
当然如果我们的函数只需要完成任务,不需要告诉我们结果,返回值类型可以写成void表示为空。
#include <stdio.h>
void test(void);
//定义函数原型,因为C语言是从上往下的,所以如果要在下面的主函数中使用这个函数,要定义到它的上面。
int main() {
}
void test(void){
//函数具体定义,添加一个花括号并在其中编写程序代码,和main中编写一样
printf("我是测试函数!");
}
或是直接在上方写上函数的具体定义:
#include <stdio.h>
void test(void){
//函数具体定义,添加一个花括号并在其中编写程序代码,就和之前在main中编写一样
printf("我是测试函数!");
}
int main() {
}
那么现在我们将函数定义好之后,该如何去使用呢?
int main() {
test(); //这里只需要使用 函数名称(); 就可以调用函数了
printf("Hello World!");
//实际上printf是一个系统函数,功能是向控制台打印字符串
}
这样,就可以很好解决上面的代码复用性的问题,只需要将会重复使用的逻辑代码定义到函数中,当我们需要执行时,直接调用编写好的函数就可以了,这样是不是简单多了?
int main() {
int a = 10;
test(); //多次使用的情况下,函数会让我们的程序简单很多
if(a > 20) test();
switch (a) {
case 30:
test();
}
}
函数除了可以实现代码的复用之外,也可以优化我们的程序,让我们的代码写得更有层次感,我们的程序可能会有很多很多的功能,需要写很多的代码,我们可以将每个功能都写到一个对应的函数中,这样就可以大大减少main函数中的代码量了。
int main() {
func1(); //直接把多行代码写到一个函数中,在main中调用对应的函数,这样能够大幅度减少代码量
func2();
func3();
...
}
而我们从一开始就在编写main函数实际上是一种比较特殊的函数,C语言规定程序一律从主函数开始执行,所以这也是为什么我们一定要写成int main()的形式。
- 全局变量和局部变量
局部变量,实际上我们之前使用的都是局部变量,比如:
int main() {
int i = 10;
//变量i是main函数中的局部变量,它的作用域只能是main函数中,其他地方是无法使用的
}
局部变量只会在其作用域中生效:
可以看到在其他函数中,无法使用main函数中的变量,因为局部变量的作用域是有限的,比如位于某个函数内部的变量,那么它的作用域就是整个函数内部,而在其他位置均无法访问。又比如我们之前学习的for循环,当我们这样定义时:
可以看到,在for循环中定义的变量i,只能在for循环内部使用,而出了这个花括号之后就用不了了,当然由于作用域不同,所以下面这种写法是完全没问题的:
int main() {
for (int i = 0; i < 10; ++i) {
//虽然这里写了两个for都使用了i,但是由于处于两个不同的作用域,所以这两个没有联系
}
for (int i = 0; i < 20; ++i) {
}
}
所以,清楚了局部变量的作用域之后,我们在编写程序的时候就很清楚了
那么如果现在我们想要在任何位置都能使用一个变量,这时就要用到全局变量了:
#include <stdio.h>
void test();
int a = 10; //可以直接将变量定义放在外面,这样所有的函数都可以访问了
int main() {
a += 10;
test();
printf("%d", a);
}
void test(){
a += 10;
}
因为现在所有函数都能使用全局变量,所以这个结果不难得到。
- 函数参数和返回
我们的函数可以接受参数来完成任务,比如我们现在想要实现用一个函数计算两个数的和并输出到控制台。
这种情况我们就需要将我们需要进行加法计算的两个数,告诉函数,这样函数才能对这两个数求和,那么怎么才能告诉函数呢?我们可以通过设定参数:
#include <stdio.h>
void test(int, int); //函数原型中需要写上需要的参数类型,多个参数用逗号隔开,比如这里我们需要的就是两个int类型的参数
int main() {
}
void test(int a, int b){ //函数具体定义中也要写上,这里的a和b我们称为形式参数(形参),等价于函数中的局部变量,作用域仅限此函数
printf("%d", a + b);
}
那么现在定义完成了,使用这个函数只需要把它所需要的参数填入即可:
int main() {
test(10, 20);
//这里直接填写一个常量、变量或是运算表达式都是可以的,我们称实际传入的值为实际参数(实参)
}
可以看到,成功计算出结果:
实际上我们传入的实参在进入到函数时,会自动给函数中形参(局部变量)进行赋值,这样我们在函数中就可以得到外部传入的参数值了。
我们来看看printf函数是怎么写的:
int printf(const char * __restrict, ...) __printflike(1, 2); //看起来比较高级
这里我们主要关心它的两个参数,一个是char *由于还没有学习指针,这里就把它当做const char[]就行了,表示一个不可修改的字符串,而第二个参数我们看到是…,这三个点是个啥?
我们知道,如果我们想要填写具体需要打印的值时,可以一直往后写:
printf("%d, %d", 1, 2); //参数可以一直写
正常情况下我们函数的参数列表都是固定的,怎么才能像这样写很多个呢?这就要用到可变长参数了,不过可变长参数的使用比较麻烦,这里我们就不做讲解了。
这里给大家提一个问题,如果我们修改形式参数的值,外面的实参值会跟着发生修改吗?
#include <stdio.h>
void swap(int, int);
int main() {
int a = 10, b = 20;
swap(a, b);
printf("a = %d, b = %d", a, b); //最后会得到什么结果?
}
void swap(int a, int b){
int tmp = a; //这里对a和b的值进行交换
a = b;
b = tmp;
}
通过结果发现,虽然调用了函数对a和b的值进行交换,但貌似并没有什么卵用。这是为什么呢?
还记得我们前面说的吗,函数的形参实际上就是函数内的局部变量,它的作用域仅仅是这个函数,而我们外面传入的实参,仅仅知识将值赋值给了函数内的形参而已,并且外部的变量跟函数内部的变量作用域都不同,所以半毛钱关系都没有,这里交换的仅仅是函数内部的两个形参变量值,跟外部作实参的变量没有任何关系。
那么,怎么样才能实现通过函数交换两个变量的值呢?这个问题我们会在指针部分进行讨论。
不过数组却不受限制,我们在函数中修改数组的值,是直接可以生效的:
#include <stdio.h>
void test(int arr[]);
int main() {
int arr[] = {4, 3, 8, 2, 1, 7, 5, 6, 9, 0};
test(arr);
printf("%d", arr[0]); //打印的是修改后的值了
}
void test(int arr[]) {
arr[0] = 999; //数组就可以做到这边修改,外面生效
}
我们再来看一个例子:
#include <stdio.h>
void test(int a){
a += 10;
printf("%d\n", a);
}
int main() {
int a = 10;
test(a);
test(a); //连续两次调用,那么这两次的结果会是什么?
}
可以看到结果都是20,(如果猜对了可以直接跳过,如果你猜测的是20和30的话,需要听我解释了)注意每次调用函数都是单独进行的,并不是复用函数中的形参,不要认为第一次调用函数test就将函数的局部变量变成20了,再次调用就是20+10变成30。实际上这两次调用都是单独进行的,形参a都是在一开始的时候被赋值为实参的值的,这两次调用没有任何关系,并且函数执行完毕后就自动销毁了。
那要是我就希望每次调用函数时保留变量的值呢?我们可以使用静态变量:
#include <stdio.h>
void test();
int main() {
test();
test();
}
void test() {
static int a = 20; //静态变量并不会在函数结束时销毁其值,而是保持
a += 20;
printf("%d ", a);
}
我们接着来看函数的返回值,并不是所有的函数都是执行完毕就结束了的,可能某些时候我们需要函数告诉我们执行的结果如何,这时我们就需要用到返回值了,比如现在我们希望实现一个函数计算a+b的值:
#include <stdio.h>
int sum(int ,int ); //现在我们要返回a和b的和(那么肯定也是int类型)所以这里需要将返回值类型修改为int
int main() {
int a = 10, b = 20; //计算a和b的和
int result = sum(a, b); //函数执行后,会返回一个int类型的结果,我们可以接收它,也可以像下面一样直接打印,当然也可以参与运算等等。
printf("a+b=%d", sum(a, b));
}
int sum(int a, int b) {
return a + b; //通过return关键字来返回计算的结果
}
我们接着来看下一个例子,现在我们希望你通过函数找到数组中第一个小于0的数字并将其返回,如果没有找到任何小于0的数,就返回0即可:
#include <stdio.h>
int findMin(int arr[], int len); //需要两个参数,一个是数组本身,还有一个是数组的长度
int main() {
int arr[] = {1, 4, -9, 2, -4, 7};
int min = findMin(arr, 6);
printf("第一个小于0的数是:%d", min);
}
int findMin(int arr[], int len) {
for (int i = 0; i < len; ++i) {
if(arr[i] < 0) return arr[i]; //当判断找到后,直接return返回即可,这样的话函数会直接返回结果,无论后面还有没有代码没有执行完,整个函数都会直接结束。
}
return 0; //如果没有找到就返回0
}
这里我们使用了return关键字来返回结果,注意当我们的程序走到return时,无论还有什么内容没执行完,整个函数都将结束,并返回结果。注意带返回值(非void)的函数中的所有情况都需要有一个对应的返回值:
int test(int a) {
if (a > 0) {
return 10; //当a大于0时有返回语句
} else{
//但是当a不大于0时就没有返回值了,这样虽然可以编译通过,但是会有警告(黄标),运行后可能会出现一些无法预知的问题
}
}
如果是没有返回值的函数,我们也可以调用return来返回,不过默认情况下是可以省略的:
void test(int a){
if(a == 10) return; //因为是void,所以什么都不需要加,直接return
printf("%d", a);
}
- 递归调用
我们的函数除了在其他地方被调用之外,也可以自己调用自己(好家伙,套娃是吧),这种玩法我们称为递归。
#include <stdio.h>
void test(){
printf("Hello World!\n");
test(); //函数自己在调用自己,这样的话下一轮又会进入到这个函数中
}
int main() {
test();
}
我们可以尝试运行一下上面的程序,会发现程序直接无限打印Hello World!这个字符串,这是因为函数自己在调用自己,不断地重复进入到这个函数,理论情况下,它将永远都不会结束,而是无限地执行这个函数的内容。
但是到最后我们的程序还是终止了,这是因为函数调用有最大的深度限制,因为计算机不可能放任函数无限地进行下去。
函数的调用过程,实际上在程序运行时会有一个叫做函数调用栈的东西,它用于控制函数的调用:
#include <stdio.h> //我们以下面的调用关系为例
void test2(){
printf("giao");
}
void test(){
test2(); //main -> test -> test2
printf("giao");
}
int main() {
test();
printf("giao");
}
其实我们可以很轻易地看出整个调用关系,首先是从main函数进入,然后调用test函数,在test函数中又调用了test2函数,此时我们就需要等待test2函数执行完毕,test才能继续,而main则需要等待test执行完毕才能继续。而实际上这个过程是由函数调用栈在控制的:
而当test2函数执行完毕后,每个栈帧又依次从栈中出去:
当所有的栈全部出去之后,程序结束。
所以这也就不难解释为什么无限递归会导致程序出现错误,因为栈的空间有限,而函数又一直在进行自我调用,所以会导致不断地有新的栈帧进入,最后塞满整个栈的空间,就爆炸了,这种问题我们称为栈溢出(Stack Overflow)
当然,如果我们好好地按照规范使用递归操作,是非常方便的,比如我们现在需要求某个数的阶乘:
#include <stdio.h>
int test(int n);
int main() {
printf("%d", test(3));
}
int test(int n){
if(n == 1) return 1; //因为不能无限制递归下去,所以我们这里添加一个结束条件,在n = 1时返回
return test(n - 1) * n; //每次都让n乘以其下一级的计算结果,下一级就是n-1了
}
通过给递归调用适当地添加结束条件,这样就不会无限循环了,并且我们的程序看起来无比简洁,那么它是如何执行的呢:
它看起来就像是一个先走到底部,然后拿到问题的钥匙后逐步返回的一个过程,并在返回的途中不断进行计算最后得到结果(妙啊)
所以,合理地使用递归反而是一件很有意思的事情。
- 实战:斐波那契数列解法其三
前面我们介绍了函数的递归调用,我们来看一个具体的实例吧,我们还是以解斐波那契数列为例。
既然每个数都是前两个数之和,那么我们是否也可以通过递归的形式不断划分进行计算呢?我们依然可以借鉴之前动态规划的思想,通过划分子问题,分而治之来完成计算。
- 实战:汉诺塔
什么是汉诺塔?
汉诺塔(Tower of Hanoi),又称河内塔,是一个源于印度古老传说的益智玩具。大梵天创造世界的时候做了三根金刚石柱子,在一根柱子上从下往上按照大小顺序摞着64片黄金圆盘。大梵天命令婆罗门把圆盘从下面开始
按大小顺序重新摆放在另一根柱子上。并且规定,在小圆盘上不能放大圆盘,在三根柱子之间一次只能移动一个圆盘。
这三根柱子我们就依次命名为A、B、C,现在请你设计一个C语言程序,计算N阶(n片圆盘)汉诺塔移动操作的每一步。
这个问题看似很难,实际上我们也可以对每一步进行推理:
当汉诺塔只有1阶的情况下:直接把A上的圆盘移动到C,搞定。
当汉诺塔只有2阶的情况下:我们的最终目标还是需要将A柱最下面的圆盘丢到C,不过现在多了圆盘,我们得先把这个圆盘给处理了,所以我们得把这上面的1个圆盘丢到B上去,这样才能把A最下面的圆盘丢给C。然后再把B上面的1个圆盘丢到C上去
当汉诺塔只有3阶的情况下:我们的最终目标还是需要将A柱最下面的圆盘丢到C,不过现在多了圆盘,我们得先把这个圆盘给处理了,所以我们得把这上面的2个圆盘丢到B上去,这样才能把A最下面的圆盘丢给C。然后再把B上面的2个圆盘丢到C上
实际上我们发现,把A移动到C是一定要进行的,而在进行之前需要先把压在上面全部的圆盘全部放到B去。而移动之后也要把B上的圆盘全部移动到C上去。其实所有的情况下最终都会有一个n=1的情况,将A上的最后一个圆盘移动到C,只是多了一个前面的步骤和后面的步骤。
不过难点就是,怎么把A上的n-1个圆盘移动到B去呢?其实这时我们可以依靠C作为中间商,来帮助我们移动(比如n = 3,那么先把最上面的移动到C,然后把第二大的移动到B,再从C上把最小的移动到B上,这样就借助了C完成了两个圆盘的转移),而最后又怎么把B上的圆盘全部移到C去呢,这时就可以依靠A作为中间商,方法同理;实际上大问题最后都会变成n = 2时这样的小问题,只不过是要移动目标不同罢了。
只要想通了怎么去借助中间商进行移动,就很好写出程序了。
递归函数如下设计:
//a存放圆盘的初始柱子,b作为中间柱子存放使用,c作为目标柱子,n表示要从a移动到c的圆盘数
void hanoi(char a, char b, char c, int n){
}
现在我们来实现一下吧。
#include <stdio.h>
//记录第几个盘子的移动顺序
void move(char a,char b,int n){
printf("第%d个圆盘,从%c移动到%c\n",n,a,b);
}
//从a到c,以b中介
void hanoi(char a,char b,char c,int n){
if(n==1){
// 如果只有一个,直接a到c
move(a,c,n);
} else{
// 如果不是一个,n-1个移动到b
hanoi(a,c,b,n-1);
move(a,c,n);
// 剩下的n-1个从b移动到c
hanoi(b,a,c,n-1);
}
}
int main() {
hanoi('a','b','c',3);
}
简化一波:
void hanoi(char a, char b, char c, int n){
if(n == 0) return;
hanoi(a, c ,b, n - 1);
printf("第%d个圆盘:%c --> %c\n", n, a, c);
hanoi(b, a, c, n - 1);
}
看似如此复杂的问题,其实只需要4行就可以解决了。
- 实战:快速排序算法
有一个数组:
int arr[] = {4, 3, 8, 2, 1, 7, 5, 6, 9, 0};
现在请你设计一个C语言程序,对数组按照从小到大的顺序进行排序。这里我们使用冒泡排序的进阶版本——快速排序来完成,它的核心思想是分而治之,每一轮排序都会选出一个基准,一轮排序完成后,所以比基准小的数一定在左边,比基准大的数一定在右边,在分别通过同样的方法对左右两边的数组进行排序,不断划分,最后完成整个数组的排序。它的效率相比冒泡排序的双重for循环有所提升。
#include <stdio.h>
void quickSort(int arr[], int left, int right){ //arr是数组,left是起始下标,right是结束下标
//请实现这一部分
}
int main() {
int arr[] = {4, 3, 8, 2, 1, 7, 5, 6, 9, 0};
quickSort(arr, 0, 9); //10个数字下标就是0-9
for (int i = 0; i < 10; ++i) {
printf("%d ", arr[i]);
}
}
不过虽然这种排序算法很快,但是极端情况下(比如遇到了刚好倒序的数组)还是会退化成冒泡排序的。
指针
指针可以说是整个C语言中最难以理解的部分了,不过其实说简单也简单,你会发现也并没有想象中的那么难,你与它的距离可能只差了那么一些基础知识,这一部分都会及时进行补充的。
- 什么是指针
下面这个函数实现交换两个数字的值。
#include <stdio.h>
void swap(int, int);
int main() {
int a = 10, b = 20;
swap(a, b);
printf("a = %d, b = %d", a, b); //最后会得到什么结果?
}
void swap(int a, int b){
int tmp = a; //这里对a和b的值进行交换
a = b;
b = tmp;
}
实际上这种写法并非是真正的a和b,而是函数中的局部变量。
但是指针可以直接对函数外部的变量进行操作
程序中使用的变量实际上都在内存中创建的,被保存在内存的某一个位置上,就像我们最终会在这个世界上的某个角落安家一样,所有的变量在对应的内存位置上都有一个地址(地址是独一无二的),而我们可以通过这个地址寻找到这个变量本体,比如int占据4字节,因此int类型变量的地址就是这4个字节的起始地址,后面32个bit位全部都是用于存放此变量的值的。
这里的0x是十六进制的表示形式(10-15用字母A - F表示)如果我们能够知道变量的内存地址,那么无论身在何处,都可以通过地址找到这个变量了。而指针的作用,就是专门用来保存这个内存地址的。
创建一个指针变量用于保存变量的内存地址:
#include <stdio.h>
int main(){
int a = 10;
//指针类型需要与变量的类型相同,且后面需要添加一个*符号(注意这里不是乘法运算)表示是对于类型的指针
int * p = &a; //这里的&并不是进行按位与运算,而是取地址操作,也就是拿到变量a的地址
printf("a在内存中的地址为:%p", p); //地址使用%p表示
}
可以看到,我们通过取地址操作&,将变量a的地址保存到了一个地址变量p中。
拿到指针之后,我们可以很轻松地获取指针所指地址上的值:
#include <stdio.h>
int main(){
int a = 666;
int * p = &a;
//在指针变量前添加一个*号来获取对应地址存储的值
printf("内存%p上存储的值为:%d", p, *p);
}
注意这里访问指针所指向地址的值时,是根据类型来获取的,比如int类型占据4个字节,那么就读取地址后面4个字节的内容作为一个int值,如果指针是char类型的,那么就只读取地址后面1个字节作为char类型的值。
同样的,也可以直接像这样去修改对应地址存放的值:
#include <stdio.h>
int main(){
int a = 666;
int * p = &a;
*p = 999; //通过*来访问对应地址的值,并通过赋值运算对其进行修改
printf("a的值为:%d", a);
}
实际上拿到一个变量的地址之后,完全不需要再使用这个变量,而是可以通过它的指针来对其进行各种修改。因此,现在我们想要实现对两个变量的值进行交换的函数就很简单了:
#include <stdio.h>
// 这里是两个指针类型的形参,其值为实参传入的地址,
// 虽然依然是值传递,但是这里传递的可是地址啊,
// 只要知道地址改变量还不是轻轻松松?
void swap(int * a, int * b){
int tmp = *a; //先暂存一下变量a地址上的值
*a = *b; //将变量b地址上的值赋值给变量a对应的位置
*b = tmp; //最后将a的值赋值给b对应位置,OK,这样就成功交换两个变量的值了
}
int main(){
int a = 10, b = 20;
swap(&a, &b); //只需要把a和b的内存地址给过去就行了,这里取一下地址
printf("a = %d, b = %d", a, b);
}
通过地址操作,就轻松实现了使用函数交换两个变量的值了。
了解了指针的相关操作之后,scanf函数就很好理解了:
#include <stdio.h>
int main(){
int a;
scanf("%d", &a); //这里就是取地址,我们需要告诉scanf函数变量的地址,这样它才能通过指针访问变量的内存地址,对我们变量的值进行修改,这也是为什么scanf里面的变量(除数组外)前面都要进行一个取地址操作
printf("%d", a);
}
当然,和变量一样,要是咱们不给指针变量赋初始值的话,就不知道指的哪里了,因为指针变量也是变量,存放的其他变量的地址值也在内存中保存,如果不给初始值,那么存放别人地址的这块内存可能在其他地方使用过,这样就不知道初始值是多少了(那么指向的地址可能是一个很危险的地址,随意使用可能导致会出现严重错误),所以一定要记得给个初始值或是将其设定为NULL,表示空指针,不指向任何内容。
#include <stdio.h>
int main(){
int * a = NULL;
}
我们接着来看看const类型的指针,这种指针比较特殊:
#include <stdio.h>
int main(){
int a = 9, b = 10;
const int * p = &a;
*p = 20; //这里直接报错,因为被const标记的指针,所指地址上的值不允许发生修改
p = &b; //但是指针指向的地址是可以发生改变的
}
我们再来看另一种情况:
#include <stdio.h>
int main(){
int a = 9, b = 10;
int * const p = &a; //const关键字被放在了类型后面
*p = 20; //允许修改所指地址上的值
p = &b; //但是不允许修改指针存储的地址值,其实就是反过来了。
}
当然也可以双管齐下:
#include <stdio.h>
int main(){
int a = 9, b = 10;
const int * const p = &a;
*p = 20; //两个都直接报错,都不让改了
p = &b;
}
- 指针与数组
先说结论,数组表示法实际上是在变相地使用指针,甚至可以将其理解为数组变量其实就是一个指针变量,它存放的就是数组中第一个元素的起始地址。
为什么这么说?
#include <stdio.h>
int main(){
char str[] = "Hello World!";
char * p = str; //???啥情况,为什么能直接把数组作为地址赋值给指针变量p???
printf("%c", *p); //还能正常使用,打印出第一个字符???
}
还能这样玩:
int main(){
char str[] = "Hello World!";
char * p = str;
printf("%c", p[1]); //???怎么像在使用数组一样用指针???
}
太迷了吧,怎么数组和指针还能这样混着用呢?来看看数组在内存中是如何存放的:
数组在内存中是一块连续的空间,所以为什么声明数组一定要明确类型和大小,因为这一块连续的内存空间生成后就固定了。
数组变量实际上存放的就是首元素的地址,而实际上我们之前一直使用的都是数组表示法来操作数组,这样可以很方便地让我们对内存中的各个元素值进行操作:
int main(){
char str[] = "Hello World!";
printf("%c", str[0]); //直接在中括号中输入对应的下标就能访问对应位置上的数组了
}
实际上str表示的就是数组的首地址,所以完全可以将其赋值给一个指针变量,因为指针变量也是存放的地址:
char str[] = "Hello World!";
char * p = str; //直接把str代表的首元素地址给到p
而使用指针后,实际上我们可以使用另一种表示法来操作数组,这种表示法叫做指针表示法:
int main(){
char str[] = "Hello World!";
char * p = str;
printf("第一个元素值为:%c,第二个元素值为:%c", *p, *(p+1)); //通过指针也可以表示对应位置上的值
}
比如我们现在需要表示数组中的第二个元素:
- 数组表示法:str[1]
- 指针表示法:*(p+1)
虽然写法不同,但是他们表示的意义是完全相同的,都代表了数组中的第二个元素,其中指针表示法使用了p+1的形式表示第二个元素,这里的+1操作并不是让地址+1,而是让地址+ 一倍的对应类型大小,也就是说地址后移一个char的长度,所以正好指向了第二个元素,然后通过*取到对应的值(注意这种操作仅对数组是有意义的,如果是普通的变量,虽然也可以通过这种方式获得后一个char的长度的数据,但是毫无意义)
*(p+i) <=> str[i] //实际上就是可以相互转换的
这两种表示法都可以对内存中存放的数组内容进行操作,只是写法不同罢了,所以你会看到数组和指针混用也就不奇怪了。了解了这些东西之后,我们来看看下面的各个表达式分别代表什么:
*p //数组的第一个元素
p //数组的第一个元素的地址
p == str //肯定是真,因为都是数组首元素地址
*str //因为str就是首元素的地址,所以这里对地址加*就代表第一个元素,使用的是指针表示法
&str[0] //这里得到的实际上还是首元素的地址
*(p + 1) //代表第二个元素
p + 1 //第二个元素的内存地址
*p + 1 //注意*的优先级比+要高,所以这里代表的是首元素的值+1,得到字符'K'
所以不难理解,为什么printf函数的参数是一个const char * 了,实际上就是需要我们传入一个字符串而已,只不过这里采用的是指针表示法而已。
当然指针也可以进行自增和自减操作,比如:
#include <stdio.h>
int main(){
char str[] = "Hello World!";
char * p = str;
p++; //自增后相当于指针指向了第二个元素的地址
printf("%c", *p); //所以这里打印的就是第二个元素的值了
}
一维数组看完了,我们来看看二维数组,那么二维数组在内存中是如何表示的呢?
int arr[2][3] = {{1, 2, 3}, {4, 5, 6}};
这是一个2x3的二维数组,其中存放了两个能够容纳三个元素的数组,在内存中,是这样的:
所以虽然我们可以使用二维数组的语法来访问这些元素,但其实我们也可以使用指针来进行访问:
#include <stdio.h>
int main(){
int arr[][3] = {{1, 2, 3}, {4, 5, 6}};
int * p = arr[0]; //因为是二维数组,注意这里要指向第一个元素,来降一个维度才能正确给到指针
//同理如果这里是arr[1]的话那么就表示指向二维数组中第二个数组的首元素
printf("%d = %d", *(p + 4), arr[1][1]); //实际上这两种访问形式都是一样的
}
多级指针
我们知道,实际上指针本身也是一个变量,它存放的是目标的地址,但是它本身作为一个变量,它也要将地址信息保存到内存中,所以,实际上当我们有指针之后:
实际上,我们我们还可以继续创建一个指向指针变量地址的指针,甚至可以创建更多级(比如指向指针的指针的指针)比如现在我们要创建一个指向指针的指针:
落实到咱们的代码中:
#include <stdio.h>
int main(){
int a = 20;
int * p = &a; //指向普通变量的指针
//因为现在要指向一个int *类型的变量,所以类型为int* 再加一个*
int ** pp = &p; //指向指针的指针(二级指针)
int *** ppp = &pp; //指向指针的指针的指针(三级指针)
}
那么我们如何访问对应地址上的值呢?
#include <stdio.h>
int main(){
int a = 20;
int * p = &a;
int ** pp = &p;
printf("p = %p, a = %d", *pp, **pp); //使用一次*表示二级指针指向的指针变量,继续使用一次*会继续解析成指针变量所指的普通变量
}
本质其实就是一个套娃而已,只要把各个层次分清楚,实际上还是很好理解的。
**特别提醒:**一级指针可以操作一维数组,那么二级指针是否可以操作二维数组呢?不能!因为二级指针的含义都不一样了,它是表示指针的指针,而不是表示某个元素的指针了。下面我们会认识数组指针,准确的说它才更贴近于二维数组的形式。
指针数组与数组指针
前面我们了解了指针的一些基本操作,包括它与数组的一些关系。我们接着来看指针数组和数组指针,这两词语看着就容易搞混,不过哪个词在后面就哪个,我们先来看指针数组,虽然名字很像数组指针,但是它本质上是一个数组,不过这个数组是用于存放指针的数组。
#include <stdio.h>
int main(){
int a, b, c;
int * arr[3] = {&a, &b, &c}; //可以看到,实际上本质还是数组,只不过存的都是地址
}
因为这个数组中全都是指针,比如现在我们想要访问数组中第一个指针指向的地址:
#include <stdio.h>
int main(){
int a, b, c;
int * arr[3] = {&a, &b, &c};
*arr[0] = 999; //[]运算符的优先级更高,所以这里先通过[0]取出地址,然后再使用*将值赋值到对应的地址上
printf("%d", a);
}
当然我们也可以用二级指针变量来得到指针数组的首元素地址:
#include <stdio.h>
int main(){
int * p[3]; //因为数组内全是指针
int ** pp = p; //所以可以直接使用指向指针的指针来指向数组中的第一个指针元素
}
实际上指针数组还是很好理解的,那么数组指针呢?可以看到指针在后,说明本质是一个指针,不过这个指针比较特殊,它是一个指向数组的指针(注意它的目标是整个数组,和我们之前认识的指针不同,之前认识的指针是指向某种类型变量的指针)
比如:
int * p; //指向int类型的指针
而数组指针则表示指向整个数组:
int (*p)[3]; //注意这里需要将*p括起来,因为[]的优先级更高
注意它的目标是整个数组,而不是普通的指针那样指向的是数组的首个元素:
int arr[3] = {111, 222, 333};
int (*p)[3] = &arr; //直接对整个数组再取一次地址(因为数组指针代表的是整个数组的地址,虽然和普通指针都是指向首元素地址,但是意义不同)
那么现在已经取到了指向整个数组的指针,该怎么去使用呢?
#include <stdio.h>
int main(){
int arr[3] = {111, 222, 333};
int (*p)[3] = &arr; //直接对整个数组再取一次地址
printf("%d, %d, %d", *(*p+0), *(*p+1), *(*p+2)); //要获取数组中的每个元素,稍微有点麻烦
}
注意此时:
- p代表整个数组的地址
- *p表示所指向数组中首元素的地址
- p+i表示所指向数组中第i个(0开始)元素的地址(实际上这里的p就是指向首元素的指针)
- *(*p + i)就是取对应地址上的值了
虽然在处理一维数组上感觉有点麻烦,但是它同样也可以处理二维数组:
int arr[][3] = {{111, 222, 333}, {444, 555, 666}};
int (*p)[3] = arr; //二维数组不需要再取地址了,因为现在维度提升,数组指针指向的是二维数组中的其中一个元素(因为元素本身就是一个数组)
比如现在我们想要访问第一个数组的第二个元素,根据上面p各种情况下的意义:
printf("%d", *(*p+1)); //因为上面直接指向的就是第一个数组,所以想要获取第一个元素和之前是一模一样的
那么要是我们现在想要获取第二个数组中的最后一个元素呢?
printf("%d", *(*(p+1)+2); //首先*(p+1)为一个整体,表示第二个数组(因为是数组指针,所以这里+1一次性跳一个数组的长度),然后再到外层+2表示数组中的第三个元素,最后再取地址,就是第二个数组的第三个元素了
当然也可以使用数组表示法:
printf("%d", p[1][2]); //好家伙,这不就是二维数组的用法吗,没错,看似很难,你甚至可以认为这两用着是同一个东西
指针函数与函数指针
我们的函数可以返回一个指针类型的结果,这种函数我们就称为指针函数。
#include <stdio.h>
int * test(int * a){ //函数的返回值类型是int *指针类型的
return a;
}
int main(){
int a = 10;
int * p = test(&a); //使用指针去接受函数的返回值
printf("%d", *p);
printf("%d", *test(&a)); //当然也可以直接把间接运算符在函数调用前面表示直接对返回的地址取地址上的值
}
不过要注意指针函数不要尝试去返回一个局部变量的地址:
#include <stdio.h>
int * test(int a){
int i = a;
return &i; //返回局部变量i的地址
}
int main(){
int * p = test(20); //连续调用两次test函数
test(30);
printf("%d", *p); //最后结果可能并不是我们想的那样
}
为什么会这样呢?还记得我们前面说的吗?函数一旦返回,那么其中的局部变量就会全部销毁了,至于这段内存之后又会被怎么去使用,我们也就不得而知了。
局部变量其实是存放在栈帧中的,如果前面的选学部分听了之后,你就知道为什么这里得到的是第二次的30了,因为第二次调用的栈帧入栈后就覆盖了这段内存,又因为是同一个函数所以栈帧结构是一样的,最后在同样的位置就存放了新的30这个值。
我们接着来看函数指针,实际上指针除了指向一个变量之外,也可以指向一个函数,当然函数指针本身还是一个指针,所以依然是用变量表示,但是它代表的是一个函数的地址(编译时系统会为函数代码分配一段存储空间,这段存储空间的首地址称为这个函数的地址)
我们来看看如何定义:
#include <stdio.h>
int sum(int a, int b) {
return a + b;
}
int main(){
//类型 (*指针变量名称)(函数参数...) //注意一定要把*和指针变量名称括起来,不然优先级不够
int (*p)(int, int) = sum;
printf("%p", p);
}
这样我们就拿到了函数的地址,既然拿到函数的地址,那么我们就可以通过函数的指针调用这个函数了:
#include <stdio.h>
int sum(int a, int b) {
return a + b;
}
int main(){
int (*p)(int, int) = sum;
int result = (*p)(1, 2); //就像我们正常使用函数那样,(*p)表示这个函数,后面依然是在小括号里面填上实参
int result = p(1, 2); //当然也可以直接写函数指针变量名称,效果一样(咋感觉就是给函数换了个名呢)
printf("%d", result);
}
有了函数指针,我们就可以编写函数回调了(所谓回调就让别人去调用我们提供的函数,而不是我们主动来调别人的函数),比如现在我们定义了一个函数,不过这个函数需要参数通过一个处理的逻辑才能正常运行:
int sum(int (*p)(int, int), int a, int b){ //将函数指针作为参数传入
//函数回调
return p(a, b); //就像你进了公司然后花钱请别人帮你写代码,工资咱们五五开,属于是直接让别人帮你实现
}
于是我们就还要给他一个其他函数的地址:
#include <stdio.h>
int sum(int (*p)(int, int), int a, int b){
return p(a, b);
}
int sumImpl(int a, int b){ //这个函数实现了a + b
return a + b;
}
int main(){
int (*p)(int, int) = sumImpl; //拿到实现那个函数的地址
printf("%d", sum(p, 10, 20));
}
当然,函数指针也可以保存一组函数的地址,成为函数指针数组,但是这里就不多说了,相信各位已经快顶不住了吧。
实战:合并两个有序数组
来源:力扣 No.88 合并两个有序数组:https://leetcode.cn/problems/merge-sorted-array/
给你两个按 非递减顺序 排列的整数数组 nums1 和 nums2,另有两个整数 m 和 n ,分别表示 nums1 和 nums2 中的元素数目。
请你 合并 nums2 到 nums1 中,使合并后的数组同样按 非递减顺序 排列。
注意:最终,合并后数组不应由函数返回,而是存储在数组 nums1 中。为了应对这种情况,nums1 的初始长度为 m + n,其中前 m 个元素表示应合并的元素,后 n 个元素为 0 ,应忽略。nums2 的长度为 n 。
示例 1:
输入:nums1 = [1,2,3,0,0,0], m = 3, nums2 = [2,5,6], n = 3
输出:[1,2,2,3,5,6]
解释:需要合并 [1,2,3] 和 [2,5,6] 。
合并结果是 [1,2,2,3,5,6] ,其中斜体加粗标注的为 nums1 中的元素。
示例 2:
输入:nums1 = [1], m = 1, nums2 = [], n = 0
输出:[1]
解释:需要合并 [1] 和 [] 。
合并结果是 [1] 。
示例 3:
输入:nums1 = [0], m = 0, nums2 = [1], n = 1
输出:[1]
解释:需要合并的数组是 [] 和 [1] 。
合并结果是 [1] 。
注意,因为 m = 0 ,所以 nums1 中没有元素。nums1 中仅存的 0 仅仅是为了确保合并结果可以顺利存放到 nums1 中。
现在请你设计一个C语言程序,实现下面的函数(要求全程使用指针,不允许出现数组用法):
void merge(int* nums1, int nums1Size, int m, int* nums2, int nums2Size, int n){
}
实战:二维数组中的查找
来源:剑指Offer 04. 二维数组中的查找:https://leetcode.cn/problems/er-wei-shu-zu-zhong-de-cha-zhao-lcof/
在一个 n * m 的二维数组中,每一行都按照从左到右递增的顺序排序,每一列都按照从上到下递增的顺序排序。请完成一个高效的函数,输入这样的一个二维数组和一个整数,判断数组中是否含有该整数。
示例:
现有矩阵 matrix 如下:
[
[1, 4, 7, 11, 15],
[2, 5, 8, 12, 19],
[3, 6, 9, 16, 22],
[10, 13, 14, 17, 24],
[18, 21, 23, 26, 30]
]
给定 target = 5,返回 true。
给定 target = 20,返回 false。
现在请你设计一个C语言程序,实现下面的函数(要求全程使用指针,不允许出现数组用法):
/*
* 输入 **matrix 是长度为 matrixSize 的数组指针的数组,其中每个元素(也是一个数组)
* 的长度组成 *matrixColSize 数组作为另一输入,*matrixColSize 数组的长度也为 matrixSize
*/
bool findNumberIn2DArray(int** matrix, int matrixSize, int* matrixColSize, int target){
}