在大部分编程语言中,数组都是从0开始编号的,那么为什么数组要从0开始而不是1呢?带着这个问题我们来看下面的内容。
如何实现随机访问?
数组(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个字节。
-
低效的“插入”和“删除”
数组的插入操作,假设数组的长度为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插入到第三个位置。那么我们只需要将c放入到 a[5] ,将 a[2] 赋值为 x 即可。
删除跟插入类似,我们要删除第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标记清楚垃圾回收算法的核心思想。
警惕数组的访问越界问题
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;
}
这段C语言代码中,运行结果并非是打印三行“hello world”,而是会无线打印“hello world”。
因为,数组大小为3,而我们的代码因为书写错误,导致for循环的结束条件写成了 i<=3 而非 i < 3,所以当 i=3 的时候,数组 a[3] 访问越界。
在C语言中,只要不是访问受限的内存,所有的内存空间都是可以自由访问的,根据我们的寻址公式,a[3] 也会被定为到某块不属于数组的内存地址上,而这个地址正好是存储变量i的内存地址,那么 a[3] = 0 就相当于 i=0,所以就会导致代码无限循环。
这种情况下,一般会出现莫名其面的逻辑错误,debug难度非常大。很多计算机病毒就是利用到了代码中的数组越界可以访问非法地址的漏洞来攻击系统,所以要警惕数组越界。
容器能否完全代替数组?
针对数组类型,很多语言都提供了容器类,比如Java中的ArrayList、C++ STL中的vector。在项目开发中,什么时候适合用数组,什么时候适合用容器呢?
我们用Java来说,ArrayList最大的优势是可以将很多数组操作的细节封装起来。比如前面提到的数组插入、删除数据时需要搬移其他数据等。另外,它还有一个优势,支持动态扩容。
数组本身在定义的时候需要预先指定大小,因为需要分配连续的内存空间。如果我们申请了大小为10的数组,当第11个数据需要存储到数组中时,我们就需要分配一块更大的空间,将原来的数据复制过去,然后插入新的数据。
如果使用ArrayList,我们就不需要关心底层的扩容逻辑了,每次存储空间都不够的时候,它都会将空间自动扩容为1.5倍大小。
但是,扩容操作涉及内存申请和数据搬移,是比较耗时的,所以最好在创建ArrayList的时候事先指定数据大小。
作为高级语言编程者,是不是数组就无用武之地了呢?当然不是,有些时候,用数组会更合适:
1. Java ArrayList无法存储基本类型,比如int、long,需要封装为Integer、Long类,而 Autoboxing、Unboxing 则有一定的性能消耗,所以如果特别关注性能,或者希望使用基本类型,就可以选用数组。
2. 如果事先知道数据大小,并且对数据的操作非常简单,且用不到ArrayList提供的大部分方法,也可以直接使用数组。
3. 当要表示多维数组时,用数组往往会更加直观。比如Object[][] array;而用容器的话则需要这样定义:ArrayList<ArrayList<object>> array。
为什么数组从0开始编号
从数组存储的内存模型上来看,“下标”最确切的定义应该是“偏移(offset)”。如果a来表示数组的首地址,a[0]就是偏移为0的位置,也就是首地址,a[k]就是表示偏移了k个type_size的位置
a[k]_address = base_address + k * type_size
但是,如果数组从1开始计数,那我们计算数组元素a[k]的内存地址就会变为:
a[k]_address = base_address + (k-1)*type_size
通过这两个公式,我们不难看出,从1开始编号,每次随机访问数组元素都多了一次减法运算,对于CPU来说,就多了一次减法指令。
数组作为非常基础的数据结构,通过下标随机访问数组元素又是其非常基础的编程操作,效率的优化就要尽可能坐到极致。所以为了减少一次减法操作,数组选择了从0开始编号,而不是从1开始。
不过我认为,上面解释得再多其实都算不上压倒性的证明,说数组起始编号非 0 开始不可。所以我觉得最主要的原因可能是历史原因。C 语言设计者用 0 开始计数数组下标,之后的 Java、JavaScript 等高级语言都效仿了 C 语言,或者说,为了在一定程度上减少 C 语言程序员学习 Java 的学习成本,因此继续沿用了从 0 开始计数的习惯。实际上,很多语言中数组也并不是从 0 开始计数的,比如 Matlab。甚至还有一些语言支持负数下标,比如 Python。
小结
数组是最基础也是最简单的数据结构了,数组用一块连续的内存空间来存储相同类型的一组数据,最大的特点是支持随机访问,但插入、删除操作也因此变得比较低小,平均情况时间复杂度为O(n)。在平时的业务开发中,我们可以直接使用编程语言提供的容器类,但是,如果是特别底层的开发,直接使用数组可能会更合适。