《JAVA核心技术 卷I》第九章 - 集合

第九章 - 集合

1. 集合框架中的接口

  • Java集合类库将接口(interface)和实现(implementation)分离,接口并不会说明某种数据结构是如何实现的,它只会提供实现类应该具有的方法名。一旦已经构造了集合,就不需要知道究竟使用了哪种实现。因此,只有在构造集合对象时,才会使用具体的类。

    //可以使用接口类型存放集合引用
    Queue<Customer> expressLane = new CircularArrayQueue<>(100);
    expressLane.add(new Customer("Harry"));
    

    这样做的好处是,一旦改变了想法,就可以轻松的使用另外一种不同的实现,只需要修改调用构造器的地方

    Queue<Customer> expressLane = new LinkedListQueue<>();
    expressLane.add(new Customer("Harry"));
    

1.1 接口概述

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

  • Collection & Map

    集合有两个基本接口,Collection和Map,可以使用boolean add(E element)在集合中插入元素

    但是,由于映射(Map)包含键值对,映射要用V put(K key,V value)来插入元素

    要从集合读取元素,可以使用迭代器访问元素。但是,从映射中读取值要用V get(K key)

  • List

    List是一个有序集合(ordered collection)。元素会增加到容器中的特定位置。可以采用两种方式访问元素:迭代器访问和随机访问(random access)。迭代器访问必须顺序的访问元素;随机访问可以使用一个整数索引来访问,这样可以按任意顺序访问元素

    ListIterator接口是Iterator的一个子接口。它定义了一个void add(E element)方法用于在迭代器位置前面增加一个元素

  • Set

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

  • 特殊的Set和Map

    SortedSet和SortedMap接口会提供用于排序的比较器对象,这两个接口定义了可以得到集合子集视图的方法。NegableSet和NegableMap,其中包含一些用于搜索和遍历有序集和映射的方法

1.2 Collection接口

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

    public interface Collection<E>
    {
        boolean add(E element);
        Iterator<E> iterator;
        //...
    }
    

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

1.2.1 Iterator接口
  • Iterator接口包含四个方法

    public interface Iterator<E>
    {
        E next();
        boolean hasNext();
        void remove();
        default void forEachReaming(Consumer<? super E> action);
    }
    
    • next(),hasNext():通过反复调用next方法,可以逐个访问集合中的每个元素。如果到达了集合的末尾,next方法将会抛出一个NoSuchElementException。因此,需要在调用next之前调用hasNext方法。如果迭代器对象还有多个可以访问的元素,hasNext方法就会返回true。

      Java迭代器中,查找一个元素的唯一方法是调用next,而在执行查找操作的同时,迭代器的位置就会随之向前移动。可以认为Java迭代器位于两个元素之间。当调用next时,迭代器就越过下一个元素,并返回刚刚越过的那个元素的引用。

    • forEachRemaining():调用forEachRemaining方法并提供一个lambda表达式,将对迭代器的每一个元素调用这个lambda表达式,直到再没有元素为止

      iterator.forEachRemaining(element -> ......);
      
    • remove():Iterator接口的remove方法将会删除上次调用next方法时返回的元素。next方法和remove方法调用之间存在依赖性,如果调用remove之前没有调用next,将是不合法的。如果这样做,将会抛出一个IllegalStateException异常

  • 由于Collection与Iterator都是泛型接口,这意味着可以编写处理任何集合类型的实用方法。实际上,Collection接口中已经声明了很多有用的方法,所有的实现类都必须提供这些方法(如contains,toArray等)。但如果实现Collection接口的每一个类都要提供如此多的方法,会很繁琐。Java类库提供了一个类AbstractCollection,它保持基础方法size和iterator仍为抽象方法,但是为实现者实现了其它例行方法。

2. 具体集合

2.1 链表(LinkedList)

  • 在Java程序设计语言中,所有链表实际上都是双向链接(doubly linked)的,即每个链接还存放着其前驱的引用。链表和泛型集合之间有一个重要的区别。链表是一个有序集合,每个对象的位置十分重要。LinkedList.add()方法将对象添加到链表的尾部。但是,常常需要将元素添加到链表的中间。由于迭代器描述了集合中的位置,所以这种依赖于位置的add方法将有迭代器负责。

    只有对自然有序的集合使用迭代器添加元素才有实际意义,例如集(set)数据类型中,元素是完全无序的。这就是为什么Iterator接口中没有add方法。集合类库提供了一个子接口ListIterator,其中包含add方法

    interface ListIterator<E> extends Iterator<E>
    {
        //与Collection.add()不同,这个add方法不返回boolean类型的值,它假设add操作总会改变链表
        void add(E element);
        
        //以下方法用来反向遍历链表;与next方法一样,previous方法返回越过的对象
        boolean hasPrevious();
        E previous();
        boolean hasNext();
        E next();
        
        //下列方法用来操作链表元素
        void remove();
        //set方法用一个新元素替换调用next或previous方法返回的上一个元素
        void set(E e);
        //add方法在迭代器位置之前添加一个新对象(注意区分迭代器.add和LinkedList.add之间的区别)
        void add(E e);
        
        //nextIndex方法返回下一次调用next方法所返回元素的整数索引;previousIndex方法返回下一次调用previous方法时所返回元素的整数索引。
        //如果有一个整数索引n,list.listIterator(n)将返回一个迭代器,这个迭代器指向索引为n的元素前面的位置。
        int nextIndex();
        int previousIndex();
    }
    
    //LinkedList类的listIterator方法返回一个实现了ListIterator接口的迭代器对象
    ListIterator<String> iter = staff.listIterator();
    
  • 在使用链表的remove操作时要格外小心,在调用next之后,remove方法和一般一样会删除迭代器左侧的元素。但是如果调用了previous,就会将右侧的元素删除。不能连续调用两次remove。add方法只依赖于迭代器的位置,而remove方法不同,它依赖于迭代器的状态。

  • 如果一个迭代器发现它的集合被另一个迭代器修改了,或是被该集合自身的某个方法修改了,就会抛出一个ConcurrentModificationException异常。为了避免发生并发修改异常,可以遵循一个规则:可以根据需要为一个集合关联多个迭代器,前提是这些迭代器只能读取集合。或者,可以再关联一个能同时读写的迭代器。

    对于并发修改的检测有一个例外,链表只跟踪对列表的结构性修改,set方法不被视为结构性修改,可以为一个链表关联多个迭代器,所有的迭代器都调用set方法修改现有链接的内容。

  • 绝对不要使用这个"虚假的"随机访问来遍历链表,每次查找一个元素都要从列表的头部重新开始搜索。LinkedList对象根本没有缓存位置信息。

    for(int i = 0;i < list.size();i++){
        //....
    }
    

2.2 数组列表(ArrayList)

  • 建议避免使用以整数索引表示链表中位置的所有方法。如果需要对集合进行随机访问,就是用数组或ArrayList,而不要使用链表。ArrayList类也实现了这个接口。ArrayList封装了一个动态再分配的对象数组。
  • 在需要动态数组的时候,可能会使用Vector类。Vector类的所有方法都是同步的,可以安全的从两个线程访问一个Vector对象。与之不同的是ArrayList方法,它不是同步的,但是性能要远高于Vector类。建议在不需要同步时使用ArrayList,而不要使用Vector

2.3 散列集(Hash)

  • 在Java中,散列表用链表数组实现。每个列表被称为"桶"(bucket)。要想查找表中对象的位置,就需要先计算它的散列码,然后与桶的总数取余,所得到的结果就是保存这个元素的桶的索引。有时候会遇到桶已经被填充的情况。这种现象被称为散列冲突。这时,需要将新对象与桶中的所有对象进行比较,查看这个对象是否已经存在

    如果想要更多的控制散列表的性能,可以指定一个初始的桶数。桶数是指用于收集有相同散列值的桶的数目。如果要插入到散列表中的元素太多,就会增加冲突数量,降低检索性能。如果大致知道最终会有多少个元素要插入到散列表中,就可以设置桶数。一般,将桶数设置为预计元素个数的75%~150%。标准类库使用的桶数是2的幂,默认值为16

  • 如果散列表太满,就需要再散列(rehashed)。如果要对散列表进行再散列,就需要创建一个桶数更多的表,并将所有元素插入到这个新表中,然后丢弃原来的表。装填因子(load factor)可以确定何时对散列表进行散列。例如,如果装填因子为0.75(默认值),说明表中已经填满了75%以上,就会自动再散列,新表的桶数是原来的两倍。

    在Java8中,桶满时会从链表变为平衡二叉树,如果选择的散列函数不好,会产生很多冲突。

  • 在更改集中的元素时要小心,如果元素的散列码发生了改变,元素在数据结构中的位置也会发生变化

2.4 树集(TreeSet)

  • TreeSet类可以以任意顺序将元素插入到集合中,在对集合进行遍历的时候,值将自动地按照排序后的顺序呈现。每次将一个元素添加到树中时,都会将其放置在正确的排序位置上。因此,迭代器总是以有序的顺序访问每个元素。树的排序顺序必须是全序,也就是说,任意两个元素必须是可比的,并且只有在两个元素相等时结果才为0
  • 关于何时使用树集,何时使用散列表:
    1. 将一个元素添加到树中要比添加到散列表中慢,但使用树查找元素会快很多
    2. 如果不需要数据是有序的,就没必要使用树,树的排序会造成性能开销
    3. 对于某些数据来说,对其进行排序要比给出一个散列函数更难

2.5 队列(Deque)

  • 双端队列(deque)允许在头部和尾部添加或删除元素,不支持在队列中间添加元素。ArrayDeque和LinkedList类都实现了Deque接口。这两个类都可以提供双端队列,其大小可以根据需要扩展。
  • 优先队列(priority queue)使用了堆(heap)的数据结构。其中的元素可以按照任意的顺序插入,但会按照有序的顺序进行检索。也就是说,无论何时调用remove方法,总会获得当前优先队列中最小的元素。与TreeSet中的迭代不同,优先队列的迭代并不是按照有序顺序来访问元素。但是删除操作却总时删除剩余元素中最小的那个元素。

2.6 映射(Map)

2.6.1 映射基本操作
  • 通常,我们知道某些关键信息,希望查找与之关联的元素。映射(map)数据结构就是为此设计的。映射用来存放键值对。如果提供了键,就能查找到值。

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

    //每当往映射中添加一个对象的时候,必须同时提供一个键。在这里,键是一个字符串,对应的值是Employee对象
    HashMap<String, Employee> staff = new HashMap<>();
    Employee harry = new Employee("Harry");
    staff.put("999",harry);
    
    //想要检索一个对象,就必须使用键如果映射中没有储存与给定键对应的信息,get将返回null
    String id = 999;
    Employee e = staff.get(id);
    
    //要迭代处理映射的键和值,可以使用forEach方法。可以提供一个接收键和值的lambda表达式。映射中的每一项会依序调用这个表达式
    scores.forEach((k,v) -> System.out.println("key =" + k = "value = " + v));
    

    键必须是唯一的。不能对同一个键存放两个值。如果对同一个键调用两次put方法,第二个值就会取代第一个值。实际上,put将会返回与这个键参数关联的上一个值。remove方法从映射中删除给定键对应的元素。size方法返回映射中的元素数。

2.6.2 映射视图
  • 集合框架不认为映射本身是一个集合。不过可以得到映射的视图(view),这是实现了Collection接口或某个子接口的对象。有三种视图:键集,值集合,以及键/值对集。下面三个方法会分别返回三个视图:

    Set<K> keySet();
    Collection<V> values();	//注意,值集合不是一个集(set)
    Set<Map.Entry<K,V>> entrySet();
    

    keySet不是HashSet或TreeSet,而是实现了Set接口的另外某个类的对象。Set接口扩展了Collection接口。因此,可以像使用任何集合一样使用keySet。例如,想要同时查看键和值,可以通过枚举映射条目来避免查找值

    for(map.Entry<String,Employee> entry : staff.entrySet())
    {
        String k = entry.getKey();
        Employee v = entry = getValue();
        //....
    }
    //或者简化一些
    map.forEach((k,v) -> {
        //....
    })
    

    如果在键集视图上调用迭代器的remove方法,实际上会从映射删除这个键和与之关联的值。不能向键集视图中添加元素,如果添加一个键而没有同时添加值是没有意义的。如果试图调用add方法,就会抛出一个UnsupportedOperationException。映射条目集视图也有同样的限制。

2.6.3 弱散列映射
  • 弱散列映射(WeakHashMap)可以解决部分关于键的回收问题。假设对于某个键的最后一个引用已经消失,那么不再有任何途径可以引用这个值的对象,也就无法从映射中删除这个键值对。此时只能寄希望于垃圾回收器,但是垃圾回收器会跟踪活动的对象,只要映射对象是活动的,其中的所有桶也都是活动的,它们不能被回收。因此,需要由程序(手动)从长期存活的映射表中删除那些无用的值,WeakHashMap就在此处发挥作用。当对键的唯一引用来自散列表映射条目时,这个数据结构将于垃圾回收器协同工作一起删除键值对。

    WeakHashMap使用弱引用(weak reference)保存键。WeakReference对象将包含对散列表键的引用,当这个对象只能由WeakReference引用的时候,垃圾回收器在将其回收后,会将引用这个对象的弱引用放入一个队列。WeakHashMap将周期性的检查队列,一边找出新添加的弱引用。一个弱引用进入这个队列意味着这个键不再被他人使用,并且已经回收。于是,WeakHashMap将删除相关联的映射条目。

2.6.4 链接散列集与映射
  • LinkedHashMap和LinkedHashSet类会记住插入元素项的顺序。这样就可以避免散列表中的项看起来是随机的。在表中插入元素项的时候,就会并入到双向链表中。或者链接散列映射可以选择使用访问顺序,而不是插入顺序来迭代处理映射条目:每次链接散列映射调用get或put时,受到影响的项将从当前的位置删除,并放到项链表的尾部(只影响项在链表中的位置,而散列表的桶不会受影响。映射条目总是在键散列码对应的桶中)。要构造这样一个散列映射,需要调用:

    LinkedHashMap<K, V>(initialCapacity, loadFactor, true);
    
2.6.5 枚举集与映射
  • EnumSet是一个枚举类型元素集的高效实现。由于枚举类型只有有限个实例,所以EnumSet内部用位序列实现(?)。如果对应的值在集中,则相应的位被置为1。EnumSet类没有公共的构造器。要使用静态工厂方法构造这个集:

    enum Weekday {MONDAY,TUSEDAY,....};
    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.WENESDAY,Weekday.FRIDAY);
    

    也可以使用Set接口的常用方法来修改EnumSet

  • EnumMap是一个键类型位枚举类型的映射。它可以直接且高效地实现为一个值数组

    EnumMap personInCharge = new EnumMap<Weekday,Employee>(Weekday.class);
    
2.6.6 标识散列映射
  • IdentityHashMap类中,键的散列值不是用hashCode函数计算的,而是用System.identityHashCode方法计算的。这是Object.hashCode根据对象的内存地址计算散列码时所使用的方法。在两个对象进行比较的时候,IdentityHashMap类使用==,而不使用equals。这意味着,不同的键对象即使内容相同,也被视为不同的对象。在实现对象遍历算法地时候,这个类很有用,可以用来跟踪哪些对象已经遍历过。

3. 视图与包装器

  • 初看起来,视图只是创建了一个新集,并填入映射中的所有键,然后返回这个集。但是实际并非如此,事实上,keySet方法返回了一个实现了Set接口的类对象,由这个类的方法操作原映射。这种集合称为视图

  • Collections类包含很多使用方法,这些方法的参数和返回值都是集合。不要将它与Collection接口混淆

3.1 子范围

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

    List<Employee> group = staff.subList(10,20);
    

    可以对子范围应用任何操作,且操作会自动反映到整个列表

    对于有序集和映射,可以使用排序顺序,而不是元素位置建立子范围

    //SortedSet接口
    SortedSet<E> subSet(E from, E to);
    SortedSet<E> headSet(E to);
    SortedSet<E> tailSet(E from);
    //这些方法将返回大于等于from且小于to的所有元素构成的子集
    
    //SortedMap接口(有序映射)
    SortedMap<K, V> subMap(K from, K to);
    SortedMap<K, V> headMap(K to);
    SortedMap<K, V> tailMap(K from);
    //这些方法会返回映射视图,该映射包含键落在指定范围内的所有元素
    

3.2 不可修改视图

  • Collections类还有几个方法,可以生成集合的不可修改视图(unmodifiable view)。这些视图对现有集合增加了一个运行时检查。如果发现试图对集合进行修改,就抛出一个异常,集合仍保持不变。一共有8个方法,每个方法都处理一种接口。例如,Collections.unmodifiableList处理ArraysList,LinkedList或者实现了List接口的其它类。

    假设想要让你的某些代码查看但不能修改一个集合的内容,就可以进行如下操作

    LinkedList staff = new LinkedList<String>();
    lookAt(Collections.unmodifiableList(staff));
    

    Collections.unmodifiableList方法将返回一个实现List接口的类对象。其访问器方法将从staff集合中获取值。当然,lookAt方法可以调用List接口中的所有方法,而不局限于访问器。但是所有的更改器方法(如add)已经被重新定义为抛出一个UnsupportedOperationException异常,而不是将调用传递给底层集合。

    不可修改的视图并不是集合本身不可修改。仍然可以通过集合的原始引用对集合进行修改,并且仍然可以对集合的元素调用更改器方法。由于视图只是包装了接口而不是具体的集合对象,所以只能访问接口中定义的方法。例如LinkedList类中有一些便利方法,它们都不是List接口方法,不能通过不可修改的视图访问这些方法。

    unmodifiableCollection方法将返回一个集合,它的equals方法不调用底层集合的equals方法,而是继承了Object类的equals方法。这意味着将集或列表转换成集合,就再也无法检测其内容是否相同了。视图就采用这种工作方式,因为相等性检测在层次结构的这一层上没有明确定义。视图将以同样的方式处理hashCode方法。不过,unmodifiableSet和unmodifiableList方法却会使用底层集合的equals方法和hashCode方法。

3.3 同步视图

  • 类库的设计者使用视图机制来确保常规集合是线程安全的,而没有实现线程安全的集合类。例如,Collections类的静态sunchroniazedMap方法可以将任何一个映射转换成有同步访问方法的Map

    var map = Collections.synchronizedMap(new HashMap<String, Employee>());
    

4. 算法

4.1 排序与混排

  • Collections类中的sort方法可以对实现了List接口的集合进行排序

    LinkedList<String> staff = new LinkedList<>();
    //填充元素
    Collections.sort(staff);
    

    这个方法假定列表元素实现了Comparable接口。如果想采用其它方式对列表进行排序,可以使用List接口的sort方法并传入一个Comparator对象。可以如下按工资对一个员工列表排序:

    staff.sort(Comparator.comparingDouble(Employee::getSalary));
    

    如果想要按照降序对列表进行排序,可以使用静态的便利方法Collections.reverseOrder()。这个方法将返回一个比较器,比较器则返回b.compareTo(a)

    staff.sort(Comparator.reverseOrder());
    
  • Java集合类库中排序算法和主流的处理方法不一样。首先Java集合类库没有对链表做排序优化处理,它只是将其复制到数组中,排序完成后再恢复为链表。其次,Java并没有使用快速排序,而是使用归并排序,因为归并排序是稳定的

  • 所有接受集合参数的方法必须描述什么时候可以安全地将集合传递给算法(毕竟不能将unmodifiableList属性的列表传递给sort算法)。根据文档说明,列表必须是可修改的,但不一定可以改变大小,如果列表支持set方法,则是可修改的(modifiable)。如果列表支持add和remove方法,则是可以改变大小地(resizable)。

4.2 二分查找

  • Collections类地binarySearch方法实现了二分查找算法。使用该算法的集合必须是有序的,否则算法会返回错误的答案。想要查找某个元素,必须提供集合以及要查找的元素。如果集合没有实现Comparable接口的compareTo方法,那么还要提供一个比较器对象。如果binarySearch方法返回负值,则表示没有匹配的元素。不过可以利用返回值来计算应该将element插入到集合的哪个位置,以保证集合的有序性。插入的位置是:

    if(i < 0){
        c.add(-i-1,element);
    }
    

    只有采用随机访问,二分查找才有意义。如果必须利用迭代方式查找链表的一半元素来找到中间元素,二分查找就完全失去了优势。因此,如果为binarySearch算法提供一个链表,它将自动地退化成线性查找。

4.3 集合与数组的转换

  • 如果需要把一个数组转换为集合,List.of包装器可以达到这个目的

    String[] values = ...;
    HashSet<> staff = new HashSet<>(List.of(values));
    

    从集合得到数组可以使用toArray方法

    Object[] values = staff.toArray();
    //但是,这样做的结果是一个对象数组。尽管知道集合中包含的是一个特定类型的对象,也不能使用强制类型转换
    String[] values = (String[]) staff.toArray();	//错误
    //toArray方法返回的数组创建为一个Object[]数组,不能改变它的类型。必须使用toArray方法的一个变体,提供一个指定类型而且长度为0的数组,这样一来,返回的数组就会创建为相同的数组类型
    String[] values = staff.toArray(new String[0]);
    
posted @   Solitary-Rhyme  阅读(79)  评论(0编辑  收藏  举报
编辑推荐:
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)
点击右上角即可分享
微信分享提示