第六讲:期末复习(下)¶
指针 ¶
形象理解 ¶
指针的声明 ¶
int *p, q;
上面的代码中,*
用于修饰 p 变量,使其成为 int 指针类型 的变量;但是变量 q 并没有被*
修饰,所以其还是 int 类型 的变量。
指针的赋值 ¶
int a = 10;
int *p, *q = &a;
p = &a;
*p = 20;
- 上面的代码中,
int *q = &a
是在定义 int 指针变量时立刻赋值; p = &a
是在定义 int 指针变量后再将 a 的地址赋值给 p,此时我们不能在 p 前加 * ;*p = 20
中的 * 是对指针 p 进行 取内容 操作,该指令是将数字 20 赋值给 p 中地址所代表的内容。因此在该代码执行之后,a 的值就变成了 20。
指针的运算 ¶
指针可以进行加减运算,得到的结果可以表示地址的差。 但注意 ,指针减法中涉及到的常数不是真正的地址之差,而是 地址差 / 该类型字节数 ,如:
Example1
int a[10];
int *p = a, *q = &a[1];
printf("%d", q - p);
Example2
int a[10] = {1, 2, 3, 4};
int *p = a;
printf("%d %d", *p, *(p + 1));
1 2
,所以可见,p + 1 指向的是 a 数组中的 a[1] 位置,而不是 a[0] 的下一个字节处。
例 1.4.1
int a[] = {1, 2, 3, 4, 5};
int *p = a, *q = &a[2];
printf("%lu\n", q - p);
Answer: 2
多级指针 ¶
int a = 10;
int *p = &a;
int **var = &p;
printf("%d = %d = %d", a, *p, **var);
注意
在程序中,最好不要混用不同级的指针。
数组和指针 ¶
数组名实际上就是一个指针,数组表示 a[1]
和指针表示 &(a + 1)
实际上指的是同一个东西,两者也可以相互转化。
此外,二维数组的地址分布和我们的直观感受并不相同,其有下面的规律:
二维数组
假设有:int a[3][2];
-
首先需要知道,二维数组中的地址是线性分布的。也就是说,
a[0][1]
的下一个就是a[1][0]
-
其次,二维数组是如何实现二维存储数据的呢?原因在于:a 是一个一维数组的指针。在上面的例子中,a 是一个长度为 2 的 int 数组的指针,所以
a[0]
指向第一个一维数组,a[1]
指向第二个一维数组。 -
实际上,我们可以使用
*(*(a + i) + j)
来表示a[i][j]
。因为*(a + i)
指向第 i 个数组(假设叫 b) ,而我们可以用*(b + j)
来表示b[j]
。 -
另外,二维数组不能被隐式转换为二级指针 。我们不能肤浅的将二维数组理解为二级指针。实际上,无论我们定义多少维数组,其都是一级指针,只是指向的元素不同而已。
另外注意,int *p[3]
相当于定义了一个指针数组,其存储的数据都是 int 类型指针。
值得一提的是,字符串相当于一个以 '\0' 结尾的 char 类型数组。
字符串相关函数
头文件:<string.h>
strlen: 返回字符串长度(不包括末尾的 '\0')
strcpy: 将 str2 直接拷贝到 str1 中(拷贝内容会包括 str2 末尾的 '\0')
strcat: 将 str2 追加到 str1 的末尾(从 str1 的 '\0' 处开始追加)
strcmp: 对两个字符串做差,返回差值(用于比较两个字符串)
strncpy
strncat
strncmp
例 1.6.2
char str1[20] = "1234567890123";
char str2[20] = "hello";
strcpy(str1, str2);
int l = strlen(str1);
printf("%d %s\n", l, str1);
for(int i = 0; i < 10; ++ i)
{
printf("%c", str1[i]);
}
Answer:
5 hello
hello7890
例 1.6.3
int a[3][3] = {1, 2, 3, 4, 5, 6, 7, 8, 9);
printf("%d\n", a[-1][5]);
Answer: 3
例 1.6.4
给出:
char s[2][3] = {"ab", "cd"}, *p = (char *)s;
下面哪一项是正确且和 s[1][1]
的值相等?
A. *(s + 3)
B. *s + 2
C. p[1][1]
D. *++p + 2
Answer: D
例 1.6.5
char *week[]={"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}, **pw=week;
char c1, c2;
c1 = (*++pw)[1];
c2 = *++pw[1];
printf("%c %c\n", c1, c2);
Answer:
u e
空指针和野指针 ¶
在程序中,我们可以使用 0
或 NULL
来表示一个空指针,当一个指针是空指针时,我们无法使用 *
来对该指针进行读取,会出现段错误。
因此,空指针在初始化和异常情况处理时非常重要。
用来处理什么异常情况呢?
野指针
当我们写
int *p;
*p = 10;
类似上面的例子,当我们写 int *p
时,我们并没有给这个指针 p 进行地址的赋值。这也就说明,我们并没有给 p 一个真正的空间,而是让这个指针乱指(显然,这是 100% 不安全的
例 1.7.1
给出 char *s, str[10]
,下列选项中表达完全正确的是:
A. strcpy(s, "hello");
B. str = "hello" + 1;
C. s = *&(str + 1);
D. s = str + 1
Answer: D
函数指针 * ¶
很少涉及,具体内容可以查看辅学网站。
形参和实参 ¶
实参
实际参数。其可以是常量、变量、表达式等等,在函数调用时,实参将具体的值传递给函数。
int a = 10;//这是实参
int b = 20;//这也是实参
形参
形式参数。其不是真实存在的变量,而是用来接收调用函数时传入的参数而设置的变量。
int max(int a, int b)//这里的 a, b 都是形参
{
return (a > b ? a : b);
}
需要注意的是,当我们使用实参将数据传到函数内部之后,我们在函数内使用的变量就不是原来的变量了,而是为了在函数内保存这些数据而自动设置的形参。所以,当我们在函数内对形参进行运算时,并不会对原来的实参产生任何影响 。 让我们来看下面的例子:
swap 函数
void swap(int a, int b)
{
int t = a;
a = b;
b = t;
return ;
}
int main()
{
int a = 10, b = 20;
printf("%d %d\n", a, b);
swap(a, b);
printf("%d %d", a, b);
}
当我们运行之后,会发现:上下两行的输出结果没有任何改变。而原因就是上面提到的形参和实参。 那么我们应该如何自己实现 swap 功能呢?可以使用指针来解决。
Solution
void swap(int *a, int *b)
{
int t = *a;
*a = *b;
*b = t;
return ;
}
int main()
{
int a = 10, b = 20;
printf("%d %d\n", a, b);
swap(&a, &b);
printf("%d %d", a, b);
}
如上,我们在 swap 函数中使用指针来进行交换。此时,由于我们交换时使用的是指针存储地址所代表的数据,而函数传递指针参数时,这个地址作为被传递的数据是不会改变的(这里的形参是 swap 内的指针变量 a、b)所以我们在交换时可以直接影响到主函数中的变量 a、b,从而实现我们的目的。 所以,当我们需要在自定义函数中影响到原函数的变量时,可以将传递的参数改为变量的指针而不是原来的变量 。
链表 Linked-List ¶
结构 ¶
大概的结构如下:
其中,其中每一个点都是一个结构体,前后两个节点使用指针相连接。 最基本的链表大概是下面这个样子:
typedef struct List_Node* Node
struct List_Node{
Element_type data;
Node next;
};
讲点基础知识 ¶
结构体指针 ¶
比如我们现在定义了以下结构体和其指针:
struct course{
char name[100];
float credit;
int score;
float point;
}A;
int main()
{
struct course* p = &A;
return 0;
}
A.name = "FDS";
A.credit = 2.5;
p -> name = "FDS";//当然,也可以写 (*p).name = "FDS"
p -> credit = 2.5;
Note
当我们使用指针来描述结构体时,需要使用 ->
符号而不是 .
符号。
内存分配与释放 ¶
在实际使用时,我们通常都需要让一个结构体指针指向一个实际有内存的结构体(也就是让 p 指向 A
小心野指针
注意,千万不要定义一个结构体指针 p 后就直接进行相关赋值。因为此时我们还没有给指针 p 分配一个确定的空间,可以理解成 p 指向了一个虚无缥缈的地方,是一个 野指针 。
此时,就需要 malloc 函数 了。其用法如下:
malloc
#include <stdlib.h>
void* malloc (size_t size);
malloc 函数的参数为 size,其是一个无符号整型,表示需要申请的空间大小。一般来说,我们可以借助 sizeof 来简单易懂的表示这个参数。 malloc 函数的返回值是 void 也就是无类型指针,当我们给一个指针用 malloc 申请空间时,需要在 malloc 前面使用其他指针类型强制转换。若 malloc 函数申请空间失败,其会返回 NULL 空指针,此时一定要注意检查。 注意:* 使用 malloc 函数申请空间之后,这块空间里面有什么内容是不确定的,所以在使用之前一定要对这些内存进行初始化。 举个栗子:
Example
对上面的例子来说,我们可以写以下代码:
struct course{
char name[100];
float credit;
int score;
float point;
};
int main()
{
struct course* p = (struct course*)malloc(sizeof(struct course));
memset(p -> name, 0, sizeof(p -> name));
p -> credit = 0;
p -> score = 0;
p -> point = 0;
return 0;
}
此外,由于指针 p 指向的空间是我们使用 malloc 函数向电脑申请的,当我们不再需要使用这块内存后,需要将这块内存 “还给” 电脑,此时需要使用 free 函数 。
free
使用 free(指针)
即可。
初始化 ¶
当我们需要新建一个链表时,一般都是先定义一个头节点,将其初始化为空指针 NULL。只有当我们需要往里面塞内容时,我们才向电脑申请空间并初始化、赋值等。
链表的遍历 ¶
对于一个数组而言,我们想访问这个数组什么地方就可以访问什么地方,只需要选对下标就可以。比如访问数组的开头:a[0]
;访问数组的第五个位置:a[4]
;访问数组末尾(假设数组有 10 个位置a[9]
。
但是对链表而言,这样的操作是无法实现的。当我们需要访问链表末尾的数据时,只能从链表的头节点开始,一直沿着指针往下找,直到找到最后的位置为止。因此,在链表中访问数据非常消耗时间。
哨兵节点 ¶
或者可以叫做 ”僵尸节点“。 多数情况下,一个链表不会从头节点开始就存储数据,而是使用一个不存储数据的空白节点当作头节点,其后面使用指针连接其他存有数据的节点。这样的空白节点叫 哨兵节点 。
链表的其他形式 ¶
除了我们一开始提到的 “单向链表”,链表还有其他几种形式,如 ”双向链表“
例 2.3.1
使用环形链表模仿约瑟夫问题。
链表的基本操作 ¶
以下都是以单向链表为例。
增 ¶
当我们需要往里面增加一个节点时,首先我们需要想办法直到插入节点的位置,也就是需要直到待插入节点的前驱节点是什么。 当我们得到前驱节点之后,就可以进行插入了。比如我们的前驱节点为 front,待插入节点为 p,f 原来的后驱节点为 back:
节点 p 赋值
Node back = front -> next;//将 front 的后驱节点 back 记录下来,方便后续操作
front -> next = p;//将 front 的后驱节点改为 p
p -> next = back;//将 p 的后驱节点赋值为 back
删 ¶
当我们需要删除一个节点时,首先也是需要找到待删除节点的位置。在找的同时,我们还需要一起记录当前点的前驱节点是什么。 当待删除点找到之后,假设为 p,其前驱节点为 front,p 的后驱节点为 back:
front -> next = p -> next;//将 front 的后驱节点改为 back,这里省略了记录 back 的过程
free(p);//记得将 p 的空间释放掉
改 ¶
当我们需要改变一个节点中的数据时,只需要先找到这个节点的位置,然后对其数据进行修改即可。
查 ¶
前面三个操作都是建立在查的基础上。当我们需要在链表中查一个点时,我们需要从头节点开始一直往下找,直到找到符合要求的点为止。
假设我们定义了一个函数 int check(Node p)
用来检验该节点是否是我们需要找的节点(符合要求则返回 1),并假设头节点为哨兵节点:
Node find(Node head)
{
Node p = head -> next;//从第一个存有数据的节点开始找
while(p != NULL)//一直找到整个链表全部遍历完为止
{
if(check(p))
{
return p;//若找到符合要求的节点,就返回这个点,同时结束程序
}
p = p -> next;//如果程序执行到这里,说明前面没找到。此时更新 p,让其变成下一个点,继续寻找
}
return NULL;//若执行到这里,说明遍历整个链表都没找到对应的节点。此时直接返回 NULL
}
输出链表 ¶
若我们需要将整个链表输出,只需要遍历整个链表,边遍历边打印即可。
假设该链表有哨兵节点,同时我们定义 void print(Node p)
用来输出相关数据。
Node p = head -> next;//从第一个存有数据的节点开始打印
while(p != NULL)//一直打印直到遍历完整个链表为止
{
print(p);//打印相关内容
p = p -> next;//更新 p,让 p 变为下一个节点继续打印
}
return ;
删除链表 ¶
当我们需要删除一个链表时,我们同样也需要遍历整个链表,一边遍历一边释放空间。 假设该链表有哨兵节点:
Node p = head, t;//哨兵节点同样需要删除
while(p != NULL)//直到遍历完整个链表为止
{
t = p -> next;//记录后驱节点
free(p);//释放空间
p = t;//更新 p
}
return ;
简单介绍两种数据结构 ¶
栈 stack ¶
栈比较类似一摞碗,当我们需要拿碗或者放碗的时候,只能从最顶上开始拿或者放。
使用数组或者链表都可以实现栈。
队列 queue ¶
想象许多人排队站在狭窄通道内部,此时人需要出去就只能从最前面走,人需要进来就只能从最后面进。
同样的,使用数组或者链表都可以实现队列。
多文件编程 ¶
宏 ¶
下面,我们来回顾一下宏定义相关的知识。
最基本的用法:定义一个常数 ¶
#define
的用法为:define <名字> <值>
,其最后不使用分号,
在程序开始编译之前,编译器会将程序中所有设计宏定义的量进行替换。
Note
- 若宏中嵌套其他的宏,则同样会被替换。
- 若宏太长以至于一行放不下,则需要在行末使用
\
后再写下一行。 - 宏后面的注释不会起作用。
使用宏定义简单函数 ¶
宏定义可以使用参数,如:
#define cube(x) ((x) * (x) * (x))
Warning
当使用宏定义一个有参数的式子时,一定要注意加上括号。不仅是给参数加上括号,还要给整体加上括号,否则会出现错误。
Example
#define RADTODEG(x) (x * 57.29)
当我们写 RADTODEG(4 - 3)
时,编译器会替换为:4 - 3 * 57.29
,和我们的目的相悖。
#define RADTODEG(x) (x) * 57.29
当我们写 4 / RADTODEG(3)
时,编译器会替换为:4 / 3 * 57.29
,和我们的目的也相悖。
此外,我们还可以使用多个参数,构造更多的函数:
#define MAX(a,b) ((a) > (b) ? (a) : (b))
无定义值的 define ¶
我们可以在文件开始写 #define SEARCH
类似的宏定义。这类宏定义的主要是起标识作用,一般需要和 #ifdef
、#else
、#endif
等配合使用。
也就是说,我们可以在文件一开始使用 #define
定义标识符,然后使用 #ifdef 标识符
检查前面是否有定义该标识符,若有,则执行 #ifdef
后面的程序;若没有,则执行 #else
后面的程序。最后,记得使用 #endif
做结尾。
多文件编程 ¶
当我们有很多很多代码及很多很多函数时,我们可以将这些函数放到不同的文件中分别编译,最后再连接到一起构成一个大程序。
而连接就需要通过 #include
定义。
快速上手 ¶
- 我们需要创建一个库函数文件,后缀是 '.h'。
- 在该文件中(我们假设叫 'a.h'
) ,我们需要写:#ifndef 某标识符 #define 某标识符//这里是为了防止之前已经定义过该标识符,从而出现重复声明的错误 你的函数声明 #endif
- 然后我们需要创建一个 '.c' 文件,并在文件开头写上:
#include "a.h"//当引入同一目录下的库时,我们使用双引号;若我们使用 '<>',则编译器会直接去指定目录下面寻找对应的库
- 并且,我们需要在该 'a.c' 文件内写上 'a.h' 中已声明函数的详细代码。
- 最后的最后,我们需要在包含主函数的 '.c' 文件中也写上
#include "a.h"
。这样,就算大功告成了。 - 最后,我们只需要依靠一点手段将所有文件都编译连接即可。
原理?¶
我们可以将 #include
理解为一个接口,当我们在代码最开始写 #include
并编译、连接程序时,会根据 #include
后的文件将对应的函数和我们自己写的函数放到一起。所以我们才可以使用本文件定义函数之外的其他函数。
上面的例子中,我们写了 #include "a.h"
,在编译连接时,电脑会将同样接了 #include "a.h"
接口的 'a.c' 文件一起放进来,所以我们才可以使用其他文件定义的函数。
static 变量 ¶
观察下面的代码:
void function();
int main()
{
for(int i = 0; i < 5; ++ i)
{
function();
}
return 0;
}
void function()
{
int j = 1;
j ++;
printf("j = %d\n", j);
return ;
}
int j = 1
改为 static int j = 1
,那么结果就会大大不同:
void function();
int main()
{
for(int i = 0; i < 5; ++ i)
{
function();
}
return 0;
}
void function()
{
static int j = 1;
j ++;
printf("j = %d\n", j);
return ;
}