Java SE基础(中篇)

数据结构基础

注意:本部分内容难度很大,推荐计算机专业课程《数据结构与算法》作为前置学习课程。本部分介绍数据结构只是为了为后面的集合类型做准备。

学习集合类之前,我们还有最关键的内容需要学习,自底向上才是最佳的学习方向,比起直接认识集合类,不如先了解一下数据结构,只有了解了数据结构基础,才能更好地学习集合类,同时,数据结构也是你以后深入学习JDK源码的必备条件(学习不要快餐式)当然,我们主要是讲解Java,数据结构作为铺垫作用,所以我们只会讲解关键的部分,其他部分可以在数据结构与算法篇视频教程中详细学习。

在计算机科学中,数据结构是一种数据组织、管理和存储的格式,它可以帮助我们实现对数据高效的访问和修改。更准确地说,数据结构是数据值的集合,可以体现数据值之间的关系,以及可以对数据进行应用的函数或操作。

通俗地说,我们需要去学习在计算机中如何去更好地管理我们的数据,才能让我们对我们的数据控制更加灵活!

比如现在我们需要保存100个学生的数据,那么你首先想到的肯定是使用数组吧!没错,没有什么比数组更适合存放这100个学生的数据了,但是如果我们现在有了新的需求呢?我们不仅仅是存放这些数据,我们还希望能够将这些数据按顺序存放,支持在某个位置插入一条数据、删除一条数据、修改一条数据等,这时候,数组就显得有些乏力了。

数组无法做到这么高级的功能,那么我们就需要定义一种更加高级的数据结构来做到,我们可以使用线性表(Linear List)

线性表是由同一类型的数据元素构成的有序序列的线性结构。线性表中元素的个数就是线性表的长度,表的起始位置称为表头,表的结束位置称为表尾,当一个线性表中没有元素时,称为空表。

线性表一般需要包含以下功能:

  • 获取指定位置上的元素:直接获取线性表指定位置i上的元素。

  • 插入元素:在指定位置i上插入一个元素。

  • 删除元素:删除指定位置i上的一个元素。

  • 获取长度:返回线性表的长度。

也就是说,现在我们需要设计的是一种功能完善的表结构,它不像是数组那么低级,而是真正意义上的表:

简单来说它就是列表,比如我们的菜单,我们在点菜时就需要往菜单列表中添加菜品或是删除菜品,这时列表就很有用了,因为数组长度固定、操作简单,而我们添加菜品、删除菜品这些操作又要求长度动态变化、操作多样。

那么,如此高级的数据结构,我们该如何去实现呢?实现线性表的结构一般有两种,一种是顺序存储实现,还有一种是链式存储实现,我们先来看第一种,也是最简单的的一种。

1.线性表:顺序表

前面我们说到,既然数组无法实现这样的高级表结构,那么我就基于数组,对其进行强化,也就是说,我们存放数据还是使用数组,但是我们可以为其编写一些额外的操作来强化为线性表,像这样底层依然采用顺序存储实现的线性表,我们称为顺序表。

这里我们可以先定义一个新的类型:

public class ArrayList<E> {   //泛型E,因为表中要存的具体数据类型待定
    int capacity = 10;   //当前顺序表的容量
    int size = 0;   //当前已经存放的元素数量
    private Object[] array = new Object[capacity];   //底层存放数据的数组
}

顺序表的插入和删除操作,其实就是:

当插入元素时,需要将插入位置给腾出来,也就是将后面的所有元素向后移,同样的,如果要删除元素,那么也需要将所有的元素向前移动,顺序表是紧凑的,不能出现空位。

所以说我们可以来尝试实现一下,首先是插入方法:

public void add(E element, int index){   //插入方法需要支持在指定下标位置插入
    for (int i = size; i > index; i--)   //从后往前,一个一个搬运元素
        array[i] = array[i - 1];
    array[index] = element;   //腾出位置之后,直接插入元素放到对应位置上
    size++;   //插入完成之后,记得将size自增
}

只不过这样并不完美,因为我们的插入操作并不是在任何位置都支持插入的,我们允许插入的位置只能是 [0, size] 这个范围内

所以说我们需要在插入之前进行判断:

public void add(E element, int index){
    if(index < 0 || index > size)    //插入之前先判断插入位置是否合法
        throw new IndexOutOfBoundsException("插入位置非法,合法的插入位置为:0 ~ "+size);
    for (int i = size; i > index; i--)
        array[i] = array[i - 1];
    array[index] = element;
    size++;
}

我们来测试一下吧:

public static void main(String[] args) {
    ArrayList<Integer> list = new ArrayList<>();
    list.add(10, 1);    //一上来只能在第一个位置插入,第二个位置肯定是非法的
}

于是就成功得到异常:

只不过依然不够完美,万一我们的顺序表装满了咋办?所以说,我们在插入元素之前,需要进行判断,如果已经装满了,那么我们需要先扩容之后才能继续插入新的元素:

public void add(E element, int index){
    if(index < 0 || index > size)
        throw new IndexOutOfBoundsException("插入位置非法,合法的插入位置为:0 ~ "+size);
    if(capacity == size) {
        int newCapacity = capacity + (capacity >> 1);   //扩容规则就按照原本容量的1.5倍来吧
        Object[] newArray = new Object[newCapacity];    //创建一个新的数组来存放更多的元素
        System.arraycopy(array, 0, newArray, 0, size);   //使用arraycopy快速拷贝原数组内容到新的数组
        array = newArray;   //更换为新的数组
        capacity = newCapacity;   //容量变成扩容之后的
    }
    for (int i = size; i > index; i--)
        array[i] = array[i - 1];
    array[index] = element;
    size++;
}

我们来重写一下toString方法打印当前存放的元素:

public String toString() {
    StringBuilder builder = new StringBuilder();
    for (int i = 0; i < size; i++) builder.append(array[i]).append(" ");
    return builder.toString();
}

可以看到,我们的底层数组会自动扩容,便于我们使用:

public static void main(String[] args) {
    ArrayList<Integer> list = new ArrayList<>();
    for (int i = 0; i < 20; i++)
        list.add(i, i);
    System.out.println(list);
}

我们接着来看删除操作,其实操作差不多,只需要将后面的覆盖到前面就可以了:

@SuppressWarnings("unchecked")   //屏蔽未经检查警告
public E remove(int index){   //删除对应位置上的元素,注意需要返回被删除的元素
    E e = (E) array[index];   //因为存放的是Object类型,这里需要强制类型转换为E
    for (int i = index; i < size; i++)   //从前往后,挨个往前搬一位
        array[i] = array[i + 1];
    size--;    //删完记得将size--
    return e;
}

同样的,我们需要对删除的合法范围进行判断:

所以说我们也来进行一下判断:

@SuppressWarnings("unchecked")
public E remove(int index){
    if(index < 0 || index > size - 1)
        throw new IndexOutOfBoundsException("删除位置非法,合法的插入位置为:0 ~ "+(size - 1));
    E e = (E) array[index];
    for (int i = index; i < size; i++)
        array[i] = array[i + 1];
    size--;
    return e;
}

因为删除不需要考虑容量的问题,所以说这里的删除操作就编写完成了。

当然,我们还得支持获取指定下标位置上的元素,这个就简单了,直接从数组中那就行了:

@SuppressWarnings("unchecked")
public E get(int index){
    if(index < 0 || index > size - 1)   //在插入之前同样要进行范围检查
        throw new IndexOutOfBoundsException("非法的位置,合法的位置为:0 ~ "+(size - 1));
    return (E) array[index];   //直接返回就完事
}
​
public int size(){   //获取当前存放的元素数量
    return size;
}

是不是感觉顺便表其实还是挺简单的,也就是一个数组多了一些操作罢了。

2.线性表:链表

前面我们介绍了如何使用数组实现线性表,我们接着来看第二种方式,我们可以使用链表来实现,那么什么是链表呢?

链表不同于顺序表,顺序表底层采用数组作为存储容器,需要分配一块连续且完整的内存空间进行使用,而链表则不需要,它通过一个指针来连接各个分散的结点,形成了一个链状的结构,每个结点存放一个元素,以及一个指向下一个结点的指针,通过这样一个一个相连,最后形成了链表。它不需要申请连续的空间,只需要按照顺序连接即可,虽然物理上可能不相邻,但是在逻辑上依然是每个元素相邻存放的,这样的结构叫做链表(单链表)。

链表分为带头结点的链表和不带头结点的链表,戴头结点的链表就是会有一个头结点指向后续的整个链表,但是头结点不存放数据:

而不带头结点的链表就像上面那样,第一个节点就是存放数据的结点,一般设计链表都会采用带头结点的结构,因为操作更加方便。

我们来尝试定义一下:

public class LinkedList<E> {
    //链表的头结点,用于连接之后的所有结点
    private final Node<E> head = new Node<>(null);
    private int size = 0;   //当前的元素数量还是要存一下,方便后面操作
    
    private static class Node<E> {  //结点类,仅供内部使用
        E element;   //每个结点都存放元素
        Node<E> next;   //以及指向下一个结点的引用
      
        public Node(E element) {
            this.element = element;
        }
    }
}

接着我们来设计一下链表的插入和删除,我们前面实现了顺序表的插入,那么链表的插入该怎么做呢?

我们可以先修改新插入的结点的后继结点(也就是下一个结点)指向,指向原本在这个位置的结点:

接着我们可以将前驱结点(也就是上一个结点)的后继结点指向修改为我们新插入的结点:

这样,我们就成功插入了一个新的结点,现在新插入的结点到达了原本的第二个位置上:

按照这个思路,我们来实现一下,首先设计一下方法:

public void add(E element, int index){
    Node<E> prev = head;   //先找到对应位置的前驱结点
    for (int i = 0; i < index; i++) 
        prev = prev.next;
    Node<E> node = new Node<>(element);   //创建新的结点
    node.next = prev.next;   //先让新的节点指向原本在这个位置上的结点
    prev.next = node;   //然后让前驱结点指向当前结点
    size++;   //完事之后一样的,更新size
}V

我们来重写一下toString方法看看能否正常插入:

@Override
public String toString() {
    StringBuilder builder = new StringBuilder();
    Node<E> node = head.next;   //从第一个结点开始,一个一个遍历,遍历一个就拼接到字符串上去
    while (node != null) {
        builder.append(node.element).append(" ");
        node = node.next;
    }
    return builder.toString();
}

可以看到我们的插入操作是可以正常工作的:

public static void main(String[] args) {
    LinkedList<Integer> list = new LinkedList<>();
    list.add(10, 0);
    list.add(30, 0);
    list.add(20, 1);
    System.out.println(list);
}

只不过还不够完美,跟之前一样,我们还得考虑插入位置是否合法:

public void add(E element, int index){
    if(index < 0 || index > size)
        throw new IndexOutOfBoundsException("插入位置非法,合法的插入位置为:0 ~ "+size);
    Node<E> prev = head;
    for (int i = 0; i < index; i++)
        prev = prev.next;
    Node<E> node = new Node<>(element);
    node.next = prev.next;
    prev.next = node;
    size++;
}

插入操作完成之后,我们接着来看删除操作,那么我们如何实现删除操作呢?实际上也会更简单一些,我们可以直接将待删除节点的前驱结点指向修改为待删除节点的下一个:

这样,在逻辑上来说,待删除结点其实已经不在链表中了,所以我们只需要释放掉待删除结点占用的内存空间就行了:

那么我们就按照这个思路来编写一下程序:

public E remove(int index){
    if(index < 0 || index > size - 1)   //同样的,先判断位置是否合法
        throw new IndexOutOfBoundsException("删除位置非法,合法的删除位置为:0 ~ "+(size - 1));
    Node<E> prev = head;
    for (int i = 0; i < index; i++)   //同样需要先找到前驱结点
        prev = prev.next;
    E e = prev.next.element;   //先把待删除结点存放的元素取出来
    prev.next = prev.next.next;  //可以删了
    size--;   //记得size--
    return e;
}

是不是感觉还是挺简单的?这样,我们就成功完成了链表的删除操作。

我们接着来实现一下获取对应位置上的元素:

public E get(int index){
    if(index < 0 || index > size - 1)
        throw new IndexOutOfBoundsException("非法的位置,合法的位置为:0 ~ "+(size - 1));
    Node<E> node = head;
    while (index-- >= 0)   //这里直接让index减到-1为止
        node = node.next;
    return node.element;
}
​
public int size(){
    return size;
}

这样,我们的链表就编写完成了,实际上只要理解了那种结构,其实还是挺简单的。

问题:什么情况下使用顺序表,什么情况下使用链表呢?

  • 通过分析顺序表和链表的特性我们不难发现,链表在随机访问元素时,需要通过遍历来完成,而顺序表则利用数组的特性直接访问得到,所以,当我们读取数据多于插入或是删除数据的情况下时,使用顺序表会更好。

  • 而顺序表在插入元素时就显得有些鸡肋了,因为需要移动后续元素,整个移动操作会浪费时间,而链表则不需要,只需要修改结点 指向即可完成插入,所以在频繁出现插入或删除的情况下,使用链表会更好。

虽然单链表使用起来也比较方便,不过有一个问题就是,如果我们想要操作某一个结点,比如删除或是插入,那么由于单链表的性质,我们只能先去找到它的前驱结点,才能进行。为了解决这种查找前驱结点非常麻烦的问题,我们可以让结点不仅保存指向后续结点的指针,同时也保存指向前驱结点的指针:

这样我们无论在哪个结点,都能够快速找到对应的前驱结点,就很方便了,这样的链表我们成为双向链表(双链表)

3.线性表:栈

栈(也叫堆栈,Stack)是一种特殊的线性表,它只能在在表尾进行插入和删除操作,就像下面这样:

也就是说,我们只能在一端进行插入和删除,当我们依次插入1、2、3、4这四个元素后,连续进行四次删除操作,删除的顺序刚好相反:4、3、2、1,我们一般将其竖着看:

底部称为栈底,顶部称为栈顶,所有的操作只能在栈顶进行,也就是说,被压在下方的元素,只能等待其上方的元素出栈之后才能取出,就像我们往箱子里里面放的书一样,因为只有一个口取出里面的物品,所以被压在下面的书只能等上面的书被拿出来之后才能取出,这就是栈的思想,它是一种先进后出的数据结构(FILO,First In, Last Out)

实现栈也是非常简单的,可以基于我们前面的顺序表或是链表,这里我们需要实现两个新的操作:

  • pop:出栈操作,从栈顶取出一个元素。

  • push:入栈操作,向栈中压入一个新的元素。

栈可以使用顺序表实现,也可以使用链表实现,这里我们就使用链表,实际上使用链表会更加的方便,我们可以直接将头结点指向栈顶结点,而栈顶结点连接后续的栈内结点:

当有新的元素入栈,只需要在链表头部插入新的结点即可,我们来尝试编写一下:

public class LinkedStack<E> {
​
    private final Node<E> head = new Node<>(null);   //大体内容跟链表类似
​
    private static class Node<E> {
        E element;
        Node<E> next;
​
        public Node(E element) {
            this.element = element;
        }
    }
}

接着我们来编写一下入栈操作:

代码如下:

public void push(E element){
    Node<E> node = new Node<>(element);   //直接创建新结点
    node.next = head.next;    //新结点的下一个变成原本的栈顶结点
    head.next = node;     //头结点的下一个改成新的结点
}

这样,我们就可以轻松实现入栈操作了。其实出栈也是同理,所以我们只需要将第一个元素移除即可:

public E pop(){
    if(head.next == null)   //如果栈已经没有元素了,那么肯定是没办法取的
        throw new NoSuchElementException("栈为空");
    E e = head.next.element;   //先把待出栈元素取出来
    head.next = head.next.next;   //直接让头结点的下一个指向下一个的下一个
    return e;
}

我们来测试一下吧:

public static void main(String[] args) {
    LinkedStack<String> stack = new LinkedStack<>();
    stack.push("AAA");
    stack.push("BBB");
    stack.push("CCC");
    System.out.println(stack.pop());
    System.out.println(stack.pop());
    System.out.println(stack.pop());
}

可以看到,入栈顺序和出栈顺序是完全相反的:

其实还是挺简单的。

4.线性表:队列

前面我们学习了栈,栈中元素只能栈顶出入,它是一种特殊的线性表,同样的,队列(Queue)也是一种特殊的线性表。

就像我们在超市、食堂需要排队一样,我们总是排成一列,先到的人就排在前面,后来的人就排在后面,越前面的人越先完成任务,这就是队列,队列有队头和队尾:

秉承先来后到的原则,队列中的元素只能从队尾进入,只能从队首出去,也就是说,入队顺序为1、2、3、4,那么出队顺序也一定是1、2、3、4,所以队列是一种先进先出(FIFO,First In, First Out)的数据结构。

队列也可以使用链表和顺序表来实现,只不过使用链表的话就不需要关心容量之类的问题了,会更加灵活一些:

注意我们需要同时保存队首和队尾两个指针,因为是单链表,所以队首需要存放指向头结点的指针,因为需要的是前驱结点,而队尾则直接是指向尾结点的指针即可,后面只需要直接在后面拼接就行。

当有新的元素入队时,只需要拼在队尾就行了,同时队尾指针也要后移一位:

出队时,只需要移除队首指向的下一个元素即可:

那么我们就按照这个思路,来编写一下代码吧:

public class LinkedQueue<E> {
​
    private final Node<E> head = new Node<>(null);
​
    public void offer(E element){  //入队操作
        Node<E> last = head;
        while (last.next != null)   //入队直接丢到最后一个结点的屁股后面就行了
            last = last.next;
        last.next = new Node<>(element);
    }
​
    public E poll(){   //出队操作
        if(head.next == null)   //如果队列已经没有元素了,那么肯定是没办法取的
            throw new NoSuchElementException("队列为空");
        E e = head.next.element;
        head.next = head.next.next;   //直接从队首取出
        return e;
    }
​
    private static class Node<E> {
        E element;
        Node<E> next;
​
        public Node(E element) {
            this.element = element;
        }
    }
}

其实使用起来还是挺简单的,我们来测试一下吧:

public static void main(String[] args) {
    LinkedQueue<String> stack = new LinkedQueue<>();
    stack.offer("AAA");
    stack.offer("BBB");
    stack.offer("CCC");
    System.out.println(stack.poll());
    System.out.println(stack.poll());
    System.out.println(stack.poll());
}

 可以看到,队列遵从先进先出,入队顺序和出队顺序是一样的。

树:二叉树

树是一种全新的数据结构,它就像一棵树的树枝一样,不断延伸。

在我们的程序中,想要表示出一棵树,就可以像下面这样连接:

可以看到,现在一个结点下面可能会连接多个节点,并不断延伸,就像树枝一样,每个结点都有可能是一个分支点,延伸出多个分支,从位于最上方的结点开始不断向下,而这种数据结构,我们就称为(Tree)注意分支只能向后单独延伸,之后就分道扬镳了,不能与其他分支上的结点相交!

  • 我们一般称位于最上方的结点为树的根结点(Root)因为整棵树正是从这里开始延伸出去的。

  • 每个结点连接的子结点数目(分支的数目),我们称为结点的(Degree),而各个结点度的最大值称为树的度。

  • 每个结点延伸下去的下一个结点都可以称为一棵子树(SubTree)比如结点B及其之后延伸的所有分支合在一起,就是一棵A的子树。

  • 每个结点的层次(Level)按照从上往下的顺序,树的根结点为1,每向下一层+1,比如G的层次就是3,整棵树中所有结点的最大层次,就是这颗树的深度(Depth),比如上面这棵树的深度为4,因为最大层次就是4。

由于整棵树错综复杂,所以说我们需要先规定一下结点之间的称呼,就像族谱那样:

  • 与当前结点直接向下相连的结点,我们称为子结点(Child),比如B、C、D结点,都是A的子结点,就像族谱中的父子关系一样,下一代一定是子女,相反的,那么A就是B、C、D父结点(Parent),也可以叫双亲结点。

  • 如果某个节点没有任何的子结点(结点度为0时)那么我们称这个结点为叶子结点(因为已经到头了,后面没有分支了,这时就该树枝上长叶子了那样)比如K、L、F、G、M、I、J结点,都是叶子结点。

  • 如果两个结点的父结点是同一个,那么称这两个节点为兄弟结点(Sibling)比如BC就是兄弟结点,因为都是A的孩子。

  • 从根结点开始一直到某个结点的整条路径的所有结点,都是这个结点的祖先结点(Ancestor)比如L的祖先结点就是A、B、E

那么在了解了树的相关称呼之后,相信各位就应该对树有了一定的了解,虽然概念比较多,但是还请各位一定记住,不然后面就容易听懵。

而我们本章需要着重讨论的是二叉树(Binary Tree)它是一种特殊的树,它的度最大只能为2,所以我们称其为二叉树,一棵二叉树大概长这样:

并且二叉树任何结点的子树是有左右之分的,不能颠倒顺序,比如A结点左边的子树,称为左子树,右边的子树称为右子树。

当然,对于某些二叉树我们有特别的称呼,比如,在一棵二叉树中,所有分支结点都存在左子树和右子树,且叶子结点都在同一层:

这样的二叉树我们称为满二叉树,可以看到整棵树都是很饱满的,没有出现任何度为1的结点,当然,还有一种特殊情况:

可以看到只有最后一层有空缺,并且所有的叶子结点是按照从左往右的顺序排列的,这样的二叉树我们一般称其为完全二叉树,所以,一棵满二叉树,一定是一棵完全二叉树。

我们接着来看看二叉树在程序中的表示形式,我们在前面使用链表的时候,每个结点不仅存放对应的数据,而且会存放一个指向下一个结点的引用:

而二叉树也可以使用这样的链式存储形式,只不过现在一个结点需要存放一个指向左子树的引用和一个指向右子树的引用了:

通过这种方式,我们就可以通过连接不同的结点形成一颗二叉树了,这样也更便于我们去理解它,我们首先定义一个类:

public class TreeNode<E> {
    public E element;
    public TreeNode<E> left, right;
​
    public TreeNode(E element){
        this.element = element;
    }
}

比如我们现在想要构建一颗像这样的二叉树:

首先我们需要创建好这几个结点:

public static void main(String[] args) {
    TreeNode<Character> a = new TreeNode<>('A');
    TreeNode<Character> b = new TreeNode<>('B');
    TreeNode<Character> c = new TreeNode<>('C');
    TreeNode<Character> d = new TreeNode<>('D');
    TreeNode<Character> e = new TreeNode<>('E');
    
}

接着我们从最上面开始,挨着进行连接,首先是A这个结点:

public static void main(String[] args) {
    ...
    a.left = b;
    a.right = c;
    b.left = d;
    b.right = e;
}

这样的话,我们就成功构建好了这棵二叉树,比如现在我们想通过根结点访问到D:

System.out.println(a.left.left.element);

断点调试也可以看的很清楚:

这样,我们就通过使用链式结构,成功构建出了一棵二叉树,接着我们来看看如何遍历一棵二叉树,也就是说我们想要访问二叉树的每一个结点,由于树形结构特殊,遍历顺序并不唯一,所以一共有四种访问方式:前序遍历、中序遍历、后序遍历、层序遍历。不同的访问方式输出都结点顺序也不同。

首先我们来看最简单的前序遍历:

前序遍历是一种勇往直前的态度,走到哪就遍历到那里,先走左边再走右边,比如上面的这个图,首先会从根节点开始:

从A开始,先左后右,那么下一个就是B,然后继续走左边,是D,现在ABD走完之后,B的左边结束了,那么就要开始B的右边了,所以下一个是E,E结束之后,现在A的左子树已经全部遍历完成了,然后就是右边,接着就是C,C没有左子树了,那么只能走右边了,最后输出F,所以上面这个二叉树的前序遍历结果为:ABDECF

  1. 打印根节点

  2. 前序遍历左子树

  3. 前序遍历右子树

我们不难发现规律,整棵二叉树(包括子树)的根节点一定是出现在最前面的,比如A在最前面,A的左子树根结点B也是在最前面的。我们现在就来尝试编写一下代码实现一下,先把二叉树构建出来:

public static void main(String[] args) {
    TreeNode<Character> a = new TreeNode<>('A');
    TreeNode<Character> b = new TreeNode<>('B');
    TreeNode<Character> c = new TreeNode<>('C');
    TreeNode<Character> d = new TreeNode<>('D');
    TreeNode<Character> e = new TreeNode<>('E');
    TreeNode<Character> f = new TreeNode<>('F');
    a.left = b;
    a.right = c;
    b.left = d;
    b.right = e;
    c.right = f;
}

组装好之后,我们来实现一下前序遍历的方法:

private static <T> void preOrder(TreeNode<T> root){
    System.out.print(root.element + " ");   //首先肯定要打印,这个是必须的
}

打印完成之后,我们就按照先左后右的规则往后遍历下一个结点,这里我们就直接使用递归来完成:

private static <T> void preOrder(TreeNode<T> root){
    System.out.print(root.element + " ");
    preOrder(root.left);    //先走左边
    preOrder(root.right);   //再走右边
}

不过还没完,我们的递归肯定是需要一个终止条件的,不可能无限地进行下去,如果已经走到底了,那么就不能再往下走了,所以:

private static <T> void preOrder(TreeNode<T> root){
    if(root == null) return;
    System.out.print(root.element);
    preOrder(root.left);
    preOrder(root.right);
}

最后我们来测试一下吧:

public static void main(String[] args) {
    ...
    preOrder(a);
}

可以看到结果为:

这样我们就通过一个简单的递归操作完成了对一棵二叉树的前序遍历,如果不太好理解,建议结合调试进行观察。

那么前序遍历我们了解完了,接着就是中序遍历了,中序遍历在顺序上与前序遍历不同,前序遍历是走到哪就打印到哪,而中序遍历需要先完成整个左子树的遍历后再打印,然后再遍历其右子树。

我们还是以上面的二叉树为例:

首先需要先不断遍历左子树,走到最底部,但是沿途并不进行打印,而是到底之后,再打印,所以第一个打印的是D,接着由于没有右子树,所以我们回到B,此时再打印B,然后再去看B的右结点E,由于没有左子树和右子树了,所以直接打印E,左边遍历完成,接着回到A,打印A,然后对A的右子树重复上述操作。所以说遍历的基本规则还是一样的,只是打印值的时机发生了改变。

  1. 中序遍历左子树

  2. 打印结点

  3. 中序遍历右子树

所以这棵二叉树的中序遍历结果为:DBEACF,我们可以发现一个规律,就是在某个结点的左子树中所有结点,其中序遍历结果也是按照这样的规律排列的,比如A的左子树中所有结点,中序遍历结果中全部都在A的左边,右子树中所有的结点,全部都在A的右边(这个规律很关键,后面在做一些算法题时会用到)

那么怎么才能将打印调整到左子树全部遍历结束之后呢?其实很简单:

private static <T> void inOrder(TreeNode<T> root){
    if(root == null) return;
    inOrder(root.left);    //先完成全部左子树的遍历
    System.out.print(root.element);    //等待左子树遍历完成之后再打印
    inOrder(root.right);    //然后就是对右子树进行遍历
}

我们只需要将打印放到左子树遍历之后即可,这样打印出来的结果就是中序遍历的结果了:

这样,我们就实现了二叉树的中序遍历,实际上还是很好理解的。

接着我们来看一下后序遍历,后序遍历继续将打印的时机延后,需要等待左右子树全部遍历完成,才会去进行打印。

首先还是一路向左,到达结点D,此时结点D没有左子树了,接着看结点D还有没有右子树,发现也没有,左右子树全部遍历完成,那么此时再打印D,同样的,D完事之后就回到B了,此时接着看B的右子树,发现有结点E,重复上述操作,E也打印出来了,接着B的左右子树全部OK,那么再打印B,接着A的左子树就完事了,现在回到A,看到A的右子树,继续重复上述步骤,当A的右子树也遍历结束后,最后再打印A结点。

  1. 后序遍历左子树

  2. 后序遍历右子树

  3. 打印结点

所以最后的遍历顺序为:DEBFCA,不难发现,整棵二叉树(包括子树)根结点一定是在后面的,比如A在所有的结点的后面,B在其子节点D、E的后面,这一点恰恰和前序遍历相反(注意不是得到的结果相反,是规律相反)

所以,按照这个思路,我们来编写一下后序遍历:

private static <T> void postOrder(TreeNode<T> root){
    if(root == null) return;
    postOrder(root.left);
    postOrder(root.right);
    System.out.print(root.element);  //时机延迟到最后
}

结果如下:

最后我们来看层序遍历,实际上这种遍历方式是我们人脑最容易理解的,它是按照每一层在进行遍历:

层序遍历实际上就是按照从上往下每一层,从左到右的顺序打印每个结点,比如上面的这棵二叉树,那么层序遍历的结果就是:ABCDEF,像这样一层一层的挨个输出。

虽然理解起来比较简单,但是如果让你编程写出来,该咋搞?是不是感觉有点无从下手?

我们可以利用队列来实现层序遍历,首先将根结点存入队列中,接着循环执行以下步骤:

  • 进行出队操作,得到一个结点,并打印结点的值。

  • 将此结点的左右孩子结点依次入队。

不断重复以上步骤,直到队列为空。

我们来分析一下,首先肯定一开始A在里面:

接着开始不断重复上面的步骤,首先是将队首元素出队,打印A,然后将A的左右孩子依次入队:

现在队列中有B、C两个结点,继续重复上述操作,B先出队,打印B,然后将B的左右孩子依次入队:

现在队列中有C、D、E这三个结点,继续重复,C出队并打印,然后将F入队:

我们发现,这个过程中,打印的顺序正好就是我们层序遍历的顺序,所以说队列还是非常有用的,这里我们可以直接把之前的队列拿来用。那么现在我们就来上代码吧,首先是之前的队列:

public class LinkedQueue<E> {
​
    private final Node<E> head = new Node<>(null);
​
    public void offer(E element){
        Node<E> last = head;
        while (last.next != null)
            last = last.next;
        last.next = new Node<>(element);
    }
​
    public E poll(){
        if(head.next == null)
            throw new NoSuchElementException("队列为空");
        E e = head.next.element;
        head.next = head.next.next;
        return e;
    }
    
    public boolean isEmpty(){   //这里多写了一个判断队列为空的操作,方便之后使用
        return head.next == null;   //直接看头结点后面还有没有东西就行了
    }
​
    private static class Node<E> {
        E element;
        Node<E> next;
​
        public Node(E element) {
            this.element = element;
        }
    }
}

我们来尝试编写一下层序遍历:

private static <T> void levelOrder(TreeNode<T> root){
    LinkedQueue<TreeNode<T>> queue = new LinkedQueue<>();  //创建一个队列
    queue.offer(root);    //将根结点丢进队列
    while (!queue.isEmpty()) {   //如果队列不为空,就一直不断地取出来
        TreeNode<T> node = queue.poll();   //取一个出来
        System.out.print(node.element);  //打印
        if(node.left != null) queue.offer(node.left);   //如果左右孩子不为空,直接将左右孩子丢进队列
        if(node.right != null) queue.offer(node.right);
    }
}

可以看到结果就是层序遍历的结果:

当然,使用递归也可以实现,但是需要单独存放结果然后单独输出,不是很方便,所以说这里就不演示了。

树:二叉查找树和平衡二叉树

注意:本部分只进行理论介绍,不做代码实现。

还记得我们开篇讲到的二分搜索算法吗?通过不断缩小查找范围,最终我们可以以很高的效率找到有序数组中的目标位置。而二叉查找树则利用了类似的思想,我们可以借助其来像二分搜索那样快速查找。

二叉查找树也叫二叉搜索树或是二叉排序树,它具有一定的规则:

  • 左子树中所有结点的值,均小于其根结点的值。

  • 右子树中所有结点的值,均大于其根结点的值。

  • 二叉搜索树的子树也是二叉搜索树。

一棵二叉搜索树长这样:

这棵树的根结点为18,而其根结点左边子树的根结点为10,包括后续结点,都是满足上述要求的。二叉查找树满足左边一定比当前结点小,右边一定比当前结点大的规则,比如我们现在需要在这颗树种查找值为15的结点:

  1. 从根结点18开始,因为15小于18,所以从左边开始找。

  2. 接着来到10,发现10比15小,所以继续往右边走。

  3. 来到15,成功找到。

实际上,我们在对普通二叉树进行搜索时,可能需要挨个进行查看比较,而有了二叉搜索树,查找效率就大大提升了,它就像我们前面的二分搜索那样。

利用二叉查找树,我们在搜索某个值的时候,效率会得到巨大提升。但是虽然看起来比较完美,也是存在缺陷的,比如现在我们依次将下面的值插入到这棵二叉树中:

20 15 13 8 6 3

在插入完成后,我们会发现这棵二叉树竟然长这样:

因为根据我们之前编写的插入规则,小的一律往左边放,现在正好来的就是这样一串递减的数字,最后就组成了这样的一棵只有一边的二叉树,这种情况,与其说它是一棵二叉树,不如说就是一个链表,如果这时我们想要查找某个结点,那么实际上查找的时间并没有得到任何优化,直接就退化成线性查找了。

所以,二叉查找树只有在理想情况下,查找效率才是最高的,而像这种极端情况,就性能而言几乎没有任何的提升。我们理想情况下,这样的效率是最高的:

所以,我们在进行结点插入时,需要尽可能地避免这种一边倒的情况,这里就需要引入平衡二叉树的概念了。实际上我们发现,在插入时如果不去维护二叉树的平衡,某一边只会无限制地延伸下去,出现极度不平衡的情况,而我们理想中的二叉查找树左右是尽可能保持平衡的,平衡二叉树(AVL树)就是为了解决这样的问题而生的。

它的性质如下:

  • 平衡二叉树一定是一棵二叉查找树。

  • 任意结点的左右子树也是一棵平衡二叉树。

  • 从根节点开始,左右子树都高度差不能超过1,否则视为不平衡。

可以看到,这些性质规定了平衡二叉树需要保持高度平衡,这样我们的查找效率才不会因为数据的插入而出现降低的情况。二叉树上节点的左子树高度 减去 右子树高度, 得到的结果称为该节点的平衡因子(Balance Factor),比如:

通过计算平衡因子,我们就可以快速得到是否出现失衡的情况。比如下面的这棵二叉树,正在执行插入操作:

可以看到,当插入之后,不再满足平衡二叉树的定义时,就出现了失衡的情况,而对于这种失衡情况,为了继续保持平衡状态,我们就需要进行处理了。我们可能会遇到以下几种情况导致失衡:

根据插入结点的不同偏向情况,分为LL型、LR型、RR型、RL型。针对于上面这几种情况,我们依次来看一下如何进行调整,使得这棵二叉树能够继续保持平衡:

动画网站:AVL Tree Visualzation(实在不理解可以看看动画是怎么走的)

  1. LL型调整(右旋)

    首先我们来看这种情况,这是典型的LL型失衡,为了能够保证二叉树的平衡,我们需要将其进行旋转来维持平衡,去纠正最小不平衡子树即可。那么怎么进行旋转呢?对于LL型失衡,我们只需要进行右旋操作,首先我们先找到最小不平衡子树,注意是最小的那一个:

    可以看到根结点的平衡因子是2,是目前最小的出现不平衡的点,所以说从根结点开始向左的三个结点需要进行右旋操作,右旋需要将这三个结点中间的结点作为新的根结点,而其他两个结点现在变成左右子树:

    这样,我们就完成了右旋操作,可以看到右旋之后,所有的结点继续保持平衡,并且依然是一棵二叉查找树。

  2. RR型调整(左旋)

    前面我们介绍了LL型以及右旋解决方案,相反的,当遇到RR型时,我们只需要进行左旋操作即可:

    操作和上面是一样的,只不过现在反过来了而已:

    这样,我们就完成了左旋操作,使得这棵二叉树继续保持平衡状态了。

  3. RL型调整(先右旋,再左旋)

    剩下两种类型比较麻烦,需要旋转两次才行。我们来看看RL型长啥样:

    可以看到现在的形状是一个回旋镖形状的,先右后左的一个状态,也就是RL型,针对于这种情况,我们需要先进行右旋操作,注意这里的右旋操作针对的是后两个结点:

    其中右旋和左旋的操作,与之前一样,该怎么分配左右子树就怎么分配,完成两次旋转后,可以看到二叉树重新变回了平衡状态。

  4. LR型调整(先左旋,再右旋)

    和上面一样,我们来看看LR型长啥样,其实就是反着的:

    形状是先向左再向右,这就是典型的LR型了,我们同样需要对其进行两次旋转:

    这里我们先进行的是左旋,然后再进行的右旋,这样二叉树就能继续保持平衡了。

这样,我们只需要在插入结点时注意维护整棵树的平衡因子,保证其处于稳定状态,这样就可以让这棵树一直处于高度平衡的状态,不会再退化了。

树:红黑树

注意:本部分只进行理论介绍,不做代码实现。

很多人都说红黑树难,其实就那几条规则,跟着我推一遍其实还是很简单的,当然前提是一定要把前面的平衡二叉树搞明白。

前面我们讲解了二叉平衡树,通过在插入结点时维护树的平衡,这样就不会出现极端情况使得整棵树的查找效率急剧降低了。但是这样是否开销太大了一点,因为一旦平衡因子的绝对值超过1那么就失衡,这样每插入一个结点,就有很大的概率会导致失衡,我们能否不这么严格,但同时也要在一定程度上保证平衡呢?这就要提到红黑树了。

在线动画网站:Red/Black Tree Visualization 

红黑树也是二叉查找树的一种,它大概长这样,可以看到结点有红有黑:

它并不像平衡二叉树那样严格要求高度差不能超过1,而是只需要满足五个规则即可,它的规则如下:

  • 规则1:每个结点可以是黑色或是红色。

  • 规则2:根结点一定是黑色。

  • 规则3:红色结点的父结点和子结点不能为红色,也就是说不能有两个连续的红色。

  • 规则4:所有的空结点都是黑色(空结点视为NIL,红黑树中是将空节点视为叶子结点)

  • 规则5:每个结点到空节点(NIL)路径上出现的黑色结点的个数都相等。

它相比平衡二叉树,通过不严格平衡和改变颜色,就能在一定程度上减少旋转次数,这样的话对于整体性能是有一定提升的,只不过我们在插入结点时,就有点麻烦了,我们需要同时考虑变色和旋转这两个操作了,但是会比平衡二叉树更简单。

那么什么时候需要变色,什么时候需要旋转呢?我们通过一个简单例子来看看:

首先这棵红黑树只有一个根结点,因为根结点必须是黑色,所以说直接变成黑色。现在我们要插入一个新的结点了,所有新插入的结点,默认情况下都是红色:

所以新来的结点7根据规则就直接放到11的左边就行了,然后注意7的左右两边都是NULL,那么默认都是黑色,这里就不画出来了。同样的,我们往右边也来一个:

现在我们继续插入一个结点:

插入结点4之后,此时违反了红黑树的规则3,因为红色结点的父结点和子结点不能为红色,此时为了保持以红黑树的性质,我们就需要进行颜色变换才可以,那么怎么进行颜色变换呢?我们只需要直接将父结点和其兄弟结点同时修改为黑色(为啥兄弟结点也需要变成黑色?因为要满足性质5)然后将爷爷结点改成红色即可:

当然这里还需注意一下,因为爷爷结点正常情况会变成红色,相当于新来了个红色的,这时还得继续往上看有没有破坏红黑树的规则才可以,直到没有为止,比如这里就破坏了性质一,爷爷结点现在是根结点(不是根结点就不需要管了),必须是黑色,所以说还要给它改成黑色才算结束:

接着我们继续插入结点:

此时又来了一个插在4左边的结点,同样是连续红色,我们需要进行变色才可以讲解问题,但是我们发现,如果变色的话,那么从11开始到所有NIL结点经历的黑色结点数量就不对了:

所以说对于这种父结点为红色,父结点的兄弟结点为黑色(NIL视为黑色)的情况,变色无法解决问题了,那么我们只能考虑旋转了,旋转规则和我们之前讲解的平衡二叉树是一样的,这实际上是一种LL型失衡:

同样的,如果遇到了LR型失衡,跟前面一样,先左旋在右旋,然后进行变色即可:

而RR型和RL型同理,这里就不进行演示了,可以看到,红黑树实际上也是通过颜色规则在进行旋转调整的,当然旋转和变色的操作顺序可以交换。所以,在插入时比较关键的判断点如下:

  • 如果整棵树为NULL,直接作为根结点,变成黑色。

  • 如果父结点是黑色,直接插入就完事。

  • 如果父结点为红色,且父结点的兄弟结点也是红色,直接变色即可(但是注意得继续往上看有没有破坏之前的结构)

  • 如果父结点为红色,但父结点的兄弟结点为黑色,需要先根据情况(LL、RR、LR、RL)进行旋转,然后再变色。

在了解这些步骤之后,我们其实已经可以尝试去编写一棵红黑树出来了,当然代码太过复杂,这里就不演示了。

哈希表

在之前,我们已经学习了多种查找数据的方式,比如最简单的,如果数据量不大的情况下,我们可以直接通过顺序查找的方式在集合中搜索我们想要的元素;当数据量较大时,我们可以使用二分搜索来快速找到我们想要的数据,不过需要要求数据按照顺序排列,并且不允许中途对集合进行修改。

在学习完树形结构篇之后,我们可以利用二叉查找树来建立一个便于我们查找的树形结构,甚至可以将其优化为平衡二叉树或是红黑树来进一步提升稳定性。

这些都能够极大地帮助我们查找数据,而散列表,则是我们数据结构系列内容的最后一块重要知识。

散列(Hashing)通过散列函数(哈希函数)将要参与检索的数据与散列值(哈希值)关联起来,生成一种便于搜索的数据结构,我们称其为散列表(哈希表),也就是说,现在我们需要将一堆数据保存起来,这些数据会通过哈希函数进行计算,得到与其对应的哈希值,当我们下次需要查找这些数据时,只需要再次计算哈希值就能快速找到对应的元素了:

散列函数也叫哈希函数,哈希函数可以对一个目标计算出其对应的哈希值,并且,只要是同一个目标,无论计算多少次,得到的哈希值都是一样的结果,不同的目标计算出的结果介乎都不同。哈希函数在现实生活中应用十分广泛,比如很多下载网站都提供下载文件的MD5码校验,可以用来判别文件是否完整,哈希函数多种多样,目前应用最为广泛的是SHA-1和MD5,比如我们在下载IDEA之后,会看到有一个验证文件SHA-256校验和的选项,我们可以点进去看看:

点进去之后,得到:

e54a026da11d05d9bb0172f4ef936ba2366f985b5424e7eecf9e9341804d65bf *ideaIU-2022.2.1.dmg

这一串由数字和小写字母随意组合的一个字符串,就是安装包文件通过哈希算法计算得到的结果,那么这个东西有什么用呢?我们的网络可能有时候会出现卡顿的情况,导致我们下载的文件可能会出现不完整的情况,因为哈希函数对同一个文件计算得到的结果是一样的,我们可以在本地使用同样的哈希函数去计算下载文件的哈希值,如果与官方一致,那么就说明是同一个文件,如果不一致,那么说明文件在传输过程中出现了损坏。

可见,哈希函数在这些地方就显得非常实用,在我们的生活中起了很大的作用,它也可以用于布隆过滤器和负载均衡等场景,这里不多做介绍了。

前面我们介绍了散列函数,我们知道可以通过散列函数计算一个目标的哈希值,那么这个哈希值计算出来有什么用呢,对我们的程序设计有什么意义呢?我们可以利用哈希值的特性,设计一张全新的表结构,这种表结构是专为哈希设立的,我们称其为哈希表(散列表)

我们可以将这些元素保存到哈希表中,而保存的位置则与其对应的哈希值有关,哈希值是通过哈希函数计算得到的,我们只需要将对应元素的关键字(一般是整数)提供给哈希函数就可以进行计算了,一般比较简单的哈希函数就是取模操作,哈希表长度是多少(长度最好是一个素数),模就是多少:

比如现在我们需要插入一个新的元素(关键字为17)到哈希表中:

插入的位置为计算出来的哈希值,比如上面是8,那么就在下标位置8插入元素,同样的,我们继续插入27:

这样,我们就可以将多种多样的数据保存到哈希表中了,注意保存的数据是无序的,因为我们也不清楚计算完哈希值最后会放到哪个位置。那么如果现在我们想要从哈希表中查找数据呢?比如我们现在需要查找哈希表中是否有14这个元素:

同样的,直接去看哈希值对应位置上看看有没有这个元素,如果没有,那么就说明哈希表中没有这个元素。可以看到,哈希表在查找时只需要进行一次哈希函数计算就能直接找到对应元素的存储位置,效率极高。

我们来尝试编写一下:

public class HashTable<E> {
    private final int TABLE_SIZE = 10;
    private final Object[] TABLE = new Object[TABLE_SIZE];
    
    public void insert(E element){
        int index = hash(element);
        TABLE[index] = element;
    }
    
    public boolean contains(E element){
        int index = hash(element);
        return TABLE[index] == element;
    }
    
    private int hash(Object object){   //哈希函数,计算出存放的位置
        int hashCode = object.hashCode();  
        //每一个对象都有一个独一无二的哈希值,可以通过hashCode方法得到(只有极小的概率会出现相同的情况)
        return hashCode % TABLE_SIZE;
    }
}

这样,我们就实现了一个简单的哈希表和哈希函数,通过哈希表,我们可以将数据的查找时间复杂度提升到常数阶。

前面我介绍了哈希函数,通过哈希函数计算得到一个目标的哈希值,但是在某些情况下,哈希值可能会出现相同的情况:

比如现在同时插入14和23这两个元素,他们两个计算出来的哈希值是一样的,都需要在5号下标位置插入,这时就出现了打架的情况,那么到底是把哪一个放进去呢?这种情况,我们称为哈希碰撞(哈希冲突)

这种问题是很严重的,因为哈希函数的设计不同,难免会出现这种情况,这种情况是不可避免的,我们只能通过使用更加高级的哈希函数来尽可能避免这种情况,但是无法完全避免。当然,如果要完全解决这种问题,我们还需要去寻找更好的方法。这里我们只介绍一种比较重要的,会在后面集合类中用到的方案。

实际上常见的哈希冲突解决方案是链地址法,当出现哈希冲突时,我们依然将其保存在对应的位置上,我们可以将其连接为一个链表的形式:

当表中元素变多时,差不多就变成了这样,我们一般将其横过来看:

通过结合链表的形式,哈希冲突问题就可以得到解决了,但是同时也会出现一定的查找开销,因为现在有了链表,我们得挨个往后看才能找到,当链表变得很长时,查找效率也会变低,此时我们可以考虑结合其他的数据结构来提升效率。比如当链表长度达到8时,自动转换为一棵平衡二叉树或是红黑树,这样就可以在一定程度上缓解查找的压力了。

public class HashTable<E> {
    private final int TABLE_SIZE = 10;
    private final Node<E>[] TABLE = new Node[TABLE_SIZE];
​
    public HashTable(){
        for (int i = 0; i < TABLE_SIZE; i++)
            TABLE[i] = new Node<>(null);
    }
​
    public void insert(E element){
        int index = hash(element);
        Node<E> prev = TABLE[index];
        while (prev.next != null)
            prev = prev.next;
        prev.next = new Node<>(element);
    }
​
    public boolean contains(E element){
        int index = hash(element);
        Node<E> node = TABLE[index].next;
        while (node != null) {
            if(node.element == element)
                return true;
            node = node.next;
        }
        return false;
    }
​
    private int hash(Object object){
        int hashCode = object.hashCode();
        return hashCode % TABLE_SIZE;
    }
​
    private static class Node<E> {
        private final E element;
        private Node<E> next;
​
        private Node(E element){
            this.element = element;
        }
    }
}

实际上这种方案代码写起来也会更简单,使用也更方便一些。

至此,数据结构相关内容,我们就讲解到这里,学习这些数据结构,实际上也是为了方便各位小伙伴对于后续结合类的学习,因为集合类的底层实现就是这些数据结构。


实战练习

合理利用集合类,我们可以巧妙地解决各种各样的难题。

反转链表

本题来自LeetCode:206. 反转链表

给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。

示例 1:

输入:head = [1,2,3,4,5] 输出:[5,4,3,2,1]

示例 2:

输入:head = [1,2] 输出:[2,1]

这道题依然是考察各位小伙伴对于链表相关操作的掌握程度,我们如何才能将一个链表的顺序进行反转,关键就在于如何修改每个节点的指针指向。

括号匹配问题

本题来自LeetCode:20. 有效的括号

给定一个只包括 '(',')','{','}','[',']' 的字符串 s ,判断字符串是否有效。

有效字符串需满足:

  1. 左括号必须用相同类型的右括号闭合。

  2. 左括号必须以正确的顺序闭合。

示例 1:

输入:s = "()" 输出:true

示例 2:

输入:s = "()[]{}" 输出:true

示例 3:

输入:s = "(]" 输出:false

示例 4:

输入:s = "([)]" 输出:false

示例 5:

输入:s = "{[]}" 输出:true

题干很明确,就是需要我们去对这些括号完成匹配,如果给定字符串中的括号无法完成一一匹配的话,那么就表示匹配失败。实际上这种问题我们就可以利用前面学习的栈这种数据结构来解决,我们可以将所有括号的左半部分放入栈中,当遇到右半部分时,进行匹配,如果匹配失败,那么就失败,如果匹配成功,那么就消耗一个左半部分,直到括号消耗完毕。

实现计算器

输入一个计算公式(含加减乘除运算符,没有负数但是有小数)得到结果,比如输入:1+4*3/1.321,得到结果为:2.2

现在请你设计一个Java程序,实现计算器。

集合类与IO

前面我们已经把基础介绍完了,从这节课开始,我们就正式进入到集合类的讲解中。

集合类

集合类是Java中非常重要的存在,使用频率极高。集合其实与我们数学中的集合是差不多的概念,集合表示一组对象,每一个对象我们都可以称其为元素。不同的集合有着不同的性质,比如一些集合允许重复的元素,而另一些则不允许,一些集合是有序的,而其他则是无序的。

集合类其实就是为了更好地组织、管理和操作我们的数据而存在的,包括列表、集合、队列、映射等数据结构。从这一块开始,我们会从源码角度给大家讲解(先从接口定义对于集合需要实现哪些功能开始说起,包括这些集合类的底层机制是如何运作的)不仅仅是教会大家如何去使用。

集合跟数组一样,可以表示同样的一组元素,但是他们的相同和不同之处在于:

  1. 它们都是容器,都能够容纳一组元素。

不同之处:

  1. 数组的大小是固定的,集合的大小是可变的。

  2. 数组可以存放基本数据类型,但集合只能存放对象。

  3. 数组存放的类型只能是一种,但集合可以有不同种类的元素。

1.集合根接口

Java中已经帮我们将常用的集合类型都实现好了,我们只需要直接拿来用就行了,比如我们之前学习的顺序表:

import java.util.ArrayList;   //集合类基本都是在java.util包下定义的
​
public class Main {
    public static void main(String[] args) {
        ArrayList<String> list = new ArrayList<>();
        list.add("hello");
    }
}

当然,我们会在这一部分中认识大部分Java为我们提供的集合类。所有的集合类最终都是实现自集合根接口的,比如我们下面就会讲到的ArrayList类,它的祖先就是Collection接口:

这个接口定义了集合类的一些基本操作,我们来看看有哪些方法:

public interface Collection<E> extends Iterable<E> {
    //-------这些是查询相关的操作----------
​
    //获取当前集合中的元素数量
    int size();
​
    //查看当前集合是否为空
    boolean isEmpty();
​
    //查询当前集合中是否包含某个元素
    boolean contains(Object o);
​
    //返回当前集合的迭代器,我们会在后面介绍
    Iterator<E> iterator();
​
    //将集合转换为数组的形式
    Object[] toArray();
​
    //支持泛型的数组转换,同上
    <T> T[] toArray(T[] a);
​
    //-------这些是修改相关的操作----------
​
    //向集合中添加元素,不同的集合类具体实现可能会对插入的元素有要求,
    //这个操作并不是一定会添加成功,所以添加成功返回true,否则返回false
    boolean add(E e);
​
    //从集合中移除某个元素,同样的,移除成功返回true,否则false
    boolean remove(Object o);
​
​
    //-------这些是批量执行的操作----------
​
    //查询当前集合是否包含给定集合中所有的元素
    //从数学角度来说,就是看给定集合是不是当前集合的子集
    boolean containsAll(Collection<?> c);
​
    //添加给定集合中所有的元素
    //从数学角度来说,就是将当前集合变成当前集合与给定集合的并集
    //添加成功返回true,否则返回false
    boolean addAll(Collection<? extends E> c);
​
    //移除给定集合中出现的所有元素,如果某个元素在当前集合中不存在,那么忽略这个元素
    //从数学角度来说,就是求当前集合与给定集合的差集
    //移除成功返回true,否则false
    boolean removeAll(Collection<?> c);
​
    //Java8新增方法,根据给定的Predicate条件进行元素移除操作
    default boolean removeIf(Predicate<? super E> filter) {
        Objects.requireNonNull(filter);
        boolean removed = false;
        final Iterator<E> each = iterator();   //这里用到了迭代器,我们会在后面进行介绍
        while (each.hasNext()) {
            if (filter.test(each.next())) {
                each.remove();
                removed = true;
            }
        }
        return removed;
    }
​
    //只保留当前集合中在给定集合中出现的元素,其他元素一律移除
    //从数学角度来说,就是求当前集合与给定集合的交集
    //移除成功返回true,否则false
    boolean retainAll(Collection<?> c);
​
    //清空整个集合,删除所有元素
    void clear();
​
​
    //-------这些是比较以及哈希计算相关的操作----------
​
    //判断两个集合是否相等
    boolean equals(Object o);
​
    //计算当前整个集合对象的哈希值
    int hashCode();
​
    //与迭代器作用相同,但是是并行执行的,我们会在下一章多线程部分中进行介绍
    @Override
    default Spliterator<E> spliterator() {
        return Spliterators.spliterator(this, 0);
    }
​
    //生成当前集合的流,我们会在后面进行讲解
    default Stream<E> stream() {
        return StreamSupport.stream(spliterator(), false);
    }
​
    //生成当前集合的并行流,我们会在下一章多线程部分中进行介绍
    default Stream<E> parallelStream() {
        return StreamSupport.stream(spliterator(), true);
    }
}

可以看到,在这个接口中对于集合相关的操作,还是比较齐全的,那么我们接着就来看看它的实现类。

2.List列表

首先我们需要介绍的是List列表(线性表),线性表支持随机访问,相比之前的Collection接口定义,功能还会更多一些。首先介绍ArrayList,我们已经知道,它的底层是用数组实现的,内部维护的是一个可动态进行扩容的数组,也就是我们之前所说的顺序表,跟我们之前自己写的ArrayList相比,它更加的规范,并且功能更加强大,同时实现自List接口。

List是集合类型的一个分支,它的主要特性有:

  • 是一个有序的集合,插入元素默认是插入到尾部,按顺序从前往后存放,每个元素都有一个自己的下标位置

  • 列表中允许存在重复元素

在List接口中,定义了列表类型需要支持的全部操作,List直接继承自前面介绍的Collection接口,其中很多地方重新定义了一次Collection接口中定义的方法,这样做是为了更加明确方法的具体功能,当然,为了直观,我们这里就省略掉:

//List是一个有序的集合类,每个元素都有一个自己的下标位置
//List中可插入重复元素
//针对于这些特性,扩展了Collection接口中一些额外的操作
public interface List<E> extends Collection<E> {
    ...
    
    //将给定集合中所有元素插入到当前结合的给定位置上(后面的元素就被挤到后面去了,跟我们之前顺序表的插入是一样的)
    boolean addAll(int index, Collection<? extends E> c);
​
    ...
​
    //Java 8新增方法,可以对列表中每个元素都进行处理,并将元素替换为处理之后的结果
    default void replaceAll(UnaryOperator<E> operator) {
        Objects.requireNonNull(operator);
        final ListIterator<E> li = this.listIterator();  //这里同样用到了迭代器
        while (li.hasNext()) {
            li.set(operator.apply(li.next()));
        }
    }
​
    //对当前集合按照给定的规则进行排序操作,这里同样只需要一个Comparator就行了
    @SuppressWarnings({"unchecked", "rawtypes"})
    default void sort(Comparator<? super E> c) {
        Object[] a = this.toArray();
        Arrays.sort(a, (Comparator) c);
        ListIterator<E> i = this.listIterator();
        for (Object e : a) {
            i.next();
            i.set((E) e);
        }
    }
​
    ...
​
    //-------- 这些是List中独特的位置直接访问操作 --------
​
    //获取对应下标位置上的元素
    E get(int index);
​
    //直接将对应位置上的元素替换为给定元素
    E set(int index, E element);
​
    //在指定位置上插入元素,就跟我们之前的顺序表插入是一样的
    void add(int index, E element);
​
    //移除指定位置上的元素
    E remove(int index);
​
​
    //------- 这些是List中独特的搜索操作 -------
​
    //查询某个元素在当前列表中的第一次出现的下标位置
    int indexOf(Object o);
​
    //查询某个元素在当前列表中的最后一次出现的下标位置
    int lastIndexOf(Object o);
​
​
    //------- 这些是List的专用迭代器 -------
​
    //迭代器我们会在下一个部分讲解
    ListIterator<E> listIterator();
​
    //迭代器我们会在下一个部分讲解
    ListIterator<E> listIterator(int index);
​
    //------- 这些是List的特殊转换 -------
​
    //返回当前集合在指定范围内的子集
    List<E> subList(int fromIndex, int toIndex);
​
    ...
}

可以看到,在List接口中,扩展了大量列表支持的操作,其中最突出的就是直接根据下标位置进行的增删改查操作。而在ArrayList中,底层就是采用数组实现的,跟我们之前的顺序表思路差不多:

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
        
    //默认的数组容量
    private static final int DEFAULT_CAPACITY = 10;
​
    ...
​
    //存放数据的底层数组,这里的transient关键字我们会在后面I/O中介绍用途
    transient Object[] elementData;
​
    //记录当前数组元素数的
    private int size;
​
    //这是ArrayList的其中一个构造方法
    public ArrayList(int initialCapacity) {
        if (initialCapacity > 0) {
            this.elementData = new Object[initialCapacity];   //根据初始化大小,创建当前列表
        } else if (initialCapacity == 0) {
            this.elementData = EMPTY_ELEMENTDATA;
        } else {
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        }
    }
  
    ...
      
    public boolean add(E e) {
        ensureCapacityInternal(size + 1);  // 这里会判断容量是否充足,不充足需要扩容
        elementData[size++] = e;
        return true;
    }
    
    ...
    
    //默认的列表最大长度为Integer.MAX_VALUE - 8
    //JVM都C++实现中,在数组的对象头中有一个_length字段,用于记录数组的长
    //度,所以这个8就是存了数组_length字段(这个只做了解就行)
        private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
    
    private void grow(int minCapacity) {
        int oldCapacity = elementData.length;
        int newCapacity = oldCapacity + (oldCapacity >> 1);   //扩容规则跟我们之前的是一样的,也是1.5倍
        if (newCapacity - minCapacity < 0)    //要是扩容之后的大小还没最小的大小大,那么直接扩容到最小的大小
            newCapacity = minCapacity;
        if (newCapacity - MAX_ARRAY_SIZE > 0)   //要是扩容之后比最大的大小还大,需要进行大小限制
            newCapacity = hugeCapacity(minCapacity);  //调整为限制的大小
        elementData = Arrays.copyOf(elementData, newCapacity);   //使用copyOf快速将内容拷贝到扩容后的新数组中并设定为新的elementData底层数组
    }
}

一般的,如果我们要使用一个集合类,我们会使用接口的引用:

public static void main(String[] args) {
    List<String> list = new ArrayList<>();   //使用接口的引用来操作具体的集合类实现,是为了方便日后如果我们想要更换不同的集合类实现,而且接口中本身就已经定义了主要的方法,所以说没必要直接用实现类
    list.add("科技与狠活");   //使用add添加元素
    list.add("上头啊");
    System.out.println(list);   //打印集合类,可以得到一个非常规范的结果
}

可以看到,打印集合类的效果,跟我们使用Arrays工具类是一样的:

集合的各种功能我们都可以来测试一下,特别注意一下,我们在使用Integer时,要注意传参问题:

public static void main(String[] args) {
    List<Integer> list = new ArrayList<>();
    list.add(10);   //添加Integer的值10
    list.remove((Integer) 10);   //注意,不能直接用10,默认情况下会认为传入的是int类型值,删除的是下标为10的元素,我们这里要删除的是刚刚传入的值为10的Integer对象
    System.out.println(list);   //可以看到,此时元素成功被移除
}

那要是这样写呢?

public static void main(String[] args) {
    List<Integer> list = new ArrayList<>();
    list.add(new Integer(10));   //添加的是一个对象
    list.remove(new Integer(10));   //删除的是另一个对象
    System.out.println(list);
}

可以看到,结果依然是删除成功,这是因为集合类在删除元素时,只会调用equals方法进行判断是否为指定元素,而不是进行等号判断,所以说一定要注意,如果两个对象使用equals方法相等,那么集合中就是相同的两个对象:

//ArrayList源码部分
public boolean remove(Object o) {
    if (o == null) {
        ...
    } else {
        for (int index = 0; index < size; index++)
            if (o.equals(elementData[index])) {   //这里只是对两个对象进行equals判断
                fastRemove(index);
                return true;  //只要判断成功,直接认为就是要删除的对象,删除就完事
            }
    }
    return false;
}

列表中允许存在相同元素,所以说我们可以添加两个一模一样的:

public static void main(String[] args) {
    List<String> list = new ArrayList<>();
    String str = "哟唉嘛干你";
    list.add(str);
    list.add(str);
    System.out.println(list);
}

那要是此时我们删除对象呢,是一起删除还是只删除一个呢?

public static void main(String[] args) {
    List<String> list = new ArrayList<>();
    String str = "哟唉嘛干你";
    list.add(str);
    list.add(str);
    list.remove(str);
    System.out.println(list);
}

可以看到,这种情况下,只会删除排在前面的第一个元素。

集合类是支持嵌套使用的,一个集合中可以存放多个集合,套娃嘛,谁不会:

public static void main(String[] args) {
    List<List<String>> list = new LinkedList<>();
    list.add(new LinkedList<>());   //集合中的每一个元素就是一个集合,这个套娃是可以一直套下去的
    System.out.println(list.get(0).isEmpty());
}

在Arrays工具类中,我们可以快速生成一个只读的List:

public static void main(String[] args) {
    List<String> list = Arrays.asList("A", "B", "C");   //非常方便
    System.out.println(list);
}

注意,这个生成的List是只读的,不能进行修改操作,只能使用获取内容相关的方法,否则抛出 UnsupportedOperationException 异常。要生成正常使用的,我们可以将这个只读的列表作为参数传入:

public static void main(String[] args) {
    List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
    System.out.println(list);
}

当然,也可以利用静态代码块:

public static void main(String[] args) {
    List<String> list = new ArrayList<String>() {{   //使用匿名内部类(匿名内部类在Java8无法使用钻石运算符,但是之后的版本可以)
            add("A");
            add("B");
            add("C");
    }};
    System.out.println(list);
}

这里我们接着介绍另一个列表实现类,LinkedList同样是List的实现类,只不过它是采用的链式实现,也就是我们之前讲解的链表,只不过它是一个双向链表,也就是同时保存两个方向:

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;
​
    //构造方法,很简单,直接创建就行了
    public LinkedList() {
    }
  
    ...
      
    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;
        }
    }
  
    ...
}

LinkedList的使用和ArrayList的使用几乎相同,各项操作的结果也是一样的,在什么使用使用ArrayList和LinkedList,我们需要结合具体的场景来决定,尽可能的扬长避短。

只不过LinkedList不仅可以当做List来使用,也可以当做双端队列使用,我们会在后面进行详细介绍。

3.迭代器

我们接着来介绍迭代器,实际上我们的集合类都是支持使用foreach语法的:

public static void main(String[] args) {
    List<String> list = Arrays.asList("A", "B", "C");
    for (String s : list) {   //集合类同样支持这种语法
        System.out.println(s);
    }
}

但是由于仅仅是语法糖,实际上编译之后:

public static void main(String[] args) {
    List<String> list = Arrays.asList("A", "B", "C");
    Iterator var2 = list.iterator();   //这里使用的是List的迭代器在进行遍历操作
​
    while(var2.hasNext()) {
        String s = (String)var2.next();
        System.out.println(s);
    }
​
}

那么这个迭代器是一个什么东西呢?我们来研究一下:

public static void main(String[] args) {
    List<String> list = Arrays.asList("A", "B", "C");
    //通过调用iterator方法快速获取当前集合的迭代器
    //Iterator迭代器本身也是一个接口,由具体的集合实现类来根据情况实现
    Iterator<String> iterator = list.iterator();
}

通过使用迭代器,我们就可以实现对集合中的元素的进行遍历,就像我们遍历数组那样,它的运作机制大概是:

一个新的迭代器就像上面这样,默认有一个指向集合中第一个元素的指针:

每一次next操作,都会将指针后移一位,直到完成每一个元素的遍历,此时再调用next将不能再得到下一个元素。至于为什么要这样设计,是因为集合类的实现方案有很多,可能是链式存储,也有可能是数组存储,不同的实现有着不同的遍历方式,而迭代器则可以将多种多样不同的集合类遍历方式进行统一,只需要各个集合类根据自己的情况进行对应实现就行了。

我们来看看这个接口的源码定义了哪些操作:

public interface Iterator<E> {
    //看看是否还有下一个元素
    boolean hasNext();
​
    //遍历当前元素,并将下一个元素作为待遍历元素
    E next();
​
    //移除上一个被遍历的元素(某些集合不支持这种操作)
    default void remove() {
        throw new UnsupportedOperationException("remove");
    }
​
    //对剩下的元素进行自定义遍历操作
    default void forEachRemaining(Consumer<? super E> action) {
        Objects.requireNonNull(action);
        while (hasNext())
            action.accept(next());
    }
}

在ArrayList和LinkedList中,迭代器的实现也不同,比如ArrayList就是直接按下标访问:

public E next() {
    ...
    cursor = i + 1;   //移动指针
    return (E) elementData[lastRet = i];  //直接返回指针所指元素
}

LinkedList就是不断向后寻找结点:

public E next() {
    ...
    next = next.next;   //向后继续寻找结点
    nextIndex++;
    return lastReturned.item;  //返回结点内部存放的元素
}

虽然这两种列表的实现不同,遍历方式也不同,但是都是按照迭代器的标准进行了实现,所以说,我们想要遍历一个集合中所有的元素,那么就可以直接使用迭代器来完成,而不需要关心集合类是如何实现,我们该怎么去遍历:

public static void main(String[] args) {
    List<String> list = Arrays.asList("A", "B", "C");
    Iterator<String> iterator = list.iterator();
    while (iterator.hasNext()) {    //每次循环一定要判断是否还有元素剩余
        System.out.println(iterator.next());  //如果有就可以继续获取到下一个元素
    }
}

注意,迭代器的使用是一次性的,用了之后就不能用了,如果需要再次进行遍历操作,那么需要重新生成一个迭代器对象。为了简便,我们可以直接使用foreach语法来快速遍历集合类,效果是完全一样的:

public static void main(String[] args) {
    List<String> list = Arrays.asList("A", "B", "C");
    for (String s : list) {
        System.out.println(s);
    }
}

在Java8提供了一个支持Lambda表达式的forEach方法,这个方法接受一个Consumer,也就是对遍历的每一个元素进行的操作:

public static void main(String[] args) {
    List<String> list = Arrays.asList("A", "B", "C");
    list.forEach(System.out::println);
}

这个效果跟上面的写法是完全一样的,因为forEach方法内部本质上也是迭代器在处理,这个方法是在Iterable接口中定义的:

default void forEach(Consumer<? super T> action) {
    Objects.requireNonNull(action);
    for (T t : this) {   //foreach语法遍历每一个元素
        action.accept(t);   //调用Consumer的accept来对每一个元素进行消费
    }
}

那么我们来看一下,Iterable这个接口又是是什么东西?

我们来看看定义了哪些内容:

//注意这个接口是集合接口的父接口,不要跟之前的迭代器接口搞混了
public interface Iterable<T> {
    //生成当前集合的迭代器,在Collection接口中重复定义了一次
    Iterator<T> iterator();
​
    //Java8新增方法,因为是在顶层接口中定义的,因此所有的集合类都有这个方法
    default void forEach(Consumer<? super T> action) {
        Objects.requireNonNull(action);
        for (T t : this) {
            action.accept(t);
        }
    }
​
    //这个方法会在多线程部分中进行介绍,暂时不做讲解
    default Spliterator<T> spliterator() {
        return Spliterators.spliteratorUnknownSize(iterator(), 0);
    }
}

得益于Iterable提供的迭代器生成方法,实际上只要是实现了迭代器接口的类(我们自己写的都行),都可以使用foreach语法:

public class Test implements Iterable<String>{   //这里我们随便写一个类,让其实现Iterable接口
    @Override
    public Iterator<String> iterator() {
        return new Iterator<String>() {   //生成一个匿名的Iterator对象
            @Override
            public boolean hasNext() {   //这里随便写的,直接返回true,这将会导致无限循环
                return true;
            }
​
            @Override
            public String next() {   //每次就直接返回一个字符串吧
                return "测试";
            }
        };
    }
}

可以看到,直接就支持这种语法了,虽然我们这个是自己写的,并不是集合类:

public static void main(String[] args) {
    Test test = new Test();
    for (String s : test) {
        System.out.println(s);
    }
}

是不是感觉集合类的设计非常巧妙?

我们这里再来介绍一下ListIterator,这个迭代器是针对于List的强化版本,增加了更多方便的操作,因为List是有序集合,所以它支持两种方向的遍历操作,不仅能从前向后,也可以从后向前:

public interface ListIterator<E> extends Iterator<E> {
    //原本就有的
    boolean hasNext();
​
    //原本就有的
    E next();
​
    //查看前面是否有已经遍历的元素
    boolean hasPrevious();
​
    //跟next相反,这里是倒着往回遍历
    E previous();
​
    //返回下一个待遍历元素的下标
    int nextIndex();
​
    //返回上一个已遍历元素的下标
    int previousIndex();
​
    //原本就有的
    void remove();
​
    //将上一个已遍历元素修改为新的元素
    void set(E e);
​
    //在遍历过程中,插入新的元素到当前待遍历元素之前
    void add(E e);
}

我们来测试一下吧:

public static void main(String[] args) {
    List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
    ListIterator<String> iterator = list.listIterator();
    iterator.next();   //此时得到A
    iterator.set("X");  //将A原本位置的上的元素设定为成新的
    System.out.println(list);
}

这种迭代器因为能够双向遍历,所以说可以反复使用。

4.Queue和Deque

通过前面的学习,我们已经了解了List的使用,其中LinkedList除了可以直接当做列表使用之外,还可以当做其他的数据结构使用,可以看到它不仅仅实现了List接口:

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

这个Deque接口是干嘛的呢?我们先来看看它的继承结构:

我们先来看看队列接口,它扩展了大量队列相关操作:

public interface Queue<E> extends Collection<E> {
    //队列的添加操作,是在队尾进行插入(只不过List也是一样的,默认都是尾插)
    //如果插入失败,会直接抛出异常
    boolean add(E e);
​
    //同样是添加操作,但是插入失败不会抛出异常
    boolean offer(E e);
​
    //移除队首元素,但是如果队列已经为空,那么会抛出异常
    E remove();
​
    //同样是移除队首元素,但是如果队列为空,会返回null
    E poll();
​
    //仅获取队首元素,不进行出队操作,但是如果队列已经为空,那么会抛出异常
    E element();
​
    //同样是仅获取队首元素,但是如果队列为空,会返回null
    E peek();
}

我们可以直接将一个LinkedList当做一个队列来使用:

public static void main(String[] args) {
    Queue<String> queue = new LinkedList<>();   //当做队列使用,还是很方便的
    queue.offer("AAA");
    queue.offer("BBB");
    System.out.println(queue.poll());
    System.out.println(queue.poll());
}

我们接着来看双端队列,实际上双端队列就是队列的升级版,我们一个普通的队列就是:

普通队列中从队尾入队,队首出队,而双端队列允许在队列的两端进行入队和出队操作:

利用这种特性,双端队列既可以当做普通队列使用,也可以当做栈来使用,我们来看看Java中是如何定义的Deque双端队列接口的:

//在双端队列中,所有的操作都有分别对应队首和队尾的
public interface Deque<E> extends Queue<E> {
    //在队首进行插入操作
    void addFirst(E e);
​
    //在队尾进行插入操作
    void addLast(E e);
        
    //不用多说了吧?
    boolean offerFirst(E e);
    boolean offerLast(E e);
​
    //在队首进行移除操作
    E removeFirst();
​
    //在队尾进行移除操作
    E removeLast();
​
    //不用多说了吧?
    E pollFirst();
    E pollLast();
​
    //获取队首元素
    E getFirst();
​
    //获取队尾元素
    E getLast();
​
        //不用多说了吧?
    E peekFirst();
    E peekLast();
​
    //从队列中删除第一个出现的指定元素
    boolean removeFirstOccurrence(Object o);
​
    //从队列中删除最后一个出现的指定元素
    boolean removeLastOccurrence(Object o);
​
    // *** 队列中继承下来的方法操作是一样的,这里就不列出了 ***
​
    ...
​
    // *** 栈相关操作已经帮助我们定义好了 ***
​
    //将元素推向栈顶
    void push(E e);
​
    //将元素从栈顶出栈
    E pop();
​
​
    // *** 集合类中继承的方法这里也不多种介绍了 ***
​
    ...
​
    //生成反向迭代器,这个迭代器也是单向的,但是是next方法是从后往前进行遍历的
    Iterator<E> descendingIterator();
​
}

我们可以来测试一下,比如我们可以直接当做栈来进行使用:

public static void main(String[] args) {
    Deque<String> deque = new LinkedList<>();
    deque.push("AAA");
    deque.push("BBB");
    System.out.println(deque.pop());
    System.out.println(deque.pop());
}

可以看到,得到的顺序和插入顺序是完全相反的,其实只要各位理解了前面讲解的数据结构,就很简单了。我们来测试一下反向迭代器和正向迭代器:

public static void main(String[] args) {
    Deque<String> deque = new LinkedList<>();
    deque.addLast("AAA");
    deque.addLast("BBB");
    
    Iterator<String> descendingIterator = deque.descendingIterator();
    System.out.println(descendingIterator.next());
​
    Iterator<String> iterator = deque.iterator();
    System.out.println(iterator.next());
}

可以看到,正向迭代器和反向迭代器的方向是完全相反的。

当然,除了LinkedList实现了队列接口之外,还有其他的实现类,但是并不是很常用,这里做了解就行了:

public static void main(String[] args) {
    Deque<String> deque = new ArrayDeque<>();   //数组实现的栈和队列
    Queue<String> queue = new PriorityQueue<>();  //优先级队列
}

这里需要介绍一下优先级队列,优先级队列可以根据每一个元素的优先级,对出队顺序进行调整,默认情况按照自然顺序:

public static void main(String[] args) {
    Queue<Integer> queue = new PriorityQueue<>();
    queue.offer(10);
    queue.offer(4);
    queue.offer(5);
    System.out.println(queue.poll());
    System.out.println(queue.poll());
    System.out.println(queue.poll());
}

可以看到,我们的插入顺序虽然是10/4/5,但是出队顺序是按照优先级来的,类似于VIP用户可以优先结束排队。我们也可以自定义比较规则,同样需要给一个Comparator的实现:

public static void main(String[] args) {
    Queue<Integer> queue = new PriorityQueue<>((a, b) -> b - a);   //按照从大到小顺序出队
    queue.offer(10);
    queue.offer(4);
    queue.offer(5);
    System.out.println(queue.poll());
    System.out.println(queue.poll());
    System.out.println(queue.poll());
}

只不过需要注意的是,优先级队列并不是队列中所有的元素都是按照优先级排放的,优先级队列只能保证出队顺序是按照优先级进行的,我们可以打印一下:

想要了解优先级队列的具体是原理,可以在《数据结构与算法》篇视频教程中学习大顶堆和小顶堆。

5.Set集合

前面我们已经介绍了列表,我们接着来看Set集合,这种集合类型比较特殊,我们先来看看Set的定义:

public interface Set<E> extends Collection<E> {
    // Set集合中基本都是从Collection直接继承过来的方法,只不过对这些方法有更加特殊的定义
    int size();
    boolean isEmpty();
    boolean contains(Object o);
    Iterator<E> iterator();
    Object[] toArray();
    <T> T[] toArray(T[] a);
​
    //添加元素只有在当前Set集合中不存在此元素时才会成功,如果插入重复元素,那么会失败
    boolean add(E e);
​
    //这个同样是删除指定元素
    boolean remove(Object o);
​
    boolean containsAll(Collection<?> c);
​
    //同样是只能插入那些不重复的元素
    boolean addAll(Collection<? extends E> c);
  
    boolean retainAll(Collection<?> c);
    boolean removeAll(Collection<?> c);
    void clear();
    boolean equals(Object o);
    int hashCode();
​
    //这个方法我们同样会放到多线程中进行介绍
    @Override
    default Spliterator<E> spliterator() {
        return Spliterators.spliterator(this, Spliterator.DISTINCT);
    }
}

我们发现接口中定义的方法都是Collection中直接继承的,因此,Set支持的功能其实也就和Collection中定义的差不多,只不过:

  • 不允许出现重复元素

  • 不支持随机访问(不允许通过下标访问)

首先认识一下HashSet,它的底层就是采用哈希表实现的(我们在这里先不去探讨实现原理,因为底层实质上是借用的一个HashMap在实现,这个需要我们学习了Map之后再来讨论)我们可以非常高效的从HashSet中存取元素,我们先来测试一下它的特性:

public static void main(String[] args) {
    Set<String> set = new HashSet<>();
    System.out.println(set.add("AAA"));   //这里我们连续插入两个同样的字符串
    System.out.println(set.add("AAA"));
    System.out.println(set);   //可以看到,最后实际上只有一个成功插入了
}

在Set接口中并没有定义支持指定下标位置访问的添加和删除操作,我们只能简单的删除Set中的某个对象:

public static void main(String[] args) {
    Set<String> set = new HashSet<>();
    System.out.println(set.add("AAA"));
    System.out.println(set.remove("AAA"));
    System.out.println(set);
}

由于底层采用哈希表实现,所以说无法维持插入元素的顺序:

public static void main(String[] args) {
    Set<String> set = new HashSet<>();
    set.addAll(Arrays.asList("A", "0", "-", "+"));
    System.out.println(set);
}

那要是我们就是想要使用维持顺序的Set集合呢?我们可以使用LinkedHashSet,LinkedHashSet底层维护的不再是一个HashMap,而是LinkedHashMap,它能够在插入数据时利用链表自动维护顺序,因此这样就能够保证我们插入顺序和最后的迭代顺序一致了。

public static void main(String[] args) {
    Set<String> set = new LinkedHashSet<>();
    set.addAll(Arrays.asList("A", "0", "-", "+"));
    System.out.println(set);
}

还有一种Set叫做TreeSet,它会在元素插入时进行排序:

public static void main(String[] args) {
    TreeSet<Integer> set = new TreeSet<>();
    set.add(1);
    set.add(3);
    set.add(2);
    System.out.println(set);
}

可以看到最后得到的结果并不是我们插入顺序,而是按照数字的大小进行排列。当然,我们也可以自定义排序规则:

public static void main(String[] args) {
    TreeSet<Integer> set = new TreeSet<>((a, b) -> b - a);  //同样是一个Comparator
    set.add(1);
    set.add(3);
    set.add(2);
    System.out.println(set);
}

目前,Set集合只是粗略的进行了讲解,但是学习Map之后,我们还会回来看我们Set的底层实现,所以说最重要的还是Map。本节只需要记住Set的性质、使用即可。

6.Map映射

什么是映射?我们在高中阶段其实已经学习过映射(Mapping)了,映射指两个元素的之间相互“对应”的关系,也就是说,我们的元素之间是两两对应的,是以键值对的形式存在。

而Map就是为了实现这种数据结构而存在的,我们通过保存键值对的形式来存储映射关系,就可以轻松地通过键找到对应的映射值,比如现在我们要保存很多学生的信息,而这些学生都有自己的ID,我们可以将其以映射的形式保存,将ID作为键,学生详细信息作为值,这样我们就可以通过学生的ID快速找到对应学生的信息了。

在Map中,这些映射关系被存储为键值对,我们先来看看Map接口中定义了哪些操作:

//Map并不是Collection体系下的接口,而是单独的一个体系,因为操作特殊
//这里需要填写两个泛型参数,其中K就是键的类型,V就是值的类型,比如上面的学生信息,ID一般是int,那么键就是Integer类型的,而值就是学生信息,所以说值是学生对象类型的
public interface Map<K,V> {
    //-------- 查询相关操作 --------
  
    //获取当前存储的键值对数量
    int size();
​
    //是否为空
    boolean isEmpty();
​
    //查看Map中是否包含指定的键
    boolean containsKey(Object key);
​
    //查看Map中是否包含指定的值
    boolean containsValue(Object value);
​
    //通过给定的键,返回其映射的值
    V get(Object key);
​
    //-------- 修改相关操作 --------
​
    //向Map中添加新的映射关系,也就是新的键值对
    V put(K key, V value);
​
    //根据给定的键,移除其映射关系,也就是移除对应的键值对
    V remove(Object key);
​
​
    //-------- 批量操作 --------
​
    //将另一个Map中的所有键值对添加到当前Map中
    void putAll(Map<? extends K, ? extends V> m);
​
    //清空整个Map
    void clear();
​
​
    //-------- 其他视图操作 --------
​
    //返回Map中存放的所有键,以Set形式返回
    Set<K> keySet();
​
    //返回Map中存放的所有值
    Collection<V> values();
​
    //返回所有的键值对,这里用的是内部类Entry在表示
    Set<Map.Entry<K, V>> entrySet();
​
    //这个是内部接口Entry,表示一个键值对
    interface Entry<K,V> {
        //获取键值对的键
        K getKey();
​
        //获取键值对的值
        V getValue();
​
        //修改键值对的值
        V setValue(V value);
​
        //判断两个键值对是否相等
        boolean equals(Object o);
​
        //返回当前键值对的哈希值
        int hashCode();
​
        ...
    }
​
    ...
}

当然,Map中定义了非常多的方法,尤其是在Java 8之后新增的大量方法,我们会在后面逐步介绍的。

我们可以来尝试使用一下Map,实际上非常简单,这里我们使用最常见的HashMap,它的底层采用哈希表实现:

public static void main(String[] args) {
    Map<Integer, String> map = new HashMap<>();
    map.put(1, "小明");   //使用put方法添加键值对,返回值我们会在后面讨论
    map.put(2, "小红");
    System.out.println(map.get(2)); //使用get方法根据键获取对应的值
}

注意,Map中无法添加相同的键,同样的键只能存在一个,即使值不同。如果出现键相同的情况,那么会覆盖掉之前的:

public static void main(String[] args) {
    Map<Integer, String> map = new HashMap<>();
    map.put(1, "小明");
    map.put(1, "小红");   //这里的键跟之前的是一样的,这样会导致将之前的键值对覆盖掉
    System.out.println(map.get(1));
}

为了防止意外将之前的键值对覆盖掉,我们可以使用:

public static void main(String[] args) {
    Map<Integer, String> map = new HashMap<>();
    map.put(1, "小明");
    map.putIfAbsent(1, "小红");   //Java8新增操作,只有在不存在相同键的键值对时才会存放
    System.out.println(map.get(1));
}

还有,我们在获取一个不存在的映射时,默认会返回null作为结果:

public static void main(String[] args) {
    Map<Integer, String> map = new HashMap<>();
    map.put(1, "小明");   //Map中只有键为1的映射
    System.out.println(map.get(3));  //此时获取键为3的值,那肯定是没有的,所以说返回null
}

我们也可以为这种情况添加一个预备方案,当Map中不存在时,可以返回一个备选的返回值:

public static void main(String[] args) {
    Map<Integer, String> map = new HashMap<>();
    map.put(1, "小明");
    System.out.println(map.getOrDefault(3, "备胎"));   //Java8新增操作,当不存在对应的键值对时,返回备选方案
}

同样的,因为HashMap底层采用哈希表实现,所以不维护顺序,我们在获取所有键和所有值时,可能会是乱序的:

public static void main(String[] args) {
    Map<String , String> map = new HashMap<>();
    map.put("0", "十七张");
    map.put("+", "牌");
    map.put("P", "你能秒我");
    System.out.println(map);
    System.out.println(map.keySet());
    System.out.println(map.values());
}

如果需要维护顺序,我们同样可以使用LinkedHashMap,它的内部对插入顺序进行了维护:

public static void main(String[] args) {
    Map<String , String> map = new LinkedHashMap<>();
    map.put("0", "十七张");
    map.put("+", "牌");
    map.put("P", "你能秒我");
    System.out.println(map);
    System.out.println(map.keySet());
    System.out.println(map.values());
}

实际上Map的使用还是挺简单的,我们接着来看看Map的底层是如何实现的,首先是最简单的HashMap,我们前面已经说过了,它的底层采用的是哈希表,首先回顾我们之前学习的哈希表,我们当时说了,哈希表可能会出现哈希冲突,这样保存的元素数量就会存在限制,而我们可以通过连地址法解决这种问题,最后哈希表就长这样了:

实际上这个表就是一个存放头结点的数组+若干结点,而HashMap也是这样的,我们来看看这里面是怎么定义的:

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
  
    ...
    
    static class Node<K,V> implements Map.Entry<K,V> {   //内部使用结点,实际上就是存放的映射关系
        final int hash;
        final K key;   //跟我们之前不一样,我们之前一个结点只有键,而这里的结点既存放键也存放值,当然计算哈希还是使用键
        V value;
        Node<K,V> next;
                ...
    }
    
    ...
    transient Node<K,V>[] table;   //这个就是哈希表本体了,可以看到跟我们之前的写法是一样的,也是头结点数组,只不过HashMap中没有设计头结点(相当于没有头结点的链表)
  
    final float loadFactor;   //负载因子,这个东西决定了HashMap的扩容效果
  
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; //当我们创建对象时,会使用默认的负载因子,值为0.75
    }
  
    ...     
}
  

可以看到,实际上底层大致结构跟我们之前学习的差不多,只不过多了一些特殊的东西:

  • HashMap支持自动扩容,哈希表的大小并不是一直不变的,否则太过死板

  • HashMap并不是只使用简单的链地址法,当链表长度到达一定限制时,会转变为效率更高的红黑树结构

我们来研究一下它的put方法:

public V put(K key, V value) {
    //这里计算完键的哈希值之后,调用的另一个方法进行映射关系存放
    return putVal(hash(key), key, value, false, true);
}
​
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)  //如果底层哈希表没初始化,先初始化
        n = (tab = resize()).length;   //通过resize方法初始化底层哈希表,初始容量为16,后续会根据情况扩容,底层哈希表的长度永远是2的n次方
    //因为传入的哈希值可能会很大,这里同样是进行取余操作
    //(n - 1) & hash 等价于 hash % n 这里的i就是最终得到的下标位置了
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);   //如果这个位置上什么都没有,那就直接放一个新的结点
    else {   //这种情况就是哈希冲突了
        Node<K,V> e; K k;
        if (p.hash == hash &&   //如果上来第一个结点的键的哈希值跟当前插入的键的哈希值相同,键也相同,说明已经存放了相同键的键值对了,那就执行覆盖操作
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;   //这里直接将待插入结点等于原本冲突的结点,一会直接覆盖
        else if (p instanceof TreeNode)   //如果第一个结点是TreeNode类型的,说明这个链表已经升级为红黑树了
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);  //在红黑树中插入新的结点
        else {
            for (int binCount = 0; ; ++binCount) {  //普通链表就直接在链表尾部插入
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);  //找到尾部,直接创建新的结点连在后面
                    if (binCount >= TREEIFY_THRESHOLD - 1) //如果当前链表的长度已经很长了,达到了阈值
                        treeifyBin(tab, hash);          //那么就转换为红黑树来存放
                    break;   //直接结束
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))  //同样的,如果在向下找的过程中发现已经存在相同键的键值对了,直接结束,让p等于e一会覆盖就行了
                    break;
                p = e;
            }
        }
        if (e != null) { // 如果e不为空,只有可能是前面出现了相同键的情况,其他情况e都是null,所有直接覆盖就行
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;   //覆盖之后,会返回原本的被覆盖值
        }
    }
    ++modCount;
    if (++size > threshold)   //键值对size计数自增,如果超过阈值,会对底层哈希表数组进行扩容
        resize();   //调用resize进行扩容
    afterNodeInsertion(evict);
    return null;  //正常插入键值对返回值为null
}

是不是感觉只要前面的数据结构听懂了,这里简直太简单。根据上面的推导,我们在正常插入一个键值对时,会得到null返回值,而冲突时会得到一个被覆盖的值:

public static void main(String[] args) {
    Map<String , String> map = new HashMap<>();
    System.out.println(map.put("0", "十七张"));
    System.out.println(map.put("0", "慈善家"));
}

现在我们知道,当HashMap的一个链表长度过大时,会自动转换为红黑树:

但是这样始终治标不治本,受限制的始终是底层哈希表的长度,我们还需要进一步对底层的这个哈希表进行扩容才可以从根本上解决问题,我们来看看resize()方法:

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;   //先把下面这几个旧的东西保存一下
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;  //这些是新的容量和扩容阈值
    if (oldCap > 0) {  //如果旧容量大于0,那么就开始扩容
        if (oldCap >= MAXIMUM_CAPACITY) {  //如果旧的容量已经大于最大限制了,那么直接给到 Integer.MAX_VALUE
            threshold = Integer.MAX_VALUE;
            return oldTab;  //这种情况不用扩了
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)   //新的容量等于旧容量的2倍,同样不能超过最大值
            newThr = oldThr << 1; //新的阈值也提升到原来的两倍
    }
    else if (oldThr > 0) // 旧容量不大于0只可能是还没初始化,这个时候如果阈值大于0,直接将新的容量变成旧的阈值
        newCap = oldThr;
    else {               // 默认情况下阈值也是0,也就是我们刚刚无参new出来的时候
        newCap = DEFAULT_INITIAL_CAPACITY;   //新的容量直接等于默认容量16
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); //阈值为负载因子乘以默认容量,负载因子默认为0.75,也就是说只要整个哈希表用了75%的容量,那么就进行扩容,至于为什么默认是0.75,原因很多,这里就不解释了,反正作为新手,这些都是大佬写出来的,我们用就完事。
    }
    ...
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;   //将底层数组变成新的扩容之后的数组
    if (oldTab != null) {  //如果旧的数组不为空,那么还需要将旧的数组中所有元素全部搬到新的里面去
        ...   //详细过程就不介绍了
    }
}

是不是感觉自己有点了解HashMap的运作机制了,其实并不是想象中的那么难,因为这些东西再怎么都是人写的。

而LinkedHashMap是直接继承自HashMap,具有HashMap的全部性质,同时得益于每一个节点都是一个双向链表,在插入键值对时,同时保存了插入顺序:

static class Entry<K,V> extends HashMap.Node<K,V> {   //LinkedHashMap中的结点实现
    Entry<K,V> before, after;   //这里多了一个指向前一个结点和后一个结点的引用
    Entry(int hash, K key, V value, Node<K,V> next) {
        super(hash, key, value, next);
    }
}

这样我们在遍历LinkedHashMap时,顺序就同我们的插入顺序一致。当然,也可以使用访问顺序,也就是说对于刚访问过的元素,会被排到最后一位。

当然还有一种比较特殊的Map叫做TreeMap,就像它的名字一样,就是一个Tree,它的内部直接维护了一个红黑树(没有使用哈希表)因为它会将我们插入的结点按照规则进行排序,所以说直接采用红黑树会更好,我们在创建时,直接给予一个比较规则即可,跟之前的TreeSet是一样的:

public static void main(String[] args) {
    Map<Integer , String> map = new TreeMap<>((a, b) -> b - a);
    map.put(0, "单走");
    map.put(1, "一个六");
    map.put(3, "**");
    System.out.println(map);
}

现在我们倒回来看之前讲解的HashSet集合,实际上它的底层很简单:

public class HashSet<E>
    extends AbstractSet<E>
    implements Set<E>, Cloneable, java.io.Serializable
{
​
    private transient HashMap<E,Object> map;   //对,你没看错,底层直接用map来做事
​
    // 因为Set只需要存储Key就行了,所以说这个对象当做每一个键值对的共享Value
    private static final Object PRESENT = new Object();
​
    //直接构造一个默认大小为16负载因子0.75的HashMap
    public HashSet() {
        map = new HashMap<>();
    }
        
    ...
      
    //你会发现所有的方法全是替身攻击
    public Iterator<E> iterator() {
        return map.keySet().iterator();
    }
​
    public int size() {
        return map.size();
    }
​
    public boolean isEmpty() {
        return map.isEmpty();
    }
}

通过观察HashSet的源码发现,HashSet几乎都在操作内部维护的一个HashMap,也就是说,HashSet只是一个表壳,而内部维护的HashMap才是灵魂!就像你进了公司,在外面花钱请别人帮你写公司的业务,你只需要坐着等别人写好然后你自己拿去交差就行了。所以说,HashSet利用了HashMap内部的数据结构,轻松地就实现了Set定义的全部功能!

再来看TreeSet,实际上用的就是我们的TreeMap:

public class TreeSet<E> extends AbstractSet<E>
    implements NavigableSet<E>, Cloneable, java.io.Serializable
{
    //底层需要一个NavigableMap,就是自动排序的Map
    private transient NavigableMap<E,Object> m;
​
    //不用我说了吧
    private static final Object PRESENT = new Object();
​
    ...
​
    //直接使用TreeMap解决问题
    public TreeSet() {
        this(new TreeMap<E,Object>());
    }
        
    ...
}

同理,这里就不多做阐述了。

我们接着来看看Map中定义的哪些杂七杂八的方法,首先来看看compute方法:

public static void main(String[] args) {
    Map<Integer, String> map = new HashMap<>();
    map.put(1, "A");
    map.put(2, "B");
    map.compute(1, (k, v) -> {   //compute会将指定Key的值进行重新计算,若Key不存在,v会返回null
        return v+"M";     //这里返回原来的value+M
    });
    map.computeIfPresent(1, (k, v) -> {   //当Key存在时存在则计算并赋予新的值
      return v+"M";     //这里返回原来的value+M
    });
    System.out.println(map);
}

也可以使用computeIfAbsent,当不存在Key时,计算并将键值对放入Map中:

public static void main(String[] args) {
    Map<Integer, String> map = new HashMap<>();
    map.put(1, "A");
    map.put(2, "B");
    map.computeIfAbsent(0, (k) -> {   //若不存在则计算并插入新的值
        return "M";     //这里返回M
    });
    System.out.println(map);
}

merge方法用于处理数据:

public static void main(String[] args) {
    List<Student> students = Arrays.asList(
            new Student("yoni", "English", 80),
            new Student("yoni", "Chiness", 98),
            new Student("yoni", "Math", 95),
            new Student("taohai.wang", "English", 50),
            new Student("taohai.wang", "Chiness", 72),
            new Student("taohai.wang", "Math", 41),
            new Student("Seely", "English", 88),
            new Student("Seely", "Chiness", 89),
            new Student("Seely", "Math", 92)
    );
    Map<String, Integer> scoreMap = new HashMap<>();
    //merge方法可以对重复键的值进行特殊操作,比如我们想计算某个学生的所有科目分数之后,那么就可以像这样:
    students.forEach(student -> scoreMap.merge(student.getName(), student.getScore(), Integer::sum));
    scoreMap.forEach((k, v) -> System.out.println("key:" + k + "总分" + "value:" + v));
}
​
static class Student {
    private final String name;
    private final String type;
    private final int score;
​
    public Student(String name, String type, int score) {
        this.name = name;
        this.type = type;
        this.score = score;
    }
​
    public String getName() {
        return name;
    }
​
    public int getScore() {
        return score;
    }
​
    public String getType() {
        return type;
    }
}

replace方法可以快速替换某个映射的值:

public static void main(String[] args) {
    Map<Integer , String> map = new HashMap<>();
    map.put(0, "单走");
    map.replace(0, ">>>");   //直接替换为新的
    System.out.println(map);
}

也可以精准匹配:

public static void main(String[] args) {
    Map<Integer , String> map = new HashMap<>();
    map.put(0, "单走");
    map.replace(0, "巴卡", "玛卡");   //只有键和值都匹配时,才进行替换
    System.out.println(map);
}

包括remove方法,也支持键值同时匹配:

public static void main(String[] args) {
    Map<Integer , String> map = new HashMap<>();
    map.put(0, "单走");
    map.remove(0, "单走");  //只有同时匹配时才移除
    System.out.println(map);
}

是不是感觉学习了Map之后,涨了不少姿势?

7.Stream流

Java 8 API添加了一个新的抽象称为流Stream,可以让你以一种声明的方式处理数据。Stream 使用一种类似用 SQL 语句从数据库查询数据的直观方式来提供一种对 Java 集合运算和表达的高阶抽象。Stream API可以极大提高Java程序员的生产力,让程序员写出高效率、干净、简洁的代码。这种风格将要处理的元素集合看作一种流, 流在管道中传输, 并且可以在管道的节点上进行处理, 比如筛选, 排序,聚合等。元素流在管道中经过中间操作(intermediate operation)的处理,最后由最终操作(terminal operation)得到前面处理的结果。

它看起来就像一个工厂的流水线一样!我们就可以把一个Stream当做流水线处理:

public static void main(String[] args) {
    List<String> list = new ArrayList<>();
    list.add("A");
    list.add("B");
    list.add("C");
  
    //移除为B的元素
    Iterator<String> iterator = list.iterator();
    while (iterator.hasNext()){
        if(iterator.next().equals("B")) iterator.remove();
    }
  
    //Stream操作
    list = list     //链式调用
            .stream()    //获取流
            .filter(e -> !e.equals("B"))   //只允许所有不是B的元素通过流水线
            .collect(Collectors.toList());   //将流水线中的元素重新收集起来,变回List
    System.out.println(list);
}

可能从上述例子中还不能感受到流处理带来的便捷,我们通过下面这个例子来感受一下:

public static void main(String[] args) {
    List<Integer> list = new ArrayList<>();
    list.add(1);
    list.add(2);
    list.add(3);
    list.add(3);
​
    list = list
            .stream()
                .distinct()   //去重(使用equals判断)
            .sorted((a, b) -> b - a)    //进行倒序排列
            .map(e -> e+1)    //每个元素都要执行+1操作
            .limit(2)    //只放行前两个元素
            .collect(Collectors.toList());
​
    System.out.println(list);
}

当遇到大量的复杂操作时,我们就可以使用Stream来快速编写代码,这样不仅代码量大幅度减少,而且逻辑也更加清晰明了(如果你学习过SQL的话,你会发现它更像一个Sql语句)

注意:不能认为每一步是直接依次执行的!我们可以断点测试一下:

List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
list.add(3);
​
list = list
        .stream()
        .distinct()   //断点
        .sorted((a, b) -> b - a)
        .map(e -> {
            System.out.println(">>> "+e);   //断点
            return e+1;
        })
        .limit(2)   //断点
        .collect(Collectors.toList());

实际上,stream会先记录每一步操作,而不是直接开始执行内容,当整个链式调用完成后,才会依次进行,也就是说需要的时候,工厂的机器才会按照预定的流程启动。

接下来,我们用一堆随机数来进行更多流操作的演示:

public static void main(String[] args) {
    Random random = new Random();  //没想到吧,Random支持直接生成随机数的流
    random
            .ints(-100, 100)   //生成-100~100之间的,随机int型数字(本质上是一个IntStream)
            .limit(10)   //只获取前10个数字(这是一个无限制的流,如果不加以限制,将会无限进行下去!)
            .filter(i -> i < 0)   //只保留小于0的数字
            .sorted()    //默认从小到大排序
            .forEach(System.out::println);   //依次打印
}

我们可以生成一个统计实例来帮助我们快速进行统计:

public static void main(String[] args) {
    Random random = new Random();  //Random是一个随机数工具类
    IntSummaryStatistics statistics = random
            .ints(0, 100)
            .limit(100)
            .summaryStatistics();    //获取语法统计实例
    System.out.println(statistics.getMax());  //快速获取最大值
    System.out.println(statistics.getCount());  //获取数量
    System.out.println(statistics.getAverage());   //获取平均值
}

普通的List只需要一个方法就可以直接转换到方便好用的IntStream了:

public static void main(String[] args) {
    List<Integer> list = new ArrayList<>();
    list.add(1);
    list.add(1);
    list.add(2);
    list.add(3);
    list.add(4);
    list.stream()
            .mapToInt(i -> i)    //将每一个元素映射为Integer类型(这里因为本来就是Integer)
            .summaryStatistics();
}

我们还可以通过flat来对整个流进行进一步细分:

public static void main(String[] args) {
    List<String> list = new ArrayList<>();
    list.add("A,B");
    list.add("C,D");
    list.add("E,F");   //我们想让每一个元素通过,进行分割,变成独立的6个元素
    list = list
            .stream()    //生成流
            .flatMap(e -> Arrays.stream(e.split(",")))    //分割字符串并生成新的流
            .collect(Collectors.toList());   //汇成新的List
    System.out.println(list);   //得到结果
}

我们也可以只通过Stream来完成所有数字的和,使用reduce方法:

public static void main(String[] args) {
    List<Integer> list = new ArrayList<>();
    list.add(1);
    list.add(2);
    list.add(3);
    int sum = list
            .stream()
            .reduce((a, b) -> a + b)   //计算规则为:a是上一次计算的值,b是当前要计算的参数,这里是求和
            .get();    //我们发现得到的是一个Optional类实例,通过get方法返回得到的值
    System.out.println(sum);
}

可能,作为新手来说,一次性无法接受这么多内容,但是在各位以后的开发中,就会慢慢使用到这些东西了。

8.Collections工具类

我们在前面介绍了Arrays,它是一个用于操作数组的工具类,它给我们提供了大量的工具方法。

既然数组操作都这么方便了,集合操作能不能也安排点高级的玩法呢?那必须的,JDK为我们准备的Collocations类就是专用于集合的工具类,比如我们想快速求得List中的最大值和最小值:

public static void main(String[] args) {
    List<Integer> list = new ArrayList<>();
    Collections.max(list);
    Collections.min(list);
}

同样的,我们可以对一个集合进行二分搜索(注意,集合的具体类型,必须是实现Comparable接口的类):

public static void main(String[] args) {
    List<Integer> list = Arrays.asList(2, 3, 8, 9, 10, 13);
    System.out.println(Collections.binarySearch(list, 8));
}

我们也可以对集合的元素进行快速填充,注意这个填充是对集合中已有的元素进行覆盖:

public static void main(String[] args) {
    List<Integer> list = new ArrayList<>(Arrays.asList(1,2,3,4,5));
    Collections.fill(list, 6);
    System.out.println(list);
}

如果集合中本身没有元素,那么fill操作不会生效。

有些时候我们可能需要生成一个空的集合类返回,那么我们可以使用emptyXXX来快速生成一个只读的空集合:

public static void main(String[] args) {
    List<Integer> list = Collections.emptyList();
    //Collections.singletonList() 会生成一个只有一个元素的List
    list.add(10);   //不支持,会直接抛出异常
}

我们也可以将一个可修改的集合变成只读的集合:

public static void main(String[] args) {
    List<Integer> list = new ArrayList<>(Arrays.asList(1,2,3,4,5));
    List<Integer> newList = Collections.unmodifiableList(list);
    newList.add(10);   //不支持,会直接抛出异常
}

我们也可以寻找子集合的位置:

public static void main(String[] args) {
    List<Integer> list = new ArrayList<>(Arrays.asList(1,2,3,4,5));
    System.out.println(Collections.indexOfSubList(list, Arrays.asList(4, 5)));
}

得益于泛型的类型擦除机制,实际上最后只要是Object的实现类都可以保存到集合类中,那么就会出现这种情况:

public static void main(String[] args) {
    //使用原始类型接收一个Integer类型的ArrayList
    List list = new ArrayList<>(Arrays.asList(1,2,3,4,5));
    list.add("aaa");   //我们惊奇地发现,这玩意居然能存字符串进去
    System.out.println(list);
}

没错,由于泛型机制上的一些漏洞,实际上对应类型的集合类有可能会存放其他类型的值,泛型的类型检查只存在于编译阶段,只要我们绕过这个阶段,在实际运行时,并不会真的进行类型检查,要解决这种问题很简单,就是在运行时进行类型检查:

public static void main(String[] args) {
    List list = new ArrayList<>(Arrays.asList(1,2,3,4,5));
    list = Collections.checkedList(list, Integer.class);   //这里的.class关键字我们会在后面反射中介绍,表示Integer这个类型
    list.add("aaa");
    System.out.println(list);
}

checkedXXX可以将给定集合类进行包装,在运行时同样会进行类型检查,如果通过上面的漏洞插入一个本不应该是当前类型集合支持的类型,那么会直接抛出类型转换异常:

是不是感觉这个工具类好像还挺好用的?实际上在我们的开发中,这个工具类也经常被使用到。

Java I/O

注意:这块会涉及到操作系统计算机组成原理相关内容。

I/O简而言之,就是输入输出,那么为什么会有I/O呢?其实I/O无时无刻都在我们的身边,比如读取硬盘上的文件,网络文件传输,鼠标键盘输入,也可以是接受单片机发回的数据,而能够支持这些操作的设备就是I/O设备。

从读取硬盘文件的角度来说,不同的操作系统有着不同的文件系统(也就是文件在硬盘中的存储排列方式,如Windows就是NTFS、MacOS就是APFS),硬盘只能存储一个个0和1这样的二进制数据,至于0和1如何排列,各自又代表什么意思,就是由操作系统的文件系统来决定的。从网络通信角度来说,网络信号通过网卡等设备翻译为二进制信号,再交给系统进行读取,最后再由操作系统来给到程序。

JDK提供了一套用于IO操作的框架,为了方便我们开发者使用,就定义了一个像水流一样,根据流的传输方向和读取单位,分为字节流InputStream和OutputStream以及字符流Reader和Writer的IO框架,当然,这里的Stream并不是前面集合框架认识的Stream,这里的流指的是数据流,通过流,我们就可以一直从流中读取数据,直到读取到尽头,或是不断向其中写入数据,直到我们写入完成,而这类IO就是我们所说的BIO,

字节流一次读取一个字节,也就是一个byte的大小,而字符流顾名思义,就是一次读取一个字符,也就是一个char的大小(在读取纯文本文件的时候更加适合),有关这两种流,会在后面详细介绍,这个章节我们需要学习16个关键的流。

1.文件字节流

要学习和使用IO,首先就要从最易于理解的读取文件开始说起。

首先介绍一下FileInputStream,我们可以通过它来获取文件的输入流:

public static void main(String[] args) {
    try {   //注意,IO相关操作会有很多影响因素,有可能出现异常,所以需要明确进行处理
        FileInputStream inputStream = new FileInputStream("路径");
        //路径支持相对路径和绝对路径
    } catch (FileNotFoundException e) {
        e.printStackTrace();
    }
}

相对路径是在当前运行目录(就是你在哪个目录运行java命令启动Java程序的)的路径下寻找文件,而绝对路径,是从根目录开始寻找。路径分割符支持使用/或是\\,但是不能写为\因为它是转义字符!比如在Windows下:

C://User/lbw/nb    这个就是一个绝对路径,因为是从盘符开始的
test/test          这个就是一个相对路径,因为并不是从盘符开始的,而是一个直接的路径

在Linux和MacOS下:

/root/tmp       这个就是一个绝对路径,绝对路径以/开头
test/test       这个就是一个相对路径,不是以/开头的

当然,这个其实还是很好理解的,我们在使用时注意一下就行了。

在使用完成一个流之后,必须关闭这个流来完成对资源的释放,否则资源会被一直占用:

public static void main(String[] args) {
    FileInputStream inputStream = null;    //定义可以先放在try外部
    try {
        inputStream = new FileInputStream("路径");
    } catch (FileNotFoundException e) {
        e.printStackTrace();
    } finally {
        try {    //建议在finally中进行,因为关闭流是任何情况都必须要执行的!
            if(inputStream != null) inputStream.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

虽然这样的写法才是最保险的,但是显得过于繁琐了,尤其是finally中再次嵌套了一个try-catch块,因此在JDK1.7新增了try-with-resource语法,用于简化这样的写法(本质上还是和这样的操作一致,只是换了个写法)

public static void main(String[] args) {

    //注意,这种语法只支持实现了AutoCloseable接口的类!
    try(FileInputStream inputStream = new FileInputStream("路径")) {   //直接在try()中定义要在完成之后释放的资源

    } catch (IOException e) {   //这里变成IOException是因为调用close()可能会出现,而FileNotFoundException是继承自IOException的
        e.printStackTrace();
    }
    //无需再编写finally语句块,因为在最后自动帮我们调用了close()
}

之后为了方便,我们都使用此语法进行教学。

现在我们拿到了文件的输入流,那么怎么才能读取文件里面的内容呢?我们可以使用read方法:

public static void main(String[] args) {
    //test.txt:a
    try(FileInputStream inputStream = new FileInputStream("test.txt")) {
        //使用read()方法进行字符读取
        System.out.println((char) inputStream.read());  //读取一个字节的数据(英文字母只占1字节,中文占2字节)
        System.out.println(inputStream.read());   //唯一一个字节的内容已经读完了,再次读取返回-1表示没有内容了
    }catch (IOException e){
        e.printStackTrace();
    }
}

使用read可以直接读取一个字节的数据,注意,流的内容是有限的,读取一个少一个。我们如果想一次性全部读取的话,可以直接使用一个while循环来完成:

public static void main(String[] args) {
    //test.txt:abcd
    try(FileInputStream inputStream = new FileInputStream("test.txt")) {
        int tmp;
        while ((tmp = inputStream.read()) != -1){   //通过while循环来一次性读完内容
            System.out.println((char)tmp);
        }
    }catch (IOException e){
        e.printStackTrace();
    }
}

使用available方法能查看当前可读的剩余字节数量(注意:并不一定真实的数据量就是这么多,尤其是在网络I/O操作时,这个方法只能进行一个预估也可以说是暂时能一次性可以读取的数量,当然在磁盘IO下,一般情况都是真实的数据量)

try(FileInputStream inputStream = new FileInputStream("test.txt")) {
    System.out.println(inputStream.available());  //查看剩余数量
}catch (IOException e){
    e.printStackTrace();
}

当然,一个一个读取效率太低了,那能否一次性全部读取呢?我们可以预置一个合适容量的byte[]数组来存放:

public static void main(String[] args) {
    //test.txt:abcd
    try(FileInputStream inputStream = new FileInputStream("test.txt")) {
        byte[] bytes = new byte[inputStream.available()];   //我们可以提前准备好合适容量的byte数组来存放
        System.out.println(inputStream.read(bytes));   //一次性读取全部内容(返回值是读取的字节数)
        System.out.println(new String(bytes));   //通过String(byte[])构造方法得到字符串
    }catch (IOException e){
        e.printStackTrace();
    }
}

也可以控制要读取数量:

System.out.println(inputStream.read(bytes, 1, 2));   //第二个参数是从给定数组的哪个位置开始放入内容,第三个参数是读取流中的字节数

注意:一次性读取同单个读取一样,当没有任何数据可读时,依然会返回-1

通过skip()方法可以跳过指定数量的字节:

public static void main(String[] args) {
    //test.txt:abcd
    try(FileInputStream inputStream = new FileInputStream("test.txt")) {
        System.out.println(inputStream.skip(1));
        System.out.println((char) inputStream.read());   //跳过了一个字节
    }catch (IOException e){
        e.printStackTrace();
    }
}

注意:FileInputStream是不支持reset()的,虽然有这个方法,但是这里先不提及。

既然有输入流,那么文件输出流也是必不可少的:

public static void main(String[] args) {
    //输出流也需要在最后调用close()方法,并且同样支持try-with-resource
    try(FileOutputStream outputStream = new FileOutputStream("output.txt")) {
        //注意:若此文件不存在,会直接创建这个文件!
    }catch (IOException e){
        e.printStackTrace();
    }
}

输出流没有read()操作而是write()操作,使用方法同输入流一样,只不过现在的方向变为我们向文件里写入内容:

public static void main(String[] args) {
    try(FileOutputStream outputStream = new FileOutputStream("output.txt")) {
        outputStream.write('c');   //同read一样,可以直接写入内容
      	outputStream.write("lbwnb".getBytes());   //也可以直接写入byte[]
      	outputStream.write("lbwnb".getBytes(), 0, 1);  //同上输入流
      	outputStream.flush();  //建议在最后执行一次刷新操作(强制写入)来保证数据正确写入到硬盘文件中
    }catch (IOException e){
        e.printStackTrace();
    }
}

那么如果是我只想在文件尾部进行追加写入数据呢?我们可以调用另一个构造方法来实现:

public static void main(String[] args) {
    try(FileOutputStream outputStream = new FileOutputStream("output.txt", true)) {  //true表示开启追加模式
        outputStream.write("lb".getBytes());   //现在只会进行追加写入,而不是直接替换原文件内容
        outputStream.flush();
    }catch (IOException e){
        e.printStackTrace();
    }
}

利用输入流和输出流,就可以轻松实现文件的拷贝了:

public static void main(String[] args) {
    try(FileOutputStream outputStream = new FileOutputStream("output.txt");
        FileInputStream inputStream = new FileInputStream("test.txt")) {   //可以写入多个
        byte[] bytes = new byte[10];    //使用长度为10的byte[]做传输媒介
        int tmp;   //存储本地读取字节数
        while ((tmp = inputStream.read(bytes)) != -1){   //直到读取完成为止
            outputStream.write(bytes, 0, tmp);    //写入对应长度的数据到输出流
        }
    }catch (IOException e){
        e.printStackTrace();
    }
}

2.文件字符流

字符流不同于字节,字符流是以一个具体的字符进行读取,因此它只适合读纯文本的文件,如果是其他类型的文件不适用:

public static void main(String[] args) {
    try(FileReader reader = new FileReader("test.txt")){
      	reader.skip(1);   //现在跳过的是一个字符
        System.out.println((char) reader.read());   //现在是按字符进行读取,而不是字节,因此可以直接读取到中文字符
    }catch (IOException e){
        e.printStackTrace();
    }
}

同理,字符流只支持char[]类型作为存储:

public static void main(String[] args) {
    try(FileReader reader = new FileReader("test.txt")){
        char[] str = new char[10];
        reader.read(str);
        System.out.println(str);   //直接读取到char[]中
    }catch (IOException e){
        e.printStackTrace();
    }
}

既然有了Reader肯定也有Writer:

public static void main(String[] args) {
    try(FileWriter writer = new FileWriter("output.txt")){
      	writer.getEncoding();   //支持获取编码(不同的文本文件可能会有不同的编码类型)
       writer.write('牛');
       writer.append('牛');   //其实功能和write一样
      	writer.flush();   //刷新
    }catch (IOException e){
        e.printStackTrace();
    }
}

我们发现不仅有write()方法,还有一个append()方法,但是实际上他们效果是一样的,看源码:

public Writer append(char c) throws IOException {
    write(c);
    return this;
}

append支持像StringBuilder那样的链式调用,返回的是Writer对象本身。

练习:尝试一下用Reader和Writer来拷贝纯文本文件。

这里需要额外介绍一下File类,它是专门用于表示一个文件或文件夹,只不过它只是代表这个文件,但并不是这个文件本身。通过File对象,可以更好地管理和操作硬盘上的文件。

public static void main(String[] args) {
    File file = new File("test.txt");   //直接创建文件对象,可以是相对路径,也可以是绝对路径
    System.out.println(file.exists());   //此文件是否存在
    System.out.println(file.length());   //获取文件的大小
    System.out.println(file.isDirectory());   //是否为一个文件夹
    System.out.println(file.canRead());   //是否可读
    System.out.println(file.canWrite());   //是否可写
    System.out.println(file.canExecute());   //是否可执行
}

通过File对象,我们就能快速得到文件的所有信息,如果是文件夹,还可以获取文件夹内部的文件列表等内容:

File file = new File("/");
System.out.println(Arrays.toString(file.list()));   //快速获取文件夹下的文件名称列表
for (File f : file.listFiles()){   //所有子文件的File对象
    System.out.println(f.getAbsolutePath());   //获取文件的绝对路径
}

如果我们希望读取某个文件的内容,可以直接将File作为参数传入字节流或是字符流:

File file = new File("test.txt");
try (FileInputStream inputStream = new FileInputStream(file)){   //直接做参数
    System.out.println(inputStream.available());
}catch (IOException e){
    e.printStackTrace();
}

练习:尝试拷贝文件夹下的所有文件到另一个文件夹

缓冲流

虽然普通的文件流读取文件数据非常便捷,但是每次都需要从外部I/O设备去获取数据,由于外部I/O设备的速度一般都达不到内存的读取速度,很有可能造成程序反应迟钝,因此性能还不够高,而缓冲流正如其名称一样,它能够提供一个缓冲,提前将部分内容存入内存(缓冲区)在下次读取时,如果缓冲区中存在此数据,则无需再去请求外部设备。同理,当向外部设备写入数据时,也是由缓冲区处理,而不是直接向外部设备写入。

要创建一个缓冲字节流,只需要将原本的流作为构造参数传入BufferedInputStream即可:

public static void main(String[] args) {
    try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("test.txt"))){   //传入FileInputStream
        System.out.println((char) bufferedInputStream.read());   //操作和原来的流是一样的
    }catch (IOException e){
        e.printStackTrace();
    }
}

实际上进行I/O操作的并不是BufferedInputStream,而是我们传入的FileInputStream,而BufferedInputStream虽然有着同样的方法,但是进行了一些额外的处理然后再调用FileInputStream的同名方法,这样的写法称为装饰者模式,我们会在设计模式篇中详细介绍。我们可以来观察一下它的close方法源码:

public void close() throws IOException {
    byte[] buffer;
    while ( (buffer = buf) != null) {
        if (bufUpdater.compareAndSet(this, buffer, null)) {  //CAS无锁算法,并发会用到,暂时不需要了解
            InputStream input = in;
            in = null;
            if (input != null)
                input.close();
            return;
        }
        // Else retry in case a new buf was CASed in fill()
    }
}

实际上这种模式是父类FilterInputStream提供的规范,后面我们还会讲到更多FilterInputStream的子类。

我们可以发现在BufferedInputStream中还存在一个专门用于缓存的数组:

/**
 * The internal buffer array where the data is stored. When necessary,
 * it may be replaced by another array of
 * a different size.
 */
protected volatile byte buf[];

I/O操作一般不能重复读取内容(比如键盘发送的信号,主机接收了就没了),而缓冲流提供了缓冲机制,一部分内容可以被暂时保存,BufferedInputStream支持reset()mark()操作,首先我们来看看mark()方法的介绍:

/**
 * Marks the current position in this input stream. A subsequent
 * call to the <code>reset</code> method repositions this stream at
 * the last marked position so that subsequent reads re-read the same bytes.
 * <p>
 * The <code>readlimit</code> argument tells this input stream to
 * allow that many bytes to be read before the mark position gets
 * invalidated.
 * <p>
 * This method simply performs <code>in.mark(readlimit)</code>.
 *
 * @param   readlimit   the maximum limit of bytes that can be read before
 *                      the mark position becomes invalid.
 * @see     java.io.FilterInputStream#in
 * @see     java.io.FilterInputStream#reset()
 */
public synchronized void mark(int readlimit) {
    in.mark(readlimit);
}

当调用mark()之后,输入流会以某种方式保留之后读取的readlimit数量的内容,当读取的内容数量超过readlimit则之后的内容不会被保留,当调用reset()之后,会使得当前的读取位置回到mark()调用时的位置。

public static void main(String[] args) {
    try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("test.txt"))){
        bufferedInputStream.mark(1);   //只保留之后的1个字符
        System.out.println((char) bufferedInputStream.read());
        System.out.println((char) bufferedInputStream.read());
        bufferedInputStream.reset();   //回到mark时的位置
        System.out.println((char) bufferedInputStream.read());
        System.out.println((char) bufferedInputStream.read());
    }catch (IOException e) {
        e.printStackTrace();
    }
}

我们发现虽然后面的部分没有保存,但是依然能够正常读取,其实mark()后保存的读取内容是取readlimit和BufferedInputStream类的缓冲区大小两者中的最大值,而并非完全由readlimit确定。因此我们限制一下缓冲区大小,再来观察一下结果:

public static void main(String[] args) {
    try (BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("test.txt"), 1)){  //将缓冲区大小设置为1
        bufferedInputStream.mark(1);   //只保留之后的1个字符
        System.out.println((char) bufferedInputStream.read());
        System.out.println((char) bufferedInputStream.read());   //已经超过了readlimit,继续读取会导致mark失效
        bufferedInputStream.reset();   //mark已经失效,无法reset()
        System.out.println((char) bufferedInputStream.read());
        System.out.println((char) bufferedInputStream.read());
    }catch (IOException e) {
        e.printStackTrace();
    }
}

了解完了BufferedInputStream之后,我们再来看看BufferedOutputStream,其实和BufferedInputStream原理差不多,只是反向操作:

public static void main(String[] args) {
    try (BufferedOutputStream outputStream = new BufferedOutputStream(new FileOutputStream("output.txt"))){
        outputStream.write("lbwnb".getBytes());
        outputStream.flush();
    }catch (IOException e) {
        e.printStackTrace();
    }
}

操作和FileOutputStream一致,这里就不多做介绍了。

既然有缓冲字节流,那么肯定也有缓冲字符流,缓冲字符流和缓冲字节流一样,也有一个专门的缓冲区,BufferedReader构造时需要传入一个Reader对象:

public static void main(String[] args) {
    try (BufferedReader reader = new BufferedReader(new FileReader("test.txt"))){
        System.out.println((char) reader.read());
    }catch (IOException e) {
        e.printStackTrace();
    }
}

使用和reader也是一样的,内部也包含一个缓存数组:

private char cb[];

相比Reader更方便的是,它支持按行读取:

public static void main(String[] args) {
    try (BufferedReader reader = new BufferedReader(new FileReader("test.txt"))){
        System.out.println(reader.readLine());   //按行读取
    }catch (IOException e) {
        e.printStackTrace();
    }
}

读取后直接得到一个字符串,当然,它还能把每一行内容依次转换为集合类提到的Stream流:

public static void main(String[] args) {
    try (BufferedReader reader = new BufferedReader(new FileReader("test.txt"))){
        reader
                .lines()
                .limit(2)
                .distinct()
                .sorted()
                .forEach(System.out::println);
    }catch (IOException e) {
        e.printStackTrace();
    }
}

它同样也支持mark()reset()操作:

public static void main(String[] args) {
    try (BufferedReader reader = new BufferedReader(new FileReader("test.txt"))){
        reader.mark(1);
        System.out.println((char) reader.read());
        reader.reset();
        System.out.println((char) reader.read());
    }catch (IOException e) {
        e.printStackTrace();
    }
}

BufferedReader处理纯文本文件时就更加方便了,BufferedWriter在处理时也同样方便:

public static void main(String[] args) {
    try (BufferedWriter reader = new BufferedWriter(new FileWriter("output.txt"))){
        reader.newLine();   //使用newLine进行换行
        reader.write("汉堡做滴彳亍不彳亍");   //可以直接写入一个字符串
      	reader.flush();   //清空缓冲区
    }catch (IOException e) {
        e.printStackTrace();
    }
}

合理使用缓冲流,可以大大提高我们程序的运行效率,只不过现在初学阶段,很少会有机会接触到实际的应用场景。

3.转换流

有时会遇到这样一个很麻烦的问题:我这里读取的是一个字符串或是一个个字符,但是我只能往一个OutputStream里输出,但是OutputStream又只支持byte类型,如果要往里面写入内容,进行数据转换就会很麻烦,那么能否有更加简便的方式来做这样的事情呢?

public static void main(String[] args) {
    try(OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream("test.txt"))){  //虽然给定的是FileOutputStream,但是现在支持以Writer的方式进行写入
        writer.write("lbwnb");   //以操作Writer的样子写入OutputStream
    }catch (IOException e){
        e.printStackTrace();
    }
}

同样的,我们现在只拿到了一个InputStream,但是我们希望能够按字符的方式读取,我们就可以使用InputStreamReader来帮助我们实现:

public static void main(String[] args) {
    try(InputStreamReader reader = new InputStreamReader(new FileInputStream("test.txt"))){  //虽然给定的是FileInputStream,但是现在支持以Reader的方式进行读取
        System.out.println((char) reader.read());
    }catch (IOException e){
        e.printStackTrace();
    }
}

InputStreamReader和OutputStreamWriter本质也是Reader和Writer,因此可以直接放入BufferedReader来实现更加方便的操作。

4.打印流

打印流其实我们从一开始就在使用了,比如System.out就是一个PrintStream,PrintStream也继承自FilterOutputStream类因此依然是装饰我们传入的输出流,但是它存在自动刷新机制,例如当向PrintStream流中写入一个字节数组后自动调用flush()方法。PrintStream也永远不会抛出异常,而是使用内部检查机制checkError()方法进行错误检查。最方便的是,它能够格式化任意的类型,将它们以字符串的形式写入到输出流。

public final static PrintStream out = null;

可以看到System.out也是PrintStream,不过默认是向控制台打印,我们也可以让它向文件中打印:

public static void main(String[] args) {
    try(PrintStream stream = new PrintStream(new FileOutputStream("test.txt"))){
        stream.println("lbwnb");   //其实System.out就是一个PrintStream
    }catch (IOException e){
        e.printStackTrace();
    }
}

我们平时使用的println方法就是PrintStream中的方法,它会直接打印基本数据类型或是调用对象的toString()方法得到一个字符串,并将字符串转换为字符,放入缓冲区再经过转换流输出到给定的输出流上。

因此实际上内部还包含这两个内容:

/**
 * Track both the text- and character-output streams, so that their buffers
 * can be flushed without flushing the entire stream.
 */
private BufferedWriter textOut;
private OutputStreamWriter charOut;

与此相同的还有一个PrintWriter,不过他们的功能基本一致,PrintWriter的构造方法可以接受一个Writer作为参数,这里就不再做过多阐述了。

而我们之前使用的Scanner,使用的是系统提供的输入流:

public static void main(String[] args) {
    Scanner scanner = new Scanner(System.in);   //系统输入流,默认是接收控制台输入
}

我们也可以使用Scanner来扫描其他的输入流:

public static void main(String[] args) throws FileNotFoundException {
    Scanner scanner = new Scanner(new FileInputStream("秘制小汉堡.txt"));  //将文件内容作为输入流进行扫描
}

相当于直接扫描文件中编写的内容,同样可以读取。

5.数据流

数据流DataInputStream也是FilterInputStream的子类,同样采用装饰者模式,最大的不同是它支持基本数据类型的直接读取:

public static void main(String[] args) {
    try (DataInputStream dataInputStream = new DataInputStream(new FileInputStream("test.txt"))){
        System.out.println(dataInputStream.readBoolean());   //直接将数据读取为任意基本数据类型
    }catch (IOException e) {
        e.printStackTrace();
    }
}

用于写入基本数据类型:

public static void main(String[] args) {
    try (DataOutputStream dataOutputStream = new DataOutputStream(new FileOutputStream("output.txt"))){
        dataOutputStream.writeBoolean(false);
    }catch (IOException e) {
        e.printStackTrace();
    }
}

注意,写入的是二进制数据,并不是写入的字符串,使用DataInputStream可以读取,一般他们是配合一起使用的。

6.对象流

既然基本数据类型能够读取和写入基本数据类型,那么能否将对象也支持呢?ObjectOutputStream不仅支持基本数据类型,通过对对象的序列化操作,以某种格式保存对象,来支持对象类型的IO,注意:它不是继承自FilterInputStream的。

public static void main(String[] args) {
    try (ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream("output.txt"));
         ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream("output.txt"))){
        People people = new People("lbw");
        outputStream.writeObject(people);
      	outputStream.flush();
        people = (People) inputStream.readObject();
        System.out.println(people.name);
    }catch (IOException | ClassNotFoundException e) {
        e.printStackTrace();
    }
}

static class People implements Serializable{   //必须实现Serializable接口才能被序列化
    String name;

    public People(String name){
        this.name = name;
    }
}

在我们后续的操作中,有可能会使得这个类的一些结构发生变化,而原来保存的数据只适用于之前版本的这个类,因此我们需要一种方法来区分类的不同版本:

static class People implements Serializable{
    private static final long serialVersionUID = 123456;   //在序列化时,会被自动添加这个属性,它代表当前类的版本,我们也可以手动指定版本。

    String name;

    public People(String name){
        this.name = name;
    }
}

当发生版本不匹配时,会无法反序列化为对象:

java.io.InvalidClassException: com.test.Main$People; local class incompatible: stream classdesc serialVersionUID = 123456, local class serialVersionUID = 1234567
	at java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:699)
	at java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:2003)
	at java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1850)
	at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2160)
	at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1667)
	at java.io.ObjectInputStream.readObject(ObjectInputStream.java:503)
	at java.io.ObjectInputStream.readObject(ObjectInputStream.java:461)
	at com.test.Main.main(Main.java:27)

如果我们不希望某些属性参与到序列化中进行保存,我们可以添加transient关键字:

public static void main(String[] args) {
    try (ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream("output.txt"));
         ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream("output.txt"))){
        People people = new People("lbw");
        outputStream.writeObject(people);
        outputStream.flush();
        people = (People) inputStream.readObject();
        System.out.println(people.name);  //虽然能得到对象,但是name属性并没有保存,因此为null
    }catch (IOException | ClassNotFoundException e) {
        e.printStackTrace();
    }
}

static class People implements Serializable{
    private static final long serialVersionUID = 1234567;

    transient String name;

    public People(String name){
        this.name = name;
    }
}

其实我们可以看到,在一些JDK内部的源码中,也存在大量的transient关键字,使得某些属性不参与序列化,取消这些不必要保存的属性,可以节省数据空间占用以及减少序列化时间。


实战:图书管理系统

设计实现一个图书管理系统(控制台),支持以下功能:保存书籍信息(要求持久化),查询、添加、删除、修改书籍信息。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值