《Java核心技术》第九章 集合 学习笔记

第九章 集合

9.1 Java集合框架

9.1.1 将集合的接口与实现分离

与现代的数据结构类库的常见情况一样,Java集合类库也将接口(interface)与实现(implementation)分离。

首先,看一下人们熟悉的数据结构——队列(queue)是如何分离的。

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

队列接口的最简形式可能类似下面这样:

public interface Queue<E> {
    
    
    void add(E element);
    E remove();
    int size();
}

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

每一个实现都可以通过一个实现了Queue接口的类表示。如果需要一个循环数组队列,就可以使用****ArrayDeque类。如果需要一个链表队列,就直接使用LinkedList类,这个类实现了Queue接口。

如果想要实现自己的队列类(也许不太可能),会发现扩展AbstractQueue类要比实现Queue接口中的所有方法轻松得多。

9.1.2 Collection接口

在Java类库中,集合类的基本接口是Collection接口。这个接口有两个基本方法:

public interface Collection<E> extends Iterable<E> {
    
    
	Iterator<E> iterator();
	boolean add(E e);
}

add方法用于向集合中添加元素。如果添加元素确实改变了集合就返回true,如果集合没有发生变化就返回false。
iterator方法用于返回一个实现了Iterator接口的对象。可以使用这个迭代器对象依次访问集合中的元素。

9.1.3 迭代器

Iterator接口包含4个方法

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

通过反复调用next方法,可以逐个访问集合中的每个元素。但是,如果到达了集合的末尾,next方法将抛出一个NoSuchElementException。因此,需要在调用next之前调用hasNext方法。如果迭代器对象还有多个供访问的元素,这个方法就返回true。如果想要查看集合中的所有元素,就请求一个迭代器,并在hasNext返回true时反复地调用next方法。

Collection<String> collection = new ArrayList<>();
collection.add("F");
collection.add("G");
// 迭代器遍历
Iterator<String> iterator = collection.iterator();
while (iterator.hasNext()) {
    
    
    String element = iterator.next();
    System.out.println(element);
}
// for each 遍历
for (String el : collection)
{
    
    
    System.out.println(el);
}

当调用next时,迭代器就越过下一个元素,并返回刚刚越过的那个元素的引用。

Iterator接口的remove方法将会删除上次调用next方法时返回的元素。在大多数情况下,在决定删除某个元素之前应该先看一下这个元素是很具有实际意义的。然而,如果想要删除指定位置上的元素,仍然需要越过这个元素。

对next方法和remove方法的调用具有互相依赖性。如果调用remove之前没有调用next将是不合法的。如果这样做,将会抛出一个IllegalStateException异常。

例如,下面是如何删除字符串集合中第一个元素的方法:

Iterator<String> iterator = collection.iterator();
iterator.next();
iterator.remove();
// 如果想删除两个相邻的元素,不能直接地这样调用:iterator.remove(); iterator.remove();
// 必须先调用next越过将要删除的元素。
iterator.next();
iterator.remove();
iterator.next();
iterator.remove();

9.1.5 集合框架中的接口

在这里插入图片描述
Java集合框架为不同类型的集合定义了大量接口。
集合有两个基本接口:Collection和Map。
在集合中使用add来添加元素,map中使用put来添加元素。
在集合中使用迭代器来访问元素,map中使用get来访问元素。

List是一个有序集合(ordered collection)。元素会增加到容器中的特定位置。可以采用两种方式访问元素:使用迭代器访问,或者使用一个整数索引来访问。后一种方法称为随机访问(randomaccess),因为这样可以按任意顺序访问元素。

Set接口等同于Collection接口,不过其方法的行为有更严谨的定义。集(set)的add方法不允许增加重复的元素要适当地定义集的equals方法:只要两个集包含同样的元素就认为是相等的,而不要求这些元素有同样的顺序。hashCode方法的定义要保证包含相同元素的两个集会得到相同的散列码。

SortedSet和SortedMap接口会提供用于排序的比较器对象,这两个接口定义了可以得到集合子集视图的方法。

9.2 具体的集合

在这里插入图片描述

9.2.1 链表

数组和数组列表都有一个重大的缺陷。这就是从数组的中间位置删除一个元素要付出很大的代价,其原因是数组中处于被删除元素之后的所有元素都要向数组的前端移动。

链表却将每个对象存放在独立的结点中。每个结点还存放着序列中下一个结点的引用。在Java程序设计语言中,所有链表实际上都是双向链接的(doubly linked)——即每个结点还存放着指向前驱结点的引用。

如果在某个迭代器修改集合时,另一个迭代器对其进行遍历,一定会出现混乱的状况。

为了避免发生并发修改的异常,请遵循下述简单规则:可以根据需要给容器附加许多的迭代器,但是这些迭代器只能读取列表。另外,再单独附加一个既能读又能写的迭代器。

方法说明

**java.util.List**

// 返回一个列表迭代器,以便用来访问列表中的元素。
ListIterator<E> listIterator();
// 在给定位置添加一个元素。
void add(int i, E element)
// 将某个集合中的所有元素添加到给定位置
void addAll(int i, Collection<? extends E> elements)
// 删除给定位置的元素并返回这个元素。
E remove(int i)
// 获取给定位置的元素。
E get(int i)
// 用新元素取代给定位置的元素,并返回原来那个元素。
E set(int i, E element)
// 返回与指定元素相等的元素在列表中第一次出现的位置,如果没有这样的元素将返回-1。
int indexOf(Object element)
// 返回与指定元素相等的元素在列表中最后一次出现的位置,如果没有这样的元素将返回-1。
int lastIndexOf(Object element)

**java.util.LinkedList**

// 构造一个链表,并将集合中所有的元素添加到这个链表中。
LinkedList(Collection<? extends E> elements)
// 将某个元素添加到列表的头部或尾部。
void addFirst(E element)
void addLast(E element)
// 返回列表头部或尾部的元素。
E getFirst()
E getLast()
// 删除并返回列表头部或尾部的元素。
E removeFirst()
E removeLast()

9.2.2 数组列表

集合类库提供了一种大家熟悉的ArrayList类,这个类也实现了List接口。ArrayList封装了一个动态再分配的对象数组。

为什么要用ArrayList取代Vector呢?原因很简单:Vector类的所有方法都是同步的。可以由两个线程安全地访问一个Vector对象。但是,如果由一个线程访问Vector,代码要在同步操作上耗费大量的时间。这种情况还是很常见的。而ArrayList方法不是同步的,因此,建议在不需要同步时使用ArrayList,而不要使用Vector。

9.2.3 散列集

有一种众所周知的数据结构,可以快速地查找所需要的对象,这就是散列表(hash table)。
散列表为每个对象计算一个整数,称为散列码(hash code)。散列码是由对象的实例域产生的一个整数。

如果自定义类,就要负责实现这个类的hashCode方法。注意,自己实现的hashCode方法应该与equals方法兼容,即如果a.equals(b)为true, a与b必须具有相同的散列码。

在Java中,散列表用链表数组实现每个列表被称为桶(bucket)。要想查找表中对象的位置,就要先计算它的散列码,然后与桶的总数取余,所得到的结果就是保存这个元素的桶的索引。

例如,如果某个对象的散列码为76268,并且有128个桶,对象应该保存在第108号桶中(76268除以128余108)。或许会很幸运,在这个桶中没有其他元素,此时将元素直接插入到桶中就可以了。当然,有时候会遇到桶被占满的情况,这也是不可避免的。这种现象被称为散列冲突(hash collision)。这时,需要用新对象与桶中的所有对象进行比较,查看这个对象是否已经存在。如果散列码是合理且随机分布的,桶的数目也足够大,需要比较的次数就会很少。

在Java SE 8中,桶满时会从链表变为平衡二叉树。如果选择的散列函数不当,会产生很多冲突,或者如果有恶意代码试图在散列表中填充多个有相同散列码的值,这样就能提高性能。

如果大致知道最终会有多少个元素要插入到散列表中,就可以设置桶数。通常,将桶数设置为预计元素个数的75%~150%。

当然,并不是总能够知道需要存储多少个元素的,也有可能最初的估计过低。如果散列表太满,就需要再散列(rehashed)。如果要对散列表再散列**,就需要创建一个桶数更多的表,并将所有元素插入到这个新表中**,然后丢弃原来的表。装**填因子(load factor)决定何时对散列表进行再散列。**例如,如果装填因子为0.75(默认值),而表中超过75%的位置已经填入元素,这个表就会用双倍的桶数自动地进行再散列。对于大多数应用程序来说,装填因子为0.75是比较合理的。

散列表可以用于实现几个重要的数据结构。其中最简单的是set类型。set是没有重复元素的元素集合。set的add方法首先在集中查找要添加的对象,如果不存在,就将这个对象添加进去。Java集合类库提供了一个HashSet类,它实现了基于散列表的集。可以用add方法添加元素。**contains方法已经被重新定义,用来快速地查看是否某个元素已经出现在集中。**它只在某个桶中查找元素,而不必查看集合中的所有元素。

散列集迭代器将依次访问所有的桶。由于散列将元素分散在表的各个位置上,**所以访问它们的顺序几乎是随机的。**只有不关心集合中元素的顺序时才应该使用HashSet。

Set<String> words = new HashSet<>();
words.add("A");
words.add("A");
words.add("B");
words.add("C");
words.add("D");
// 判断是否包含
boolean contains = words.contains("D");
words.remove("D");
// 迭代器遍历
Iterator<String> iter = words.iterator();
while (iter.hasNext())
    System.out.print(iter.next());
// for循环遍历
for (String word : words) {
    
    
    System.out.print(word);
}

9.2.4 树集

TreeSet类与散列集十分类似,不过,它比散列集有所改进。树集是一个有序集合(sorted collection)。可以以任意顺序将元素插入到集合中。在对集合进行遍历时,每个值将自动地按照排序后的顺序呈现

排序是用树结构完成的(当前实现使用的是红黑树(red-black tree)。

每次将一个元素添加到树中时,都被放置在正确的排序位置上。因此,迭代器总是以排好序的顺序访问每个元素。

将一个元素添加到树中要比添加到散列表中慢。与检查数组或链表中的重复元素相比还是快很多。

**如果不需要对数据进行排序,就没有必要付出排序的开销。**更重要的是,对于某些数据来说,对其排序要比散列函数更加困难。散列函数只是将对象适当地打乱存放,而比较却要精确地判别每个对象。

SortedSet<String> sorter = new TreeSet<>();
sorter.add("Bob");
sorter.add("Alice");
sorter.add("Alice");
sorter.add("Dick");
for (String s : sorter)
    System.out.println(s);
// 返回有序集中最大元素
String first = sorter.first();
// 返回有序集中最小元素
String last = sorter.last();

9.2.5 队列与双端队列

有两个端头的队列,即双端队列,可以让人们有效地在头部和尾部同时添加或删除元素。不支持在队列中间添加元素。
引入了Deque接口,并由ArrayDeque和LinkedList类实现。这两个类都提供了双端队列,而且在必要时可以增加队列的长度。

队列Queue的接口定义如下

// 如果队列没有满,将给定的元素添加到这个双端队列的尾部并返回true。如果队列满了,第一个方法将抛出一个IllegalStateException,而第二个方法返回false。
boolean add(E element)
boolean offer(E element)
// 假如队列不空,删除并返回这个队列头部的元素。如果队列是空的,第一个方法抛出NoSuchElementException,而第二个方法返回null。
E remove()
E poll()
// 如果队列不空,返回这个队列头部的元素,但不删除。如果队列空,第一个方法将抛出一个NoSuchElementException,而第二个方法返回null。
E element()
E peek()

双向队列Deque的接口定义如下

// 将给定的对象添加到双端队列的头部或尾部。如果队列满了,前面两个方法将抛出一个IllegalStateException,而后面两个方法返回false。
void addFirst(E element)
void addLast(E element)
boolean offerFirst(E element)
boolean offerLast(E element)
// 如果队列不空,删除并返回队列头部的元素。如果队列为空,前面两个方法将抛出一个NoSuchElementException,而后面两个方法返回null。
E removeFirst()
E removeLast()
E pollFirst()
E pollLast()
// 如果队列非空,返回队列头部的元素,但不删除。如果队列空,前面两个方法将抛出一个NoSuchElementException,而后面两个方法返回null。
E getFirst()
E getLast()
E peekFirst()
E peekLast()

9.2.6 优先级队列

优先级队列(priority queue)中的元素可以按照任意的顺序插入,却总是按照排序的顺序进行检索。也就是说,无论何时调用remove方法,总会获得当前优先级队列中最小的元素。然而,优先级队列并没有对所有的元素进行排序。如果用迭代的方式处理这些元素,并不需要对它们进行排序。

优先级队列使用了一个优雅且高效的数据结构,称为堆(heap)。堆是一个可以自我调整的二叉树,对树执行添加(add)和删除(remore)操作,可以让最小的元素移动到根,而不必花费时间对元素进行排序。

使用优先级队列的典型示例是任务调度。每一个任务有一个优先级,任务以随机顺序添加到队列中。每当启动一个新的任务时,都将优先级最高的任务从队列中删除(由于习惯上将1设为“最高”优先级,所以会将最小的元素删除)。

// 程序清单9-5显示了一个正在运行的优先级队列。
PriorityQueue<LocalDate> priorityQueue = new PriorityQueue<>();
priorityQueue.add(LocalDate.of(1906, 12, 9));
priorityQueue.add(LocalDate.of(1815, 12, 10));
priorityQueue.add(LocalDate.of(1903, 12, 3));
priorityQueue.add(LocalDate.of(1910, 6, 22));

for (LocalDate date : priorityQueue)
    System.out.println(date);

while (!priorityQueue.isEmpty())
{
    
    
    System.out.println(priorityQueue.remove());
}

9.3 映射

映射用来存放键/值对。如果提供了键,就能够查找到值。

9.3.1 基本映射操作

Java类库为映射提供了两个通用的实现:HashMap和TreeMap。这两个类都实现了Map接口。

散列映射对键进行散列,树映射用键的整体顺序对元素进行排序,并将其组织成搜索树。散列或比较函数只能作用于键。与键关联的值不能进行散列或比较。

应该选择散列映射还是树映射呢?与集一样,散列稍微快一些,如果不需要按照排列顺序访问键,就最好选择散列。

**每当往映射中添加对象时,必须同时提供一个键。**要想检索一个对象,必须使用(因而,必须记住)一个键。如果在映射中没有与给定键对应的信息,get将返回null。null返回值可能并不方便。有时可以有一个好的默认值,用作为映射中不存在的键。然后使用getOrDefault方法。

**键必须是唯一的。不能对同一个键存放两个值。**如果对同一个键两次调用put方法,第二个值就会取代第一个值。实际上,put将返回用这个键参数存储的上一个值。

remove方法用于从映射中删除给定键对应的元素。size方法用于返回映射中的元素数。要迭代处理映射的键和值,最容易的方法是使用forEach方法。可以提供一个接收键和值的lambda表达式。映射中的每一项会依序调用这个表达式。

Map<String, Employee> staff = new HashMap<>();
staff.put("144", new Employee("Lee"));
staff.put("598", new Employee("Wang"));
staff.put("787", new Employee("Liu"));
System.out.println(staff);
// 删除
staff.remove("144");
// 更新
staff.put("787", new Employee("Kan"));
// 判断是否有值
boolean con = staff.containsKey("144");
// 获取
System.out.println(staff.get("787"));
// 遍历
staff.forEach((k, v) ->
        System.out.println("key=" + k +",value =" + v));

9.3.2 更新映射项

处理映射时的一个难点就是更新映射项。正常情况下,可以得到与一个键关联的原值,完成更新,再放回更新后的值。不过,必须考虑一个特殊情况,即键第一次出现。

作为一个简单的补救,可以使用getOrDefault方法:
另一种方法是首先调用putIfAbsent方法。只有当键原先存在时才会放入一个值。

Map<String, Integer> counts = new HashMap<>();
counts.put("1", counts.getOrDefault("1", 0) + 1);
counts.putIfAbsent("2", 4);
counts.forEach((k, v) ->
        System.out.println("key" + k + "value "+ v));

9.3.3 映射视图

不过,可以得到映射的视图(view)——这是实现了Collection接口或某个子接口的对象。

有3种视图:键集、值集合(不是一个集)以及键/值对集。键和键/值对可以构成一个集,因为映射中一个键只能有一个副本。

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

9.3.5 链接散列集与映射

LinkedHashSet和LinkedHashMap类用来记住插入元素项的顺序。这样就可以避免在散列表中的项从表面上看是随机排列的。当条目插入到表中时,就会并入到双向链表中。

链接散列映射将用访问顺序,而不是插入顺序,对映射条目进行迭代。每次调用get或put,受到影响的条目将从当前的位置删除,并放到条目链表的尾部。

访问顺序对于实现高速缓存的“最近最少使用”原则十分重要。例如,可能希望将访问频率高的元素放在内存中,而访问频率低的元素则从数据库中读取。当在表中找不到元素项且表又已经满时,可以将迭代器加入到表中,并将枚举的前几个元素删除掉。这些是近期最少使用的几个元素。

9.4 视图与包装器

通过使用视图(views)可以获得其他的实现了Collection接口和Map接口的对象。

9.4.1 轻量级集合包装器

Arrays类的静态方法asList将返回一个包装了普通Java数组的List包装器。这个方法可以将数组传递给一个期望得到列表或集合参数的方法。

Integer[] list = new Integer[52];
List<Integer> l = Arrays.asList(list);

9.4.2 子范围

可以为很多集合建立子范围(subrange)视图。
例如,假设有一个列表staff,想从中取出第10个~第19个元素。可以使用subList方法来获得一个列表的子范围视图。

List<Integer> l2 = staff.subList(10, 20);

9.5 算法

泛型集合接口有一个很大的优点,即算法只需要实现一次。

9.5.1 排序与混排

Collections类中的sort方法可以对实现了List接口的集合进行排序。
果想采用其他方式对列表进行排序,可以使用List接口的sort方法并传入一个Comparator对象。
如果想按照降序对列表进行排序,可以使用一种非常方便的静态方法Collections.reverse-Order()。这个方法将返回一个比较器,比较器则返回b.compareTo(a)。

List<Employee> staff = new LinkedList<>();
Collections.sort(staff);
// 降序排序
staff.sort(Comparator.reverseOrder());
// 按照工资排序
staff.sort(Comparator.comparingDouble(Employee::getSalary));

9.5.2 二分查找

Collections类的binarySearch方法实现了二分查找算法。注意,集合必须是排好序的,否则算法将返回错误的答案。
如果集合没有采用Comparable接口的compareTo方法进行排序,就还要提供一个比较器对象。
如果binarySearch方法返回的数值大于等于0,则表示匹配对象的索引。也就是说,c.get(i)等于在这个比较顺序下的element。如果返回**负值,则表示没有匹配的元素。**但是,可以利用返回值计算应该将element插入到集合的哪个位置,以保持集合的有序性。
插入的位置是

insertionPoint = -i - 1;

List<Integer> integers = new LinkedList<>();
int pos = Collections.binarySearch(integers, 1);

9.6 遗留的集合

9.6.1 Hashtable类

Hashtable类与HashMap类的作用一样,实际上,它们拥有相同的接口。与Vector类的方法一样。Hashtable的方法也是同步的。如果对同步性或与遗留代码的兼容性没有任何要求,就应该使用HashMap。如果需要并发访问,则要使用ConcurrentHashMap

9.6.4 栈

从1.0版开始,标准类库中就包含了Stack类,其中有大家熟悉的push方法和pop方法。但是,Stack类扩展为Vector类,从理论角度看,Vector类并不太令人满意,它可以让栈使用不属于栈操作的insert和remove方法,即可以在任何地方进行插入或删除操作,而不仅仅是在栈顶。

猜你喜欢

转载自blog.csdn.net/qq_17677907/article/details/112471720