前言:

之前我们实现了双端队列,这是一种比较完美的数据结构,在两端进行插入和删除都非常迅速,然而,当我们需要访问队列中的第i项时,总是需要从哨兵结点开始,先遍历前i-1项,才能找到第i项,最坏的情况是i位于中间,此时我们需要遍历链表长度n的一半n/2,假设n非常大时,时间的耗费就会比较大( O(n) ),考虑到数组可以直接访问下标的特性,完成第i项的查找几乎是O(1),因此本次课决定使用数组实现List


1.AList的抽象数据类型

首先建立class AList,成员分别是一个int数组和长度size,构造函数初始化,先分配一个长度为100,各项均为0的数组item,由于最开始我们并未添加任何元素,因此size = 0

public class AList {
    private int[] items;    
    private int size;

public AList() {
    items = new int[100];
    size = 0;
  }
}

与前面的Deque相同,对于AList,有如下操作:

  • addLast(): 末尾添加数据
  • removeLast(): 删除并返回末尾数据
  • get(): 返回第i项数据
  • getLast(): 获取数组最后一项元素数据
  • size(): 返回List的长度

image.png
通过观察,我们可以发现数组拥有以下不变量:

  • 在数组末尾添加下一项元素的位置(下标)总是size
  • size总是数组的长度
  • 数组最后一项的下标总是size - 1(因为数组的index是从0开始的)

因此我们可以写出上述数据操作了:

 //数组末尾添加元素
  public void addLast(int x) {
    items[size] = x;
    size += 1;
  }
 
 //获取数组最后一项元素
  public int getLast() {
    return items[size - 1];
  }
 
 //查询第i项
  public int get(int i) {
    return items[i];
  }

 //查询数组长度
  public int size() {
    return size;
  }

 //删除末尾项
 public int removeLast(){
    int x = items[size-1];
    size = size - 1;
    return x;
  }

2.Resizing Array

最开始的时候我们定义的item数组大小默认是100,假设现在需要添加第101个数据11,数组当前的容量显然是不够的,此时需要我们重新调整数组的大小,也就是resizing Array。我们先考虑最简单的方式,也就是使用System.arraycopy():

int[] a = new int[size + 1];
System.arraycopy(items, 0, a, 0, size);
a[size] = 11;
items = a;
size = size + 1;

我们重新声明了一个新数组a,比原数组item长1位,并把原数组item的值copy到a中,把a最后一位用来存放新数据,由此解决数组容量不够的问题。之后还可以写成一个专用的函数resize()来操作开辟与复制数组:

private void resize(int capacity) {
  int[] a = new int[capacity];
  System.arraycopy(items, 0, a, 0, size);
  items = a;
}
 
public void addLast(int x) {
  if (size == items.length) {
    resize(size + 1);
  }
  items[size] = x;
  size += 1;
}

3.分析Resizing Array的时间复杂度

回想以下我们之前的SLList,往队尾添加一个元素的时间复杂度是O(1),那么对于AList来说,与SLList相比时间复杂度如何呢?
考虑一下,如果此时原数组大小是100,向其中新增一个数据,那么addLast()需要做的是:

  1. 新声明一个数组,大小为101,并拷贝原数组的100个元素
  2. 添加新增元素至101

操作总共需要201个内存空间
如果新增两个元素呢?按照上面的步骤,总共需要101+102 = 203个空间,新增1000个,需要101+102+103+......+1000 = 495450 约50w个空间,可见内存空间消耗很大

在Linux下,可以在运行命令java filename前加一个time,从而获得程序的运行时间
以下是SLListAList的末尾插入新元素对比图:
image.png
由于前者每次在末尾添加元素的时间复杂度是O(1),常数阶,总的操作时间的累加即常数的积分:一次函数,是一条直线
对于后者,每次的拷贝操作是从0开始将原数组全部元素拷贝至新数组,拷贝操作时间复杂度是O(n),总的拷贝次数的累加即n的积分,也就是抛物线


4.优化Resizing Array

时间复杂度优化

将每次size+1改成size•refactor,也就是每次数组大小的扩充从加一个数变成乘以某个数,从而化为Geometric Resizing,可以大幅降低时间复杂度

public void addLast(int x) {
  if (size == items.length) {
    resize(size * RFACTOR);
  }
  items[size] = x;
  size += 1;
}

空间内存优化

假如我们有一个大小为100000的数组,当我删除其99999项后,原数组只剩下1项。事实上,删除操作只是将size不断左移,原来数组所占的内存空间仍然存在,在这种情况下剩余内存大量浪费。考虑引入usage ratio,定义为

“usage ratio” R = size / items.length

如果R<0.25,则将数组长度减半


5.泛型数组

正如我们之前对SLList所做的,我们可以修改AList以便它可以保存任何数据类型,而不仅仅是整数,例如,使用ElemType作为泛型参数
在此之前需要注意的一点是Java 不允许我们创建泛型对象数组,也就是说不能这样写:

Elemtype[] items = new Elemtype[8];

相反,可以先创建一个Object类型的数组,再强制类型转化为泛型数组:

Elemtype[] items = (Elemtype []) new Object[8];

尽管编译器会产生警告,以后解释...

最后是一点优化,前面我们写removeLast()的时候,只使用了

size = size-1;

末尾的元素值在数组中仍然存在,只是被我们忽视了,当数组较大时,这将造成内存浪费,因此在执行size = size-1之前,将原来的末尾元素值赋值为null,让垃圾回收器将其回收, avoid "loitering"

  items[size - 1] = null;
  size -= 1; 

Fallenpetals
4 声望9 粉丝