Collections(三)——具体集合类

我们首先看下Java标准库提供了哪些集合类,之后在回顾Collection框架,下面列出了所有具体的Java集合类,线程安全的集合类在后续会讨论,此处不列出。除了以Map结尾的集合类,其他所有集合类集成自Collection接口,Map类实现了Map接口。我们稍后讨论。

集合类类型描述
ArrayList使用数组存储的序列数据,会自动扩容
LinkedList使用链式结构存储数据,可以高效地删除和插入元素
ArrayDeque使用数组实现的双端队列
HashSet没有重复数据的无序集合
TreeSet排序Set
EnumSet枚举类型的Set
LinkedHashSet按照插入顺序排序的Set
PriorityQueue高效删除最小元素的集合类
HashMap保存键值对的数据接口
TreeMap按照键排序的键值对序列
EnumMap键为枚举类型的键值对
LinkedHashMap按照键值排序的键值对
WeakHashMap如果值没有在其他地方使用,那么就会被回收的HashMap
IdentityHashMap它的键值可以使用==判断是否相等,而不是equals

链表

我们在之前就使用过了数组和它的泛型类型,ArrayList。但是,他们都有一个致命缺点,从数组中间删除元素非常困难,因为后面的元素都需要向前移动,在中间插入元素也是一样的。想下面的图一样。
在ArrayList删除元素
另外一个常见的数据结构是链表,他成功解决了这个问题。链表在链式结构中存储数据。每个数据还会存储下一个元素的引用。在Java编程语言中,所有的链表是双向链表,也就是说,不仅会保存后面的元素,还会保留前面的元素。
链表结构
在链表的中间删除元素代价并不高,只有被删除元素的周围元素会受影响。
或许你之前学过数据结构,知道怎么实现这些,你可能对此有些痛苦的回忆。但是在Java中,你可以直接使用。
下面的代码向链表中插入三个元素,然后删除第二个。

List<String> staff = new LinkedList<String>(); // 链表实现的List
staff.add("Amy");
staff.add("Bob");
staff.add("Carl");
Iterator ite = staff.iterator;
String first = ite.next();		// 第一个元素
String second = ite.next();		// 第二个元素
ite.remove();					// 删除上一个访问的元素

链表结构和其他泛型集合类有一个重要区别。链表是一个排序的集合类,它的位置是包含信息的。LinkedList.add函数在链表的结尾插入元素。但是,有时你会想在中间插入元素。这种插入操作是和迭代器相关的。通过迭代器向有序的结构中插入元素是合理的。比如,在后面我们要讨论的集合数据接口,它不考虑数据的位置问题。所以,迭代器中就没有add方法。但是,它的子接口ListIterator包含有add方法。

interface ListIterator<E> extends Itetrator<E>
{
	void add(E element);
}

不想Collection.add,这个方法不返回boolean。也就是说,他认为add操作总是会改变数组。
另外,他还提供了用于反向遍历的API。

E previous();
boolean hasPrevious();

他们的具体用法和next和hasNext类似。
LinkedList的listIterator返回实现了ListIterator接口的类。

ListIterator<String> ite = staff.listIterator();

add方法在迭代器的前面插入元素,比如,下面的代码向数组中添加三个元素,并在第二个元素之前插入新数据。

List<String> staff = new LinkedList<String>();
staff.add("Amy");
staff.add("Bob");
staff.add("Carl");
ListIterator<String> iter = staff.listIterator();
iter.next();
iter.add("Juliet");

链表插入元素
如果你多次调用add方法,那么他每次都在当前迭代器之前插入数据。如果你在迭代器刚创建完成的时候插入数据,那么他会被插入到开始,如果你在列表的最后一个元素(也就是hasNext返回false的时候)插入数据,那么会在末尾插入输入,新元素变为列表的结尾。也就是说,如果一个列表长度为n,那么你有n+1个位置可以插入数据。
此外,迭代器还有一个set方法,可以设置迭代器上一次返回的值。下面的代码将列表的第一个元素替换为其他值。

ListIterator<String> iter = list.listIterator();
String oldValue = iter.next();
iter.set(newValue);

当一个迭代器在修改元素,另外一个在遍历的时候,冲突就发生了。链表的迭代器会监测这种错误,并报出ConcurrentModificationException。

List<String> iter = ...;
ListIterator<String> iter1 = list.listIterator();
ListIterator<String> iter2 = list.listIterator();
iter1.next();
iter1.remove();
iter2.next();	// 抛出ConcurrentModificationException

为了避免这个错误,你需要遵守以下原则:你可以创建任意多的迭代器,但是只有其中一个具备读写能力,其他都只能读。
错误的检测采用简单的技术方法。集合类保留一个变量,用于指示当前集合类中包含多少元素,在迭代器创建时,迭代器中包含有创建是的元素数量。每次调用add或者remove方法时,元素数量变化,但是迭代器中保存的元素数量不变。当调用next方法时,迭代器对比自己的计数变量和集合类中的计数变量,二者不同,则报错。
现在你已经完全了解了LinkedList类。集合类还提供了一些其他有用的函数,他们大多继承自AbstractCollection。比如toString()方法会生成一个"[A,B,C,D]"类似的字符串,你可以使用他们来调试。contains方法判断列表中是否存在某个元素。
此外,还有一些方法,比如你可以随机获取链表中的某个值,但是,由于你只能依次访问链表,因此,每次随机访问,你都需要访问前n - 1个元素,效率很低,因此当你需要随机访问时,最好不要使用链表。
LinkedList给出了一个get函数,用于随机获取元素。

LinkedList<String> list = ...;
String obj = list.get(n);

这效率非常低,如果你需要经常执行这样的操作,那么你选错了数据结构。
你永远不应该使用下标遍历链表。

for(int i = 0;i < list.size();i++)
	list.get(i);

上面的代码效率非常低。
此外,迭代器也能返回当前遍历到的下标,nextIndex方法返回的是下一次调用next的下标,previousIndex返回的下一次调用previous的下标。这些方法都是很高效的。如果你需要从第n个开始遍历,你在构造迭代器的时候可以使用list.listIterator(n),下一次next的时候就获取第n个元素。但是获取这样的迭代器效率不高。
当你使用链表的时候,你应该尽量避免随机读取,如果你需要大量的随机读取操作,你应该使用ArrayList。

ArrayList

在前面你学习了LinkedList,下面开始学习ArrayList。ArrayList同样实现了List接口,他维护了一个都动态数组。和linkedList相比,他最大的有点是随机读取速度快,也就是list.get(n)方法可以快速执行。当时相反,在中间插入元素就很慢。

HashSet

使用List接口,你可以按照顺序记录元素,但是当你想要查找一个元素,而忘记了他的位置时,就很难判断是否存在。但是使用HashSet,你可以快速判断一个元素是否存在,但是他的确定是你没有办法知道元素的排列顺序。
HashSet是快速查找数据的数据结构。Hash表会计算一个整数值,叫做hash值。hash值是从对象的各个域计算得到的整数值。如果你自己实现了自己的类,那么你需要自己实现hashCode方法。你的实现需要考虑equals方法,如果equals方法返回true,那么他们应该具有相同的hash值。
在计算过程中,最重要的是需要快速计算得到hash值,而且计算过程只依靠当前的对象,跟hash表中的其他值没有关系。
在Java中,hash表使用链表的数组完成。每个列表称为桶。为了找到值在表中的位置,你计算hash值,然后对桶的数量求余,得到存储这个元素的桶的下标。比如,如果一个对象的hash值是76268,而且有128个桶,那么这个对象的桶的下表就是108(因为76268 % 128 = 108)。可能你运气好,这个桶刚好没有元素,那么你直接插入到里面就好。但是如果桶里面已经有元素了,那么这种情况叫做hash冲突。然后,你需要查看,你需要插入的值是不是跟桶中的值重复,这也是要求hash值最好服从均匀分布的原因,这样每个桶里面被插入的概率是相同的。如果你需要提高效率,你可以指定初始桶的数量。如果桶的数量不够,hash冲突发生的概率就会增加。如果你大概知道你需要插入多少个元素,那么你可以预先设置桶的个数,通常在元素个数的75%到150%之间。有些研究人员认为你最好将桶的数量设置为质数,这样hash冲突最少,虽然这并没有严格证明。标准库使用2的幂次作为桶的数量,默认是16(如果你指定的不是2的幂次,那么就让他变成不小于它的2的幂次)。
当然,大多数情况下,你都不知道有多少元素需要存储,或者实际存储的数量远超你的预期。如果你的hash表太满了,那你需要重排。重排时,桶的个数将会增加,数据将会被全部迁移到新的地方,原始的表将会被销毁。负载系数决定了在什么情况下会重排。比如,如果负载系数是0.75(默认值),那么当HashSet的元素数量超过满情况下的75%时,重排将发生,桶的数量将翻倍。对于大多数情况,设置负载系数为0.75是合理的。
Hash表可以用于实现多种有用的数据结构。最简单的就是集合类型。集合类型是一系列没有重复的元素集合。add操作会在集合中没有该元素是插入该元素,如果集合中存在,则不插入。
Java中的HashSet类时使用hash表实现的set数据结构。你可以使用add方法添加元素。contains方法判断当前元素是否在集合中存在。他只检查一个桶中的元素,而不是整个hash表。
hash集合的迭代器会依次访问每个桶。由于hash值的随机分布,因此遍历顺序也是随机的。只有在你不考虑元素顺序时,你才应该使用HashSet。

TreeSet

TreeSet类和hash集合类似,但添加了一点改进。TreeSet类是排序集合。你可以按照任何顺序插入元素。当你遍历时,值自动按照排序的结果遍历。比如,如果你插入三个元素,然后遍历。

SortedSet<String> sorter = new TreeSet<String>(); // TreeSet实现了SortedSet接口
sorter.add("Bob");
sorter.add("Amy");
sorter.add("Carl");
for(String s: sorter){
	System.out.println(s);
}

打印出来的字符串是Amy Bob Carl。就像名字指示的一样,这个类内部使用树结构保存值(当前实现使用红黑树)。每次添加元素时,都将其添加到数的对应位置,然后迭代器顺序的访问这些元素。
向TreeSet中添加元素比向HashSet中添加元素慢,但是仍然比向链表或数组的指定位置插入数据快很多。复杂度大约为logn。下表比较了不同数据结构的性能。

文档词数量不同词的数量HashSet时间TreeSet时间
爱丽丝梦游仙境2819559095秒7秒
基督山伯爵4663003754575秒98秒

对象比较

那么TreeSet应该怎么比较对象呢?默认情况下,TreeSet假设你插入的对象实现了Comparable接口,该接口只有一个方法

public interface Comparable<T>
{
	int compareTo(T other);
}

调用a.compareTo(b)时,如果a和b相等,则返回0,如果a应该在b之前,那么返回负值,否则返回正值,只有符号才有效。一些内置类实现了Comparable接口,比如String类,它的compareTo方法使用字典序。
当你插入自己的类时,你需要实现Comparable方法,Object类没有compareTo的默认实现。比如,下面的类实现了Comparable接口。

class Item implements Comparable<Item>
{
	public int compareTo(Item other)
	{
		return partNumber - other.partNumber;
	}
}

如果你要比较正数,那么你可以直接做差。
但这种做法有一个局限性,使用这种方法需要你在实现类的时候就实现Comparable接口,而类只能定义一次,而在有些情况下,我们希望定义多种比较方式。
在这种情况下,你可以使用另外一种方法,你想TreeSet对象传入一个Comparator对象,Comparator接口定义了一个方法,该方法有两个参数。

public interface Comparator<T>
{
	int compare(T a,T b);
}

它的返回值含义和Comparable中的compareTo方法一样。
为了按照你的意愿排序,你可以先定义一个实现Comparator接口的类

class ItemComparator implements Comparator<Item>
{
	public int compareTo(Item a,Item b)
	{
		String descrA = a.getDescription();
		String descrB = a.getDescription();
		return descrA.compareTo(descrB);
	}
}

然后你将对象传入TreeSet构造函数。

ItemComparator comp = new ItemComparator();
SortedSet<Item> sortByDescription = new TreeSet<Item>(comp);

你可以多次使用这个对象,在不同的场景下比较Item。
Comparator对象通常没有数据,而只有一个比较方法,这种对象通常成为函数对象。函数对象通常使用内部类定义。

SortedSet<Item> sortByDescription = new TreeSet<Item>(new
	Comparator<Item>()
	{
		public int compare(Item a,Item b)
		{
			String descrA = a.getDescription();
			String descrB = b.getDescription();
			return descrA.compareTo(descrB);
		}
	});

如果你仔细查看性能对比表,你会想,我在所有情况下都使用TreeSet就可以了,为什么要使用HashSet呢?答案是使用TreeSet你需要了解对象排序的方法,有些对象不好排序,比如Rectangle类(矩形类),使用面积排序好像不太好,使用坐标又要考虑四个点,还不如直接使用HashSet。
Java SE 6的TreeSet实现了NavigableSet接口,实现了多种有用的方法,用于定位元素,或者反向遍历,你可以查看API文档了解具体用法。

队列和双端队列

你已经知道,队列对象可以在队列尾部插入元素,在头部弹出元素。双端队列可以在尾部和头部高效地插入和弹出元素。队列不允许在中间插入数据。Java SE 6添加了Deque接口。ArrayDeque和LinkedList都实现了这个接口,他们的大小都自动增长,我们将在后续介绍。

优先队列

优先队列可以保证你每次获取到的数值是最小的数值。他使用一种叫做堆的数据结构,可以保证每次插入和移除元素时,最小的元素位于根节点。
和TreeMap一样,有限队列可以用于存储实现了Comparable的对象或者在初始化时使用Comparator对象。
优先队列的一个典型应用是任务规划。不同的任务随机到来,每个任务有不同的优先级,优先级最高的任务必须最先处理。

映射

集合数据结构可以使你快速查找一个元素是否存在。但有时,你需要根据一个值,查找另外一个值。映射就是一个键值对。你可以根据键查找值。Java提供了两种通用的映射实现:HashMap和TreeMap,他们都实现了Map接口。
HashMap使用键的Hash值存储,而TreeMap使用数结构存储。hash函数或比较函数只针对键,不针对值。
你应该使用HashMap还是TreeMap?通常,HashMap更快,如果你不需要键值排序,那么你应该使用HashMap。
下面介绍用法.

Map<String,Employee> staff = new HashMap<String,Employee>(); // HashMap实现了Map接口
Employee harry = new Employee("Harry Hacker");
staff.put("987-98-9996",harry)

当你添加值时,你也需要添加键,在我们的这个例子中,键是String类型,而值是Employee。
当获取值时,你需要使用键。

String s = "987-98-9996";
e = staff.get(s); // 获取harry

如果没有找到键对应的值,那么会返回null.
键必须是唯一的,你不能使用同一个Map保存两个键相同的值,如果你使用put函数两次,而键是相同的,那么后面的值将会覆盖前面的值,并返回上一个值。
remove方法从Map中移除某个键(以及他的值)。size方法给出Map中键的个数。
集合类框架不把映射类型当做集合类(其他数据结构框架通常将映射当做对的集合,或者被键索引的值的集合)。但是,你可以得到映射的视图。
你可以获取三种类型的视图:键的集合,值的列表,和键值对的集合。键和键值对是集合,因为他们在每个映射中只有一个。他们分别使用下面的方法获得。

Set<K> keySet();
Collection<K> values();
Set<Map.Entry<K,V>> entrySet()

你需要注意的是,keySet返回的不是HashSet,也不是TreeSet,而是另外一个实现了Set接口的类对象。Set接口集成自Collection接口,因此你可以把它当做一个常规的集合类。
比如,你可以使用下面的方法遍历映射中的键

Set<String> keys = map.keySet();
for(String key: key)
{
	dosomething.
}

如果你想同时获得键和值,那么你可以使用下面的代码

for(Map.Entry<String,Employee> entry: staff.entrySet())
{
	String key = entry.getKey();
	Employee value = entry.getValue();
	dosomething;
}

你可以移除键集合视图中的元素,但是你没有办法向其中添加元素,这会造成UnsupportedOperationException。键值对集合视图也有类似的问题,

专用集合和映射类

集合类标准库里面还实现了一些专用的映射类,我们需要讨论一下。

弱哈希映射

WeakHashMap用于解决一些有趣的问题。如果你的映射中,有一个键在你的程序中再也不会用到,会怎么样?加入指向键的最后一个应用被回收,那么,你就没有办法知道这个键对应的值,因为你的程序没有办法获取键。那么为什么垃圾回收系统不能回收它呢?这难道不是垃圾回收系统的职责吗?
很不幸,没有那么简单。垃圾回收系统会追踪活跃对象,只要映射类是活动对象,那么它里面的所有桶都是活动对象,也不会被回收。所以如果你要回收,你应该自己负责。或者,你可以使用WeakHashMap。他只会保留当前键值是活动对象的键值对。
下面介绍内部机理。WeakHashMap使用弱应用保存键。弱引用对象保存对另一个对象的引用,在我们这里,就是哈希表的键。这种类型的变量会被垃圾回收系统特殊对待。通常,如果垃圾回收系统发现没有引用指向一个对象,那么就会回收它。但是如果一个对象只被弱引用引用,那么垃圾回收仍然会回收它,但是它还会把弱引用放到队列中,弱引用的操作会检查新到来的弱引用。新到来的弱引用表示键没有被任何其他引用引用,那么就会回收它。然后WeakHashMap移除它对应的键和值。

链表集合和映射

Java SE 1.4添加了LinkedHashSet和LinkedHashMap,他们会记住插入顺序,他们使用双向队列实现。
LinkedHashMap
比如,看看下面的代码

Map staff = new LinkedHashMap();
staff.put("144-25-5464",new Employee("Amy Lee"));
staff.put("567-24-2546",new Employee("Harry Hacker"));
staff.put("157-62-7935",new Employee("Gary Cooper"));
staff.put("456-62-5527",new Employee("Francesca Cruz"));

那么staff.keySet().iterator()迭代器将会按照下面的顺序返回。

144-25-5464
567-24-2546
157-62-7935
456-62-5527

而且staff.values().iterator()迭代器会按照下面的顺序迭代。

Amy Lee
Harry Hacker
Gary Cooper
Francesca Cruz

链表哈希映射也可以使用获取顺序遍历对象,而不是按照插入顺序。这样的链表哈希映射可以使用下面的构造函数创建。

LinkedHashMap<K,V>(initialCapacity,loadFactor,true);

枚举集合和映射

枚举集合中的元素只能是枚举类型中的值。
枚举集合没有构造函数,你需要使用工厂模式构造集合:

enum Weekday = {MONDAY,TUESDAY,WEDNESDAY,THURSDAY,FRIDAY,SATURDAY,SUNDAY};
EnumSet<Weekday> always = EnumSet.allOf(Weekday.class);
EnumSet<Weekday> never = EnumSet.noneOf(Weekday.class);

你可以像使用常规Set类一样使用枚举集合。
枚举映射类是键只能是枚举类型中的值的映射类。在构造时,你需要指定键的类型。

基于地址的HashMap

还有一个特殊的HashMap叫做IdentityHashMap,这种HashMap认为不同的对象就是不同的key,在计算hash值时使用System.identityHashCode,这个函数在遇到不同的对象时,返回不同的hash码,及时他们的内容是一样的。因此你可以使用==判断键是否相同。这种HashMap通常用于特殊用途。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值