观念:1.学习本身是一件痛苦的事情(改变行为,改变认知)。
2.从学习知识的认知转变为学习技能(理论先行,理论与实践并重)
技巧:课前预习,课堂认真听讲(跟上老师的思路,如何思考问题),课后(作业按时完成,及时总结复习)
忠告:1.千万不要用百度解决代码问题(google
)
2.英语很重要(逼自己一把)
3.品味很重要(读好书)
就业方向:
C/S(client/server)(游戏,金融) | B/S(browser/server)(电商,政府网站) |
---|---|
速度快,传输数据量少,可采样自定义协议 | 学习成本低,不需要开发客户端 |
开发客户端,学习成本,更新比较麻烦 | 传输数据量大,HTTP/HTTPs协议(速度慢) |
1.前端
client端:QT
browser端:HTML5/CSS/JavaScript
2.后端
Server端:业务逻辑+架构
逻辑:数据库,缓存,消息队列
架构:微服务(上云)
3.底层开发
开发数据库,操作系统,浏览器,游戏引擎(变动不大)
4.数据方向(有一定数学能力)
大数据,人工智能
5.嵌入式方向
汽车,芯片,IOT(智能家居)
6.安全方向(密码学)
网络攻防
阶段一:C语言/数据结构/算法
(15天)
C语言基础:9天
C语言是奇怪的,有缺陷的,取得巨大的成功的语言。
数据结构和算法:6天 动态数组,链表,栈,队列,哈希表,位图(数据结构),二叉树;排序,查找(分析算法)
词法分析器:题目
第一章.C语言概述
1.C语言起源
1.当今网络世界是建立在C语言之上的,Linux,HTTP服务器(Nginx),Redis(内存数据库,缓存),PostgreSql,网络协议栈等都是C语言实现的。
3.特性少(函数,结构体,指针等)
2.第一个程序编写编译执行
1 |
|
课堂使用VScode软件编写程序
这个程序虽然简短,但是它包含了C语言程序的基本结构。接下来,我们对这个程序 做一个简单的说明。
include 是一条预处理指令,它表示在程序中 “包含” stdio.h 头文件, 这个头文件中有C语言标准输入/输出库的信息。
main 函数是程序的入口, main 函数中的第一行代码是用来打印信息的。 printf 函数来自于标准输入/输出库,它可以产生格式化的输出。 \n 是一个转义序列,它 表示换行。
第二行代码 return 0 ; 有两个作用:一是终止 main 函数,这样其实也就终止了整 个程序。二是程序终止时会向操作系统返回状态码 0。
2.1.编译和链接(♥)
虽然HelloWold.c
很简单,但是为了运行这个程序,却需要涉及很多内容。我们需要把文本形式的HelloWorld.c
转换成计算机可以执行的(二进制)形式。对C程序来说,通常包括下面4个步骤:
预处理:首先程序会由预处理器进行处理。预处理器执行以#开头的指令。预处理指令一般都比较简单:比如,把头文件中内容copy到源代码中,或者是对宏进行文本替换等。
预处理命令不用加分号
宏函数:
1 |
|
程序只执行预处理的设置:右键项目-属性-属性配置(C/C++)-预处理器-预处理到文件(是)
预处理过程就是将.h文件的内容复制到.c文件中生成一个.i文件
编译:经过预处理器处理的文件(main.i)会交给编译器进行编译,编译器会把程序翻译成对应平台的汇编代码
汇编:汇编器会把生成的汇编代码翻译成对应平台的机器代码(目标代码)。然而程序现在是不能运行的,还需要经过最后一个步骤链接。
链接:在链接阶段,链接器会把由汇编器生成的目标代码和程序需要的其他附加代码整合在一起,生成最终可执行的程序。这些附加代码包括程序中用的的库函数(如printf函数)
注意:在C / C++ 中,编译单元为源文件。也就是说我们会对每一个源文件进行编译, 生成对应的目标文件。然后将多个目标文件链接在一起,生成可执行程序。
2.2.进程虚拟内存空间(♥)
程序和进程:程序是经过(预处理,编译,汇编,链接)形成的二进制文件,进程是一个运行的程序,每个进程都有自己的虚拟内存空间。
3.变量和赋值
很少有程序会像 HelloWorld.c 那样简单。大多数程序在产生输出之前都需要执行一系 列的计算,因此需要在程序执行过程中有一种临时存储数据的方法。和大多数编程语 言一样,C语言中的这类存储单元被称为变量 (variable)。
变量的三要素:
变量名:引言变量绑定的值
类型:1.限定了值的范围(限定编码和内存的大小来限定值的范围);
2.值能够进行的操作
值:
3.1类型
每一个变量都必须有一个类型 (why?)。类型用来说明变量所存储数据的种类。C语言 有各种各样的类型。但是现在,我们只简单地讲两种类型:int 类型和 float 类型。类 型会影响变量的存储方式以及允许对变量进行的操作,所以选择合适的类型是非常关 键的。int 类型的变量可以存储整数,如:0, 9527, -1 等。float 类型变量可以存储小 数,如:3.14, -0.123 等。
3.2声明
在使用变量之前必须对其进行声明,声明的格式如下:
1 | 类型 变量名; |
例如,我们可以这样声明变量 height 和 profit:
1 | int height; |
如果几个变量具有相同的类型,我们可以把它们的声明合并:
1 | int height, length, width, volume; |
3.3赋值
变量通过赋值操作获取值。如
1 | height = 8; |
我们通常会把一个浮点数赋值给 float 类型的变量,而且往往会在浮点数后面添加字母 f。如:
1 | profit = 2150.48f; |
一旦变量被赋值,我们就可以用它来计算其他变量的值:
1 | height = 8; |
3.4显示变量的值
我们可以用 printf 显示变量的值。如:
1 | printf("Height: %d\n", height); |
其中 %d 是占位符,我们称之为转换说明 (conversion specification),它是用来指明 变量 height 在显示中的位置。
%d 仅适用于 int 类型变量,如果要显示 float 类型变量,需要用 %f 来代替 %d。默认情 况下,%f 会显示出小数点后 6 位数字。如果要强制 %f 显示小数点后 p 位数字,可以 把 .p 放置在 % 和 f 之间,如:
1 | printf("Profit: $%.2f\n", profit); |
C 语言没有限制 printf 可以显示变量的数量,我们可以同时显示多个变量的值:
1 | printf("Height: %d Length: %d Width: %d\n", height, length, width); |
3.5初始化
当程序开始执行时,某些变量会被自动设置为零,而大多数变量不会。没有默认值并 且尚未在程序中被赋值的变量是未初始化的。
注意:试图访问未初始化的变量,其行为是未定义的。在有些编译器中,可能会得到 一个无意义的值;在另一些编译器中,则可能发生更坏的情况 ( 如程序崩溃 ) 。
我们当然可以采用赋值的方式给变量赋初始值,但C语言还提供了一个更简便的方 法:在声明变量的同时赋初始值。如:
1 | int height=8; |
在一个声明语句中,可以对任意数量的变量进行初始化:
1 | int height = 8, length = 12, width = 10; |
注意,上面每一个变量都有自己的初始化式。如果写成这样:
1 | int height, length, width = 10; |
则只有变量 width 被初始化了。
4.标识符
标识符:在编写程序时,需要对变量,函数,宏等内容进行命名.这些名字我们称为标识符。在C语言中,标识符可以含有字母,数字,下划线,但是必须以字母或者下划线开头(C语言区分大小写)。
规范:单词间用下划线;驼峰命名法(前单词小写后单词首字母大写)
4.1关键字
关键字:关键字不能作为标识符来使用,对C编辑器有特殊意义
5.输入
在 C 标准库有一个与 printf 相对应的函数 scanf 函数和 scanf ,它可以获取用户的输入。 printf 函数都需要使用格式串来指定输入或输出数据的格式。
我们可以像下面这样,读入一个 int 类型的值:
1 | scanf("%d", &i); |
其中 %d 说明 scanf 读入的是一个整数值,i 是一个 int 类型的变量。
我们可以使用 %f 读入一个 float 类型的值:
1 | scanf("%f", &x); |
其中 x 是一个 float 类型的变量。
6.为常量定义名字
当程序含有一些有特殊意义的常量时 (比如 32.0f),建议给这些常量定义名字,以免 别人在阅读程序不知道这个常量的含义。在 C 语言中我们可以采取宏定义的方式给常量命名:
1 |
这里的 #define 是预处理指令,类似于 #include ,因而结尾处也没有分号。在预 处理阶段,预处理器会把每一个宏替换为其表示的值。
我们还可以利用宏来定义表达式,如:
1 |
当宏表示一个表达式时,我们最好用括号把表达式括起来?
为了避免运算符优先级问题和确保逻辑正确性
1 | //假设有一个宏用来计算两个数的平方和 |
第二章 格式化输入/输出
scanf 和 printf 函数是C语言中使用最频繁的两个函数,它们用来进行格式化输 入和输出。这两个函数功能强大,但要用好它们却不容易。
1.输入输出模型
这些年,我们的硬件设备:CPU,内存,I/O 设备都在不断迭代,不断朝着更快的方 向努力。但是,在快速发展的过程中,有一个核心矛盾一直存在,那就是这三者之间 的速度差异。我们可以形象地描述为:CPU 一天,内存一年;内存一天,IO 设备十 年。(假设 CPU 执行一条普通指令需要一天,那么 CPU 读取内存就需要一年;假设内 存之间传递单位数据需要一天,那么内存与 IO 设备之间传递单位数据就需要十年)。
为了平衡这三者之间的鸿沟,一个有效的手段是引入缓存。缓存在计算机世界中是随 处可见的,如下图所示:
为了平衡内存和 IO 设备之间的速度差异,我们会在内存中设置一些缓冲区,其中就 有标准输入缓冲区 (stdin) 和标准输出缓冲区 (stdout). 一般情况下,stdin 关联到键 盘,而 stdout 关联到屏幕。scanf 的作用是,从 stdin 读取数据到程序;而 printf 的 作用是,将输出结果写入到 stdout。
2.printf函数
printf=print(打印)+format(格式化)
printf
的工作原理:打印格式串中的内容,并用后面用表达式的值替换格式串
中的转换说明(占位符)。
调用 printf 函数时必须提供格式串,后面的参数是需要插入到格式串中指 定位置的值:
1 | printf(格式字符串, 表达式1, 表达式2, ...); |
2.1转换说明
m 和 p 都是可选的;如果省略 p,则小数点也要省略。
最小字段宽度m ,如果显示值所需的字符数少于 m,那么会在值前面添加额外的空格(右对齐)。如果显示值所需的字符数多于m,那么 会自动扩展为所需的大小,不会丢失数字。m前面的负号会导致值左对齐
精度 (precision) p 的含义依赖于转换说明符 (conversion specifier) X 的选择。X 指 示对数值进行哪种转换。
常用的转换说明符有以下几个:
d —— 表示十进制整数。p 表示待显示数字的最小个数 (必要时在数字前面补 0); 如果省略 p,则默认为1。
f —— 表示“定点十进制”形式的浮点数。p 表示小数点后数字的个数 (默认为 6)。 如果 p 为 0,则不显示小数点。
m.p的作用是控制输出格式
3.scanf函数
scanf=scan(扫描)+format(格式化)
值得注意的是,通常我们会在后面变量的前面加 & 符号(取地址运算符)
3.1scanf函数的工作原理
scanf的工作原理:从左到右依次匹配格式串中的每一项,忽略前置的空白字符。
如果有一项匹配不成功,scanf会立刻返回,123ABC。
scanf会返回匹配成功的转换说明的个数。
scanf 函数本质上是一个”模式匹配”函数,试图把输入的字符与转换说明匹配。
scanf 函数是从左到右处理格式串中的信息。对于格式串中的每一个转换说明,
scanf 函数试图从输入中读取对应的数据项。如果读入数据项成功,那么 数会继续处理格式串的剩余部分;如果读入不成功,那么 串的剩余部分,而会立刻返回。
Q: scanf 函数是如何识别整数和浮点数的呢?
scanf 函数在寻找数值时,会忽略前面的空白字符 (white-space character: 包括空 格符、水平和垂直制表符、换页符和换行符)。
在读入整数时, scanf 函数首先会寻找数字、正号或者负号,然后读取数字直到读到 一个非数字时才停止。
在读入浮点数时, scanf 函数会首先读取正号或者负号 (可选),随后是一串数字 (可能包含小数点),再然后是指数 (可选)。指数由字符 e (或 E),正负号 (可选) 和 一串数字组成。
%c:匹配一个字符
3.2格式串中的普通字符
处理格式串中的普通字符时, scanf 的行为依赖于这个字符是否为空白字符。
空白字符。当在格式串中遇到一个或多个连续的空白字符时, scanf 会读取所有的 空白字符直到遇到一个非空白字符。格式串中的一个空白字符可以与输入中任意数 量的空白字符匹配,所以格式串中空白字符的数量是无关紧要的,一个空白字符的 效果与多个空白字符的效果是一样的。
附带提一下,在格式串中包含空白字符并不意味着输入中必须包含空白字符。格 式串中的一个空白字符可以与输入中任意数量的空白字符相匹配,包括零个.
其他字符。当在格式串中遇到非空白字符时, scanf 会把它与下一个输入字符进行 比较。如果两个字符相匹配,那么 scanf 会丢弃输入字符而继续处理格式串。如果 两个字符不匹配,那么 scanf 不会读取该字符。之后会异常退出,不会进一步处理 格式串或者从输入中读取字符。
3.3 不要混淆printf函数和scanf函数
虽然 scanf 函数调用和差异printf 函数调用看起来很相似,但这两个函数之间有很大的
一个常见的错误是:调用 printf 函数时,在变量的前面加 &。
1 | printf("%d, %d\n", &i, &j); /*** WRONG ***/ |
scanf 函数在寻找数据项时,通常会跳过前面的空白字符。所以除了转换说明,格式 串通常不包含其他字符。
另一个常见的错误就是:认为 scanf 函数的格式串应该类似于printf 函数的格式串。
1 | scanf("%d, %d", &i, &j); |
第三章 基本整数类型
1.整数的类型
整数类型又可以分为两大类:有符号整数和无符号整数。默认情况下,C 语言的整数 类型都是有符号的;若要声明为无符号整数,则需要加 unsigned 关键字。C 语言的 整数类型有以下这些:
C 语言整数类型的取值范围可能根据机器的不同而不同。但是有两条所有编译器都必 须遵循的原则:
首先,C 标准规定了 short(2), int(2), long(4), long long(8) 的最小字节长度。
2其次,C 标准规定了各个整数类型的字节长度满足下面的关系:
1
short <= int <= long <= long long
下表是64 位机器上整数类型的常见取值范围:
1.1整数字面值
C 语言允许使用十进制 (decimal),八进制 (octal) 或者十六进制 (hexadecimal) 来 书写整数字面值。
十进制字面值包含数字 0~9,但是不能以 0 开头。如:15, 255, 32767。
八进制字面值包含数字 0~7,而且必须以 0 开头。如:017, 0377, 077777。
十六进制字面值包含数字 0~9 和字母 a~f,而且总以 0x 开头。如:0xf, 0xff, 0x7ff。
十六进制字面值中的字母即可以是大写也可以是小写,如:0xff, 0xfF, 0xFF, 0Xff, 0XfF, 0XFF。
注意:整数字面值也是有类型的
十进制整数字面值的类型通常是 int,但如果该字面值超出了 int 的表示范围,那么它 的类型是 int, long 和 long long 中能表示该值的 “最小” 类型。对八进制和十六进制 整数字面值来说,可能的类型顺序为:int, unsigned int, long, unsigned long, long long 和 unsigned long long
如果要指明某个整数字面值为一个 long 类型,我们需要在后面加字母 L (或 l):
1 | 15L, 0377L, 0x7fffL |
如果要指明整数字面值是 long long 类型,我们需要在后面加 LL (或 ll):
1 | 15LL, 0377LL, 0x7fffLL |
如果要指明整数常量是无符号的,我们需要在后面加字母 U (或 u):
1 | 15U, 0377U, 0x7fffU |
L、LL还可以和 U 结合使用,如:
1 | 0xffffffffUL, 0x12345678ULL |
顺便说一下:字母 L, LL 和 U 的顺序可以颠倒。
1.2 读/写整数
如果使用 scanf 和 printf 函数读写无符号整数、短整数和长整数,那么我们需要一 些新的转换说明符。%d 只适用于读写 int 类型的数据。
读写 unsigned int 时,使用字母 u, o 或 x 替代转换说明符中的 d。其中 u 表明无符号整数是十进制形式;o 表明无符号整数是八进制形式;x 表明是十六进制形式。
```c
unsigned int n;scanf(“%u”, &n);
printf(“%u”, n);
scanf(“%o”, &n);
printf(“%o”, n);
scanf(“%x”, &n);
/ reads n in base 10 /
/ writes n in base 10 /
/ reads n in base 8 /
/ writes n in base 8 /
/ reads n in base 16 /
printf(“%x”, n);
/ writes n in base 16 /1
2
3
4
5
6
7
- 读写 short 时,在 d, u, o 或着 x 前面加字母 h (short):
```c
short n;
scanf("%hd", &n);
printf("%hd", n);读写 long 时,在 d, u, o 或者 x 前面加字母 l:
```c
long n;
scanf(“%ld”, &n);
printf(“%ld”, n);1
2
3
4
5
6
7
- 读写 long long 时,在 d, u, o 或者 x 前面加字母 ll:
- ```c
long long n;
scanf("%lld", &n);
printf("%lld", n);
1.3整数类型编码
无符号整数和有符号整数它们的编码是不一样的。
补码的两个性质:
1.补码里二进制表示都是1,则值为-1
2.两个有符号整数x+(-x),结果为100000000000000(n个0)
为什么计算机采用补码存储有符号整数? ->用加法器来做减法运算。
原码:简单直观但不利于直接用于硬件上的算术运算。
00001110(14)
+10000110 (-6)
10010100 (-20)
反码:解决了部分运算问题但仍存在两个零的问题。
对于正数,其反码与原码相同;对于负数,将原码除符号位外的所有位取反
00001110(14)
+11111001 (-6)
1 00000111 (7)
补码:广泛应用于现代计算机系统中,因为它简化了算术运算并有效解决了两个零的问题。
对于正数,其补码与原码相同;对于负数,先求出其反码,然后在其最低位加1得到补码。
00001110(14)
+11111010 (-6)
00001000 (8)
2.浮点数类型
编码:IEEE754标准,浮点数是不精确的,越往0越集中,越远离0越稀疏。
单精度float(%f)
双精度double(%lf)
long double
当对精度要求不高时 (比如只有一位小数的运算),我们可以使用 float 类型;大多数 情况下,我们都会使用 double 类型;在极少数对精度要求非常高的情况下,才会使 用 long double。
C 语言标准并没有明确说明 float, double, long double 类型提供的精度到底是多少, 不同的计算机可以使用不同的方式存储浮点数。不过,大多数计算机都遵循 IEEE 754 标准 (也就是说 IEEE 754 是事实上的标准)。
下表展示了遵循 IEEE 标准的浮点数的特征:
类型 | 规范化的最小正值 | 最大值 | 精度 |
---|---|---|---|
float | 1.175 49 x 10^-38 | 3.402 82 x 10^38 | 6位数字 |
double | 2.225 07 x 10^-308 | 1.797 69 x 10^308 | 15位数字 |
long double 类型没有显示在此表中,因为它的长度可能随机器的不同而变化,最常 见的是 80 位和 128 位。
2.1浮点数字面值
浮点数常量有多种书写方式。例如,下面都是 57.0 的有效表示方式:
1 | 57.0 57. 57.0e0 57E0 5.7e1 5.7e+1 .57e2 570.e-1 |
浮点数必须包含小数点或者是指数;字母 E (或 e) 后面的数字表示以 10 为底的指 数。
默认情况下,浮点常量都是 double 类型。如果需要表明以单精度方式存储,可以在 末尾加字母 F 或 f,如 57.0F 。如果以 long double 方式存储,则在后面加 L 或 l, 如 57.0L 。
2.2读/写浮点数
前面我们已经讲过,可以使用转换说明符 %f 来读写 float 类型的数据。读写 double 和 long double 类型所需的说明符与 float 略有不同。
读写 double 类型的值时,需要在 f 前面添加字母 l;
1
2
3double d;
scanf("%lf", &d);
printf("%lf", d);读写 long double 类型的值时,需要在 f 前面添加字母 L。
1
2
3long double ld;
scanf("%Lf", &ld);
printf("%Lf", ld);
3.字符类型
编码采用ASCII码(1个字节,低7位,128个字符)
前32个+127是控制字符
五个特殊码点:Null=0,’ ‘ = 32, ‘0’ = 48, ‘A’ = 65, ‘a’ = 97。
char 类型的变量可以存储单个字符,注意:字符字面值应该用单引号括起来。
3.1字符操作
C 语言是把字符当作小的整数进行处理的,毕竟字符和整数之间的关联是非常强的。 当计算中出现字符时,C 语言会使用字符对应的整数值,如:
1 | char ch; |
我们也可以像比较整数那样对字符进行比较。比如,下面的代码可以把小写字母变成 大写字母。
1 | /* not commended, toupper function is better */ |
我们还可以像下面这样使用 for 循环遍历所有的大写字母:
1 | for (ch = 'A'; ch <= 'Z'; ch++) ... |
把字符当作整数来处理,可以给我们带来方便,但也可以导致我们写出一些无意义的 表达式:
1 | 'a' * 'b' / 'c'; /* Don't do this */ |
3.2转义序列
字符转义序列使用起来是很方便的,但是它有一个问题:字符转义序列没有包含所有 的不可打印的 ASCII 字符,而且它也不能表示 ASCII 以外的字符。
数字转义序列可以解决上面的问题,它可以表示任意一个字符。我们可以使用八进制 或者十六进制来书写数字转义序列。
八进制转义序列由 \ 和一个最多 3 位的八进制数字组成。比如 ESC 的编码为 27, 用八进制表示就是 33,因此它可以表示成 \33 或者 \033。注意,和八进制整数不 一样的是,转义字符中的八进制数不需要以 0 开头。
十六进制转义序列由 \x 和一个十六进制数组成。比如 ESC 字符也可以写成 \x1b 或 \x1B 的形式。其中 x 必须小写,但是十六进制数字不限大小写。
当转义序列作为字符常量使用时,必须用一对单引号括起来。比如:`\n’, ‘\33’ 或 ‘\x1B’。
3.3 字符处理函数
头文件 中声明了许多字符处理函数,主要分为两大类:字符分类函数和大 小写转换函数
1 | //C语言把字符当作一个字节的整数处理 |
3.5读/写操作
读操作和写操作(和用户交互)
printf+%c:打印字符
scanf+%c:读取字符 (%c匹配一个字符,不会跳过前面的空白字符
如果需要跳过前面的空白 字符,则要在转换说明符 %c 前面加一个空格)
1 | scanf(" %c", &ch); /* skip white-space characters, then reads ch */ |
scanf 格式串中的空格意味着”跳过零个或着多个空白字符”。
C 语言还提供了另外一些读写单个字符的方法,其中比较常用的是 getchar 和 putchar 函数。
putchar 用于写单个字符:
1 | int putchar(int c); |
其作用是向标准输出 stdout 写入一个字符。如果写入成功,则返回写入的字符,否则 返回 EOF。
getchar 用于读单个字符:
1 | int getchar(void); |
其作用是从标准输入 stdin 读入一个字符,并且把读入的字符返回。如果读到文件的 末尾,或者在读的过程中发生错误,那么返回 EOF。
注意:putchar 和 getchar 函数的执行效率要高于 printf 和 scanf。
Q:跳过空白字符,读取下一个非空白字符:“ %c”, 或者putchar()和getchar()
getchar()惯用法:
1 |
|
4.类型转换
void空类型没有值,不能定义空类型的变量,变量需要绑定一个值。
4.1.给类型定义别名
格式:typedef 类型 别名;
我们可以使用宏给某一类型创建一个别名,如:但是更好的做法是使用 typedef
注意:我们定义的别名是放在最后的,而且类型定义是一条语句,后面需要加分号。使用 typedef 定义别名 Bool,编译器会把 Bool 加入它所能识别的类型名列表中。现在我们可以像使用 C 语言内置类型一样使用 Bool 类型了。如:编译器会把 Bool 看作 int 的同义词;因此,flag 其实就是一个普通的 int 类型变量。
1 |
|
为什么要定义别名:
1.可读性强 (前提是选择合适的类型名)
2.可移植性强
我们举个例子来说明这一点。把 C 语言程序从一台机器移植到另一台机器,可能出现的问题之一就是类型的取值范围不同。比如,i 是 int 类型的变量,那么在 32-bit 的机器上是没有问题的,但在 16-bit 的机器上就会出错。当然为了避免出现可移植性问题,我们可以变量 i 声明为 long int 类型。但是 int 类型的变量在运算时会比 long int 类型的变量更快,而且所占内存空间更少。
另一个解决方案就是使用别名。在 32-bit 的机器上,我们这样定义 Quantity 类型:
1 | typedef int Quantity; |
而在 16-bit 的机器上,我们将 Quantity 的定义改为:
1 | typedef long Quantity; |
C 语言库也经常使用 typedef 去定义一些类型;这些类型名经常以 _t 结尾。如:size_t, ptrdiff_t 和 wchar_t,这些类型的定义可能随着实现的不同而不同(所以才定义别名,增加代码的可移植性)。下面是它们的典型定义:
1 | typedef long int ptrdiff_t; |
12.sizeof运算符
作用:计算某一类型的值在内存中,所占字节的长度,运算符也可以应用于常量、变量和表达式。
第四章 语句
到目前为止,我们只见过两种语句: return 语句和表达式语句。根据语句对执行顺序的影响,C 语言其余语句大多属于以下 3 大类。
选择语句: if 语句和 switch 语句。
循环语句: while 语句, do…while 语句和 for 语句。
跳转语句:break 语句,continue 语句和 goto 语句 ( return 语句也属于类)。
C 语言还有其他两类语句,一类是复合语句 (把几条语句组合成一条语句),一类是空 语句 (不执行任何操作)。
1. 选择语句
1.1 if语句
if 语句最简单的格式为:
1 | if (expr) statement |
比如下面这个示例:
1 | if (line_num == MAXLINES) |
注意: 不要混淆 == 和 =。语句 if (i == 0) … 测试 i 是否等于 0;而语句 if (i = 0) … 则是先把 0 赋值给 i,然后测试赋值表达式的值是否非零,在这种情况下,测试总是失败的。
复合语句
如果我们想用 if 语句控制两条或者更多条语句,该怎么办呢?这时,就需要引入复合语句了。复合语句格式如下:
1 | { statements } |
通过将多条语句用花括号括起来,可以强制编译器将其作为一条语句来处理。如:
1 | { |
这样,我们就可以在 if 语句中使用复合语句了:
1 | if (line_num == MAX_LINES) { |
1.2 else语句
if 语句还可以有 else 子句,其格式为:
1 | if (expr) |
如果 expr 的值为 0,那么就执行 else 子句。如:
1 | if (i > j) |
C 语言对 if 语句内部的语句没有任何限制。事实上,在 if 语句内部嵌套其他 if 语句是非常普遍的。比如,我们可以用下面的语句找出 i, j, k 中的最大值:
1 | if (i > j) { |
1.3 级联式 if 语句
写程序时,我们经常需要判定一系列条件,直到某个条件为真。级联式 if 语句往往 是编写这类程序的最好方法。如:
1 | if (n < 0) |
虽然第二个 if 语句是嵌套在第一个 if 语句内部的,但 C 程序员通常不会像上面一 样对第二个 if 语句进行缩进,而是写成下面这样:
1 | if (n < 0) |
因此,级联式 if 语句有自己独特的书写形式:
1 | if (expr) |
请记住:级联式 if 语句不是新的语句类型。它仅仅是普通的 if 语句,只是碰巧它 的 else 子句又是一条新的 if 语句,以此类推…
1.4 悬空else问题:
当 if 语句嵌套时,我们需要当心臭名昭著的”悬空 else”问题。思考下面这个例子:
1 | if (y != 0) |
上面 else 子句究竟属于哪一个 if 语句呢?缩进暗示它属于最外层的 if 语句。然 而 C 语言遵循的规则是 else 子句应该属于离它最近的,且还没有和其他 else 匹配 的 if 语句。因此,在这个例子中, else 子句属于内层的 if 语句。
为了使 else 子句属于外层的 if 语句,我们可以用花括号将内层的 if 语句括起来:
1 | if (y != 0) { |
1.5 条件表达式
C 语言提供了一种特殊的运算符——条件运算符,这种运算符可以根据条件产生两个 值中的一个。条件运算符由 ? 和 : 组成,其格式如下:
1 | expr1 ? expr2 : expr3 |
条件运算符是 C 语言中唯一一个有 3 个操作数的运算符,因此我们又把它称为三目运算符。
条件表达式的求值步骤是:首先计算 expr1 的值,如果该值不为 0,则计算 expr2 的 值,并且把 expr2 的值当作整个表达式的值;如果 expr1 的值为 0,那么计算 expr3 的值,并把 expr3 的值当作整个表达式的值。
请看下面的示例:
1 | int i, j, k; |
顺便说以下,最后一条语句的圆括号是必须的,因为三目运算符的优先级只比赋值运算符的优先级高一点点…
1.6 布尔值
在最初的 C 语言中,我们是用非零值表示 true,用零值表示 false。缺少布尔类型多多 少少会带来一些麻烦。因此在 C99 中,定义了 _Bool 类型, _Bool 类型的变量只能 赋值为 0 或者 1。一般来说,往 _Bool 类型中存储非零值,会导致该变量赋值为 1。
1 | _Bool flag = 5; /* flag is assigned 1 */ |
C99 中还提供了一个新的头文件 stdbool.h,该头文件定义了bool 宏,用它来表示 _Bool ;而且该头文件还定义了 true 和 false 两个宏,它们分别代表 1 和 0。因此,以后我们可以这样写程序:
1 |
|
1.7 switch 语句
在日常编程中,常常需要把表达式和一些列值进行比较,从中找出匹配的值。前面可 以看到,级联式 if 语句可以可以达到这个目的。
弊端:1.可读性差;2.存在效率问题
1 | if (grade == 4) |
限制条件:1.switch后面的表达式必须是整型(包括char,枚举)
2.switch后面的表达式和case的标签,是用==做比较的
注意事项:1.多个case标签可以共用一组语句
2.case穿透,(如果需要上面标签做预处理,省略break需要加注释说明)
C 语言提供了 switch 语句作为这类级联式 if 语句的替换。如上面的例子可以写成这样:
1 | switch (grade) { |
执行这条语句时,变量 grade 的值与 4, 3, 2, 1 和 0 进行比较。如果值和 4 匹配,则打印 Excellent,然后 break 语句会把控制传递给 的值和列出的任何选择都不匹配,那么执行 switch 后边的语句。如果 grade default 分支,显示 Illegal grade。
switch 语句往往比级联式 if 语句更容易阅读。此外,switch 语句的执行速度也会比 if 语句快一些。
1 | switch (expr) { |
switch 语句相对来说比较复杂,下面我们来看以下它的组成成分:
控制表达式。 switch 后边表达式的值必须是整数类型。C 语言把字符类型也当作整数来处理,因此 switch 语句也可以对字符类型进行判定。但是,不能判定浮点数和 字符串 (why?)。
分支标号。 case 后边必须跟常量表达式,并且常量表达式的值必须是整数(字符类型也可以)。
常量表达式:即能够在编译期间求值的表达式。
语句。每个case 后面可以跟任意数量的语句 (不需要用花括号括起来)。每组语句的最后通常是一条break 语句。
C 语言不允许有重复的分支标号,但对分支的顺序没有要求,特别是 default 分支不一 定要放到最后。而且 switch 语句不要求一定要有 default 分支。如果 default 不存在, 而且控制表达式的值和任何一个分支标号都不匹配,控制会直接传递给 switch 后面的语句。
多个分支标号,可以共用一组语句。如:
1 | switch (grade) { |
1.71 case穿透
如果一个分支的最后没有break 语句,那么控制会从一个分支继续到下一个分支,这种现象我们称之为case 穿透。思考下面的语句:
1 | switch (grade) { |
如果 grade 的值为 3,那么会显示什么呢?
2.循环语句
在 C 语言中,每个循环语句都有一个控制表达式。每次执行循环体时,都要对控制表 达式求值。如果表达式为真,那么继续执行循环语句;否则执行循环语句的下一条语 句。 C 语言提供了 3 种循环语句,即 while 语句, do…while 语句和 for 语句。 while 语句在循环体执行之前测试控制表达式, do…while 循环在循环体执行之后 测试控制表达式, for 语句则非常适合那些递增或递减计数变量的循环。
2.1 while语句
在所有循环语句中, while 语句是最简单也是最基本的。 while 语句的格式如下:
1 | while (expr) statement |
其中圆括号内的表达式称为控制表达式,圆括号后面的语句是循环体。下面是一个示例:
1 | i = 1; |
虽然循环体必须是单独的一条语句,但这只是个技术问题:如果需要多条语句,那么 只要用一对花括号构造一条复合语句就可以了:
1 | i = 10; |
无限循环
如果控制表达式的值始终非零,那么 while 语句将永远执行下去。事实上,有时候我 们会故意用非零的常量表达式作为控制表达式,以此来构造无限循环。
1 | /* idiom */ |
除非循环体内含有跳出循环的控制语句 (break, goto, return) 或者调用了导致程序终 止的函数,否则上述形式的 while 语句将永远执行下去。
2.2 do….while语句
do…while 语句和 while 语句关系紧密。事实上, do…while 语句本质上就是 while 语句,只不过其控制表达式是在每次执行完循环体之后进行判定的。 do…while 语句的格式如下:
1 | do statement while (expr) ; |
执行 do…while 语句时,先执行循环体,再计算控制表达式的值。如果表达式的值 非零,那么继续执行循环体,然后再计算表达式的值;如果表达式的值为零,则终止 do…while 语句的执行。如:
1 | i = 10; |
do…while 语句和 while 语句的唯一区别是:do…while 语句的循环体至少会执行 一次,而 while 语句在控制表达式的值初始为 0 时,一次都不会执行。
2.3 for语句
现在介绍 C 语言中最后一种循环,也是功能最强大的一种循环: for 语句。 for 语 句非常适合那些递增或递减计数变量的循环,当然它也可以灵活地应用在许多其他类 型的循环中。 for 语句的格式如下:
1 | for (expr1; expr2; expr3) statement |
下面是一个例子:
1 | for(i = 10; i > 0; i--) |
执行 for 语句时,变量 i 先初始化为 10,接着判定 i 是否大于 0。因为 10 > 0,因此 打印 Counting down: 10,然后变量 i 自减。随后再次对条件 i > 0 进行判定…
从上面的例子,我们可以看到 for 语句和 while 语句关系非常紧密。事实上,除了 一些极少数的情况以外(你能举出例子吗?), for 循环总可以用等价的 while 循环替换:
1 | expr1; |
从等价的 while 循环可以看出,expr1 是循环开始执行前的初始化步骤,只执行一 次;expr2 是控制循环终止的;expr3 是每次循环中最后被执行的一个操作。
2.31 省略for语句中的表达式
for 语句远比现在看到的更加灵活,C 语言允许 for 语句省略一些或者是全部的表达 式。
如果省略 expr1,那么在执行循环前没有初始化的操作:
1 | i = 10; |
如果省略 expr3,那么循环体需要确保 expr2 的值最终会变成 0:
1 | for (i = 10; i > 0; ) |
当同时省略 expr1 和 expr3 时,那么 for 语句和 while 语句就没有任何分别,如:
1 | i = 10; |
等价于
1 | i = 10; |
如果省略 expr2,那么它的默认值为真。例如,某些程序员利用下列的 for 语句建立 无限循环:
1 | /* idiom */ |
2.32 C99中的for语句
在 C99 中, for 语句的第一个表达式可以替换为一个声明,这一特性使得程序员可以声明一个用于循环的变量:
1 | for (int i = 0; i < n; i++) |
变量 i 不需要在该语句前进行声明。如果变量 i 在之前已经进行了声明,这个语句将创建一个新的 i,且该变量只能在循环内使用。
for 语句声明的变量在循环外是不可见的:
1 | for (int i = 0; i < n; i++) { |
让 for 语句自己声明循环控制变量通常是一个好办法:这样很方便,而且程序的可读性更强。
顺便提一下,for 语句可以声明多个变量,只要它们的类型相同:
1 | for (int i = 0, j = 0; i < n; i++) |
2.33 逗号表达式
有时候,我们可能需要编写有两个 (或更多个) 初始表达式的 for 语句,或者希望在每 次循环时对几个变量进行自增操作。这时我们就需要使用逗号表达式了。
逗号表达式的格式如下:
1 | expr1, expr2 |
逗号表达式的求值分为两步:第一步,计算 expr1 并且扔掉计算出的值;第二步,计 算 expr2,把这个值作为整个表达式的值。对 expr1 的计算应该产生一些副作用,否 则 expr1 就没有存在的必要了。举个例子:
1 | i = 1; |
逗号表达式是左结合的,因此编译器会把表达式
1 | i = 1, j = 2, k = i + j |
解释为
1 | (i = 1, j = 2), k = i + j |
C 语言之所以提供逗号运算符,是为了在只能有一个表达式的地方可以使用两个甚至 是多个表达式。换句话说,逗号运算符允许将两个表达式”粘”在一起,构成一个表达 式 (和复合语句类似,复合语句可以把一组语句当成一条语句来使用)。
例如,我们可以把原来的程序
1 | sum = 0; |
改写成这样:
1 | for (sum = 0, i = 1; i <= N; i++) |
3.跳转语句
目前我们已经知道如何在循环体之前 ( while 语句和 for 语句) 和之后 ( do…while 语句) 设置退出点。然而,有些时候我们也需要在循环的中间设置退出 点,甚至可能需要对循环设置多个退出点。break 语句可以满足上述需求。
在学习完break 语句后,我们将看到两个相关的语句: continue 语句和 goto 语 句。由于已经有了break 和continue 这样好用的语句,所以现在很少使用 goto 语 句了。
3.1 break
前面已经讨论过,break 可以跳出 switch 语句。break 还可以用于跳出 while , do…while 或者 for 循环。比如,我们可以通过下面的代码测试 n 是否为素数:
1 | for (d = 2; d < n; d++) { |
break 语句经常使用在 while (1) 这样的语句中,在满足特定的条件时退出循环, 以免出现死循环。如:
1 | while (1) { |
break 可以跳出 switch , while , do…while 和 for 语句。但是当这些语句 嵌套时,break 只能跳出包含break 语句的最内层嵌套。如:
1 | while (...) { |
上述 break 语句只能将控制从 switch 语句中转移出来,但是不能跳出 while 循环。 (思考:如何跳出外层的 while 语句?)
3.2 continue
break 语句会把控制转移到整个循环的后面,而 continue 会将控制转移到循环体的末 尾。break 语句会跳出循环,而 continue 语句仍然留在循环体内。break 语句和 continue 语句还有另外一个区别:break 语句可以用于 switch 语句和循环,而 continue 只能用于循环。
下面我们通过一个例子来说明这一点:对 10 个非零的整数进行求和。
1 | count = 0; |
对应的 for 语句是这样:
1 | for (count = 0, sum = 0; count < 10; ) { |
3.3 goto
break 和 continue 语句都是受限制的,break 只能跳出到 switch 语句或者循环语句后 面的那一点,而 continue 语句只能跳转到循环体的末尾。goto 语句就没有这些限 制,它可以跳转到函数中任何有标号的语句处。它的唯一限制是只能在函数内进行跳 转。
标号是放置在语句开始处的标识符:
1 | identifier: statement |
一条语句可以有多个标号。
goto语句的格式如下:
1 | goto identifier; |
很显然,我们可以用 goto 语句来实现 break 或者是 continue 的效果 (但是不推荐这 么做)。
1 | for (d = 2; d < n; d++) |
在早期的编程语言中,goto 语句还是很常见的,但是现在基本上已经很少使用了 (why?)。break, continue, return 语句以及 exit 函数足以应付 goto 语句的大多数使 用场景。 虽然如此,但是 goto 语句偶尔还是很有用的。考虑前面提到过的 switch 语句嵌套在 while 语句的场景,break 语句是不可以跳出 while 循环的,但是 goto 语句可以解决 这个问题:
1 | while (...) { |
在 C 语言中,goto 语句也常用于进行错误处理。
1 | if (...) { /* check whether error happens */ |
第五章 表达式
C 语言的一个特点就是它更多地强调表达式而不是语句。表达式是用来计算某个值的 公式;最简单的表达式就是变量和常量。表达式可以用运算符进行连接,从而创建出 更复杂的表达式,比如 a + (b * c)。 C 语言拥有异常丰富的运算符:算术运算符,赋值运算符,关系运算符,判等运算 符,逻辑运算符,位运算符…
1. 算术运算符
算术运算符包含 +, - , , /, % ,它们分别表示加,减,乘,除,取余。算术运算符比较简单,不过其中 / 和 % 需要我们特别注意: 如果 / 的两个操作数都是整数,那么结果也是整数 (向零取整)。因此,1 / 2 的 结果为 0 而不是 0.5。 +, - , , / 可以用于浮点数,但 % 要求两个操作数都是整数。 取余运算的结果可能为负数,比如 -9 % 7 的值为 -2。假设 它们满足公式: i = (i / j) * j + (i % j) 。 i, j 都是整数,那么 i%j 的符号总是和i 同。
1.1 运算符的优先级和结合性
当表达式包含多个运算符时,其含义可能不是一目了然的。比如,i + j k 表示 (i + j) k 还是 i + (j * k) ?和其它语言一样,C 语言采用优先级来解决这种歧义性问题。C 语言完整的优先级规则,如下图所示:
由于 的优先级高于 + ,因此 i + j k 等价于 i + (j * k) 。
当表达式中包含两个或者更多个具有相同优先级的运算符时,仅有运算符优先级规则是不够的。在这种情况下,运算符的结合性开始发挥作用。如果运算符是从左向右结合的,那么这种运算符是左结合的。二元运算符大多是左结合的:
1 | i-j+k 等价于(i-j)+k |
如果运算符是从右向左结合的,那么称这种运算符是右结合的。一元运算符大多是右结合的:
1 | -+i 等价于 -(+i) |
2.赋值运算符
我们常常会将表达式的值保存到变量中,以便将来使用。赋值运算符就是用于此目的的。
2.1 简单赋值
表达式 v = e 的作用是:求出表达式 e 的值,并把这个值赋值给变量 v。如:
1 | i = 5; /* i is now 5 */ |
如果 v 和 e 的类型不同,那么在赋值过程中会把 e 的值转换成 v 的类型:
1 | int i; |
赋值运算符可以串联在一起,如:
1 | i = j = k = 0; |
由于赋值运算符是右结合的,上述表达式等价于:
1 | i = (j = (k = 0)); |
2.2 复合赋值
利用变量原有的值去计算新的值,这在程序中是非常普遍的。例如:
1 | i=i+2; |
C 语言的复合赋值运算符可以将上面的表达式简写为:
1 | i += 2; /* same as i = i + 2 */ |
类似地,还有:
1 | i -= 2; /* same as i = i - 2 */ |
3. 自增运算符和自减运算符
C 程序中最常用的两种运算就是自增 (加 1) 和自减 (减 1)。当然,我们可以通过赋值 运算符来完成这类操作:
1 | i = i + 1; |
不过 C 语言提供了一种更简洁的方式:++(自增) 和 —(自减)运算符。++和—运算符 既可以作为前缀运算符 (如++i , - i ),也可以作为后缀运算符 (如 i++ , i- ),不过表达式的值不同。
表达式++i 的值为(i + 1),副作用是i自增:
1 | i = 1; |
表达式 i++ 的值为i,副作用是i自增:
1 | i = 1; |
所以,++i 意味着 “立即自增i”;而 i++ 意味着 “先用 i 的原始值,稍后再自增 i”。
—运算符具有相同的特性:
1 | i = 1; |
需要记住的是:后缀++ 和后缀— 比正号、负号的优先级高;前缀++ 和前缀— 与正 号、负号的优先级相同。
4.关系运算符
我们可以用关系运算符,判等运算符和逻辑运算符来构建逻辑表达式。在 C 语言中, 逻辑表达式的值为 0 或者 1 (0表示false, 1表示true)。
关系运算符包含:<, >, <=, >= 。关系运算符的优先级低于算术运算符,并且是左结合的。
1 | i + j < k - 1 等价于 (i + j) < (k - 1) |
5.判等运算符
判等运算符包含:==, != 。判等运算符的优先级低于关系运算符,并且也是左结合的。
1 | i < j == j < k 等价于(i < j) == (j < k) |
6.逻辑运算符
逻辑运算符包含: &&, ||, ! 。但是逻辑运算符不要求它操作数的值也为 0 或者 1,C 语言会把任何零值当作 false,任何非零值当作 true。其中需要特别注意的是, && 和 || 会对操作数进行 “短路” 计算。也就是说,这些操作符会首先计算左操作数的值,然后计算右操作数;如果整个表达式的值可以由左操作数的值推导出来,那么将不会计算右操作数的值。如:
1 | (i != 0) && (j / i > 0) |
短路计算的好处是显而易见的,如果没有短路计算,上面的表达式可能会出现除零错误。
运算符 ! 的优先级和正负号的优先级是相同的,而且是右结合的; && 和 || 的优先级低于关系运算符和判等运算符。因此:
1 | i < j && k == m 等价于 (i < j) && (k == m) |
7.位运算符
位操作在编写系统程序 (包括编译器和操作系统)、加密程序、图形程序以及其他一些 对性能要求非常高的程序时,非常有用。但是在后端开发中,我们很少会用到。 C 语言提供了 6 个位运算符。这些运算符可以对整数数据进行位运算。首先,我们讨论两个移位运算符,然后再讨论其他 4 个按位运算符 (按位取反,按位与,按位异 或,按位或)。
7.1移位运算符
移位运算符可以通过将位向左或向右移动来变换整数的二进制表示。C 语言提供了左移运算符<< 和右移运算符 >> 。
i << j :将 i 的位左移 j 位,在 i 的右边补 0。
i >> j :将 i 的位右移 j 位。如果 i 是无符号数或者非负值,则在左边补 0。如果 i 是负值,其结果是由实现定义的:一些实现会在左边补 0,一些实现会在左边补 1。
Tips: 为了可移植性,最好仅对无符号数进行移位运算
1 | unsigned short i, j; |
从上面的例子可以看出,对无符号整数左移 j 位,相当于乘以 2^j (不发生溢出);对无符号整数右移 j 位,相当于除以 2^j。
7.2按位运算符
按位位运算符包含:按位取反,按位与,按位异或,按位或。其中按位取反是单目运 算符,其余是双目运算符。
~i
: 会对 i 的每一位进行取反操作,即 0 变成 1,1 变成 0。
i & j
: 会对 i 和 j 的每一位进行逻辑与运算。 (全1为1,其他为0)
i | j
: 会对 i 和 j 的每一位进行逻辑或运算。(有1为1,全0为0)
i ^ j
: 会对 i 和 j 的每一位进行异或运算,如果对应的位相同则为0,如果对应的位不同则结果为 1(不同意)。
Tips: 千万不要将按位运算符 & 和 | 与逻辑运算符 && 和 || 混淆。
下面的例子演示了这些运算符的作用:
1 | unsigned short i, j, k; |
其中按位异或运算有一些良好的性质 (请确认自己是否真的理解了这些性质?):
1 | a ^ 0 = a; |
除了按位取反运算符外,其余位运算符都有对应的复合赋值运算符:
1 | i = 21; |
7.3常见的面试题
请判断一个整数是否为奇数?
1
2
3
4
5
6
7
8
9//在C语言中,如果 `b` 是正数,则结果的符号与 `a` 的符号相同;如果 `a` 是负数,那么 `a % b` 的结果也会是负数
//在纯数学中,模运算的结果总是非负的,并且小于模数。
int a
a%2==1(错)
a%2!=0(对)
//用位运算来解决(二进制(补码)表示有符号整数a)
//补码公式:如下图,如果是奇数这最低有效位b02^0一定是1
return a & 0x1;
printf("issOdd(-1)=%s\n",isOdd(-1)?"true":"false");如何判断一个非0整数是否为2的幂(1, 2, 4, 8, 16, …)?
1
2
3
4
5
6
7
8// 方法一:
n>0
while((n&0x1)==0){
n>>1;
}
return n==1;
// 方法二:
return (n&n-1)==0?True:False;给定一个值不为0的整数,请找出值为1的最低有效位。
1
2
3输入:n = 24
输出:8
解释:24的二进制表示为11000,值为1 的最低有效位为2^3。1
2
3
4
5
6
7
8方法一:
int x=ox01;
while((n&x)==0){
x<<=1;
}
方法二:
补码的相反数:取反后加1
补码&补码的相反数
给定两个整数 a 和 b,请交换它们两个的值 (要求不使用中间临时变量)。
1
2
3
4
5
6
7
8一对逆运算,a=3,b=4
a=a+b;
b=a-b;
a=a-b;
另一对逆运算:^和^
a=a^b;
b=a^b;
a=a^b;给你一个 非空整数数组 nums,除了某个元素只出现一次以外,其余每个元素均出 现两次。找出那个只出现了一次的元素。
1
2输入:nums = [1,4,2,1,2]
输出:41
2
3
4
5
6
7// 相同数异或结果为0,0和一个数异或结果是本身
//0^a^b^b^c^c=a
int singleNum=0;
for (int i = 0;i<n;i++){
singleNum^=nums[i]
}
return singleNum;
给你一个整数数组 nums,其中恰好有两个元素只出现一次,其余所有元素均出现 两次。 找出只出现一次的那两个元素。你可以按任意顺序返回答案。
1
2
3输入:nums = [1,2,1,3,2,5]
输出:[3,5]
解释:[5, 3] 也是有效的答案。
1 | 分区+异或思想: |
8.表达式语句
C 语言有一条不同寻常的规则,那就是任何表达式都可以用作语句。换句话说,不论表达式是什么类型,计算什么结果,我们都可以通过在后面添加分号将其转换成语句。例如,我们可以这样将表达式 ++i 转换成语句:
1 | ++i; |
执行这条语句时,首先会计算表达式 ++i 的值,之后这个值会被丢弃,然后执行下一 条语句。
注意事项
编译链接和运行时的错误和bug:
调试程序:
第六章 数组
到目前为止,我们见过的变量都只能存储单个数据项,这样的变量称为标量 (scalar)。C 语言也支持聚合变量
,这类变量可以存储多个数据项。C 语言提供两种类型的聚合变量:数组和结构体。这章我们主要讲讲数组,结构体留到以后再讲。
1.数组的内存模型:连续的一片内存空间。并且会划分成大小相等(同一类型,随机访问元素)的小空间。
为什么同一类型可以随机访问:i_地址=base_addr+sizeof(elem_type)*i
2.为什么数组的索引一般是从0开始的?
i_addr=base_addr+sizeof(elem_type)*(i-1);每一次都多做一次加法运算。
3.刻板印象认为:数组效率>链表效率?
空间利用率:链表不仅要存数据还要存指针
空间的局部性:数组的空间局部性好(连续的)
空间局部性指的是如果某个数据项被访问了,那么与该数据项相邻或附近的其他数据项很快也会被访问。这是基于程序倾向于访问靠近已访问数据的数据项这一观察得出的。
1.一维数组
数组是含有多个数据项的数据结构,并且这些数据项都具有相同的数据类型。这些数据项称为数组的元素,我们可以根据元素在数组中的位置来选取元素。
最简单的数组就是一维数组。数组元素在内存中是依次排列的,如下图所示:
声明一个数组,我们需要指定数组元素的类型和数量。如:
1 | int a[10];//数组的长度是类型的一部分 |
数组元素可以是任何类型,数组的长度则必须是常量表达式 (能够在编译期间求值的 表达式)。因为程序以后可能需要调整数组的长度,所以一般情况下,我们会使用宏来 定义数组的长度:
1 |
|
数组索引
我们可以用数组索引来访问数组中的元素。在 C 语言中,数组索引是从 0 开始的,所 以长度为 n 的数组索引范围为 0 ~ n-1。假如 a 是含有 10 个元素的数组,那么这些元 素可以依次标记为 a[0], a[1], …, a[9],如下图所示:
数组和 for 循环是好伙伴,它们往往结伴而行。下面给出了在长度为 N 的数组上的 一些常见操作:
1 | /* clears a */ |
C 语言有一个常被人诟病的问题:不检查数组索引越界 (why?)。当数组索引越界时,程序的行为是未定义的,也就是说任何情况都可能发生。
1.1 数组的初始化
数组可以像其它变量一样进行初始化,不过数组的初始化需要一些技巧。
数组初始化式最常见的形式是,用大括号包含一组常量表达式,常量表达式之间用逗号分隔。
1 | int a[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; |
如果初始化式比数组短,那么剩余元素被初始化为 0:
1 | int a[10] = {1, 2, 3, 4, 5, 6}; |
利用这个特性,我们很容易把数组元素全部初始化为 0:
1 | int a[10] = {0}; |
注意:初始化式不能比数组长,也不能完全为空。(初始化长度,数组长度)
如果给定了初始化式,我们也可以省略数组的长度:
1 | int a[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; |
编译器会利用初始化式的长度来确定数组的长度,数组 a 的长度仍为 10。
1.2 对数组使用sizeof运算符
我们可以使用 sizeof 运算符确定数组的大小 (字节)。如果数组 a 包含 10 个整数,那 么 sizeof(a) 的值通常为 40。sizeof 运算符也可以确定数组元素的大小,两者相除即得到数组的长度:
1 | sizeof(a) / sizeof(a[0]) |
有些程序员喜欢用这个表达式来表示数组的长度。例如,清空数组我们可以写成如下 形式:
1 | for (i = 0; i < sizeof(a) / sizeof(a[0]); i++) |
这样做有个好处:即使日后数组的长度发生改变,这个 for 循环是不需要发生变化 的。为了可读性和通用性,我们往往把 sizeof(a) / sizeof(a[0]) 定义为带参数的宏:
1 |
|
2 多维数组
数组可以有任意维数。其中最常用的是一维数组,其次是二维数组,一般很少见到更高维的数组。如下,我们声明了一个二维数组 (类似数学上的矩阵)
1 | int matrix[5][9]; |
数组 matrix 有 5 行 9 列,且行和列的索引都是从 0 开始的,如下图所示:
访问 i 行 j 列的元素,需要写成 matrix[i] [j]。matrix[i] 指定了二维数组 matrix 的第 i 行,它是一个一维数组;matrix[i] [j]指定了这一行的第 j 个元素。
虽然我们经常把二维数组看成矩阵,但实际上它们在内存中是连续存储的。C 语言是 按照行主序的方式存储二维数组的,也就是先存储第 0 行,再存储第 1 行,依次类推。如下图所示:
二维数组的本质:元素是一维数组的一维数组。
就像一维数组和 for 循环是好伙伴一样,二维数组和嵌套的 for 循环是好伙伴。例 如,我们可以通过下面的方式创建一个维度为 10 的单位矩阵:
1 |
|
2.1 多维数组初始化
通过嵌套一维数组初始化式,我们可以构建二维数组的初始化式:
1 | int matrix[5][9] = {{1, 1, 1, 1, 1, 0, 1, 1, 1}, |
更高维数组的初始化式可以采取类似的方法构建。
C 语言提供了多种方法来简化多维数组的初始化。
如果初始化式的长度不够,那么剩余元素被初始化为 0。如,下面的初始化式只填 充了数组的前 3 行,后面 2 行将被初始化为 0。
1 | int matrix[5][9] = {{1, 1, 1, 1, 1, 0, 1, 1, 1}, |
如果内层初始化式不足以填满数组的一行,那么这一行剩余的元素会被初始化为0。
1 | int matrix[5][9] = {{1, 1, 1, 1, 1, 0, 1, 1, 1}, |
甚至,我们可以省略内存的大括号 (不推荐)。
编译器一旦发现值足以填满一行,它就开始填充下一行。
注意:在初始化式中省略内层大括号是非常危险的,因为不小心多写或者少写一 个值都会影响后面元素的初始化。
2.2 常量数组
无论是一维数组还是二维数组,我们都可以在声明时加上 const 修饰符而变成 “常 量”:
1 | const char hex_chars[] = |
程序在运行期间不会对数组进行修改。 const 不仅仅可以修饰数组,它可以修饰任意变量。但是 const 在数组声明中特别有用,因为数组经常包含一些不会发生改变的信息。
安全(存储静态数据,在程序运行期间不会发生改变的数据)
提升效率(比普通数组效率高)
建议:能用常量数组的就用常量数组。
如何生成伪随机数:
伪随机数:
设置随机种子:srand()
根据时间设置随机种子:
1 | //根据时间设置时间种子,往往随机种子只需要设置一次 |
第七章 函数
虽然”函数”这个术语来自于数学,但是 C 语言中的函数和数学中的函数还是有一些区 别的。在 C 语言中,函数可以没有参数,也不一定要有返回值。
函数是 C 语言的基本构建组件 (building blocks)。使用函数,我们可以把一个大程序 划分为小的组件,从而降低程序的复杂度。使用函数还可以避免编写重复的代码,从 而提高程序的可维护性。此外,函数还是可复用的,一个函数可以在多个程序中使 用,比如 C 语言函数库。
1.函数的定义和调用
1.1 函数的定义
函数定义的一般格式如下:
1 | return-type function-name (parameters) { |
返回值类型遵循以下规则:
函数不能返回数组,除此之外,函数可以返回任意类型的值。
如果函数的返回值类型为 void,则函数没有返回值。
如果省略返回值类型,C89 会假定返回值类型为 int;但在 C99 中这是不合法的。
一些程序员喜欢把返回值类型放在函数名的上边:
1 | double |
如果返回值类型很长,比如 unsigned long long int ,那么这种方法是很有用的。
函数名的后面是形式参数列表。我们需要在每个形式参数前面指定它的类型,并且参 数之间用逗号分隔。如果函数没有形式参数,那么应该标明为 void。
函数体内包含声明和语句。在函数体内声明的变量只属于此函数,其他函数不能访问 和修改这些变量。
1.2 函数的调用
函数调用由函数名和实际参数 (argument) 列表组成。如:average(x, y) 。
非 void 函数 (返回值类型不为 void 的函数) 调用会产生一个值,该值可以存储在变量 中,用于测试、显示,或者是用于其他用途:
1 | avg = average(x, y); |
如果不需要函数的返回值,我们还可以将其丢弃:
1 | average(x, y); |
返回值类型为 void 的函数,如:
1 | void print_pun(void) { |
void 函数调用必须紧跟分号,如:
1 | print_pun(); |
2.函数声明
以前,我们总是在函数调用之前定义函数。如果把函数定义放在函数调用之后,会发 生什么情况呢?
1 |
|
当编译器在 数的信息。此时编译器会为 main 函数中遇到 average 函数调用时,它没有任何有关 average 函 average 函数创建一个隐式声明:编译器会假定函数的 返回值类型为 int。由于不知道 average 的形参个数以及形参类型,编译器只能进行 根据实际参数的类型和个数进行推断,并期待最好的情况会发生。当编译器后面遇到 average 函数的定义时,发现函数的返回值类型是 double 而不是 int,从而会给出 一条错误信息。
为了避免这样的问题,一种方法是使每个函数的定义都出现在其调用之前。但是这种 方法不总是可行的 (why?),而且会使程序难以阅读。
幸运的是,C 语言提供了一种更好的解决方法:在调用之前声明函数。函数声明 (function declaration) 使得编译器能够知道函数的概要信息。函数声明类似函数定义 的第一行,其格式如下:
1 | return-type function-name ( parameters ); |
不用多说,函数的声明必须和函数的定义一致。 顺便说明一下,函数声明中可以省略形参的名字,只要给定形参的类型即可。如:
1 | double average(double, double); |
3.实际参数
我们首先复习一下形式参数和实际参数的区别。形式参数出现在函数定义中,它们表 示在函数调用时应该提供哪些值;而实际参数出现在函数调用中。
在 C 语言中实参是值传递 (passed by value) 的。调用函数时,计算每个实参的值并 把它赋值给相应的形参。在函数调用过程中,对形参的改变不会影响到实参的值,这 是因为形参中保留的其实是实参的副本。
值传递有利也有弊。因为形参的改变不会影响到实参,所以我们可以把形参当作变量 来使用,从而减少所需变量的数量。考虑下面这个函数:
1 | int power(int x, int n) { |
因为 n 只是原始指数的副本,因此我们可以在函数体内修改它的值,从而就不需要变 量 i 了。
1 | int power(int x, int n) { |
但是值传递也会使得某些函数变得难以编写。如:
1 | void swap(int a, int b) { |
假如我们像这样调用 swap 函数:
1 | swap(a, b); |
那么变量 a 和变量 b 的值是不会发生修改的(why? And how to solve this problem?)。
3.1 数组作为参数
我们经常把数组当作参数。当形式参数为一维数组时,我们可以不指定它的长度:
1 | int f(int a[]) { |
数组作为参数传递时,会退化成指向数组第一个元素的指针。
实参可以是元素类型匹配的任意一维数组。不过这里有一个问题:函数如何知道数组 的长度呢?可惜的是,C 语言没有提供任何简便的方法供函数确定数组的长度。如果 需要,我们必须额外提供一个长度参数。
虽然我们可以使用 sizeof 运算符计算数组的长度,但在这种情况下不适用。(why?)
下面的函数说明了数组参数的常用用法:
1 | int sum_array(int a[], int n) { |
函数调用时,第一个参数是数组名,第二个参数是数组的长度。如:
1 |
|
正如前面所说,函数是无法确认数组的实际长度的。因此,我们可以传入一个比实际 长度小的值,比如:
1 | total = sum_array(b, 50); |
这样我们就只会对数组的前 50 个元素求和。
注意:我们不能传入一个比数组长度更大的值,这会导致数组索引越界,从而发生未 定义的行为。
关于数组参数另一个值得注意的点是:函数通过传入的数组,可以改变数组的元素。 如:
1 | void clear(int a[], int n) { |
如果我们这样调用函数:
1 | clear(b, 100); |
数组 b 的前 100 个元素将会清零。这似乎与 C 语言中参数的值传递相矛盾,但事实并 不矛盾 (这个问题我们留到学习指针时再解释)。
如果形式参数是多维数组,声明参数时我们只能省略第一个维度的长度 (why?)。例 如,如果 sum_array 函数的第一个参数是二维数组,我们可以不指定行的数量,但 是必须指定列的数量:
1 |
|
4.局部变量和外部变量
我们把在函数体内声明的变量称为该函数的局部变量。比如下面函数中的 sum 是局部 变量:
1 | int sum_digits(int n) { |
默认情况下,局部变量具有下列性质:
自动存储期限。简单来讲,存储期限就是变量在程序运行过程中存在的时间长度。 局部变量的存储单元在函数调用时”自动”分配,在函数返回时自动回收,所以称这 种变量具有自动存储期限。
块作用域。变量的作用域就是:在程序文本中可以引用该变量的部分。局部变量拥 有块作用域 :从变量声明的点开始一直到所在块的末尾。 块:简单来说,就是用 {} 括起来的文本区域。
4.1 静态局部变量
在局部变量声明中使用 static 关键字,可以使局部变量具有静态存储期限。具有静态 存储期限的变量拥有永久的存储单元,所以在整个程序执行期间都会保留变量的值。 比如:
1
2
3
4void foo(void) {
static int i = 0;
printf("%p: %d\n", &i, i++);
}静态局部变量始终具有块作用域,所以它对其他函数是不可见的。
静态局部变量可以保存上一次函数调用时的状态,在某些应用中非常有用。比如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// 0, 1, 1, 2, 3, 5, 8, 13, 21...
long long next_fib(void) {
static long long a = 0;
static long long b = 1;
long long tmp = a;
a = b;
b = a + tmp;
return a;
}
int main(void) {
printf("%lld\n", next_fib()); // 1
printf("%lld\n", next_fib()); // 1
printf("%lld\n", next_fib()); // 2
printf("%lld\n", next_fib()); // 3
return 0;
}4.2 外部变量
外部变量(全局变量)就是声明在任何函数体外的变量,外部变量的性质不同于局部变 量:
静态存储期限。具有静态存储期限的变量拥有永久的存储单元,所以在整个程序执 行期间都会保留变量的值。
文件作用域。从变量声明开始,一直到文件的末尾。因此,在外部变量声明之后的 函数都可以访问(并修改)它。
在多个函数必须共享一个变量时,外部变量是很有用的。然而,在大多数情况下,函 数之间通过形式参数进行通信会比共享外部变量更好。 使用外部变量不利于程序的排 错和维护。
5 return语句
非 void 函数必须使用 return 语句来指定函数要返回的值。return 语句的格式如下:
1 | return expr; |
如果 return 语句中表达式的类型和函数的返回值类型不匹配,那么系统将会把表达式 的类型隐式转换为返回值类型。例如,如果函数的返回值类型为 int,但是 return 语 句中表达式的类型为 double,那么系统会把表达式的值转换成 int 类型。
void 函数也可以使用 return 语句,使函数立刻返回,只是 return 后面不能接表达 式。如:
1 | void print_positive(int n) { |
6.程序终止
main 函数的返回值类型为 int,它表示程序返回时的状态码。如果程序正常终止, main 函数应该返回 0;如果程序异常终止,那么 main 函数应该返回非 0 的值。
exit函数
在 main 函数中执行 return 语句是终止程序的一种方法,另一种方法是调用 函数。 exit 函数包含在 头文件中。传递给 exit 函数的参数和 exit main 函 数的返回值具有相同的含义:两种都表示程序终止时的状态。
正常结束时,我们可以这样写:
1 | exit(); |
因为 0 是一个魔法数字,所以 C 语言允许使用EXIT_SUCCESS 来替代:
1 | exit(EXIT_SUCCESS); |
如果异常退出,可以这样写:
1 | exit(EXIT_FAILURE); |
EXIT_SUCCESS 和 EXIT_FAILURE 都是定义在 中的宏,它们的值是由实 现决定的,通常分别为 0 和 1。
return 语句和 exit 函数之间的差异是:不管哪个函数调用 exit 函数都会导致程 序终止, return 语句仅当在 main 函数中执行时才会导致程序的终止。
Tips: 一些程序员仅使用 exit 函数终止程序,这样做的好处是方便以后定位程序的全 部退出点。
7.递归
如果一个函数调用它本身,那么这个函数就是递归的。
从递归的定义去理解递归不是很好,我们可以从名字入手去理解。
1.递:把大问题分解成若干个子问题,子问题的求解方式和大问题一致,只是问 题规模不一致.
2.归:把子问题的解合并成大问题的解。
例子1:电影院的例子。
例子2:Fibonacci 数列。
我们可以利用上面的公式,求解 Fibonacci 数列的第 n 项。
1 | // 0, 1, 1, 2 |
但是这种求解方式的效率很低,会存在大量重复的计算。如下图所示:
so,如何避免重复计算问题呢?
答案是动态规划。顺序求解子问题,并将子问题的解保存起来,从而避免重复计算, 最终求解到大问题。
但是对于求解 Fibnacci 数列来说,我们并不需要保存前面所有项的值,我们只需要保 存最近两项即可。
1 | long long fib(int n) { |
例子3:汉诺塔。
有三根杆子A,B,C。A杆上有 N 个 (N>1) 穿孔圆盘,盘的尺寸由下到上依次变小。 要求按下列规则将所有圆盘移至 C 杆:
1. 每次只能移动一个圆盘;
2. 大盘不能叠在小盘上面.
提示:可将圆盘临时置于 B 杆,也可将从 A 杆移出的圆盘重新移回 A 杆,但都必须遵 循上述两条规则
问:最少需要移动多少次?如何移?
1 | (1) |
例子4:约瑟夫环问题
约瑟夫环是一个数学的应用问题:已知 n 个人 (以编号1,2,3, …, n 分别表示) 围 坐在一张圆桌周围。从编号为 1 的人开始,每两个人出列一个人,直至只剩一个人。 问:最终剩下的这个人的编号是多少?
总结:递归三问
Q1:什么情况下可以考虑使用递归?
答:问题具有递归结构。(1)也就是说,大问题可以分解成若干个子问题,子问题的 求解方式和大问题一致,只是问题规模不一致。(2)子问题的解可以合并成大问题的 解。
Q2: 到底要不要使用递归?
答:如果不存在重复计算问题,且递归的层次不是很深时,就可以使用递归。
Q3: 如何写递归?
答:两步走。(1) 边界条件 (2) 递归公式
第八章 指针
指针是 C 语言最重要——也是最常被误解——的特性之一。由于指针的重要性,我们 将用3个章节对其进行讨论:指针的基础、指针与数组的关系以及指针的高级应用。
1 指针变量
大多数现代计算机都将内存分割为字节,每个字节都有唯一的地址。程序中的每一个 变量占一个或多个字节的内存,我们把第一个字节的地址称为是变量的地址。
这就是指针的由来。我们可以认为指针就是地址,而指针变量就是存储地址的变量。 注意:有时候我们也把指针变量称作为指针。
当指针变量 p 存储变量 i 的地址时,我们就说 p 指向了 i。
1.1 指针变量的声明
指针变量的声明与普通变量的声明基本一样,唯一不同的就是必须在指针变量名的前 面加上星号:
1 | int *p; |
注意事项:1. 指针变量名为 p 而非 p;2. 指针变量的类型为 int,而非 int 类型。
指针变量可以和其它变量一起出现在声明中:
1 | int i, j, a[10], b[20], *p, *q; |
2 &和*
为了使用指针,C 语言提供了一对特殊的运算符。为了获取变量的地址,C 语言设计 了 & 的运算符;为了访问指针变量所指向的对象,C 语言设计了 运算符。如果 p 是 指针,那么 p 就表示 p 当前指向的对象。
取地址运算符 &
& 运算符可以获取变量的地址,我们可以把这个地址赋值给指针变量:
1 | int i, *p; |
解引用运算符 *
一旦指针变量指向了对象,就可以使用 * 运算符访问被指向的对象。例如,如果 p 指 向了 i,那么下面的语句将显示 i 的值:
1 | printf("%d\n", *p); |
只要 p 指向了 i,那么 p 就是 i 的别名。p 不仅拥有和 i 相同的值,而且对 *p 的改 变也会改变 i 的值。
如果你习惯于数学思维,那么可以把 & 和 * 看作是一对逆运算
警惕野指针问题
如果指针变量 p 未被初始化或者指向了一个未知的区域,这样的指针我们称为野指 针。试图对野指针使用解引用运算符会导致未定义的行为:
1 | int *p; |
3 指针赋值
我们有两种方式对指针变量进行赋值。一是把变量的地址赋值给指针变量:
1 | int i, j, *p, *q; |
二是通过另一个指针变量进行赋值:
1 | q = p; |
请注意 q = p 和 q = p 的区别!
4 指针作为参数
C 语言函数调用时,是进行值传递的。所以在函数调用中,我们无法改变实参的值。 如:
1 | int main(void) { |
指针提供了此问题的解决方法:不再传递 a 和 b 作为函数的实际参数,而是传递 &a 和 &b。相应地我们也会把形式参数声明为对应的指针类型。调用函数时,p 和 q 分 别为变量 a 和 b 的别名,因此可以通过 p 和 q 改变变量 a 和 b 的值。
1 | void swap(int* p, int* q) { |
指针作为参数实际上并不新鲜,我们在 scanf 函数中早就用过了。
1 | int i; |
虽然 scanf 函数格式串之后的参数必须是指针,但并不是说我们必须使用 & 运算符。
1 | int i, *p; |
5 指针作为返回值
我们不仅可以为函数传递指针,还可以编写返回指针的函数。如:
1 | int* find_middle(int a[], int n) { |
注意:永远不要返回指向当前栈帧区域的指针:
1 | /*** WRONG ***/ |
阶段二:Linux系统编程
(30天)
Shell命令:4天,编写编译程序,debug
文件: 目录(递归地处理目录)(递归复制,删除,打印目录)
普通文件:(对文件描述符的操作)
进程: 基本操作(创建,终止进程,执行程序)
进程间的通信(管道,信号)
线程: 基本操作(创建,终止,等待,游离,join,清理线程)
同步(互斥地访问资源,等待某个条件成立)
模型:生产者消费者模型
线程池
网络:降低复杂度
Socket套接字(传输层提供给应用层的操作)
I/O多路复用模型(提高服务器的效率)(select,poll,epoll
)
代码仓库:https://gitee.com/cplusplus2023/cpp58th
代码在线编译平台:https://godbolt.org/