Java集合分析
前言
从开始接触Java的时候, 就在强调的一个重要版块, 集合. 终于能够开始对它的源码进行分析, 理解, 如果不懂得背后的思想, 那么读懂代码, 也仅仅是读懂了 if else 仅仅是读懂了代码的逻辑而已, 对背后深藏的原因, 却没有能力进行一个深入的探究.
我会知道为什么这样写, 但我却不知道这样写的优点, 我知道 for 可以进行循环, 但却不知道这里为什么要做这个循环. 而正是这样的原因, 读代码变成一件很枯燥的事情. 所幸, 现在是有一定的能力进行一个初步的探究.
集合
Java中常用到的集合, 无非是 LinkedList, ArrayList, HashSet, TreeSet, HashMap, TreeMap 并不是说 其他的集合并不重要, 而是现在没有提到的必要.
初步学过算法, 对红黑树, 散列表 符号表的空间时间运行效率, 存储量等概念才有了一个比较初步的认知.
而并发, 安全性, 强弱引用, Stream流等目前并不在考虑范围内.
需要注意的是:
以下提到的有序, 指的是按照 Comparable的实现进行排序后的有序, 而非按照输入的顺序来进行排序.
ArrayList
ArrayList 是线程不安全的集合, 如果想要在多线程情况下使用:
List list = Collections.synchronizedList(new ArrayList(...));
允许元素为空, 同时数组和插入时的顺序一致.
实现:
-
add()
public boolean add(E e) { ensureCapacityInternal(size + 1); // Increments modCount!! elementData[size++] = e; return true; } private void ensureCapacityInternal(int minCapacity) { if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity); } ensureExplicitCapacity(minCapacity); } private void ensureExplicitCapacity(int minCapacity) { modCount++; // overflow-conscious code if (minCapacity - elementData.length > 0) grow(minCapacity); } private void grow(int minCapacity) { // overflow-conscious code int oldCapacity = elementData.length; int newCapacity = oldCapacity + (oldCapacity >> 1); if (newCapacity - minCapacity < 0) newCapacity = minCapacity; if (newCapacity - MAX_ARRAY_SIZE > 0) newCapacity = hugeCapacity(minCapacity); // minCapacity is usually close to size, so this is a win: elementData = Arrays.copyOf(elementData, newCapacity); }
在ArrayList中, 并没有太多值得关注的地方, 但还是需要知道他的扩容策略. 从上面不难知道, 在第一次扩容的时候, 会扩容至DEFAULT_CAPACITY 也就是10, 如果超过10, 则会扩容1.5倍.
在之前使用 ArrayList的时候, 一般不指定长度, 如果需要存储800个元素.
就需要经过11次扩容, 每次都需要重新创建数组, 复制元素.另一个问题是如果需要存储元素不超过10000个, 但在扩容的时候, 会达到14000, 占据不必要的空间. 因此在使用的时候,最好对自己的元素上限有所估计, 选取合适的值. 使用 ArrayList(int capacity)构造函数.
同时会看到一个比较有趣的参数 modCount, 表示修改次数.当调用删除和添加操作的时候, 都会令这个值加1, 而当批量添加或删除的时候, 也仅仅只增加一次.
-
remove()
public E remove(int index) { rangeCheck(index); modCount++; E oldValue = elementData(index); int numMoved = size - index - 1; if (numMoved > 0) System.arraycopy(elementData, index+1, elementData, index, numMoved); elementData[--size] = null; // clear to let GC do its work return oldValue; }
这里有个细节, elementData[--size] = null;删除的时候将元素置为null, 回收. 在这里就不难发现, 在每次remove的时候, 都会将整个数组重新复制一遍.
特别是在 remove(Object o)时, 需要将整个数组遍历一遍, 先查找, 后复制. 随着数组越来越大, 成本越来越高.
-
removeAll()
public boolean retainAll(Collection<?> c) { Objects.requireNonNull(c); return batchRemove(c, true); } private boolean batchRemove(Collection<?> c, boolean complement) { final Object[] elementData = this.elementData; int r = 0, w = 0; boolean modified = false; try { for (; r < size; r++) if (c.contains(elementData[r]) == complement) elementData[w++] = elementData[r]; } finally { // even if c.contains() throws. if (r != size) { System.arraycopy(elementData, r, elementData, w, size - r); w += size - r; } if (w != size) { // clear to let GC do its work for (int i = w; i < size; i++) elementData[i] = null; modCount += size - w; size = w; modified = true; } } return modified; }
从这里就可以看出一种比较有效的处理方式, 对待这种底层为数组的数据结构的时候, 需要提供 removeAll() 这种批量删除的操作, 在这里就只需要将数组遍历的同时复制一遍, 即可.
所以不难想象, 当需要批量删除的时候, 将被删除的元素在第一遍遍历的时候存进另一个集合. 而后再通过 removeAll() 操作一次性删除, 效率会远远高于找到一个删除一个. 当然这同样需要根据数据的特点来看, 如果本次需要删除的数据量特别大, 用于存储删除元素的集合 也要选取 TreeSet这样的有序集合, 因为 contains()效率远高于 ArrayList()方法;
-
contains()
public boolean contains(Object o) { return indexOf(o) >= 0; } 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; }
不难看出, 这需要遍历整个数组才能实现. 最坏情况下需要N次才能实现.
同时从这里也能看出 ArrayList的另一个要点, 必须实现或重写 equals()方法, 否则的话很容易找不到对应元素.
-
iterator()
至于这个方法, 就不多说, 遍历一个数组还是很简单的, 其中有一个地方, 通过modCount判断在遍历过程中是否修改, 限制修改. 因此如果需要修改的话, 需要调用 iterator.remove() 方法, 至于如何放开修改限制:
public void remove() { if (lastRet < 0) throw new IllegalStateException(); checkForComodification(); try { ArrayList.this.remove(lastRet); cursor = lastRet; lastRet = -1; expectedModCount = modCount; } catch (IndexOutOfBoundsException ex) { throw new ConcurrentModificationException(); } }
值得一提的是, 在 遍历的过程中, 仅仅是阻止增删操作, 其 修改操作即 set()操作, 和修改元素本身的操作都是开放的.
-
总结
还有一个很重要的点, 在各个 Collection之间的相互转换得以实现的关键因素, 是因为 统一实现了 toArray() 方法, 同时在 Arrays.asList() 可以将数组转换为集合.
ArrayList本身是无序的, 元素可为空, 遍历的时候不支持增删操作, 如果删除的话, 需要调用返回对象的 remove方法.
如果需要排序的话, 则可以调用 ArrayList的 sort()方法, 而无需自己转换.
那在什么时候使用它呢?
明显的在增删次数较多的时候, 不适合, 同时必须注意它的无序性, 即使存在 sort()方法, 或者自己进行排序, 但必须注意的是, 在以后的任何一次增删操作, 并不维持新数组的有序性. 所以说, 对ArrayList进行持续性的操作是不划算的.
存在的另一个问题是, 底层是数组, 当数据量过大的时候, 需要更大的数组, 相比于使用 散列表实现的, 优点并不多.
优点不多, 查询速度较快, 如果一次性放入, 且数据量并不大的情况下还是比较合适的. 这里的查询也仅仅是指按照下标进行查询.
最后:
所以在我看来, ArrayList更适合将本身有序的数据存入, 否则的话 无论是他的 contains方法, get(Object o)都是效率相当低下的方式. 更不用说 add() 和 remove操作.
LinkedList
-
Node
private static class Node<E> { E item; Node<E> next; Node<E> prev; Node(Node<E> prev, E element, Node<E> next) { this.item = element; this.next = next; this.prev = prev; } }
从核心数据结构来看, 采用的是双向链表.
-
变量
transient int size = 0; transient Node<E> first; transient Node<E> last; protected transient int modCount = 0;
和常规一样, 需要三个变量, 分别为容器存量, 头尾. 因为是双向链表, 自然要记录头尾的节点. 同样的, 维护了一个变量, modCount, 用以记录被更改的次数. 至于这个变量, 在两种数据结构中 都只是为了 遍历时保证数据的一致性.
-
add()
public boolean add(E e) { linkLast(e); return true; } void linkLast(E e) { final Node<E> l = last; final Node<E> newNode = new Node<>(l, e, null); last = newNode; if (l == null) first = newNode; else l.next = newNode; size++; modCount++; }
-
remove()
public boolean remove(Object o) { if (o == null) { for (Node<E> x = first; x != null; x = x.next) { if (x.item == null) { unlink(x); return true; } } } else { for (Node<E> x = first; x != null; x = x.next) { if (o.equals(x.item)) { unlink(x); return true; } } } return false; } E unlink(Node<E> x) { // assert x != null; final E element = x.item; final Node<E> next = x.next; final Node<E> prev = x.prev; if (prev == null) { first = next; } else { prev.next = next; x.prev = null; } if (next == null) { last = prev; } else { next.prev = prev; x.next = null; } x.item = null; size--; modCount++; return element; }
首先要提到的一点是, 同样的在删除的时候采用了equals() 这也就意味着我们同样要重写 equals()方法.
值得一提的是, 在删除当前节点的时候, 将被删除节点的三个属性都置空, 这是我在自己实现的时候, 所未考虑到的.
另外则是, 在删除的时候要从头到尾的遍历这个集合进行删除.
在删除的时候还需要注意到的一点是, 集合中是允许存在相同元素的, 因此在删除的时候, 特别是删除 Object的时候, 并不会删除所有相同元素.
-
get()
public E get(int index) { checkElementIndex(index); return node(index).item; } Node<E> node(int index) { // assert isElementIndex(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; } }
双向链表的作用在这里就体现出来了, 根据所查找位置的不同, 从末节点或首节点进行查找. 效率提升了一倍.
-
Iterator()
public Iterator<E> iterator() { return listIterator(); } public ListIterator<E> listIterator(int index) { checkPositionIndex(index); return new ListItr(index); } private class ListItr implements ListIterator<E> { private Node<E> lastReturned; private Node<E> next; private int nextIndex; private int expectedModCount = modCount; ListItr(int index) { // assert isPositionIndex(index); next = (index == size) ? null : node(index); nextIndex = index; } public boolean hasNext() { return nextIndex < size; } public E next() { checkForComodification(); if (!hasNext()) throw new NoSuchElementException(); lastReturned = next; next = next.next; nextIndex++; return lastReturned.item; } public boolean hasPrevious(); public E previous(); public int nextIndex(); public int previousIndex(); public void remove(); public void set(E e); public void add(E e); public void forEachRemaining(Consumer<? super E> action); }
在iterator() 的实现中, 仅仅给出所实现的 API, 发现它实现的功能相当全面. 如果我们需要在遍历的同时做出某些操作, 最好不要用 foreach() 的方法, 去遍历这个集合, 而是应该调用 listIterator() 可以选择更多更全面, 更合适的额外操作.
-
总结1
在种种方法中, 有如下几个特点:
同样的, 集合是无序的, 秉承着先进后出, 栈的数据存储原则.(这里的先进后出, 指的是在遍历的时候, 所遵循的策略) 同时实现了List的所有方法, 支持 在某一个位置进行插入, 修改, 删除操作, 又或者插入最前, 最后, 或不选择. 可以插入重复元素, 或者null值.但也可能是因为支持的操作太多, 导致给我的感觉它很混乱, 不够清晰明了.
它支持的操作是如此之多, 我们又该在什么时候使用链表呢?
必须是查找的次数远远小于增删的时候使用, 它的每一次查找都需要进行遍历操作, 而相比于 ArrayList() 的增删, 每次都需要复制数组, 增删的成本相当低廉.
同样的, 不适合在需要比较大小的场合里进行使用, 在对存储数据的要求上, 发现仅仅需要 equals()方法, 至于数据的大小, 是漠不关心的.
所以在需要排序的种种场合, ArrayList和LinkedList实在不是一个好选择.
但接下来再谈谈另一个地方, 会发现 LinkedList中, 不仅仅实现了List接口, 还有很多其他方法.
-
Deque
LinkedList还实现了另一个接口, Deque, 我们先来看看这个接口的API, 这个接口继承了另一个接口: Queue.
是的, 队列: add(E), offer(E), remove(), poll(), element(), peek();
队列的特点是先进先出. 实现也有两种方式, 数组和链表, 当然,双向链表是最合适的.
然而, LinkedList中不仅仅是实现了队列, 而是 Double ended Queue.叫做双向队列. 双向队列允许在头尾分别进行操作.
但是感觉API设计仍旧有难以理解的地方, poll() 和 pollFirst() 操作, 完全相同的定义和实现, 不太明白有什么区别.
Deque的设计同样让人感觉很混乱, 难以理解.
在这里就不看它的各种实现了, 因为是基于链表的操作, 当双向链表的操作不成问题的时候, 自然对于LinkedList的所有问题都不存在了.
-
总结2
在这里再回头看看 LinkedList这个数据结构, 在LinkedList来看, 才更近一步理解了, 在创建对象的时候一定要以接口来定义数据类型.
LinkedList集成了众多需要链表来实现的API, 在使用的时候最好明确, 究竟想要使用的是List 还是 队列. 又或者双向队列更适合你.
List的话, 需要重点关注的特点是: 增删操作远远大于查找操作. 而 队列的话, 就不用多说, 常规操作.
HashMap
-
散列表
在HashMap中, 有两个数据至关重要, 容量 和 负载因子. 在谈到这个数据的时候, 就需要来看另一个很有趣的数据类型.散列表.散列表这种数据类型比较简单, 无论是从原理还是从实现来看, 首先说原理:
底层是数组, 存有两个数组, 一个保存键一个保存值, 又或者创建一个新的数据类型 Node, 保存键值对, 在数组中保存Node即可.
至于数据如何存储在散列表中呢?
无论哪一种方式都需要计算对应的散列值, 也就是用某一个数字 % 数组的容量, 所得的数就是当前元素在数组中所存储的位置. 至于查找的话, 同样的方式, 用求余所得的数字, 通过Array的索引去查找. 即可完成这一点.
所以关键点就在于这个数字怎么求取? 怎样的要求, 怎样的特点?
这个数字的求取尤为关键, 关系着散列表的性能. 称作散列函数.
首先来看, 需要有怎样的特点: 在求取的过程中, key的每一点变化都会引起散列值的变化. 再细微的差别, 都需要不同的散列值来对应. 另一点是尽可能的足够分散. 在 Java中 实现的 hashCode()方法, 正是为了这个目的.
另一点是: % M, M的选择也尤为关键, 假设散列值为: 998 999 997 1999, 如果此时M选择10, 不难发现求余所得结果为 8, 9 , 7, 9. 有什么不对的吗?
很有问题, 因为高位无关性. 这样所得的结果会发现仅仅与个位数相关, 高位无关, 明显是不符合我们要求的. 那该怎么选择呢?
非2的素数. 比较符合我们的心理期望. 通过这样的方式就存储了对应的数组.
还有许多问题, 那就跟着Java的 HashMap的实现来一点点看, 究竟该怎么解决. 以及为什么这样解决.
-
HashMap注意:
读源码的时候, 我首先关注的是对这个类的解释, 有很多信息值得我们去发现.
- 容量和负载因子, 这两个与空间和时间相关. 当负载因子过大的时候, 会占用不必要的空间内存, 当负载因子过小的时候, 会导致时间上的消耗要更多一些. 在 HashMap中的默认取值, 负载因子为常数, 取的是 0.75.
在整个操作中, 我们需要始终维持 当前存量 / 负载因子 <= 容量.
当然, 也并不是说, 就不能够自由的调整负载因子, 如果查找时间对我们来说不是关注重点, 负载因子就可以调的大一点, 如果不在乎内存消耗, 就可以把这个值调整的更小一些.初始容量给的更大.
-
同样的无序性, 不过它的无序性更胜一筹, 不仅仅是大小无序, 另外存储的顺序也是完全得不到体现的.
-
不安全性, 线程不安全, 不多做解释, 需要线程安全的需要在创建的时候:
Map m = Collections.synchronizedMap(new HashMap(...));
-
当需要存储的数据量较大的时候, 在初始化的时候最好指定容量, 避免频繁的 rehash操作. 因为当每次容量变化的时候, 都需要重新计算转移数据, 比数组的复制更复杂.
-
属性
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int TREEIFY_THRESHOLD = 8;
transient Node<K,V>[] table;
其余属性暂时不太理解原因. 一步步来看.
-
Node
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; Node(int hash, K key, V value, Node<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } public final K getKey() { return key; } public final V getValue() { return value; } public final String toString() { return key + "=" + value; } public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } public final boolean equals(Object o) { if (o == this) return true; if (o instanceof Map.Entry) { Map.Entry<?,?> e = (Map.Entry<?,?>)o; if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue())) return true; } return false; } }
在这里, Node的hash值 由 value 和 key共同决定:
Objects.hashCode(key) ^ Objects.hashCode(value)
-
构造函数
在HashMap中, 提供了三个构造函数
public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; } public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; //这个参数 this.threshold = tableSizeFor(initialCapacity); } public HashMap(Map<? extends K, ? extends V> m);
三个构造函数都是开放的, 在必要时,可以指定容量和 负载因子. 不过一般使用第二个就可以. 当不指定容量的时候, 为默认值16.
-
put()
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } static final int hash(Object key) { int h; /** *通过这种方式将高位bit位的变动传递到低位bit位, 只要有任何 *一位bit值改变, 都会在低16位上体现出来.原因还不是太清楚. */ return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
初始值默认设定为16;
Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;
从下面这句代码就能够看出来, 首先, 在HashMap中采取的存储方式是拉链法, 在之前已经提到过数据的存储方式, 但是当数据量越来越多, 无论如何hash值都会重复, 这个时候该怎么存储数据呢?
将数组的每一个元素都定义为一个链表, 将hash值相同的元素存入链表中即可, 存在负载因子的限制, 因此整个链表的长度也不会很长, 就保证了查找效率. 同时, 这里存储的时候, 并没有采取 hash%M 的方式, 而是采取了 table.length & hash 值来求取索引. 而这种方式的效率当然远远高于通过求余的方式, 然后插入对应的数组.
毕竟我们的根本性目的还是根据不同的散列值, 如果按照之前的方式来看, 高位不相关的这种现象很容易在这里出现. 所以事实上,其解决方式为通过HashCode() 的计算,保证散列分布的特性. 下面就是String的 hashCode() 实现方式. 并且也是大多实现方式的模板.
public int hashCode() { int h = hash; if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; } return h; }
table.length的初始值通过 resize() 给定. 让我们继续往下看.
if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null);
下面的处理方式需要重点关注, put delete get 是整个集合的核心.
在当前位置已经存在元素的情况下:第一种处理方式是深入当前链表, 也就是桶, 如果找到key就更新 value值,此时并不会修改 modCount, 在目前提到过的容器中, 修改操作,都不会导致modCount值发生变化. 如果当前元素已经被转换成 TreeNode类型, 则采取TreeNode的方式进行添加(稍后再看), 否则的话向链表的末尾添加一个节点, 同时这个时候就需要提到另一个 属性:
static final int TREEIFY_THRESHOLD = 8;
这个是将链表转换为二叉树的阈值, 当hash值的取值, 也就是 hashCode()方法设置的不够合理时,就会出现这种情况, 在这样的情况下 随着链表越来越长就会严重影响性能. 因此在这里就做了这种处理方式, 将其转换为二叉树的实现方式, 兼顾了各种性能, 不得不说是一种很好的处理方式.
同时也不难想象, 在二叉树的实现过程中, 需要 key 实现 Comparable接口, 如果恰好没有实现这个接口, 而hashCode() 又实现的很差, 就不要怨天尤人啦, 我的Map为什么这么慢.
同时, 让我们来看看为什么. 当add element的时候, 都会导致 size++, 如果size到达阈值的时候, 还会扩充数组容量, 假设当前数组容量为100, 在小于阈值的情况下, 也就是最多填充了75个元素, 而此时, 这个节点的长度最大为7, 这意味着什么, 也就是可能 70个元素仅仅占据了数组的的10个空间, 剩余90个位置, 是没有值填充的. 大量的空间浪费, 同时时间性能上也大大降低, 也就不难理解为什么会 转换为 TreeNode进行处理.
else { Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) 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; } } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
好, 再来看看在put方法中调用的几个非公开方法, 对这个函数的补充理解.
为什么要先看下面这个方法呢? 这个方法的调用时机是在 当当前元素非TreeNode的同时, 链表长度又已经到达八个时候转而将链表转换为树.final void treeifyBin(Node<K,V>[] tab, int hash) { int n, index; Node<K,V> e; if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) resize(); else if ((e = tab[index = (n - 1) & hash]) != null) { TreeNode<K,V> hd = null, tl = null; do { TreeNode<K,V> p = replacementTreeNode(e, null); if (tl == null) hd = p; else { p.prev = tl; tl.next = p; } tl = p; } while ((e = e.next) != null); if ((tab[index] = hd) != null) hd.treeify(tab); } }
在这个方法里, 并非简简单单的直接转换, 又进行了一次判定, 数组长度是否小于 MIN_TREEIFY_CAPACITY,64, 不难理解,如果数组长度本身就过小, 这件事发生的概率可能就会提高, 至于提高多少, 就不在我目前的考虑范围内了.将数组再度扩容.
然后来看看TreeNode这个数据结构是怎样的.
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> { TreeNode<K,V> parent; // red-black tree links TreeNode<K,V> left; TreeNode<K,V> right; TreeNode<K,V> prev; // needed to unlink next upon deletion boolean red; TreeNode(int hash, K key, V val, Node<K,V> next) { super(hash, key, val, next); } } static class Entry<K,V> extends HashMap.Node<K,V> { Entry<K,V> before, after; Entry(int hash, K key, V value, Node<K,V> next) { super(hash, key, value, next); } } TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) { return new TreeNode<>(p.hash, p.key, p.value, next); }
一点点来看, 有一个属性 boolean red, 看到这个自然就联想起来红黑树, 说明这里采取的正是红黑树的处理方式. 而在非递归方式中使用红黑树, 代码无疑是比较复杂的, 而其复杂性正是在于, 无法定位其父节点, 保存其父节点的链接. 在这里就定义了一个 parent父节点. 但同时不太理解prev这个属性的作用.
同时我们也可以看到在使用 TreeNode的时候, 代价还是比较高昂的, 我们至少是需要额外的4个引用. 将空间使用量几乎要扩大一倍.
至于如何在TreeNode中插入一个节点, 和二叉树的插入并无不同, 这里有一个很有趣的地方, 由于我们可能在使用hashMap的时候, 并未实现Comparable方法, 但二叉树的插入却必须要进行比较, HashMap中采取了多种策略预防:
首先比较当前的hash值, 由于hashCode()方法的实现太差, hash值相同, 然后检查当前类, 及所有的父类, 是否有实现了Comparable的, 否则的话使用当前类的类名进行比较, 如果类名依然相同, 则采取 Object实现的默认的hashCode()进行比较, 也就是当前对象的地址.
System.identityHashCode(a)
所以在对自己的hashCode()方法不够自信的情况下, 最好实现Comparable方法.对自己的代码做一个双重保证.
而在这里可以采取多种方式比较的原因是: 事实上我们并不在乎比较的最终结果及次序究竟如何, 重点在于统一的标准, 对比次序, 去进行比较.
在查找的时候采取同样的策略, 用一长串代码 来替代 compareTo() 方法;
当可以进行比较之后, 就可以在树中插入对应的数据, 但红黑树还需要进行一步平衡操作, 我就不粘贴代码:
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root, TreeNode<K,V> x);
仅仅在这里给出方法的构造, 不写实现, 在之前红黑树的章节就可以看出来, 一棵树在操作过程中的主要难点就在于 父节点的获取, 而红黑树的平衡操作又要更进一步, 每次插入的节点都是红色节点, 需要自底向上不断遍历, 同时应用到左旋, 右旋的方法进行树的调整. 由于保存有父节点的链接, 因此整个操作的难度被大大简化.
在最后不要忘记将 root节点的颜色置为黑色, 同时令 root = 返回值, 还有就是将tab[i] 的位置设为root节点.
至于在这里 TreeNode的各种方法的实现细节暂时不进行过多考虑.留在 TreeMap 或 TreeSet再来看.
-
remove()
我觉得在put方法中, 各种东西其实已经是比较完备的, 因此不再详细贴出其他代码, 整个数据结构的框架已经搭建起来了, 对数据结构的已经有了大致的了解, 明白了底层的运作原理, 再来看实现就是一件相当简单的事情了.
-
containsKey()
看到方法名, 就知道是通过key值返回存在性, 不难想象必然是通过 key 的hash值到 table[] 的对应位置查找即可.
其get(Object key)与之相类似, 就不再看了.
-
containsValue()
public boolean containsValue(Object value) { Node<K,V>[] tab; V v; if ((tab = table) != null && size > 0) { for (int i = 0; i < tab.length; ++i) { for (Node<K,V> e = tab[i]; e != null; e = e.next) { if ((v = e.value) == value || (value != null && value.equals(v))) return true; } } } return false; }
这里是采取从头到尾一个个遍历的方式进行查找, 如果能够获取到对应的key值, 还是不要使用这个方法. 但在某些情况下我们需要做反向符号表, 也就是通过值来查找出每一个对应的key.
-
iterator
在HashMap中, 发现一个非常有趣的实现, 并不是单纯的 Iterator方法
abstract class HashIterator { Node<K,V> next; // next entry to return Node<K,V> current; // current entry int expectedModCount; // for fast-fail int index; // current slot HashIterator() { expectedModCount = modCount; Node<K,V>[] t = table; current = next = null; index = 0; if (t != null && size > 0) { // advance to first entry do {} while (index < t.length && (next = t[index++]) == null); } } public final boolean hasNext() { return next != null; } final Node<K,V> nextNode() { Node<K,V>[] t; Node<K,V> e = next; if (modCount != expectedModCount) throw new ConcurrentModificationException(); if (e == null) throw new NoSuchElementException(); if ((next = (current = e).next) == null && (t = table) != null) { do {} while (index < t.length && (next = t[index++]) == null); } return e; } public final void remove() { Node<K,V> p = current; if (p == null) throw new IllegalStateException(); if (modCount != expectedModCount) throw new ConcurrentModificationException(); current = null; K key = p.key; removeNode(hash(key), key, null, false, false); expectedModCount = modCount; } }
在这里提供了一个公共的抽象内部类, 去实现 Iterable接口的方法, 但本身并不 implements Iterable, 通过这个内部类的 nextNode 遍历整个 table的所有node.
if ((next = (current = e).next) == null && (t = table) != null) { do {} while (index < t.length && (next = t[index++]) == null); }
而其中这段代码是核心代码, 判断并赋值当前节点的next节点是否为空, 为空进入下一个index节点. 倒不是说实现非常困难, 而是这段代码相当简洁.
如果要遍历 key, value, node 这三者, 事实上都是通过这个 HashIterator实现的.
在我原来的理解中, KeySet本身应该是遍历Map然后将所有的 key值放入一个Set中. 我们遍历也是遍历这个Set.
而事实上, 代码巧妙的多:
public Set<K> keySet() { Set<K> ks = keySet; if (ks == null) { ks = new KeySet(); keySet = ks; } return ks; } final class KeySet extends AbstractSet<K> { public final Iterator<K> iterator() { return new KeyIterator(); } ...
通过这种方式就能获取到对应的集合.
-
forEach()
public void forEach(BiConsumer<? super K, ? super V> action) { Node<K,V>[] tab; if (action == null) throw new NullPointerException(); if (size > 0 && (tab = table) != null) { int mc = modCount; for (int i = 0; i < tab.length; ++i) { for (Node<K,V> e = tab[i]; e != null; e = e.next) action.accept(e.key, e.value); } if (modCount != mc) throw new ConcurrentModificationException(); } } void accept(T t, U u);
当然, 其遍历方式并没有太大区别, 有趣的是 Java1.8以后所带来的新特性, lamda函数式.
map.forEach((k, v) { System.out.println("key : " + k + "; value : " + v) })
-
总结
漫长的HashMap之旅总算结束:
-
hashCode() 的实现方式与 map的性能息息相关, 因此最好能够在实现hashCode() 的时候, 自主测试一下, 是否能够达到均匀分布.
-
hashCode()的实现要点之一则是务必要使用到 对象中的每一个区分属性.
-
equals()方法也要一并实现, 在 keys 判断两个键是否相等时, 需要用到equals()方法.
-
如果可以的话, 最好同时实现comparable接口, 这样当你的 hashCode()实现比较糟糕时, 不至于使得性能一塌糊涂.
-
在使用大多数方法的时候, 最好谨慎小心的使用, 因为有很多方法的性能并不是那么高, 但是不得不体现出来. 比如 containsValue() 方法.
-
-
补充
在HashMap的实现中, 采取的是拉链法实现的散列表, 这里还有另一种方式, 叫做 线性探测法:
在拉链法中, 恰如其名, 数组中的每一个元素都是一个桶, 用来存储 hash值冲突的元素. 通过链表的存储方式来进行存储.
但是存在一点, 我们之前看过 Node 的结构, 包含有key value next hash 四个属性, 又由于Node本身就要占据一个开销, 所以事实上是加大了开销, 这当然不是太大的缺点. 但同时又由于 糟糕的 hashCode() 计算方法会导致速度变慢也是不可避免的问题.
线性探测法, 空间开销上稍好一些, 在这里最好需要两个数组, 一个存储 values 一个存储 keys, 那他怎么解决冲突呢?
答案是: 当当前index已经存在元素, 就向下移动一格看下一个元素是否为空, 同时对比 key是否 equals(). 如果都不是, 则再向下移动一格位置, 直到为空, 或找到 key相同的元素. 查找的时候遵循同样的原则.
对于线性探测法而言, 除了hashCode()的实现以外, 数组的阈值也是尤为重要的一点, 如果设置不合理, 可以想象, 数组已经满了 会陷入无限循环中.
HashSet
HashSet是要比HashMap简单许多的, 但又由于它的底层就是HashMap, 因此放到这里. 事实上, 在我看源码之前, 并不知道HashSet的底层就是 Map.不多说, 继续看吧.
Set s = Collections.synchronizedSet(new HashSet(...));
-
构造方法
public HashSet() { map = new HashMap<>(); } public HashSet(int initialCapacity, float loadFactor) { map = new HashMap<>(initialCapacity, loadFactor); } public HashSet(int initialCapacity) { map = new HashMap<>(initialCapacity); } HashSet(int initialCapacity, float loadFactor, boolean dummy) { map = new LinkedHashMap<>(initialCapacity, loadFactor); } public HashSet(Collection<? extends E> c) { map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16)); addAll(c); }
在这里又会有一个新发现: LinkedHashMap, 之后再来大概看一下.
-
add()
public boolean add(E e) { return map.put(e, PRESENT)==null; } private static final Object PRESENT = new Object();
可以看到在存入的时候, 会同步存入一个新创建的空对象.直接引用HashMap对应的存入方法, 如果不在乎内存的话, 一个对象的开销并不是太大的影响.
当然更优化的方式, 是自己实现一个HashSet.
-
iterator()
public Iterator<E> iterator() { return map.keySet().iterator(); }
-
总结
在看过HashMap 的实现方式之后, HashSet实在是一件极为简单的事, 我也就不一一列举, 没有必要.
在HashMap中的注意事项, 在这里同样通用, 不再赘述.
TreeMap
-
注意事项
-
TreeMap是基于红黑树实现的集合. 因此同样的需要实现 Comparable接口, 或在创建Map的时候传递 Comparator比较器.
-
为线程不安全的, 若果要使用的话:
SortedMap m = Collections.synchronizedSortedMap(new TreeMap(...));
-
通常来说, equals方法和 比较方法的一致性需要多多注意, equals()返回为true的, compareTo一般来说相等, 当然也有其他情况存在.
-
-
属性
private final Comparator<? super K> comparator; private transient Entry<K,V> root; private transient int size = 0; private transient int modCount = 0;
从这里就不难看出来, TreeMap的底层是红黑树, 红黑树的节点数据类型为:
Entry<K, V>, 同时 除了已有的排序方式之外, 接受额外的 Comparator, 这是一个相当友好的实现, 允许了不同的排序扩展, 通过多种多样的方式进行排序.
-
构造器
public TreeMap() { comparator = null; }
在这个构造器中, 将comparator赋值为null, 因此要求key必须实现Comparable接口.
public TreeMap(Comparator<? super K> comparator) { this.comparator = comparator; }
在这种情况下, 我们用扩展的比较器来进行比较, 同时会注意到另一个问题, 比如我们创建了一个简单的对象, User:
public class User implements Comparable<User> { private int age; private String name; private int class; @Override public int compareTo(User u); }
我们实现了内部比较器, 这时候为了保持一致性, 需要重写equals()方法,并与compare保持一致.
但如果我们传入的比较器为通过 age进行比较, 那么age相同的元素呢? 虽然equals方法并不相等, 但在排序中属于等值的元素. 说到这里我们就会发现, 其实在TreeMap中的 不一致性是允许存在的.
public TreeMap(Map<? extends K, ? extends V> m) { comparator = null; putAll(m); } public TreeMap(SortedMap<K, ? extends V> m) { comparator = m.comparator(); try { buildFromSorted(m.size(), m.entrySet().iterator(), null, null); } catch (java.io.IOException cannotHappen) { } catch (ClassNotFoundException cannotHappen) { } }
-
核心类型
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; Entry(K key, V value, Entry<K,V> parent) { this.key = key; this.value = value; this.parent = parent; } public K getKey(); public V getValue(); public V setValue(V value); public boolean equals(Object o) { if (!(o instanceof Map.Entry)) return false; Map.Entry<?,?> e = (Map.Entry<?,?>)o; return valEquals(key,e.getKey()) && valEquals(value,e.getValue()); } public int hashCode() { int keyHash = (key==null ? 0 : key.hashCode()); int valueHash = (value==null ? 0 : value.hashCode()); return keyHash ^ valueHash; } public String toString() { return key + "=" + value; } }
为了不采用递归的方式解决问题, 不出意外的加了一个属性, parent; 同时当创建一个节点的时候, 默认颜色为黑色(true), 但事实上并没有太大影响.
-
put()
public V put(K key, V value) { Entry<K,V> t = root; if (t == null) { compare(key, key); // type (and possibly null) check root = new Entry<>(key, value, null); size = 1; modCount++; return null; }
首先不支持key == null, 其次comparator的优先级要高于 comparable, 优先级的控制是 通过下面的 compare()方法实现的.
//补充 final int compare(Object k1, Object k2) { return comparator==null ? ((Comparable<? super K>)k1).compareTo((K)k2) : comparator.compare((K)k1, (K)k2); }
继续来看这个方法, 首先判断比较器是否为null, 之后进行插入操作.从根节点开始向下查找, 直到命中节点(更新value值), 或者找到未命中节点, 同时未命中节点的 父节点. 从这里就可以看出来, 在之前提到过的, 如果仅仅是通过 age 这个属性实现比较器, 那么在 TreeMap中会把他们当成同一个节点, 而直接更新value值, 所带来的直接后果是, 你会发现明明不是同一个对象, 但偏偏可以相互覆盖. 所以需要注意的就是一定要尽可能的利用到每一个你认为必要的属性.
int cmp; Entry<K,V> parent; // split comparator and comparable paths Comparator<? super K> cpr = comparator; if (cpr != null) { 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); } else { if (key == null) throw new NullPointerException(); @SuppressWarnings("unchecked") Comparable<? super K> k = (Comparable<? super K>) key; do { parent = t; cmp = k.compareTo(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; }
其余操作都是常规操作, 没有太多需要注意的地方, 红黑树的关键点在于fixAfterInsertion(e);
但在通篇看完之后, 其操作与实现与想象并没有多少出入, 可以说一切的问题都在 parent这个属性下解决掉了, 再加上本来就是自底向上的归溯. 其实现方式在红黑树的讲解章节都已经提到过了, 就不再赘述. 但是有所区别的是, 在这里的实现方式中, 在左旋右旋的节点交换过程中, 并没有在 rotateLeft() 或 rotateRight() 中实现颜色的转换, 因此需要在左旋右旋之前对其节点的颜色做好先一步的调整.
同样的, 在一开始并没有注意, 以至于到 delete方法的时候就实在难以理解了, 在这里, 需要强调一下put的实现, 在这里的红黑树规定与之前又有所区别:
之前是, 红色链接必须是左链接, 而在这里, 红色链接却并没有做强制性的规定, 甚至于左右子节点均为红色链接也是可以的. 这一点并不难理解. 因为在之前仅仅是为了简化操作以及理解才做出那样的规定.
但有一条依然适用, 不允许出现两条连续的红色链接. 仅此而已. 通过左旋右旋,变色操作, 即可做到平衡调整.
并且无需重新赋值, 因为在调整的过程中将 父节点链接也直接进行了转换.
-
firstKey() lastKey()
final Entry<K,V> getFirstEntry() { Entry<K,V> p = root; if (p != null) while (p.left != null) p = p.left; return p; } final Entry<K,V> getLastEntry() { Entry<K,V> p = root; if (p != null) while (p.right != null) p = p.right; return p; }
在看到名字的时候以为是返回 root节点, 看了代码之后才发现事实上是返回 最小 最大节点.
-
remove()
public V remove(Object key) { Entry<K,V> p = getEntry(key); if (p == null) return null; V oldValue = p.value; deleteEntry(p); return oldValue; } private void deleteEntry(Entry<K,V> p) { modCount++; size--; /** *在这里如果左右子节点都为空, 取到的为叶节点. 否则的话取出以右子节点为根节点的最小节点. */ if (p.left != null && p.right != null) { Entry<K,V> s = successor(p); p.key = s.key; p.value = s.value; p = s; } /** *无论上面的方法执行与否, p节点的左子节点都为空.因此返回值有两种可能性: *红色右节点且为叶节点, 或者null; */ Entry<K,V> replacement = (p.left != null ? p.left : p.right); if (replacement != null) { //删除p节点, 且将其父节点与replacement节点相互关联. replacement.parent = p.parent; if (p.parent == null) root = replacement; else if (p == p.parent.left) p.parent.left = replacement; else p.parent.right = replacement; // Null out links so they are OK to use by fixAfterDeletion. p.left = p.right = p.parent = null; // 因为当前节点颜色为红色, 事实上就是删除p节点, 同时将replacement置为黑色即可. if (p.color == BLACK) fixAfterDeletion(replacement); } else if (p.parent == null) { // return if we are the only node. root = null; } else { //如果当前节点为2-节点, 才需要进行平衡处理, 否则直接删除. if (p.color == BLACK) fixAfterDeletion(p); if (p.parent != null) { if (p == p.parent.left) p.parent.left = null; else if (p == p.parent.right) p.parent.right = null; p.parent = null; } } } //补充一:successor /** *根据deleteEntry易知 t节点不为空, 且非叶节点.因此执行的是1处的代码. *其返回值为当前节点右子节点的最小节点. */ static <K,V> TreeMap.Entry<K,V> successor(Entry<K,V> t) { if (t == null) ...; //标注:1 else if (t.right != null) { Entry<K,V> p = t.right; while (p.left != null) p = p.left; return p; } else ...; return p; } }
补充二:这里的方法有点不同, 也是一种比较有趣的思路, 还记得在红黑树的学习章节, 我们是首先找到被删除节点, 然后删除右子树的最小节点, 而删除最小节点, 采取的方式是 自上而下遍历节点, 将所有的左子节点都变为3-节点或4-节点, 以备删除使用, 直到末级节点删除, 而后回溯所有节点, 拆解. 而在这之中, 所有变换的核心要点是:
无论如何都要维持以当前节点为根节点的总高度是不变的, 如果高度变化,则在当前根节点进行颜色变换, 维持高度不变. 直到 root节点. 进行高度加减操作.
而在这里我就看到了另一种操作思路. 找到最小节点之后, 此时必定是左子节点,如果为红色, 直接删除, 否则的话需要将右子节点的高度减一, 也就是将右子节点变成红色.如果右子节点为红色, 进行右旋操作, 而后继续关注新的父节点的右子节点. 直到不为红色.
这样使得右子树高度必然会减一, 那左子树呢? 左子树是要删除的, 因此高度也会减一. 这样就维持了当前父节点的平衡性.
那么父节点的父节点呢? 这时候需要关注, 如果当前父节点为红色, 则转黑, 高度+1, 子节点高度减一, 父节点高度+1, 总的高度保持不变.
如果节点为黑色呢? 继续向上重复刚刚的操作.不过此时 x = parent;然后变换右节点颜色, 以新的父节点为根节点的树高度减一. 新的父节点继续向上, 直到 父节点高度可以+1为止, 又或者 到达root节点. 转换root节点颜色即可.
但是仍存在一个问题: 如果当前右子节点直接变为红色, 如果右子节点的子节点依然存在红色节点呢? 岂不是出现了两个连续的红色节点, 违反了我们的规定? 而我们又不会在向下检查一遍, 因为这里是自底向上的操作, 所以有一段代码就是解决这个问题的, 直接单独拉出来讲解.
if (colorOf(rightOf(sib)) == BLACK) { setColor(leftOf(sib), BLACK); setColor(sib, RED); rotateRight(sib); sib = rightOf(parentOf(x)); } setColor(sib, colorOf(parentOf(x))); setColor(parentOf(x), BLACK); setColor(rightOf(sib), BLACK); rotateLeft(parentOf(x)); x = root;
这是if (colorOf(leftOf(sib)) == BLACK && colorOf(rightOf(sib)) == BLACK) 判断的else 代码块.
我们可以看到, 第一个if语句是将红色左子节点进行右旋操作.给 sib进行赋值操作, 使得 sib 始终指向 x.parent.right节点.
而下面那段代码, 乍一看是左旋操作, 其实不尽然, 因为在每一次旋转操作的时候, 相应的子节点必须为红色. 必须要有对应的颜色转换, 否则会导致树平衡性出现问题.
因此这里并不是一次标准的左旋操作(其父节点的左右子节点均为黑色), 那么是什么呢? 就需要关注到另一个问题, 以当前节点为根节点. 从全局来看, 左旋操作 是令根节点左子树高度+1, 右子树高度-1的一种操作. 而在以往的过程中, 同时含有对应的颜色变换, 以维持高度不变.
那么这里呢? 首先我们知道, 在向上的过程中, 就是要令父节点高度 +1,以维持树的整体平衡性. 而这一步操作:
setColor(rightOf(sib), BLACK);
如果是正常的右旋操作, 这里颜色不变, 根据上文来看, 依然是 RED, 但我们在这里令颜色变为黑色, 这就很巧妙了, 左子树因为左旋, 高度+1,右子树高度-1, 而右子树的红色节点颜色也由红色调整为黑色, 使得右子树高度+1, 这样就维持了右子树的高度不变, 而左子树高度+1的操作.
这是一种非常巧妙的红黑树变换操作. 自下而上, 核心在于 - + 先减后加操作, 通过这种方式就实现了树的平衡.
private void fixAfterDeletion(Entry<K,V> x) { while (x != root && colorOf(x) == BLACK) { if (x == leftOf(parentOf(x))) { Entry<K,V> sib = rightOf(parentOf(x)); //如果右节点为红色, 左旋, 使得整体节点下沉 if (colorOf(sib) == RED) { setColor(sib, BLACK); setColor(parentOf(x), RED); rotateLeft(parentOf(x)); sib = rightOf(parentOf(x)); } //使得右节点高度减一 if (colorOf(leftOf(sib)) == BLACK && colorOf(rightOf(sib)) == BLACK) { setColor(sib, RED); //同时向上, 最终令父节点高度+1 x = parentOf(x); } else { if (colorOf(rightOf(sib)) == BLACK) { setColor(leftOf(sib), BLACK); setColor(sib, RED); rotateRight(sib); sib = rightOf(parentOf(x)); } setColor(sib, colorOf(parentOf(x))); setColor(parentOf(x), BLACK); setColor(rightOf(sib), BLACK); rotateLeft(parentOf(x)); x = root; } } else { // symmetric Entry<K,V> sib = leftOf(parentOf(x)); if (colorOf(sib) == RED) { setColor(sib, BLACK); setColor(parentOf(x), RED); rotateRight(parentOf(x)); sib = leftOf(parentOf(x)); } if (colorOf(rightOf(sib)) == BLACK && colorOf(leftOf(sib)) == BLACK) { setColor(sib, RED); x = parentOf(x); } else { if (colorOf(leftOf(sib)) == BLACK) { setColor(rightOf(sib), BLACK); setColor(sib, RED); rotateLeft(sib); sib = leftOf(parentOf(x)); } setColor(sib, colorOf(parentOf(x))); setColor(parentOf(x), BLACK); setColor(leftOf(sib), BLACK); rotateRight(parentOf(x)); x = root; } } } setColor(x, BLACK); }
至此delete操作,终于告一段落.
-
iterator()
在 TreeMap中采取了和HashMap相同的策略, 返回对应的 Entry类型, 在具体的iterator中再继承相应的对象即可.
abstract class PrivateEntryIterator<T> implements Iterator<T> { Entry<K,V> next; Entry<K,V> lastReturned; int expectedModCount; //传入最小节点作为起始节点. PrivateEntryIterator(Entry<K,V> first) { expectedModCount = modCount; lastReturned = null; next = first; } public final boolean hasNext() { return next != null; } final Entry<K,V> nextEntry() { Entry<K,V> e = next; if (e == null) throw new NoSuchElementException(); if (modCount != expectedModCount) throw new ConcurrentModificationException(); next = successor(e); lastReturned = e; return e; } final Entry<K,V> prevEntry() { Entry<K,V> e = next; if (e == null) throw new NoSuchElementException(); if (modCount != expectedModCount) throw new ConcurrentModificationException(); next = predecessor(e); lastReturned = e; return e; } public void remove(); }
关键点在于 successor()方法, 在这里处理方法并不难, 从最小节点开始, 然后逐步向上. 和二叉树中的遍历实现方法是相同的, 不同的是, 我当时用了数组存储父节点, 而在这个地方没必要使用数组而已.
在当前节点有几个方向可以走, 必然不能向左子树深入, 因为必然是从左子树回溯上来的, 只能向上, 或是右.如果右节点不为空, 则直接向右深入, 否则的话, 说明当前节点已经是 父节点下的最大节点了, 必须向上回溯.
如果当前节点是父节点的左子节点, 说明, 父节点没有被遍历过, 返回父节点, 否则的话, 一直向上, 直到当前节点时父节点的左子节点为止.
static <K,V> TreeMap.Entry<K,V> successor(Entry<K,V> t) { if (t == null) return null; else if (t.right != null) { Entry<K,V> p = t.right; while (p.left != null) p = p.left; return p; } else { Entry<K,V> p = t.parent; Entry<K,V> ch = t; while (p != null && ch == p.right) { ch = p; p = p.parent; } return p; } }
-
总结
TreeMap的实现到这里告一段落, 可能会有更深入的研究, 但不在目前的探讨范围内.
红黑树的实现特点等等就不再多说, 如果有兴趣, 向上看, 否则我们直接看使用.
-
TreeMap为有序线程不安全集合. 这里的有序指的是大小有序.在这里并不强制要求实现equals方法.
-
TreeMap 必须在实现 key的 comparable接口 和 构造器中传入 comparator中选择一个, 不能两者都不实现.
-
TreeMap底层是红黑树, 查找, 增加, 删除操作都可以在 logn时间内完成.相当高效.
-
需要注意comparator的使用, 当两个元素 comparator返回0的时候, 如果我们采用的比较器 或 comparable的实现, 不够合理, 并没有利用到全部元素, 那么即使两个不等价的元素, 在这里也会被当做相等元素, 而进一步覆盖掉. 因为我们知道, 在这里是不支持相同键的存在.
-
因此在使用的时候, 最好同步实现 equals()方法, 并保证两者返回一致, 顺便也可以提醒自己多多注意.
-
-
TreeSet
鉴于在之前已经看过HashSet, TreeMap, 这里就不多说.
TreeSet的实现底层则是 TreeMap, 在TreeMap中的注意要点, 在这里同样适用. 因此并没有太多值得关注的地方.
哦, 除了这个:
SortedSet s = Collections.synchronizedSortedSet(new TreeSet(...));
结束语
到这里, 我想要关注的集合, 也就全部讲完了, 至于文中提到的 LinkedHashMap
public class LinkedHashMap<K,V>
extends HashMap<K,V>
implements Map<K,V>
public LinkedHashMap(int initialCapacity, float loadFactor) {
super(initialCapacity, loadFactor);
accessOrder = false;
}
也并不想再多做介绍, 毕竟要学习的还很多, 没办法面面俱到. 每个细节都关注.
我觉得学习算法, 看源码, 相互理解印证这种方式还是相当不错的一种学习手段. 特别是在漫长的三个多月的算法学习中, 从一无所知, 到一点点啃下来, 链表, 队列, 排序, 二叉树, 红黑树, 散列表, 等等. 自己几乎每一种都做了实现, 而不是照抄代码, 搬运工, 很感谢自己的耐心.
实现过这些, 看看源码, 对性能, 时间, 空间的选择理解更深, 才终于觉得自己从码农的身份渐渐脱离出来, 并不仅仅是一个 会 xx.xx 的人了.
还是要多思考, 多努力, 前方的还有许许多多的知识需要学习. 不能懈怠.