12.3 容器类总结

前面章节中,我们介绍了多种容器类,本节进行简要总结,我们主要从三个角度进行总结:

  • 用法和特点;
  • 数据结构和算法;
  • 设计思维和模式。

12.3.1 用法和特点

图12-1包含了容器类主要的接口和类,我们还是用该图进行总结。容器类有两个根接口,分别是Collection和Map, Collection表示单个元素的集合,Map表示键值对的集合。

Collection表示的数据集合有基本的增、删、查、遍历等方法,但没有定义元素间的顺序或位置,也没有规定是否有重复元素。

List是Collection的子接口,表示有顺序或位置的数据集合,增加了根据索引位置进行操作的方法。它有两个主要的实现类:ArrayList和LinkedList。ArrayList基于数组实现, LinkedList基于链表实现;ArrayList的随机访问效率很高,但从中间插入和删除元素需要移动元素,效率比较低,LinkedList则正好相反,随机访问效率比较低,但增删元素只需要调整邻近节点的链接。

Set也是Collection的子接口,它没有增加新的方法,但保证不含重复元素。它有两个主要的实现类:HashSet和TreeSet。HashSet基于哈希表实现,要求键重写hashCode方法,效率更高,但元素间没有顺序;TreeSet基于排序二叉树实现,元素按比较有序,元素需要实现Comparable接口,或者创建TreeSet时提供一个Comparator对象。HashSet还有一个子类LinkedHashSet可以按插入有序。还有一个针对枚举类型的实现类EnumSet,它基于位向量实现,效率很高。

Queue是Collection的子接口,表示先进先出的队列,在尾部添加,从头部查看或删除。Deque是Queue的子接口,表示更为通用的双端队列,有明确的在头或尾进行查看、添加和删除的方法。普通队列有两个主要的实现类:LinkedList和ArrayDeque。LinkedList基于链表实现,ArrayDeque基于循环数组实现。一般而言,如果只需要Deque接口,Array-Deque的效率更高一些。

Queue还有一个特殊的实现类PriorityQueue,表示优先级队列,内部是用堆实现的。堆除了用于实现优先级队列,还可以高效方便地解决很多其他问题,比如求前K个最大的元素、求中值等。

Map接口表示键值对集合,经常根据键进行操作,它有两个主要的实现类:HashMap和TreeMap。HashMap基于哈希表实现,要求键重写hashCode方法,操作效率很高,但元素没有顺序。TreeMap基于排序二叉树实现,要求键实现Comparable接口,或提供一个Comparator对象,操作效率稍低,但可以按键有序。

HashMap还有一个子类LinkedHashMap,它可以按插入或访问有序。之所以能有序,是因为每个元素还加入到了一个双向链表中。如果键本来就是有序的,使用LinkedHashMap而非TreeMap可以提高效率。按访问有序的特点可以方便地用于实现LRU缓存。

如果键为枚举类型,可以使用专门的实现类EnumMap,它使用效率更高的数组实现。

需要说明的是,除了Hashtable、Vector和Stack,我们介绍的各种容器类都不是线程安全的,也就是说,如果多个线程同时读写同一个容器对象,是不安全的。如果需要线程安全,可以使用Collections提供的synchronizedXXX方法对容器对象进行同步,或者使用线程安全的专门容器类。

此外,容器类提供的迭代器都有一个特点,都会在迭代中间进行结构性变化检测,如果容器发生了结构性变化,就会抛出ConcurrentModificationException,所以不能在迭代中间直接调用容器类提供的add/remove方法,如需添加和删除,应调用迭代器的相关方法。

在解决一个特定问题时,经常需要综合使用多种容器类。比如,要统计一本书中出现次数最多的前10个单词,可以先使用HashMap统计每个单词出现的次数,再使用TopK类用PriorityQueue求前10个单词,或者使用Collections提供的sort方法。

在之前各节介绍的例子中,为简单起见,容器中的元素类型往往是简单的,但需要说明的是,它们也可以是复杂的自定义类型,还可以是容器类型。比如在一个新闻应用中,表示当天的前十大新闻可以用一个List表示,形如List<News>,而为了表示每个分类的前十大新闻,可以用一个Map表示,键为分类Category,值为List<News>,形如Map<Category, List<News>>,而表示每天的每个分类的前十大新闻,可以在Map中使用Map,键为日期,值也是一个Map,形如Map<Date, Map<Category, List<News>>

12.3.2 数据结构和算法

在容器类中,我们看到了如下数据结构的应用:

1)动态数组:ArrayList内部就是动态数组,HashMap内部的链表数组也是动态扩展的,ArrayDeque和PriorityQueue内部也都是动态扩展的数组。
2)链表:LinkedList是用双向链表实现的,HashMap中映射到同一个链表数组的键值对是通过单向链表链接起来的,LinkedHashMap中每个元素还加入到了一个双向链表中以维护插入或访问顺序。
3)哈希表:HashMap是用哈希表实现的,HashSet、LinkedHashSet和LinkedHashMap基于HashMap,内部当然也是哈希表。
4)排序二叉树:TreeMap是用红黑树(基于排序二叉树)实现的,TreeSet内部使用TreeMap,当然也是红黑树,红黑树能保持元素的顺序且综合性能很高。
5):PriorityQueue是用堆实现的,堆逻辑上是树,物理上是动态数组,堆可以高效地解决一些其他数据结构难以解决的问题。
6)循环数组:ArrayDeque是用循环数组实现的,通过对头尾变量的维护,实现了高效的队列操作。
7)位向量:EnumSet和BitSet是用位向量实现的,对于只有两种状态,且需要进行集合运算的数据,使用位向量进行表示、位运算进行处理,精简且高效。

每种数据结构中往往包含一定的算法策略,这种策略往往是一种折中,比如:

1)动态扩展算法:动态数组的扩展策略,一般是指数级扩展的,是在两方面进行平衡,一方面是希望减少内存消耗,另一方面希望减少内存分配、移动和复制的开销。
2)哈希算法:哈希表中键映射到链表数组索引的算法,算法要快,同时要尽量随机和均匀。
3)排序二叉树的平衡算法:排序二叉树的平衡非常重要,红黑树是一种平衡算法, AVL树是另一种平衡算法。平衡算法一方面要保证尽量平衡,另一方面要尽量减少综合开销。

Collections实现了一些通用算法,比如二分查找、排序、翻转列表顺序、随机化重排等,在实现大部分算法时,Collections也都根据容器大小和是否实现了RandomAccess接口采用了不同的实现方式。

12.3.3 设计思维和模式

在容器类中,我们也看到了Java的多种语言机制和设计思维的运用:
1)封装:封装就是提供简单接口,并隐藏实现细节,这是程序设计的最重要思维。在容器类中,很多类、方法和变量都是私有的,比如迭代器方法,基本都是通过私有内部类或匿名内部类实现的。
2)继承和多态:继承可以复用代码,便于按父类统一处理,但继承是一把双刃剑。在容器类中,Collection是父接口,List/Set/Queue继承自Collection,通过Collection接口可以统一处理多种类型的集合对象。容器类定义了很多抽象容器类,具体类通过继承它们以复用代码,每个抽象容器类都有详细的文档说明,描述其实现机制,以及子类应该如何重写方法。容器类的设计展示了接口继承、类继承,以及抽象类的恰当应用。
3)组合:一般而言,组合应该优先于继承,我们看到HashSet通过组合的方式使用HashMap, TreeSet通过组合使用TreeMap,适配器和装饰器模式也都是通过组合实现的。
4)接口:面向接口编程是一种重要的思维,可降低代码间的耦合,提高代码复用程度,在容器类方法中,接受的参数和返回值往往都是接口,Collections提供的通用算法,操作的也都是接口对象,我们平时在使用容器类时,一般也只在创建对象时使用具体类,而其他地方都使用接口。
5)设计模式:我们在容器类中看到了迭代器、工厂方法、适配器、装饰器等多种设计模式的应用。

本节从用法和特点、数据结构和算法以及设计思维和模式三个角度简要总结了之前介绍的各种容器类。至此,关于容器类就介绍完了。到目前为止,我们还没有接触过文件处理,而我们在日常的计算机操作中,接触最多的就是各种文件了,让我们从下一章开始,一起探讨文件操作。

12.2 Collections

类Collections以静态方法的方式提供了很多通用算法和功能,这些功能大概可以分为两类。
1)对容器接口对象进行操作。
2)返回一个容器接口对象。

对于第1类,操作大概可以分为三组。

  • 查找和替换。
  • 排序和调整顺序。
  • 添加和修改。

对于第2类,大概可以分为两组。

  • 适配器:将其他类型的数据转换为容器接口对象。
  • 装饰器:修饰一个给定容器接口对象,增加某种性质。

它们都是围绕容器接口对象的,第1类是针对容器接口的通用操作,这是面向接口编程的一种体现,是接口的典型用法;第2类是为了使更多类型的数据更为方便和安全地参与到容器类协作体系中。下面我们分别介绍这两类操作及其实现原理,代码分析基于Java 7。

12.2.1 查找和替换

查找和替换包含多组方法。查找包括二分查找、查找最大值/最小值、查找元素出现次数、查找子List、查看两个集合是否有交集等,下面具体介绍。

1.二分查找

我们在介绍Arrays类的时候介绍过二分查找,Arrays类有针对数组对象的二分查找方法,Collections提供了针对List接口的二分查找,如下所示:

1
2
3
4
public static <T> int binarySearch(
List<? extends Comparable<? super T>> list, T key)
public static <T> int binarySearch(List<? extends T> list,
T key, Comparator<? super T> c)

从方法参数角度而言,一个要求List的每个元素实现Comparable接口,另一个不需要,但要求提供Comparator。二分查找假定List中的元素是从小到大排序的。如果是从大到小排序的,需要传递一个逆序Comparator对象,Collections提供了返回逆序Comparator的方法,之前我们也用过:

1
2
public static <T> Comparator<T> reverseOrder()
public static <T> Comparator<T> reverseOrder(Comparator<T> cmp)

比如,可以这么用:

1
2
3
4
5
List<Integer> list = new ArrayList<>(Arrays.asList(new Integer[]{
35, 24, 13, 12, 8, 7, 1
}));
System.out.println(Collections.binarySearch(list, 7,
Collections.reverseOrder()));

输出为:5。List的二分查找的基本思路与Arrays中的是一样的,数组可以根据索引直接定位任意元素,实现效率很高,但List就不一定了,具体分为两种情况,如果List可以随机访问(如数组),即实现了RandomAccess接口,或者元素个数比较少,则实现思路与Arrays一样,根据索引直接访问中间元素进行比较,否则使用迭代器的方式移动到中间元素进行比较。从效率角度,如果List支持随机访问,效率为O(log2(N)),如果通过迭代器,那么比较的次数为O(log2(N)),但遍历移动的次数为O(N), N为列表长度。

2.查找最大值/最小值

Collections提供了如下查找最大值/最小值的方法(省略了修饰符publicstatic):

1
2
3
4
<T extends Object & Comparable<? super T>> T max(Collection<? extends T> coll)
<T> T max(Collection<? extends T> coll, Comparator<? super T> comp)
<T extends Object & Comparable<? super T>> T min(Collection<? extends T> coll)
<T> T min(Collection<? extends T> coll, Comparator<? super T> comp)

含义和用法都很直接,实现思路也很简单,就是通过迭代器进行比较,比如:

1
2
3
4
5
6
7
8
9
10
11
public static <T extends Object & Comparable<? super T>> T max(
Collection<? extends T> coll) {
Iterator<? extends T> i = coll.iterator();
T candidate = i.next();
while(i.hasNext()) {
T next = i.next();
if(next.compareTo(candidate) > 0)
candidate = next;
}
return candidate;
}

3.其他方法

查找元素出现次数,方法为:

1
public static int frequency(Collection<? > c, Object o)

返回元素o在容器c中出现的次数,o可以为null。含义很简单,实现思路也很简单,就是通过迭代器进行比较计数。

Collections提供了如下方法,在source List中查找target List的位置:

1
2
public static int indexOfSubList(List<? > source, List<? > target)
public static int lastIndexOfSubList(List<? > source, List<? > target)

indexOfSubList从开头找,lastIndexOfSubList从结尾找,没找到返回-1,找到返回第一个匹配元素的索引位置。这两个方法的实现都是属于“暴力破解”型的,将target列表与source从第一个元素开始的列表逐个元素进行比较,如果不匹配,则与source从第二个元素开始的列表比较,再不匹配,与source从第三个元素开始的列表比较,以此类推。

查看两个集合是否有交集,方法为:

1
public static boolean disjoint(Collection<? > c1, Collection<? > c2)

如果c1和c2有交集,返回值为false;没有交集,返回值为true。实现原理也很简单,遍历其中一个容器,对每个元素,在另一个容器里通过contains方法检查是否包含该元素,如果包含,返回false,如果最后不包含任何元素返回true。这个方法的代码会根据容器是否为Set以及集合大小进行性能优化,即选择哪个容器进行遍历,哪个容器进行检查,以减少总的比较次数,具体我们就不介绍了。

替换方法为:

1
public static <T> boolean replaceAll(List<T> list, T oldVal, T newVal)

将List中的所有oldVal替换为newVal,如果发生了替换,返回值为true,否则为false。用法和实现都比较简单,就不赘述了。

12.2.2 排序和调整顺序

针对List接口对象,Collections除了提供基础的排序,还提供了若干调整顺序的方法,包括交换元素位置、翻转列表顺序、随机化重排、循环移位等,下面具体介绍。

1.排序、交换位置与翻转

Arrays类有针对数组对象的排序方法,Collections提供了针对List接口的排序方法,如下所示:

1
2
public static <T extends Comparable<? super T>> void sort(List<T> list)
public static <T> void sort(List<T> list, Comparator<? super T> c)

使用很简单,就不举例了,内部它是通过Arrays.sort实现的,先将List元素复制到一个数组中,然后使用Arrays.sort,排序后,再复制回List。代码如下所示:

1
2
3
4
5
6
7
8
9
public static <T extends Comparable<? super T>> void sort(List<T> list) {
Object[] a = list.toArray();
Arrays.sort(a);
ListIterator<T> i = list.listIterator();
for(int j=0; j<a.length; j++) {
i.next();
i.set((T)a[j]);
}
}

交换元素位置的方法为:

1
public static void swap(List<? > list, int i, int j)

交换list中第i个和第j个元素的内容。实现代码为:

1
2
3
4
public static void swap(List<? > list, int i, int j) {
final List l = list;
l.set(i, l.set(j, l.get(i)));
}

翻转列表顺序的方法为:

1
public static void reverse(List<? > list)

将list中的元素顺序翻转过来。实现思路就是将第一个和最后一个交换,第二个和倒数第二个交换,以此类推,直到中间两个元素交换完毕。如果list实现了RandomAccess接口或列表比较小,根据索引位置,使用上面的swap方法进行交换,否则,由于直接根据索引位置定位元素效率比较低,使用一前一后两个listIterator定位待交换的元素,具体代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void reverse(List<? > list) {
int size = list.size();
if(size < REVERSE_THRESHOLD || list instanceof RandomAccess) {
for(int i=0, mid=size>>1, j=size-1; i<mid; i++, j--)
swap(list, i, j);
} else {
ListIterator fwd = list.listIterator();
ListIterator rev = list.listIterator(size);
for(int i=0, mid=list.size()>>1; i<mid; i++) {
Object tmp = fwd.next();
fwd.set(rev.previous());
rev.set(tmp);
}
}
}

2.随机化重排

我们在随机一节介绍过洗牌算法,Collections直接提供了对List元素洗牌的方法:

1
2
public static void shuffle(List<? > list)
public static void shuffle(List<? > list, Random rnd)

实现思路与随机一节介绍的是一样的:从后往前遍历列表,逐个给每个位置重新赋值,值从前面的未重新赋值的元素中随机挑选。如果列表实现了RandomAccess接口,或者列表比较小,直接使用前面swap方法进行交换,否则,先将列表内容复制到一个数组中,洗牌,再复制回列表。代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static void shuffle(List<? > list, Random rnd) {
int size = list.size();
if(size < SHUFFLE_THRESHOLD || list instanceof RandomAccess) {
for(int i=size; i>1; i--)
swap(list, i-1, rnd.nextInt(i));
} else {
Object arr[] = list.toArray();
//对数组进行洗牌
for(int i=size; i>1; i--)
swap(arr, i-1, rnd.nextInt(i));
//将数组中洗牌后的结果保存回list
ListIterator it = list.listIterator();
for(int i=0; i<arr.length; i++) {
it.next();
it.set(arr[i]);
}
}
}

3.循环移位

我们解释下循环移位的概念,比如列表为:

1
[8, 5, 3, 6, 2]

循环右移2位,会变为:

1
[6, 2, 8, 5, 3]

如果是循环左移2位,会变为:

1
[3, 6, 2, 8, 5]

因为列表长度为5,循环左移3位和循环右移2位的效果是一样的。

循环移位的方法是:

1
public static void rotate(List<? > list, int distance)

distance表示循环移位个数,一般正数表示向右移,负数表示向左移,比如:

1
2
3
4
5
6
7
8
9
10
List<Integer> list1 = Arrays.asList(new Integer[]{
8, 5, 3, 6, 2
});
Collections.rotate(list1, 2);
System.out.println(list1);
List<Integer> list2 = Arrays.asList(new Integer[]{
8, 5, 3, 6, 2
});
Collections.rotate(list2, -2);
System.out.println(list2);

输出为:

1
2
[6, 2, 8, 5, 3]
[3, 6, 2, 8, 5]

这个方法很有用的一点是:它也可以用于子列表,可以调整子列表内的顺序而不改变其他元素的位置。比如,将第j个元素向前移动到k(k>j),可以这么写:

1
Collections.rotate(list.subList(j, k+1), -1);

再举个例子:

1
2
3
4
5
List<Integer> list = Arrays.asList(new Integer[]{
8, 5, 3, 6, 2, 19, 21
});
Collections.rotate(list.subList(1, 5), 2);
System.out.println(list);

输出为:

1
[8, 6, 2, 5, 3, 19, 21]

这个类似于列表内的“剪切”和“粘贴”,将子列表[5, 3]“剪切”, “粘贴”到2后面。如果需要实现类似“剪切”和“粘贴”的功能,可以使用rotate()方法。

循环移位的内部实现比较巧妙,根据列表大小和是否实现了RandomAccess接口,有两个算法,都比较巧妙,两个算法在《编程珠玑》这本书的2.3节有描述。

限于篇幅,我们只解释下其中的第二个算法,它将循环移位看作列表的两个子列表进行顺序交换。再来看上面的例子,循环左移2位:

1
[8, 5, 3, 6, 2] -> [3, 6, 2, 8, 5]

就是将[8, 5]和[3, 6, 2]两个子列表的顺序进行交换。循环右移两位:

1
[8, 5, 3, 6, 2] -> [6, 2, 8, 5, 3]

就是将[8, 5, 3]和[6, 2]两个子列表的顺序进行交换。

根据列表长度size和移位个数distance,可以计算出两个子列表的分隔点,有了两个子列表后,两个子列表的顺序交换可以通过三次翻转实现。比如,有A和B两个子列表,A有m个元素,B有n个元素:a1a2…amb1b2…bn,要变为b1b2…bna1a2…am,可经过三次翻转实现:

(1)翻转子列表A
$$
a_1a_2 … a_mb_1b_2 … b_n → a_m … a_2 a_1b_1b_2 … b_n
$$
(2)翻转子列表B
$$
a_m … a_2 a_1 b_1 b_2 … b_n → a_m … a_2 a_1 b_n … b_2 b_1
$$
(3)翻转整个列表
$$
a_m … a_2 a_1 b_n … b_2 b_1 → b_1 b_2 … b_n a_1 a_2 … a_m
$$

这个算法的整体实现代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
private static void rotate2(List<? > list, int distance) {
int size = list.size();
if(size == 0)
return;
int mid = -distance % size;
if(mid < 0)
mid += size;
if(mid == 0)
return;
reverse(list.subList(0, mid));
reverse(list.subList(mid, size));
reverse(list);
}

mid为两个子列表的分割点,调用了三次reverse方法以实现子列表顺序交换。

12.2.3 添加和修改

Collections也提供了几个批量添加和修改的方法,逻辑都比较简单,我们看下。批量添加,方法为:

1
public static <T> boolean addAll(Collection<? super T> c, T... elements)

elements为可变参数,将所有元素添加到容器c中。这个方法很方便,比如:

1
2
3
4
5
List<String> list = new ArrayList<String>();
String[] arr = new String[]{"深入", "浅出"};
Collections.addAll(list, "hello", "world", "老马", "编程");
Collections.addAll(list, arr);
System.out.println(list);

输出为:

1
[hello, world, 老马, 编程, 深入, 浅出]

批量填充固定值,方法为:

1
public static <T> void fill(List<? super T> list, T obj)

这个方法与Arrays类中的fill方法是类似的,给每个元素设置相同的值。

批量复制,方法为:

1
public static <T> void copy(List<? super T> dest, List<? extends T> src)

将列表src中的每个元素复制到列表dest的对应位置处,覆盖dest中原来的值,dest的列表长度不能小于src, dest中超过src长度部分的元素不受影响。

12.2.4 适配器

所谓适配器,就是将一种类型的接口转换成另一种接口,类似于电子设备中的各种USB转接头,一端连接某种特殊类型的接口,一段连接标准的USB接口。Collections类提供了几组类似于适配器的方法:

  • 空容器方法:类似于将null或“空”转换为一个标准的容器接口对象。
  • 单一对象方法:将一个单独的对象转换为一个标准的容器接口对象。
  • 其他适配方法:将Map转换为Set等。

它们接受其他类型的数据,转换为一个容器接口,目的是使其他类型的数据更为方便地参与到容器类协作体系中,下面,我们分别来看下。

1.空容器方法

Collections中有一组方法,返回一个不包含任何元素的容器接口对象,如下所示:

1
2
3
4
public static final <T> List<T> emptyList()
public static final <T> Set<T> emptySet()
public static final <K, V> Map<K, V> emptyMap()
public static <T> Iterator<T> emptyIterator()

分别返回一个空的List、Set、Map和Iterator对象。比如,可以这么用:

1
2
3
List<String> list = Collections.emptyList();
Map<String, Integer> map = Collections.emptyMap();
Set<Integer> set = Collections.emptySet();

一个空容器对象有什么用呢?空容器对象经常用作方法返回值。比如,有一个方法,可以将可变长度的整数转换为一个List,方法声明为:

1
public static List<Integer> asList(int... elements)

在参数为空时,这个方法应该返回null还是一个空的List呢?如果返回null,方法调用者必须进行检查,然后分别处理,代码结构大概如下所示:

1
2
3
4
5
6
7
int[] arr = …; //从别的地方获取到的arr
List<Integer> list = asList(arr);
if(list==null){
//…
}else{
//…
}

这段代码比较烦琐,而且如果不小心忘记检查,则有可能会抛出空指针异常,所以推荐做法是返回一个空的List,以便调用者安全地进行统一处理,比如,asList可以这样实现:

1
2
3
4
5
6
7
8
9
10
public static List<Integer> asList(int... elements){
if(elements.length==0){
return Collections.emptyList();
}
List<Integer> list = new ArrayList<>(elements.length);
for(int e : elements){
list.add(e);
}
return list;
}

返回一个空的List。也可以这样实现:

1
return new ArrayList<Integer>();

这与emptyList方法有什么区别呢?emptyList方法返回的是一个静态不可变对象,它可以节省创建新对象的内存和时间开销。我们来看下emptyList方法的具体定义:

1
2
3
public static final <T> List<T> emptyList() {
return (List<T>) EMPTY_LIST;
}

EMPTY_LIST的定义为:

1
public static final List EMPTY_LIST = new EmptyList<>();

是一个静态不可变对象,类型为EmptyList,它是一个私有静态内部类,继承自Abstract-List,主要代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private static class EmptyList<E>
extends AbstractList<E>
implements RandomAccess {
public Iterator<E> iterator() {
return emptyIterator();
}
public ListIterator<E> listIterator() {
return emptyListIterator();
}
public int size() {return 0; }
public boolean isEmpty() {return true; }
public boolean contains(Object obj) {return false; }
public boolean containsAll(Collection<? > c) { return c.isEmpty(); }
public Object[] toArray() { return new Object[0]; }
public <T> T[] toArray(T[] a) {
if(a.length > 0)
a[0] = null;
return a;
}
public E get(int index) {
throw new IndexOutOfBoundsException("Index: "+index);
}
public boolean equals(Object o) {
return (o instanceof List) && ((List<? >)o).isEmpty();
}
public int hashCode() { return 1; }
}

emptyIterator和emptyListIterator返回空的迭代器。emptyIterator的代码为:

1
2
3
public static <T> Iterator<T> emptyIterator() {
return (Iterator<T>) EmptyIterator.EMPTY_ITERATOR;
}

EmptyIterator是一个静态内部类,代码为:

1
2
3
4
5
6
7
private static class EmptyIterator<E> implements Iterator<E> {
static final EmptyIterator<Object> EMPTY_ITERATOR
= new EmptyIterator<>();
public boolean hasNext() { return false; }
public E next() { throw new NoSuchElementException(); }
public void remove() { throw new IllegalStateException(); }
}

以上这些代码都比较简单,就不赘述了。需要注意的是,EmptyList不支持修改操作,比如:

1
Collections.emptyList().add("hello");

会抛出异常UnsupportedOperationException。

如果返回值只是用于读取,可以使用emptyList方法,但如果返回值还用于写入,则需要新建一个对象。其他空容器方法与emptyList方法类似,我们就不赘述了。它们都可以被用于方法返回值,以便调用者统一进行处理,同时节省时间和内存开销,它们的共同限制是返回值不能用于写入。我们将空容器方法看作适配器,是因为它将null或“空”转换为了容器对象。

需要说明的是,在Java 9中,可以使用List、Map和Set不带参数的of方法返回一个空的只读容器对象,也就是说,如下两行代码的效果是相同的:

1
2
1. List list = Collections.emptyList();
2. List list = List.of();

2.单一对象方法

Collections中还有一组方法,可以将一个单独的对象转换为一个标准的容器接口对象,比如:

1
2
3
public static <T> Set<T> singleton(T o)
public static <T> List<T> singletonList(T o)
public static <K, V> Map<K, V> singletonMap(K key, V value)

比如,可以这么用:

1
2
3
4
Collection<String> coll = Collections.singleton("编程");
Set<String> set = Collections.singleton("编程");
List<String> list = Collections.singletonList("老马");
Map<String, String> map = Collections.singletonMap("老马", "编程");

这些方法也经常用于构建方法返回值,相比新建容器对象并添加元素,这些方法更为简洁方便,此外,它们的实现更为高效,它们的实现类都针对单一对象进行了优化。比如, singleton方法的代码:

1
2
3
public static <T> Set<T> singleton(T o) {
return new SingletonSet<>(o);
}

新建了一个SingletonSet对象,SingletonSet是一个静态内部类,主要代码为:

1
2
3
4
5
6
7
8
9
10
private static class SingletonSet<E>
extends AbstractSet<E> {
private final E element;
SingletonSet(E e) {element = e; }
public Iterator<E> iterator() {
return singletonIterator(element);
}
public int size() {return 1; }
public boolean contains(Object o) {return eq(o, element); }
}

singletonIterator是一个内部方法,将单一对象转换为了一个迭代器接口对象,代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static <E> Iterator<E> singletonIterator(final E e) {
return new Iterator<E>() {
private boolean hasNext = true;
public boolean hasNext() {
return hasNext;
}
public E next() {
if(hasNext) {
hasNext = false;
return e;
}
throw new NoSuchElementException();
}
public void remove() {
throw new UnsupportedOperationException();
}
};
}

eq方法就是比较两个对象是否相同,考虑了null的情况,代码为:

1
2
3
static boolean eq(Object o1, Object o2) {
return o1==null ? o2==null : o1.equals(o2);
}

需要注意的是,singleton方法返回的也是不可变对象,只能用于读取,写入会抛出UnsupportedOperationException异常。其他singletonXXX方法的实现思路是类似的,返回值也都只能用于读取,不能写入,我们就不赘述了。

除了用于构建返回值,这些方法还可用于构建方法参数。比如,从容器中删除对象, Collection有如下方法:

1
2
boolean remove(Object o);
boolean removeAll(Collection<? > c);

remove方法只会删除第一条匹配的记录,removeAll方法可以删除所有匹配的记录,但需要一个容器接口对象,如果需要从一个List中删除所有匹配的某一对象呢?这时,就可以使用Collections.singleton封装这个要删除的对象。比如,从list中删除所有的”b”,代码如下所示:

1
2
3
4
List<String> list = new ArrayList<>();
Collections.addAll(list, "a", "b", "c", "d", "b");
list.removeAll(Collections.singleton("b"));
System.out.println(list);

需要说明的是,在Java 9中,可以使用List、Map和Set的of方法达到singleton同样的功能,也就是说,如下两行代码的效果是相同的:

1
2
1. Set<String> b = Collections.singleton("b");
2. Set<String> b = Set.of("b");

除了以上两组方法,Collections中还有如下适配器方法,用的相对较少,我们就不详细介绍了。

1
2
3
4
5
6
//将Map接口转换为Set接口
public static <E> Set<E> newSetFromMap(Map<E, Boolean> map)
//将Deque接口转换为后进先出的队列接口
public static <T> Queue<T> asLifoQueue(Deque<T> deque)
//返回包含n个相同对象o的List接口
public static <T> List<T> nCopies(int n, T o)

12.2.5 装饰器

装饰器接受一个接口对象,并返回一个同样接口的对象,不过,新对象可能会扩展一些新的方法或属性,扩展的方法或属性就是所谓的“装饰”,也可能会对原有的接口方法做一些修改,达到一定的“装饰”目的。Collections有三组装饰器方法,它们的返回对象都没有新的方法或属性,但改变了原有接口方法的性质,经过“装饰”后,它们更为安全了,具体分别是写安全、类型安全和线程安全,我们分别来看下。

1.写安全

写安全的主要方法有:

1
2
3
4
5
public static <T> Collection<T> unmodifiableCollection(
Collection<? extends T> c)
public static <T> List<T> unmodifiableList(List<? extends T> list)
public static <K, V> Map<K, V> unmodifiableMap(Map<? extends K, ? extends V> m)
public static <T> Set<T> unmodifiableSet(Set<? extends T> s)

顾名思义,这组unmodifiableXXX方法就是使容器对象变为只读的,写入会抛出UnsupportedOperationException异常。为什么要变为只读的呢?典型场景是:需要传递一个容器对象给一个方法,这个方法可能是第三方提供的,为避免第三方误写,所以在传递前,变为只读的,如下所示:

1
2
3
4
5
6
7
8
public static void thirdMethod(Collection<String> c){
c.add("bad");
}
public static void mainMethod(){
List<String> list = new ArrayList<>(Arrays.asList(
new String[]{"a", "b", "c", "d"}));
thirdMethod(Collections.unmodifiableCollection(list));
}

这样,调用就会触发异常,从而避免了将错误数据插入。

这些方法是如何实现的呢?每个方法内部都对应一个类,这个类实现了对应的容器接口,它内部是待装饰的对象,只读方法传递给这个内部对象,写方法抛出异常。比如, unmodifiableCollection方法的代码为:

1
2
3
4
public static <T> Collection<T> unmodifiableCollection(
Collection<? extends T> c) {
return new UnmodifiableCollection<>(c);
}

UnmodifiableCollection是一个静态内部类,代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
static class UnmodifiableCollection<E> implements Collection<E>,
Serializable {
private static final long serialVersionUID = 1820017752578914078L;
final Collection<? extends E> c;
UnmodifiableCollection(Collection<? extends E> c) {
if(c==null)
throw new NullPointerException();
this.c = c;
}
public int size() {return c.size(); }
public boolean isEmpty() {return c.isEmpty(); }
public boolean contains(Object o) {return c.contains(o); }
public Object[] toArray() {return c.toArray(); }
public <T> T[] toArray(T[] a) {return c.toArray(a); }
public String toString() {return c.toString(); }
public Iterator<E> iterator() {
return new Iterator<E>() {
private final Iterator<? extends E> i = c.iterator();
public boolean hasNext() {return i.hasNext(); }
public E next() {return i.next(); }
public void remove() {
throw new UnsupportedOperationException();
}
};
}
public boolean add(E e) {
throw new UnsupportedOperationException();
}
public boolean remove(Object o) {
throw new UnsupportedOperationException();
}
public boolean containsAll(Collection<? > coll) {
return c.containsAll(coll);
}
public boolean addAll(Collection<? extends E> coll) {
throw new UnsupportedOperationException();
}
public boolean removeAll(Collection<? > coll) {
throw new UnsupportedOperationException();
}
public boolean retainAll(Collection<? > coll) {
throw new UnsupportedOperationException();
}
public void clear() {
throw new UnsupportedOperationException();
}
}

代码比较简单,其他unmodifiableXXX方法的实现也都类似,我们就不赘述了。

2.类型安全

所谓类型安全是指确保容器中不会保存错误类型的对象。容器怎么会允许保存错误类型的对象呢?我们看段代码:

1
2
3
List list = new ArrayList<Integer>();
list.add("hello");
System.out.println(list);

我们创建了一个Integer类型的List对象,但添加了字符串类型的对象”hello”,编译没有错误,运行也没有异常,程序输出为”[hello]”。

之所以会出现这种情况,是因为Java是通过擦除来实现泛型的,而且类型参数是可选的。正常情况下,我们会加上类型参数,让泛型机制来保证类型的正确性。但是,由于泛型是Java 5以后才加入的,之前的代码可能没有类型参数,而新的代码可能需要与老的代码互动。

为了避免老的代码用错类型,确保在泛型机制失灵的情况下类型的正确性,可以在传递容器对象给老代码之前,使用类似如下方法“装饰”容器对象:

1
2
3
4
public static <E> List<E> checkedList(List<E> list, Class<E> type)
public static <K, V> Map<K, V> checkedMap(Map<K, V> m,
Class<K> keyType, Class<V> valueType)
public static <E> Set<E> checkedSet(Set<E> s, Class<E> type)

使用这组checkedXXX方法,都需要传递类型对象,这些方法都会使容器对象的方法在运行时检查类型的正确性,如果不匹配,会抛出ClassCastException异常。比如:

1
2
3
List list = new ArrayList<Integer>();
list = Collections.checkedList(list, Integer.class);
list.add("hello");

这次,运行就会抛出异常,从而避免错误类型的数据插入:

1
2
java.lang.ClassCastException:  Attempt  to  insert  class  java.lang.String  element
into collection with element type class java.lang.Integer

这些checkedXXX方法的实现机制是类似的,每个方法内部都对应一个类,这个类实现了对应的容器接口,它内部是待装饰的对象,大部分方法只是传递给这个内部对象,但对添加和修改方法,会首先进行类型检查,类型不匹配会抛出异常,类型匹配才传递给内部对象。以checkedCollection为例,我们来看下代码:

1
2
3
4
public static <E> Collection<E> checkedCollection(
Collection<E> c, Class<E> type) {
return new CheckedCollection<>(c, type);
}

CheckedCollection是一个静态内部类,主要代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
static class CheckedCollection<E> implements Collection<E>, Serializable {
private static final long serialVersionUID = 1578914078182001775L;
final Collection<E> c;
final Class<E> type;
void typeCheck(Object o) {
if(o ! = null && ! type.isInstance(o))
throw new ClassCastException(badElementMsg(o));
}
private String badElementMsg(Object o) {
return "Attempt to insert " + o.getClass() +
" element into collection with element type " + type;
}
CheckedCollection(Collection<E> c, Class<E> type) {
if(c==null || type == null)
throw new NullPointerException();
this.c = c;
this.type = type;
}
public int size() { return c.size(); }
public boolean isEmpty() { return c.isEmpty(); }
public boolean contains(Object o) { return c.contains(o); }
public Object[] toArray() { return c.toArray(); }
public <T> T[] toArray(T[] a) { return c.toArray(a); }
public String toString() { return c.toString(); }
public boolean remove(Object o) { return c.remove(o); }
public void clear() { c.clear(); }
public boolean containsAll(Collection<? > coll) {
return c.containsAll(coll);
}
public boolean removeAll(Collection<? > coll) {
return c.removeAll(coll);
}
public boolean retainAll(Collection<? > coll) {
return c.retainAll(coll);
}
public Iterator<E> iterator() {
final Iterator<E> it = c.iterator();
return new Iterator<E>() {
public boolean hasNext() { return it.hasNext(); }
public E next() { return it.next(); }
public void remove() { it.remove(); }};
}
public boolean add(E e) {
typeCheck(e);
return c.add(e);
}
}

代码比较简单,add方法中,会先调用typeCheck进行类型检查。其他checkedXXX方法的实现也都类似,我们就不赘述了。

3.线程安全

关于线程,我们后续章节会详细介绍,这里简要说明下。之前我们介绍的各种容器类基本都不是线程安全的,也就是说,如果多个线程同时读写同一个容器对象,是不安全的。

Collections提供了一组方法,可以将一个容器对象变为线程安全的,比如:

1
2
3
4
public static <T> Collection<T> synchronizedCollection(Collection<T> c)
public static <T> List<T> synchronizedList(List<T> list)
public static <K, V> Map<K, V> synchronizedMap(Map<K, V> m)
public static <T> Set<T> synchronizedSet(Set<T> s)

需要说明的是,这些方法都是通过给所有容器方法加锁来实现的,这种实现并不是最优的。Java提供了很多专门针对并发访问的容器类,我们在第17章介绍。

12.2.6 小结

本节介绍了类Collections中的两类操作。第一类操作是一些通用算法,包括查找、替换、排序、调整顺序、添加、修改等,这些算法操作的都是容器接口对象,这是面向接口编程的一种体现,只要对象实现了这些接口,就可以使用这些算法。第二类操作都返回一个容器接口对象,这些方法代表两种设计模式,一种是适配器,另一种是装饰器,我们介绍了这两种设计模式,以及这些方法的用法、适用场合和实现机制。

第12章 通用容器类和总结

之前的章节中,我们介绍的都是具体的容器类,本章介绍一些抽象容器类、一些通用的算法和功能,并对整个容器类体系进行梳理总结。

之前介绍的具体容器类其实都不是从头构建的,它们都继承了一些抽象容器类。这些抽象类提供了容器接口的部分实现,方便了Java具体容器类的实现。此外,通过继承抽象类,自定义的类也可以更为容易地实现容器接口。为什么需要实现容器接口呢?至少有两个原因。
1)容器类是一个大家庭,它们之间可以方便地协作,比如很多方法的参数和返回值都是容器接口对象,实现了容器接口,就可以方便地参与这种协作。
2)Java有一个类Collections,提供了很多针对容器接口的通用算法和功能,实现了容器接口,可以直接利用Collections中的算法和功能。

本章首先介绍抽象容器类,然后介绍Collections中的通用功能,最后对整个容器类体系进行梳理总结。

12.1 抽象容器类

抽象容器类与之前介绍的接口和具体容器类的关系如图12-1所示。

虚线框表示接口,有Collection、List、Set、Queue、Deque和Map。有6个抽象容器类。
1)AbstractCollection:实现了Collection接口,被抽象类AbstractList、AbstractSet、AbstractQueue继承,ArrayDeque也继承自AbstractCollection(图中未画出)。
2)AbstractList:父类是AbstractCollection,实现了List接口,被ArrayList、Abstract-SequentialList继承。

epub_923038_110

图12-1 容器类体系与抽象容器类

3)AbstractSequentialList:父类是AbstractList,被LinkedList继承。
4)AbstractMap:实现了Map接口,被TreeMap、HashMap、EnumMap继承。
5)AbstractSet:父类是AbstractCollection,实现了Set接口,被HashSet、TreeSet和EnumSet继承。
6)AbstractQueue:父类是AbstractCollection,实现了Queue接口,被PriorityQueue继承。

下面,我们分别来介绍这些抽象类,包括它们提供的基础功能、如何实现、如何进行扩展等,代码分析基于Java 7。

12.1.1 AbstractCollection

AbstractCollection提供了Collection接口的基础实现,具体来说,它实现了如下方法:

1
2
3
4
5
6
7
8
9
10
11
public boolean addAll(Collection<? extends E> c)
public boolean contains(Object o)
public boolean containsAll(Collection<? > c)
public boolean isEmpty()
public boolean remove(Object o)
public boolean removeAll(Collection<? > c)
public boolean retainAll(Collection<? > c)
public void clear()
public Object[] toArray()
public <T> T[] toArray(T[] a)
public String toString()

AbstractCollection又不知道数据是怎么存储的,它是如何实现这些方法的呢?它依赖于如下更为基础的方法:

1
2
3
public boolean add(E e)
public abstract int size();
public abstract Iterator<E> iterator();

add方法的默认实现是:

1
2
3
public boolean add(E e) {
throw new UnsupportedOperationException();
}

抛出“操作不支持”异常,如果子类集合是不可被修改的,这个默认实现就可以了,否则,必须重写add方法。addAll方法的实现就是循环调用add方法。

size方法是抽象方法,子类必须重写。isEmpty方法就是检查size方法的返回值是否为0。toArray方法依赖size方法的返回值分配数组大小。

iterator方法也是抽象方法,它返回一个实现了迭代器接口的对象,子类必须重写。我们知道,迭代器定义了三个方法:

1
2
3
boolean hasNext();
E next();
void remove();

如果子类集合是不可被修改的,迭代器不用实现remove方法,否则,三个方法都必须实现。

AbstractCollection中的大部分方法都是基于迭代器的方法实现的,比如contains方法,其代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
public boolean contains(Object o) {
Iterator<E> it = iterator();
if(o==null) {
while(it.hasNext())
if(it.next()==null)
return true;
} else {
while(it.hasNext())
if(o.equals(it.next()))
return true;
}
return false;
}

通过迭代器方法循环进行比较。

除了接口中的方法,Collection接口文档建议,每个Collection接口的实现类都应该提供至少两个标准的构造方法,一个是默认构造方法,另一个接受一个Collection类型的参数。

具体如何通过继承AbstractCollection来实现自定义容器呢?我们通过一个简单的例子来说明。我们使用在8.1节自己实现的动态数组容器类DynamicArray来实现一个简单的Collection。

DynamicArray当时没有实现根据索引添加和删除的方法,我们先来补充一下,如代码清单12-1所示。

代码清单12-1 添加方法后的DynamicArray
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class DynamicArray<E> {
//…
public E remove(int index) {
E oldValue = get(index);
int numMoved = size - index - 1;
if(numMoved > 0)
System.arraycopy(elementData, index + 1, elementData, index,
numMoved);
elementData[--size] = null;
return oldValue;
}
public void add(int index, E element) {
ensureCapacity(size + 1);
System.arraycopy(elementData, index, elementData, index + 1,
size - index);
elementData[index] = element;
size++;
}
}

基于DynamicArray,我们实现一个简单的迭代器类DynamicArrayIterator,如代码清单12-2所示。

代码清单12-2 一个简单的迭代器类DynamicArrayIterator
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class DynamicArrayIterator<E>   implements Iterator<E>{
DynamicArray<E> darr;
int cursor;
int lastRet = -1;
public DynamicArrayIterator(DynamicArray<E> darr){
this.darr = darr;
}
@Override
public boolean hasNext() {
return cursor ! = darr.size();
}
@Override
public E next() {
int i = cursor;
if(i >= darr.size())
throw new NoSuchElementException();
cursor = i + 1;
lastRet = i;
return darr.get(i);
}
@Override
public void remove() {
if(lastRet < 0)
throw new IllegalStateException();
darr.remove(lastRet);
cursor = lastRet;
lastRet = -1;
}
}

代码很简单,就不解释了,为简单起见,我们没有实现实际容器类中的有关检测结构性变化的逻辑。

基于DynamicArray和DynamicArrayIterator,通过继承AbstractCollection,我们来实现一个简单的容器类MyCollection,如代码清单12-3所示。

代码清单12-3 一个简单的容器类MyCollection
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class MyCollection<E> extends AbstractCollection<E> {
DynamicArray<E> darr;
public MyCollection(){
darr = new DynamicArray<>();
}
public MyCollection(Collection<? extends E> c){
this();
addAll(c);
}
@Override
public Iterator<E> iterator() {
return new DynamicArrayIterator<>(darr);
}
@Override
public int size() {
return darr.size();
}
@Override
public boolean add(E e) {
darr.add(e);
return true;
}
}

代码很简单,就是按建议提供了两个构造方法,并重写了size、add和iterator方法,这些方法内部使用了DynamicArray和DynamicArrayIterator。

12.1.2 AbstractList

AbstractList提供了List接口的基础实现,具体来说,它实现了如下方法:

1
2
3
4
5
6
7
8
9
10
11
public boolean add(E e)
public boolean addAll(int index, Collection<? extends E> c)
public void clear()
public boolean equals(Object o)
public int hashCode()
public int indexOf(Object o)
public Iterator<E> iterator()
public int lastIndexOf(Object o)
public ListIterator<E> listIterator()
public ListIterator<E> listIterator(final int index)
public List<E> subList(int fromIndex, int toIndex)

AbstractList是怎么实现这些方法的呢?它依赖于如下更为基础的方法:

1
2
3
4
5
public abstract int size();
abstract public E get(int index);
public E set(int index, E element)
public void add(int index, E element)
public E remove(int index)

size方法与AbstractCollection一样,也是抽象方法,子类必须重写。get方法根据索引index获取元素,它也是抽象方法,子类必须重写。

set、add、remove方法都是修改容器内容,它们不是抽象方法,但默认实现都是抛出异常UnsupportedOperationException。如果子类容器不可被修改,这个默认实现就可以了。如果可以根据索引修改内容,应该重写set方法。如果容器是长度可变的,应该重写add和remove方法。

与AbstractCollection不同,继承AbstractList不需要实现迭代器类和相关方法,AbstractList内部实现了两个迭代器类,一个实现了Iterator接口,另一个实现了ListIterator接口,它们是基于以上的这些基础方法实现的,逻辑比较简单,就不赘述了。

具体如何扩展AbstractList呢?我们来看个例子,也通过DynamicArray来实现一个简单的List,如代码清单12-4所示。

代码清单12-4 扩展AbstractList的List实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class MyList<E> extends AbstractList<E> {
private DynamicArray<E> darr;
public MyList(){
darr = new DynamicArray<>();
}
public MyList(Collection<? extends E> c){
this();
addAll(c);
}
@Override
public E get(int index) {
return darr.get(index);
}
@Override
public int size() {
return darr.size();
}
@Override
public E set(int index, E element) {
return darr.set(index, element);
}
@Override
public void add(int index, E element) {
darr.add(index, element);
}
@Override
public E remove(int index) {
return darr.remove(index);
}
}

代码很简单,就是按建议提供了两个构造方法,并重写了size、get、set、add和remove方法,这些方法内部使用了DynamicArray。

12.1.3 AbstractSequentialList

AbstractSequentialList是AbstractList的子类,也提供了List接口的基础实现,具体来说,它实现了如下方法:

1
2
3
4
5
6
public void add(int index, E element)
public boolean addAll(int index, Collection<? extends E> c)
public E get(int index)
public Iterator<E> iterator()
public E remove(int index)
public E set(int index, E element)

可以看出,它实现了根据索引位置进行操作的get、set、add、remove方法,它是怎么实现的呢?它是基于ListIterator接口的方法实现的,在AbstractSequentialList中,listIterator方法被重写为了一个抽象方法:

1
public abstract ListIterator<E> listIterator(int index)

子类必须重写该方法,并实现迭代器接口。

我们来看段具体的代码,看get、set、add、remove是如何基于ListIterator实现的。get方法代码为:

1
2
3
4
5
6
7
public E get(int index) {
try {
return listIterator(index).next();
} catch (NoSuchElementException exc) {
throw new IndexOutOfBoundsException("Index: "+index);
}
}

代码很简单,其他方法也都类似,就不赘述了。

注意与AbstractList相区别,可以说,虽然AbstractSequentialList是AbstractList的子类,但实现逻辑和用法上,与AbstractList正好相反。

  • AbstractList需要具体子类重写根据索引操作的方法get、set、add、remove,它提供了迭代器,但迭代器是基于这些方法实现的。它假定子类可以高效地根据索引位置进行操作,适用于内部是随机访问类型的存储结构(如数组),比如ArrayList就继承自AbstractList。
  • AbstractSequentialList需要具体子类重写迭代器,它提供了根据索引操作的方法get、set、add、remove,但这些方法是基于迭代器实现的。它适用于内部是顺序访问类型的存储结构(如链表),比如LinkedList就继承自AbstractSequentialList。

具体如何扩展AbstractSequentialList呢?我们还是以DynamicArray举例来说明,在实际应用中,如果内部存储结构类似DynamicArray,应该继承AbstractList,这里主要是演示其用法。

扩展AbstractSequentialList需要实现ListIterator,前面介绍的DynamicArrayIterator只实现了Iterator接口,通过继承DynamicArrayIterator,我们实现一个新的实现了List-Iterator接口的类DynamicArrayListIterator,如代码清单12-5所示。

代码清单12-5 实现了ListIterator接口的类DynamicArrayListIterator
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class DynamicArrayListIterator<E>
extends DynamicArrayIterator<E> implements ListIterator<E>{
public DynamicArrayListIterator(int index, DynamicArray<E> darr){
super(darr);
this.cursor = index;
}
@Override
public boolean hasPrevious() {
return cursor > 0;
}
@Override
public E previous() {
if(! hasPrevious())
throw new NoSuchElementException();
cursor--;
lastRet = cursor;
return darr.get(lastRet);
}
@Override
public int nextIndex() {
return cursor;
}
@Override
public int previousIndex() {
return cursor - 1;
}
@Override
public void set(E e) {
if(lastRet==-1){
throw new IllegalStateException();
}
darr.set(lastRet, e);
}
@Override
public void add(E e) {
darr.add(cursor, e);
cursor++;
lastRet = -1;
}
}

逻辑比较简单,就不解释了。有了DynamicArrayListIterator,我们看基于Abstract-SequentialList的List实现,如代码清单22-6所示。

代码清单12-6 基于AbstractSequentialList的List实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class MySeqList<E> extends AbstractSequentialList<E> {
private DynamicArray<E> darr;
public MySeqList(){
darr = new DynamicArray<>();
}
public MySeqList(Collection<? extends E> c){
this();
addAll(c);
}
@Override
public ListIterator<E> listIterator(int index) {
return new DynamicArrayListIterator<>(index, darr);
}
@Override
public int size() {
return darr.size();
}
}

代码很简单,就是按建议提供了两个构造方法,并重写了size和listIterator方法,迭代器的实现是DynamicArrayListIterator。

12.1.4 AbstractMap

AbstractMap提供了Map接口的基础实现,具体来说,它实现了如下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
public void clear()
public boolean containsKey(Object key)
public boolean containsValue(Object value)
public boolean equals(Object o)
public V get(Object key)
public int hashCode()
public boolean isEmpty()
public Set<K> keySet()
public void putAll(Map<? extends K, ? extends V> m)
public V remove(Object key)
public int size()
public String toString()
public Collection<V> values()

AbstractMap是如何实现这些方法的呢?它依赖于如下更为基础的方法:

1
2
public V put(K key, V value)
public abstract Set<Entry<K, V>> entrySet();

putAll就是循环调用put。put方法的默认实现是抛出异常UnsupportedOperation-Exception,如果Map是允许写入的,则需要重写该方法。

其他方法都基于entrySet方法。entrySet方法是一个抽象方法,子类必须重写,它返回所有键值对的Set视图,如果Map是允许删除的,这个Set的迭代器实现类,即entrySet(). iterator()的返回对象,必须实现迭代器的remove方法,这是因为AbstractMap的remove方法是通过entrySet().iterator().remove()实现的。

除了提供基础方法的实现,AbstractMap类内部还定义了两个公有的静态内部类,表示键值对:

1
2
AbstractMap.SimpleEntry implements Entry<K, V>
AbstractMap.SimpleImmutableEntry implements Entry<K, V>

SimpleImmutableEntry用于表示只读的键值对,而SimpleEntry用于表示可写的。

Map接口文档建议:每个Map接口的实现类都应该提供至少两个标准的构造方法,一个是默认构造方法,另一个接受一个Map类型的参数。

具体如何扩展AbstractMap呢?我们定义一个简单的Map实现类MyMap,内部还是用DynamicArray,如代码清单12-7所示。

代码清单12-7 一个简单的Map实现类MyMap
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class MyMap<K, V> extends AbstractMap<K, V> {
private DynamicArray<Map.Entry<K, V>> darr;
private Set<Map.Entry<K, V>> entrySet = null;
public MyMap() {
darr = new DynamicArray<>();
}
public MyMap(Map<? extends K, ? extends V> m) {
this();
putAll(m);
}
@Override
public Set<Entry<K, V>> entrySet() {
Set<Map.Entry<K, V>> es = entrySet;
return es ! = null ? es : (entrySet = new EntrySet());
}
@Override
public V put(K key, V value) {
for(int i = 0; i < darr.size(); i++) {
Map.Entry<K, V> entry = darr.get(i);
if((key == null && entry.getKey() == null)
|| (key ! = null && key.equals(entry.getKey()))) {
V oldValue = entry.getValue();
entry.setValue(value);
return oldValue;
}
}
Map.Entry<K, V> newEntry = new AbstractMap.SimpleEntry<>(key, value);
darr.add(newEntry);
return null;
}
class EntrySet extends AbstractSet<Map.Entry<K, V>> {
public Iterator<Map.Entry<K, V>> iterator() {
return new DynamicArrayIterator<Map.Entry<K, V>>(darr);
}
public int size() {
return darr.size();
}
}
}

我们定义了两个构造方法,实现了put和entrySet方法。put方法先通过循环查找是否已存在对应的键,如果存在则修改值,否则新建一个键值对(类型为AbstractMap.Simple-Entry)并添加。entrySet返回的类型是一个内部类EntrySet,它继承自AbstractSet,重写了size和iterator方法,iterator方法中,返回的是迭代器类型是DynamicArrayIterator,它支持remove方法。

12.1.5 AbstractSet

AbstractSet提供了Set接口的基础实现,它继承自AbstractCollection,增加了equals和hashCode方法的默认实现。Set接口要求容器内不能包含重复元素,AbstractSet并没有实现该约束,子类需要自己实现。

扩展AbstractSet与AbstractCollection是类似的,只是需要实现无重复元素的约束,比如,add方法内需要检查元素是否已经添加过了。具体实现比较简单,我们就不赘述了。

12.1.6 AbstractQueue

AbstractQueue提供了Queue接口的基础实现,它继承自AbstractCollection,实现了如下方法:

1
2
3
4
5
public boolean add(E e)
public boolean addAll(Collection<? extends E> c)
public void clear()
public E element()
public E remove()

这些方法是基于Queue接口的其他方法实现的,包括:

1
2
3
E peek();
E poll();
boolean offer(E e);

扩展AbstractQueue需要实现这些方法,具体逻辑也比较简单,我们就不赘述了。

12.1.7 小结

本小节介绍了Java容器类中的抽象类AbstractCollection、AbstractList、AbstractSequen-tialList、AbstractSet、AbstractQueue以及AbstractMap,介绍了它们与容器接口和具体类的关系,对每个抽象类,介绍了它提供的基础功能,如何实现,并举例说明了如何进行扩展,完整的代码在github上,地址为 https://github.com/swiftma/program-logic ,位于包shuo.lao-ma.collection.c52下。

前面我们提到,实现了容器接口,就可以方便地参与到容器类这个大家庭中进行相互协作,也可以方便地利用Collections这个类实现的通用算法和功能

但Collections都实现了哪些算法和功能?都有什么用途?如何使用?内部又是如何实现的?有何参考价值?让我们下一小节来探讨。

11.3 堆和PriorityQueue的应用

PriorityQueue除了用作优先级队列,还可以用来解决一些别的问题,本章开头提到了如下两个应用。
1)求前K个最大的元素,元素个数不确定,数据量可能很大,甚至源源不断到来,但需要知道到目前为止的最大的前K个元素。这个问题的变体有:求前K个最小的元素,求第K个最大的元素,求第K个最小的元素。
2)求中值元素,中值不是平均值,而是排序后中间那个元素的值,同样,数据量可能很大,甚至源源不断到来。

本节,我们就来探讨如何解决这两个问题。

11.3.1 求前K个最大的元素

一个简单的思路是排序,排序后取最大的K个就可以了,排序可以使用Arrays.sort()方法,效率为O(N×log2(N))。不过,如果K很小,比如是1,就是取最大值,对所有元素完全排序是毫无必要的。另一个简单的思路是选择,循环选择K次,每次从剩下的元素中选择最大值,这个效率为O(N×K),如果K的值大于log2(N),这个就不如完全排序了。

不过,这两个思路都假定所有元素都是已知的,而不是动态添加的。如果元素个数不确定,且源源不断到来呢?

一个基本的思路是维护一个长度为K的数组,最前面的K个元素就是目前最大的K个元素,以后每来一个新元素的时候,都先找数组中的最小值,将新元素与最小值相比,如果小于最小值,则什么都不用变,如果大于最小值,则将最小值替换为新元素。

这有点类似于生活中的末位淘汰,新元素与原来最末尾的比即可,要么不如最末尾,上不去,要么替掉原来的末尾。

这样,数组中维护的永远是最大的K个元素,而且不管源数据有多少,需要的内存开销是固定的,就是长度为K的数组。不过,每来一个元素,都需要找最小值,都需要进行K次比较,能不能减少比较次数呢?

解决方法是使用最小堆维护这K个元素,最小堆中,根即第一个元素永远都是最小的,新来的元素与根比就可以了,如果小于根,则堆不需要变化,否则用新元素替换根,然后向下调整堆即可,调整的效率为O(log2(K)),这样,总体的效率就是O(N×log2(K)),这个效率非常高,而且存储成本也很低。

使用最小堆之后,第K个最大的元素也很容易获得,它就是堆的根。

理解了思路,下面我们来看代码。我们实现一个简单的TopK类,如代码清单11-1所示。

代码清单11-1 求前K个最大的元素:TopK
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class TopK <E> {
private PriorityQueue<E> p;
private int k;
public TopK(int k){
this.k = k;
this.p = new PriorityQueue<>(k);
}
public void addAll(Collection<? extends E> c){
for(E e : c){
add(e);
}
}
public void add(E e) {
if(p.size()<k){
p.add(e);
return;
}
Comparable<? super E> head = (Comparable<? super E>)p.peek();
if(head.compareTo(e)>0){
//小于TopK中的最小值,不用变
return;
}
//新元素替换掉原来的最小值成为TopK之一
p.poll();
p.add(e);
}
public <T> T[] toArray(T[] a){
return p.toArray(a);
}
public E getKth(){
return p.peek();
}
}

我们稍微解释一下。TopK内部使用一个优先级队列和k,构造方法接受一个参数k,使用PriorityQueue的默认构造方法,假定元素实现了Comparable接口。

add方法实现向其中动态添加元素,如果元素个数小于k直接添加,否则与最小值比较,只在大于最小值的情况下添加,添加前,先删掉原来的最小值。addAll方法循环调用add方法。

toArray方法返回当前的最大的K个元素,getKth方法返回第K个最大的元素。

我们来看一下使用的例子:

1
2
3
4
5
6
TopK<Integer> top5 = new TopK<>(5);
top5.addAll(Arrays.asList(new Integer[]{
100, 1, 2, 5, 6, 7, 34, 9, 3, 4, 5, 8, 23, 21, 90, 1, 0
}));
System.out.println(Arrays.toString(top5.toArray(new Integer[0])));
System.out.println(top5.getKth());

保留5个最大的元素,输出为:

1
2
[21, 23, 34, 100, 90]
21

代码比较简单,就不解释了。

11.3.2 求中值

中值就是排序后中间那个元素的值,如果元素个数为奇数,中值是没有歧义的,但如果是偶数,中值可能有不同的定义,可以为偏小的那个,也可以是偏大的那个,或者两者的平均值,或者任意一个,这里,我们假定任意一个都可以。

一个简单的思路是排序,排序后取中间那个值就可以了,排序可以使用Arrays.sort()方法,效率为O(N× log2(N))。

不过,这要求所有元素都是已知的,而不是动态添加的。如果元素源源不断到来,如何实时得到当前已经输入的元素序列的中位数?

可以使用两个堆,一个最大堆,一个最小堆,思路如下。
1)假设当前的中位数为m,最大堆维护的是<=m的元素,最小堆维护的是>=m的元素,但两个堆都不包含m。
2)当新的元素到达时,比如为e,将e与m进行比较,若e<=m,则将其加入最大堆中,否则将其加入最小堆中。
3)第2步后,如果此时最小堆和最大堆的元素个数的差值>=2 ,则将m加入元素个数少的堆中,然后从元素个数多的堆将根节点移除并赋值给m。

我们通过一个例子来解释下。比如输入元素依次为:

1
34, 90, 67, 45,1

输入第1个元素时,m即为34。

输入第2个元素时,90大于34,加入最小堆,中值不变,如图11-20所示。

epub_923038_105

图11-20 求中值:输入第2个元素后

输入第3个元素时,67大于34,加入最小堆,但加入最小堆后,最小堆的元素个数为2,需调整中值和堆,现有中值34加入最大堆中,最小堆的根67从最小堆中删除并赋值给m,如图11-21所示。

epub_923038_106

图11-21 求中值:输入第三个元素后

输入第4个元素45时,45小于67,加入最大堆,中值不变,如图11-22所示。

img

图11-22 求中值:输入第四个元素后

输入第5个元素1时,1小于67,加入最大堆,此时需调整中值和堆,现有中值67加入最小堆中,最大堆的根45从最大堆中删除并赋值给m,如图11-23所示。

epub_923038_108

图11-23 求中值:输入第五个元素后

理解了基本思路,我们来实现一个简单的中值类Median,如代码清单11-2所示。

代码清单11-2 求中值:Median
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class Median <E> {
private PriorityQueue<E> minP; //最小堆
private PriorityQueue<E> maxP; //最大堆
private E m; //当前中值
public Median(){
this.minP = new PriorityQueue<>();
this.maxP = new PriorityQueue<>(11, Collections.reverseOrder());
}
private int compare(E e, E m){
Comparable<? super E> cmpr = (Comparable<? super E>)e;
return cmpr.compareTo(m);
}
public void add(E e){
if(m==null){ //第一个元素
m = e;
return;
}
if(compare(e, m)<=0){
//小于中值, 加入最大堆
maxP.add(e);
}else{
minP.add(e);
}
if(minP.size()-maxP.size()>=2){
//最小堆元素个数多,即大于中值的数多
//将m加入到最大堆中,然后将最小堆中的根移除赋给m
maxP.add(this.m);
this.m = minP.poll();
}else if(maxP.size()-minP.size()>=2){
minP.add(this.m);
this.m = maxP.poll();
}
}
public void addAll(Collection<? extends E> c){
for(E e : c){
add(e);
}
}
public E getM() {
return m;
}
}

代码和思路基本是对应的,比较简单,就不解释了。我们来看一个使用的例子:

1
2
3
4
5
6
Median<Integer> median = new Median<>();
List<Integer> list = Arrays.asList(new Integer[]{
34, 90, 67, 45, 1, 4, 5, 6, 7, 9, 10
});
median.addAll(list);
System.out.println(median.getM());

输出为中值9。

11.3.3 小结

本节介绍了堆和PriorityQueue的两个应用,求前K个最大的元素和求中值,介绍了基本思路和实现代码,相比使用排序,使用堆不仅实现效率更高,而且可以应对数据量不确定且源源不断到来的情况,可以给出实时结果。

之前章节我们还介绍过ArrayDeque。PriorityQueue和ArrayDeque都是队列,都是基于数组的,但都不是简单的数组,通过一些特殊的约束、辅助成员和算法,它们都能高效地解决一些特定的问题,这大概是计算机程序中使用数据结构和算法的一种艺术吧

至此,关于堆的概念与算法、优先级队列PriorityQueue及其应用,就介绍完了。之前的章节中,我们介绍的基本都是具体的容器类,下一章,我们看一些抽象容器类,以及针对容器接口的通用功能,并对整个容器类体系进行总结。

11.2 剖析PriorityQueue

本节探讨堆在Java中的具体实现类:PriorityQueue。顾名思义,PriorityQueue是优先级队列,它首先实现了队列接口(Queue),与LinkedList类似,它的队列长度也没有限制,与一般队列的区别是,它有优先级的概念,每个元素都有优先级,队头的元素永远都是优先级最高的。

PriorityQueue内部是用堆实现的,内部元素不是完全有序的,不过,逐个出队会得到有序的输出。虽然名字叫优先级队列,但也可以将PriorityQueue看作一种比较通用的实现了堆的性质的数据结构,可以用PriorityQueue来解决适合用堆解决的问题,下一小节我们会来看一些具体的例子。下面,我们先介绍其用法,接着分析实现代码,最后总结分析其特点。

11.2.1 基本用法

PriorityQueue实现了Queue接口,我们在LinkedList一节介绍过Queue,为便于阅读,这里重复下其定义:

1
2
3
4
5
6
7
8
public interface Queue<E> extends Collection<E> {
boolean add(E e); //在尾部添加元素,队列满时抛异常
boolean offer(E e); //在尾部添加元素,队列满时返回false
E remove(); //删除头部元素,队列空时抛异常
E poll(); //删除头部元素,队列空时返回null
E element(); //查看头部元素,队列空时抛异常
E peek(); //查看头部元素,队列空时返回null
}

PriorityQueue有多个构造方法,部分构造方法如下所示:

1
2
3
public PriorityQueue()
public PriorityQueue(int initialCapacity, Comparator<? super E> comparator)
public PriorityQueue(Collection<? extends E> c)

PriorityQueue是用堆实现的,堆物理上就是数组,与ArrayList类似,PriorityQueue同样使用动态数组,根据元素个数动态扩展,initialCapacity表示初始的数组大小,可以通过参数传入。对于默认构造方法,initialCapacity使用默认值11。对于最后的构造方法,数组大小等于参数容器中的元素个数。与TreeMap/TreeSet类似,为了保持一定顺序, PriorityQueue要求要么元素实现Comparable接口,要么传递一个比较器Comparator。

我们来看个基本的例子:

1
2
3
4
5
6
7
8
Queue<Integer> pq = new PriorityQueue<>();
pq.offer(10);
pq.add(22);
pq.addAll(Arrays.asList(new Integer[]{
11, 12, 34, 2, 7, 4, 15, 12, 8, 6, 19, 13 }));
while(pq.peek()! =null){
System.out.print(pq.poll() + " ");
}

代码很简单,添加元素,然后逐个从头部删除,与普通队列不同,输出是从小到大有序的:

1
2 4 6 7 8 10 11 12 12 13 15 19 22 34

如果希望是从大到小呢?传递一个逆序的Comparator,将第一行代码替换为:

1
Queue<Integer> pq = new PriorityQueue<>(11, Collections.reverseOrder());

输出就会变为:

1
34 22 19 15 13 12 12 11 10 8 7 6 4 2

我们再来看个例子。模拟一个任务队列,定义一个内部类Task表示任务,如下所示:

1
2
3
4
5
static class Task {
int priority;
String name;
//省略构造方法和getter方法
}

Task有两个实例变量:priority表示优先级,值越大优先级越高;name表示任务名称。Task没有实现Comparable,我们定义一个单独的静态成员taskComparator表示比较器,如下所示:

1
2
3
4
5
6
7
8
9
10
11
private static Comparator<Task> taskComparator = new Comparator<Task>() {
@Override
public int compare(Task o1, Task o2) {
if(o1.getPriority()>o2.getPriority()){
return -1;
}else if(o1.getPriority()<o2.getPriority()){
return 1;
}
return 0;
}
};

下面来看任务队列的示例代码:

1
2
3
4
5
6
7
8
9
10
Queue<Task> tasks = new PriorityQueue<Task>(11, taskComparator);
tasks.offer(new Task(20, "写日记"));
tasks.offer(new Task(10, "看电视"));
tasks.offer(new Task(100, "写代码"));
Task task = tasks.poll();
while(task! =null){
System.out.print("处理任务: "+task.getName()
+",优先级:"+task.getPriority()+"\n");
task = tasks.poll();
}

代码很简单,就不解释了,输出任务按优先级排列:

1
2
3
处理任务: 写代码,优先级:100
处理任务: 写日记,优先级:20
处理任务: 看电视,优先级:10

11.2.2 实现原理

理解了PriorityQueue的用法和特点,我们来看其具体实现代码(基于Java
7),从内部组成开始。内部有如下成员:

1
2
3
4
private transient Object[] queue;
private int size = 0;
private final Comparator<? super E> comparator;
private transient int modCount = 0;

queue就是实际存储元素的数组。size表示当前元素个数。comparator为比较器,可以为null。modCount记录修改次数,在介绍第一个容器类ArrayList时已介绍过。

如何实现各种操作,且保持堆的性质呢?我们来看代码,从基本构造方法开始。

几个基本构造方法的代码是:

1
2
3
4
5
6
7
8
9
10
11
12
13
public PriorityQueue() {
this(DEFAULT_INITIAL_CAPACITY, null);
}
public PriorityQueue(int initialCapacity) {
this(initialCapacity, null);
}
public PriorityQueue(int initialCapacity,
Comparator<? super E> comparator) {
if(initialCapacity < 1)
throw new IllegalArgumentException();
this.queue = new Object[initialCapacity];
this.comparator = comparator;
}

代码很简单,就是初始化了queue和comparator。下面介绍一些操作的代码,大部分的算法和图示我们在11.1节已经介绍过了。

添加元素(入队)的代码如下所示,我们添加了一些注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public boolean offer(E e) {
if(e == null)
throw new NullPointerException();
modCount++;
int i = size;
if(i >= queue.length) //首先确保数组长度是够的,如果不够,调用grow方法动态扩展
grow(i + 1);
size = i + 1; //增加长度
if(i == 0) //如果是第一次添加,直接添加到第一个位置即可
queue[0] = e;
else //否则将其放入最后一个位置,但同时向上调整(siftUp),直至满足堆的性质
siftUp(i, e);
return true;
}

有两步复杂一些,一步是grow,另一步是siftUp,我们来细看下。grow()方法的代码为:

1
2
3
4
5
6
7
8
9
10
11
private void grow(int minCapacity) {
int oldCapacity = queue.length;
// Double size if small; else grow by 50%
int newCapacity = oldCapacity + ((oldCapacity < 64)
(oldCapacity + 2) :
(oldCapacity >> 1));
// overflow-conscious code
if(newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
queue = Arrays.copyOf(queue, newCapacity);
}

如果原长度比较小,大概就是扩展为两倍,否则就是增加50%,使用Arrays.copyOf方法复制数组。siftUp的基本思路我们在11.1节介绍过了,其实际代码为:

1
2
3
4
5
6
private void siftUp(int k, E x) {
if(comparator ! = null)
siftUpUsingComparator(k, x);
else
siftUpComparable(k, x);
}

根据是否有comparator分为了两种情况,代码类似,我们只看一种:

1
2
3
4
5
6
7
8
9
10
11
private void siftUpUsingComparator(int k, E x) {
while(k > 0) {
int parent = (k - 1) >>> 1;
Object e = queue[parent];
if(comparator.compare(x, (E) e) >= 0)
break;
queue[k] = e;
k = parent;
}
queue[k] = x;
}

参数k表示插入位置,x表示新元素。k初始等于数组大小,即在最后一个位置插入。代码的主要部分是:往上寻找x真正应该插入的位置,这个位置用k表示。

怎么找呢?新元素(x)不断与父节点(e)比较,如果新元素(x)大于等于父节点(e),则已满足堆的性质,退出循环,k就是新元素最终的位置,否则,将父节点往下移(queue [k]=e),继续向上寻找。这与11.1节介绍的算法和图示是对应的。

查看头部元素的代码为:

1
2
3
4
5
public E peek() {
if(size == 0)
return null;
return (E) queue[0];
}

就是返回第一个元素。

删除头部元素(出队)的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
public E poll() {
if(size == 0)
return null;
int s = --size;
modCount++;
E result = (E) queue[0];
E x = (E) queue[s];
queue[s] = null;
if(s ! = 0)
siftDown(0, x);
return result;
}

返回结果result为第一个元素,x指向最后一个元素,将最后位置设置为null(queue[s] =null),最后调用siftDown将原来的最后元素x插入头部并调整堆,siftDown的代码为:

1
2
3
4
5
6
private void siftDown(int k, E x) {
if(comparator ! = null)
siftDownUsingComparator(k, x);
else
siftDownComparable(k, x);
}

同样分为两种情况,代码类似,我们只看一种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private void siftDownComparable(int k, E x) {
Comparable<? super E> key = (Comparable<? super E>)x;
int half = size >>> 1; //loop while a non-leaf
while(k < half) {
int child = (k << 1) + 1; //assume left child is least
Object c = queue[child];
int right = child + 1;
if(right < size &&
((Comparable<? super E>) c).compareTo((E) queue[right]) > 0)
c = queue[child = right];
if(key.compareTo((E) c) <= 0)
break;
queue[k] = c;
k = child;
}
queue[k] = key;
}

k表示最终的插入位置,初始为0, x表示原来的最后元素。代码的主要部分是:向下寻找x真正应该插入的位置,这个位置用k表示。

怎么找呢?新元素key不断与较小的孩子节点比较,如果小于等于较小的孩子节点,则已满足堆的性质,退出循环,k就是最终位置,否则将较小的孩子节点往上移,继续向下寻找。这与11.1节介绍的算法和图示也是对应的。

解释下其中的一些代码:
1)k<half表示编号为k的节点有孩子节点,没有孩子节点,就不需要继续找了;
2)child表示较小的孩子节点编号,初始为左孩子,如果有右孩子(编号right)且小于左孩子则child会变为right;
3)c表示较小的孩子节点。

根据值删除元素的代码为:

1
2
3
4
5
6
7
8
9
public boolean remove(Object o) {
int i = indexOf(o);
if(i == -1)
return false;
else {
removeAt(i);
return true;
}
}

先查找元素的位置i,然后调用removeAt进行删除,removeAt的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private E removeAt(int i) {
assert i >= 0 && i < size;
modCount++;
int s = --size;
if(s == i) // removed last element
queue[i] = null;
else {
E moved = (E) queue[s];
queue[s] = null;
siftDown(i, moved);
if(queue[i] == moved) {
siftUp(i, moved);
if(queue[i] ! = moved)
return moved;
}
}
return null;
}

如果是删除最后一个位置,直接删即可,否则移动最后一个元素到位置i并进行堆调整,调整有两种情况,如果大于孩子节点,则向下调整,否则如果小于父节点则向上调整。代码先向下调整(siftDown(i, moved)),如果没有调整过(queue[i] ==moved),可能需向上调整,调用siftUp(i, moved)。如果向上调整过,返回值为moved,其他情况返回null,这个主要用于正确实现PriorityQueue迭代器的删除方法,迭代器的细节我们就不介绍了。

如果从一个既不是PriorityQueue也不是SortedSet的容器构造堆,代码为:

1
2
3
4
private void initFromCollection(Collection<? extends E> c) {
initElementsFromCollection(c);
heapify();
}

initElementsFromCollection的主要代码为:

1
2
3
4
5
6
7
private void initElementsFromCollection(Collection<? extends E> c) {
Object[] a = c.toArray();
if(a.getClass() ! = Object[].class)
a = Arrays.copyOf(a, a.length, Object[].class);
this.queue = a;
this.size = a.length;
}

主要是初始化queue和size。heapify的代码为:

1
2
3
4
private void heapify() {
for(int i = (size >>> 1) - 1; i >= 0; i--)
siftDown(i, (E) queue[i]);
}

与之前算法一样,heapify也在11.1节介绍过了,就是从最后一个非叶子节点开始,自底向上合并构建堆。如果构造方法中的参数是PriorityQueue或SortedSet,则它们的toArray方法返回的数组就是有序的,就满足堆的性质,就不需要执行heapify了。

11.2.3 小结

本节介绍了Java中堆的实现类PriorityQueue,它实现了队列接口Queue,但按优先级出队,内部是用堆实现的,有如下特点:
1)实现了优先级队列,最先出队的总是优先级最高的,即排序中的第一个。
2)优先级可以有相同的,内部元素不是完全有序的,如果遍历输出,除了第一个,其他没有特定顺序。
3)查看头部元素的效率很高,为O(1),入队、出队效率比较高,为O(log2(N)),构建堆heapify的效率为O(N)。
4)根据值查找和删除元素的效率比较低,为O(N)。

除了用作基本的优先级队列,PriorityQueue还可以作为一种比较通用的数据结构,用于解决一些其他问题,让我们在下一节继续探讨。

第11章 堆与优先级队列

前面两章介绍了Java中的基本容器类,每个容器类背后都有一种数据结构,ArrayList是动态数组,LinkedList是链表,HashMap/HashSet是哈希表,TreeMap/TreeSet是红黑树,本章介绍另一种数据结构:堆。之前我们提到过堆,那里,堆指的是内存中的区域,保存动态分配的对象,与栈相对应。这里的堆是一种数据结构,与内存区域和分配无关。

堆到底是什么结构呢?这个待会再细看。我们先来说明,堆有什么用?为什么要介绍它?堆可以非常高效方便地解决很多问题,比如:
1)优先级队列,我们之前介绍的队列实现类LinkedList是按添加顺序排列的,但现实中,经常需要按优先级来,每次都应该处理当前队列中优先级最高的,高优先级的即使来得晚,也应该被优先处理。
2)求前K个最大的元素,元素个数不确定,数据量可能很大,甚至源源不断到来,但需要知道到目前为止的最大的前K个元素。这个问题的变体有:求前K个最小的元素,求第K个最大的元素,求第K个最小的元素。
3)求中值元素,中值不是平均值,而是排序后中间那个元素的值,同样,数据量可能很大,甚至源源不断到来。

堆还可以实现排序,称之为堆排序,不过有比它更好的排序算法,所以,我们就不介绍其在排序中的应用了。

Java容器中有一个类PriorityQueue,表示优先级队列,它实现了堆,本章我们会详细介绍。关于如何使用堆高效解决求前K个最大的元素和求中值元素,我们也会在本章中用代码实现并详细解释。

说了这么多好处,堆到底是什么呢?我们先来看堆的基本概念与算法。

11.1 堆的概念与算法

我们先来了解堆的概念,然后介绍堆的一些主要算法。

堆首先是一棵二叉树,但它是完全二叉树。什么是完全二叉树呢?我们先来看另一个相似的概念:满二叉树。满二叉树是指除了最后一层外,每个节点都有两个孩子,而最后一层都是叶子节点,都没有孩子。比如,图11-1所示两棵二叉树都是满二叉树。

epub_923038_86

图11-1 满二叉树示例

满二叉树一定是完全二叉树,但完全二叉树不要求最后一层是满的,但如果不满,则要求所有节点必须集中在最左边,从左到右是连续的,中间不能有空的。比如,图11-2所示几棵二叉树都是完全二叉树。

epub_923038_87

图11-2 完全二叉树示例

而图11-3所示的几棵二叉树则都不是完全二叉树。

epub_923038_88

图11-3 非完全二叉树示例

在完全二叉树中,可以给每个节点一个编号,编号从1开始连续递增,从上到下,从左到右,如图11-4所示。

完全二叉树有一个重要的特点:给定任意一个节点,可以根据其编号直接快速计算出其父节点和孩子节点编号。如果编号为i,则父节点编号即为i/2,左孩子编号即为2× i,右孩子编号即为2× i+1。比如,对于5号节点,父节点为5/2即2,左孩子为2× 5即10,右孩子为2× 5+1即11。

epub_923038_89

图11-4 完全二叉树编号

这个特点为什么重要呢?它使得逻辑概念上的二叉树可以方便地存储到数组中,数组中的元素索引就对应节点的编号,树中的父子关系通过其索引关系隐含维持,不需要单独保持。比如,图11-4所示的逻辑二叉树,保存到数组中,其结构如图11-5所示。

epub_923038_90

图11-5 用数组表示完全二叉树

父子关系是隐含的,比如对于第5个元素13,其父节点就是第2个元素15,左孩子就是第10个元素7,右孩子就是第11个元素4。

这种存储二叉树的方法与之前介绍的TreeMap是不一样的。在TreeMap中,有一个单独的内部类Entry, Entry有三个引用,分别指向父节点、左孩子、右孩子。使用数组存储的优点是节省空间,而且访问效率高。堆逻辑概念上是一棵完全二叉树,而物理存储上使用数组,还有一定的顺序要求。

之前介绍过排序二叉树。排序二叉树是完全有序的,每个节点都有确定的前驱和后继,而且不能有重复元素。与排序二叉树不同,在堆中,可以有重复元素,元素间不是完全有序的,但对于父子节点之间,有一定的顺序要求。根据顺序分为两种堆:一种是最大堆,另一种是最小堆

最大堆是指每个节点都不大于其父节点。这样,对每个父节点,一定不小于其所有孩子节点,而根节点就是所有节点中最大的,对每个子树,子树的根也是子树所有节点中最大的。最小堆与最大堆正好相反,每个节点都不小于其父节点。这样,对每个父节点,一定不大于其所有孩子节点,而根节点就是所有节点中最小的,对每个子树,子树的根也是子树所有节点中最小的。我们看个例子,如图11-6所示。

总结来说,逻辑概念上,堆是完全二叉树,父子节点间有特定顺序,分为最大堆和最小堆,最大堆根是最大的,最小堆根是最小的,堆使用数组进行物理存储。

为什么堆可以高效地解决之前我们说的问题呢?在回答之前,我们需要先看下,如何在堆上进行数据的基本操作,在操作过程中如何保持堆的属性不变。

epub_923038_91

图11-6 最大堆与最小堆示例

11.1.2 堆的算法

下面,我们介绍如何在堆上进行数据的基本操作。最大堆和最小堆的算法是类似的,我们以最小堆来说明。先来看如何添加元素。

1.添加元素

如果堆为空,则直接添加一个根就行了。我们假定已经有一个堆,要在其中添加元素,基本步骤为:

1)添加元素到最后位置。
2)与父节点比较,如果大于等于父节点则满足堆的性质,结束,否则与父节点进行交换,然后再与父节点比较和交换,直到父节点为空或者大于等于父节点。

epub_923038_92

图11-7 堆的算法示例:添加元素前的初始结构

我们来看个例子。图11-7是添加元素前的初始结构。

添加元素3,第一步后,结构如图11-8所示。

epub_923038_93

图11-8 堆的算法示例:添加元素3第一步后的结构

3小于父节点8,不满足最小堆的性质,所以与父节点交换,变为图11-9所示。

epub_923038_94

图11-9 堆的算法示例:添加元素3第一次交换后的结构

交换后,3还是小于父节点6,所以继续交换,变为图11-10所示。

epub_923038_95

图11-10 堆的算法示例:添加元素3第二次交换后的结构

交换后,3还是小于父节点,也是根节点4,继续交换,变为图11-11所示。

epub_923038_96

图11-11 堆的算法示例:添加元素3第三次交换后的结构

至此,调整结束,树保持了堆的性质。

从以上过程可以看出,添加一个元素,需要比较和交换的次数最多为树的高度,即log2(N), N为节点数。这种自底向上比较、交换,使得树重新满足堆的性质的过程,我们称为向上调整(siftup)。

2.从头部删除元素

在队列中,一般是从头部删除元素,Java中用堆实现优先级队列。下面介绍如何在堆中删除头部,其基本步骤为:
1)用最后一个元素替换头部元素,并删掉最后一个元素;
2)将新的头部与两个孩子节点中较小的比较,如果不大于该孩子节点,则满足堆的性质,结束,否则与较小的孩子节点进行交换,交换后,再与较小的孩子节点比较和交换,一直到没有孩子节点,或者不大于两个孩子节点。这个过程称为向下调整(siftdown)。

我们来看个例子。图11-12是删除元素前的初始结构。

epub_923038_97

图11-12 堆的算法示例:删除元素前的初始结构

执行第一步,用最后元素替换头部,如图11-13所示。

epub_923038_98

图11-13 堆的算法示例:删除头部元素第一步后的结构

现在根节点16大于孩子节点,与更小的孩子节点6进行替换,结构变为图11-14所示。

epub_923038_99

图11-14 堆的算法示例:删除头部元素第一次交换后的结构

16还是大于孩子节点,与更小的孩子8进行交换,结构如图11-15所示。

epub_923038_100

图11-15 堆的算法示例:删除头部元素第二次交换后的结构

至此,就满足堆的性质了。

3.从中间删除元素

那如果需要从中间删除某个节点呢?与从头部删除一样,都是先用最后一个元素替换待删元素。不过替换后,有两种情况:如果该元素大于某孩子节点,则需向下调整(sift-down);如果小于父节点,则需向上调整(siftup)。

我们来看个例子,删除值为21的节点,第一步如图11-16所示。

epub_923038_101

图11-16 堆的算法示例:从中间删除元素21第一步后的结构

替换后,6没有子节点,小于父节点12,执行向上调整(siftup)过程,最后结果如图11-17所示。

epub_923038_102

图11-17 堆的算法示例:从中间删除元素21调整后的结构

我们再来看个例子,删除值为9的节点,第一步如图11-18所示。

epub_923038_103

图11-18 堆的算法示例:从中间删除元素9第一步后的结构

交换后,11大于右孩子10,所以执行向下调整(siftdown)过程,执行结束后如图11-19所示。

epub_923038_104

图11-19 堆的算法示例:从中间删除元素9调整后的结构

4.构建初始堆

给定一个无序数组,如何使之成为一个最小堆呢?将普通无序数组变为堆的过程称为heapify。基本思路是:从最后一个非叶子节点开始,一直往前直到根,对每个节点,执行向下调整(siftdown)。换句话说,是自底向上,先使每个最小子树为堆,然后每对左右子树和其父节点合并,调整为更大的堆,因为每个子树已经为堆,所以调整就是对父节点执行向下调整(siftdown),这样一直合并调整直到根。这个算法的伪代码是:

1
2
3
4
void heapify() {
for(int i=size/2; i >= 1; i--)
siftdown(i);
}

size表示节点个数,节点编号从1开始,size/2表示第一个非叶子节点的编号。

这个构建的时间效率为O(N), N为节点个数,具体就不证明了。

5.查找和遍历

在堆中进行查找没有特殊的算法,就是从数组的头找到尾,效率为O(N)。

在堆中进行遍历也是类似的,堆就是数组,堆的遍历就是数组的遍历,第一个元素是最大值或最小值,但后面的元素没有特定的顺序。

需要说明的是,如果是逐个从头部删除元素,那么堆可以确保输出是有序的。

6.算法小结

以上就是堆操作的主要算法,小结如下。
1)在添加和删除元素时,有两个关键的过程以保持堆的性质,一个是向上调整(siftup),另一个是向下调整(siftdown),它们的效率都为O(log2(N))。由无序数组构建堆的过程heapify是一个自底向上循环的过程,效率为O(N)。
2)查找和遍历就是对数组的查找和遍历,效率为O(N)。

11.1.3 小结

本节介绍了堆这一数据结构的基本概念和算法。堆是一种比较神奇的数据结构,概念上是树,存储为数组,父子有特殊顺序,根是最大值/最小值,构建/添加/删除效率都很高,可以高效解决很多问题。但在Java中,堆到底是如何实现的呢?本章开头提到的那些问题,用堆到底如何解决呢?让我们在接下来的小节中继续探讨。

10.8 剖析EnumSet

本节介绍同样针对枚举类型的Set接口的实现类EnumSet。与EnumMap类似,之所以会有一个专门的针对枚举类型的实现类,主要是因为它可以非常高效地实现Set接口。

之前介绍的Set接口的实现类HashSet/TreeSet,它们内部都是用对应的HashMap/TreeMap实现的,但EnumSet不是,它的实现与EnumMap没有任何关系,而是用极为精简和高效的位向量实现的。位向量是计算机程序中解决问题的一种常用方式,我们有必要理解和掌握

除了实现机制,EnumSet的用法也有一些不同。EnumSet可以说是处理枚举类型数据的一把利器,在一些应用领域,它非常方便和高效。

下面,我们先来看EnumSet的基本用法,然后通过一个场景来看EnumSet的应用,最后分析EnumSet的实现机制。

10.8.1 基本用法

与TreeSet/HashSet不同,EnumSet是一个抽象类,不能直接通过new新建,也就是说,类似下面代码是错误的:

1
EnumSet<Size> set = new EnumSet<Size>();

不过,EnumSet提供了若干静态工厂方法,可以创建EnumSet类型的对象,比如:

1
public static <E extends Enum<E>> EnumSet<E> noneOf(Class<E> elementType)

noneOf方法会创建一个指定枚举类型的EnumSet,不含任何元素。创建的EnumSet对象的实际类型是EnumSet的子类,待会我们再分析其具体实现。

为方便举例,我们定义一个表示星期几的枚举类Day,值从周一到周日,如下所示:

1
2
3
enum Day {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}

可以这么用noneOf方法:

1
2
3
4
Set<Day> weekend = EnumSet.noneOf(Day.class);
weekend.add(Day.SATURDAY);
weekend.add(Day.SUNDAY);
System.out.println(weekend);

weekend表示休息日,noneOf返回的Set为空,添加了周六和周日,所以输出为:

1
[SATURDAY, SUNDAY]

EnumSet还有很多其他静态工厂方法,如下所示(省略了修饰public static):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//初始集合包括指定枚举类型的所有枚举值
<E extends Enum<E>> EnumSet<E> allOf(Class<E> elementType)
//初始集合包括枚举值中指定范围的元素
<E extends Enum<E>> EnumSet<E> range(E from, E to)
//初始集合包括指定集合的补集
<E extends Enum<E>> EnumSet<E> complementOf(EnumSet<E> s)
//初始集合包括参数中的所有元素
<E extends Enum<E>> EnumSet<E> of(E e)
<E extends Enum<E>> EnumSet<E> of(E e1, E e2)
<E extends Enum<E>> EnumSet<E> of(E e1, E e2, E e3)
<E extends Enum<E>> EnumSet<E> of(E e1, E e2, E e3, E e4)
<E extends Enum<E>> EnumSet<E> of(E e1, E e2, E e3, E e4, E e5)
<E extends Enum<E>> EnumSet<E> of(E first, E... rest)
//初始集合包括参数容器中的所有元素
<E extends Enum<E>> EnumSet<E> copyOf(EnumSet<E> s)
<E extends Enum<E>> EnumSet<E> copyOf(Collection<E> c)

可以看到,EnumSet有很多重载形式的of方法,最后一个接受的是可变参数,其他重载方法看上去是多余的,之所以有其他重载方法是因为可变参数的运行效率低一些。

10.8.2 应用场景

下面,我们通过一个场景来看EnumSet的应用。想象一个场景,在一些工作中(如医生、客服),不是每个工作人员每天都在的,每个人可工作的时间是不一样的,比如张三可能是周一和周三,李四可能是周四和周六,给定每个人可工作的时间,我们可能有一些问题需要回答。比如:

  • 有没有哪天一个人都不会来?
  • 有哪些天至少会有一个人来?
  • 有哪些天至少会有两个人来?
  • 有哪些天所有人都会来,以便开会?
  • 哪些人周一和周二都会来?

使用EnumSet,可以方便高效地回答这些问题,怎么做呢?我们先来定义一个表示工作人员的类Worker,如下所示:

1
2
3
4
5
6
7
8
9
class Worker {
String name;
Set<Day> availableDays;
public Worker(String name, Set<Day> availableDays) {
this.name = name;
this.availableDays = availableDays;
}
//省略getter方法
}

为演示方便,将所有工作人员的信息放到一个数组workers中,如下所示:

1
2
3
4
5
6
7
Worker[] workers = new Worker[]{
new Worker("张三", EnumSet.of(
Day.MONDAY, Day.TUESDAY, Day.WEDNESDAY, Day.FRIDAY)),
new Worker("李四", EnumSet.of(
Day.TUESDAY, Day.THURSDAY, Day.SATURDAY)),
new Worker("王五", EnumSet.of(Day.TUESDAY, Day.THURSDAY)),
};

每个工作人员的可工作时间用一个EnumSet表示。有了这个信息,我们就可以回答以上的问题了。哪些天一个人都不会来?代码可以为:

1
2
3
4
5
Set<Day> days = EnumSet.allOf(Day.class);
for(Worker w : workers){
days.removeAll(w.getAvailableDays());
}
System.out.println(days);

days初始化为所有值,然后遍历workers,从days中删除可工作的所有时间,最终剩下的就是一个人都不会来的时间,这实际是在求worker时间并集的补集,输出为:

1
[SUNDAY]

有哪些天至少会有一个人来?就是求worker时间的并集,代码可以为:

1
2
3
4
5
Set<Day> days = EnumSet.noneOf(Day.class);
for(Worker w : workers){
days.addAll(w.getAvailableDays());
}
System.out.println(days);

输出为:

1
[MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY]

有哪些天所有人都会来?就是求worker时间的交集,代码可以为:

1
2
3
4
5
Set<Day> days = EnumSet.allOf(Day.class);
for(Worker w : workers){
days.retainAll(w.getAvailableDays());
}
System.out.println(days);

输出为:

1
[TUESDAY]

哪些人周一和周二都会来?使用containsAll方法,代码可以为:

1
2
3
4
5
6
7
8
9
10
Set<Worker> availableWorkers = new HashSet<Worker>();
for(Worker w : workers){
if(w.getAvailableDays().containsAll(
EnumSet.of(Day.MONDAY, Day.TUESDAY))){
availableWorkers.add(w);
}
}
for(Worker w : availableWorkers){
System.out.println(w.getName());
}

输出为:

1
张三

哪些天至少会有两个人来?我们先使用EnumMap统计每天的人数,然后找出至少有两个人的天,代码可以为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Map<Day, Integer> countMap = new EnumMap<>(Day.class);
for(Worker w : workers){
for(Day d : w.getAvailableDays()){
Integer count = countMap.get(d);
countMap.put(d, count==null?1:count+1);
}
}
Set<Day> days = EnumSet.noneOf(Day.class);
for(Map.Entry<Day, Integer> entry : countMap.entrySet()){
if(entry.getValue()>=2){
days.add(entry.getKey());
}
}
System.out.println(days);

输出为:

1
[TUESDAY, THURSDAY]

理解了EnumSet的使用,下面我们来看它是怎么实现的(基于Java 7)。

10.8.3 实现原理

EnumSet是使用位向量实现的,什么是位向量呢?就是用一个位表示一个元素的状态,用一组位表示一个集合的状态,每个位对应一个元素,而状态只可能有两种。

对于之前的枚举类Day,它有7个枚举值,一个Day的集合就可以用一个字节byte表示,最高位不用,设为0,最右边的位对应顺序最小的枚举值,从右到左,每位对应一个枚举值,1表示包含该元素,0表示不含该元素。

比如,表示包含Day.MONDAY、Day.TUESDAY、Day.WEDNESDAY、Day.FRIDAY的集合,位向量结构如图10-14所示。

对应的整数是23。

位向量能表示的元素个数与向量长度有关,一个byte类型能表示8个元素,一个long类型能表示64个元素,那EnumSet用的长度是多少呢?

epub_923038_84

图10-14 位向量示例

EnumSet是一个抽象类,它没有定义使用的向量长度,它有两个子类:RegularEnumSet和JumboEnumSet。RegularEnumSet使用一个long类型的变量作为位向量,long类型的位长度是64,而JumboEnumSet使用一个long类型的数组。如果枚举值个数小于等于64,则静态工厂方法中创建的就是RegularEnumSet,如果大于64就是JumboEnumSet。

理解了位向量的基本概念,下面我们来看EnumSet的实现,包括其内部组成和一些主要方法的实现。同EnumMap一样,EnumSet也有表示类型信息和所有枚举值的实例变量,如下所示:

1
2
final Class<E> elementType;
final Enum[] universe;

elementType表示类型信息,universe表示枚举类的所有枚举值。

EnumSet自身没有记录元素个数的变量,也没有位向量,它们是子类维护的。对于RegularEnumSet,它用一个long类型表示位向量,代码为:

1
private long elements = 0L;

它没有定义表示元素个数的变量,是实时计算出来的,计算的代码是:

1
2
3
public int size() {
return Long.bitCount(elements);
}

对于JumboEnumSet,它用一个long数组表示,有单独的size变量,代码为:

1
2
private long elements[];
private int size = 0;

我们来看EnumSet的静态工厂方法noneOf,代码为:

1
2
3
4
5
6
7
8
9
public static <E extends Enum<E>> EnumSet<E> noneOf(Class<E> elementType) {
Enum[] universe = getUniverse(elementType);
if(universe == null)
throw new ClassCastException(elementType + " not an enum");
if(universe.length <= 64)
return new RegularEnumSet<>(elementType, universe);
else
return new JumboEnumSet<>(elementType, universe);
}

getUniverse的代码与EnumMap是一样的,就不赘述了。如果元素个数不超过64,就创建RegularEnumSet,否则创建JumboEnumSet。

RegularEnumSet和JumboEnumSet的构造方法为:

1
2
3
4
5
6
7
RegularEnumSet(Class<E>elementType, Enum[] universe) {
super(elementType, universe);
}
JumboEnumSet(Class<E>elementType, Enum[] universe) {
super(elementType, universe);
elements = new long[(universe.length + 63) >>> 6];
}

它们都调用了父类EnumSet的构造方法,其代码为:

1
2
3
4
EnumSet(Class<E>elementType, Enum[] universe) {
this.elementType = elementType;
this.universe = universe;
}

就是给实例变量赋值,JumboEnumSet根据元素个数分配足够长度的long数组。

其他工厂方法基本都是先调用noneOf方法构造一个空的集合,然后再调用添加方法。我们来看添加方法,RegularEnumSet的add方法的代码为:

1
2
3
4
5
6
public boolean add(E e) {
typeCheck(e);
long oldElements = elements;
elements |= (1L << ((Enum)e).ordinal());
return elements ! = oldElements;
}

主要代码是按位或操作:

1
elements |= (1L << ((Enum)e).ordinal());

(1L << ((Enum)e).ordinal())将元素e对应的位设为1,与现有的位向量elements相或,就表示添加e了。JumboEnumSet的add方法的代码为:

1
2
3
4
5
6
7
8
9
10
11
public boolean add(E e) {
typeCheck(e);
int eOrdinal = e.ordinal();
int eWordNum = eOrdinal >>> 6;
long oldElements = elements[eWordNum];
elements[eWordNum] |= (1L << eOrdinal);
boolean result = (elements[eWordNum] ! = oldElements);
if(result)
size++;
return result;
}

与RegularEnumSet的add方法的区别是,它先找对应的数组位置,eOrdinal>>> 6就是eOrdinal除以64, eWordNum就表示数组索引,有了索引之后,其他操作与Regular-EnumSet就类似了。

对于其他操作,JumboEnumSet的思路是类似的,主要算法与RegularEnumSet一样,主要是增加了寻找对应long位向量的操作,或者有一些循环处理,逻辑也都比较简单,后文就只介绍RegularEnumSet的实现了。

RegularEnumSet的remove方法的代码为:

1
2
3
4
5
6
7
8
9
10
public boolean remove(Object e) {
if(e == null)
return false;
Class eClass = e.getClass();
if(eClass ! = elementType && eClass.getSuperclass() ! = elementType)
return false;
long oldElements = elements;
elements &= ~(1L << ((Enum)e).ordinal());
return elements ! = oldElements;
}

主要代码是:

1
elements &= ~(1L << ((Enum)e).ordinal());

~是取反,该代码将元素e对应的位设为了0,这样就完成了删除。

查看是否包含某元素的方法是contains,其代码为:

1
2
3
4
5
6
7
8
public boolean contains(Object e) {
if(e == null)
return false;
Class eClass = e.getClass();
if(eClass ! = elementType && eClass.getSuperclass() ! = elementType)
return false;
return (elements & (1L << ((Enum)e).ordinal())) ! = 0;
}

代码也很简单,按位与操作,不为0,则表示包含。

EnumSet的静态工厂方法complementOf是求补集,它调用的代码是:

1
2
3
4
5
6
void complement() {
if(universe.length ! = 0) {
elements = ~elements;
elements &= -1L >>> -universe.length; // Mask unused bits
}
}

这段代码有点晦涩,elements=~elements比较容易理解,就是按位取反,相当于就是取补集,但我们知道elements是64位的,当前枚举类可能没有用那么多位,取反后高位部分都变为了1,需要将超出universe.length的部分设为0。下面的代码就是在做这件事:

1
elements &= -1L >>> -universe.length;

-1L是64位全1的二进制,我们在剖析Integer一节介绍过移动位数是负数的情况,上面代码相当于:

1
elements &= -1L >>> (64-universe.length);

如果universe.length为7,则-1L>>>(64-7)就是二进制的1111111,与elements相与,就会将超出universe.length部分的右边的57位都变为0。

以上就是EnumSet的基本实现原理,内部使用位向量,表示很简洁,节省空间,大部分操作都是按位运算,效率极高。

10.8.4 小结

本节介绍了EnumSet的用法和实现原理,用法上,它是处理枚举类型数据的一把利器,简洁方便,实现原理上,它使用位向量,精简高效。

对于只有两种状态,且需要进行集合运算的数据,使用位向量进行表示、位运算进行处理,是计算机程序中一种常用的思维方式

Java中有一个更为通用的可动态扩展长度的位向量容器类BitSet,可以方便地对指定位置的位进行操作,与其他位向量进行位运算,具体可参看API文档,我们就不介绍了。

至此,关于Map和Set的实现类就介绍完了,关于它们的系统总结,我们留待到介绍完所有容器类之后,下一章,我们来看另一种数据结构:堆。

10.7 剖析EnumMap

如果需要一个Map的实现类,并且键的类型为枚举类型,可以使用HashMap,但应该使用一个专门的实现类EnumMap。为什么要有一个专门的类呢?我们之前介绍过枚举的本质,主要是因为枚举类型有两个特征:一是它可能的值是有限的且预先定义的;二是枚举值都有一个顺序,这两个特征使得可以更为高效地实现Map接口。我们先来看EnumMap的用法,然后看它到底是怎么实现的。

10.7.1 基本用法

举个简单的例子。比如,有一批关于衣服的记录,我们希望按尺寸统计衣服的数量。定义一个简单的枚举类Size,表示衣服的尺寸:

1
2
3
public enum Size {
SMALL, MEDIUM, LARGE
}

定义一个简单类Clothes,表示衣服:

1
2
3
4
5
class Clothes {
String id;
Size size;
//省略getter/setter和构造方法
}

有一个表示衣服记录的列表List<Clothes>,我们希望按尺寸统计数量,统计方法可以为:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static Map<Size, Integer> countBySize(List<Clothes> clothes){
Map<Size, Integer> map = new EnumMap<>(Size.class);
for(Clothes c : clothes){
Size size = c.getSize();
Integer count = map.get(size);
if(count! =null){
map.put(size, count+1);
}else{
map.put(size, 1);
}
}
return map;
}

大部分代码都很简单,需要注意的是EnumMap的构造方法,如下所示:

1
Map<Size, Integer> map = new EnumMap<>(Size.class);

与HashMap不同,它需要传递一个类型信息,Size.class表示枚举类Size的运行时类型信息,Size.class也是一个对象,它的类型是Class。为什么需要这个参数呢?没有这个, EnumMap就不知道具体的枚举类是什么,也无法初始化内部的数据结构。

使用以上的统计方法也是很简单的,比如:

1
2
3
4
5
6
List<Clothes> clothes = Arrays.asList(new Clothes[]{
new Clothes("C001", Size.SMALL), new Clothes("C002", Size.LARGE),
new Clothes("C003", Size.LARGE), new Clothes("C004", Size.MEDIUM),
new Clothes("C005", Size.SMALL), new Clothes("C006", Size.SMALL),
});
System.out.println(countBySize(clothes));

输出为:

1
{SMALL=3, MEDIUM=1, LARGE=2}

需要说明的是,与HashMap不同,EnumMap是保证顺序的,输出是按照键在枚举中的顺序的。

你可能认为,对于枚举,使用Map是没有必要的,比如对于上面的统计例子,可以使用一个简单的数组:

1
2
3
4
5
6
7
8
public static int[] countBySize(List<Clothes> clothes){
int[] stat = new int[Size.values().length];
for(Clothes c : clothes){
Size size = c.getSize();
stat[size.ordinal()]++;
}
return stat;
}

这个方法可以这么使用:

1
2
3
4
5
6
7
8
9
List<Clothes> clothes = Arrays.asList(new Clothes[]{
new Clothes("C001", Size.SMALL), new Clothes("C002", Size.LARGE),
new Clothes("C003", Size.LARGE), new Clothes("C004", Size.MEDIUM),
new Clothes("C005", Size.SMALL), new Clothes("C006", Size.SMALL),
});
int[] stat = countBySize(clothes);
for(int i=0; i<stat.length; i++){
System.out.println(Size.values()[i]+": "+ stat[i]);
}

输出为:

1
2
3
SMALL 3
MEDIUM 1
LARGE 2

可以达到同样的目的。但,直接使用数组需要自己维护数组索引和枚举值之间的关系,正如枚举的优点是简洁、安全、方便一样,EnumMap同样是更为简洁、安全、方便,它内部也是基于数组实现的,但隐藏了细节,提供了更为方便安全的接口。

10.7.2 实现原理

下面我们来看下具体的代码(基于Java
7)。从内部组成开始。EnumMap有如下实例变量:

1
2
3
4
private final Class<K> keyType;
private transient K[] keyUniverse;
private transient Object[] vals;
private transient int size = 0;

keyType表示类型信息,keyUniverse表示键,是所有可能的枚举值,vals表示键对应的值,size表示键值对个数。EnumMap的基本构造方法代码为:

1
2
3
4
5
public EnumMap(Class<K> keyType) {
this.keyType = keyType;
keyUniverse = getKeyUniverse(keyType);
vals = new Object[keyUniverse.length];
}

调用了getKeyUniverse以初始化键数组,这段代码又调用了其他一些比较底层的代码,就不列举了,原理是最终调用了枚举类型的values方法,values方法返回所有可能的枚举值。关于values方法,我们在枚举一节介绍过其用法和实现原理,这里就不赘述了。

保存键值对的方法是put,代码为:

1
2
3
4
5
6
7
8
9
public V put(K key, V value) {
typeCheck(key);
int index = key.ordinal();
Object oldValue = vals[index];
vals[index] = maskNull(value);
if(oldValue == null)
size++;
return unmaskNull(oldValue);
}

首先调用typeCheck检查键的类型,其代码为:

1
2
3
4
5
private void typeCheck(K key) {
Class keyClass = key.getClass();
if(keyClass ! = keyType && keyClass.getSuperclass() ! = keyType)
throw new ClassCastException(keyClass + " ! = " + keyType);
}

如果类型不对,会抛出异常。如果类型正确,调用ordinal获取索引index,并将值value放入值数组vals[index]中。EnumMap允许值为null,为了区别null值与没有值,EnumMap将null值包装成了一个特殊的对象,有两个辅助方法用于null的打包和解包,打包方法为maskNull,解包方法为unmaskNull。这个特殊对象及两个方法的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private static final Object NULL = new Object() {
public int hashCode() {
return 0;
}
public String toString() {
return "java.util.EnumMap.NULL";
}
};
private Object maskNull(Object value) {
return (value == null ? NULL : value);
}
private V unmaskNull(Object value) {
return(V) (value == NULL ? null : value);
}

根据键获取值的方法是get,代码为:

1
2
3
4
public V get(Object key) {
return (isValidKey(key)
unmaskNull(vals[((Enum)key).ordinal()]) : null);
}

如果键有效,通过ordinal方法取索引,然后直接在值数组vals里找。isValidKey的代码与typeCheck类似,但是返回boolean值而不是抛出异常,代码为:

1
2
3
4
5
6
7
private boolean isValidKey(Object key) {
if(key == null)
return false;
//Cheaper than instanceof Enum followed by getDeclaringClass
Class keyClass = key.getClass();
return keyClass == keyType || keyClass.getSuperclass() == keyType;
}

查看是否包含某个值的方法是containsValue,代码为:

1
2
3
4
5
6
7
public boolean containsValue(Object value) {
value = maskNull(value);
for(Object val : vals)
if(value.equals(val))
return true;
return false;
}

就是遍历值数组进行比较。

根据键删除的方法是remove,其代码为:

1
2
3
4
5
6
7
8
9
10
public V remove(Object key) {
if(! isValidKey(key))
return null;
int index = ((Enum)key).ordinal();
Object oldValue = vals[index];
vals[index] = null;
if(oldValue ! = null)
size--;
return unmaskNull(oldValue);
}

代码也很简单,就不解释了。

10.7.3 小结

本节介绍了EnumMap的用法和实现原理,用法上,如果需要一个Map且键是枚举类型,则应该用它,简洁、方便、安全;实现原理上,内部有两个数组,长度相同,一个表示所有可能的键,一个表示对应的值,值为null表示没有该键值对,键都有一个对应的索引,根据索引可直接访问和操作其键和值,效率很高。

下一节,我们来看枚举类型的Set接口的实现类EnumSet,与之前介绍的Set的实现类不同,它内部没有用对应的Map类EnumMap,而是使用了一种极为高效的方式,什么方式呢?

10.6 剖析LinkedHashMap

前面我们介绍了Map接口的两个实现类HashMap和TreeMap,本节介绍另一个实现类LinkedHashMap。它是HashMap的子类,但可以保持元素按插入或访问有序,这与TreeMap按键排序不同。按插入有序容易理解,按访问有序是什么意思呢?这两个有序有什么用呢?内部是怎么实现的?本节就来探讨这些问题,从用法开始。

10.6.1 基本用法

LinkedHashMap是HashMap的子类,但内部还有一个双向链表维护键值对的顺序,每个键值对既位于哈希表中,也位于这个双向链表中。LinkedHashMap支持两种顺序:一种是插入顺序;另外一种是访问顺序。

插入顺序容易理解,先添加的在前面,后添加的在后面,修改操作不影响顺序。访问顺序是什么意思呢?所谓访问是指get/put操作,对一个键执行get/put操作后,其对应的键值对会移到链表末尾,所以,最末尾的是最近访问的,最开始的最久没被访问的,这种顺序就是访问顺序。

LinkedHashMap有5个构造方法,其中4个都是按插入顺序,只有一个构造方法可以指定按访问顺序,如下所示:

1
2
public LinkedHashMap(int initialCapacity, float loadFactor,
boolean accessOrder)

其中参数accessOrder就是用来指定是否按访问顺序,如果为true,就是访问顺序。默认情况下,LinkedHashMap是按插入有序的,我们看个例子:

1
2
3
4
5
6
7
8
Map<String, Integer> seqMap = new LinkedHashMap<>();
seqMap.put("c", 100);
seqMap.put("d", 200);
seqMap.put("a", 500);
seqMap.put("d", 300);
for(Entry<String, Integer> entry : seqMap.entrySet()){
System.out.println(entry.getKey()+" "+entry.getValue());
}

键是按照”c”、”d”、”a”的顺序插入的,修改”d”的值不会修改顺序,所以输出为:

1
2
3
c 100
d 300
a 500

什么时候希望保持插入顺序呢?

Map经常用来处理一些数据,其处理模式是:接收一些键值对作为输入,处理,然后输出,输出时希望保持原来的顺序。比如一个配置文件,其中有一些键值对形式的配置项,但其中有一些键是重复的,希望保留最后一个值,但还是按原来的键顺序输出, LinkedHashMap就是一个合适的数据结构。

再如,希望的数据模型可能就是一个Map,但希望保持添加的顺序,如一个购物车,键为购买项目,值为购买数量,按用户添加的顺序保存。

另外一种常见的场景是:希望Map能够按键有序,但在添加到Map前,键已经通过其他方式排好序了,这时,就没有必要使用TreeMap了,毕竟TreeMap的开销要大一些。比如,在从数据库查询数据放到内存时,可以使用SQL的order by语句让数据库对数据排序。

我们来看按访问有序的例子,代码如下:

1
2
3
4
5
6
7
8
9
Map<String, Integer> accessMap = new LinkedHashMap<>(16, 0.75f, true);
accessMap.put("c", 100);
accessMap.put("d", 200);
accessMap.put("a", 500);
accessMap.get("c");
accessMap.put("d", 300);
for(Entry<String, Integer> entry : accessMap.entrySet()){
System.out.println(entry.getKey()+" "+entry.getValue());
}

每次访问都会将该键值对移到末尾,所以输出为:

1
2
3
a 500
c 100
d 300

什么时候希望按访问有序呢?一种典型的应用是LRU缓存,它是什么呢?

缓存是计算机技术中一种非常有用的技术,是一个通用的提升数据访问性能的思路,一般用来保存常用的数据,容量较小,但访问更快。缓存是相对主存而言的,主存的容量更大,但访问更慢。缓存的基本假设是:数据会被多次访问,一般访问数据时都先从缓存中找,缓存中没有再从主存中找,找到后再放入缓存,这样下次如果再找相同数据访问就快了。

缓存用于计算机技术的各个领域,比如CPU里有缓存,有一级缓存、二级缓存、三级缓存等,一级缓存非常小、非常贵、也非常快,三级缓存则大一些、便宜一些、也慢一些, CPU缓存是相对于内存而言的,它们都比内存快。内存里也有缓存,内存的缓存一般是相对于硬盘数据而言的。硬盘也可能是缓存,缓存网络上其他机器的数据,比如浏览器访问网页时,会把一些网页缓存到本地硬盘。

LinkedHashMap可以用于缓存,比如缓存用户基本信息,键是用户Id,值是用户信息,所有用户的信息可能保存在数据库中,部分活跃用户的信息可能保存在缓存中。

一般而言,缓存容量有限,不能无限存储所有数据,如果缓存满了,当需要存储新数据时,就需要一定的策略将一些老的数据清理出去,这个策略一般称为替换算法。LRU是一种流行的替换算法,它的全称是Least Recently Used,即最近最少使用。它的思路是,最近刚被使用的很快再次被用的可能性最高,而最久没被访问的很快再次被用的可能性最低,所以被优先清理。

使用LinkedHashMap,可以非常容易地实现LRU缓存,默认情况下,LinkedHashMap没有对容量做限制,但它可以容易地做到,它有一个protected方法,如下所示:

1
2
3
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return false;
}

在添加元素到LinkedHashMap后,LinkedHashMap会调用这个方法,传递的参数是最久没被访问的键值对,如果这个方法返回true,则这个最久的键值对就会被删除。Linked-HashMap的实现总是返回false,所有容量没有限制,但子类可以重写该方法,在满足一定条件的情况,返回true。

代码清单10-4就是一个简单的LRU缓存的实现,它有一个容量限制,这个限制在构造方法中传递。

代码清单10-4 LRU缓存
1
2
3
4
5
6
7
8
9
10
11
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private int maxEntries;
public LRUCache(int maxEntries){
super(16, 0.75f, true);
this.maxEntries = maxEntries;
}
@Override
protected boolean removeEldestEntry(Entry<K, V> eldest) {
return size() > maxEntries;
}
}

这个缓存可以这么用:

1
2
3
4
5
6
7
LRUCache<String, Object> cache = new LRUCache<>(3);
cache.put("a", "abstract");
cache.put("b", "basic");
cache.put("c", "call");
cache.get("a");
cache.put("d", "call");
System.out.println(cache);

限定缓存容量为3,先后添加了4个键值对,最久没被访问的键是”b”,会被删除,所以输出为:

1
{c=call, a=abstract, d=call}

10.6.2 实现原理

理解了LinkedHashMap的用法,下面我们来看其实现代码(基于Java
7)。先来看内部组成,再看一些主要方法的实现。LinkedHashMap是HashMap的子类,内部增加了如下实例变量:

1
2
private transient Entry<K, V> header;
private final boolean accessOrder;

accessOrder表示是按访问顺序还是插入顺序。header表示双向链表的头,它的类型Entry是一个内部类,这个类是HashMap.Entry的子类,增加了两个变量before和after,指向链表中的前驱和后继,Entry的完整定义如代码清单10-5所示。

代码清单10-5 LinkedHashMap中的Entry
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private static class Entry<K, V> extends HashMap.Entry<K, V> {
Entry<K, V> before, after;
Entry(int hash, K key, V value, HashMap.Entry<K, V> next) {
super(hash, key, value, next);
}
private void remove() {
before.after = after;
after.before = before;
}
private void addBefore(Entry<K, V> existingEntry) {
after = existingEntry;
before = existingEntry.before;
before.after = this;
after.before = this;
}
void recordAccess(HashMap<K, V> m) {
LinkedHashMap<K, V> lm = (LinkedHashMap<K, V>)m;
if(lm.accessOrder) {
lm.modCount++;
remove();
addBefore(lm.header);
}
}
void recordRemoval(HashMap<K, V> m) {
remove();
}
}

recordAccess和recordRemoval是HashMap.Entry中定义的方法,在HashMap中,这两个方法的实现为空,它们就是被设计用来被子类重写的。在put被调用且键存在时,HashMap会调用Entry的recordAccess方法;在键被删除时,HashMap会调用Entry的recordRemoval方法。

LinkedHashMap.Entry重写了这两个方法。在recordAccess方法中,如果是按访问顺序的,则将该节点移到链表的末尾;在recordRemoval方法中,将该节点从链表中移除。

了解了内部组成,我们来看操作方法,先看构造方法。

在HashMap的构造方法中,会调用init方法,init方法在HashMap的实现中为空,也是被设计用来被重写的。LinkedHashMap重写了该方法,用于初始化链表的头节点,代码如下:

1
2
3
4
void init() {
header = new Entry<>(-1, null, null, null);
header.before = header.after = header;
}

header被初始化为一个Entry对象,前驱和后继都指向自己,如图10-12所示。

header.after指向第一个节点,header.before指向最后一个节点,指向header表示链表为空。

epub_923038_82

图10-12 LinkedHashMap初始内存结构

在LinkedHashMap中,put方法还会将节点加入到链表中来,如果是按访问有序的,还会调整节点到末尾,并根据情况删除最久没被访问的节点。

HashMap的put实现中,如果是新的键,会调用addEntry方法添加节点,LinkedHash-Map重写了该方法,代码为:

1
2
3
4
5
6
7
8
void addEntry(int hash, K key, V value, int bucketIndex) {
super.addEntry(hash, key, value, bucketIndex);
//Remove eldest entry if instructed
Entry<K, V> eldest = header.after;
if(removeEldestEntry(eldest)) {
removeEntryForKey(eldest.key);
}
}

它先调用父类的addEntry方法,父类的addEntry会调用createEntry创建节点,Linked-HashMap重写了createEntry,代码为:

1
2
3
4
5
6
7
void createEntry(int hash, K key, V value, int bucketIndex) {
HashMap.Entry<K, V> old = table[bucketIndex];
Entry<K, V> e = new Entry<>(hash, key, value, old);
table[bucketIndex] = e;
e.addBefore(header);
size++;
}

新建节点,加入哈希表中,同时加入链表中,加到链表末尾的代码是:

1
e.addBefore(header)

比如,执行如下代码:

1
2
3
Map<String, Integer>  countMap  =  new
LinkedHashMap<>();
countMap.put("hello", 1);

执行后,内存结构如图10-13所示。

epub_923038_83

图10-13 LinkedHashMap插入一个元素后的内存结构

添加完后,调用removeEldestEntry检查是否应该删除老节点,如果返回值为true,则调用removeEntryForKey进行删除,remove-EntryForKey是HashMap中定义的方法,删除节点时会调用HashMap.Entry的record-Removal方法,该方法被LinkedHashMap.Entry重写了,会将节点从链表中删除。

在HashMap的put实现中,如果键已经存在了,则会调用节点的recordAccess方法。LinkedHashMap.Entry重写了该方法,如果是按访问有序,则调整该节点到链表末尾。

LinkedHashMap重写了get方法,代码为:

1
2
3
4
5
6
7
public V get(Object key) {
Entry<K, V> e = (Entry<K, V>)getEntry(key);
if(e == null)
return null;
e.recordAccess(this);
return e.value;
}

与HashMap的get方法的区别,主要是调用了节点的recordAccess方法,如果是按访问有序,recordAccess调整该节点到链表末尾。

查看HashMap中是否包含某个值需要进行遍历,由于LinkedHashMap维护了单独的链表,它可以使用链表进行更为高效的遍历,具体代码比较简单,我们就不列举了。

以上就是LinkedHashMap的基本实现原理,它是HashMap的子类,它的节点类LinkedHashMap.Entry是HashMap.Entry的子类,LinkedHashMap内部维护了一个单独的双向链表,每个节点即位于哈希表中,也位于双向链表中,在链表中的顺序默认是插入顺序,也可以配置为访问顺序,LinkedHashMap及其节点类LinkedHashMap.Entry重写了若干方法以维护这种关系。

10.6.3 LinkedHashSet

之前介绍的Map接口的实现类都有一个对应的Set接口的实现类,比如HashMap有HashSet, TreeMap有TreeSet, LinkedHashMap也不例外,它也有一个对应的Set接口的实现类LinkedHashSet。LinkedHashSet是HashSet的子类,它内部的Map的实现类是LinkedHashMap,所以它也可以保持插入顺序,比如:

1
2
3
4
5
6
Set<String> set = new LinkedHashSet<>();
set.add("b");
set.add("c");
set.add("a");
set.add("c");
System.out.println(set);

输出为:

1
[b, c, a]

LinkedHashSet的实现比较简单,我们就不再介绍了。

10.6.4 小结

本节主要介绍了LinkedHashMap的用法和实现原理,用法上,它可以保持插入顺序或访问顺序。插入顺序经常用于处理键值对的数据,并保持其输入顺序,也经常用于键已经排好序的场景,相比TreeMap效率更高;访问顺序经常用于实现LRU缓存。实现原理上,它是HashMap的子类,但内部有一个双向链表以维护节点的顺序。最后,我们简单介绍了LinkedHashSet,它是HashSet的子类,但内部使用LinkedHashMap。

10.5 剖析TreeSet

在介绍HashSet时,我们提到,HashSet有一个重要局限,元素之间没有特定的顺序,我们还提到,Set接口还有另一个重要的实现类TreeSet,它是有序的,与HashSet和HashMap的关系一样,TreeSet是基于TreeMap的,本节我们来详细讨论TreeSet。下面,我们先介绍TreeSet的用法,然后介绍实现原理,最后总结分析TreeSet的特点。

10.5.1 基本用法

TreeSet的基本构造方法有两个:

1
2
public TreeSet()
public TreeSet(Comparator<? super E> comparator)

第一个是默认构造方法,假定元素实现了Comparable接口;第二个使用传入的比较器,不要求元素实现Comparable。TreeSet经常也只是当作Set使用,只是希望迭代输出有序,如下面代码所示:

1
2
3
4
5
6
7
Set<String> words = new TreeSet<String>();
words.addAll(Arrays.asList(new String[]{
"tree", "map", "hash", "map",
}));
for(String w : words){
System.out.print(w+" ");
}

输出为:

1
hash map tree

TreeSet实现了两点:排重和有序。如果希望不同的排序,可以传递一个Comparator,比如:

1
2
3
4
5
6
7
8
9
10
11
Set<String> words = new TreeSet<String>(new Comparator<String>(){
@Override
public int compare(String o1, String o2) {
return o1.compareToIgnoreCase(o2);
}}
);

words.addAll(Arrays.asList(new String[]{
"tree", "map", "hash", "Map",
}));
System.out.println(words);

忽略大小写进行比较,输出为:

1
[hash, map, tree]

需要注意的是,Set是排重的,排重是基于比较结果的,结果为0即视为相同,”map”和”Map”虽然不同,但比较结果为0,所以只会保留第一个元素。

以上就是TreeSet的基本用法,简单易用。因为有序,TreeSet还实现了NavigableSet和SortedSet接口,NavigableSet扩展了SortedSet,可以方便地根据顺序进行查找和操作,如第一个、最后一个、某一取值范围、某一值的邻近元素等,限于篇幅,我们就不介绍了,具体可参见API文档。

10.5.2 实现原理

之前章节介绍过,HashSet是基于HashMap实现的,元素就是HashMap中的键,值是一个固定的值,TreeSet是类似的,它是基于TreeMap实现的。我们具体来看一下代码,先看其内部组成。

TreeSet的内部有如下成员:

1
2
private transient NavigableMap<E, Object> m;
private static final Object PRESENT = new Object();

m就是背后的那个TreeMap,这里用的是更为通用的接口类型NavigableMap,PRESENT就是那个固定的共享值。TreeSet的方法实现主要就是调用m的方法,我们具体来看下。

默认构造方法的代码为:

1
2
3
4
5
6
TreeSet(NavigableMap<E, Object> m) {
this.m = m;
}
public TreeSet() {
this(new TreeMap<E, Object>());
}

代码都比较简单,就不解释了。添加元素,add方法的代码为:

1
2
3
public boolean add(E e) {
return m.put(e, PRESENT)==null;
}

就是调用map的put方法,元素e用作键,值就是固定值PRESENT, put返回null表示原来没有对应的键,添加成功了。检查是否包含元素,代码为:

1
2
3
public boolean contains(Object o) {
return m.containsKey(o);
}

就是检查map中是否包含对应的键。删除元素,代码为:

1
2
3
public boolean remove(Object o) {
return m.remove(o)==PRESENT;
}

就是调用map的remove方法,返回值为PRESENT表示原来有对应的键且删除成功了。

TreeSet的实现代码都比较简单,主要就是调用内部NavigatableMap的方法。

10.5.3 小结

本节介绍了TreeSet的用法和实现原理,在用法方面,它实现了Set接口,但有序,在内部实现上,它基于TreeMap实现,而TreeMap基于大致平衡的排序二叉树:红黑树,这决定了它有如下特点。
1)没有重复元素。
2)添加、删除元素、判断元素是否存在,效率比较高,为O(log2(N)), N为元素个数。
3)有序,TreeSet同样实现了SortedSet和NavigatableSet接口,可以方便地根据顺序进行查找和操作,如第一个、最后一个、某一取值范围、某一值的邻近元素等。
4)为了有序,TreeSet要求元素实现Comparable接口或通过构造方法提供一个Com-parator对象。