介绍如何利用Java类库在程序设计中实现传统的数据结构。
(1)集合接口
①将集合的接口与实现分离
与现代的数据结构类库的常见情况一样,Java集合类库也将接口与实现分离。下面以 队列(queue)为例,说明是如何将二者分离的。
队列接口,指出可以在队列尾部添加元素,头部删除元素,可以查找元素个数,且具有“先进先出”的规则。
一个队列接口的最小形式可能为:
interface Queue<E> { //a simlified form of the interface
boolean add(E element);
E remove();
int size();
}
当然,接口中没有说明队列的具体实现。队列通常有两种实现方式:
一种,使用循环数组;
另一种,使用链表。
每一种实现方式都可以通过实现一个 Queue 接口完成。
class CircularArrayQueue<E> implements Queue<E> { //not an actual library class
CircularArrayQueue(int capacity) {...}
public boolean add(E element) {...}
public E remove() {...}
public int size() {...}
private E[] elements;
private int head;
private int tail;
}
class LinkedListQueue<E> implements Queue<E> { //not an actual library class
LinkedListQueue() {...}
public boolean add(E element) {...}
public E remove() {...}
public int size() {...}
private int head;
private int tail;
}
注释:实际上,在Java类库中没有名为 CircularArrayQueue 和 LinkedListQueue 的类。这里只是举例说明。如果需要一个循环数组队列,就可以使用 ArrayDeque类。如果需要一个链表队列,就直接使用 LinkedList 类,这个类实现了 Queue接口。
在实际使用中,可以使用接口类型存放集合的引用。
Queue<Customer> expressLane = new CircularArrayQueue<>(100);
expressLane.add(new Customer("Harry"));
Queue<Customer> expressLane = new LinkedListQueue <>(100);
expressLane.add(new Customer("Harry"));
在两种具体实现方式切换时,只需要对调用构造器的地方进行修改。
②Java类库中的集合接口和迭代器接口
Collection接口
- 遍历下一个元素之前,要先调用 hasNext()方法判定是否已经到集合的末尾。
- Collection接口扩展了 Iterable 接口。因此,对于标准类库中的任何集合都可以使用 “for each”循环。
Iterator接口
- Java 迭代器是位于两个元素之间。当调用 next 时,迭代器就越过下一个元素,并返回刚刚越过的那个元素的引用。
- 注意,对 next 方法和remove方法的调用具有相互依赖性。调用 remove 方法删除元素前,需要先调用 next 方法越过这个元素。否则,会抛出 IllegalStateException异常。
事实上,Collection 接口声明了很多有用的方法,所有实现类都必须提供这些方法。部分方法如下:
int size()
boolean isEmpty()
boolean contains(Object obj)
boolean containsAll(Collection<?> c)
boolean equals(Object other)
boolean addAll(Collection<? extends E> from)
boolean remove(Object obj)
boolean removeAll(Collection<?> c)
void clear()
boolean retainAll(Collection<?> c)
Object[] toArray()
<T> T[] toArray(T[] arrayToFill)
(2)具体集合
本节,首先介绍一下Java类库中提供的具体的数据结构。然后再研究一些抽象的概念,看一看集合框架组织这些类的方式。如下表所示,除了以 Map结尾的类之外,其他类都实现了 Collection 接口。而以 Map 结尾的类实现了 Map 接口。
①链表
- 尽管数组在连续的存储位置上存放对象引用,但链表却将每个对象存放在独立的结点中。每个结点还存放着序列中下一个结点的引用。在Java语言中,所有的链表实际上都是双向链接的——即每个结点还存放着指向前驱结点的引用。
- 链表是一个有序集合,每个对象的位置十分重要。
- 在 Iterator 接口中没有 add 方法。集合提供了子接口 ListIterator,其中包含 add方法。
- 注意链表中 cursor 的位置,以及 remove 方法的使用。
- 为了避免发生并发修改异常,应该遵循以下原则:可以根据需要给容器附加多个迭代器,但这些迭代器只能读取列表。另外,再单独附加一个既能读又能写的迭代器。
- 还有一种检测并发修改问题的方法:集合可以跟踪改写操作的次数。每个迭代器都维护一个独立的计数值。在每个迭代器方法的开始处检查自己改写操作的计数值是否与集合的改写操作计数值一致。如果不一致,抛出一个 ConcurrentModificationException 异常。
- 列表迭代器接口提供了访问当前位置的索引:nextIndex 方法,previousIndex方法。
- 注意要避免使用以整数索引表示链表中位置的所有方法(这些方法每次都要从链表头部遍历链表,效率极低)。如果需要对集合进行随机访问,就使用数组或 ArrayList,而非链表。
②数组列表
List接口有两个常用的实现类:LinkedList类、ArrayList 类。前者适合迭代器访问,后者则适合随机访问。ArrayList 封装了一个动态再分配的对象数组。
为何要用ArrayList类代替 Vector 类?
原因: Vector 类的所有方法都是同步的,即多线程可以安全访问。但是,如果只有一个线程访问时,Vector 要花费很多时间在同步操作上。而ArrayList 方法不是同步的,因此在不需要同步时建议选用 ArrayList类。
③散列集
适用场景:不要求元素的顺序,但希望能够快速查找到元素时。
概念:散列表、散列码、桶(bucket)、散列冲突、桶数、再散列、装填因子。
Java集合类库提供了一个 HashSet类,它实现了基于散列表的集。
散列表迭代器将以此访问所有的桶。由于散列将元素分散在表的各个位置,因此访问元素的顺序几乎是随机的。
问题1(?):
如果自定义类,就要负责实现这个类的 hashCode 方法(有关该方法的内容参见第5章)。
④树集(TreeSet类)
树集是一个有序集合。
正如 TreeSet 类名所示,排序是用树结构完成的(当前使用的是红黑树)。每次将一个元素添加到树中时,都被放置在正确的排序位置上。因此,迭代器总是以排好序的顺序访问每个元素。
将一个元素添加到树中要比添加到散列表中慢,但,与新元素添加到数组或链表的正确位置上相比还是快很多的。
⑤对象的比较
- 问题引出:TreeSet 如何知道希望元素怎样排列呢?
- 在默认情况时,树集假定插入的元素实现了 Comparable 接口。有些标准的 Java 平台类实现了 Comparable 接口,例如,String 类。这个类的 compareTo 方法依据字典序对字符串进行比较。
- 如果要插入自定义的对象到 TreeSet中,就必须通过实现 Comparable 接口自定义排列顺序。在 Object 类中,没有提供任何 compareTo 接口的默认实现。
例如,下面代码展示如何利用部件编号对 Item 对象进行排序:
class Item implements Comparable<Item> {
public int compareTo(Item other) {
return partNumber - otherNumber;
}
}
注意,在减法计算时要防止可能会溢出。
- 然而,通过实现 Comparable 接口定义排列顺序具有局限性。对于一个给定的类,只能够实现这个接口一次。以上例为例,如果在一个需要按照编号排序,而在另一个集合中却要求按照描述信息进行排序,该怎么办?另外,如果需要对一个类的对象进行排序,而这个类的创建者没有实现 Comparable 接口,又该怎么办?
- 在这种情况下,可以通过将 Comparable 对象传递给 TreeSet 构造器来告诉树集使用不同的比较方法。
SortedSet<Item> sortedSet = new TreeSet<>(new Comparator<Item>() {
@Override
public int compare(Item a, Item b) {
String decrA = a.getDescription();
String decrB = b.getDescription();
return decrA.compareTo(decrB);
}
});
- 如何选择是用散列表还是树集?
这取决于要收集的数据。如果不需要对数据进行排序,就没必要付出排序的开销。
⑥队列与双端队列
- 双端队列:可以在头部和尾部同时添加或删除元素。
- Deque 接口的两个实现类,ArrayDeque类 和 LinkedList 类都提供了双端队列。
⑦优先级队列
- 优先队列(priority queue),中的元素可以按照任意顺序插入,却总是按照排序的顺序进行检索。即,无论何时调用 remove 方法,总会获得当前优先级队列中最小的元素。然而,优先级队列并没有对所有元素进行排序。如果用迭代的方式处理这些元素并不需要对它们进行排序。
- 优先级队列使用了 堆数据结构实现。
- 与 TreeSet 一样,一个优先级队列既可以保存实现了 Comparable 接口的类对象,也可以保存在构造器中提供比较器的对象。
- 优先级队列的典型应用是,任务调度。
⑧映射表
- 引入:集是一个集合,集合可实现元素的快速查找。但是,查找时要提供元素的精确副本。但这不是一种非常通用的查找方式。我们通常希望,知道一个键的信息,就能够查找到对应的值。映射表(map)数据结构就是为此设计。映射表存放的是键/值对。如果提供了键,就能够查找到值。
- Java类库中,映射表的两个通用实现:HashMap 和 TreeMap。这两个类都实现了 Map 接口。
- 散列映射表对键进行散列,树映射表用键的整体顺序对元素进行排序,并将其组织成搜索树。散列或比较函数都只能作用于键。与键关联的值不能进行散列或比较。
- 如何选择散列映射表还是树映射表?
与集一样,散列稍微快一些,如果不需要按照排列顺序访问键,就最好选用散列。 - 一定要注意,集合框架并没有将映射表本身视为一个集合。然而,可以获得映射表的视图,这是一组实现了 Collection 接口或其子接口的视图。
- 散列映射表有3个视图:键集、值集合(不是集)、键/值对集。下列方法将返回这3个视图(条目集的元素是静态内部类 Map.Entry 的对象):
Set<K> keySet()
Collection<K> values()
Set<Map.Entry<K,V>> entrySet()
注意,keySet既不是 HashSet,也不是 TreeSet,而是实现了 Set 接口的某个其他类的对象。Set接口扩展了 Collection 接口,因此,可以与使用任何集合一样使用 keySet。
- 提示,如果想要同时查看键与值,可以通过枚举各个条目(entries)查看,以避免对值进行查找。
for (Map.Entry<String, Employee> entry : staff.entrySet()) //枚举各个条目,避免了对值进行查找
{
String key = entry.getKey();
Employee value = entry.getValue();
do something with key, value
}
⑨ 专用集与映射表类
(3)集合框架