数据结构基础之动态数组

动态数组

数组的局限性

目前为止所实现的数组类,有一个非常严重的局限性,就是这个数组实际使用的还是一个静态数组,内部容量有限。在实际使用的时候,我们往往无法预估要在这个数组中存入多少个元素

解决方案

在这种情况下,如果容量首次开太大,可能会浪费很多空间,但容量太小,又有可能不够用。这时候,需要有一种解决方案使得这个数组的容量是可伸缩的,也就是所谓的动态数组

思路

  1. 首先,原数组data,容量capacity为4,数组中元素size为4
  2. 然后新开一个数组new data(原数组data),开的空间要比原来大一些(从4–>8)
  3. 遍历原数组data,赋值到new data中。此时容量capacity为8,数组中的元素size为4
  4. 本身data指向4个空间的数组,现在指向8个空间的数组(new data也指向它)

图解操作:

  • 总结
    整个过程封装在一个函数内,对于new data这个变量在函数执行完便失效了,而data由于是类的成员变量,与整个类的生存周期一致(只要类还在使用,data就是有效的)
  • 细节
    对于原来4个空间的数组,由于没有对象指向它,所以利用java的垃圾回收机制将其回收

具体代码实现

 
// 将数组空间的容量变成newCapacity大小
public void resize(int newCapacity) {
    E[] newData = (E[]) new Object[newCapacity];
    for (int i = 0; i < size; i++) {
        newData[i] = data[i];
    }
    // 由newData指向data
    data = newData;
}

动态数组的优势

使用上述方法,让我们这个数组类拥有容量的动态伸缩的能力,所以在用这个数组类的时候,使用者不必关心数组容量是否够用的问题

简单的时间复杂度分析

前提

到目前为止,都主要以编程的思想来实现我们代码的逻辑,而对于这段代码的性能方面,我们一无所知。因此才需要使用复杂度分析的方式,来解析我们的代码

定义

  • 通常我们用O(1),O(n),O(logn),O(n^2)来描述一个算法的时间复杂度
  • O描述的是算法的运行时间和输入数据之间的关系

以代码做演示:

注:实际上,我们忽略了很多常数。比如for循环里,从nums数组中取数,还有sum相加的过程等等,所花的时间都是常量。

  • 实际时间T=c1*n+c2,c1表示n次操作每次所耗费的时间常数,c2表示完成算法内其他操作所耗费时间
  • 为什么用大O,叫O(n)? ,因为忽略常数,实际时间T=c1*n+c2
  • 举例:
  •           T = 2*n  + 2                     O(n)
  •           T = 2000*n  + 10000       O(n)
  •           T =1*n*n  + 0                   O(n^2)  渐进时间复杂度,描述n趋近无穷的情况
  •           T =2*n*n  + 300n + 10      O(n^2)

分析动态数组的时间复杂度

添加操作

  • addLast(e) 向数组末尾添加一个元素,O(1)意味着,消耗时间跟数据的规模大小无关,无论数组中有多少个元素,都能在常数时间里完成
  • addFirst(e)** O(n)
  • add(index, e) 取决于index的位置,考虑极端情况则是演变成addLast(e)的O(1)操作,亦或者是退变成addFirst(e)的O(n)的操作,平均操作O(n/2)=O(n)
  • 在算法时间复杂度分析上,通常我们关注的是最坏最糟糕的情况。

综上所述,对于我们的动态数组来说,添加操作时间复杂度是O(n)级别的。

删除操作

  • removeLast(e) 在末尾删除一个元素,O(1)意味着,消耗时间跟数据的规模大小无关,无论数组中有多少个元素,都能在常数时间里完成
  • removeFirst(e)** O(n)
  • remove(index, e) 取决于index的位置,考虑极端情况则是演变成removeLast(e)的O(1)操作,亦或者是退变成removeFirst(e)的O(n)的操作,平均操作O(n/2)=O(n)
  • 在算法时间复杂度分析上,通常我们关注的是最坏最糟糕的情况。

综上所述,对于我们的动态数组来说,删除操作时间复杂度是O(n)级别的。

修改操作

  • set(index, e) 是O(1)
  • 修改操作在动态数组中非常简单,只需要知道要修改的元素所对应的索引,直接利用set(index, e)。这个时间复杂度是O(1)级别的,这是数组最大的优势,专业术语是,支持随机访问。只要知道索引是谁,便可以一下子访问到它

查询操作(根据索引和元素进行查找)

  • get(index) :O(1)
  • contains(e) : O(n)
  • find(e) :        O(n)

总结:增: O(n); 删: O(n); 当只对最后一个元素操作依然是O(n),因为存在resize()

           改:已知索引O(1),未知索引O(n);  查 : 已知索引O(1),未知索引O(n)

我们可以轻松的使用索引,去检索数组中的元素,那么在性能上便有非常强的优势。

resize()复杂度分析

防止复杂度震荡

复杂度震荡

  1. 假设现在我们有一个数组,容量是n,并且装满了元素。
  2. 这时候,我想添加一个元素,显然是需要进行扩容,容量变为2n,耗时O(n)的时间。
  3. 但是此时,我又删除了一个元素触发了缩容操作,耗时O(n)的时间。
  4. 当我们每次触发缩容或扩容操作,都会耗费O(n)额复杂度,那么这便是复杂度的震荡

分析

在特殊情况下,我们频繁的添加和删减操作,导致过于着急的去扩容或缩容
图解:

解决方案

可以采用一种相对懒惰的策略。

  • 比如说,一个满的数组,容量n,添加元素需要进行扩容,容量变为2n
  • 但在这时,在进行删除元素后,不立即进行缩容操作,而是再等等
  • 如果后面一直有删除操作的话,删除到整个数组容积的1/4,再触发缩容操作。缩容数组的1/2,而不是直接缩容到1/4
  • 此时,数组中存在1/4的元素,还预留了1/4的空间

通过这样的策略,防止了复杂度的震荡,从而有效的提升整体的性能

具体代码实现

 
 / 从数组中删除index位置的元素,返回删除的元素
 public E remove(int index) {
     if (index < 0 || index >= size) {
         throw new IllegalArgumentException("Remove failed. Index is illegal.");
     }
     E ret = data[index];
     for (int i = index + 1; i < size; i++) {
         data[i - 1] = data[i];
     }
     size--;
     // 端点(最后一位)要置空
     data[size] = null; // loitering objects != memory leak
     if (size == data.length / 4 && data.length / 2 != 0) {
         resize(data.length / 2);
     }
     return ret;
 }

猜你喜欢

转载自blog.csdn.net/mingyuli/article/details/82352976