本文开始梳理数据结构的内容,从数组开始,逐层深刻。html
在java中,数组是一种效率最高的存储和随机访问对象引用序列的方式。数组是一种线性序列,这使得元素访问很是快速。可是为了这种快速所付出的代价是数组对象的大小被固定,而且是在其整个生命周期中不可被改变,简单的来讲能够理解为数组一旦被初始化,则其长度不可被改变。java
从上面一段话中咱们不难发现几个关键词:效率最高,随机访问,线性序列,长度固定。算法
从而咱们对数组的优缺点就可见一斑:数组
优势:
随机访问。数组的随机访问速度是O(1)的时间复杂度。效率极高。 缺点:
长度固定。一旦初始化完成,数组的大小被固定。灵活性不足。
上面咱们说数组是一种线性序列,如何理解这句话呢?简单来讲就是将数据码成一排进行存放。数据结构
int[] a = new int[5];//数组的静态初始化
执行上面这行代码,JVM的内存是如何分布的呢?ui
如图所示根据代码的定义,该数组的长度为5,则在栈内存中开辟长度为5的连续内存空间。而且JVM会自动根据类型分配初始值。int 类型的初始值为0。若是类型为Integer,初始值为null(这是java基础内容)。this
1 a[0] = 0; 2 a[1] = 1; 3 a[2] = 2; 4 a[3] = 3; 5 a[4] = 4;
若是再执行如上代码,内存分配以下:spa
正如以上代码所示,数组的存储效率也是极高的,可根据下标直接将目标元素存放至指定的位置。因此添加元素的时间复杂度也是O(1)级别的。3d
本章咱们的重点是封装一个属于本身的数组。对于二次封装的数组咱们想要达到的效果以下所示:code
1 使用java中的数组做为底层数据结构 2 数组的基本操做:增删改查等 3 使用泛型-增长灵活性 4 动态数组-解决数组最大的痛点
1 /** 2 * 描述:动态数组类 3 * 4 * @Author shf 5 * @Date 2019/7/18 10:48 6 * @Version V1.0 7 **/ 8 public class Array<E> {// 使用泛型 9 private final static int DEFAULT_SIZE = 10;// 默认的数组容量 10 11 private E[] data;// 动态数组的底层容器 12 private int size;// 数组的长度 13 14 /** 15 * 根据传入的 capacity 定义一个指定容量的数组 16 * @param capacity 17 */ 18 public Array(int capacity){ 19 this.data = (E[])new Object[capacity]; 20 this.size = 0; 21 } 22 23 /** 24 * 无参构造方法 - 默认容量为 DEFAULT_SIZE = 10; 25 */ 26 public Array(){ 27 this(DEFAULT_SIZE); 28 } 29 }
TIPS: java中泛型不能直接 new 出来。须要new Object,而后强转为咱们的泛型。 以下所示: this.data = (E[])new Object[capacity];
对于咱们的数组,咱们须要规定数组中的元素都存放在 size - 1的位置。这样作首先咱们能根据size参数知道,开辟的数组空间哪些被用了,哪些还没被用。另一个重要做用就是判断咱们的数组是否是已经满了,为后面的动态扩容奠基基础。
最初咱们的数组以下图所示:
咱们在数组的尾部添加一个元素也就是在size处添加一个元素。
代码实现一下:
1 /** 2 * 向数组的尾部 添加 元素 3 * @param e 4 */ 5 public void addLast(int e){ 6 if(size == data.length){ 7 throw new IllegalArgumentException("AddLast failed. Array is full."); 8 } 9 data[size] = e; 10 size ++; 11 }
以下图所示,若是咱们想在 index 为2的位置添加一个元素66。
如图中所示,咱们想在 index = 2 的位置添加元素,咱们须要将 index为2 到尾部的全部元素移动日后移动一个位置。而后将66方法 2索引位置。
接下来咱们用代码实现一下这个过程。
1 /** 2 * 在 index 的位置插入一个新元素e 3 * @param index 4 * @param e 5 */ 6 public void add(int index, int e){ 7 8 if(size == data.length) 9 throw new IllegalArgumentException("Add failed. Array is full."); 10 11 if(index < 0 || index > size) 12 throw new IllegalArgumentException("Add failed. Require index >= 0 and index <= size."); 13 14 for(int i = size - 1; i >= index ; i --) 15 data[i + 1] = data[i]; 16 17 data[index] = e; 18 19 size ++; 20 }
咱们发现有了这个方法,4.2.1中的向数组尾部添加元素就能够直接调用该方法,而且对于向数组头添加元素也是显而易见了。
1 /** 2 * 向数组 尾部 添加元素 3 * @param e 4 */ 5 public void addLast(E e){ 6 this.add(this.size, e); 7 } 8 9 /** 10 * 向数组 头部 添加元素 11 * @param e 12 */ 13 public void addFirst(E e){ 14 this.add(0, e); 15 }
删除指定位置的元素。假设咱们删除 index = 2位置的元素66。
如上图所示,我只须要将索引 2 之后的元素向前移动一个位置,并从新维护一下size便可。
代码实现一下上面过程:
1 /** 2 * 删除指定位置上的元素 3 * @param index 4 * @return 返回删除的元素 5 */ 6 public int remove(int index){ 7 if(index < 0 || index >= size) 8 throw new IllegalArgumentException("Remove failed. Index is illegal."); 9 10 int ret = data[index]; 11 for(int i = index + 1 ; i < size ; i ++) 12 data[i - 1] = data[i]; 13 size --; 14 return ret; 15 }
有了上面的方法,对于删除数组 头 或者 尾 部的元素就好办了
1 /** 2 * 删除第一个元素 3 * @return 4 */ 5 public E removeFirst(){ 6 return this.remove(0); 7 } 8 9 /** 10 * 从数组中删除最后一个元素 11 * @return 12 */ 13 public E removeLast(){ 14 return this.remove(this.size - 1); 15 }
这些操做都是不改变数组长度的操做,逻辑相对来讲就很简单了。
1 /** 2 * 获取 index 索引位置的元素 3 * @param index 4 * @return 5 */ 6 public E get(int index){ 7 if(index < 0 || index >= size){ 8 throw new IllegalArgumentException("获取失败,Index 参数不合法"); 9 } 10 return this.data[index]; 11 } 12 13 /** 14 * 获取第一个 15 * @return 16 */ 17 public E getFirst(){ 18 return get(0); 19 } 20 21 /** 22 * 获取最后一个 23 * @return 24 */ 25 public E getLast(){ 26 return get(this.size - 1); 27 } 28 29 /** 30 * 修改 index 元素位置的元素为e 31 * @param index 32 * @param e 33 */ 34 public void set(int index, E e){ 35 if(index < 0 || index >= size){ 36 throw new IllegalArgumentException("获取失败,Index 参数不合法"); 37 } 38 this.data[index] = e; 39 } 40 41 /** 42 * 查找数组中是否有元素 e 43 * @param e 44 * @return 45 */ 46 public Boolean contains(E e){ 47 for (int i = 0; i< size; i++){ 48 if(this.data[i].equals(e)){ 49 return true; 50 } 51 } 52 return false; 53 } 54 55 /** 56 * 查找数组中元素e所在的索引,若是不存在元素e,则返回-1 57 * @param e 58 * @return 59 */ 60 public int find(E e){ 61 for(int i=0; i< this.size; i++){ 62 if(this.data[i].equals(e)){ 63 return i; 64 } 65 } 66 return -1; 67 }
既然是动态数组,resize操做就是咱们的重中之重了。
扩容是添加操做触发的。
如图所示,若是咱们继续往数组中添加元素100,这时咱们就须要进行扩容了。咱们将原来的容量 capacity 扩充为原来的两倍,而后再进行添加。即:capacity * 2 = 20;(以capacity默认为10为例)
扩容的临界值:size == capacity时继续添加。
首先将容量扩充为原来的2倍:
而后添加元素100
代码上,对于add方法咱们要作以下改变:
1 /** 2 * 在 index 的位置插入一个新元素e 3 * @param index 4 * @param e 5 */ 6 public void add(int index, E e){ 7 if(index < 0 || this.size < index){ 8 throw new IllegalArgumentException("添加失败,要求参数 index >= 0 而且 index <= size"); 9 } 10 if(size == data.length){ 11 this.resize(2 * data.length);//扩容 12 } 13 for (int i = size - 1; i >= index; i--) { 14 data[i + 1] = data[i]; 15 } 16 data[index] = e; 17 size ++; 18 }
在添加元素以前,咱们进行判断size == data.length(n*capacity,n表明扩容次数,若是咱们用capacity,须要维护一个n,或者每次操做都要维护capacity,咱们直接用data.length判断)
对于resize方法,逻辑就很简单了。新建立一个容量为newCapacity的数组,将原数组中的元素拷贝到新数组便可。从这能够发现,每次resize操做因为须要有一个copy操做,时间复杂度为O(n)。
1 /** 2 * 将数组容量调整为 newCapacity 大小 3 * @param newCapacity 4 */ 5 public void resize(int newCapacity){ 6 E[] newData = (E[]) new Object[newCapacity]; 7 for (int i = 0; i< this.size; i++){ 8 newData[i] = this.data[i]; 9 } 10 this.data = newData; 11 }
缩容在删除操做中触发。
接着上面的步骤,若是咱们想删除元素100,该怎么作?
删除100元素后才达到resize的临界值 size == 1/2*capacity。因此缩容的时机为删除元素后当 size == 1/2的capacity时。
进行缩容操做:
如上图所示,这时size == 1/2*capacity,已经到了咱们缩容的时机。
咱们考虑一个问题,假如删除了元素100后,将容量缩为原来的1/2 = 10,若是这时,我又添加元素,是否是又得进行扩容,再删除一个元素,又得缩容。。。
这样频繁的进行扩容,缩容是否是很耗时?这种频繁的进行缩容和扩容会引发复杂度震荡。那咱们该如何防止复杂度的震荡呢?很简单,假如咱们为扩容--缩容取一个过渡带,即当容量为原来的1/4时再进行缩容是否是就能够避免这种问题了?答案,是的。
代码实现的两个重点:1,防止复杂度震荡。2,缩容发生在 删除一个元素后size == 当前容量的1/4时。
1 /** 2 * 删除指定位置上的元素 3 * @param index 4 * @return 5 */ 6 public E remove(int index){ 7 if(index < 0 || this.size <= index){ 8 throw new IllegalArgumentException("删除失败,Index 参数不合法"); 9 } 10 E ret = this.data[index]; 11 for(int i=index+1; i< this.size; i++){ 12 data[i-1] = data[i]; 13 } 14 size --; 15 this.data[this.size] = null; 16 if(size == this.data.length / 4 && this.data.length / 2 != 0){//防止复杂度的震荡,当size == 1/4capacity时。 17 this.resize(this.data.length / 2); 18 } 19 return ret; 20 }
addFirst(e) O(n)
addLast(e) O(1)
add(index, e) O(1)-O(n) = O(n)
因此add总体的复杂度最坏状况为O(n)。
removeLast(e) O(1)
removeFirst(e) O(n)
remove(index, e) O(1)-O(n) = O(n)
因此remove总体的复杂度最坏状况为O(n)。
对于resize来讲,每次进行一次resize,时间复杂度是O(n)。可是对于resize咱们仅仅经过resize操做来界定其时间复杂度合理吗?考虑一个问题,resize操做是每次add或者remove操做都会触发的吗?答案确定不是的。由于假设当前数组的容量为10,每次使用addLast添加一个元素,须要进行11次的添加操做,才会发生一次resize,一次resize对应10次的元素移动过程。也就是直到resize完成,一共进行了21次操做。假设capacity=n,addLast = n+1,触发resize共进行了2n+1次操做,因此对于addLast操做来讲每一次操做,须要进行2次基本操做。
这样均摊计算,addLast的均摊复杂度就是O(1)级别的。均摊复杂度有时比计算最坏的状况更有意义,由于对坏的状况不是每次都发生的。
同理对于removeLast操做来讲,均摊复杂度也是O(1)级别的。
对于addLast和removeLast操做而言,时间复杂度都是O(1)级别的,可是当咱们对这两个操做总体来看,在极端状况下可能会发生的有趣的案例
假设对于添加操做当数组size == capacity 扩容为当前容量的2倍。对于removeLast,达到当前数组容量的1/2,进行缩容,缩为当前容量的1/2。
当前数组的容量为10,这时反复进行addLast和removeLast操做。咱们会发现有意思的状况就是对于两个复杂度为O(1)级别的操做,因为每次都触发resize操做,时间复杂度每次都是最坏的状况O(n)。这种因为某种操做形成的复杂度不断变化的状况称为-复杂度的震荡。
如何解决复杂度的震荡呢?上面咱们也提到过,就是添加一个缓冲带,减小这种状况的发生。那就是当容量变为原来的1/4时进行缩容。因此对于addLast和removeLast的操做,中间间隔1/4容量的操做才会发生复杂度的震荡。这样咱们就有效的减小了复杂度的震荡。
看到这里若是你发现咱们手写的动态数组跟java中的ArrayList很类似的话,说明你对ArrayList的了解仍是很不错的。
参考文献:
《玩转数据结构-从入门到进阶-刘宇波》
《数据结构与算法分析-Java语言描述》
若有错误的地方还请留言指正。
原创不易,转载请注明原文地址:http://www.javashuo.com/article/p-egowplue-a.html