9.集合与映射

集合

9.1Java集合框架
9.1.1集合接口与实现分离

队列接口指出可以在队列的尾部添加元素,在队列的头部删除元素,并且可以查找队列中元素的个数。当需要收集对象,并按照先进先出方式检索对象时就应该使用队列。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GL8XAarM-1627890434047)(evernotecid://95126015-5853-4CC7-AE3B-105960046EC5/appyinxiangcom/15766490/ENResource/p689)]

这个接口并没有说明队列是如何实现的。队列通常有两种实现方式:一种是使用循环数组;另一种是使用链表

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wMOcfVMU-1627890434051)(evernotecid://95126015-5853-4CC7-AE3B-105960046EC5/appyinxiangcom/15766490/ENResource/p690)]

接口本身并不能说明哪种实现的效率究竟如何。循环数组要比链表更高效,因此多数人优先选择循环数组。不过,由于它是一个有界集合,容量有限如果程序中要收集的对象数量没有上限,就最好使用链表来实现

9.1.2Collection接口

在java类库中,集合类的基本接口是Collection接口。

9.1.3迭代器
public interface Iterator<E> {
    E next();
    boolean hasNext();
    void remove();
    default void forEachRemaining(Consumer<? super E> action);
}

如果到达了集合的末尾,next方法将抛出一个NoSuchElementException。因此,需要在调用next之前调用hasNext方法。

Collection<String> c = ...;
Iterator<String> iter = c.iterator();

while (iter.hasNext()) {
    String element = iter.next();
    // Do something with element
}

// 等价于
for (String element : c) {
    // Do something with element
}

Collection接口扩展了Iterable接口,因此,对于标准类库中的任何集合都可以使用for each循环。
可以认为java的迭代器位于两个元素之间。当调用next时,迭代器就越过下一个元素,并返回刚刚越过的那个元素的引用Iterator接口的remove方法将会删除上次调用next方法时返回的元素。如果想要删除指定位置上的元素,仍然需要越过这个元素。例如,可以如下删除一个字符串集合中的第一个元素:

Iterator<String> it = c.iterator();
it.next();  // Skip over the first element
it.remove();    // Now remove it

更重要的是,next方法和remove方法调用之间存在依赖性。如果调用remove之前没有调用next,将是不合法的。如果这样做,将会抛出一个IllegalStateException异常。因此,如果想要删除两个相邻的元素:

it.remove();
// it.remove(); // ERROR
it.next();
it.remove();    // OK
9.2集合框架中的接口

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RwyCCFKC-1627890434052)(evernotecid://95126015-5853-4CC7-AE3B-105960046EC5/appyinxiangcom/15766490/ENResource/p691)]

ListIterator接口是Iterator的一个子接口。它定义了一个add方法用于在迭代器位置前面增加一个元素。
要适当地定义集(Set)的equals方法:只要两个集包含同样的元素就认为它们是相等的,而不要求这些元素有同样的顺序。hashCode方法的定义要保证包含相同元素的两个集会得到相同的散列码。

9.3具体集合

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-71ayzFwO-1627890434056)(evernotecid://95126015-5853-4CC7-AE3B-105960046EC5/appyinxiangcom/15766490/ENResource/p692)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-N53MO9QT-1627890434057)(evernotecid://95126015-5853-4CC7-AE3B-105960046EC5/appyinxiangcom/15766490/ENResource/p693)]

9.3.1链表

链表是一个有序集合,每个对象的位置十分重要。只有对自然有序的集合使用迭代器添加元素才有实际意义。例如,中的元素是完全无序的。因此,Iterator接口中没有add方法。实际上,集合类库提供了一个子接口ListIterator,其中包含add方法。另外还有previoushasPrevious用来反向遍历。
如果多次调用add方法,将按照提供的次序把元素添加到集合中,它们被依次添加到迭代器当前位置之前。最后需要说明,set方法用一个新元素替换调用nextprevious方法返回的上一个元素

如果在某个迭代器修改集合时,另一个迭代器却在遍历这个集合,那么一定会出现混乱。链表迭代器(或者说是迭代器)设计为可以检测到这种修改(只是跟踪结构性修改,set方法除外)。如果一个迭代器发现它的集合被另一个迭代器修改了,或是被该集合自身的某个方法修改了,就会抛出一个ConcurrentModificationException异常。
为了避免发生并发修改异常,请遵循这样一个简单的规则:可以根据需要为一个集合关联多个迭代器,前提是这些迭代器只能读取集合。或者,可以再关联一个能同时读写的迭代器。
集合可以跟踪更改操作(诸如添加或删除元素)的次数。每个迭代器都会为它负责的更改操作维护一个单独的更改操作数。在每个迭代器方法的开始处,迭代器会检查它自己的更改操作数是否与集合的更改操作数相等。如果不一致,就抛出一个ConcurrentModificationException异常。

列表迭代器接口还有一个方法,可以告诉当前位置的索引。实际上,从概念上来讲,由于java迭代器指向两个元素之间的位置,所以可以有两个索引nextIndex方法返回下一次调用next方法时所返回元素的整数索引;previousIndex方法返回下一次调用previous方法时所返回元素的整数索引。
最后需要说明一点,如果有一个整数索引n,list.listIterator(n)将返回一个迭代器,这个迭代器指向索引为n的元素前面的位置。也就是说,调用next与调用list.get(n)会产生同一个元素,只是获得迭代器的效率比较低。

9.3.3散列集

链表和数组允许根据意愿指定元素的次序。但是,如果想要查看某个指定的元素,却又不记得它的位置,就需要访问所有元素,直到找到为止。如果集合中包含的元素很多,这将会需要很长时间。
有一种众所周知的数据结构,可以用于快速地查找对象,这就是散列表。散列表为每个对象计算一个整数,称为散列码。散列码是由对象的实例字段得出的一个整数。更准确地说,不同数据的对象将产生不同的散列码。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TrAsXB4u-1627890434058)(evernotecid://95126015-5853-4CC7-AE3B-105960046EC5/appyinxiangcom/15766490/ENResource/p741)]@w=300

在java中,散列表用链表数组实现。每个列表被称为。要想查找表中对象的位置,就要先计算它的散列码,然后与桶的总数取余,所得到的结果就是保存这个元素的桶的索引。或许很幸运,在这个桶中没有其他元素,此时将元素直接插入到桶中即可。当然,有时候会遇到桶已经被填充的情况。这种现象被称为散列冲突。这时,需要将新对象与桶中的所有对象进行比较,查看这个对象是否已经存在。如果散列码合理地随机分布,桶的数目也足够大,需要比较的次数就会很少。
如果想要更多地控制散列表的性能,可以指定一个初始的桶数。桶数是指用于收集有相同散列值的桶的数目。如果要插入到散列表中的元素太多,就会增加冲突数量,降低检索性能。
如果大致知道最终会有多少个元素要插入到散列表中,就可以设置桶数。通常,将桶数设置为预计元素个数的75%~150%。
当然,并不能总是能够知道需要存储多少个元素,也有可能最初的估值过低。如果散列表太满,就需要再散列。如果要对散列表再散列,就需要创建一个桶数更多的表,并将所有元素插入到这个新表中,然后丢弃原来的表。装填因子可以确定何时对散列表进行再散列。例如,如果装填因子为0.75(默认值),说明表中已经填满了75%以上,就会自动再散列,新表的桶数是原来的两倍。
散列表可以用于实现很多重要的数据结构。其中最简单的是集类型。集是没有重复元素的元素集合。集的add方法首先在这个集中查找要添加的对象,如果不存在,就添加这个元素。

9.3.4树集

TreeSet类与散列集十分类似,不过,它比散列集有所改进。树集是一个有序集合。可以以任意顺序将元素插入到集合中。在对集合进行遍历时,值将自动地按照排序后的顺序呈现。排序是用一个树数据结构完成的(当前实现使用的是红黑树)。每次将一个元素添加到树中时,都会将其放置在正确的排序位置上。因此,迭代器总是以有序的顺序访问每个元素
将一个元素添加到树中要比添加到散列表中慢,但是,与检查数组或链表中的重复元素相比,使用树会快很多。

9.3.5队列与双端队列

队列允许高效地在尾部添加元素,并在头部删除元素双端队列允许在头部和尾部都高效地添加或删除元素。不支持在队列中间添加元素。

9.3.6优先队列(典型用法是任务调度)

优先队列中的元素可以按照任意的顺序插入,但会按照有序的顺序进行检索。也就是说,无论何时调用remove方法,总会获得当前优先队列中最小的元素。不过,优先队列并没有对所有元素进行排序。如果迭代处理这些元素,并不需要对它们进行排序。优先队列使用了一个精巧且高效的数据结构,称为。堆是一个可以自组织的二叉树,其添加和删除操作可以让最小的元素移动到根,而不必花费时间对元素进行排序。
优先队列的典型用法是任务调度。每一个任务有一个优先级,任务以随机顺序添加到队列中。每当启动一个新的任务时,都将优先级最高的任务从队列中删除(由于习惯上将1设为最高优先级,所以remove操作会将最小的元素删除)。

9.4映射
9.4.1基本映射操作

HashMapTreeMap两个类都实现了Map接口。散列映射对键进行散列,树映射根据键的顺序将元素组织为一个搜索树。散列或比较函数只应用于键。与键关联的值不进行散列或比较。
与集一样,散列稍微快一点,如果不需要按照有序的顺序访问键,最好选择散列映射。

9.4.2更新映射条目

处理映射的一个难点就是更新映射条目。正常情况下,可以得到与一个键关联的原值,完成更新,再放回更新后的值。不过,必须考虑一个特殊情况,即键第一次出现。例如,使用映射统计一个单词在文件中出现的频度:

// 正常情况是可以的,但是当第一次看到word的时候,get会返回null,因此会出现NullPointException
counts.put(word, counts.get(word) + 1);

// 一种简单的补救是使用getOrDefault方法:
counts.put(word, counts.getOrDefault(word, 0) + 1);

// 另一种方法是首先调用putIfAbsent方法。只有当键原先存在(或者映射到null)时才会放入一个值:
counts.putIfAbsent(word, 0);
counts.put(word, counts.get(word) + 1); // Now we know that get will succeed

// 不过还可以做得更好,merge方法可以简化这个常见操作。如果键原先不存在,下面的调用将把word
// 与1关联,否则使用Integer::sum函数组合原值和1(也就是将原值与1求和)
counts.merge(word, 1, Integer::sum);
9.4.3映射视图

集合框架不认为映射本身是一个集合(其他数据结构框架认为映射是一个键/值对集合,或者是按键索引的值集合)。不过,可以得到映射的视图(view)——这是实现了Collection接口或某个子接口的对象。
有三种视图:键集、值集合(不是一个集)以及键/值对集:

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

需要说明的是,keySet不是HashSetTreeSet,而是实现了Set接口的另外某个类的对象。
如果在键集视图上调用迭代器的remove方法,实际上会从映射中删除这个键和与它关联的值。不过,不能向键集视图中添加元素。另外,如果添加一个键而没有同时添加值也是没有意义的。如果试图调用add方法,它会抛出一个UnsupportedOperationException。映射条目集视图有同样的限制,尽管理论上增加一个新的键/值对好像有意义。

9.4.4弱散列映射(使用弱引用保存键)

当对键的唯一引用来自散列表映射条目时,WeakHashMap将与垃圾回收器协同工作一起删除键/值对。
这种机制的内部工作原理在于,WeakHashMap使用弱引用保存键。WeakReference对象将包含另一个对象的引用,在这里,就是一个散列表键。对于这种类型的对象,垃圾回收器采用一种特有的方式进行处理。正常情况下,如果垃圾回收器发现某个特定的对象已经没有他人引用了,就将其回收。然而,如果某个对象只能由WeakReference引用,垃圾回收器也会将其回收,但会将引用这个对象的弱引用放入一个队列。WeakHashMap将周期性地检查队列,以便找出新添加的弱引用。一个弱引用进入队列意味着这个键不再被他人使用,并且已经回收。于是,WeakHashMap将删除相关联的映射条目。

9.4.5链接散列集与映射(记住插入元素项的顺序)

LinkedHashSetLinkedHashMap类会记住插入元素项的顺序。这样就可以避免散列表中的项看起来顺序是随机的。在表中插入元素项时,就会并入到双向链表中。

var staff = new LinkedHashMap<String, Employee>();
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
 */

或者,链接散列映射可以使用访问顺序而不是插入顺序来迭代处理映射条目。每次调用getput时,受到影响的项将从当前位置删除,并放到项链表的尾部(只影响项在链表中的位置,而散列表的桶不会受影响。映射条目总是在键散列码对应的桶中)。要构造这样一个散列映射,需要调用:

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

访问顺序对于实现缓存的最近最少使用原则十分重要。例如,可能希望将访问频率高的元素放在内存中,而访问频率低的元素从数据库中读取。当在表中找不到元素项而且表已经相当满时,可以得到表的一个迭代器,并删除它枚举的前几个元素(经常访问的会在尾部)。这些项是近期最少使用的几个元素。
甚至可以让这一过程自动化。构造LinkedHashMap的一个子类,然后覆盖下面这个方法:

protected boolean removeEldestEntry(Map.Entry<K, V> eldest);

每当方法返回true时,添加一个新映射条目就会导致删除eldest项。例如,下面的缓存最多可以存放100个元素:

var cache = new LinkedHashMap<K, V>(128, 0.75F, true) {
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > 100;
    }
};

或者,还可以考虑eldest元素,来决定是否将它删除。例如,可以检查与这一项一起存储的时间戳。

9.4.6枚举集与映射

EnumSet是一个枚举类型元素集的高效实现。由于枚举类型只有有限个实例,所以EnumSet内部用位序列实现。如果对应的值在集中,则相应的位被置为1.
EnumSet类没有公共的构造器。要使用静态工厂方法构造这个集:

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

可以使用Set接口的常用方法来修改EnumSet
EnumMap是一个键类型为枚举类型的映射。它可以直接且高效地实现为一个值数组。需要在构造器中指定键类型:

var personInCharge = new EnumMap<Weekday, Employee>(Weekday.class);
9.4.7标识散列映射(可以用来跟踪哪些对象已经遍历过)

IdentityHashMap有特殊的用途。在这个类中,键的散列值不是用hashCode函数计算的,而是用System.identityHashCode方法计算的。这是Object.hashCode根据对象的内存地址计算散列码时所使用的方法。而且,在对两个对象进行比较时,IdentityHashMap类使用==,而不使用equals
也就是说,不同的键对象即使内容相同,也被视为不同的对象。在实现对象遍历算法(如对象串行化)时,这个类非常有用,可以用来跟踪哪些对象已经遍历过

9.5视图与包装器

可以使用视图获得其他实现了Collection接口或Map接口的对象,并填入映射中的所有键,然后返回这个集。但是,情况并非如此。实际上,keySet方法返回一个实现了Set接口的类对象,由这个类的方法操纵原映射。这种集合称为视图。

9.5.1小集合(这些集合对象是不可修改的)

Java9引入了一些静态方法,可以生成给定元素的集或列表,以及给定键/值对的映射:

List<String> names = List.of("Peter", "Paul", "Mary");
Set<Integer> numbers = Set.of(2, 3, 5);
Map<String, Integer> scores = Map.of("Peter", 2, "Paul", 3, "Mary", 5);
// 等价于
Map<String, Integer> scores = Map.ofEntries(
    Map.entry("Peter", 2),
    Map.entry("Paul", 3),
    Map.entry("Mary", 5)
);

元素、键或值不能为null
需要注意的是,这些集合对象是不可修改的。如果试图改变它们的内容,会导致一个UnsupportedOperationException异常。如果需要一个可更改的集合,可以把这个不可修改的集合传递到构造器

var names = new ArrayList<>(List.of("Peter", "Paul", "Mary"));

以下方法调用:Collections.nCopies(n, anObject)会返回一个实现了List接口的不可变的对象,给人一种错觉:就像有n个元素,每个元素都是一个anObject。例如:

List<String> settings = Collections.nCopies(100, "DEFAULT");
// 将创建一个包含100个字符串的List,每个串都设置为"DEFAULT",
// 这样存储开销很小。对象只存储一次,详见源码。

of方法是java9新引入的。之前有一个静态方法Arrays.asList,它会返回一个可更改但是大小不可变的列表。另外还有遗留的方法Collections.emptySetCollections.singleton

9.5.2子范围(操作会自动反应到整个列表)

可以为很多集合建立子范围视图。可以对子范围应用任何操作,而且操作会自动反应到整个列表。对于有序集和映射,可以使用排序顺序而不是元素位置建立子范围。SortedSet接口声明了3个方法:

SortedSet<E> subSet(E from, E to);
SortedSet<E> headSet(E to);
SortedSet<E> tailSet(E from);

这些方法将返回大于等于from且小于to的所有元素构成的子集。有序映射也有类似的方法:

SortedMap<K, V> subMap(K from, K to);
SortedMap<K, V> headMap(K to);
SortedMap<K, V> tailMap(K from);

这些方法会返回映射视图,该映射包含键落在指定范围内的所有元素。

Java6引入的NavigableSet接口允许更多地控制这些子范围操作。可以指定是否包括边界:

NavigableSet<E> subSet(E from, boolean fromInclusive, E to, boolean toInclusive);
NavigableSet<E> headSet(E to, boolean toInclusive); 
NavigableSet<E> tailSet(E from, boolean fromInclusive);
9.5.3不可修改的视图

Collections类还有几个方法,可以生成集合的不可修改视图。这些视图对现有集合增加了一个运行时检查。如果发现试图对集合进行修改,就抛出一个异常,集合仍保持不变。

Collections.unmodifiableCollection
Collections.unmodifiableList
Collections.unmodifiableSet
Collections.unmodifiableSortedSet
Collections.unmodifiableNavigableSet
Collections.unmodifiableMap
Collections.unmodifiableSortedMap
Collections.unmodifiableNavigableMap
// 假设想要让某些代码查看但不能修改一个集合的内容:
var staff = new LinkedList<String>();
// ...
lookAt(Collections.unmodifiableList(staff));

不可修改的视图并不是集合本身不可更改。仍然可以通过集合的原始引用对集合进行修改。需要注意的是,由于视图只是包装了接口而不是具体的集合对象,所以只能访问接口中定义的方法

9.5.4同步视图(线程同步时使用)

如果从多个线程访问集合,就必须确保集合不会被意外地破坏。类库的设计者使用视图机制来确保常规集合是线程安全的,而没有实现线程安全的集合类。例如:

// 将任何一个映射转换成有同步访问方法的Map
var map = Collections.synchronizedMap(new HashMap<String, Employee>());
// 现在就可以从多线程访问这map对象了。类似get和put等方法都是同步的,
// 即每个方法调用必须完全结束,另一个线程才能调用另一个方法。
9.5.5检查型视图

检查型视图用来对泛型类型可能出现的问题提供调试支持,毕竟实际上将错误类型的元素混入泛型集合中的情况极有可能发生:

var strings = new ArrayList<String>();
ArrayList rawList = strings;
// 运行时检测不到,只有当另一部分代码调用get方法,并将结果强制转换为String时,
// 才会出现一个类强制转换异常。
rawList.add(new Date());

List<String> safeStrings = Collections.checkedList(strings, String.class);
rawList = safeStrings;
rawList.add(new Date());    // Checked list throws a ClassCastException

需要注意的是,检查型视图受限于虚拟机可以完成的运行时检查。例如,对于ArrayList<Pair<String>>,由于虚拟机有一个原始的Pair类,所以无法阻止插入Pair<Date>

9.6算法(更多的是API的使用以及其算法详解,需要的时候翻阅书本即可)
9.7遗留的集合
9.7.1Hashtable

Hashtable方法是同步的。如果对遗留代码的兼容性没有任何要求,就应该使用HashMap。如果需要并发访问,则要使用ConcurrentHashMap

9.7.2枚举

遗留的集合使用Enumeration接口遍历元素序列。Enumeration接口有两个方法,即hasMoreElementsnextElement。这两个方法完全类似于Iterator接口的hasNext方法和next方法。
如果发现遗留的类实现了这个接口,可以使用Collections.list将元素收集到一个ArrayList中:

// LogManager类只是将登录者的名字提供为一个Enumeration
ArrayList<String> loggerNames = Collections.list(LogManager.getLoggerNames());

// 或者,在java9中,可以把一个枚举转换为一个迭代器
LogManager.getLoggerNames().asIterator().forEachRemaining(n -> { /* ... */ });

// 有时还会遇到遗留的方法希望得到枚举参数。静态方法Collections.enumeration
// 将产生一个枚举对象,枚举集合中的元素:
List<InputStream> streams = ...;
var in = new SequenceInputStream(Collections.enumeration(streams));
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值