一、数组的概念
定义:数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。
从概念中可以知道一下几点:
- 数组是线性表。
所谓的线性表就是数据排成一排,想一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。当然除了数组,链表、队列、栈等也是线性表结构
- 连续的内存空间和形同类型的数据。
正因为有了上述两个特点,数组才能够有一个堪称“杀手锏”的特性:随机访问
数组实现下标随机访问
下面通过一个实际的例子来说明:
例如有一个长度为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表示数组中每个元素的大小。例如,数组中存储的int类型的数据,所以,data_type_size就是4字节。
二、数组的操作
通常我们都会说,“链表适合插入、删除。时间复杂度为O(1);数组适合查找,查找时间复杂度为O(1)”,其实该种说法是不对的,正确的应该是:数组支持随机访问,根据下标随机访问的时间复杂度为O(1)。
因为,数组是适合查找操作,但是查找的时间复杂度并不是O(1),即便是排好序的数组,使用二分查找,时间复杂度也是O(logn)
数组为了保持内存的数据的连续性,会导致插入、删除这两个操作比较低效。
数组的插入
分为三种情况:
- 数组头插入
当我们在数组的头部插入一个元素的时候,那么所有的元素都需要向后挪一位
该种情况下的最坏时间复杂度为O(n)。
- 数组中间插入
在该种情况下,如果要将某个元素插入到数组中的第k个位置,就必须按照上一种方式搬移k之后的数据。
该种情况下数据插入的最坏时间复杂度为O(n)。
但是如果说,数组只是被用来当做一个存储集合,而不考虑数组中数据的顺序的话,为了避免大规模的数据搬移操作,有一个简单的办法,就是直接将第k个位置的数据搬移到数组的最后,然后将新元素直接放到第k个位置。例如:
该种处理技巧,在特定场景下,在第k个位置插入一个元素的时间复杂度会降为O(1)。
- 数组尾部插入
如果是在数组尾部插入元素的话,此时,就不需要移动数据
该种情况下,元素插入的时间复杂度为O(1)
数组的删除
其实数组的删除操作,跟插入操作类似,同样分为三种:
- 删除头部元素
该种情况下,因为也数组也需要保持内存空间的连续性,所以也需要搬移数据,最坏时间复杂度为O(n)
- 删除中间位置元素
这种情况下,其实跟删除头部元素类似,数组为了保持内存的连续性,也需要搬移数据,不然中间会出现空洞,内存就不连续了,最坏时间复杂度也为O(n)
- 删除尾部元素
如果说是删除末尾元素的话,此时数组其他的数据不需要进行移动,时间复杂度为O(1)
但是在某些特殊场景下,我们并不一定需要非得追求数组中数据的连续性。如果我们将多次删除操作集中在一起执行,删除的效率是不是会提高很多呢,比如说:
现在有个数组a[10]中存储了8个元素:a,b,c,d,e,f,g,h,现在要依次删除a,b,c三个元素
为了避免数据d,e,f,g,h这几个数据搬移三次,我们可以先记录下已经删除的数据。每次的删除操作并不是真正的搬移数据,只是记录数据已经被删除。当数组中没有更多的空间的时候,我们再触发一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。
咦,这种形式貌似跟HBase的删除操作有点类似,在每次执行delete的时候,并不是真正的从region当中执行删除操作,而是先给要删除的记录打个标记,最后在合适的时间统一进行移除底层的hfile中的数据文件
同样,如果对jvm了解的话,会发现,这种情况也同JVM的标记清除垃圾回收算法的核心思想相类似。
容器与数组
大多数语言中提供了容器类,比如java中提供了ArrayList,但是具体与数组相比较,优势在哪里呢?
- ArrayList
ArrayList最大的优势就是可以将很多数组操作的细节封装起来.
ArrayList支持动态扩容,每次存储空间不够的时候,会自动将空间扩容为原来的1.5倍大小。但是同样,动态扩容涉及到内存的申请和数据的搬移,也是比较耗时的。所以,如果实现能确定好需要存储的数据大小,最好在创建Arraylist的时候事先指定数据大小。
- 数组
数组本身在定义的时候需要预先指定大小,因为需要分配连续的内存空间。如果我们申请了大小为10的数组,当第11个数据需要存储到数组中时,我们就需要重新分配一块更大的空间,将原来的数据复制过去,然后再将新的数据插入。
*问题:为何数组要从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
对于当中的参数的含义,base_address:数组的首地址,正如文章开头画的图表示的事1000,k表示的则是数组的下标,type_size则表示数组存储的数据的大小,如果存储的是一个int型的数据,那么就是4字节。
对比上面两个公式,并结合文章开头说话的图验证一下,可以发现,从1开始编号,每次随机访问数组元素都多了一次减法运算,对于CPU来说的话,那就是多了一次减法指令。
数组作为非常基础的数据结构,通过下标随机访问数组元素有事非常基础的编程操作,效率的优化就要尽可能的做大极致。所以为了减少一次减法操作,数组选择了从0开始编号,而不是从1开始编号。
总结
- Java ArrayList无法存储基本数据类型,比如int,Long,需要封装为Integer,Long类,而Autoboxing,Unboxing择优一定的性能消耗,所以如果特别关注性能,或者希望使用基本类型,既可以选用数组。
- 如果数据大小事先已知,并且对数据的操作非常简单,用不用到ArrayList提供的大部分方法,也可使用 数组。
- 当要表示多维数组时,用数组往往会更加直观。比如Object[][] array;而用容器的话则需要这样定义:ArrayList<ArrayList,> array.