1:什么是数组?
2:Java中数组的声明及数组的遍历
3:数组天生的优势——索引
4:动态数组
5:封装自己的数组类——增加元素
6:封装自己的数组类——删除元素
7:封装自己的数组类——修改元素,查询元素
8:简单的时间复杂度分析
9:均摊复杂度与复杂度震荡
1:什么是数组?
数组是我们在学习任何一种编程语言最早接触到的数据结构。它是一种相同数据类型的元素存储的集合;数组中各个元素的存储是有先后顺序的,并且它们在内存中也会按照这样的顺序连续存放在一起。
2:Java中数组的声明及数组的遍历
Java中数组的声明
Java语言当中,数组常规的声明方式有三种。
// 第一种
int [] students = new int [50];
// 第二种
int [] scores = new int [3]{99,88,79};
// 第三种
String [] hobby = {"拳击","健身","跑步"}
无论是哪一种声明方式,都可以看出数组的声明直接或间接地指定了数组的大小,只有在声明数组时,告诉计算机数组的大小,计算机才可以在指定的内存中为你声明的数组开辟一串连续的空间。我们可以想象一连串小格子一个挨着一个紧密地拼凑在一起,每一个小格子都装着一个数据,而装着数据的小格子又对应计算机内存上的一个地址,每个小格子所对应的地址是连续的......
Java中数组的遍历
除了while循环,for循环等基本的遍历方式,数组还支持一种特殊的遍历:foreach.举一个简单的例子:
// 声明数组:
int [] scores = new int[50];
// 普通for循环
for(int i=0;i
因为数组在内存中连续排布,所以数组本身就具有可遍历的能力。
3:数组天生的优势——索引
数组最大的优势就是通过索引可以快速查询一个元素。因为数组在内存中开辟了一段空间,这一段连续的空间就是用来存储数组元素的,所以当我们想获取某一个数组索引的元素时,计算机只要通过这个索引就可以在开辟的内存空间中,找到存放这个元素的地址,继而通过内存地址就可以快速查询到这个元素。我们将索引查询的时间复杂度定义为O(1)。在后文有关于时间复杂度的介绍。当数组的索引变得有一定的语意时,数组的应用就更加方便了,例如:int [] students = new int [50];
如果索引代表的是班级里学生们的学号,如:students[21]
代表的是学号为21号的学生,那么这种索引就变得非常有意义。但并非所有有语意的数组索引都适用,例如一个公司有10名员工,现在需要将员工信息存储于一个emp[]数组当中,如果将员工的身份证号作为索引去创建一个数组,那么显然是不合理的。虽然索引变得有意义,但是计算机为了存储10名员工的信息就要在内存上开辟身份证号长度的内存去存储,实在是大大浪费了空间。索引最好建立在有语意的前提下,但是一定要合理。
4:动态数组
什么是动态数组?
了解Java的人一定知道,Java Collecion里面,ArrayList的底层实现原理就是动态数组,那么动态数组的含义是什么呢?在上文我们说过,如果想要声明定义一个数组,都需要直接或间接地告诉计算机我们要声明的数组的大小,只有计算机知道数组的大小后,才可以为我们的数组分配具体的内存空间。但是这样一来,数组就变得不是那么灵活了,当数组元素已满,我们就无法继续添加元素了。如果我们开辟了1000个元素空间的数组,但是仅仅存储10个元素,那这种情况也是不合理的,我们希望数组能够通过自己的存储元素的实际数量去调节自己的容量大小,这就需要数组具备动态的能力。Java 提供的数组不具备动态能力,所以,我们需要自己封装一个我们自己的数组,这个数组需要具备动态调节自身容量大小的能力,即:动态数组。
动态数组的原理
现有一个数组:int [] data = new int[5];
该数组已经无法继续添加元素了,所以我们再初始化一个新的数组,其容量为10,即数组arr容量的2倍:
int [] newData = new int [10];
然后将原数组的所有元素全部都赋值给新的数组。
再将原数组的引用 arr指向 新的数组。
这个过程转换为伪码为:
public void resize(int newCapacity){
E [] newData = (E[]) new Object[newCapacity];
for(int i=0;i
动态数组扩容或者缩容的过程封装成了一个方法:resize.在方法中,使用了泛型,用来代表所有类型的数组。
5:封装自己的数组类——增加元素
现在我们要实现添加元素的方法,这个方法可以在指定的合法的索引位置进行元素的添加。例如:在索引index=1
处添加元素88
-
原数组:
-
在索引 index=1处添加元素 88
过程转换为代码:
public void add(int index,E e){
if(index<0 || index>size)
throw new IllegalArgumentException("Index is Illegal");
if(size == data.length)
resize(2*data.length);
for(int i=this.size-1;i>=index;i--){
data[i+1] = data[i];
}
data[index] = e;
size++;
}
6:封装自己的数组类——删除元素
举例:删除索引为index=1
处的元素88
.
-
原数组
-
在索引 index=1处删除元素 88
-
删除后的数组
可以看到,在本例中,删除元素88后,我们使用size这样一个变量去维护实际数组元素的数量,实际元素的数量已经变为4了,但是原本索引为 index=4 处的元素仍然还在,以用户的角度来看,用户是无法访问data[size] 这样的一个元素的,所以最后的这个元素的存在已经没有意义了,理应被GC回收。这样的元素,被称为:"Loitering Objects",它们存在并没有意义,理应被Java的GC回收机制回收,所以我们需要手动对其进行回收工作(非必需),data[size] = null
就可以让Java的GC进行回收。删除元素的代码为:
public E remove(int index){
if(index<0 || index>=this.size)
throw new IllegalArgumentException("Index is Illegal");
E ret = data[index];
for(int i=index+1;i<=this.size-1;i++){
data[i-1] = data[i];
}
size--;
// data[size] 为loitering Object 最好将其赋值为null 让jvm GC自动进行垃圾回收
data[size] = null;
// &&data.length/2!=0的原因:防止出现当 data.length = 1 且当前无元素即size=0时
if(this.size == data.length/4 && data.length/2!=0)
resize(data.length/2);
return ret;
}
对于代码:
if(this.size == data.length/4 && data.length/2!=0)
resize(data.length/2);
在后面的文章中,有详细的解释。
7:封装自己的数组类——修改元素,查询元素
- 修改元素
public void set(int index,E e){
if(index<0 || index>=this.size)
throw new IllegalArgumentException("Index is Illegal");
data[index] = e;
}
- 查询元素
// 查:get
public E get(int index){
if(index<0 || index>=this.size)
throw new IllegalArgumentException("Index is Illegal");
return data[index];
}
// 查:contains
public boolean contains(E e){
for(int i=0;i
8:简单的时间复杂度分析
时间复杂度分析
常见的时间复杂度有:O(1),O(n),O(n logn),O(n^2),等等,其中O( ) 描述的是算法的运行时间和输入数据的关系。拿数组的索引举例,数组的索引就是一个O(1) 级别的算法,因为知道索引获取元素的过程和数据的数量级没有关系,也就是说无论数组开辟了10的空间还是开辟了100万的内存空间,索引任一下标的时间都是一个常量。再例如程序:
public static int sum(int[]nums){
int sum = 0;
for(int num:nums){
sum+=num;
}
return sum;
}
上面的程序就是一个O(n) 级别的算法,程序是计算nums数组所有元素的和,计算出结果需要将nums数组从头至尾遍历一边,而遍历这个过程则与nums元素的数量n呈线性关系,随着元素个数n越来越大,遍历需要的时间就越来越长。当然,这个时间复杂度分析其实也忽略了很多常数及一些细节,包括使用的语言不同,程序消耗的时间也是有差异的,所以O( )时间复杂度分析分析的只是一种趋势。
简单的时间复杂度分析与比对
O(1),O(n),O(n^2) 这三种级别的算法 哪一种更优秀呢?首先,O(1)级别的算法肯定是最优的,但是也有一定的弊端。拿数组的索引来说,数组之所以能够快速索引,就是因为它是一种以空间来换取时间的数据结构。如果数据的数量级是千万级的,那么数组就要在内存中开辟千万级的内存空间来支持索引,这显然是不现实。那么O(n)级别的算法一定要比O(n^2)级别的算法更优吗?其实也是不一定的,如T1 = 2000n+10000
这是一个O(n)级别的算法,T2 = n*n
这是一个O(n^2)级别的算法。当n取值很小如100时,很显然 T2的时间要小于T1,但是随着n的取值越来越大,O(n)算法的优势就会越来越大。所以从整体上来看O(1)算法最优,O(n)算法要优于O(n^2)级别的算法,实际上也确实如此,O(n)算法不仅仅是优于O(n^2),在海量级的数据下,这两种算法的差异是巨大的。
分析动态数组的时间复杂度
- 添加操作
除了add(int index,E e)
方法,为了方便一些功能 我增加了方法addLast(E e)
以及addFirst(E e)
。先不考虑resize操作带来的影响。
-
addLast(E e)
O(1) -
addFirst(E e)
O(n) -
add(int index,E e)
当index=0时,相当于向数组的头添加一个元素,所有的元素都需要向后挪动一个位置,所以是O(n)的时间复杂度,当index取值为size时,则相当于addLast操作,即向数组的末尾添加一个元素,是O(1)的时间复杂度。index的取值在0~size 的概率是相等的,这里面涉及到概率论的问题,平均而言,add(int index,E e)
的时间复杂度为:O(n/2) 。也就是说addLast (E e)
直接就可以将增加的元素添加到数组的末尾,addFirst (E e)
操作,数组挪动了n个元素,add(int index,E e)
操作平均来讲,数组需要挪动n/2个元素,它消耗的时间也同数组的个数n 呈线性关系,所以可以将add(int index,E e)
看作一个O(n)时间复杂度的操作(仅仅代表该方法的时间复杂度同元素个数呈线性关系)。
整体来看 动态数组的添加元素方法:add是一个O(n)级别时间复杂度的算法。
- 删除操作
除了remove(int index)
方法,为了方便一些功能,我也增加了方法removeFirst()
以及removeLast()
方法。同样,也不考虑resize()方法对删除操作带来的影响。
-
removeFirst()
O(n) -
removeLast()
O(1) -
remove(int index)
对于remove(int index)
方法时间复杂度的分析和add(int index,E e)
方法的分析过程类似,index的取值在 0~n的概率是相等的,平均上来看,remove(int index)
方法会使数组移动n/2个元素,也就是说remove(int index)
操作的时间与数组元素的个数n呈线性关系,remove(int index)
也是一个O(n)级别时间复杂度的算法。
整体上来看,删除操作remove为 O(n)的时间复杂度。
- 修改操作
-
set(int index,E e)
修改操作只有一个方法即:set(int index,E e)
。因为其利用了数组快速索引的特性,所以修改操作为O(1)的时间复杂度。
- 查询操作
-
get(int index)
O(1) contains(E e)
public boolean contains(E e){
for(int i=0;i
contains方法为查看数组中是否包含某个元素,因为contains方法需要将数组整体进行一次遍历,所以contains方法为O(n)的时间复杂度。
find(E e)
public int find(E e){
for(int i=0;i
find方法为查看是否数组中包含某个元素,如果包含则返回这个元素所在的索引,如果没有则返回-1。find方法为O(n)的时间复杂度。
- resize操作
resize操作的本质是将一个数组的所有元素依次赋值给一个空数组,它涉及到数组的遍历,所以resize方法为O(n)的时间复杂度。
9:均摊复杂度与复杂度震荡
以上,我们简单分析了增删该查操作的时间复杂度,但是除了改查两种操作不涉及到resize扩容或缩容操作外,添加元素和删除元素都有resize这种机制在里面。
均摊复杂度
以时间复杂度为O(1)的方法addLast(E e)
来举例,如果初始化数组的原始capacity为10,开始时,数组内没有任何元素。一直使用addLast(E e)
向数组末尾添加元素,在添加10次后,即第十一次再次添加元素则触发了一次扩容操作,扩容后的capacity为20即 原来的capacity的2倍。而在第21次添加元素操作时,又再次触发了扩容的操作。
也就是说:第n+1次addLast操作会触发依次resize方法。如果将 O(1)的操作称作1次基本操作的话,从第1次添加元素至第n+1次添加操作共进行了2n+1次基本操作(resize为 O(n),相当于n次基本操作)。n+1次addLast操作,计算机做了2n+1次基本操作即 O(1) 的操作,也就是说,平均下来,每1次addLast,计算机就要做(2n+1)/(n+1)次基本的 O(1)操作。也就是说当n这个数字趋近无穷大时,则每1次addLast操作,计算机会进行2次基本的 O(1)操作,也就是说——addLast操作和n没有关系,它仍然是一个 O(1)级别的算法。以上分析的思想就是均摊复杂度的分析思想,同理:其他的方法也可以用均摊复杂度来进行分析,得到的结果是一致的,resize虽然会触发 O(n)的操作,但是将resize的 O(n)操作平均到每一次 O(1)操作上,对我们之前分析的时间复杂度并无结果上的变化。
复杂度震荡
还记得这段代码么?
if(this.size == data.length/4 && data.length/2!=0)
resize(data.length/2);
这段代码是remove(int index)
方法中,data.length/2!=0
是为了防止出现:数组无元素,capacity已经缩容到1的这种情况,防止resize缩容到capacity=0,这很显然是错误的。代码中,当数组元素的个数减少到 数组capacity的四分之一时,触发了缩容,且缩容为当前capacity的一半,为什么要这样写呢?这种写法叫做 Lazy 机制,它是为了解决复杂度震荡的方法。如果我们将代码写成:
if(this.size == data.length/2 && data.length/2!=0)
resize(data.length/2);
那么就会出现一个问题:
当前数组的size为5,capacity为5,现在对数组进行这样的操作:
- addLast,触发扩容扩容成capacity=10
- removeLast,触发缩容,又缩容成capacity=5
- addLast,触发扩容扩容成capacity=10
- removeLast,触发缩容,又缩容成capacity=5
... ...
想必我们已经看到问题的所在了,原本为O(1) 时间复杂度的addLast和removeLast方法硬生生地被玩成了O(n)的算法,出现这个问题的原因就是因为我们在remove操作时,resize太过于着急了(too Eager),所以造成了复杂度震荡。其实解决方法已经给出,就是Lazy机制。当数组元素的个数到capacity的一半时,不着急去缩容,而是等到size==capacity/4时,将capacity的容积缩容为capacity/2。这种看似懒惰的机制,却解决了这样的一个问题。
最后附上 GitHub的代码链接:MyArray
测试代码:Main