数据与算法之美第三天

140 阅读6分钟

数组:为什么很多编程语言数组都是从0开始编号

如何实现随机访问?

什么是数组:

数组是一种线性表数据结构,它用一组连续的内存空间,来存储一组具有相同类型的数据

线性表

线性表就是数据排成像一条线一样的结构,每个线性表上的数据只有前后两个方向,其中数组、链表、队列、栈等也是线性结构。

image.png

二叉树、堆、图等都叫非线性,是因为在非线性表中,数据之间并不是简单的前后关系。

image.png

连续的内存空间和相同类型的数据

因为这两个特殊的限制,才有了特性“随机访问”。但这两个限制也导致数组很多操作变得低效,删除和插入数据的时候,为了保持连续性,需要做数据迁移

数组是如何实现根据下标随机访问数组元素的?
样例

int[]a = new int[10]来举例计算机给数组a[10]分配一块连续内存空间1000~1039,其中内存块的首地址为
base_address=1000.

image.png

计算机会给每一个内存单元分配一个地址,计算机通过地址来访问问内存中的数据。当时计算机需要随机访问数组中的某个元素时,他会使用寻址公式计算出该元素的内存地址:

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+3+...+n)/n=O(n)(备注:1+2+3....+n是等差数列=n(n+1)/2)
如果数组中的数据是有序的,我们在某个位置插入一个新的元素时,就必须按这个方法搬移K之后的数据。但是如果数组中存储的数据并没有任何规律,数组只是被当做一个存储数据的集合,只要将某个数据插入到K个位置,为了比避免大规模数据搬移,我们还有一个比较简单的办法,直接将第K位的数据移动到数组元素的最后,把新的元素直接放入第K个位置

样例
假设数组a[10]中存储了如下5个元素:a,b,c,d,e.我们需要将x插入到第3个位置,我们只需要将c放入到a[5],将啊[2]赋值为x即可,最后数组中的元素如下:a,b,x,d,e,c

image.png

删除操作
思考?

如果我们要删除一个数组中的k位置的元素,为了保持内存的连续性,也需要搬移数据,不然中间就会出现空洞,内存就不连续了
和插入一样,如果删除数组末尾位置的数据,最好情况时间复杂度就是O(1);如果删除开头的数据,则最坏情况时间复杂度为O(n);平均情况时间复杂度也就是O(n)。

样例

数组a[10]中存储了8个元素:a,b,c,d,e,f,g,h.现在我们要一次删除a,b,三个元素,

image.png 为了避免d,e,f,g,h这几个数据会被搬移三次,我们可以先记录下已经删除的数据,每次的删除操作并不是真正的搬移数据,只是疾苦数据已经被删除,当数组没有更多空间存储数据时我们在执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移

容器是否能替代数组?

数组是一种基本的数据结构,而平常我们用的list等语言中的数据类型属于对其进行了封装,也就被称为容器,容器会帮助开发者自动实现一些功能去实现对数组的操作
ArrayList与数组相比,它的优势是什么呢? ArrayList最大的优势就是可以将很多数组的操作进行封装起来,还有一个优势就是可以支持动态扩容,如果每次存储空间不足的时候,他都会将空间自动扩容1.5倍大小。
数组:在定义的时候就需要指定大小,因为需要分配连续的内存空间,如果申请了大小为10的数组,当 第11个数据需要存储到数组中时,我们需要重新分配一块更大的空间,将原来的数据复制过去,然后将 新的数据插入。

注意点

因为扩容操作涉及内存申请和数据搬移,是比较耗时,所以如果事先能确定需要存储的数据大小,最好在创建ArrayList的时候事先指定数据大小。 如果要从数据库取出10000条数据放入ArrayList。样例代码中事先指定数据大小可以省掉很多次内存申请和数据搬移操作。
ArrayList users = new ArrayList(10000);
for (int i = 0; i < 10000; ++i) {
users.add(xxx);
}

思考点二

什么时候用数组会更好一点 1.java ArrayList无法存储基本类型,比如int long ,需要封装Integer、Long类,而AutoBoxing、Unboxing则有一定的性能消耗,所以如果特别关注性能,或希望使用基本类型,就可以选用数组
2.数组大小已知,并且不需要ArrayList提供的大部分方法可以使用数组
3.使用多维数组时,用数组往往会更加直观比如Object[][]array;而用容器的话则需要这样定义:ArrayList<Arraylist< object> >array.

总结

对于业务开发的话直接使用容器就足够了,完全不会影响到系统整体的性能。但如果使用开发网络框架,性能的优化需要做到极致,这个时候数据就会优于容器,称为首选

那么数组为什么要从0开始编号,而不是从1开始

从数组存储的内存模型上来看,"下标"最确切的定义应该是“偏移(offset)”,如果用a来表示数组的首地址,a[0]就是偏移为0的位置,也就是首地址,a[K]就表示偏移k个type_size的位置,所以计算a[k]的内存地址只需要用这个公式
a[k]=base_address+k * type_size
如果数组从1开始计算a[k]的内存地址公式变为:
a[k]=base_address+(k-1)* type_size
对比两个公式,发现从1开始编号,每次随机访问数组元素都多了一次减法运算,对于cpu来说也就多了一次减法指令

总结

1.数组是一种线性表结构数据,它用一组连续性内存空间存储一组具有相同类型的数据
2.线性表结构有数组、链表、队列、和栈
3.插入和删除效率很低,因为平均时间复杂度为O(n)
4.容器与数组的区别,容器具有可扩容性,并且将数组的许多方法进行了封装。但是不能存储基本类型数组可以
5.数组为什么要从0开始编号: 数组的下标是指数组的偏移量。a[0],指的是偏移了0个位置type_size。数组的内存地址计算公式
a[K]=base_address + k * type_size;
如果用1开始编号公式变为
a[k]=base_address + (k-1) * type_size