数组(Array)是有序的元素序列。若将有限个类型相同的变量的集合命名,那么这个名称为数组名。组成数组的各个变量称为数组的分量,也称为数组的元素,有时也称为下标变量。用于区分数组的各个元素的数字编号称为下标。数组是在程序设计中,为了处理方便, 把具有相同类型的若干元素按有序的形式组织起来的一种形式。这些有序排列的同类数据元素的集合称为数组。
数组(Array)是「线性表」的一种,它使用一块连续的内存空间来存放相同类型的元素。
何为线性表?
线性表中的元素具有线性结构,元素与元素之间有前后顺序。例如:数组、链表、栈、队列都属于线性表。反之,像树、图、堆等元素间具有多对多的复杂关系的就是「非线性表」。
很多编程语言都内置了数组,开发者可以直接拿来用,如下是Java语言使用数组的示例:
public void array() {
int[] arr = new int[5];
arr[0] = 1;
arr[1] = 2;
int i = arr[0];
}
1. 数组的特点
1.1 空间连续
数组使用一块连续的内存空间来存储数据,例如new int[5]
数组可以存放5个int整形数据,数组创建时JVM会为其开辟20个字节的连续内存。
由于内存空间是连续的,因此数据访问时可以很好的利用计算机的「局部性原理」来提升性能。同时还可以根据起始地址、索引值和步长来快速定位元素,这是空间连续带来的优点。
空间连续的优点很多,与此同时,带来的缺点也不少。如果你要创建一个大容量的数组,将给JVM带来很大压力,即使堆内存是够用的,但如果内存碎片化严重,JVM找不到一大块连续的内存空间,就会提前触发GC。
除此之外,一旦创建的数组空间超过了-XX:PretenureSizeThreshold
设定的阈值,那么它会直接被分配到老年代,尽管它很快就可以被释放了,但是由于在老年代,导致Young GC无法释放它。
另外还有一点需要注意,由于内存空间是连续的,如果JVM即使Full GC了也无法开辟出一大块连续的内存空间,就会导致内存溢出,数组的创建不易过大,按需创建。
1.2 定长
数组的另一个特点就是「定长」。拿new int[5]
为例,数组创建时JVM会为其开辟20个字节的连续内存。但是内存开辟完成后,很可能其相邻的内存也马上就被分配使用了,由于数组的内存空间必须连续,因此它不可能再扩容了,这是使用数组的一大痛点,你必须清楚要存放的数据容量。
「定长」是一个痛点,很多时候,开发者往往并不知道元素的数量,只有运行时才知道该分配多大的内存空间。Java解决了这个问题,提供了容器类ArraList。它底层依然使用数组来存储元素,但是它支持动态的扩容,你可以放心的调用add()
方法来存储元素,而不必担心下标越界的问题。
public void list() {
List<Integer> list = new ArrayList();
list.add(1);
list.add(2);
Integer number = list.get(0);
}
1.3 快速随机访问
数组支持快速随机访问,它的读写操作时间复杂度是O(1),效率极高。
这主要得益于它的内存空间连续,无需挨个遍历,只要知道数组的起始地址,根据索引值和单个元素占用的内存空间,即可计算出偏移量offset,起始地址+offset就是要访问的下标元素,伪代码如下:
public long getArray(long baseMemory, int index) {
// 假设是int数组,单个元素占4字节
int offset = index * 4;
return baseMemory + offset;
}
它的时间复杂度如何计算,还记得之前说的大O阶推导过程吗?这个算法执行了两步,没有最高阶,用常数1取代所有加法常数,因此:
画了个简图,如下所示:
1.4 插入、删除操作慢
数组的插入和删除效率很慢,时间复杂度为O(n)。
数组要求所有的数据元素有序的紧凑存储,如果有新的元素要插入,那么在这后面的所有元素都必须向后移动一位。
如果是恰好插入到最后一位,元素不用移动,最好的时间复杂度是O(1)。如果插入到第0个位置,则整个数组都要向后移动一位,时间复杂度为O(n)。
同理,如果要删除元素,这后面的所有元素都需要向前移动一位。
如果是删除最后一位,元素不用移动,最好的时间复杂度是O(1)。如果删除0号元素,则整个数组都要向前移动一位,时间复杂度为O(n)。
2. 总结
数组作为最基础的一种数据结构,许多编程语言都内置了,开发者直接拿来主义即可。了解数组的存储结构,内存空间连续、定长、访问效率极高、插入和删除效率较低。