浅谈Java容器的复杂度

写在前面

最近学校的作业是有关jml规格化设计的。原本是很简单的一个单元,但是由于助教限制了CPU执行时间,大家都开始扒各种容器的运行效率。这里简单的对常用容器做一个总结。

集合框架

首先来看一看Java的集合框架。
Java集合框架
图片转载自:http://pierrchen.blogspot.com/2014/03/java-collections-framework-cheat-sheet.html

可以看到MapCollection分别是集合框架的两大顶层接口。而Collection之下,又分为List, QueueSet。再往下几层,就到我们熟知的容器类了。毕向东老师教导我们,阅读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;
}

基于红黑树的理论,树高度不会超过 2 l o g ( n ) 2\cdot log(n) ,因此复杂度为 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在插入元素时需要将后面所有元素依次后移。平均情况下,需要移动 n 2 \frac{n}{2} 次,也就是说平均复杂度为 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)函数将搜索范围分为前后两部分,根据索引所属的区间搜索。这样可以将搜索所需的操作从 n n 变为 n 2 \frac{n}{2} 。但是复杂度还是 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)

猜你喜欢

转载自blog.csdn.net/LutingWang/article/details/89785369