1. 向量与列表
向量(Vector)与列表(List)是两种典型的序列(Sequence)。所谓序列,就是指一组依次排列的对象。此特点与数组十分相似,只是对于向量和列表而言,它们不再受到“容量”的制约,简单的来说,可以将它们看成一种“可变的数组”。
向量和列表所支持的操作类似,其主要的区别在于实现的方式,以及由此引起的时间复杂度的区别。
实现方式 | |
---|---|
向量(Vector) | 基于数组实现,因此也被称作数组列表(ArrayList) |
列表(List) | 基于链表实现,是对链表结构的抽象与扩展 |
正如前面所说的,向量与列表拥有类似的方法接口,它们的 ADT(Abstract Data Type)如下所示:
方法 | 功能描述 |
---|---|
size() | 返回当前向量/列表中对象的数目 |
isEmpty() | 用于检测向量/列表是否为空 |
get(index) | 返回 index 对应的元素 |
add(index, element) | 将 element 插入到 index 处 |
remove(index) | 删除 index 对应的元素 |
序列的应用十分广泛,就其功能而言,栈和队列都可以看作是带有限制条件的序列:插入、删除和访问操作只限于首元素的,就是栈;删除和访问操作只限于首元素、插入操作只限于末元素的,就是队列。
2. 时间复杂度的区别
由于实现方式不同,向量与列表在插入、删除和读取的时间复杂度上有所区别。
-
对于向量而言,由于它是基于数组实现的,所以其静态获取某个 index 上数据的时间复杂度只有 O(1)。但对于动态插入/删除操作而言,虽然找到对应的元素只需要 O(1) 的时间,但在插入/删除操作之后还需将该位置后边的元素依此向后/向前移动一个位置,此操作需要 O(n) 的时间,故整个操作的时间复杂度为 O(n)。
-
对于列表而言,由于它是基于链表实现的,所以静态获取某个数据需要遍历列表,最差的情况下需要 n 次操作,故其时间复杂度为 O(n)。而对于插入/删除操作而言,如果已知某元素的位置,插入/删除操作只需要修改其前后节点的引用即可,此操作可在常数时间内完成,即时间复杂度为 O(1)。但正如前面所说的,找到一个元素的位置需要花费 O(n) 的时间,即整体操作的时间复杂度依旧是 O(n)。
但是,如果能够利用某种数据结构(位置,Position)直接定位列表中的元素,就可以在常数时间内完成插入、删除和读取的操作。
3. 位置(Position)
为避免每次访问节点时都要对列表进行遍历,按照面向对象的规范对列表的节点对象进行了封装,称作位置(Position),其 ADT 如下所示:
方法 | 功能描述 |
---|---|
getElement() | 返回存放于当前位置的元素 |
setElement(element) | 将 element 放入当前位置 |
按线性次序排列的一组位置,就构成了一个列表。在列表中,各个位置都是相对而言的——相对于它的前、后邻居。利用位置,我们可以通过以下的方式实现列表的插入:
算法:insertBefore(p, e)
输入:位置p和元素e
输出:将e插入至p之前后,返回插入的位置
{
创建一个新节点v;
v.setElement(e);//用v来存放e
v.setNext(p);//以p为v的直接后继
v.setPrev(p.getPrev);//以p的直接前驱为v的直接前驱
(p.getPrev()).setNext(v);//p的直接前驱应该以v为直接后继
p.setPrev(v);//作为v的直接后继,p应该以v为直接前驱
return((Position)v);//v作为一个位置被返回
}
删除操作与此类似,即改变其前后位置的引用即可,当然修改引用的操作并不能直接通过位置对象实现(此接口只提供了访问和修改位置内部元素的方法),需要在 remove 方法中隐式的实现。这也符合面向对象的设计思想,通过封装位置这一数据结构,保护了数据的安全性,避免了可以直接对前后的引用进行操作等风险。
利用位置,就可以直接访问其中的元素,在常数时间内完成插入、删除和读取的操作。
4. 基于可扩充数组实现的向量
Java本身也提供了与向量ADT功能类似的两个类:java.util.ArrayList 和 java.util.Vector。对于 ArrayList 类,乍一看可能回将其误认为是列表,但正如我们前面所说的,对数组结构进行抽象与扩展之后,就可以得到向量结构,因此向量也称作数组列表,而将 ArrayList 翻译下不正好就是数组列表么!
正是由于向量是基于数组实现的,为了避免数组容量带来的限制,ArrayList 类和 Vector 类都采用了动态扩充数组容量的策略(当元素个数达到数组容量时,将所有的元素copy到一个新的数组中,ArrayList 和 Vector 类都默认新数组的容量是老数组的两倍,并用新数组代替老数组进行工作),这也是实现向量这一数据结构的一般策略。
当然,动态扩充数组容量涉及到两个数组之间的元素拷贝,势必会影响时间复杂度。但是,由于并不是每次插入操作都是需要扩容的,因此无法采用常规的方法来度量和分析其复杂度。为此可以引入分摊复杂度的概念,所谓分摊复杂度,就是指在连续执行的足够多次操作中,每次操作所需的平均运行时间。
可以证明,基于可扩充数组实现的向量,如果扩容后的容量是之前的两倍,则每次数组扩容的分摊运行时间为O(1)。动态扩充数组容量的策略不仅可行,而且就分摊复杂度而言,其效率也足以令人满意。