C++基础教程
——
C语言部分卷2数组
节1数组

最新版本V2.0
王道C++团队
COPYRIGHT ⓒ 2021-2024. 王道版权所有

概述

Gn!

到目前为止,我们所见到的变量都是具有保存单一数据能力的标量,相对的,C语言中还提供了保存组合数据能力的聚合数据类型

聚合数据类型主要包括:

  1. 数组(array)

  2. 结构体(structure)

在本章节,我们将重点探讨数组,尤其是一维数组。至于结构体,我们会在接下来的课程中进行详细学习。

数组的概念(定义)

Gn!

在C语言中,数组是一种数据结构,它占据一片连续的内存区域,有序地存储多个具有相同数据类型元素(element)。数组中的每个元素都可以通过索引(index,也叫下标)来访问。

这个定义就描述了数组的核心特性:

  1. 聚合数据类型:数组能够存储多个元素,它是一种聚合数据类型。

  2. 数据类型一致性:数组中的所有元素必须具有相同的数据类型。

  3. 有序存储

    1. 每个元素在数组中都有一个唯一的编号,这个编号从0开始,最大值为数组长度减1。

    2. 这个编号通常称为数组的“下标”或“索引”。

除此之外,还有以下一些需要注意的地方:

  1. 数组是一种特殊的聚合变量,它可以存储多个同类型的元素,而这些元素可以通过索引(或下标)进行访问。

  2. 数组本身是一个变量,而数组中的每个元素又都像一个普通的变量,拥有一个值和一个地址。

  3. 数组可以存储基本数据类型,如int、float等,或再存储数组、结构体等这样的复合数据类型。但重要的是,一个给定的数组只能存储一种特定类型的元素。这个元素的类型决定了数组的类型,例如,一个int类型的数组只能存储int类型的元素

数组的数据结构及优缺点

Gn!

在C语言代码中使用数组是很简单的,无非就是一些固定语法。

在讲解这些语法之前,我们先来探究一下数组的数据结构以及数组的优缺点问题,这对理解数组的使用非常有帮助。

线性表和数组

Gn!

为了帮助那些未曾深入研究《数据结构》的同学,我们首先要明确数据结构中的两个核心概念:

  1. 逻辑结构:描述数据元素之间的逻辑关系。常见的如:

    1. 线性表:其中元素之间存在一对一的关系。

    2. 树型结构:一个层次结构的节点集合,其中n个节点(n≥0)有明确的上下级(父子)关系。

  2. 物理结构:描述数据元素如何在存储介质上物理存放。例如:

    1. 数组:线性表的一种常见物理实现,要求元素在内存中连续存储。

    2. 链表:线性表另一种常见物理实现,元素在内存中可以不连续,但每个元素指向下一个,形成链式结构。

有了上述背景知识后,我们来看数组。

数组需要一块连续的内存空间来存储元素,如果我们想象一个int类型的数组的内存模型,它会是一系列连续的内存块,每个块都存储一个整数:

数组的内存模型图

数组是一种在内存中需要一片连续空间,连续存储数据的数据结构。

随机访问(优点)

Gn!

了解数组的随机访问特性首先需要明白“随机访问”这一概念:

随机访问:指的是能够直接、立即访问序列中的任何一个元素,而不需要先访问其他元素。换句话说,访问第n个元素不依赖于序列中的其他元素。

非随机访问:相反,它意味着为了访问第n个元素,必须首先遍历前面的n-1个元素。

很明显,随机访问的效率要比非随机访问高很多,从时间复杂度(时间复杂度是指执行算法所需要的计算工作量)上来说:

  1. 随机访问是常数级别的访问效率,即O(1)

  2. 非随机访问的访问效率是O(n)

数组的核心优势就是其随机访问能力,它允许我们通过索引直接、迅速地访问数组中的任意元素。

数组是如何实现随机访问的呢?(重点)

首先,数组实现随机访问的关键在于其数据结构的连续内存分配以及固定的元素大小

  1. 连续内存分配:当声明一个数组时,例如"int arr[10];",运行时操作系统为该数组分配10个连续存储的int空间。因此,你有一块连续的内存,每个int元素都紧密地排列在一起。

  2. 固定元素大小:数组中的每个元素都具有相同的大小。例如,对于一个int类型的数组,(一般)每个元素都占用4字节的空间。

上述两点意味着数组中元素的地址,均匀可以连续计算,是实现随机访问必不可少的前提。

了解上述前提后,我们还需要知道两个概念:基地址与偏移量,我们要想随机访问数组的某个元素,就需要知道目标元素的地址。

[目标元素的地址] = 数组基地址 + 偏移量,其中:

  1. 数组的基地址,即首元素的地址,也是数组变量的地址。实际上,数组名在内存中就代表该数组的基地址。

  2. 偏移量指的是目标元素地址和首元素地址的字节差值。恰好这个字节差值就等于:[目标元素的下标i] * [sizeof_element] (思考一下为什么?)

于是我们就得到一个计算目标元素地址的,寻址公式

数组的寻址公式

假如我们有一个长度为5的int数组,其基地址是0x0053fbd4,要访问第五个元素,即arr[4],那么根据寻址公式就可以计算出地址:

address_arr[4] = 0x0053fbd4 + (4 * 4) = 0x0053fbe4

所以,每次你用"arr[4]"这样的语法访问数组时,程序会直接访问0x0053fbe4这个地址来获取或设置元素值,不再需要查找或遍历,效率非常高,这就是随机访问的魅力。

数组的缺点

Gn!

搞清楚了数组的随机访问,那么就应该知道数组为了实现随机访问付出了巨大的代价,这样就带来了数组的缺点。

数组通过基地址和下标,使用简单的寻址公式,实现了高效的随机访问。为了维持这种高效访问方式,数组在其结构和存储元素上都有明确的要求:

  1. 连续内存存储:为确保可以直接计算每个元素的地址,数组必须占据一段连续的内存空间。

  2. 统一的元素数据类型:数组中的所有元素必须具有相同的数据类型,以保证每个元素的内存大小是固定的,从而保证了固定的地址偏移。

进一步推导可以得出数组的缺点:

  1. 高内存要求:数组需要一段连续的内存空间,这意味为了分配数组,系统必须找到足够大的单一连续内存块。尤其对于大型数组而言,这可能导致内存不足分配失败等问题。

  2. 固定大小:一旦创建了一个数组,它的大小就是固定的、不可改变的。这意味着数组不能动态地调整长度,使得其灵活性受到限制,这是数组最主要的缺点之一。

  3. 单一数据类型限制:所有存储在数组中的元素都必须是同一数据类型,限制了它能存储的数据种类。

总结

Gn!

数组的优点:

  1. 随机访问:由于连续内存分配,数组可以在O(1)时间复杂度内通过索引直接访问任意元素。这是数组最大的优势。

  2. 内存使用:连续的内存结构意味着较少的内存碎片。

  3. 简洁性:在C语言中,数组的使用和操作都相对直接和简单。

数组的缺点:

  1. 固定大小:在C语言中,普通数组的大小在声明时确定,之后不能更改。这是数组最大的限制,这意味着普通数组在处理动态数据时很不灵活。

  2. 插入/删除困难:向数组的中间位置插入或删除元素需要移动其他元素,效率比较低。

  3. 类型限制:C语言的数组只能存储同一数据类型的元素。

注:C/C++都可以通过动态的内存分配,在程序运行时创建一个动态长度的数组,这在一定程度上弥补了普通数组灵活性差的缺点。

数组下标为什么从0开始?

Gn!

尽管C语言并非首个采用从0开始的数组下标设计的语言,但由于其广泛的影响,许多后续的编程语言基本沿用了这一设计。

采取这样的设计,主要原因是为了简化偏移量的计算,从而提高数组的效率,具体来说是:

如果从0开始作为数组的下标,那么下标值可以直接用于计算偏移量。反之,如果从1开始,那么每次寻址都需要额外执行一个减法操作(即数组下标减1)。

简而言之,以0为数组下标的起始值,可以避免额外的减法运算,从而提高数组访问的效率。

如果你还不太明白,可以用一个简单的楼层比喻来理解:

假设数组下标从1开始。这就好比你从1楼开始上楼,当你走到3楼时,你实际上经过了2层,怎么算的呢?相当于做了一个(3-1)的减法计算,所以你的“偏移量”是2。

而当数组下标从0开始。就像你从0楼(假设某栋大楼第一层是0楼)开始上楼。

这次当你走到3楼时,你实际上经过了3层,怎么算的呢?0到1,1到2,3到3,所以就经过了3层。

这说明从0楼开始,到几楼,就经过了几楼,偏移量就是几,无需额外的计算。和补码统一加减法类似,这里将下标设置为从0开始,就减少了一次减法操作,大大提升了效率。

一维数组

Gn!

一维数组是数组最常见、常用的形式。如下图所示就是一个一维数组:

一维数组-内存示意图

声明

Gn!

在C语言中,声明一个一维数组需要指定数组的数据类型和大小。声明的语法为:

一维数组的声明语法

例如,想要声明一个可以存储5个int整数的数组,可以这样写:

注意:

  1. 如果arr数组声明在函数体内部,是一个局部变量数组,那么仅有声明的数组全部元素都未初始化,此时元素的取值是随机的,使用这样的数组会引发未定义行为。

  2. 数组名也是变量名的一种。我们之前已经讨论过变量的命名规范,如果你需要回顾,请参考之前的内容。通常,数组名采用名词的复数形式,以反映其存储多个数据项的特性。但最关键的是,命名应该直观并见名知意。

关于C语言中声明普通数组时长度的限制

在C语言当中,声明一个普通数组时,数组的长度/大小必须是一个编译时常量。这意味着数组的长度必须是一个在编译时期确定大小的常量,这一般是:

  1. 整数字面值常量(以及它的表达式)

  2. 宏常量

一般在标准C代码中,为了更好的可读性和扩展性,我们更推荐使用宏常量作为数组长度的表示。例如:

利用宏常量声明数组-演示代码

而那些可能在程序运行时期确定大小的变量,const修饰的只读变量(不是编译时常量),都是不能作为数组长度的:

数组声明时长度的取值-演示代码

除此之外,C语言也不允许数组的长度是0,以下声明是错误的:

以上。

初始化

Gn!

在C语言中,一维数组的初始化主要有以下几种形式:

  1. 完整初始化:为数组的所有元素指定值。

  2. 自动推断数组长度:在声明时省略数组大小,让编译器根据初始化的元素数量自动确定数组的大小。

  3. 部分初始化:为数组的部分元素指定值,剩余的元素会自动初始化为0值。

  4. 全部元素为0的初始化:可以使用{0},来初始化数组的所有元素为0。

其中,前两种都比较常用,第四种不过是第三种情况的延申。

注意:

除了上面给出的初始化形式,其余形式都是不正确的,都是错误的。比如:

初始化包含了比数组长度更多的元素:

初始化时,{}内不给出任何元素。这是不对的,C语言不允许数组长度是0:

数组长度不能为0

Gn!

在 C 语言中,数组的长度必须是正整数,因此不能直接声明长度为0的数组。这意味着以下代码是不合法的:

数组长度不为0-演示代码

C语言中长度为0的数组并没有多大实际意义,所以C语言语法不允许这种数组。

数组元素的读/写

Gn!

声明初始化一个数组后,就可以使用该数组了,这主要涉及数组元素的读/写。

数组允许使用下标直接访问/修改一个元素,所以数组元素的读写形式都是一致的:

数组元素的读/写-语法

其中,index是要访问的元素的下标,从0开始计数。而value是你想赋给数组元素的值,也可以填一个表达式。

注意事项:

  1. C语言不会对数组访问的索引进行自动检查。但访问非法索引会导致未定义的行为,比如程序崩溃或返回错误的数据等。为确保安全,程序员必须确保所使用的索引都在合法范围内。

  2. 未初始化的数组元素具有未定义的值。访问这些元素也可能导致未定义的行为,声明数组时,务必对其进行适当的初始化后在使用。

数组元素的循环遍历

Gn!

数组允许我们存储多个数据项,使其成为循环遍历的理想对象,无论是访问元素还是赋值操作。以下是利用for循环进行的数组的常见操作示例:

使用for循环完成数组常见操作

在上述操作中,LEN代表数组的长度。

在具体使用时,我们可以把它换成具体的数组长度,比如一个长度为5的int数组,遍历打印的代码是:

遍历输出数组-代码1

这个代码实现功能是完全没有问题的,但是从规范性上讲是不合理的,因为它引入了魔法值"5"。

更好的做法是动态计算数组长度,我们可以使用sizeof运算符来计算数组长度,代码如下:

遍历输出数组-代码2

注:

  1. sizeof(arr)计算整个数组占用的内存大小。

  2. sizeof(arr[0])计算数组中单个元素的内存占用大小。

为了进一步简化和增强代码的可读性,我们可以定义一个函数宏来计算数组长度:

遍历输出数组-代码3

在实际开发中,建议使用这种方法来获取数组的长度,增强了代码的可读性和可维护性。这是一个惯用法。

局部变量数组

Gn!

在C语言中,局部变量数组指的是在函数体内部声明的数组,它只在声明它的"{}"内部可见和可用。目前我们已知以下信息:

  1. 虚拟内存空间中的栈用于描述C程序中的函数调用,局部变量存储在栈中。

  2. 栈空间虽然可以从"高地址向低地址"扩展,但大小很有限,比较容易出现内存溢出的情况。

所以,定义局部变量数组时要格外小心,不能定义过长、过大的数组,否则会导致栈空间不足以装下整个数组,导致栈溢出,引发未定义行为(一般都会导致程序崩溃退出)。

实际上,在C语言中,大数组建议在堆上开辟空间,分配内存。这涉及到我们后面学习的内容,这里先按住不表。

课堂小练习

Gn!

固定投资的初始资金是100元,用户先输入年利率(rate)和要投资的年份(years),投资收益后的资产每年计算一次,随后此程序将打印此利率在投资年份内每年的资产总价值,和紧随其后的4个更高的利率(总计5个利率)下投资的总价值。

余额考虑复利,第一年的余额是(本金 + 本金 * 利率),第二年则可以在第一年余额的基础上累加(本金 * 利率)。

程序的执行示意图如下:

课堂练习题-程序输出图

提示:可以用一个double数组来存放存放初始金额,然后计算第一年几个利率下的余额存入数组,然后第二年就是对数组中的余额元素做累加,以此类推....

参考代码如下:

课堂小练习-参考代码

以上。

The End