数组入门指北

产品经理的需求

先看下面这样一段代码:

#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,我咋知道用户输没输完呢?

好学如我,遍查课本发现了数组,可将上述需求轻松解决。

什么是数组

类似于 intdoublefloat 这些变量类型,可以认为数组也是一种变量类型。只不过这种变量类型更高级,需要和其他变量类型组合使用。

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 的元素的数组类型变量。

如何使用数组

本部分主要介绍数组的初始化和读写操作,以及动态分配数组。

初始化

intdouble 等基本类型的变量一样,数组类型的变量也可在定义时进行初始化。从语法上来讲,有以下几种初始化方式:

  • 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 的内置类型包括:

  • 字符(charunsigned char)
  • 整数(shortintlong,以及对应的无符号类型等)
  • 浮点数(floatdoublelong 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++ 借助指针这一概念,以及 newdelete 两个运算符,可以实现在运行时指定数组的长度。

#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); 返回 1
    • scanf("%d %d", &a, &b); 返回 2
  • 当函数发生错误,或到达文件末尾时,返回 EOF (一个宏定义,一般值为 -1)。到达文件末尾,可由两种方式触发(可能还有其他的?欢迎补充):
    • 程序从磁盘文件读入,在调用该次 scanf 之前已经读入了文件的所有数据,则该次调用返回 EOF
    • 用户按下了特定的组合键,通知进程读入结束了(mac 是 ctrl+d, windows 是 ctrl+c)。

因此,我们每次调用 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) (n1x,n1y) 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,n1x)

同理,可以推算出 C 4 C_4 C4 的坐标为 ( n − 1 − y , x ) (n-1-y,x) (n1y,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,n1x)
  • C 3 C_3 C3 的坐标为 ( n − 1 − x , n − y − 1 ) (n-1-x, n-y-1) (n1x,ny1)
  • C 4 C_4 C4 的坐标为 ( n − y − 1 , x ) (n-y-1, x) (ny1,x)

再提炼一下:

  • 「纵坐标」变「横坐标」
  • n − 1 n-1 n1减去横坐标」变「纵坐标」

经过上述操作,即可从「当前点的坐标」计算出「下一个点的坐标」。

需要注意的是,当 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;
}

那么逆时针如何旋转呢?调整一下赋值语句的顺序就好啦~

最后

好了朋友们,链表就先讲到这里啦,希望对大家有帮助。有不足或者错误的地方,欢迎大家指出。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值