数组是我们在学习任何一种编程语言最早接触到的数据结构。它是一种相同数据类型的元素存储的集合;数组中各个元素的存储是有先后顺序的,并且它们在内存中也会按照这样的顺序连续存放在一起。
Java语言当中,数组常规的声明方式有三种。
// 第一种 int [] students = new int [50]; // 第二种 int [] scores = new int [3]{99,88,79}; // 第三种 String [] hobby = {"拳击","健身","跑步"} 复制代码
无论是哪一种声明方式,都可以看出数组的声明直接或间接地指定了数组的大小,只有在声明数组时,告诉计算机数组的大小,计算机才可以在指定的内存中为你声明的数组开辟一串连续的空间。我们可以想象一连串小格子一个挨着一个紧密地拼凑在一起,每一个小格子都装着一个数据,而装着数据的小格子又对应计算机内存上的一个地址,每个小格子所对应的地址是连续的......
除了while循环,for循环等基本的遍历方式,数组还支持一种特殊的遍历:foreach.举一个简单的例子:
// 声明数组: int [] scores = new int[50]; // 普通for循环 for(int i=0;i<scores.length;i++){ System.out.println(score); } // foreach遍历 for(int score:scores){ System.out.println(score); } 复制代码
因为数组在内存中连续排布,所以数组本身就具有可遍历的能力。
数组最大的优势就是通过索引可以快速查询一个元素。因为数组在内存中开辟了一段空间,这一段连续的空间就是用来存储数组元素的,所以当我们想获取某一个数组索引的元素时,计算机只要通过这个索引就可以在开辟的内存空间中,找到存放这个元素的地址,继而通过内存地址就可以快速查询到这个元素。我们将索引查询的时间复杂度定义为 O(1) 。在后文有关于时间复杂度的介绍。当数组的索引变得有一定的语意时,数组的应用就更加方便了,例如: int [] students = new int [50];
如果索引代表的是班级里学生们的学号,如: students[21]
代表的是学号为21号的学生,那么这种索引就变得非常有意义。但并非所有有语意的数组索引都适用,例如一个公司有10名员工,现在需要将员工信息存储于一个emp[]数组当中,如果将员工的身份证号作为索引去创建一个数组,那么显然是不合理的。虽然索引变得有意义,但是计算机为了存储10名员工的信息就要在内存上开辟身份证号长度的内存去存储,实在是大大浪费了空间。索引最好建立在有语意的前提下,但是一定要合理。
了解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<size;i++){ newData[i] = data[i]; } data= newData; } 复制代码
动态数组扩容或者缩容的过程封装成了一个方法:resize.在方法中,使用了泛型,用来代表所有类型的数组。
现在我们要实现添加元素的方法,这个方法可以在指定的合法的索引位置进行元素的添加。例如:在索引 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++; } 复制代码
举例:删除索引为 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); 复制代码
在后面的文章中,有详细的解释。
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<size;i++){ if(data[i].equals(e)){ return true; } } return false; } // 查:find public int find(E e){ for(int i=0;i<size;i++){ if(data[i].equals(e)){ return i; } } return -1; } 复制代码
常见的时间复杂度有: 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<size;i++){ if(data[i].equals(e)) return true; } return false; } 复制代码
contains方法为查看数组中是否包含某个元素,因为contains方法需要将数组整体进行一次遍历,所以contains方法为*O(n)*的时间复杂度。
find(E e)
public int find(E e){ for(int i=0;i<size;i++){ if(data[i].equals(e)) return i; } return -1; } 复制代码
find方法为查看是否数组中包含某个元素,如果包含则返回这个元素所在的索引,如果没有则返回-1。find方法为*O(n) 的时间复杂度。 5. resize操作 resize操作的本质是将一个数组的所有元素依次赋值给一个空数组,它涉及到数组的遍历,所以resize方法为 O(n)*的时间复杂度。
以上,我们简单分析了增删该查操作的时间复杂度,但是除了改查两种操作不涉及到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,现在对数组进行这样的操作:
想必我们已经看到问题的所在了,原本为 O(1) 时间复杂度的addLast和removeLast方法硬生生地被玩成了*O(n)*的算法,出现这个问题的原因就是因为我们在remove操作时,resize太过于着急了( too Eager ),所以造成了复杂度震荡。其实解决方法已经给出,就是Lazy机制。当数组元素的个数到capacity的一半时,不着急去缩容,而是等到size==capacity/4时,将capacity的容积缩容为capacity/2。这种看似懒惰的机制,却解决了这样的一个问题。
最后附上 GitHub的代码链接: MyArray 测试代码: Main