前言
数组:是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。
线性表:顾名思义,线性表就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前后两个方向。而且除了数组、链表、队列、栈等也是线性表结构。
而与它对立的概念是非线性表,比如二叉树、堆、图等。之所以叫非线性,是因为在非线性表中,数据之间并不是简单的前后关系。
连续的内存空间和相同类型的数据:正是因为这两个条件的限制,才有了一个特别厉害的特性:随机访问。但是有利有弊,这两个限制也让数组的很多操作变得很低效,比如想要在数组中删除、插入一个数据,为了保证连续性,就需要做大量的数据搬移工作。
数组是如何实现根据下标随机访问数组元素的?
我们拿一个长度为10的int类型的数组举例
int[] a = new int[]
计算机给数组a分配了一块连续内存1000-1039,其中存块的首地址为base_addr = 1000。
我们知道,计算机会给每个内存单元分配一个地址,计算机通过地址来访问内存中的数据,当计算机需要访问数组中的某个元素时,它会首先通过下面的寻址公式,计算出该元素存储的内存地址:
a[i] 内存地址 = base_addr + i * 每个元素大小(因为数组中存的是int类型,所以元素大小为4个字节)
抵效的插入和删除
数组为了保持内存数据的连续性,会导致插入、删除这两个操作比较低效。
插入
假设数组的长度为n,现在我们需要将一个数组插入到数组的第k
个位置。为了给k腾出位置,我们就需要把k-n这部分的元素顺序的往后依次挪一位。
如果这个k代表是末尾,那么就不要移动数据,那么此时的时间复杂度为O(1),如果k是在数组的开头位置,那么所有的数据都要依次往后挪一位,那么最坏的时间复杂度为O(n)。因为我们在每一个位置插入元素的概率是一样的,所以平均情况时间复杂度为O(n)。
以上的情况是为了在数组有序的排序,那么如果我们在数组中没有任何规律,只是当做存储数据的集合,在这种情况下,我们如果要将一个数据插入到数组第k哥位置,为了防止大规模数据的搬移,我们还有一种简单方法就是,直接将第k的数据搬到数组元素的最后位置,把新元素放到第k个位置。见下图:
我现在想把x放到c的位置,那么我就可以把c放到数组最后,把x放到之前c的位置。这种方式在特定的场景下,时间复杂度就会降为O(1)。这种处理思想在快速排序中是被应用到的。
删除
跟插入操作类似,如果我想删除第k个位置的元素,为了保持内存的连续性,我们也需要搬运数据,不然就会导致内存不会连续。
和插入操作类似,如果删除数组末尾的数据,最好情况的时间复杂度为O(1),如果删除开头的数据,那么最坏情况的时间复杂度为O(n)。
实际上在某些特定的场景下,我们并不一定追求数组的连续性,如果我们将多次的删除操作集中一起执行,那么删除的效率是不是会提高很多?
举个例子:
一个数组中存储了8个元素,现在我们依次删除a,b,c三个元素,为了避免d,e,f,g,h这三个数据会被搬运三次,我们可以先记录已经删除的数据。每次删除操作并不是真的搬运数据,只是在基础上先打上一个删除的标记。如果当前数据没有更多的空间存储数据,我们再出发删除操作,将要删的一次性删除,这样就大大减少了删除操作导致的数据搬移。
容器能否完全替代数组
针对数组的类型,Java中提供了ArrayList等一些容器。
那我们什么时候适合数组,什么时候适合容器?
ArrayList最大的优势就是可以将很多数组操作的细节封装起来,比如前面提到的数组插入删除时需要搬移数据等。另外还有一个优势就是动态扩容。
数组本身定义的时候就要指定大小,因为需要分配连续的内存空间。如果我们申请了大小为10的数组,当第11个数据需要存储到数组中,我们就要重新分配一块更大的空间,将原来的数据复制过去,然后重新将新的数据插入。
如果使用了ArrayList,我们就完全不需要关系底层的扩容逻辑,ArrayList已经帮我们实现好了,每次存储空间不够的时候,都会扩容1.5倍。
不过这里需要注意一点,因为扩容操作设计内存的申请和数据搬移,是比较耗时的,所以如果先能确定需要存储的数据大小,最好在创建ArrayList的时候事先指定数据大小。
回到问题,那什么时候适合数组,什么时候适合容器?
1.ArrayList无法存储基本类型,比如int、long需要封装为Integer、Long类,而插箱装箱需要一定的性能消耗,如果特别关注性能,或者希望使用基本类型,就可以选择数组
2.如果数据大小事先固定好,并且对数据的操作很简单,用不到ArrayList提供的大部分方法,就可以直接使用数组。
3.如果要表示多维数组,用数组会更直观
4.对于业务开发,直接使用容器就足够了。毕竟损失一丢性能,完全不会影响到系统整体的性能。
数组为什么从0开始编号,而不是从1开始?
从内存模型来看,下标最好的定义应该是偏移。
如果计算a[k]的内存地址,需要用到下面的公式
a[k] 内存地址 = base_addr + k * 每个元素大小
如果数组开始从1开始计数,那么计算a[k]的内存地址就变为
a[k] 内存地址 = base_addr + (k-1) * 每个元素大小
对比两个公式,如果从1开始编号,每次随机访问数组元素都多了一次减法运算,对于cpu来说,也多了一次减法指令。为了保持效率吧,选择了从0开始。但主要原因还是c语言开始就这么定义的。