顺序表

概念

顺序表是一种线性表,使用数组在计算机内存中连续存储。线性表的顺序存储是指,在一组地址连续的存储单元中,按顺序存储线性表中的元素,使得逻辑结构上相邻的数据元素存储在相邻的物理存储单元中。简言之,通过数据元素的物理存储相邻关系来反映数据元素之间的逻辑相邻关系。
notion image
 

自定义顺序表

典型的java集合容器顺序表实现
ArrayList
notion image
 
public class SequenceList<T> implements Iterable<T>{ //存储元素的数组 private T[] eles; //记录当前顺序表中的元素个数 private int N; //构造方法 public SequenceList(int capacity){ //初始化数组 this.eles=(T[])new Object[capacity]; //初始化长度 this.N=0; } //将一个线性表置为空表 public void clear(){ this.N=0; } //判断当前线性表是否为空表 public boolean isEmpty(){ return N==0; } //获取线性表的长度 public int length(){ return N; } //获取指定位置的元素 public T get(int i){ return eles[i]; } //向线型表中添加元素t public void insert(T t){ if (N==eles.length){ resize(2*eles.length); } eles[N++]=t; } //在i元素处插入元素t public void insert(int i,T t){ if (N==eles.length){ resize(2*eles.length); } //先把i索引处的元素及其后面的元素依次向后移动一位 for(int index=N;index>i;index--){ eles[index]=eles[index-1]; } //再把t元素放到i索引处即可 eles[i]=t; //元素个数+1 N++; } //删除指定位置i处的元素,并返回该元素 public T remove(int i){ //记录索引i处的值 T current = eles[i]; //索引i后面元素依次向前移动一位即可 for(int index=i;index<N-1;index++){ eles[index]=eles[index+1]; } //元素个数-1 N--; if (N<eles.length/4){ resize(eles.length/2); } return current; } //查找t元素第一次出现的位置 public int indexOf(T t){ for(int i=0;i<N;i++){ if (eles[i].equals(t)){ return i; } } return -1; } //根据参数newSize,重置eles的大小 public void resize(int newSize){ //定义一个临时数组,指向原数组 T[] temp=eles; //创建新数组 eles=(T[])new Object[newSize]; //把原数组的数据拷贝到新数组即可 for(int i=0;i<N;i++){ eles[i]=temp[i]; } } @Override public Iterator<T> iterator() { return new SIterator(); } private class SIterator implements Iterator{ private int cusor; public SIterator(){ this.cusor=0; } @Override public boolean hasNext() { return cusor<N; } @Override public Object next() { return eles[cusor++]; } } }

顺序表的遍历

一般作为容器存储数据,都需要向外部提供遍历的方式,因此我们需要给顺序表提供遍历方式。
在java中,遍历集合的方式一般都是用的是foreach循环,如果想让我们的SequenceList也能支持foreach循环,则需要做如下操作: 1.让SequenceList实现Iterable接口,重写iterator方法; 2.在SequenceList内部提供一个内部类SIterator,实现Iterator接口,重写hasNext方法和next方法;

容量变化

顺序表的容量可变
在之前的实现中,当我们使用SequenceList时,先new SequenceList(5)创建一个对象,创建对象时就需要指定容器的大小,初始化指定大小的数组来存储元素,当我们插入元素时,如果已经插入了5个元素,还要继续插入数据,则会报错,就不能插入了。这种设计不符合容器的设计理念,因此我们在设计顺序表时,应该考虑它的容量的伸缩性。 考虑容器的容量伸缩性,其实就是改变存储数据元素的数组的大小,那我们需要考虑什么时候需要改变数组的大 小?
  1. 添加元素 添加元素时,应该检查当前数组的大小是否能容纳新的元素,如果不能容纳,则需要创建新的容量更大的数组,我们这里创建一个是原数组两倍容量的新数组存储元素。
    1. notion image
  1. 移除元素时
    1. 移除元素时,应该检查当前数组的大小是否太大,比如正在用100个容量的数组存储10个元素,这样就会造成内存空间的浪费,应该创建一个容量更小的数组存储元素。如果我们发现数据元素的数量不足数组容量的1/4,则创建一个是原数组容量的1/2的新数组存储元素。
      notion image
顺序表的时间复杂度
get(i):不难看出,不论数据元素量N有多大,只需要一次eles[i]就可以获取到对应的元素,所以时间复杂度为O(1);
insert(int i,T t):每一次插入,都需要把i位置后面的元素移动一次,随着元素数量N的增大,移动的元素也越多,时间复杂为O(n);
remove(int i):每一次删除,都需要把i位置后面的元素移动一次,随着数据量N的增大,移动的元素也越多,时间复杂度为O(n);
由于顺序表的底层由数组实现,数组的长度是固定的,所以在操作的过程中涉及到了容器扩容操作。这样会导致顺序表在使用过程中的时间复杂度不是线性的,在某些需要扩容的结点处,耗时会突增,尤其是元素越多,这个问题越明显。

低效删除及优化

在追求内存连续线的前提下,频繁的删除数组元素,会移动多次数组内其他元素,时间复杂度O(n)影响性能,
在一些业务场景一下,会对数组的删除操作进行优化,降低其时间复杂度,提供程序执行效率

lazy delete

数组 a[10] 中存储了 8 个元素:a,b,c,d,e,f,g,h。现在,我们要依次删除 a,b,c 三个元素。
notion image
为了避免 d,e,f,g,h 这几个数据会被搬移三次,我们可以先记录下已经删除的数据。每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除。当数组没有更多空间存储数据时,我们再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。
这也是 JVM 标记清除垃圾回收算法的核心思想

数组和容器选择

1.Java ArrayList 无法存储基本类型,比如 int、long,需要封装为 Integer、Long 类,而 Autoboxing、Unboxing 则有一定的性能消耗,所以如果特别关注性能,或者希望使用基本类型,就可以选用数组。
2. 如果数据大小事先已知,并且对数据的操作非常简单,用不到 ArrayList 提供的大部分方法,也可以直接使用数组。
3. 还有一个是我个人的喜好,当要表示多维数组时,用数组往往会更加直观。比如 Object[][] array;而用容器的话则需要这样定义:ArrayList<ArrayList > array。
我总结一下,对于业务开发,直接使用容器就足够了,省时省力。毕竟损耗一丢丢性能,完全不会影响到系统整体的性能。但如果你是做一些非常底层的开发,比如开发网络框架,性能的优化需要做到极致,这个时候数组就会优于容器,成为首选。

数组下标从0开始的原因

现在我们来思考开篇的问题:为什么大多数编程语言中,数组要从 0 开始编号,而不是从 1 开始呢?
从数组存储的内存模型上来看,“下标”最确切的定义应该是“偏移(offset)”。前面也讲到,如果用 a 来表示数组的首地址,a[0] 就是偏移为 0 的位置,也就是首地址,a[k] 就表示偏移 k 个 type_size 的位置,所以计算 a[k] 的内存地址只需要用这个公式:
a[k]_address = base_address + k * type_size 复制代码
但是,如果数组从 1 开始计数,那我们计算数组元素 a[k] 的内存地址就会变为:
a[k]_address = base_address + (k-1)*type_size 复制代码
对比两个公式,我们不难发现,从 1 开始编号,每次随机访问数组元素都多了一次减法运算,对于 CPU 来说,就是多了一次减法指令。
数组作为非常基础的数据结构,通过下标随机访问数组元素又是其非常基础的编程操作,效率的优化就要尽可能做到极致。所以为了减少一次减法操作,数组选择了从 0 开始编号,而不是从 1 开始。
不过我认为,上面解释得再多其实都算不上压倒性的证明,说数组起始编号非 0 开始不可。所以我觉得最主要的原因可能是历史原因。
C 语言设计者用 0 开始计数数组下标,之后的 Java、JavaScript 等高级语言都效仿了 C 语言,或者说,为了在一定程度上减少 C 语言程序员学习 Java 的学习成本,因此继续沿用了从 0 开始计数的习惯。实际上,很多语言中数组也并不是从 0 开始计数的,比如 Matlab。甚至还有一些语言支持负数下标,比如 Python。