动态数组介绍
动态数组(Dynamic array):动态数组是一种非常基础的数据结构,指的是大小可以变化的数组。之前在讲语言基础的时候,Python中的list、C++中的vector、以及Java中的ArrayList,都是动态数组,当时只是讲解了动态数组的使用,并没有讲解实现和源码。我们今天从增、删、改、查入手,详细讲解一下动态数组的源码。
构造器
我们直接看源码吧,看一下Java 11中的ArrayList是如何构造的。
ArrayList有三个构造方法,我们看一下最常用的两个。
无参构造器
先看下我最喜欢用的无参构造,就一行代码
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
我们可以看出elementData是一个Object[]类型的变量,因此可以才想到,它就是保存元素的数组。让它初始化为一个默认的空数组。
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = new Object[0];
这也就表示我们创建的是一个长度为0的Object的数组。
一个int参数的构造器
下面是有一个int参数的构造器
写法也很简单,当参数大于0时,就创建一个参数大小的数组,说明传入的参数表示初始化数组的长度。当参数小于0时,抛出异常,说明不能创建负数容量的数组。当参数等于0时,让数组初始化为一个空数组。
1 | private static final Object[] EMPTY_ELEMENTDATA = new Object[0]; |
注意了,为什么无参构造器中,数组赋值是DEFAULTCAPACITY_EMPTY_ELEMENTDATA,而传入0构造器中,数组赋值是EMPTY_ELEMENTDATA,而且两个都是长度为0的Object[]类型,有什么区别呢?
难道是开发Java的程序员们写了这么冗余的代码?这里先卖个关子,后面会给小伙伴们解析。
增加元素
我们下面将介绍动态数组最最重要的一个操作,增加元素,只有数组的元素可以增加,才能被称为动态数组。因此如何高效的添加元素,才是动态数组的关键。
一个泛型参数的add方法
我们不关心这个modCount,我们关心如何增加元素和扩容。
在一个参数的add方法中首先调用三个参数的add方法,在三参的add方法中
1 | if (s == elementData.length) { |
s表示当前的元素个数,注意在动态数组中,当前的元素个数是指我们add的元素数量,即可见的元素数量。elementData.length表示数组的容量,容量是大于等于元素数量的。
举个例子,我们初始化长度为10的动态数组,因为new Object[10],所以数组的容量是10,但是并没有添加元素,说明元素的个数为0。
理解上面的两段话,我们就很容易理解这个代码,当元素的个数等于元素的容量时,此时增加元素会超过数组的索引,因此需要进行扩容。因此会调用grow方法,在grow方法中又会调用一参的grow方法,参数传入元素数量+1,表示最小需要的容量。
在一参的grow方法中,我们会创建一个newCapacity(minCapacity)大小的数组,将原数组中的内容拷贝到新数组中。
因此可以理解为newCapacity(minCapacity)就是扩容后数组的大小,我们用for循环可以实现上面的一段话。
1 | int k = newCapacity(minCapacity); |
因此对于扩容来说,最重要的方法就是看明白newCapacity是在做什么?
先让oldCapacity表示原数组的容量,让新的容量 = 原容量 x 1.5,并向下取整。oldCapacity >> 1等于oldCapacity x 0.5并向下取整。
当扩容后的容量小于等于原容量时,说明原容量是0或者1。
- 如果数组等于默认的数组,则扩容到10。
- 如果最小需要的容量小于0,抛出异常。
- 否则扩容到最小的容量
当扩容后的容量大于原容量时,此时直接返回扩容后的容量。这里就不讨论hugeCapacity的可能性了,也比较简单。
因此这里就可以解释初始化时的DEFAULTCAPACITY_EMPTY_ELEMENTDATA和EMPTY_ELEMENTDATA。
注意:
当初始化时使用无参构造器,扩容时会直接扩容到10。再扩容到15、22、33…
当初始化时使用有参构造器,传入的容量是0,扩容时会先扩容到最小容量1,再扩容到最小容量2,然后扩容到3、4、6、9…
一个int参数和一个泛型参数的add方法
第一个参数表示插入的位置,第二个参数表示插入的元素。因为数据本质上是存放在数组里,因此如果想在某个地方插入元素,那么后面的元素必然需要向后移动。我们先看源码是如何实现的吧。
首先先检查了插入的索引是否满足条件,如果大于元素数量,或者说小于0,都会抛出异常。
然后和一个泛型参数的add方法一样,检查是否需要扩容,扩容的方式也相同。
差异的点在于扩容后,将该索引后面的元素均向后移动一个位置,并且将参数插入到该索引的位置上。
注意:ArrayList一旦扩容后,里面的元素可以删除,数组的长度不会再变小。
追溯到源码后,我们会发现这个数据结构虽然可以动态增删元素,但是效率并不是非常高,因为一旦扩容或者从某个位置插入元素,均需要拷贝大量的元素。
删除元素
一个int参数的remove方法
一个int参数表示删除该索引对应的元素,首先要坚持索引是否越界。然后取出对应索引的元素,用于返回值。最后要移除元素,并且将数组中的元素向前移动一个位置。
一个泛型参数的remove方法
一个泛型参数的remove方法表示从数组中删除某个元素。
实现是遍历这个数组,如果找到则返回true,按照一个int参数的方法将后面的元素均向前移动一个位置,如果没找到则返回false。
注意:
- remove数据时,从源码里可以看出,如果发现相等则会break,因此只移除第一个相等的元素,如果有多个值相等的元素,也只会移除一个。
- 如果ArrayList保存的是Integer类型的数据,add一个int的数据会进行自动装箱,变成一个Integer,然后add进去。如果remove传入int类型,并不会进行自动装箱,而是会匹配最适合的方法,将传入的值表示为索引,移除该索引对应的元素。如果想移除Integer类型,则需要手动进行装箱。remove(Integer.valueOf(i));
我们可以看出,删除元素时,也会牵扯到大量的元素拷贝,因此效率也不是很高。
修改元素
一个int和一个泛型参数的set方法
set方法没啥好说的,不涉及容量的变化,因此不需要扩容和移动。
第一个参数表示索引,第二个参数表示要修改的元素,对数组进行赋值即可。直接看上图源码就可以理解,这里不赘述。
源码中this.elementData(index),里面的实现就是return this.elementData[index];
取出索引的值并返回。
查找元素
一个int参数的get方法
get方法更简单,也不涉及容量的变化,不需要扩容和移动。
参数表示索引,直接取出数组该索引中的元素。
一个参数的indexOf方法
上面是根据索引查找元素,下面介绍一下根据元素查找索引,也非常简单,也就是从开始向后遍历,找到相等时返回,如果遍历结束都没找到则返回-1。
一个参数的lastIndexOf方法
顾名思义,这个方法是从后向前查找元素对应的索引,找到相等时返回,如果遍历结束都没找到则返回-1。
代码实现
讲解完毕后,要求不看源码,实现简单的Integer类型的增删改查数据结构,要求扩容机制也要一样。
这一步是非常重要的,如果只是学习并不练习,会很快被遗忘,小伙伴们一定要注意,计算机不是理科而是工科,一定要亲手敲出来,才是真正的学会了。
1 | class IntegerArrayList { |
下面写一下测试代码
1 | public class Solution { |
输出结果为
1 | array = [], capacity = 0 |
小结
到目前为止,动态数组已经讲解完毕,小伙伴们有没有手写出来动态数组呢?在我的代码中,为了更直观的展示DEFAULTCAPACITY_EMPTY_ELEMENTDATA和EMPTY_ELEMENTDATA的差异,我直接使用了一个boolean进行区分。Java源码中的ArrayList类还有许多有趣的实现,可以添加一个集合、删除一个集合(求差集)等等,这里不做过多赘述,只讲解最重要的增删改查,感兴趣的小伙伴们可以作为扩展阅读去探寻源码。