数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。这个定义里面有几个关键词,下面详细解释一下:
1)线性表
顾名思义,线性表就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。其实除了数组,链表、队列、栈等也是线性表结构。
与它相对立的是非线性表,比如二叉树、堆、图等。在非线性表中,数据之间并不是简单的前后关系。
2)连续的内存空间和相同类型的数据
正是因为这两个限制,数组才可以实现随机访问的特性,根据下标随机访问的时间复杂度为 O(1)。但这两个限制也让数组的很多操作变得非常低效,比如要想在数组中删除、插入一个数据,为了保证连续性,就需要做大量的数据搬移工作。
我们拿一个长度为 10 的 int 类型的数组来举例。在下图中,计算机给数组 a[10] 分配了一块连续内存空间 1000~1039,其中,内存块的首地址为 base_address = 1000。
我们知道,计算机会给每个内存单元分配一个地址,计算机通过地址来访问内存中的数据。当计算机需要随机访问数组中的某个元素时,它会首先通过下面的寻址公式,计算出该元素存储的内存地址:
a[i]_address = base_address + i * data_type_size
其中 data_type_size 表示数组中每个元素的大小。此外,对于数组和链表的区别,很多人都回答说,链表适合插入、删除,时间复杂度 O(1);数组适合查找,查找时间复杂度为 O(1)。实际上,这种表述是不准确的。数组是适合查找操作,但是查找的时间复杂度并不为 O(1)。即便是排好序的数组,用二分查找,时间复杂度也是 O(logn)。所以,正确的表述应该是,数组支持随机访问,根据下标随机访问的时间复杂度为 O(1)。
低效的插入和删除
数组为了保持内存数据的连续性,会导致插入、删除这两个操作比较低效。现在我们就来详细说一下,究竟为什么会导致低效?又有哪些改进方法呢?
1. 插入
假设数组的长度为 n,如果我们需要将一个数据插入到数组中的第 k 个位置。为了把第 k 个位置腾出来,我们需要将第 k~n 这部分的元素都顺序地往后挪一位。如果在数组的末尾插入元素,那就不需要移动数据了,这时的时间复杂度为 O(1)。但如果在数组的开头插入元素,那所有的数据都需要依次往后移动一位,所以最坏时间复杂度是 O(n)。 因为我们在每个位置插入元素的概率是一样的,所以平均时间复杂度为 (1+2+…n)/n=O(n)。
如果数组中的数据是有序的,我们在某个位置插入新元素时,就必须按照刚才的方法搬移 k 之后的数据。但如果数组中存储的数据是无序的,数组只是被当作一个存储数据的集合。在这种情况下,为了避免大规模的数据搬移,我们可以直接将第 k 位的数据搬移到数组元素的最后,把新的元素直接放入第 k 个位置。
下面举个例子。假设数组 a[10] 中存储了如下 5 个元素:a,b,c,d,e。我们现在需要将元素 x 插入到第 3 个位置。我们只需要将 c 放入到 a[5],将 a[2] 赋值为 x 即可。最后数组中的元素如下:a,b,x,d,e,c。
利用这种处理技巧,在特定场景下,在第 k 个位置插入一个元素的时间复杂度就会降为 O(1)。这个处理思想在快排中也会用到。
2. 删除
跟插入数据类似,如果我们要删除第 k 个位置的数据,为了内存的连续性,也需要搬移数据,不然中间就会出现空洞,内存就不连续了。和插入类似,如果删除数组末尾的数据,则最好情况时间复杂度为 O(1);如果删除开头的数据,则最坏情况时间复杂度为 O(n);平均情况时间复杂度也为 O(n)。
实际上,在某些特殊场景下,我们并不一定非得追求数组中数据的连续性。如果我们将多次删除操作集中在一起执行,删除的效率会提高很多。比如,数组 a[10] 中存储了 8 个元素:a,b,c,d,e,f,g,h。现在,我们要依次删除 a,b,c 三个元素。
为了避免 d,e,f,g,h 这几个数据会被搬移三次,我们可以先记录下已经删除的数据。每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除。当数组没有更多空间存储数据时,再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。实际上,这正是 JVM 标记清除垃圾回收算法的核心思想。
