产品经理的需求
先看下面这样一段代码:
#include<stdio.h>
int main() {
int a, b;
scanf("%d %d", &a, &b);
printf("%d+%d=%d\n", a, b, a+b);
return 0;
}
稍有C语言基础的老铁,应该都能看明白:输入两个整数,然后求和并打印。在实现上:
- 我们先定义了两个
int
类型的变量 a 和 b 。 - 接着,借助库函数
scanf
读入数据并存储在 a 和 b 中。 - 最后,计算两者之和,并按照指定格式打印。
过了几天,产品经理说要调整需求:要求三个整数的累加和。机智如我,立马给出解决方案:添加一个 int
类型的变量 c
不就得了。于是,上述代码被修改为:
#include<stdio.h>
int main() {
int a, b, c;
scanf("%d %d %d", &a, &b, &c);
printf("%d+%d+%d=%d\n", a, b, c, a+b+c);
return 0;
}
又过了几天,产品经理又来了:要计算一百个整数的累加和。~~暴躁如我,立马爆出国粹WQNMLGB。~~勤奋如我,立马定义了一百个int类型的变量 a0,a1,...,a99
。于是,上述代码被修改为:
#include<stdio.h>
int main() {
int a0, a1, ..., a99; // 太长了,上伪代码吧Orz
scanf("%d %d ... %d", &a0, &a1, ..., &a99);
printf("%d+%d+...+%d=%d\n", a0, a1, ..., a99, a0+a1+...+a99);
return 0;
}
虽然代码很丑陋,不过还是实现了功能。不管了,先下班回家陪女朋友刷剧了。
又过了几天,产品经理又来了:要计算 n
个整数的累加和,n
不超过十万,且不需要用户在输入数据前输入 n
。这直接给我整不会了:
- 首先,输入整数的数量上限是十万,总不能定义十万个变量吧?
- 其次,输入的数量不固定了,那如何控制输入输出的格式呢?
- 最后,用户不告诉我
n
,我咋知道用户输没输完呢?
好学如我,遍查课本发现了数组,可将上述需求轻松解决。
什么是数组
类似于 int
,double
,float
这些变量类型,可以认为数组也是一种变量类型。只不过这种变量类型更高级,需要和其他变量类型组合使用。
int integer;
int integer_array[100000];
上述代码做了两件事情:
- 定义了一个int类型的变量
- 定义了一个可存放十万个 int 类型数据的数组类型变量。
与 int
类型变量不同的是,每个 int
类型变量仅能存储一个整数,而数组类型的变量 integer_array
可以存储十万个整数。
不难发现,在定义数组类型的变量时,程序员要在代码中指明两个要素:
- 长度:该数组类型的变量能存储多少份数据。
- 类型:该数组类型的变量要存储什么类型的数据。
如下图示例,在 C/C++ 中,一行定义数组类型变量的代码,可以拆解为三部分:类型,变量名,长度。
通过不同类型和长度的组合,可以组装出各种各样的数组,如下所示:
double double_array[1000]; // 可存放一千个 double 类型数据的数组。
int int_array[1]; // 可存放一个 int 类型数据的数组。
综上所述,数组也是一种类型,在定义数组类型变量时,必须指明长度和类型两个要素。一行形如 data_type variable_name[capacity];
的代码,定义了一个名为variable_name
的,可存放 capacity
个类型为 data_type
的元素的数组类型变量。
如何使用数组
本部分主要介绍数组的初始化和读写操作,以及动态分配数组。
初始化
与 int
,double
等基本类型的变量一样,数组类型的变量也可在定义时进行初始化。从语法上来讲,有以下几种初始化方式:
int array[5] = {}
;此时array
中的所有元素都被初始化为零值。int array[5] = {1,2,3,4,5};
为每个元素都指定初始值。int array[5] = {1,2};
为部分元素指定初始值,其他元素会被初始化为零值。int array[] = {1,2,3,4,5};
隐式的指定长度,长度与初始化列表的长度相同。
如果在定义时,未指定初始化列表会怎样呢?这取决 array
的类型以及在内存中的位置。在讨论之前先明确一个概念——内置类型。C 的内置类型包括:
- 字符(
char
,unsigned char
) - 整数(
short
,int
,long
,以及对应的无符号类型等) - 浮点数(
float
,double
,long double
,以及对应的无符号类型等)
在 C++ 中,内置类型还多了布尔(bool
)。
接下来,先讨论类型对初始值的影响,如下述代码所示:
struct Node {
int val;
Node() : val(-1) {}
};
Node array[100];
我们定义了一个数据类型为 Node
的数组。对于这种非内置类型的数组,其初始值该类型的构造函数决定。
如果 array
中的数据为内置类型,则要根据 array
在内存中位置,来确定初始值。
- 如果位于静态变量区,则全部元素被初始化为零值。比如由
static
修饰的变量均位于静态变量区。 - 位于其他区域时,值未定义。
读写
在一个长度为 n
的数组中,每个元素都会被独占一个下标。这 n
个下标分别为 0,1,2,...,n-1
。
在定义数组时,我们通过 []
来指定数组的长度。在访问数组时,我们也通过 []
来告诉编译器待访问元素的下标,此时,[]
被称为下标运算符。下面是一个读写指定下标数据的样例。
int array[10] = {}; // 定义一个数组,全部初始为零值。
printf("%d\n", array[5]); // 访问下标为 5 的元素,输出 0。
array[2] = 10; // 更新下标为 2 的元素
printf("%d\n", array[2]); // 输出 10。
值得一提的是,对于一个长度为 n
的数组,在读写时,下标应位于 [0, n-1]
之内。否则程序可能会抛出异常 Segmentation fault
。反向思考,当你看到这个错误时,也要意识到有可能是下标越界啦。
运行时指定数组的长度
在 C/C++ 借助指针这一概念,以及 new
和 delete
两个运算符,可以实现在运行时指定数组的长度。
#include <stdio.h>
int main() {
unsigned int n;
scanf("%u", &n);
int *ptr = new int[n];
/*
* do something
*/
delete[] ptr;
return 0;
}
完成产品经理的需求
好啦,我们现在学会了数组的一些基本知识。接下来,一起来解决产品经理的需求。回忆一下产品经理的需求:要计算 n
个整数的累加和,n
不超过十万,且不需要用户输入 n
。
在动手之前,还需要学习一个知识—— scanf
的返回值。scanf
声明如下:
int scanf ( const char * format, ... );
返回值分为两种情况:
- 当函数成功执行结束时,返回值为读入变量的数量。比如:
scanf("%d", &a);
返回 1scanf("%d %d", &a, &b);
返回 2
- 当函数发生错误,或到达文件末尾时,返回
EOF
(一个宏定义,一般值为 -1)。到达文件末尾,可由两种方式触发(可能还有其他的?欢迎补充):- 程序从磁盘文件读入,在调用该次 scanf 之前已经读入了文件的所有数据,则该次调用返回
EOF
。 - 用户按下了特定的组合键,通知进程读入结束了(mac 是 ctrl+d, windows 是 ctrl+c)。
- 程序从磁盘文件读入,在调用该次 scanf 之前已经读入了文件的所有数据,则该次调用返回
因此,我们每次调用 scanf
都只读取一个整数,如果 scanf
返回了 -1,则说明已经读入了所有数据。想明白这些之后,就有了下述代码:
#include <stdio.h>
int data[100000]; // 定义一个数组
int main() {
int n = 0; // 计数器,记录读取数字的个数,初始为 0。
while (scanf("%d", &data[n]) != EOF) { //读取整数,并将其存放在下标 n 处。
n = n+1; // 下标移动到下个位置。
}
int sum = 0; // 读入结束啦,统计累加和。
for (int i = 0; i < n; i++) {
sum += data[i];
printf("%d%c", data[i], ((i+1 < n) ?'+': '=')); // 按格式输出
}
printf("%d\n", sum); // 输出累加和。
return 0;
}
数组可以有多个维度
using IntArray10w = int[100000];
IntArray10w data;
在上述代码中,我们通过 using
关键字,定义了一个新类型 IntArray10w
—— 可存放十万个 int
的数组类型。
因此,IntArray10w data;
和 int data[100000];
这两行代码可以认为是完全等价的。更为惊喜的是:
IntArray10w two_dim_array[10];
我们定义了一个可存储十个 IntArray10w
的数组。这等价于 int two_dim_array[10][100000];
。为了区分,我们为数组加上维度的概念,比如:
int a[10];
是一维数组。int b[10][5]
是二维数组。
更高级的是,C/C++ 并没有对维度进行限制,你可以定义出任意维度的数据,只要你的内存放的下。
int d1[10]; // 一维数组
int d2[2][3] = {{1,2,3},{4,5,6}}; // 二维数组
int d5[10][2][30][4][50] = {}; // 五维数组。
高维数组在一些动态规划类的题目中,经常会用到。这个后面有机会我们再一起讨论。
数组在内存中的存储方式
在 C/C++ 中,内存的最小分配单位为字节(byte, 八个 bit)。简单来讲,存储一个数组所需的字节数,取决于数组的长度以及单个元素所需字节数。
比如,int32_t data[8];
,长度为 8, 类型为 int32_t
,一个 int32_t
需占 4 字节。因此存储 data
需要 8*4=32 个字节。
在同一个数组中,下标相邻的两个元素,在内存中也存放在相邻位置。通过下述代码验证一下:
#include <stdio.h>
int main() {
int32_t data[8] = {};
printf("data's addr: %x(%u)\n", &data, &data);
for (int i = 0; i < 8; i++) {
printf("i: %d, addr: %x(%u)\n", i, &data[i], &data[i]);
}
return 0;
}
输出如下:
data's addr: e22b8990(3794504080)
i: 0, addr: e22b8990(3794504080)
i: 1, addr: e22b8994(3794504084)
i: 2, addr: e22b8998(3794504088)
i: 3, addr: e22b899c(3794504092)
i: 4, addr: e22b89a0(3794504096)
i: 5, addr: e22b89a4(3794504100)
i: 6, addr: e22b89a8(3794504104)
i: 7, addr: e22b89ac(3794504108)
值得一提的是,首地址即data的地址每次执行时均可能发生变化(原因我们后续再讨论),但是相邻元素的地址的偏移量是不会变化的。偏移量为 4, 即存储一个 int32_t
所需的字节数量。
因此,操作指定下标的元素,仅需三步:
- 计算偏移量:下标 × 单个元素所占字节数
- 计算内存地址:首地址 + 偏移量
- 操作内存地址的数据
不难发现,仅需一次乘法和一次加法即可找到目标元素在内存中的位置。
另外,图中黑色部分所示的内存,可能已经存储了其他数据,这使得数组无法扩大自己的长度。
小结
- 在定义数组时,需要指明长度(隐式的或显示的),存储数据的类型。
- 数组可以是多维的。
- 优点:通过下标运算符[]可以极其方便快速的读写任意位置的数据。
- 缺点:长度不可修改。
做题技巧
仅有「数组」这一个知识点的题目很少,大都是一些旋转类的题目。因为数组本身很简单,玩不出什么花。但数组是很多知识点的基石,如「递推」,「二分」,「排序」,「队列」,「栈」,「图」等,都需要用到数组。
这里先总结数组本身的一些技巧,其他的知识点,后续有机会再作讨论。
数组本身的考点以翻转,旋转,螺旋走位等几种较为常见。下面听我一一道来。
一维数组
给出一个长度为 n
的数组,将其反转。
其实这种题目一点也不难想。我们可以动手画一画,就不难发现:
- 第一个位置和倒数第一个位置交换了数据。
- 第二个位置和倒数第二个位置交换了数据。
- 依次类推…
更严格的描述:设下标从 0
开始,则第 i
个位置和第 n-i-1
个位置交换了数据。于是,我们可以「枚举前
⌊
n
2
⌋
\lfloor \frac{n}{2} \rfloor
⌊2n⌋ 个下标」并「交换
⌊
n
2
⌋
\lfloor \frac{n}{2} \rfloor
⌊2n⌋对数据」来完成一次反转。代码如下。
void reverse(int *arr, int n) {
// 每执行一次循环体,会翻转两个元素,因此只枚举前一半下标即可。
for (int i = n/2-1; i >= 0; i--) {
swap(arr[i], arr[n-i-1]); // 两个轴对称的下标相加为 n-1
}
}
二维数组
二维数组的花样比较多一点了,包含:
- 翻转:
- 水平翻转
- 竖直翻转
- 对角线翻转
- 旋转:
- 逆时针旋转
- 顺时针旋转
翻转
二维数组的水平翻转或者竖直翻转,都可以拆解为多个一维数组的翻转,这里不再赘述。这里主要说下对角线翻转。
一般来说,只有行数和列数相同的二维数组才能进行对角线的镜像翻转。这个比较好理解:可以拿一张长方形的纸,按对角线折叠一下,会发现越界啦~
对角线分为两种:左对角和右对角线。
先来看左对角线翻转。
还是通过画图找规律:
- 第一行移到了第一列,第一列移到了第一行。
- 第二行移到了第二列,第二列移到了第二行。
- 依次类推。
更严格的描述:在上三角中的每个元素 ( x , y ) (x,y) (x,y) 都与 ( y , x ) (y,x) (y,x) 进行了交换。 因此,我们可以遍历上三角中的所有元素 ( x , y ) (x,y) (x,y),并交换 ( x , y ) (x,y) (x,y) 与 ( y , x ) (y,x) (y,x)。代码如下:
void reverse(int **arr, int n) {
for (int i = 0; i < n; i++) {
for (int j = i+1; j < n; j++) {
swap(arr[i][j], arr[j][i]);
}
}
}
右对角线的情况大同小异,老铁可以自己推导下,这里值给出代码。
void reverse(int **arr, int n) {
for (int i = 0; i < n; i++) {
for (int j = n-i-2; j >= 0; j++) {
swap(arr[i][j], arr[n-j-1][n-i-1]);
}
}
}
旋转
旋转分为顺时针和逆时针,两者大同小异,只要找到下标之间的关系,问题便迎刃而解。
观察下图:
- 每种颜色的格子均有四个。
- 每种颜色的格子有且仅有一个在左上角区域内。
基于上述结论,「旋转 90°」 可以拆解为「交换四个颜色相同的格子」。那么剩下的问题是如何确认「同颜色四个格子的位置关系」呢?
设图中有编号的四个格子分别为 C 1 C_1 C1, C 2 C_2 C2, C 3 C_3 C3, C 4 C_4 C4。
先来观察 C 1 C_1 C1 和 C 3 C_3 C3 旋转前的位置:
- 「 C 1 C_1 C1 到第一行的距离」和「 C 3 C_3 C3 到最后一行的距离」相同
- 「 C 1 C_1 C1 到第一列的距离」和「 C 3 C_3 C3 到最后一列的距离」相同
设 C 1 C_1 C1 的坐标为 ( x , y ) (x,y) (x,y),则 C 3 C_3 C3 的坐标为 ( n − 1 − x , n − 1 − y ) (n-1-x,n-1-y) (n−1−x,n−1−y)。 n n n 为矩阵的行数,下标从零开始。
继续观察 C 1 C_1 C1 和 C 2 C_2 C2 旋转前的位置:
- 「 C 1 C_1 C1 到第一行的距离」和「 C 2 C_2 C2 到最后一列的距离」相同
- 「 C 1 C_1 C1 到第一列的距离」和「 C 3 C_3 C3 到第一行的距离」相同
则 C 2 C_2 C2 的坐标为 ( y , n − 1 − x ) (y, n-1-x) (y,n−1−x)。
同理,可以推算出 C 4 C_4 C4 的坐标为 ( n − 1 − y , x ) (n-1-y,x) (n−1−y,x)
总结一下:
- 设 C 1 C_1 C1 的坐标为 ( x , y ) (x,y) (x,y)
- 则 C 2 C_2 C2 的坐标为 ( y , n − 1 − x ) (y, n-1-x) (y,n−1−x)
- 则 C 3 C_3 C3 的坐标为 ( n − 1 − x , n − y − 1 ) (n-1-x, n-y-1) (n−1−x,n−y−1)
- 则 C 4 C_4 C4 的坐标为 ( n − y − 1 , x ) (n-y-1, x) (n−y−1,x)
再提炼一下:
- 「纵坐标」变「横坐标」
- 「 n − 1 n-1 n−1减去横坐标」变「纵坐标」
经过上述操作,即可从「当前点的坐标」计算出「下一个点的坐标」。
需要注意的是,当
n
n
n 是奇数时,左上角的区域若有不同。如下图所示,多了中间列的上半部分。
这样,我们「枚举左上角区域的坐标
(
i
,
j
)
(i,j)
(i,j)」,然后用「四次赋值」即可实现顺时针旋转九十度。代码如下:
void rotate(int **arr, int n) {
int col = (n-1)/2;
int row = n/2-1;
for (int i = 0; i <= row; i++) {
for (int j = 0; j <= col; j++) {
int tmp = arr[i][j];
arr[i][j] = arr[n-j-1][i];
arr[n-j-1][i] = arr[n-i-1][n-j-1];
arr[n-i-1][n-j-1] = arr[j][n-i-1];
arr[j][n-i-1] = tmp;
}
}
return arr;
}
那么逆时针如何旋转呢?调整一下赋值语句的顺序就好啦~
最后
好了朋友们,链表就先讲到这里啦,希望对大家有帮助。有不足或者错误的地方,欢迎大家指出。