为什么数组要从0开始编号(索引),而不是从1开始(⊙o⊙)? 从1开始不是更符合人类的思维习惯?
关于数组的定义:
数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。
对于数组定义的几个关键字,来了解一下数组的概念
第一:线性表(Linear List)。就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前后两个方向。其实,除了数组,链表、队列、栈等也是线性表结构。
而与它相对立的概念是非线性表,比如二叉树、堆、图等。之所以叫非线性,是因为,在非线性表中,数据之间并不是简单的前后关系。
第二:是连续的内存空间和相同类型的数据。
这个连续的内存空间可以理解,但是对于相同类型的数据,有的语言没有这些要求,比如PHP的数组,其数据类型都是可以是随意的。
数组的这两个连续的内存空间和相同类型的数据的限制,保证了数组有了这两个特性:”随机访问“。但是这两个限制也让数组的很多的操作变得非常的低效,比如,想要在数组中删除、插入一个数据,为了保证内存连续性,就需要做大量的数据迁移工作。
说道了数据的访问,那么数组是如何实现根据下标随机访问数组元素的(⊙o⊙)?
我们重点一JAVA语言为案例:
一个长度为10的int类型的数组 ,int[] a = new int[10];
,计算机为数组a[10]分配了一块连续内存空间1000~1039,七中内存的首地址为base_address =1000; 如下图:
计算机会给每个内存单元分配一个地址,计算机通过地址来访问内存中的数据。当 计算机需要随机访问数组中的某个元素的时候,它会首先通过下面的寻址公式,计算出该元素存储的内存地址:a[i]_address = base_address + i * data_type_size
Tips:
data_type_size 表示数组中每个元素的大小,案例中的int类型的数组,int类型的占4个字节,那么data_type_size =4 字节
数组比较适合查找操作,但是查找的时间复杂度并不为O(1).即便是排序号的数组,你用二分查找,时间的复杂度也是O(logn)。数组支持随机访问,根据下标随机访问的时间复杂度是O(1)。
由于数组在内存的地址要保持内存数据的连续性,会导致插入、删除这两个操作比较低效。那么原因是什么(⊙o⊙)??
插入操作
案例:一个数组的长度为n,我们需要将一个数据插入到数组中第K 个位置。为了把第k个位置腾出来,给新来的数据,我们需要将第k~n这部分的元素都顺序的往后挪动,那插入的时间的复杂度会是多少呢?如果是末尾插入,时间复杂度为O(1),但是如果是在数组的开头插入,那么所有的数据都要往后挪动,那么 这种最坏的时间复杂度是O(n).因为我们在每个位置插入元素的概率都是一样的,所以平均情况时间复杂度为(1+2+3+…n)/n = O(n);
如果数组中的数据是有序的,我们在某个位置插入一个新的元素时,就必须按照刚才的方法搬移 k 之后的数据。但是,如果数组中存储的数据并没有任何规律,数组只是被当作一个存储数据的集合。在这种情况下,如果要将某个数组插入到第 k 个位置,为了避免大规模的数据搬移,我们还有一个简单的办法就是,直接将第 k 位的数据搬移到数组元素的最后,把新的元素直接放入第 k 个位置。
删除操作
为了内存的连续性,也需要搬移数据,不然中间就会出现空洞,内存就会不连续了。
其复杂度,经过分析,和数组的插入的的时间复杂度咿呀个。
实际上,在某些特殊场景下,我们并不一定非得追求数组中数据的连续性。如果我们将多次删除操作集中在一起执行,删除的效率是不是会提高很多呢?
我们继续来看例子。数组 a[10] 中存储了 8 个元素:a,b,c,d,e,f,g,h。现在,我们要依次删除 a,b,c 三个元素。
为了避免 d,e,f,g,h 这几个数据会被搬移三次,我们可以先记录下已经删除的数据。每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除。当数组没有更多空间存储数据时,我们再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。这就是JVM标记清除垃圾回收算法的核心思想。
容器能否完全取代数组?
很多语言都提供了容器类,比如Java中的ArrayList、C++ STL中的vector。
对于容器来说,可以将很多数组操作的细节封装起来,支持动态扩容(Redis的SDS结构):数组本身在定义的时候需要预先指定大小,因为需要分配连续的内存空间。如果我们申请了大小为 10 的数组,当第 11 个数据需要存储到数组中时,我们就需要重新分配一块更大的空间,将原来的数据复制过去,然后再将新的数据插入。但是当我们使用ArrayList的时候,我们不需要去关心底层是如何实现的扩展逻辑,容器已经帮我们实现好了,每一次空间不够的时候,它都会讲空间自动扩容为1.5倍大小。扩容操作涉及内存申请和数据迁移,如果开发的时候能够确定存储数据的大小,最好在创建的ArrayList的时候,事先指定好数据大小,这样会省去很多内存申请和数据迁移操作。在做业务开发的时候,如果资源充足,直接使用容器就可以满足了,省时省力。毕竟耗损一丢丢性能,完全不会影响到系统整体的性能。但是如果你要做一些非常底层的开发,比如:开发网络框架,性能的优化需要做到极致,那么这个时候使用数组比较明智。
数组从0开始的问题。
从数组存储的内存模型上来看,”下标“最确切的定义应该是”偏移(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
下面的公式比上面的公司多了一次减法 的运算,对于CPU来说,就多了一个减法指令。数组作为非常基础的数据结构,通过下标随机访问数组元素又是其非常基础的编程操作,效率的优化就要尽可能做到极致。所以为了减少一次减法操作,数组选择了从 0 开始编号,而不是从 1 开始。C 语言设计者用 0 开始计数数组下标,之后的 Java、JavaScript 等高级语言都效仿了 C 语言,或者说,为了在一定程度上减少 C 语言程序员学习 Java 的学习成本,因此继续沿用了从 0 开始计数的习惯。实际上,很多语言中数组也并不是从 0 开始计数的,比如 Matlab。甚至还有一些语言支持负数下标,比如 Python。
大多数主流虚拟机采用可达性分析算法来判断对象是否存活,在标记阶段,会遍历所有 GC ROOTS,将所有 GC ROOTS 可达的对象标记为存活。只有当标记工作完成后,清理工作才会开始。
不足:
1.效率问题。标记和清理效率都不高,但是当知道只有少量垃圾产生时会很高效。
2.空间问题。会产生不连续的内存空间碎片。
二维数组内存寻址:
对于 m * n 的数组,a [ i ][ j ] (i < m,j < n)的地址为:
address = base_address + ( i n + j) type_size
另外,对于数组访问越界造成无限循环,我理解是编译器的问题,对于不同的编译器,在内存分配时,会按照内存地址递增或递减的方式进行分配。老师的程序,如果是内存地址递减的方式,就会造成无限循环。