【Java面试经典】谈谈你用过的集合(一)List

137 阅读11分钟

面试Java开发绕不过去的一个面试题就是集合,算是很基础的知识了,今天来总结一下。

当遇到这个题目的时候,需要全面的说一下,主要三部分,第一部分线性表List、第二部分Set、第三部分Map,我们今天主要来说第一部分。

当我们在回答这一类问题的时候,必须说明其存储结构、操作特点以及适用场景等。

我们主要说说数组、链表、栈和队列几部分;

ArrayList

我们常用的ArrayList就是由数组实现的,是一块连续的内存空间,我们来详细说说ArrayList的存储结构、操作特点以及试用场景

1. 存储结构
    • ArrayList 是基于数组实现的存储结构。在内存中,它会分配一块连续的空间来存储元素,就像一排紧密排列的盒子,每个盒子可以存放一个元素。例如,当创建一个初始容量为 10 的 ArrayList 时,Java 会在内存中开辟一块连续的空间来容纳这 10 个元素。它通过一个内部的数组(Object[] elementData)来实际存储元素,这个数组的大小会根据元素的添加和删除操作动态变化。每次扩容之后容量都会变为原来的 1.5 倍左右(oldCapacity 为偶数就是 1.5 倍,否则是 1.5 倍左右)奇偶不同,比如:10+10/2 = 15, 33+33/2=49。如果是奇数的话会丢掉小数.
2. 操作特点
    • 随机访问:
      • 由于其基于数组的特性,ArrayList 的随机访问速度非常快。可以通过索引直接访问元素,时间复杂度为 O (1)。例如,在一个存储学生成绩的 ArrayList 中,如果要查询第 5 个学生的成绩,能够迅速定位并获取。这是因为在内存中,元素是连续存储的,通过计算偏移量就可以快速找到指定索引的元素。
    • 插入和删除操作:
      • 在列表末尾插入元素比较高效,时间复杂度近似为 O (1)。这是因为如果数组还有剩余空间,只需要将元素添加到数组的下一个空闲位置即可。但是,在中间或开头插入 / 删除元素效率较低,因为需要移动插入 / 删除位置之后的所有元素,时间复杂度为 O (n)。例如,在一个已经有 100 个元素的 ArrayList 中,在第 50 个元素位置插入一个新元素,那么从第 50 个元素往后的所有元素都要向后移动一位。
    • 扩容机制:
      • 当 ArrayList 中的元素数量超过其当前容量时,它会进行扩容。通常情况下,会创建一个新的、更大的数组(一般是原来容量的 1.5 倍左右),然后将旧数组中的元素复制到新数组中。这个过程相对比较耗时,特别是当元素数量较多时。例如,一个容量为 10 的 ArrayList 已经存满了元素,当再添加一个元素时,它会创建一个新的容量可能为 15 的数组,然后将原来的 10 个元素复制过去,最后再添加新元素。
3. 适用场景
    • 适用于频繁进行随机访问操作,而插入和删除操作相对较少的场景。比如存储一个应用程序的配置参数列表,这些参数在运行过程中很少修改,但可能会经常被读取。另外,在对数据进行排序操作时,由于其基于数组的结构,一些排序算法(如快速排序、冒泡排序等)在 ArrayList 上的实现效率也比较高,所以在需要对元素进行排序的场景下也比较适用。

LinkedList

LinkedList即是由链表实现的,双向列表,JDK1.6之前为循环链表,JDK1.7取消了循环。我们来详细说说LinkedList的存储结构、操作特点以及试用场景

1. 存储结构
    • LinkedList 底层是基于双向链表(Doubly - Linked List)存储结构。它由一系列节点(Node)组成,每个节点包含三个部分:一个用于存储数据元素的字段、一个指向前一个节点(prev)的引用和一个指向后一个节点(next)的引用。这种结构就像一条链,每个链节(节点)都连接着前后的链节,使得数据元素可以按顺序排列,并且可以方便地从任何一个节点向前或向后遍历。
2. 操作特点
    • 随机访问:
      • 随机访问元素的速度较慢,因为没有像数组那样的直接索引访问方式。如果要访问第 n 个元素,需要从链表头(或尾)开始逐个遍历节点,时间复杂度为 O (n)。例如,要访问 LinkedList 中的第 5 个元素,可能需要从链表头部开始,沿着节点的引用逐个查找,经过 4 次节点间的跳转才能到达目标节点。
    • 插入和删除操作:
      • 在链表的头部或尾部插入 / 删除元素效率很高,时间复杂度为 O (1)。这是因为在头部插入或删除只需要改变头节点的引用和新节点(如果是插入)的引用,在尾部操作类似。在中间插入 / 删除元素也比较高效,只需要调整相关节点的引用即可,时间复杂度为 O (1)(不考虑查找插入 / 删除位置的时间)。例如,在一个记录日志信息的 LinkedList 中,不断添加新的日志记录(在头部或尾部添加)非常方便,只需要更新相应的节点引用关系。
    • 遍历操作:
      • 可以从头部或尾部开始双向遍历。从头部开始,通过每个节点的 next 引用向后遍历;从尾部开始,通过每个节点的 prev 引用向前遍历。这种双向遍历的特性在某些特定场景下非常有用,比如需要同时查看链表中前后相邻元素的情况。在JDK1.6之前 链表的头节点的prev指针指向尾节点,尾节点的next指针指向头节点 。这种循环双向链表结构提供了一种特殊的遍历方式,即可以从链表中的任意一个节点开始,通过不断地沿着prev和next指针进行遍历,能够在整个链表中循环访问节点。从 JDK 1.7 开始,LinkedList不再是循环链表,而是非循环的双向链表。头节点的prev指针为null,表示没有前驱节点,尾节点的next指针为null,表示没有后继节点。这种改变使得链表的基本操作(如插入、删除和访问)的代码逻辑更加简洁,减少了因为循环结构带来的复杂边界情况处理,同时也更符合大多数实际应用场景中对链表的使用方式。
3. 适用场景
    • 适用于需要频繁进行插入和删除操作,特别是在链表头部或尾部进行操作,而对随机访问要求不高的场景。例如,实现一个简单的队列(在尾部添加元素,头部删除元素)或栈(在头部添加和删除元素)数据结构时,LinkedList 是很好的选择。还比如在一个图形用户界面(GUI)应用程序中管理用户操作的历史记录,每次用户进行一个操作就将其添加到 LinkedList 的头部或尾部,当用户需要撤销操作时,从头部或尾部删除相应的记录。

栈是一种特殊的线性表,它只允许在一端进行插入和删除操作。这一端被称为栈顶(top),另一端则是栈底(bottom),我们通常使用数组或者链表来实现

Java 提供了java.util.Stack类来实现栈 。它继承自java.util.Vector类。

不过,在实际开发中,更推荐使用Deque接口(通常是ArrayDeque实现)来代替Stack,因为Stack类有一些历史遗留的设计问题(例如,它继承自Vector,这使得它继承了一些不必要的方法,并且这些方法可能不符合栈的严格语义)。

Deque接口和ArrayDeque实现

  • java.util.Deque(双端队列)接口可以用于实现栈。ArrayDeque是Deque接口的一个实现类,它提供了高效的栈操作。
  • ArrayDeque在内部是基于数组实现的,它在实现栈操作(如push、pop和peek)时具有很好的性能,时间复杂度为 O (1)。
  1. 存储结构
    • Deque 接口:
      • Deque(双端队列)是一个接口,它定义了在双端进行插入和删除操作的方法集合,本身并没有规定具体的存储结构。它就像是一个抽象的规范,规定了双端队列应该具备的操作,如在头部插入(addFirst)、在头部删除(removeFirst)、在尾部插入(addLast)、在尾部删除(removeLast)等操作方法。
    • ArrayDeque 实现:
      • ArrayDeque 是 Deque 接口的一个实现类,它在内部是基于数组实现的双端队列。它有一个存储元素的内部数组,并且维护了头(front)和尾(tail)指针(在逻辑上,不是传统意义的指针)来跟踪队列的两端。元素在数组中循环存储,当尾指针到达数组末尾后,下一个元素会 “循环” 到数组的开头部分继续存储,这种存储方式有效地利用了数组空间,避免了频繁的数组扩容和收缩操作。
  2. 操作特点
    • 插入操作:
      • 在头部插入(addFirst):当在 ArrayDeque 头部插入一个元素时,需要先调整头指针,然后将元素放置在新的头部位置。这个操作的时间复杂度为 O (1),因为它主要涉及指针操作和简单的数组赋值。例如,在一个存储字符的 ArrayDeque 中,插入一个新的字符到头部非常迅速。
      • 在尾部插入(addLast):在尾部插入元素时,同样要调整尾指针并放置元素,时间复杂度也是 O (1)。这使得在两端添加元素都很高效,例如在实现一个可以从两端添加数据的缓冲区时很有用。
    • 删除操作:
      • 从头部删除(removeFirst):删除头部元素时,先获取头部元素,然后调整头指针,时间复杂度为 O (1)。例如,从一个存储整数的 ArrayDeque 中删除头部整数操作很快。
      • 从尾部删除(removeLast):删除尾部元素的操作类似,时间复杂度为 O (1)。这使得 ArrayDeque 在两端删除元素都很方便,适合用于需要从两端灵活删除元素的场景。
    • 查看操作:
      • 查看头部元素(peekFirst)和查看尾部元素(peekLast):这两个操作只是返回相应位置的元素,而不进行删除,时间复杂度为 O (1)。例如,在一个存储对象的 ArrayDeque 中,可以快速查看两端的对象。
  3. 适用场景
    • 用户操作历史记录:
      • 在图形用户界面(GUI)应用程序中,ArrayDeque 可以用于记录用户的操作历史。例如,在一个文本编辑器中,用户的每一个编辑操作(如插入文字、删除文字等)都可以通过addFirst操作添加到 ArrayDeque 中。当用户需要撤销操作时,通过removeFirst操作回退到上一个操作状态。同时,还可以通过peekFirst操作查看最近的操作,以便在界面上显示操作提示或者恢复选项。
    • 导航栏与面包屑路径:
      • 在 Web 应用程序或者桌面应用程序的导航栏中,ArrayDeque 可以用于存储用户的浏览路径(面包屑路径)。当用户在不同的页面或者视图之间切换时,新的路径从一端添加,通过双端操作可以方便地实现前进和后退功能,为用户提供更好的导航体验。

队列

队列(Queue)的实现

  • java.util.Queue接口和实现类
    • Java 提供了java.util.Queue接口,它定义了队列的基本操作。常见的实现类有LinkedList和ArrayDeque。
    • LinkedList基于链表实现,在实现队列操作(如offer(入队)、poll(出队)和peek(查看队头元素))时,特别是在头部和尾部进行操作,效率较高,时间复杂度为 O (1)。
    • ArrayDeque也可以作为队列来使用,它在内部是基于数组实现的,同样提供了高效的队列操作。