文章目录
写在前面
最近学校的作业是有关jml规格化设计的。原本是很简单的一个单元,但是由于助教限制了CPU执行时间,大家都开始扒各种容器的运行效率。这里简单的对常用容器做一个总结。
集合框架
首先来看一看Java的集合框架。
图片转载自:http://pierrchen.blogspot.com/2014/03/java-collections-framework-cheat-sheet.html
可以看到Map
和Collection
分别是集合框架的两大顶层接口。而Collection
之下,又分为List
, Queue
和Set
。再往下几层,就到我们熟知的容器类了。毕向东老师教导我们,阅读API时要自顶向下去看。因此本文也将针对接口中列举的方法,对常用容器如ArrayList
, HashMap
等进行分析。
函数式编程
Java8中引入了函数式编程的概念。属于Collection
体系的所有容器类都支持stream
方法,而Map
一派则可以通过自身方法转化为Collection
。因此可以说,集合框架内的所有容器都是支持函数式编程的。
之所以在这里提到函数时编程,是因为Stream
对象有一个非常重要的方法distinct
(说他重要主要是因为作业里会用到)。之前纠结了很久究竟是用内置的distinct
方法,还是手写一个。查了一些资料都说stream
的运行效率要比原生for
循环低得多,但是因为懒最后还是选择了stream
。因此这里还是把stream
单独拿出来说一下,看一看stream
的复杂度究竟有多高。
Map
HashMap
存储形式
HashMap
主要以链式列表存储,在哈希表冲突过多(单个链表长度超过 8)时,该链表会转化为红黑树进行存储优化。
static class Node<K,V> implements Map.Entry<K,V> {
// ...
Node<K,V> next;
}
transient Node<K,V>[] table;
元素查找
HashMap
查找元素的复杂度可以达到 O(1) 的水平。这是由于HashMap
首先依靠hash
在静态数组中找到链表头,然后再遍历链表查找。如果一个元素的hashCode
方法可以很好的避免元素间的冲撞,那么这样的查找不需要遍历就可以完成。在最坏的情况下,查找需要在红黑树中完成,这时的复杂度是 O(log n)。
由于红黑树的操作比较复杂,这里将HashMap
源码进行了一点整理,删去了有关红黑树的部分。可以看出在平均条件下,HashMap
遍历只是对一个元素个数小于 8 的链表进行遍历,复杂度自然很低。
final Node<K,V> getNode(int hash, Object key) {
Node<K,V> e = table[(n - 1) & hash];
while (e != null) {
if (key != null && key.equals(e.key))
return e;
e = e.next;
}
return null;
}
元素插入
由于HashMap
的特性,在插入元素前需要对集合进行检索,看元素是否已经存在。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V> p = table[(n - 1) & hash];
Node<K,V> e = null;
if (p != null && key != null && key.equals(e.key)) {
e = p;
} else {
while ((e = p.next) != null) {
if (key != null && key.equals(e.key))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
p.next = newNode(hash, key, value, null);
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
putVal
函数中,本质上是一个和查找一样的循环,因此复杂度相同。好在查找操作的复杂度比较低,因此插入的复杂度也是 O(1)。
但是插入时会涉及到另外两个问题。首先是链表到红黑树的转换。如果插入元素后,链表长度超过 8,则链表需要转换成红黑树。这一步还好办,毕竟元素个数有限,消耗时间不会太长。
但是如果元素总个数超过了threshold
,容器需要扩容,这一步是比较费时的。这里为了完成扩容操作,调用了resize()
函数。函数中是一个二重循环,对容器中的每个元素遍历,重新计算他们所属的位置。
如果这样的扩容操作比较少那还好办,但是一旦次数多了必然会影响效率。因此 java 提供了预先指定容器大小的方法,以putAll
方法为例
public void putAll(Map<? extends K, ? extends V> m) {
putMapEntries(m, true);
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
if (table == null) { // pre-size
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
if (t > threshold)
threshold = tableSizeFor(t);
}
else if (s > threshold)
resize();
// ...
}
}
函数首先根据传入的元素个数进行扩容,而后遍历插入。这样显然比用户遍历的速度要快。也就是说,使用 HashMap 时尽量将多个元素一次性插入,可以节省扩容的消耗。
TreeMap
TreeMap
不同HashMap
之处在于它总是保证元素有序。这一顺序一般由存储元素的compareTo
方法指定,或者可以由定义时传入的Comparator
对象给出。
存储形式
TreeMap
内部使用红黑树来组织数据。
private transient Entry<K,V> root;
static final class Entry<K,V> implements Map.Entry<K,V> {
K key;
V value;
Entry<K,V> left;
Entry<K,V> right;
Entry<K,V> parent;
boolean color = BLACK;
// ...
}
元素查找
在一颗红黑树上查找元素比较简单,只需要逐个节点的比较,直到找到一个等于当前的节点或者叶子节点即可。
final Entry<K,V> getEntry(Object key) {
Comparable<? super K> k = (Comparable<? super K>) key;
Entry<K,V> p = root;
while (p != null) {
int cmp = k.compareTo(p.key);
if (cmp < 0)
p = p.left;
else if (cmp > 0)
p = p.right;
else
return p;
}
return null;
}
基于红黑树的理论,树高度不会超过 ,因此复杂度为 O(log n)。
元素插入
与HashMap
类似,TreeMap
在插入元素前也需要进行一次查找操作。
public V put(K key, V value) {
Entry<K,V> t = root;
int cmp;
Entry<K,V> parent;
Comparator<? super K> cpr = comparator;
do {
parent = t;
cmp = cpr.compare(key, t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value);
} while (t != null);
Entry<K,V> e = new Entry<>(key, value, parent);
if (cmp < 0)
parent.left = e;
else
parent.right = e;
fixAfterInsertion(e);
size++;
modCount++;
return null;
}
可以看到,在完成元素插入的操作之后,TreeSet
对树进行了旋转,以维持其性质。原本红黑树的查找就比HashTable
要慢,这一下效率更是差了许多,更不用说用户在程序中使用循环进行插入时的损耗了。因此,除非要十分频繁的使用排序操作,最好还是使用HashMap
。总结一下,TreeSet
无论是查询还是插入,其复杂度均为 O(log n)。
Collection
List
ArrayList
存储形式
ArrayList
的底层数据结构是一个静态数组。
transient Object[] elementData; // non-private to simplify nested class access
private int size;
元素查找
如果使用get
方法获取元素,操作基本等同于在静态数组中使用下表搜索。只不过ArrayList
类增加了越界检查,防止由于访问下标越界引发异常。
如果使用indexOf
检索,例如contains
方法,就需要对数组元素进行遍历,逐个调用equals
方法。
public int indexOf(Object o) {
if (o == null) {
for (int i = 0; i < size; i++)
if (elementData[i]==null)
return i;
} else {
for (int i = 0; i < size; i++)
if (o.equals(elementData[i]))
return i;
}
return -1;
}
可见get
方法的复杂度是 O(1),而indexOf
方法的复杂度是 O(n)。
元素插入
为了保证元素有序,ArrayList
在插入元素时需要将后面所有元素依次后移。平均情况下,需要移动
次,也就是说平均复杂度为 O(n)。
public void add(int index, E element) {
rangeCheckForAdd(index);
ensureCapacityInternal(size + 1); // Increments modCount!!
System.arraycopy(elementData, index, elementData, index + 1,
size - index);
elementData[index] = element;
size++;
}
LinkedList
LinkedList
一般没有ArrayList
常用,因为综合考虑他的效率并没有ArrayList
高。只在插入删除明显多于查询时,才推荐使用LinkedList
。
存储形式
LinkedList
采用双向链表的形式存储元素,这样可以在一定程度上提高效率。
transient Node<E> first;
transient Node<E> last;
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
// ...
}
元素查询
按索引查询时,LinkedList
使用一个核心函数node(int)
。
Node<E> node(int index) {
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
node(int)
函数将搜索范围分为前后两部分,根据索引所属的区间搜索。这样可以将搜索所需的操作从
变为
。但是复杂度还是 O(n)。
按值搜索时,就比较麻烦了。例如indexOf(Object)
函数
public int indexOf(Object o) {
int index = 0;
for (Node<E> x = first; x != null; x = x.next) {
if (o.equals(x.item))
return index;
index++;
}
return -1;
}
这几乎就是我们写出来的遍历检索,复杂度为 O(n)。
元素插入
虽然说链表在插入和删除方面比较有优势,但是如果指定在某个位置插入元素,操作还是十分复杂的。
public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
和其他容器一样,LinkedList
在插入元素前也需要先进行搜索。这大大降低了他的效率,复杂度为 O(n)。
对于LinkedList
比较友好的操作是在队列尾部插入,这时容器可以轻松的完成,复杂度 O(1)。
Set
TreeSet
TreeSet
本质上就是TreeMap
的一个包装类,甚至TreeSet
中元素唯一的性质也是由TreeMap
而来。因此二者几乎完全一样。
HashSet
同理。
Stream
实现原理
为了说明函数式编程的复杂度,首先来介绍一下Stream
的操作原理。
Stream操作分类 | ||
中间操作(Intermediate operations) | 无状态(Stateless) | unordered() filter() map() mapToInt() mapToLong() mapToDouble() flatMap() flatMapToInt() flatMapToLong() flatMapToDouble() peek() |
有状态(Stateful) | distinct() sorted() sorted() limit() skip() | |
结束操作(Terminal operations) | 非短路操作 | forEach() forEachOrdered() toArray() reduce() collect() max() min() count() |
短路操作(short-circuiting) | anyMatch() allMatch() noneMatch() findFirst() findAny() |
表格转载自:http://www.cnblogs.com/CarpenterLee/p/6637118.html
对一个Stream
对象的调用可以包含多个中间操作,但是只能有一个结束操作。例如
ArrayList<Integer> arrayList = new ArrayList<>();
// ...
arrayList.stream()
.filter(/* predict */)
.map(/* lambda */)
.distinct()
.count(); // terminal
这里count
方法是结束操作,其他方法都是中间操作。在执行时,Stream
并不会对每个操作遍历一次(我们自己写程序也不会这样的),因为效率实在是太低了。因此Stream
采取的策略是记录所有的中间操作,并在结束操作调用时同时执行。这样就可以在最少的循环次数中完成操作。
中间操作
中间操作的返回值都是Stream
对象,这样可以进行链式方法调用,保证用户端代码的简洁性。正因如此,中间操作一般不会出现在函数式调用的最后一句。
中间操作又可以分为有状态和无状态两类。有状态指的是在操作内部需要记录数据,一般来说线程不安全。而无状态恰好相反。可以想见,有状态操作应该比无状态操作更加费时,因为需要进行存储。以sort
方法为例,在输入流没有结束之前,方法都不知道排序最小的元素是谁,因而没有办法向下传递元素。这样就破坏了Stream
方法的流水线结构,势必对效率造成一定影响。
结束操作
结束操作分为短路和非短路两种。这个比较好理解,类似findFirst
这样的方法可以在遇到第一个符合条件的元素时就跳出循环。而count
这样的方法则必须遍历容器。
distinct
distinct
属于有状态的中间操作。由于Stream
对象的操作较为复杂,这里只截取了和distinct
操作直接相关的部分代码。
// DistinctOps.java
return new Sink.ChainedReference<T, T>(sink) {
Set<T> seen;
@Override
public void begin(long size) {
seen = new HashSet<>();
downstream.begin(-1);
}
@Override
public void end() {
seen = null;
downstream.end();
}
@Override
public void accept(T t) {
if (!seen.contains(t)) {
seen.add(t);
downstream.accept(t);
}
}
};
我们先不纠结这段代码的环境。可以看出,Stream.distinct()
方法本质上使用了HashSet
容器来记录所有不同的数据。由于HashSet
本身的复杂度是 O(1),而stream
本身的性质决定了它必须遍历所有元素一次,因此总的复杂度是 O(n)。
总结
容器名称 | 存储形式 | 复杂度 | |||
查询 | 插入 | ||||
Map | HashMap | 哈希表 | O(1) | O(1) | |
NavigableMap | TreeMap | 红黑树 | O(log n) | O(log n) | |
Collection | List | ArrayList | 静态数组 | O(1) | O(n) |
LinkedList | 双向链表 | O(n) | O(n) | ||
Set | HashSet | HashMap | O(1) | O(1) | |
TreeSet | TreeMap | O(log n) | O(log n) |