1、什么是数组
❝数组是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据
❞
定义中有几个关键词,一个是「线性表」包括顺序表和链表,每个线性表的数据最多只有前后两个方向。除了数组,链表、队列、栈等也是线性表结构
| 顺序表 | 链表 | |
|---|---|---|
| 存储方式 | 在内存上一组连续的存储单元存储 数据,存储单元是连续的 | 采用链式存储结构,用一组任意的存储单元存放数据 |
| 时间复杂度 | 根据下标查询 O(1),如果是根据给定值去查询是O(n),插入和删除是O(n) |
查找是O(n)、插入和删除是O(1) |
| 空间性能 | 初始化时,就要确定长度,分大了浪费,小了容易发生上溢,不支持扩容 | 初始化时不需要确定存储长度,按实际需要,支持动态扩容 |
与之相对立的概念是非线性表,比如二叉树、堆、图等,非线性表中的数据之间并不是简单的前后关系
第二个是「连续的内存空间和相同类型的数据」,这两个限制,让数组的很多操作变得非常低效,比如在数组中删除、插入一个数据,为了保证数组的连续性,需要做大量的数据搬移工作;但是数组内存空间的连续性使得数组具有支持「随机访问」的特性。
2、数组是如何实现根据下标访问数组元素的
举个栗子:定义一个长度为10的int 类型的数组int[] a = new int [10],计算机给数组a[10]分配一块连续内存空间1000~1039。其中,内存块的首地址是base_address = 1000。
计算机会给每个内存单元分配一个地址,计算机通过地址来访问内存中的数据。但计算机需要随机访问数组中的某个元素时,他会首先通过下面的寻址公式,计算出该元素存储地址:
a[i]_address = base_address + i * data_type_size
其中data_type_size表示数组中的每个元素的大小,data_type_size是数组中存储的数据自己字节大小,此处为4个字节。
❝注意:
❞
- 数组是适合查找操作,但查找的时间的复杂度并不是
O(1);即便是排好序的数组,你用二分查找时间复杂度也是O(lg n)。- 正确的表述应该是,数组支持随机访问,根据下标随机访问的时间复杂度为
O(1)。
3、为什么大多数的编程语言都是从0 开始编号的,而不是从1开始的?
从数组存储的内存模型上来看,「下标」最确切的定义应该是「偏移(offset)」。如果用数组a来表示数组的首地址,a[0]就是偏移为0的位置,也就是首地址,a[k]就是偏移k个type_size的位置,所以计算a[k]的内存地址只需用这个公式:
a[k]_address = base_address + k * type_size
但是,如果从1开始计数,那我们计算数组元素a[k]的内存地址就会变为:
a[k]_address = base_address + (k-1)*type_size
对比两个公式,可以发现,从1开始编号,每次随机访问数组元素都多了一次减法运算,对CPU来说,就是多了一次减法指令
数组作为非常基础的数据结构,通过下标随机访问数组元素又是非常基础的编程操作,效率优化就要尽可能地做到极致。所以为了减少一次减法操作,数组选中从0开始编号,而不是从1开始
4、低效的插入和删除
数组为了保持内存数据的连续性,会导致插入和删除这个两个操作比较低效
4.1、插入操作
假设数组的长度为 n,现在需要将一个数据插入到数组中的第 k 个位置。为了把第 k 个位置腾出来,我们需要将第 k ~ n 这部分的元素都顺序的往后挪一位。那么插入操作的时间复杂度是少?
如果在数组的末尾插入元素,就不需要移动数据了,则最好的时间复杂度为O(1);如果在数组开头插入元素,那所有的元素都要一次往后移动一位,则最坏的时间复杂度为O(n)。因为数组中的每个位置插入元素的概率都是一样的,所以平均情况时间复杂度为
如果数组中的数据是有序的,我们在某个位置插入一个新的元素,就必须按照刚才的方法搬移k之后的数据。如果数组中的数据并没有任何规律,数组只是被当作一个存储数据的集合,那么如果在数组中的第k个位置插入某个数据,为了避免大规模的数据搬移,我们还有可以使用一个简单的办法:直接将第 k 位的数据搬移到数组元素的最后,把新的元素直接放回第k个位置
举个栗子:假设数组a[10]={a,b,c,d,e},我们现在需要将元素x插入到a[3],我们只需要将c放入a[5],将a[2]赋值为 x 即可。结果为:a[10]={a,b,x,d,e,c}
利用这种出来了技巧,在特定的场景下,在第
k位置插入一个元素的时间复杂度就会将为O(1)。
4.2、删除操作
跟插入数据类似,如果我们要删除第k个位置的数据,为了内存的连续性,也需要搬移数据,不然就会出现空洞,内存就不连续。那么插入时间复杂度是多少?
和插入类似,如果删除数据末尾的数据,则最好的时间复杂度为O(1);如果删除首地址的数据,则最坏的的时间复杂度为O(n);因为每个位置删除元素的概率都是一样的,所以平均情况的时间复杂度为
实际上,在某些特殊的场景下,我们并不一定非得追求数据总数据的连续性。我们可以将多次删除操作集中在一起执行,删除的效率会提高很多。
举个栗子:int[10]={a,b,c,d,e,f,g,h},现在需要依次删除a,b,c三个元素,为了避免d,e,f,g,h这几个数据会被搬移三次,我们可以先记录已删除的数据;每次的删除操作并不是真正地搬移数据,只是记录数据已被删除。当数组没有更多的空间存储数据时,我们再发出一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。
如果你知道 JVM 的标记-清除垃圾回收,你会发现这就是标记-清除算法的核心思想。
❝标记-清除算法,如同名字一样,算法分为“标记”和“清除”两个阶段:
- 首先需要标记出所有需要回收的对象
- 在标记完成后统一回收所有被标记的对象 其中判断对象是否需要回收用的是可达性分析算法,基本思路是通过一系列的“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径就是引用链(Reference Chain),当一个对象 GC Roots 没有任何引用链相连时,则证明此对象是不可用的,JVM 会将这些对象标记为可回收对象。
在 Java 语言中,可作为 GC Roots 的对象包括下面几种:
❞
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象。
5、数组的访问越界问题
下面是一段C语言代码:
int main(int argc, char* argv[]){
int i = 0;
int arr[3] = {0};
for(; i <= 3; i++){
arr[i] = 0;
printf("hello world\n");
}
return 0;
}
这段代码实际运行的结果并非打印三行hello world,而是会无限打印hello world,这会为什么?
因为数组的长度为3,下标分别为a[0],a[1],a[2],而代码中当i = 3时,数组下标为a[3],此时下标越界。在C语言中,只要不是访问受限的内存,所有的内存都是可以自由访问的。根据前面的寻址公式,a[3]也会被定位到某块不属于数组的内存地址上,而这个地址正好是存储变量 i 的内存地址,那么a[3] = 0,就相当于i = 0,所以代码就会无限循环。
数组越界在C语言中是一种未定义行为(指C语言标准未做规定的行为),并未有规定数组访问越界时,编译器应该如何处理。因为访问数组的本质是访问一段连续内存,只要通过偏移计算得到的内存地址是可用的,那么程序就可能不报任何错误,所以在C代码的时候一定要警惕数组越界。
Java语言对数组下标是否越界,做了检查,如果数组越界则会抛出异常 java.lang.ArrayIndexOutOfBoundsException,并终止此次此方法的继续运行,不会出现死循环的情况。
6、总结
数组是最基础,最简单的数据结构,它使用一块连续的内存空间,来存储一组相同类型的数据,最大的特点就是支持下标随机访问数组,此时的时间复杂度为O(1);但插入和删除操作中,为了要保证数组的其他部分的数据的顺序不变且是连续的,使得数组的这两个操作变得不是很高效,时间复杂度为O(n)。