11、容器

内容来自王争 Java 编程之美

1、JCF

Java 提供了非常多的容器,但这些容器并非杂乱无章、毫无联系的,而是构成了一个体系,叫作 JCF(Java Collections Framework)
类似 C++ 中的 STL(Standard Template Library)
我们先看一张 JCF 层次图,里面几乎包含了所有的 Java 容器,建议你好好看一看
image
尽管 Map 容器没有实现上图中 Collection 接口,但仍然属于 JCF 的一部分
上图中的 Collection 接口跟 Java Collections Framework 中的 Collections 并非同一个意思
Java Collections Framework 中的 Collections 表示容器的意思,而上图中的 Colleciton 只是一个接口

在图中, Map 容器跟其他容器毫无关联,这是因为 Map 容器在用法上跟其他容器不同,Map 容器中存储键和值两部分数据,而其他容器只存储单一的值
除此之外,其他容器都支持遍历,而 Map 容器常用来通过 "键" 查找 "值",不支持直接的遍历操作(关于这点,后面会有相关的章节详细介绍)
但凡实现 Iterable 接口的容器,都支持迭代器遍历,所以,Map 容器都没有实现 Iterable 接口,也就没有跟其他容器那样实现 Collection 接口

我们将 JCF 中的容器分为 5 类,如下所示

  • List:ArrayList、LinkedList、Vector(废弃)
  • Stack:Stack(废弃)
  • Queue:ArrayDeque、LinkedList、PriorityQueue
  • Set:HashSet、LinkedHashSet、TreeSet
  • Map:HashMap、LinkedHashMap、TreeMap、HashTable(废弃)

之所以这样分类,主要是结合两个方面因素来考虑

  • 从底层数据结构的归类来看,数组、链表、栈、队列、哈希表本应该划分为不同的类
  • 从用法上来看,同类容器往往基于相同的接口来创建,这样在使用过程中,可以互相替换,如下代码所示
    尽管 Set 容器和 Map 容器都是基于哈希表来实现的,但在用法上无法互相替换,所以不能归为一类
    同理,Stack、Queue 也几乎不会基于 List 接口创建,所以也单独做了分类
List<Integer> arrayList = new ArrayList<>();
List<Integer> linkedList = new LinkedList<>();

Stack<Integer> stack = new Stack<>();
// List<Integer> stack = new Stack<>();       // 不会有人这么写

Queue<Integer> queue = new LinkedList<>();
Queue<Integer> aQueue = new ArrayDeque<>();
Queue<Integer> pQueue = new PriorityQueue<>();
// List<Integer> aQueue = new ArrayQueue<>(); // 不会有人这么写

Set<Integer> hashSet = new HashSet<>();
Set<Integer> treeSet = new TreeSet<>();
Set<Integer> linkedHashSet = new LinkedHashSet<>();

Map<Integer, String> hashMap = new HashMap<>();
Map<Integer, String> treeMap = new TreeMap<>();
Map<Integer, String> linkedHashMap = new LinkedHashMap<>();

接下来,我们依次介绍一下这 5 类容器

2、List

ArrayList、LinkedList、Vector 这三个类都实现了 List 接口,并且继承了 AbstractList 抽象类
仔细观察 JCF 层次图,我们可以发现,其他类别的容器也具有相同的层次结构:实现一个接口并继承一个抽象类
接口存在的意义不用多说,方便开发中替换不同的实现类,那么,抽象类的作用是什么呢?

如果你读过我的《设计模式之美》,应该能顺利回答这个问题,答案就是复用
ArrayList、LinkedList、Vector 中的一些方法的代码实现是相同,为了避免重复编写,我们就得找个地方存放这些公共的代码,那就只好再设计一个抽象类了

接下来,我们依次来看一下这三个类

2.1、ArrayList

这个类应该是用的最多的容器之一了,其底层依赖的数据结构为可动态扩容的数组
动态扩容可以说是容器的必备功能,ArrayList、Stack、ArrayQueue、PriorityQueue、HashSet、HashMap 等都支持动态扩容
关于动态扩容,我们在后面的章节中详细介绍

2.2、LinkedList

LinkedList 底层依赖的数据结构为链表,并且是链表中的双向链表
在算法面试中,跟链表相关的题目大部分都是针对单链表,因为单链表操作起来非常费劲,容易拿来考察候选人的逻辑思维能力
而正因为单链表的这个特点,在项目开发中,我们很少使用单链表,更倾向选择双向链表
双向链表支持双向遍历,并且很多操作实现起来都更加简单,比如插入、删除操作,也正因如此,Java 使用双向链表来实现 LinkedList
有关单链表、双向链表更详细的讲解,可以参看我的《数据结构与算法之美》

public class LinkedList<E> extends AbstractSequentialList<E> implements List<E>, Deque<E>, Cloneable, Java.io.Serializable {

    transient int size = 0;
    transient Node<E> first; // 头指针
    transient Node<E> last;  // 尾指针

    private static class Node<E> { // 双向链表节点
        E item;
        Node<E> next;
        Node<E> prev;

        Node(Node<E> prev, E element, Node<E> next) {
            this.item = element;
            this.next = next;
            this.prev = prev;
        }
    }

    // ... 省略其他代码 ...
}

因为 ArrayList 和 LinkedList 都实现了 List 接口,抛开性能来说,在用法上完全可以互相替换
在项目开发或面试中,我们也经常会遇到 ArrayList 和 LinkedList 的选择问题,简单来讲,如果需要频繁的在内部而非尾部添加删除元素,那么我们首选 LinkedList
对于 ArrayList,在内部添加元素因为涉及到数据搬移,时间复杂度为 O(n),如果需要频繁的按照下标查询元素,那么我们首选 ArrayList
对于 LinkedList,按照下标查询元素,需要遍历链表,时间复杂度为 O(n)

List<Integer> arrayList = new ArrayList<>();
arrayList.add(1);
arrayList.add(4);
arrayList.add(2);
for (int i = 0; i < 1000; ++i) {
    arrayList.add(0, i);      // 执行效率很低, add() 方法的时间复杂度为 O(n)
}

List<Integer> linkedList = new LinkedList<>();
for (int i = 0; i < 1000; ++i) {
    linkedList.add(0, i);     // 执行效率高, add() 方法的时间复杂度 O(1)
}

int sum = 0;
for (int i = 0; i < 1000; ++i) {
    sum += linkedList.get(i); // 执行效率很低, get() 方法的时间复杂度为 O(n)
}

2.3、Vector

Vector 是线程安全的 ArrayList,早在 JDK 1.0 就存在了,显然,它是模仿了 C++ STL 中的 Vector,这也应证了,Java 中的很多特性都来自于 C++
JDK 1.0 中还没有 JCF,只有少数的几个容器,比如 Vector、Stack、HashTable,而且都是线程安全的
JDK 1.2 设计了 JCF,定义了一套完善的容器框架,从功能上完全可以替代 JDK 1.0 中引入的 Vector、Stack、HashTable
但为了兼容,Java 并没有将 Vector、Stack、HashTable 从 JDK 中移除,尽管没有废弃,但已经不推荐使用了,所以,在现在的项目开发中,我们很少用到这三个类

为了更符合程序员的开发习惯, JCF 将线程安全容器和非线程安全容器分开来设计,在非多线程环境下,我们使用没有加锁的容器,性能更高
例如,替代 Vector, JCF 设计了非线程安全的 ArrayList,对于多线程环境

  • 我们可以使用 Collections 工具类提供的 sychronizedList() 方法
    将非线程安全的 List,转换为线程安全的 SynchronizedList(关于这一点会在下一节讲解)
  • 或者使用 JUC(Java .util.concurrent)提供的 CopyOnWriteArrayList(关于这一点会在多线程中讲解)

3、Stack

Stack 也是 JDK 1.0 的产物,继承自 Vector,也是线程安全的,在项目中不推荐使用
取而代之,我们可以使用 Deque 双端队列来模拟栈,双端队列支持在一端存入数据、取出数据,支持先进后出的访问模式,可以当做栈来使用

Deque<Integer> deque = new LinkedList<>();

deque.push(1);
deque.push(2);
deque.push(3);

System.out.println(deque.peek());    // 输出 3

while (!deque.isEmpty()) {
    System.out.println(deque.pop()); // 输出顺序: 3、2、1
}

4、Queue

4.1、队列

JCF 中的队列分为双端队列(Deque)和优先级队列(PriorityQueue)

普通队列只能队尾添加元素,队首获取元素,双端队列的首尾两端,均可添加和获取元素
在《数据结构与算法之美》中,我们讲到,双端队列有两种实现方式:基于数组来实现和基于链表来实现
在 JCF 中,ArrayDeque 就是基于数组实现的队列,LinkedList 就是基于链表实现的队列
对的,你没看错,基于链表实现的队列,直接复用了 LinkedList 类,LinkedList 类在实现时,实现了 Deque 接口,如下所示

public class LinkedList<E> extends AbstractSequentialList<E> implements List<E>, Deque<E>, Cloneable, Java.io.Serializable {
    // ... 省略代码实现 ...
}

优先级队列底层依赖堆这种数据结构来实现,对应 JCF 容器 PriorityQueue
默认情况下,优先级队列基于小顶堆来实现的,最先出队列的为当前队列中的最小值
当然,我们也可以通过使用 Comparator 接口的匿名类对象,改变优先级队列的实现方式,改为基于大顶堆来实现,此时,最先出队列的为当前队列中的最大值

最大堆、最小堆、堆排序、优先队列

PriorityQueue<Integer> pq1 = new PriorityQueue<>();                           // 默认是最小堆
PriorityQueue<Integer> pq2 = new PriorityQueue<>(Collections.reverseOrder()); // 修改为最大堆

// 默认为小顶堆实现的优先级队列
PriorityQueue<Integer> pq = new PriorityQueue<>();
pq.add(2);
pq.add(3);
pq.add(1);
while (!pq.isEmpty()) {
    System.out.println(pq.poll()); // 输出 1、2、3
}

// 改为大顶堆实现的优先级队列
PriorityQueue<Integer> pq = new PriorityQueue<>(new Comparator<Integer>() {
    @Override
    public int compare(Integer o1, Integer o2) {
        return o2 - o1;
    }
});
pq.add(2);
pq.add(3);
pq.add(1);
while (!pq.isEmpty()) {
    System.out.println(pq.poll()); // 输出 3、2、1
}

4.2、比较器

堆的构建过程,需要比较节点中数据的大小,所以,添加到优先级队列中的元素,需要能够比较大小
比较大小的方法有两种:基于 Comparable 接口和基于 Comparator 接口,两个接口的定义如下所示

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

public interface Comparator<T> {
    int compare(T o1, T o2);
}

对于基于 Comparable 接口实现的比较方法:存储在优先级队列中的元素,需要实现 Comparable 接口,优先级队列调用 compareTo() 方法比较元素大小
对于基于 Comparator 接口实现的比较方法:我们创建一个 Comparator 接口的匿名类对象,优先级队列会调用 compare() 方法比较元素的大小
实际上,在 Java 中,所有需要比较元素大小的地方,都是使用这两种方法来实现,比如 Collections.sort()、TreeMap 等

// 实现 Comparable
public class Student implements Comparable<Student> {
    public int id;
    public int age;
    public int score;

    public Student(int id, int age, int score) {
        this.id = id;
        this.age = age;
        this.score = score;
    }

    @Override
    public int compareTo(Student o) {
        return this.id - o.id; // 根据 id 1、2、3
    }

    @Override
    public String toString() {
        return "[id: " + id + ", age: " + age + ", score: " + score + "]";
    }
}

// 使用 Comparator 灵活定义优先级
PriorityQueue<Student> q = new PriorityQueue<>(new Comparator<Student>() {
    @Override
    public int compare(Student o1, Student o2) {
        return o2.score - o1.score; // 根据 score 99、89、79
    }
});
q.add(new Student(2, 10, 79));
q.add(new Student(3, 9, 99));
q.add(new Student(1, 11, 89));
while (!q.isEmpty()) {
    System.out.println(q.poll());
}

Java 提供的类,比如 Integer、String,大都实现了 Comparable 接口,但如果是我们自己定义的类,需要主动去实现 Comparable 接口
如果一个类既没有实现 Comparable 接口,也没有创建 Comparator 接口的匿名类对象,那么使用 PriorityQueue 存储这个类的对象将会报运行时异常
如果两种比较方法同时实现,则优先使用基于 Comparator 接口的比较方法,如 PriorityQueue 中的代码所示

// PriorityQueue 部分源码
private void siftUp(int k, E x) {
    if (comparator != null)
        siftUpUsingComparator(k, x);
    else
        siftUpComparable(k, x);
}

// 元素实现 Comparable 接口, 使用 compareTo() 对比
@SuppressWarnings("unchecked")
private void siftUpComparable(int k, E x) {
    Comparable<? super E> key = (Comparable<? super E>) x;
    while (k > 0) {
        int parent = (k - 1) >>> 1;
        Object e = queue[parent];
        if (key.compareTo((E) e) >= 0)
            break;
        queue[k] = e;
        k = parent;
    }
    queue[k] = key;
}

// 采用第二种方法, 定义 Comparator 对象, 使用 compare() 方法对比
@SuppressWarnings("unchecked")
private void siftUpUsingComparator(int k, E x) {
    while (k > 0) {
        int parent = (k - 1) >>> 1;
        Object e = queue[parent];
        if (comparator.compare(x, (E) e) >= 0)
            break;
        queue[k] = e;
        k = parent;
    }
    queue[k] = x;
}

5、Set

Set 容器跟 List 容器都可以用来存储一组数据
不同的地方在于,List 容器中有下标的概念,不同下标对应的位置可以存储相同的数据,而 Set 容器没有下标的概念,不允许存储相同的数据

Set 容器包括 HashSet、LinkedHashSet、TreeSet
从代码实现上来说,这三个类底层分别是依赖 HashMap、LinkedHashMap、TreeMap 这三个类实现的
例如,往 HashSet 中存储对象 obj,底层实现如下代码所示:将 obj 值作为键,一个空的 Object 对象做为值,一并存储到 HashMap 中
所以,Set 容器相当于是 Map 容器的封装,我们重点讲解 Map 容器

public class TreeSet<E> extends AbstractSet<E> implements NavigableSet<E>, Cloneable, Java.io.Serializable {

    // 数据存储在这个 Map 中
    private transient NavigableMap<E, Object> m;

    // 存储在 Map 中的默认 value 值
    private static final Object PRESENT = new Object();

    public TreeSet() {
        this(new TreeMap<E, Object>());
    }

    public boolean add(E e) {
        return m.put(e, PRESENT) == null;
    }

    public boolean contains(Object o) {
        return m.containsKey(o);
    }

    // ... 省略其他属性和方法 ...
}

6、Map

推荐阅读:二分搜索树AVL 树2 - 3 树红黑树哈希表

Map 容器包括 HashMap、LinkedHashMap 和 TreeMap,跟 Set 容器类似, Map 容器也不支持存储重复的键
在这个三个容器中,HashMap 和 LinkedHashMap 的底层实现原理比较复杂,其底层实现原理是面试中非常常考的知识点
所以,接下来,我们会用两节课的时间详细地讲解这两个容器,在本节课中,我们介绍一下比较简单的 TreeMap

TreeMap 是基于红黑树来实现的,TreeMap 基于 "键值对" 的 "键" 来构建红黑树,"值" 作为数据,附属存储在红黑树的节点中
具体红黑树的实现原理,我们就不在这里讲述了,如果不了解,你可以参看一下《数据结构与算法之美》

前面讲到,PriorityQueue 底层依赖堆,堆的构建需要比较元素的大小,我们可以基于 Comparable 接口或 Comparator 接口,为 PriorityQueue 提供比较元素大小的方法
而 TreeMap 底层依赖红黑树,红黑树的构建也需要比较元素的大小,所以,跟 PriorityQueue 类似,如果在使用 TreeMap 时
要么键值对中的键实现 Comparable 接口,要么在创建 TreeMap 时传入 Comparator 接口的匿名类对象

从 JCF 层次图中,我们可以发现,TreeMap 直接实现的接口是 SortedMap,而非 Map,这是因为 TreeMap 底层依赖红黑树来实现,其中序遍历的结果是有序的
因此,相较于基于哈希表实现的 HashMap,TreeMap 可以提供更加丰富的功能
比如查看最大键值、最小键值、大于某个值的键值、有序输出所有的键值等等,这些操作都定义在 SortedMap 接口中
对于这些操作的实现原理,你可以自行阅读代码

7、课后思考题

1、容器为什么设计成泛型?有什么好处?为什么不支持存储基本类型数据?

将容器设计成泛型的目的是

1、复用,容器可以支持不同类型的数据
2、类型检查,泛型支持类型检查
容器不支持基本类型的原因是容器的类型擦除,在编译字节码时,容器的泛型中的类型会被擦除,基本类型无法做到类型擦除

2、Map 容器为什么没有实现 Collection 接口?

这是因为 Map 容器底层采用哈希表、二叉树等数据结构来实现,常用来实现通过 "键" 查找 "值" 的操作,不支持直接的遍历操作
而但凡实现 Iterable 接口的容器,都支持迭代器遍历
因此,Map 容器就没有实现 "继承了 Iterable 接口的 Collection 接口"
posted @ 2023-05-14 11:39  lidongdongdong~  阅读(84)  评论(0编辑  收藏  举报