文章
大家好,这里是爱好编程的斌斌。笔者最近在总结数据结构与算法的知识点,如果你对此感兴趣可以点个关注支持一下!
在很多编程语言中,都有数组这种数据类型。除此之外,它还是一种数据结构。即使它非常基础和容易,但相信很多人对它只存在与表面,没有做深层的探索!
在大部分编程语言中,数组都是从 0 开始编号的,但你是否下意识地想过,为什么数组要从 0 开始编号,而不是从 1 开始呢? 从 1 开始不是更符合人类的思维习惯吗?
你可以带着这个问题来看这篇文章。
一、如何实现随机访问?
1.什么是数组?
数组是一种,利用连续的内存空间,来存储一组相同类型的数据,的线性表数据结构。
要想理解一个概念,只要理解它的关键点就好了。数组的定义有这几个关键信息:
连续的内存空间+相同类型的数据:
简单理解,数组的中数据的数据类型是一致的,比如:int [] a = new int [10];这里申请了一个长度为10的整数类型的数组,如图,系统会为数组 a[10],分配了一块连续内存空间 1000~1039,其中,内存块的首地址为 base_address = 1000。其中a数组只能存储int类型的数据。
正是因为这两个特点,树组拥有了一个堪称"杀手锏"的特性—随机访问,也就是说可以根据索引直接获取对应位置的元素。有利就有弊,在数组中插入、删除数据之后,为了保证内存的连续性,需要大量的搬移数据。
说到元素访问,那你知道数组是如何实现根据下标随机访问数组元素的吗?
就那上面这个例子来说。计算机会给每个内存单元分配一个地址,并根据这个地址访问内存中的数据。当需要访问数组中的数据时,计算机会根据下面的寻址公式,首先计算出该元素的内存地址: a[i]_address = base_address + i * data_type_size
其中,base_address为数组首地址,data_type_size为数组中每一个元素的大小。
需要注意的是:数组支持随机访问,且时间复杂度为O(1),但这里说的不是数组的查询,即便是用二分查找,时间复杂度也为O(logn),很多人在这里会把"随机访问"误认为是查询。
线性表数据结构:
线性表,顾名思义,数据排成像一条线一样的结构。数据与数据之间仅仅是简单的前后关系。拿下面这幅图来理解,除了数组,链表、队列、栈等也是线性表结构。
与之相对应的是非线性表数据结构,即数据之间不是简单的前后关系。比如:树、图等等。笔者的数据结构集合中都有相关描述,如果你想进一步学习,可以留个关注。
二、低效的插入、删除操作
前面提到,数组支持随机访问,非常方便根据索引获取元素。但是插入和删除就没有那么高效了,为什么没有那么高效呢?有什么改进办法不?
1.低效的插入操作
假设存在一个数组b,它的长度为n。现在,需要往数组的第k位置插入一个元素b,需要将k到(n-1)这部分的元素顺序得往后面移动一位,把第k个位置给腾出来给元素b。这样的操作看起来比较简单,但是它的时间复杂度是多少呢?我们简单得分析一下:
理想情况上,我们往数组最后一个位置插入元素,不需要搬移数据,时间复杂度为O(1);最坏情况下,我们往数组第一个位置插入元素,之后的元素都需要顺序往后移,时间复杂度为O(n)。由于每种情况发生的概率一致,所以平均时间复杂度=(1+2+…+n)/n=O(n).
当数据是按顺序排列的时候,数组的插入只能按照上面的操作进行,没有优化的情况;但是,当数据不是有序的时候,在往第k个位置插入元素,我们可以先将第k个位置的元素放到数组的最后,然后把添加的元素b放到空出来的位置。这样,插入的时间复杂度就从O(n)下降为O(1).
举例说明,我们现在有一个无序数组a[6],存了元素:a、c、d、e,现在需要往第三个位置插入x元素。先把c元素移到最后的位置上,然后bax插入到2索引位置。
2.低效的删除操作
类比插入操作,数组删除的删除,为了保证内存的连续性,需要将元素往前面移,否则中间就空出来了,出现一个空洞。最好情况,删除最后一个数据,时间复杂度为O(1);最坏情况下,删除第一个元素,时间复杂度为O(n).
三、警惕数组索引越界异常
为什么需要警惕"数组访问越界"呢?
从两种情况考虑,第一种,如果你使用的编程语言对数组越界没有处理,像C语言。对于C语言,只要访问的不是受限的内存,所有内存空间是可以自由访问的。也就意味着,只要通过指针公式计算出来的地址有效,并且索引越界了,依然可以访问。针对这种情况,一般都会出现莫名其妙的逻辑错误,debug 的难度非常的大。而且,很多计算机病毒也正是利用到了代码中的数组越界可以访问非法地址的漏洞,来攻击系统,所以写代码的时候一定要警惕数组越界。
另一种情况,如果你是用的编程语言以及预先处理了数组索引越界异常,比如Java语言,会存在编译时异常,也需要你进行手动处理。
四、容器和数组怎么选?
1.容器的有点
容器支持动态扩容,长度可变化,数组长度固定不可变;容器提供了很多方法供我们使用,比数组更加实用。
2.数组的优点
可以存储基本数据类型,容器不可以,需要将其转换成对应的包装类;Java中的自动拆装箱需要消耗一定的性能,数组的不需要,性能高;数组对数据的操作非常简单,如果事先知道数据的大小并且数组操作简单可以直接用数组;当用到二维数组时,数组比容器更加直观。
总结:
在业务开发当中,可以直接使用容器,毕竟消耗一丢丢性能对整个系统并无太大的影响,省时省力。如果做的是非常底层的开发,性能优化需要做到极致,这个时候数组优于容器。
五、解答开篇
为什么大多数编程语言中,数组要从 0 开始编号,而不是从 1 开始呢?
我们从两个角度进行分析,首先,从数组的内存寻址公式。如果数组从0开始编号,那寻址公式为:a[k]_address = base_address + k * type_size;如果数组从1开始编号,那寻址公式为:a[k]_address = base_address + (k-1)*type_size。发现:从1开始编号的寻址公式比从0开始的寻址公式对了一个减法操作。对于数组这种非常基础的数据结构,性能优化需要做到极致,所以需要选择从0开始编号。
动一下脑子,一个减法能消耗多少性能?不见得。所以,我觉得主要原因,还是历史原因。C语言的设计者用0开始对数组索引进行编号,后面的Java和JavaScript沿用了C语言的设计.
六、课后思考题
前面我们讲到一维数组的内存寻址公式,那你可以思考一下,类比一下,二维数组的内存寻址公式是怎样的呢?
假设我们有一个二维数组a[i][j],也就是说:a是由i个长度为j的一维数组组成。现在我们要计算a[m][n]的内存地址,首先它是在第(m+1)个一维数组中,且一维数组的长度为j,所以m*j;其次它是指第(m+1)个一维数组的第n个元素。即为a[m][n]_address = base_address +( m * j + n )type_size
七、总结
数组用一块连续的内存空间,来存储相同类型的一组数据,最大的特点就是支持随机访问,但插入、删除操作也因此变得比较低效,平均情况时间复杂度为 O(n)。在平时的业务开发中,我们可以直接使用编程语言提供的容器类,但是,如果是特别底层的开发,直接使用数组可能会更合适。