数组时候是一种非常基础的数据结构,每种编程语言都会有。
数组的内存
「数组是一种线性表数据结构。使用的是一组连续的内存空间,存储一组相同类型的数据」。
部分编程语言可存储不同类型数据,如 JavaScript
从定义来看:
是一个线性表
线性表上的数据元素有前后两个方向,连续成线性结构。
数组,链表,队列,栈都是线性表结构。
而与之对应的就是非线性表结构了,如二叉树,堆,图这些。数据之间不再是简单的前后关系。
连续的内存空间,相同的数据类型
这两个限制,直接造就了数组的顶级特性:随机访问,或者叫任意访问。
根据下标来随机访问的原理:
定义一个长度为 10 的数组,计算机会分配一块连续的内存空间,假设这一块内存的首地址为 base_address = 1000。而「计算机中每个内存单元都会被分配一个地址,通过地址来访问数据」。
所以,需要一个寻址的方式:
arr[i]_address = base_address + i * type_size
type_size 就是该类型数据的每个元素的大小, 那这个寻址方式的含义就是基准地址加上地址偏移。
这个寻址方式其实就包含了问题的答案:
「因为数组被分配内存的起点地址是第一个元素的地址,如果下标从 1 开始,那么后面的元素通过下标访问的时候势必要进行一次减法操作才能算出正确的元素地址。在讲究效率和极致性能的底层设计中,这一次减法操作被认为是多余的,所以就设置成直接从 0 开始下标,计算地址的操作就更少。」
而还一部分原因,则应该是历史遗留,java,javascript这些都效仿了C语言。
关于插入和删除
案例:将一个元素插入到数组的第 K个位置的时候,K 位置后面的元素需要将位置腾出,以至于 k-n 的元素都会有一次移动的操作。
这个插入操作的最好情况的复杂度是 O(1),最坏情况是 O(n),平均复杂度为 O(n)。
当然,这是在保证原来数据的次序不变的情况下的操作,如果不用保证顺序,也可以直接将第 K 位的元素放到最后,然后再将该元素放到第 K 位上,这样复杂度就是O(1)。
删除数据的时候,为了内存的连续性,也是需要挪动数据的。平均时间复杂度也是O(n)。
在某些情况下,可以将删除操作集中合并执行,删除效率会提高很多。删除数据,只是对数据标记,然后到某一时机(如数组满载时)再真正删除。
数组访问越界问题
访问数组的本质就是访问一段连续的内存,如果通过偏移计算得到的内存地址是可用的,那么程序就不会报错。但是这样经常会访问到其他元素,导致莫名其妙的Bug。
代码中的数组越界会访问非法地址,甚至会成为计算机病毒利用的漏洞。
当然,一些高级语言中数据越界的行为已经做了检查,如 Java 中经典的java.lang.ArrayIndexOutOfBoundsException。
容器和数组
一些高级语言,对数组这一结构提供了容器类。如java的ArrayList,以此对比:
ArrayList的最大优势就是将数组的操作细节封装,如插入,删除移动数据。且支持动态扩展,每次扩容1.5倍。
在使用容器的时候,尽可能的在创建的时候就指定数据大小。
对于选择容器还是数组,在有选择非必须的条件下可以从这几点考虑:
极致性能,基本类型选择数组
数据明确,操作简单选择数据
业务开发,追求操作调用方便选择容器
二维数组的寻址方式
对于 a1*a2 的二维数组,就是将一维数组中 1 个元素的地址长度替换为 a2 个元素的地址长度。第 i 个就是 i * a2 ,再加上二维下标 j 个偏移,就是 arr[i][j]的地址偏移。
[i][j]_address = base_address + (a2*i + j) * type_size
同样,a1 * a2 * a3 的三维数组的寻址方式:
[i][j][k]_address = base_address + ( i * a2*a3 + j * a3 + k ) * type_size
- END -