1.向量介绍
计算机程序主要运行在内存中,而内存在逻辑上可以被看做是连续的地址。为了充分利用这一特性,在主流的编程语言中都存在一种底层的被称为数组(Array)的数据结构与之对应。在使用数组时需要事先声明固定的大小以便程序在运行时为其开辟内存空间;数组通过下标值计算出地址偏移量来对内部元素进行访问。
可以看到,原始的数组很基础,所以运行效率非常的高。但同时也存在着严重的问题:
1.由于数组的大小需要在创建时被固定下来,但大多数程序在编写时无法很好的预测到可能的数据量大小,因而也就无法在创建时设置合适的数组大小,过大则浪费内存空间;过小则会出现上溢,需要编程人员进行特别的处理。
2.访问数组时,很容易出现数组下标越界的情况。由于数组的访问是非常频繁的,因而在追求性能的语言中(如C语言),编译器都没有对数组下标越界进行额外的检查,当程序出现了意外的数组下标越界时,依然允许程序访问和修改数组外部的内存地址,这很容易造成古怪的,难以复现的bug。(Java,python等较为高级的语言为了安全起见,即使舍弃掉一定的性能也要对数组下标越界进行检查)。
针对上述问题,我们需要对原始的数组进行一定程度的封装,在不改变基本使用方式的前提下,使其在运行过程中能够针对所存储的数据量大小自适应的扩容;对数组下标的越界访问进行检查,同时提供一系列的常用接口供用户使用。
而这个基于数组封装之后的数据结构,我们一般称之为"向量(vector)"或者"顺序表(sequence list)"。
2.向量主要ADT接口介绍
由于是使用java作为实现的语言,因此在设计上参考了jdk自带的向量数据结构:java.util.ArrayList类。
1.size()
接口定义:int size();
接口描述:返回当前列表中元素的个数。
2.isEmpty()
接口定义:boolean isEmpty();
接口描述:如果当前列表中元素个数为0,返回true;否则,返回false。
3.indexOf()
接口定义:int indexOf(E e);
接口描述:判断元素"e"是否存在于列表中
4.contains()
接口定义:boolean contains(E e);
接口描述: 判断元素"e"是否存在于列表中
5.add()
接口定义:boolean add(E e);
接口描述:在列表的最后插入元素"e"。
接口定义:void add(int index,E e);
接口描述:在列表的下标为"index"位置处插入元素"e"。
6.remove()
接口定义:boolean remove(E e);
接口描述:从列表中找到并且移除"e"对象,找到并且成功移除返回true;否则返回false。
接口定义:E remove(int index);
接口描述:移除列表中下标为"index"位置处的元素,返回被移除的元素。
7.set()
接口定义:E set(int index,E e);
接口描述:将列表中下标为"index"位置处的元素替代为"e",返回被替代的元素。
8.get()
接口定义:E get(int index);
接口描述:返回列表中下标为"index"位置处的元素。
3.向量实现细节
3.1 向量属性
向量作为数组的进一步封装,内部持有着一个数组,首先我们有以下属性:
public class ArrayList <E> implements List <E>{ /** * 内部封装的数组 */ private Object[] elements; /** * 线性表默认的容量大小 * */ private static final int DEFAULT_CAPACITY = 16; /** * 扩容翻倍的基数 * */ private static final double EXPAND_BASE = 1.5; /** * 内部数组的实际大小 * */ private int capacity; /** * 当前线性表的实际大小 * */ private int size; //=================================================构造方法====================================================== /** * 默认的无参构造方法 * */ public ArrayList() { this.capacity = DEFAULT_CAPACITY; size = 0; //:::设置数组大小为默认 elements = new Object[capacity]; } /** * 设置内部数组初始大小的构造方法 * @param capacity 内部数组初始大小 * */ public ArrayList(int capacity) { if(capacity <= DEFAULT_CAPACITY){ capacity = DEFAULT_CAPACITY; } this.capacity = capacity; size = 0; //:::设置数组大小 elements = new Object[capacity]; } }
3.2 较为简单的 size(),isEmpty(),indexOf(),contains()方法实现:
@Override public int size() { return this.size; } @Override public boolean isEmpty() { return (this.size == 0); } @Override public int indexOf(E e) { //:::判断当前参数是否为null if(e != null){ //::::参数不为null //:::从前到后依次比对 for(int i=0; i<this.size; i++){ //:::判断当前item是否 equals 参数e if(e.equals(elements[i])){ //:::匹配成功,立即返回当前下标 return i; } } }else{ //:::参数为null //:::从前到后依次比对 for(int i=0; i<this.size; i++){ //:::判断当前item是否为null if(this.elements[i] == null){ //:::为null,立即返回当前下标 return i; } } } //:::遍历列表未找到相等的元素,返回特殊值"-1"代表未找到 return -1; } @Override public boolean contains(E e) { //:::复用indexOf方法,如果返回-1代表不存在;反之,则代表存在 return (indexOf(e) != -1); }
indexOf、contains方法——时间复杂度:
可以看到indexOf方法的内部是通过一次循环遍历来查询的。
因此indexOf方法、contains方法的渐进时间复杂度都是O(n),这个查询效率比未来要介绍的哈希表的查询时间复杂度O(1)有明显差距。
3.3.增删改查接口实现:
3.3.1 下标越界检查
部分增删改查接口会通过下标来进行操作,必须对访问数组的下标进行校验。
下标越界检查方法实现:
/** * 插入时,下标越界检查 * @param index 下标值 */ private void rangeCheckForAdd(int index){ //:::如果下标小于0或者大于size的值,抛出异常 //:::注意:插入时,允许插入向量的末尾,因此(index == size)是合法的 if(index > this.size || index < 0){ throw new RuntimeException("index error index=" + index + " size=" + this.size) ; } } /** * 下标越界检查 * @param index 下标值 */ private void rangeCheck(int index){ //:::如果下标小于0或者大于等于size的值,抛出异常 if(index >= this.size || index < 0){ throw new RuntimeException("index error index=" + index + " size=" + this.size) ; } }
3.3.2 插入方法实现:
@Override public boolean add(E e) { //:::插入新数据前进行扩容检查 expandCheck(); //;::在末尾插入元素 this.elements[this.size] = e; //:::size自增 this.size++; return true; } @Override public void add(int index, E e) { //:::插入时,数组下标越界检查 rangeCheckForAdd(index); //:::插入新数据前进行扩容检查 expandCheck(); //:::插入位置下标之后的元素整体向后移动一位(防止数据被覆盖,并且保证数据在数组中的下标顺序) //:::Tips: 比起for循环,System.arraycopy基于native的内存批量复制在内部数组数据量较大时具有更高的执行效率 for(int i=this.size; i>index; i--){ this.elements[i] = this.elements[i-1]; } //:::在index下标位置处插入元素"e" this.elements[index] = e; //:::size自增 this.size++; }
插入方法——时间复杂度:
可以看到,向量的插入操作会导致插入位置之后的数据整体向后平移一位。
在这里,使用了for循环将数据一个一个的进行复制。事实上,由于数组中下标连续的数据段在内存中也是连续成片的(逻辑意义上的),因此操作系统可以通过批量复制内存的方法来优化这种"数组中一片连续数据复制"的操作。java在jdk中自带的向量实现中采用了native的System.arraycopy()方法来实现这个优化操作。
在我的向量实现中,有多处这种"数组中一片连续数据复制"的操作,为了增强代码的可理解性,都使用了for循环这种较低效率的实现方式,希望能够理解。
虽然System.arraycopy能够优化这一操作的效率,但是在渐进的意义上,向量插入操作的时间复杂度为O(n)。
动态扩容:
前面我们提到,向量相比数组的一大改进就是向量能够在数据新增时根据存储的数据量进行动态的扩容,而不需要人工的干预。
向量扩容方法的实现:
/** * 内部数组扩容检查 * */ private void expandCheck(){ //:::如果当前元素个数 = 当前内部数组容量 if(this.size == this.capacity){ //:::需要扩容 //:::先暂存之前内部数组的引用 Object[] tempArray = this.elements; //:::当前内部数组扩充 一定的倍数 this.capacity = (int)(this.capacity * EXPAND_BASE); //:::内部数组指向扩充了容量的新数组 this.elements = new Object[this.capacity]; //:::为了代码的可读性,使用for循环实现新老数组的copy操作 //:::Tips: 比起for循环,System.arraycopy基于native的内存批量复制在内部数组数据量较大时具有更高的执行效率 for(int i=0; i<tempArray.length; i++){ this.elements[i] = tempArray[i]; } } }
动态扩容——时间复杂度:
动态扩容的操作由于需要进行内部数组的整体copy,其时间复杂度是O(n)。
但是站在全局的角度,动态扩容只会在插入操作导致空间不足时偶尔的被触发,所以整体来看,动态扩容的时间复杂度为O(1)。
3.3.3 删除方法实现:
@Override @SuppressWarnings("unchecked") public E remove(int index) { //:::数组下标越界检查 rangeCheck(index); //:::先暂存将要被移除的元素 E willBeRemoved = (E)this.elements[index]; //:::将删除下标位置之后的数据整体前移一位 //:::Tips: 比起for循环,System.arraycopy基于native的内存批量复制在内部数组数据量较大时具有更高的执行效率 for(int i=index+1; i<this.size; i++){ this.elements[i-1] = this.elements[i]; } //:::由于数据整体前移了一位,释放列表末尾的失效引用,增加GC效率 this.elements[(this.size - 1)] = null; //:::size自减 this.size--; //:::返回被删除的元素 return willBeRemoved; }
删除方法——时间复杂度:
向量的删除操作会导致被删除位置之后的数据整体前移一位。
和插入操作类似,向量删除操作的时间复杂度为O(n)。
3.3.4 修改/查询方法实现:
@Override @SuppressWarnings("unchecked") public E set(int index, E e) { //:::数组下标越界检查 rangeCheck(index); //:::先暂存之前index下标处元素的引用 E oldValue = (E)this.elements[index]; //:::将index下标元素设置为参数"e" this.elements[index] = e; //:::返回被替换掉的元素 return oldValue; } @Override @SuppressWarnings("unchecked") public E get(int index) { //:::数组下标越界检查 rangeCheck(index); //:::返回对应下标的元素 return (E)this.elements[index]; }
修改/查询方法——时间复杂度:
可以看到,向量的修改和查询操作都直接通过下标访问内部数组。
通过下标访问数组内部元素只需要计算偏移量即可直接访问对应数据,因此向量修改/查询操作的时间复杂度为O(1)。
3.4 向量其它接口:
3.4.1 clear方法
clear方法用于清空向量内的元素,初始化向量。
@Override public void clear() { //:::遍历列表,释放内部元素引用,增加GC效率 for(int i=0; i<this.size; i++){ this.elements[i] = null; } //:::将size重置为0 this.size = 0; }
3.4.2 trimToSize方法
前面提到,向量在空间不足时会自动的进行扩容。自动增长的特性非常方便,但是也带来了一个问题:向量会在新增元素时扩容,但出于效率的考量,删除元素却不会自动的收缩。举个例子:一个很大的向量执行clear时,虽然内部元素的引用被销毁,但是内部数组elements依然占用了很多不必要的内存空间。
因此,向量提供了trimToSize方法,允许用户在必要的时候手动的使向量收缩,以增加空间效率。
/** * 收缩内部数组,使得"内部数组的大小"和"向量逻辑大小"相匹配,提高空间利用率 * */ public void trimToSize(){ //:::如果当前向量逻辑长度 小于 内部数组的大小 if(this.size < this.capacity){ //:::创建一个和当前向量逻辑大小相等的新数组 Object[] newElements = new Object[this.size]; //:::将当前旧内部数组的数据复制到新数组中 //:::Tips: 这里使用Arrays.copy方法进行复制,效率更高 for(int i = 0; i< newElements.length; i++){ newElements[i] = this.elements[i]; } //:::用新数组替换掉之前的老内部数组 this.elements = newElements; //:::设置当前容量 this.capacity = this.size; } }
3.4.3 toString方法
@Override public String toString(){ //:::空列表 if(this.isEmpty()){ return "[]"; } //:::列表起始使用"[" StringBuilder s = new StringBuilder("["); //:::从第一个到倒数第二个元素之间 for(int i=0; i<size-1; i++){ //:::使用", "进行分割 s.append(elements[i]).append(",").append(" "); } //:::最后一个元素使用"]"结尾 s.append(elements[size - 1]).append("]"); return s.toString(); }
4.向量的Iterator(迭代器)
在我们使用数据结构容器时,会遇见以下问题:
1. 需要理解内部设计才能遍历容器中数据。如果说基于数组的向量还可以较轻松的通过循环下标来进行遍历,那么更加复杂的数据结构例如哈希表、平衡二叉树等在遍历时将变得更加困难。同时在业务代码中如果存储数据的容器类型一旦被改变(向量--->链表) ,意味着大量代码的推倒重写。
2. 缺少对容器遍历行为的抽象,导致重复代码的出现。这一问题必须在实现了多个数据结构容器之后才会体现出来。例如,上面提到的向量的toString方法中,如果将遍历内部数组的行为抽象出来,则可以使得多种不同的类型的数据结构容器复用同一个toString方法。
为此java在设计数据结构容器架构时,抽象出了Iterator接口,用于整合容器遍历的行为,并要求所有的容器都必须提供对应的Iterator接口。
Iterator接口设计:
1. hasNext()
接口定义:boolean hasNext();
接口描述:当前迭代器 是否存在下一个元素。
2. next()
接口定义:E next();
接口描述:获得迭代器 迭代的下一个元素。
3. remove()
接口定义:void remove();
接口描述: 移除迭代器指针当前指向的元素
个人认为迭代器之所以加上了remove接口,是因为很多时候迭代的操作都伴随着删除容器内部元素的需求。由于删除元素会导致内部数据结构的变化,导致无法简单的完成遍历,需要使用者熟悉容器内部实现原理,小心谨慎的实现遍历代码。
而Iterator接口的出现,将这一问题带来的复杂度交给了容器的设计者,降低了用户使用数据结构容器的难度。
向量Iterator实现:
/** * 向量 迭代器内部类 * */ private class Itr implements Iterator<E>{ /** * 迭代器下一个元素 指针下标 */ private int nextIndex = 0; /** * 迭代器当前元素 指针下标 * */ private int currentIndex = -1; @Override public boolean hasNext() { // 如果"下一个元素指针下标" 小于 "当前线性表长度" ==> 说明迭代还未结束 return this.nextIndex < ArrayList.this.size; } @Override @SuppressWarnings("unchecked") public E next() { // 当前元素指针下标 = 下一个元素指针下标 this.currentIndex = nextIndex; // 下一个元素指针下标自增,指向下一元素 this.nextIndex++; // 返回当前元素 return (E)ArrayList.this.elements[this.currentIndex]; } @Override public void remove() { if(this.currentIndex == -1){ throw new IteratorStateErrorException("迭代器状态异常: 可能在一次迭代中进行了多次remove操作"); } // 删除当前元素 ArrayList.this.remove(this.currentIndex); // 由于移除了数据,会导致下一元素被略过,因此nextIndex=currentIndex,将当前迭代器下标恢复 this.nextIndex = this.currentIndex; // 为了防止用户在一次迭代(next调用)中多次使用remove方法,将currentIndex设置为-1 this.currentIndex = -1; } }
5.向量总结
5.1 向量的性能
空间效率:向量中空间占比最大的就是一个随着存储数据规模增大而不断增大的内部数组。而数组是十分紧凑的,因此向量的空间效率非常高。
时间效率:评估一个数据结构容器的时间效率,可以从最常用的增删改查接口来进行衡量。
我们已经知道,向量的增加、删除操作的时间复杂度为O(n),效率较低;而向量的随机修改、查询操作效率则非常高,为常数的时间复杂度O(1)。对于有序向量,其查找特定元素的时间复杂度也能够被控制在O(logn)对数时间复杂度上。
因此向量在随机查询较多,而删除和增加较少的场景表现优异,但是并不适合频繁插入和删除的场景。
5.2 当前向量实现存在的缺陷
到这里,我们已经完成了一个最基础的向量数据结构。限于个人水平,以及为了代码尽可能的简单和易于理解,所以并没有做进一步的改进。
下面是我认为当前实现版本的主要缺陷:
1.不支持并发
java是一门支持多线程的语言,因此容器也必然会在多线程并发的环境下运行。
jdk的向量数据结构,Vector主要通过对方法添加synchronized关键字,用悲观锁来实现线程安全,效率较低。而另一个向量的实现,ArrayList则是采用了快速失败的,基于版本号的乐观锁对并发提供一定的支持。
2.没有站在足够高的角度构建数据结构容器的关系
java在设计自身的数据结构容器的架构时,高屋建瓴的设计出了一个庞大复杂的集合类型关系。这使得java的数据结构容器API接口非常的灵活,各种内部实现迥然不同的容器可以很轻松的互相转化,使用者可以无负担的切换所使用的数据结构容器。同时,这样的设计也使编写出抽象程度很高的API接口成为可能,减少了大量的重复代码。
3.接口不够丰富
限于篇幅,这里仅仅列举和介绍了主要的向量接口,还有许多常见的需求接口没有实现。其实,在理解了前面内容的基础之上,实现一些其它常用的接口也并不困难。
4.异常处理不够严谨
在当前版本的下标越界校验中,没有对容器可能产生的各种异常进行仔细的归类和设计,抛出的是最基础的RunTimeException,这使得用户无法针对容器抛出的异常类型进行更加细致的处理。
5.3 "自己动手实现java数据结构"系列博客后续
这是"自己动手实现java数据结构"系列的第一篇博客,因此选择了相对比较简单的"向量"数据结构入手。
我的目标并不在于写出非常完善的数据结构实现,而是尝试着用最易于接受的方式使大家熟悉常用的数据结构。如果读者能够在阅读这篇博客之后,在理解思路,原理的基础之上,自己动手实现一个初级,原始的向量数据结构,以及在此基础上进行优化,那么这篇博客的目标就完美达成啦。
本系列博客的代码在我的 github上:https://github.com/1399852153/DataStructures ,欢迎交流 0.0。