21.集合框架【Java温故系列】

Java 最初版本只为最常用的数据结构提供了很少的一组类:Vector、Stack、Hashtable、BitSet与Enumeration接口,其中的Enumeration接口提供了一种用于访问任意容器中各个元素的抽象机制

在 Java 集合类库中,集合的接口和实现是分离的

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

public interface Collection<E>{
    boolean add(E element);    //向集合添加元素,改变了集合返回true,否则返回false
    Iterator<E> iterator();   //返回一个迭代器对象,可以使用此迭代器对象依次访问集合中的元素
    ...
}

1. 迭代器

迭代器对象:实现了 Iterator 接口的对象,Iterator 接口包含4个方法:

public interface Iterator<E>{
    E next();      //调用next时,迭代器就越过下一个元素,并返回刚刚越过那个元素的引用
    boolean hasNext();
    void remove();
    default void forEachRemaining(Consumer<? super E> action);
}

通过反复调用 next 方法可以逐个访问集合中的每个元素。但是,到达集合的末尾,next 方法将抛出一个 NoSuchElementException。因此,在调用 next 方法之前调用 hasNext 方法。若迭代器对象中还有多个供访问的元素,hasNext 方法就返回 true.

元素被访问的顺序取决于集合类型。如果对 ArrayList 进行迭代,迭代器将从索引 0 开始,每迭代一次,索引值加 1,;然而,如果访问 HashSet 中的元素,每个元素将会按照某种随机的次序出现。虽然可以确定在迭代过程中能够遍历到集合中的所有元素,但却无法预知元素被访问的次序。

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

Iterator<String> it = c.iterator();
it.next();
it.remove();

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PFWjgdhb-1599577340046)(C:\Users\86136\AppData\Roaming\Typora\typora-user-images\image-20200830225859863.png)]

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

如果想要删除两个相邻的元素,不能直接调用:

it.remove();
it.remove();

相反地,必须先调用 next 越过将要删除的元素:

it.remove();
it.next();
it.remove();

2.泛型实用方法

Collection 和 Iterator 都是泛型接口,可以编写操作任何集合类型的实用方法。

例如,以下是一个检测任意集合是否包含指定元素的泛型方法:

public static <E> boolean contains(Collection<E> c,Object obj){
    for(E element:c){
        if(elements.equals(obj)){
            return true;
        }
    }
    return false;
}

Collection 接口声明了很多有用的方法,它的所有实现类都实现了这些方法。如下(常用的方法):

  • Iterator<E> iterator() 返回一个用于访问集合中每个元素的迭代器
  • int size() 返回当前存储在集合中的元素个数
  • boolean isEmpty() 如果集合中没有元素,则返回true
  • boolean contains(Object obj) 如果集合中包含了一个与obj相等(equal)的对象,返回true
  • boolean containsAll(Collection<?> other) 如果这个集合中包含other集合中的所有元素,返回true
  • boolean add(Object element) 将一个元素添加到集合中。如果由于这个调用改变了集合,返回true
  • boolean addAll(Collection<? extends E> other) 将other集合中的所有元素添加到这个集合
  • boolean remove(Object obj) 从这个集合中删除等于obj的对象。如果有匹配的对象被删除,返回true
  • boolean removeAll(Collection<?> other) 从这个集合中删除other集合中存在的所有元素
  • void clear() 从这个集合中删除所有元素
  • boolean retainAll(Collection<?> other) 从这个集合中删除所有与other集合中的元素不同的元素。如果由于这个调用改变了集合,返回true
  • Object[] toArray() 返回这个集合的对象数组

若实现 Collection 接口的每一个类都要具体实现这么多的方法是不可能的。为了能够让实现者更容器实现这个接口,Java 类库提供了一个类 AbstractCollection,它将基础方法 size 和iterator 抽象化了,但实现了其他的一些方法:

public abstract class AbstractCollection<E> implements Collection<E>{
    ...
    public abstract Iterator<E> iterator();
    public abstract int size();
    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;
    }
}

于是,一个具体的集合类可以扩展 AbstractCollection 类,由具体的集合类实现 iterator 和 size 方法,而其他的一些方法都可以由AbstractCollection 超类实现。


3.集合框架中的接口

Java 集合框架为不同类型的集合定义了大量接口:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vxdKFk1r-1599577340047)(C:\Users\86136\AppData\Roaming\Typora\typora-user-images\image-20200903145728011.png)]

其中,Collection 和 Map 是两个基本接口。

Map(映射)包含键值对(key-value),所以向 Map 中插入和读取元素的方法:

V put(K key,V value);
V get(K key);

List 是一个有序集合。它的元素会增加到容器中的特定位置。

可以采用两种方式访问 List 的元素:使用迭代器访问,或者使用一个整数索引来访问。后一种方法称为随机访问,因为这样可以按任意顺序访问元素。与之不同,使用迭代器访问时,必须按照集合的元素顺序访问元素。

List 接口定义了多个用于随机访问的方法:

void add(int index,E element);
void remove(int index);
E get(int index);
E set(int index,E element);

ListIterator 接口是 Iterator 的一个子接口。它定义了一个方法用于在迭代器位置前面添加一个元素:

void add(E element);

坦率地讲,List 有个方面设计得很不好:实际上有两种有序集合,其性能开销有很大差异。由数组支持的有序集合可以快速地随机访问,因此适合使用 List 的方法并提供一个整数索引来访问;与之不同,链表尽管也是有序的,但是随机访问很慢,所以最好使用迭代器来遍历。

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

Java 库中的具体集合:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6cuF30x7-1599577340050)(C:\Users\86136\AppData\Roaming\Typora\typora-user-images\image-20200903160334672.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nZysucZu-1599577340052)(C:\Users\86136\AppData\Roaming\Typora\typora-user-images\image-20200903160359207.png)]


4.链表

数组和动态的ArrayList类(数组列表)都有一个重大的缺陷:要从数组中间位置删除一个元素要付出很大的代价,其原因是数组中处于被删除元素之后的所有元素都要向数组的前端移动。在数组中间的位置上插入一个元素也是如此。

但链表(linked list)这种数据结构却解决了这个问题。

数组是在连续的存储位置上存放对象引用;但链表是将每个对象存放在独立的结点中,每个结点还存放着序列中下一个结点的引用

在 Java 中,所有链表实际上都是双向链接的——即每个结点还存放着指向前驱结点的引用。

从链表中间删除一个元素是一个很轻松的操作,即只需要更新被删除元素附近的链接:

List<String> staff = new LinkedList<>();
staff.add("Amy");
staff.add("Bob");
staff.add("Carl");
Iterator<String> iterator = staff.iterator();
String first = iterator.next();   //链表的第一个元素
String second = iterator.next();   //链表的第二个元素
iterator.remove();   //删除最近访问的元素(即第二个元素)
System.out.println(Arrays.toString(staff.toArray()));  //打印链表元素

链表是一个有序集合,它的每个元素的位置十分重要。LinkedList.add 方法将新的元素添加到链表的尾部。但常常需要将新元素添加到链表的中间。前面了解到,迭代器正是描述集合中位置的,所以这种在链表中间添加元素依赖集合位置的 add 方法将由迭代器提供(只有对自然有序的集合使用迭代器添加元素才有实际意义,所以 Iterator 接口中是没有 add 方法的)。

但 Iterator 有一个子接口 ListIterator ,其中包含 add 方法:

interface ListIterator<E> extends Iterator<E>{
    void add(E element);   //它假定这个添加操作总会改变链表
    ...
}

ListIterator 接口中还有两个方法,可以用来反向遍历链表:

E previous();
boolean hasPrevious();

与 next 方法一样,previous 方法返回越过的集合元素

LinkedList 类的 listIterator 方法返回了一个实现了 ListIterator 接口的迭代器对象。

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

ListIterator 的 add 方法在迭代器位置之前添加一个新元素:

List<String> staff = new LinkedList<>();
staff.add("Amy");
staff.add("Bob");
staff.add("Carl");
ListIterator<String> iter = staff.listIterator();
iter.next();
iter.add("Juliet");  //在该链表的第一个元素之后,第二个元素之前插入新元素 "Juliet"

如果多次调用 add 方法,将按照提供的次序把元素添加到链表中。它们被依次添加到迭代器当前位置之前。

当用一个刚刚由 listIterator 方法返回,并且指向链表表头的迭代器调用 add 操作时,新添加的元素将变成列表的新表头。当迭代器越过链表的最后一个元素时(即 hasNext 返回 false),add 新添加的元素将变成列表的新表尾。

在调用 next 或 previous 方法时,需要注意 remove 操作:在调用 next 之后,remove 方法删除迭代器左侧的元素;在调用 previous 之后,remove 方法则删除迭代器右侧的元素。

:不能连续调用两次 remove

add 方法只依赖于迭代器的位置;而 remove 方法依赖于迭代器的状态。

listIterator 还有一个 set 方法用于替换集合中的元素:用一个新元素取代调用 next 或 previous 方法返回的上一个元素:

ListIterator<String> iter = staff.listIterator();
String oldValue = iter.next();  //返回链表的第一个元素
iter.set(newValue);  //使用新元素取代链表的第一个元素

可以想象,如果在某个迭代器修改集合时,另一个迭代器对其进行遍历,一定会出现混乱的状况。例如 , 一个迭代器指向另一个迭代器刚刚删除的元素前面,现在这个迭代器就是无效的,并且不应该再使用。链表迭代器的设计使它能够检测到这种修改。如果迭代器发现它的集合被另一个迭代器修改了,或是被该集合自身的方法修改了,就会抛出一个 ConcurrentModificationException 异常 。

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

有一种简单的方法可以检测到并发修改的问题。集合可以跟踪改写操作( 诸如添加或删除元素 )的次数。每个迭代器都维护一个独立的计数值。在每个迭代器方法的开始处检查自己改写操作的计数值是否与集合的改写操作计数值一致。如果不一致,抛出异常。

:对于并发修改列表的检测肴一个奇怪的例外。链表只负责跟踪对列表的结构性修改,例如,添加元素、删除元素。set 方法不被视为结构性修改。

链表不支持快速地随机访问。如果要查看链表中第 n 个元素,就必须从头开始,越过个元素。没有捷径可走。鉴于这个原因,在程序需要采用整数索引访问元素时,通常不选用链表。

尽管如此,LinkedList 类还是提供了一个用来访问某个特定元素的 get 方法:

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

当然,这个方法效率并不高。而且,不应该使用 get 方法来遍历链表。

:get 方法做了微小的优化:如果索引大于 size()/2 就从列表尾端开始搜索元素。

列表迭代器接口还有一个方法,可以告之当前位置的索引。实际上,从概念上讲,由于 Java 迭代器指向两个元素之间的位置,所以可以同时产生两个索引:nextlndex 方法返回下一次调用 next 方法时返回元素的整数索引;previouslndex 方法返回下一次调用 previous 方法时返回元素的整数索引。当然,这个索引只比 nextlndex 返回的索引值小 1。这两个方法的效率非常高,这是因为迭代器保持着当前位置的计数值 。

如果有一个整数索引 n , list.listlterator(n) 将返回一个迭代器,这个迭代器指向索引为 n 的元素前面的位置。也就是说,调用 next 与调用 list.get(n) 会产生同一个元素,只是获得这个迭代器的效率比较低。

如果链表中只有很少几个元素,就完全没必要为使用整数索引(get 和 set 方法)的开销而烦恼。但建议:避免使用以整数索引表示链表中位置的所有方法

使用链表的唯一理由:尽可能地减少在列表中间插入或删除元素所付出的代价(如果列表只有少数几个元素,就完全可以使用 ArrayList)。


5.数组列表

数组列表(ArrayList)实现了 List 接口,它封装了一个动态再分配的对象数组。

:Vector 类也是一个动态数组,但它的所有方法都是同步的。可以由两个线程安全地访问一个 Vector 对象,但是,如果由一个线程访问 Vector,代码要在同步操作上耗费大量时间。而 ArrayList 的方法不是同步的。因此,建议在不需要同步时使用 ArrayList,而不要使用 Vector.


6.散列集

如果需要查看某个指定的元素,却又不知道它的位置,就需要访问所有元素,直到找到该元素为止。如果集合中包含的元素很多,将会消耗很多时间。如果不在意元素的顺序,有几种数据结构能够快速查找元素,其缺点是无法控制元素出现的次序。

散列表(hash table)能快速地查找需要的对象。散列表为每个对象计算一个整数,称为散列码 (hash code)。散列码是由对象的实例域产生的一个整数(如 String 对象就由 String 类的 hashCode 方法产生散列码)。更准确地说,具有不同数据域的对象将产生不同的散列码。

在 Java 中,散列表由链表数组实现。


7.树集

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


8.队列和双端队列

队列可以让人们有效地在尾部添加一个元素,在头部删除一个元素。有两个端头的队列,即双端队列,可以让人们有效地在头部和尾部同时添加或删除元素。不支持在队列中间添加元素。

在 Java SE 6 中引入了 Deque 接口,并由 ArrayDeque 和 LinkedList 类实现。这两个类都提供了双端队列,而且在必要时可以增加队列的长度。


在这里插入图片描述


9.优先级队列

优先级队列(priority queue)中的元素可以按照任意的顺序插人,却总是按照排序的顺序进行检索。也就是说,无论何时调用 remove 方法,总会获得当前优先级队列中最小的元素。

然而,优先级队列并没有对所有的元素进行排序。如果用迭代的方式处理这些元素,并不需要对它们进行排序。

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

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


10.映射

映射(map)这种数据结构用来存放键/值对。如果提供了键,就能够查找到值。

(1)基本映射操作

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

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

在映射的选择上:和集一样,散列稍微快一些,如果不需要按照排列顺序访问键,就最好选择散列。

下列代码将为存储的员工信息建立一个散列映射:

Map<String,Employee> staff = new HashMap<>();
Employee harry = new Employee("Harry Hacker");
staff.put("123",harry);   //键为员工的id,值为Employee对象
...

每当往映射中添加对象时,必须同时提供一个键。而要想检索一个对象,必须使用一个对应的键:

String id = "123";
e = staff.get(id);   //获取一个Employee对象

如果在映射中没有与给定键对应的信息,get 方法将返回 null.

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

remove 方法用于从映射中删除给定键对应的元素。size 方法用于返回映射中的元素数。

要迭代处理映射的键和值,最容易的方法是使用 forEach 方法,。可以提供一个接收键和值的lambda表达式。映射中的每一项都会依序调用这个表达式:

staff.forEach((k,v) ->
    System.out.println("key=" + k + ",value=" + v));

(2)更新映射项

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

下面的例子中,使用一个映射统计一个单词在文件中出现的频度:

counts.put(word,couns.get(word)+1);  //当word出现时,将该单词对应的计数器+1

一般来说这样是可以的,不过有一种情况除外:就是word第一次出现时。这种情况下,get 会返回 null,因此会出现一个空指针异常。

作为一个简单的补救,可以使用 getOrDefault 方法:

counts.put(word,counts.getOrDefault(word,0)+1);   //当映射中没能找到word这个key,就返回0

另一个方法是先调用 putIfAbsent 方法:

counts.putIfAbsent(word,0);   //如果map集合中没有该key对应的值,则直接添加,并返回null,如果已经存在对应的值,则依旧为原来的值
counts.put(word,couns.get(word)+1);

不过可以做的更好。merge 方法可以更加简化这个操作:

counts.merge(word,1,Integer::sum);

//merge 方法源码如下:
default V merge(K key, V value,
                BiFunction<? super V, ? super V, ? extends V> remappingFunction) {
    Objects.requireNonNull(remappingFunction);
    Objects.requireNonNull(value);
    V oldValue = get(key);
    //如果oldValue为null,newValue就等于value,否则调用传递的函数将旧值与新值进行函数运算
    V newValue = (oldValue == null) ? value :remappingFunction.apply(oldValue, value);
    if(newValue == null) {
        remove(key);
    } else {
        //核心依然是使用put方法
        put(key, newValue);
    }
    return newValue;
}
(3)映射视图

集合框架不认为映射本身是一个集合(其他数据结构框架认为映射是一个键/值对集合,或者是由键索引的值集合)。不过,映射的视图(view)–是实现了 Collection 接口或它的某个子接口的对象。

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

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

以上三个方法可以获取上述的3个视图。

Set 接口扩展了 Collection 接口,因此,可以使用集合一样使用 Set。

例如,枚举一个映射的所有键:

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

如果想要同时查看键和值,可以通过枚举条目来避免查找值:

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

:上述是原先访问所有映射条目(key-value)最高效的方法。如今,只需要使用 forEach :

staff.forEach((k,v)->{
    ...
});

如果在键集视图上调用迭代器的 remove 方法,实际上会从映射中删除这个键和与它关联的值;另外,不能向键集视图增加元素,如果试图调用 add 方法,它会抛出一个 UnsupportedOperationException。键值对集也有同样的限制。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值