mirror of
https://github.com/Snailclimb/JavaGuide
synced 2025-06-16 18:10:13 +08:00
335 lines
18 KiB
Markdown
335 lines
18 KiB
Markdown
---
|
||
title: Java集合常见面试题总结(上)
|
||
category: Java
|
||
tag:
|
||
- Java集合
|
||
head:
|
||
- - meta
|
||
- name: keywords
|
||
content: Collection,List,Set,Queue,Deque,PriorityQueue
|
||
- - meta
|
||
- name: description
|
||
content: Java集合常见知识点和面试题总结,希望对你有帮助!
|
||
---
|
||
|
||
## 集合概述
|
||
|
||
### Java 集合概览
|
||
|
||
Java 集合, 也叫作容器,主要是由两大接口派生而来:一个是 `Collection`接口,主要用于存放单一元素;另一个是 `Map` 接口,主要用于存放键值对。对于`Collection` 接口,下面又有三个主要的子接口:`List`、`Set` 和 `Queue`。
|
||
|
||
Java 集合框架如下图所示:
|
||
|
||

|
||
|
||
注:图中只列举了主要的继承派生关系,并没有列举所有关系。比方省略了`AbstractList`, `NavigableSet`等抽象类以及其他的一些辅助类,如想深入了解,可自行查看源码。
|
||
|
||
### 说说 List, Set, Queue, Map 四者的区别?
|
||
|
||
- `List`(对付顺序的好帮手): 存储的元素是有序的、可重复的。
|
||
- `Set`(注重独一无二的性质): 存储的元素是无序的、不可重复的。
|
||
- `Queue`(实现排队功能的叫号机): 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。
|
||
- `Map`(用 key 来搜索的专家): 使用键值对(key-value)存储,类似于数学上的函数 y=f(x),"x" 代表 key,"y" 代表 value,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。
|
||
|
||
### 集合框架底层数据结构总结
|
||
|
||
先来看一下 `Collection` 接口下面的集合。
|
||
|
||
#### List
|
||
|
||
- `ArrayList`: `Object[]` 数组
|
||
- `Vector`:`Object[]` 数组
|
||
- `LinkedList`: 双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)
|
||
|
||
#### Set
|
||
|
||
- `HashSet`(无序,唯一): 基于 `HashMap` 实现的,底层采用 `HashMap` 来保存元素
|
||
- `LinkedHashSet`: `LinkedHashSet` 是 `HashSet` 的子类,并且其内部是通过 `LinkedHashMap` 来实现的。有点类似于我们之前说的 `LinkedHashMap` 其内部是基于 `HashMap` 实现一样,不过还是有一点点区别的
|
||
- `TreeSet`(有序,唯一): 红黑树(自平衡的排序二叉树)
|
||
|
||
#### Queue
|
||
|
||
- `PriorityQueue`: `Object[]` 数组来实现二叉堆
|
||
- `ArrayQueue`: `Object[]` 数组 + 双指针
|
||
|
||
再来看看 `Map` 接口下面的集合。
|
||
|
||
#### Map
|
||
|
||
- `HashMap`: JDK1.8 之前 `HashMap` 由数组+链表组成的,数组是 `HashMap` 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间
|
||
- `LinkedHashMap`: `LinkedHashMap` 继承自 `HashMap`,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,`LinkedHashMap` 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。详细可以查看:[《LinkedHashMap 源码详细分析(JDK1.8)》](https://www.imooc.com/article/22931)
|
||
- `Hashtable`: 数组+链表组成的,数组是 `Hashtable` 的主体,链表则是主要为了解决哈希冲突而存在的
|
||
- `TreeMap`: 红黑树(自平衡的排序二叉树)
|
||
|
||
### 如何选用集合?
|
||
|
||
主要根据集合的特点来选用,比如我们需要根据键值获取到元素值时就选用 `Map` 接口下的集合,需要排序时选择 `TreeMap`,不需要排序时就选择 `HashMap`,需要保证线程安全就选用 `ConcurrentHashMap`。
|
||
|
||
当我们只需要存放元素值时,就选择实现`Collection` 接口的集合,需要保证元素唯一时选择实现 `Set` 接口的集合比如 `TreeSet` 或 `HashSet`,不需要就选择实现 `List` 接口的比如 `ArrayList` 或 `LinkedList`,然后再根据实现这些接口的集合的特点来选用。
|
||
|
||
### 为什么要使用集合?
|
||
|
||
当我们需要保存一组类型相同的数据的时候,我们应该是用一个容器来保存,这个容器就是数组,但是,使用数组存储对象具有一定的弊端,
|
||
因为我们在实际开发中,存储的数据的类型是多种多样的,于是,就出现了“集合”,集合同样也是用来存储多个数据的。
|
||
|
||
数组的缺点是一旦声明之后,长度就不可变了;同时,声明数组时的数据类型也决定了该数组存储的数据的类型;而且,数组存储的数据是有序的、可重复的,特点单一。
|
||
但是集合提高了数据存储的灵活性,Java 集合不仅可以用来存储不同类型不同数量的对象,还可以保存具有映射关系的数据。
|
||
|
||
## Collection 子接口之 List
|
||
|
||
### ArrayList 和 Vector 的区别?
|
||
|
||
- `ArrayList` 是 `List` 的主要实现类,底层使用 `Object[]`存储,适用于频繁的查找工作,线程不安全 ;
|
||
- `Vector` 是 `List` 的古老实现类,底层使用`Object[]` 存储,线程安全的。
|
||
|
||
### ArrayList 与 LinkedList 区别?
|
||
|
||
- **是否保证线程安全:** `ArrayList` 和 `LinkedList` 都是不同步的,也就是不保证线程安全;
|
||
- **底层数据结构:** `ArrayList` 底层使用的是 **`Object` 数组**;`LinkedList` 底层使用的是 **双向链表** 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别,下面有介绍到!)
|
||
- **插入和删除是否受元素位置的影响:**
|
||
- `ArrayList` 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行`add(E e)`方法的时候, `ArrayList` 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(`add(int index, E element)`)时间复杂度就为 O(n-i)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。
|
||
- `LinkedList` 采用链表存储,所以,如果是在头尾插入或者删除元素不受元素位置的影响(`add(E e)`、`addFirst(E e)`、`addLast(E e)`、`removeFirst()` 、 `removeLast()`),时间复杂度为 O(1),如果是要在指定位置 `i` 插入和删除元素的话(`add(int index, E element)`,`remove(Object o)`), 时间复杂度为 O(n) ,因为需要先移动到指定位置再插入。
|
||
- **是否支持快速随机访问:** `LinkedList` 不支持高效的随机元素访问,而 `ArrayList`(实现了 RandomAccess 接口) 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于`get(int index)`方法)。
|
||
- **内存空间占用:** `ArrayList` 的空 间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。
|
||
|
||
我们在项目中一般是不会使用到 `LinkedList` 的,需要用到 `LinkedList` 的场景几乎都可以使用 `ArrayList` 来代替,并且,性能通常会更好!就连 `LinkedList` 的作者约书亚 · 布洛克(Josh Bloch)自己都说从来不会使用 `LinkedList` 。
|
||
|
||

|
||
|
||
另外,不要下意识地认为 `LinkedList` 作为链表就最适合元素增删的场景。我在上面也说了,`LinkedList` 仅仅在头尾插入或者删除元素的时候时间复杂度近似 O(1),其他情况增删元素的时间复杂度都是 O(n) 。
|
||
|
||
#### 补充内容:双向链表和双向循环链表
|
||
|
||
**双向链表:** 包含两个指针,一个 prev 指向前一个节点,一个 next 指向后一个节点。
|
||
|
||

|
||
|
||
**双向循环链表:** 最后一个节点的 next 指向 head,而 head 的 prev 指向最后一个节点,构成一个环。
|
||
|
||

|
||
|
||
#### 补充内容:RandomAccess 接口
|
||
|
||
```java
|
||
public interface RandomAccess {
|
||
}
|
||
```
|
||
|
||
查看源码我们发现实际上 `RandomAccess` 接口中什么都没有定义。所以,在我看来 `RandomAccess` 接口不过是一个标识罢了。标识什么? 标识实现这个接口的类具有随机访问功能。
|
||
|
||
在 `binarySearch()` 方法中,它要判断传入的 list 是否 `RandomAccess` 的实例,如果是,调用`indexedBinarySearch()`方法,如果不是,那么调用`iteratorBinarySearch()`方法
|
||
|
||
```java
|
||
public static <T>
|
||
int binarySearch(List<? extends Comparable<? super T>> list, T key) {
|
||
if (list instanceof RandomAccess || list.size()<BINARYSEARCH_THRESHOLD)
|
||
return Collections.indexedBinarySearch(list, key);
|
||
else
|
||
return Collections.iteratorBinarySearch(list, key);
|
||
}
|
||
```
|
||
|
||
`ArrayList` 实现了 `RandomAccess` 接口, 而 `LinkedList` 没有实现。为什么呢?我觉得还是和底层数据结构有关!`ArrayList` 底层是数组,而 `LinkedList` 底层是链表。数组天然支持随机访问,时间复杂度为 O(1),所以称为快速随机访问。链表需要遍历到特定位置才能访问特定位置的元素,时间复杂度为 O(n),所以不支持快速随机访问。,`ArrayList` 实现了 `RandomAccess` 接口,就表明了他具有快速随机访问功能。 `RandomAccess` 接口只是标识,并不是说 `ArrayList` 实现 `RandomAccess` 接口才具有快速随机访问功能的!
|
||
|
||
### 说一说 ArrayList 的扩容机制吧
|
||
|
||
详见笔主的这篇文章: [ArrayList 扩容机制分析](https://javaguide.cn/java/collection/arraylist-source-code.html#_3-1-%E5%85%88%E4%BB%8E-arraylist-%E7%9A%84%E6%9E%84%E9%80%A0%E5%87%BD%E6%95%B0%E8%AF%B4%E8%B5%B7)
|
||
|
||
## Collection 子接口之 Set
|
||
|
||
### comparable 和 Comparator 的区别
|
||
|
||
- `comparable` 接口实际上是出自`java.lang`包 它有一个 `compareTo(Object obj)`方法用来排序
|
||
- `comparator`接口实际上是出自 java.util 包它有一个`compare(Object obj1, Object obj2)`方法用来排序
|
||
|
||
一般我们需要对一个集合使用自定义排序时,我们就要重写`compareTo()`方法或`compare()`方法,当我们需要对某一个集合实现两种排序方式,比如一个 song 对象中的歌名和歌手名分别采用一种排序方法的话,我们可以重写`compareTo()`方法和使用自制的`Comparator`方法或者以两个 Comparator 来实现歌名排序和歌星名排序,第二种代表我们只能使用两个参数版的 `Collections.sort()`.
|
||
|
||
#### Comparator 定制排序
|
||
|
||
```java
|
||
ArrayList<Integer> arrayList = new ArrayList<Integer>();
|
||
arrayList.add(-1);
|
||
arrayList.add(3);
|
||
arrayList.add(3);
|
||
arrayList.add(-5);
|
||
arrayList.add(7);
|
||
arrayList.add(4);
|
||
arrayList.add(-9);
|
||
arrayList.add(-7);
|
||
System.out.println("原始数组:");
|
||
System.out.println(arrayList);
|
||
// void reverse(List list):反转
|
||
Collections.reverse(arrayList);
|
||
System.out.println("Collections.reverse(arrayList):");
|
||
System.out.println(arrayList);
|
||
|
||
// void sort(List list),按自然排序的升序排序
|
||
Collections.sort(arrayList);
|
||
System.out.println("Collections.sort(arrayList):");
|
||
System.out.println(arrayList);
|
||
// 定制排序的用法
|
||
Collections.sort(arrayList, new Comparator<Integer>() {
|
||
|
||
@Override
|
||
public int compare(Integer o1, Integer o2) {
|
||
return o2.compareTo(o1);
|
||
}
|
||
});
|
||
System.out.println("定制排序后:");
|
||
System.out.println(arrayList);
|
||
```
|
||
|
||
Output:
|
||
|
||
```
|
||
原始数组:
|
||
[-1, 3, 3, -5, 7, 4, -9, -7]
|
||
Collections.reverse(arrayList):
|
||
[-7, -9, 4, 7, -5, 3, 3, -1]
|
||
Collections.sort(arrayList):
|
||
[-9, -7, -5, -1, 3, 3, 4, 7]
|
||
定制排序后:
|
||
[7, 4, 3, 3, -1, -5, -7, -9]
|
||
```
|
||
|
||
#### 重写 compareTo 方法实现按年龄来排序
|
||
|
||
```java
|
||
// person对象没有实现Comparable接口,所以必须实现,这样才不会出错,才可以使treemap中的数据按顺序排列
|
||
// 前面一个例子的String类已经默认实现了Comparable接口,详细可以查看String类的API文档,另外其他
|
||
// 像Integer类等都已经实现了Comparable接口,所以不需要另外实现了
|
||
public class Person implements Comparable<Person> {
|
||
private String name;
|
||
private int age;
|
||
|
||
public Person(String name, int age) {
|
||
super();
|
||
this.name = name;
|
||
this.age = age;
|
||
}
|
||
|
||
public String getName() {
|
||
return name;
|
||
}
|
||
|
||
public void setName(String name) {
|
||
this.name = name;
|
||
}
|
||
|
||
public int getAge() {
|
||
return age;
|
||
}
|
||
|
||
public void setAge(int age) {
|
||
this.age = age;
|
||
}
|
||
|
||
/**
|
||
* T重写compareTo方法实现按年龄来排序
|
||
*/
|
||
@Override
|
||
public int compareTo(Person o) {
|
||
if (this.age > o.getAge()) {
|
||
return 1;
|
||
}
|
||
if (this.age < o.getAge()) {
|
||
return -1;
|
||
}
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
```java
|
||
public static void main(String[] args) {
|
||
TreeMap<Person, String> pdata = new TreeMap<Person, String>();
|
||
pdata.put(new Person("张三", 30), "zhangsan");
|
||
pdata.put(new Person("李四", 20), "lisi");
|
||
pdata.put(new Person("王五", 10), "wangwu");
|
||
pdata.put(new Person("小红", 5), "xiaohong");
|
||
// 得到key的值的同时得到key所对应的值
|
||
Set<Person> keys = pdata.keySet();
|
||
for (Person key : keys) {
|
||
System.out.println(key.getAge() + "-" + key.getName());
|
||
|
||
}
|
||
}
|
||
```
|
||
|
||
Output:
|
||
|
||
```
|
||
5-小红
|
||
10-王五
|
||
20-李四
|
||
30-张三
|
||
```
|
||
|
||
### 无序性和不可重复性的含义是什么
|
||
|
||
- 无序性不等于随机性 ,无序性是指存储的数据在底层数组中并非按照数组索引的顺序添加 ,而是根据数据的哈希值决定的。
|
||
- 不可重复性是指添加的元素按照 `equals()` 判断时 ,返回 false,需要同时重写 `equals()` 方法和 `hashCode()` 方法。
|
||
|
||
### 比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同
|
||
|
||
- `HashSet`、`LinkedHashSet` 和 `TreeSet` 都是 `Set` 接口的实现类,都能保证元素唯一,并且都不是线程安全的。
|
||
- `HashSet`、`LinkedHashSet` 和 `TreeSet` 的主要区别在于底层数据结构不同。`HashSet` 的底层数据结构是哈希表(基于 `HashMap` 实现)。`LinkedHashSet` 的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。`TreeSet` 底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。
|
||
- 底层数据结构不同又导致这三者的应用场景不同。`HashSet` 用于不需要保证元素插入和取出顺序的场景,`LinkedHashSet` 用于保证元素的插入和取出顺序满足 FIFO 的场景,`TreeSet` 用于支持对元素自定义排序规则的场景。
|
||
|
||
## Collection 子接口之 Queue
|
||
|
||
### Queue 与 Deque 的区别
|
||
|
||
`Queue` 是单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循 **先进先出(FIFO)** 规则。
|
||
|
||
`Queue` 扩展了 `Collection` 的接口,根据 **因为容量问题而导致操作失败后处理方式的不同** 可以分为两类方法: 一种在操作失败后会抛出异常,另一种则会返回特殊值。
|
||
|
||
| `Queue` 接口 | 抛出异常 | 返回特殊值 |
|
||
| ------------ | --------- | ---------- |
|
||
| 插入队尾 | add(E e) | offer(E e) |
|
||
| 删除队首 | remove() | poll() |
|
||
| 查询队首元素 | element() | peek() |
|
||
|
||
`Deque` 是双端队列,在队列的两端均可以插入或删除元素。
|
||
|
||
`Deque` 扩展了 `Queue` 的接口, 增加了在队首和队尾进行插入和删除的方法,同样根据失败后处理方式的不同分为两类:
|
||
|
||
| `Deque` 接口 | 抛出异常 | 返回特殊值 |
|
||
| ------------ | ------------- | --------------- |
|
||
| 插入队首 | addFirst(E e) | offerFirst(E e) |
|
||
| 插入队尾 | addLast(E e) | offerLast(E e) |
|
||
| 删除队首 | removeFirst() | pollFirst() |
|
||
| 删除队尾 | removeLast() | pollLast() |
|
||
| 查询队首元素 | getFirst() | peekFirst() |
|
||
| 查询队尾元素 | getLast() | peekLast() |
|
||
|
||
事实上,`Deque` 还提供有 `push()` 和 `pop()` 等其他方法,可用于模拟栈。
|
||
|
||
### ArrayDeque 与 LinkedList 的区别
|
||
|
||
`ArrayDeque` 和 `LinkedList` 都实现了 `Deque` 接口,两者都具有队列的功能,但两者有什么区别呢?
|
||
|
||
- `ArrayDeque` 是基于可变长的数组和双指针来实现,而 `LinkedList` 则通过链表来实现。
|
||
|
||
- `ArrayDeque` 不支持存储 `NULL` 数据,但 `LinkedList` 支持。
|
||
|
||
- `ArrayDeque` 是在 JDK1.6 才被引入的,而`LinkedList` 早在 JDK1.2 时就已经存在。
|
||
|
||
- `ArrayDeque` 插入时可能存在扩容过程, 不过均摊后的插入操作依然为 O(1)。虽然 `LinkedList` 不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢。
|
||
|
||
从性能的角度上,选用 `ArrayDeque` 来实现队列要比 `LinkedList` 更好。此外,`ArrayDeque` 也可以用于实现栈。
|
||
|
||
### 说一说 PriorityQueue
|
||
|
||
`PriorityQueue` 是在 JDK1.5 中被引入的, 其与 `Queue` 的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。
|
||
|
||
这里列举其相关的一些要点:
|
||
|
||
- `PriorityQueue` 利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据
|
||
- `PriorityQueue` 通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。
|
||
- `PriorityQueue` 是非线程安全的,且不支持存储 `NULL` 和 `non-comparable` 的对象。
|
||
- `PriorityQueue` 默认是小顶堆,但可以接收一个 `Comparator` 作为构造参数,从而来自定义元素优先级的先后。
|
||
|
||
`PriorityQueue` 在面试中可能更多的会出现在手撕算法的时候,典型例题包括堆排序、求第 K 大的数、带权图的遍历等,所以需要会熟练使用才行。
|