第七章 集合
7.1 集合概述
- 什么是集合,有什么用?
- 集合是一种容器,用来组织和管理数据的。非常重要。
- Java的集合框架对应的这套类库其实就是对各种数据结构的实现。
- 每一个集合类底层采用的数据结构不同,例如ArrayList集合底层采用了数组,LinkedList集合底层采用了双向链表,HashMap集合底层采用了哈希表,TreeMap集合底层采用了红黑树。
- 我们不用写数据结构的实现了。直接用就行了。但我们需要知道的是在哪种场合下选择哪一个集合效率是最高的。
- 集合中存储的是引用,不是把堆中的对象存储到集合中,是把对象的地址存储到集合中。
- 默认情况下,如果不使用泛型的话,集合中可以存储任何类型的引用,只要是Object的子类都可以存储。
- Java集合框架相关的类都在 java.util 包下。
- Java集合框架分为两部分:
- Collection结构:元素以单个形式存储。
- Map结构:元素以键值对的映射关系存储。
7.2 Collection继承结构
- SequencedCollection和SequencedSet接口都是Java21新增的接口。
- 右图中蓝色的是实现类。其它的都是接口。
- 6个实现类中只有HashSet是无序集合。剩下的都是有序集合。
- 有序集合:集合中存储的元素有下标或者集合中存储的元素是可排序的。
- 无序集合:集合中存储的元素没有下标并且集合中存储的元素也没有排序。
- 每个集合实现类对应的数据结构如下:
- LinkedList:双向链表(不是队列数据结构,但使用它可以模拟队列)
- ArrayList:数组
- Vector:数组(线程安全的)
- HashSet:哈希表
- LinkedHashSet:双向链表和哈希表结合体
- TreeSet:红黑树
- List集合中存储的元素可重复。Set集合中存储的元素不可重复。
7.3 Collection接口
7.3.1 Collection接口的通用方法
boolean add(E e);
向集合中添加元素int size();
获取集合中元素个数boolean addAll(Collection c);
将参数集合中所有元素全部加入当前集合boolean contains(Object o);
判断集合中是否包含对象o(底层到底包含o还是不包含o,调用equals方法进行比对。)boolean remove(Object o);
从集合中删除对象ovoid clear();
清空集合boolean isEmpty();
判断集合中元素个数是否为0Object[] toArray();
将集合转换成一维数组
7.3.2 Collection的遍历(集合的通用遍历方式)【非常重要】
- 第一步:获取当前集合依赖的迭代器对象
Iterator it = collection.iterator(); - 第二步:编写循环,循环条件是:当前光标指向的位置是否存在元素。
while(it.hasNext()){} - 第三步:如果有,将光标指向的当前元素返回,并且将光标向下移动一位。
Object obj = it.next();
public class CollectionTest03 {
public static void main(String[] args) {
// 创建集合对象
// Collection c = new ArrayList();
// 修改成这个代码之后底层数据结构就变成了链表的结构,但是后续的代码不需要进行任何修改
// 这是因为后续所有的代码都是面向接口编程的。去更换数据结构,后续的迭代程序不需要修改
Collection c = new LinkedList();
// 添加
c.add("zhangsan");
c.add("xxx");
c.add("fff");
// 遍历
// 第一步:获取迭代器
Iterator it = c.iterator();
// 第二步:判断当前光标指向的位置有没有数据
/* boolean has = it.hasNext();
// 第三步:如果has是true,表示当前光标指向的位置有数据
if (has) {
// 1. 先将光标指向的当前位置的元素获取到
// 2. 再将cursor向下移动一位
Object obj = it.next();
System.out.println(obj);
}*/
// 循环方式
while (it.hasNext()) {
Object object = it.next();
System.out.println(object);
}
// for循环
/*for (Iterator it1 = c.iterator(); it.hasNext(); ) {
Object obj = it.next();
System.out.println(obj);
}*/
}
}
7.4 SequencedCollection接口
所有的有序集合都实现了SequencedCollection接口
- SequencedCollection接口是Java21版本新增的。
- SequencedCollection接口中的方法:
void addFirst(Object o)
:向头部添加void addLast(Object o)
:向末尾添加Object removeFirst()
:删除头部Object removeLast()
:删除末尾Object getFirst()
:获取头部节点Object getLast()
:获取末尾节点SequencedCollection reversed()
; 反转集合中的元素
- ArrayList,LinkedList,Vector,LinkedHashSet,TreeSet,Stack 都可以调用这个接口中的方法。
7.5 泛型
7.5.1 泛型概述
- 泛型是Java5的新特性,属于编译阶段的功能。
- 泛型可以让开发者在编写代码时指定集合中存储的数据类型
- 泛型作用:
- 类型安全:指定了集合中元素的类型之后,编译器会在编译时进行类型检查,如果尝试将错误类型的元素添加到集合中,就会在编译时报错,避免了在运行时出现类型错误的问题。
- 代码简洁:使用泛型可以简化代码,避免了繁琐的类型转换操作。比如,在没有泛型的时候,需要使用 Object 类型来保存集合中的元素,并在使用时强制类型转换成实际类型,而有了泛型之后,只需要在定义集合时指定类型即可。
- 在集合中使用泛型
Collection strs = new ArrayList();
这就表示该集合只能存储字符串,存储其它类型时编译器报错。
并且以上代码使用泛型后,避免了繁琐的类型转换,集合中的元素可以直接调用String类特有的方法。 - Java7的新特性:钻石表达式
Collection<String> strs = new ArrayList<>();
7.5.2 泛型的擦除与补偿(了解)
- 泛型的出现提高了编译时的安全性,正因为编译时对添加的数据做了检查,则程序运行时才不会抛出类型转换异常。因此泛型本质上是编译时期的技术,是专门给编译器用的。加载类的时候,会将泛型擦除掉(擦除之后的类型为Object类型),这个称为泛型擦除。
- 为什么要有泛型擦除呢?其本质是为了让JDK1.4和JDK1.5能够兼容同一个类加载器。在JDK1.5版本中,程序编译时期会对集合添加的元素进行安全检查,如果检查完是安全的、没有错误的,那么就意味着添加的元素都属于同一种数据类型,则加载类时就可以把这个泛型擦除掉,将泛型擦除后的类型就是Object类,这样擦除之后的代码就与JDK1.4的代码一致。
- 由于加载类的时候,会默认将类中的泛型擦除为Object类型,所以添加的元素就被转化为Object类型,同时取出的元素也默认为Object类型。而我们获得集合中的元素时,按理说取出的元素应该是Object类型,为什么取出的元素却是实际添加的元素类型呢?
- 这里又做了一个默认的操作,我们称之为泛型的补偿。在程序运行时,通过获取元素的实际类型进行强转,这就叫做泛型补偿(不必手动实现强制转换)。获得集合中的元素时,虚拟机会根据获得元素的实际类型进行向下转型,也就是会恢复获得元素的实际类型,因此我们就无需手动执行向下转型操作,从本质上避免了抛出类型转换异常
7.5.3 泛型的使用
-
泛型的使用:在类上定义泛型
语法:class 类名<泛型1,泛型2,泛型3...>{}
/** * 在类上自定义泛型。 */ public class MyClass<T> { // 这一行代码的作用是:表示在类声明的时候,给类声明/定义一个泛型。 private T name; public MyClass(T name) { this.name = name; } public T getName() { return name; } public void setName(T name) { this.name = name; } public static void main(String[] args) { MyClass<String> myClass = new MyClass<>("zhangsan"); myClass.setName("lisi"); // 编译检查错误! //MyClass<String> myClass2 = new MyClass<>(100); MyClass<Integer> myClass2 = new MyClass<>(100); myClass2.setName(120); } }
-
泛型的使用:在静态方法上定义泛型
- 在类上定义的泛型,在静态方法中无法使用。如果在静态方法中使用泛型,则需要再方法返回值类型前面进行泛型的声明。
- 语法格式:
<泛型1, 泛型2, 泛型3, ...> 返回值类型 方法名(形参列表) {}
-
泛型的使用:在接口上定义泛型
- 语法格式:
interface 接口名<泛型1,泛型2,...> {}
- 例如:
public interface Flayable<T>{}
- 实现接口时,如果知道具体的类型,则:
public class MyClass implements Flyable<Bird>{}
- 实现接口时,如果不知道具体的类型,则:
public class MyClass<T> implements Flyable<T>{}
- 语法格式:
7.5.4 泛型通配符
- 泛型是在限定数据类型,当在集合或者其他地方使用到泛型后,那么这时一旦明确泛型的数据类型,那么在使用的时候只能给其传递和数据类型匹配的类型,否则就会报错。
- 有的情况下,我们在定义方法时,根本无法确定集合中存储元素的类型是什么。为了解决这个“无法确定集合中存储元素类型”问题,那么Java语言就提供了泛型的通配符。
- 通配符的几种形式:
- 无限定通配符,
<?>
,此处“?”可以为任意引用数据类型。 - 上限通配符,
<? extends Number>
,此处“?”必须为Number及其子类。 - 下限通配符,
<? super Number>
,此处“?”必须为Number及其父类。
- 无限定通配符,
7.6 迭代时删除元素
- 迭代集合时删除元素
- 使用“集合对象.remove(元素)”:会出现ConcurrentModificationException异常。
- 使用“迭代器对象.remove()”:不会出现异常。
- 关于集合的并发修改问题
- 想象一下,有两个线程:A和B。A线程负责迭代遍历集合,B线程负责删除集合中的某个元素。当这两个线程同时执行时会有什么问题?
- 如何解决并发修改问题:fail-fast机制
- fail-fast机制又被称为:快速失败机制。也就是说只要程序发现了程序对集合进行了并发修改。就会立即让其失败,以防出现错误。
- fail-fast机制是如何实现的?以下是源码中的实现原理:
- 集合中设置了一个modCount属性,用来记录修改次数,使用集合对象执行增,删,改中任意一个操作时,modCount就会自动加1。
- 获取迭代器对象的时候,会给迭代器对象初始化一个expectedModCount属性。并且将expectedModCount初始化为modCount,即:int expectedModCount = modCount;
- 当使用集合对象删除元素时:modCount会加1。但是迭代器中的expectedModCount不会加1。而当迭代器对象的next()方法执行时,会检测expectedModCount和modCount是否相等,如果不相等,则抛出:ConcurrentModificationException异常。
- 当使用迭代器删除元素的时候:modCount会加1,并且expectedModCount也会加1。这样当迭代器对象的next()方法执行时,检测到的expectedModCount和modCount相等,则不会出现ConcurrentModificationException异常。
- 注意:虽然我们当前写的程序是单线程的程序,并没有使用多线程,但是通过迭代器去遍历的同时使用集合去删除元素,这个行为将被认定为并发修改。
- 结论:迭代集合时,删除元素要使用“迭代器对象.remove()”方法来删除,避免使用“集合对象.remove(元素)”。主要是为了避免ConcurrentModificationException异常的发生。注意:迭代器的remove()方法删除的是next()方法的返回的那个数据。remove()方法调用之前一定是先调用了next()方法,如果不是这样的,就会报错。
7.7 List接口
7.7.1 List接口常用方法
- List集合存储元素特点:有序可重复。
- 有序:是因为List集合中的元素都是有下标的,从0开始,以1递增。
- 可重复:存进去1,还可以再存一个1。
- List接口下常见的实现类有:
ArrayList
:数组Vector、Stack
:数组(线程安全的)LinkedList
:双向链表
- List接口特有方法:(在Collection和SequencedCollection中没有的方法,只适合List家族使用的方法,这些方法都和下标有关系。)
void add(int index, E element)
在指定索引处插入元素E set(int index, E element)
; 修改索引处的元素E get(int index)
; 根据索引获取元素(通过这个方法List集合具有自己特殊的遍历方式:根据下标遍历)E remove(int index)
; 删除索引处的元素int indexOf(Object o)
; 获取对象o在当前集合中第一次出现时的索引。int lastIndexOf(Object o)
; 获取对象o在当前集合中最后一次出现时的索引。List<E> subList(int fromIndex, int toIndex)
; 截取子List集合生成一个新集合(对原集合无影响)。[fromIndex, toIndex)static List<E> of(E... elements)
; 静态方法,返回包含任意数量元素的不可修改列表。(获取的集合是只读的,不可修改的。)
7.7.2 List接口特有迭代
- 特有的迭代方式
ListIterator<E> listIterator()
; 获取List集合特有的迭代器(该迭代器功能更加强大,但只适合于List集合使用)ListIterator<E> listIterator(int index)
; 从列表中的指定位置开始,返回列表中元素的列表迭代器
- ListIterator接口中的常用方法:
boolean hasNext();
判断光标当前指向的位置是否存在元素。E next();
将当前光标指向的元素返回,然后将光标向下移动一位。void remove();
删除上一次next()方法返回的那个数据(删除的是集合中的)。remove()方法调用的前提是:你先调用next()方法。不然会报错。void add(E e);
添加元素(将元素添加到光标指向的位置,然后光标向下移动一位。)boolean hasPrevious();
判断当前光标指向位置的上一个位置是否存在元素。E previous();
获取上一个元素(将光标向上移动一位,然后将光标指向的元素返回)int nextIndex();
获取光标指向的那个位置的下标int previousIndex();
获取光标指向的那个位置的上一个位置的下标void set(E e);
修改的是上一次next()方法返回的那个数据(修改的是集合中的)。set()方法调用的前提是:你先调用了next()方法。不然会报错。
7.7.3 List接口使用Comparator排序
- 回顾数组中自定义类型是如何排序的?
- 所有自定义类型排序时必须指定排序规则。(int不需要指定,String不需要指定,因为他们都有固定的排序规则。int按照数字大小。String按照字典中的顺序)
- 如何给自定义类型指定排序规则?让自定义类型实现java.lang.Comparable接口,然后重写compareTo方法,在该方法中指定比较规则。
- List集合的排序
- default void sort(Comparator<? super E> c); 对List集合中元素排序可以调用此方法。
- sort方法需要一个参数: java.util.Comparator。我们把这个参数叫做比较器。这是一个接口。
- 如何给自定义类型指定比较规则?可以对Comparator提供一个实现类,并重写compare方法来指定比较规则。
- 当然,Comparator接口的实现类也可以采用匿名内部类的方式。
7.8 ArrayList
- ArrayList集合底层采用了数组这种数据结构。
- ArrayList集合优点:
- 底层是数组,因此根据下标查找元素的时间复杂度是O(1)。因此检索效率高。
- ArrayList集合缺点:
- 随机增删元素效率较低。不过只要数组的容量还没满,对末尾元素进行增删,效率不受影响。
- ArrayList集合适用场景:
- 需要频繁的检索元素,并且很少的进行随机增删元素时建议使用。
- ArrayList默认初始化容量?
- 从源码角度可以看到,当调用无参数构造方法时,初始化容量0,当第一次调用add方法时将ArrayList容量初始化为10个长度。
- ArrayList集合扩容策略?
- 底层扩容会创建一个新的数组,然后使用数组拷贝。扩容之后的新容量是原容量的1.5倍。
- ArrayList集合源码分析:
- 属性分析
- 构造方法分析(使用ArrayList集合时最好也是预测大概数量,给定初始化容量,减少扩容次数。)
- 添加元素
- 修改元素
- 插入元素
- 删除元素
常用方法
add(E e) 添加元素在末尾
add(int index, E element) 在指定位置插入元素
remove(int index) 根据下标删除元素
remove(Object o) 根据对象删除元素
set(int index, E element) 修改元素
get(int index) 根据下标获取元素
toArray() 将集合转换为数组
size() 返回此列表中的元素数。
isEmpty() 判断集合长度是否为0
iterator() 获取迭代器
indexOf(Object o) 返回集合中指定元素第一次出现的位置
contains(Object o) 判断集合是否包含某个元素
clear() 清空集合
7.9 Vector
- Vector底层也是数组,和ArrayList相同。
- 不同的是Vector几乎所有的方法都是线程同步的(被synchronized修饰:线程排队执行,不能并发),因此Vector是线程安全的,但由于效率较低,很少使用。因为控制线程安全有新方式。
- Vector初始化容量:10
- Vector扩容策略:扩容之后的容量是原容量的2倍。
ArrayList和Vector的区别、?
Vector是线程安全的 ArrayList线程不安全
Vector无参构造初始化数组长度为10 ArrayList无参构造初始化数组长度为0
Vector扩容2倍 ArrayList扩容1.5倍
7.10 链表存储结构
- 单向链表
- 双向链表
- 环形链表
- 环形单链表
- 环形双链表
- 链表优点:
- 因为链表节点在空间存储上,内存地址不是连续的。因此删除某个节点时不需要涉及到元素位移的问题。因此随机增删元素效率较高。时间复杂度O(1)
- 链表缺点:
- 链表中元素在查找时,只能从某个节点开始顺序查找,因为链表节点的内存地址在空间上不是连续的。链表查找元素效率较低,时间复杂度O(n)
- 链表的适用场景:
- 需要频繁进行随机增删,但很少的查找的操作时。
7.11 LinkedList(双向链表)
- LinkedList是一个双向链表
- 源码分析:
- 属性分析
- 构造方法分析
- 添加元素
- 修改元素
- 插入元素
- 删除元素
- 手写单向链表
add(E e) 添加元素在末尾
add(int index, E element) 在指定位置插入元素
addFirst( E element) 添加在头部
addLast( E element) 添加在尾部
remove(int index) 根据下标删除元素
remove(Object o) 根据对象删除元素
removeFirst() 删除头部元素
removeLast() 删除尾部元素
set(int index, E element) 修改元素
get(int index) 根据下标获取元素
getFirst() 获取头部元素
getLast() 获取尾部元素
toArray() 将集合转换为数组
size() 返回此列表中的元素数。
isEmpty() 判断集合长度是否为0
iterator() 获取迭代器
indexOf(Object o) 返回集合中指定元素第一次出现的位置
contains(Object o) 判断集合是否包含某个元素
clear() 清空集合
7.12 栈数据结构
- LIFO原则(Last In,First Out):后进先出
- 实现栈数据结构,可以用数组来实现,也可以用双向链表来实现。
- 用数组实现的代表是:Stack、ArrayDeque
- Stack:Vetor的子类,实现了栈数据结构,除了具有Vetor的方法,还扩展了其它方法,完成了栈结构的模拟。不过在JDK1.6(Java6)之后就不建议使用了,因为它是线程安全的,太慢了。Stack中的方法如下:
- E push(E item):压栈
- E pop():弹栈(将栈顶元素删除,并返回被删除的引用)
- int search(Object o):查找栈中元素(返回值的意思是:以1为开始,从栈顶往下数第几个)
- E peek():窥视栈顶元素(不会将栈顶元素删除,只是看看栈顶元素是什么。注意:如果栈为空时会报异常。)
- ArrayDeque
- E push(E item)
- E pop()
- Stack:Vetor的子类,实现了栈数据结构,除了具有Vetor的方法,还扩展了其它方法,完成了栈结构的模拟。不过在JDK1.6(Java6)之后就不建议使用了,因为它是线程安全的,太慢了。Stack中的方法如下:
- 用链表实现的代表是:LinkedList
- LinkedList
- E push(E item)
- E pop()
- LinkedList
7.13 队列数据结构
- 队列是一种特殊的线性表,特殊之处在于它只允许在表的前端(front)进行删除操作,而在表的后端(rear)进行插入操作,队列是一种操作受限制的线性表。进行插入操作(入口)的端称为队尾,进行删除操作(出口)的端称为队头。
- 队列的插入操作只能在队尾操作,队列的删除操作只能在队头操作,因此队列是一种先进先出(First In First Out)的线性表,简称FIFO表。
- Queue接口是一种基于FIFO(先进先出)的数据结构,而Deque接口则同时支持FIFO和LIFO(后进先出)两种操作。因此Deque接口也被称为“双端队列”。
- Java集合框架中队列的实现:
- 链表实现方式:LinkedList
- 数组实现方式:ArrayDeque
- LinkedList和ArrayDeque都实现了Queue、Deque接口,因此这两个类都具备队列和双端队列的特性。
- LinkedList底层是基于双向链表实现的,因此它天然就是一个双端队列,既支持从队尾入队,从队头出队,也支持从队头入队,从队尾出队。用Deque的实现方式来说,就是它既实现了队列的offer()和poll()方法,也实现了双端队列的offerFirst()、offerLast()、pollFirst()和pollLast()方法等。
- ArrayDeque底层是使用环形数组实现的,也是一个双端队列。它比LinkedList更加高效,因为在数组中随机访问元素的时间复杂度是O(1),而链表中需要从头或尾部遍历链表寻找元素,时间复杂度是O(N)。循环数组:index = (start + i) % capacity
- Queue接口基于Collection扩展的方法包括:
boolean offer(E e); 入队。
E poll(); 出队,如果队列为空,返回null。
E remove(); 出队,如果队列为空,抛异常。
E peek(); 查看队头元素,如果为空则返回null。
E element(); 查看对头元素,如果为空则抛异常。 - Deque接口基于Queen接口扩展的方法包括:
- 以下2个方法可模拟队列:
- boolean offerLast(E e); 从队尾入队
- E pollFirst(); 从队头出队
- 以下4个方法可模拟双端队列:
- boolean offerLast(E e); 从队尾入队
- E pollFirst(); 从队头出队
- boolean offerFirst(E e); 从队头入队
- E pollLast(); 从队尾出队
- 另外offerLast+pollLast或者pollFirst+offerFirst可以模拟栈数据结构。或者也可以直接调用push/pop方法。
- 以下2个方法可模拟队列:
7.14 Map继承结构
- Map集合以key和value的键值对形式存储。key和value存储的都是引用。
- Map集合中key起主导作用。value是附属在key上的。
- SequencedMap是Java21新增的。
- LinkedHashMap和TreeMap都是有序集合。(key是有序的)
- HashMap,Hashtable,Properties都是无序集合。(key是无序的)
- Map集合的key都是不可重复的。key重复的话,value会覆盖。
- HashSet集合底层是new了一个HashMap。往HashSet集合中存储元素实际上是将元素存储到HashMap集合的key部分。HashMap集合的key是无序不可重复的,因此HashSet集合就是无序不可重复的。HashMap集合底层是哈希表/散列表数据结构,因此HashSet底层也是哈希表/散列表。
- TreeSet集合底层是new了一个TreeMap。往TreeSet集合中存储元素实际上是将元素存储到TreeMap集合的key部分。TreeMap集合的key是不可重复但可排序的,因此TreeSet集合就是不可重复但可排序的。TreeMap集合底层是红黑树,因此TreeSet底层也是红黑树。它们的排序通过java.lang.Comparable和java.util.Comparator均可实现。
- LinkedHashSet集合底层是new了一个LinkedHashMap。LinkedHashMap集合只是为了保证元素的插入顺序,效率比HashSet低,底层采用的哈希表+双向链表实现。
- 根据源码可以看到向Set集合中add时,底层会向Map中put。value只是一个固定不变的常量,只是起到一个占位符的作用。主要是key。
7.15 Map接口常用方法
V put(K key, V value);
添加键值对void putAll(Map<? extends K,? extends V> m)
;添加多个键值对V get(Object key)
; 通过key获取valueboolean containsKey(Object key)
; 是否包含某个keyboolean containsValue(Object value)
;是否包含某个valueV remove(Object key)
; 通过key删除key-valuevoid clear()
; 清空Mapint size()
; 键值对个数boolean isEmpty()
; 判断是否为空MapCollection<V> values()
; 获取所有的valueSet<K> keySet()
; 获取所有的keySet<Map.Entry<K,V>> entrySet()
; 获取所有键值对的Set视图。static <K,V> Map<K,V> of(K k1, V v1, K k2, V v2, K k3, V v3)
; 静态方法,使用现有的key-value构造Map
Map集合遍历
public class MapTest02 {
public static void main(String[] args) {
// 创建Map集合
Map<Integer,String> maps = new HashMap<>();
// 存放元素
maps.put(1, "张三");
maps.put(2, "李四");
maps.put(3, "王五");
maps.put(4, "赵六");
// 遍历Map集合(第一种方式)
// 思路:获取Map集合的所有key,然后遍历每个key,通过key获取value。
/*Set<Integer> keys = maps.keySet();
Iterator<Integer> it = keys.iterator();
while (it.hasNext()) {
Integer key = it.next();
String value = maps.get(key);
System.out.println(key + "=" + value);
}*/
// for-each
/*Set<Integer> keys = maps.keySet();
for(Integer key : keys){
System.out.println(key + "=" + maps.get(key));
}*/
// 遍历Map集合(第二种方式)
// 这种方式效率较高,建议使用。
/*Set<Map.Entry<Integer, String>> entries = maps.entrySet();
Iterator<Map.Entry<Integer, String>> it = entries.iterator();
while (it.hasNext()) {
Map.Entry<Integer, String> entry = it.next();
Integer key = entry.getKey();
String value = entry.getValue();
System.out.println(key + "=" + value);
}*/
// for-each
for(Map.Entry<Integer, String> entry : maps.entrySet()){
System.out.println(entry.getKey() + "=" + entry.getValue());
}
}
}
7.16 HashMap
7.16.1 HashMap概念
-
HashMap集合的key是无序不可重复的。
- 无序:插入顺序和取出顺序不一定相同。
- 不可重复:key具有唯一性。
-
向HashMap集合中put时,key如果重复的话,value会覆盖。
-
HashMap集合的key具有唯一性,向key部分插入自定义的类型会怎样?如果自定义的类型重写equals之后会怎样???
-
HashMap底层的数据结构是:哈希表/散列表
- 哈希表是一种查询和增删效率都很高的一种数据结构,非常重要,在很多场合使用,并且面试也很常见。必须掌握。
- 哈希表如何做到的查询和增删效率都好的呢,因为哈希表是“数组 + 链表”的结合体。数组和链表的结合不是绝对的。
- 哈希表可能是:数组 + 链表,数组 + 红黑树, 数组 + 链表 + 红黑树等。
-
HashMap集合底层部分源码:
7.16.2 哈希表存储原理
- 概念:
- 哈希表:一种数据结构的名字。
- 哈希函数:
- 通过哈希函数可以将一个Java对象映射为一个数字。(就像现实世界中,每个人(对象)都会映射一个身份证号(哈希值)一样。)
- 也就是说通过哈希函数的执行可以得到一个哈希值。
- 在Java中,hashCode()方法就是哈希函数。
- 也就是说hashCode()方法的返回值就是哈希值。
- 一个好的哈希函数,可以让散列分布均匀。
- 哈希值:也叫做哈希码。是哈希函数执行的结果。
- 哈希碰撞:也叫做哈希冲突。
- 当两个对象“哈希值%数组长度”之后得到的下标相同时,就发生了哈希冲突。
- 如何解决哈希冲突?将冲突的挂到同一个链表上或同一个红黑树上。
- 以上描述凡是“哈希”都可以换为“散列”。
- 重点:
- 存放在HashMap集合key部分的元素必须同时重写hashCode+equals方法。
- equals返回true时,hashCode必须相同。
7.16.3 手写HashMap
7.16.3.1 手写HashMap的put方法
- 【第一步】:处理key为null的情况
如果添加键值对的key就是null,则将该键值对存储到table数组索引为0的位置。 - 【第二步】:获得key对象的哈希值
如果添加键值对的key不是null,则就调用key的hashcode()方法,获得key的哈希值。 - 【第三步】:获得键值对的存储位置
因为获得的哈希值在数组合法索引范围之外,因此我们就需要将获得的哈希值转化为[0,数组长度-1]范围的整数,
那么可以通过取模法来实现,也就是通过“哈希值 % 数组长度”来获得索引位置(i)。 - 【第四步】:将键值对添加到table数组中
- 当table[i]返回结果为null时,则键键值对封装为Node对象并存入到table[i]的位置。
- 当table[i]返回结果不为null时,则意味着table[i]存储的是单链表。我们首先遍历单链表,如果遍历出来节点的
- key和添加键值对的key相同,那么就执行覆盖操作;如果遍历出来节点的key和添加键值对的key都不同,则就将键键
- 值对封装为Node对象并插入到单链表末尾
7.16.3.2 手写HashMap的get方法
- 【第一步】:处理key为null的情况
如果查询的key就是null,则就在table数组索引为0的位置去查询。 - 【第二步】:获得key对象的哈希值
如果查询的key不是null,则就调用key的hashcode()方法,获得key的哈希值。 - 【第三步】:获得键值对的存储位置
因为获得的哈希值在数组合法索引范围之外,因此我们就需要将获得的哈希值转化为[0,数组长度-1]范围的整数,那么可以通过取模法来实现,也就是通过“哈希值 % 数组长度”来获得索引位置(i)。 - 【第四步】:遍历单链表,根据key获得value值
如果table[i]返回的结果为null,则证明单链表不存在,那么返回null即可
如果table[i]返回的结果不为null时,则证明单链表存在,那么就遍历整个单链表。如果遍历出来节点的key和查询的key相同,那么就返回遍历出来节点的value值;如果整个单链表遍历完毕,则遍历出来节点的key和查询的key都不相等,那么就证明查询key在链表中不存在,则直接返回null即可。
7.16.4 HashMap在Java8后的改进(包含Java8)
- 初始化时机:
- Java8之前,构造方法执行时初始化table数组。
- Java8之后,第一次调用put方法时初始化table数组。
- 插入法:
- Java8之前,头插法
- Java8之后,尾插法
- 数据结构:
- Java8之前:数组 + 单向链表
- Java8之后:数组 + 单向链表 + 红黑树。
- 最开始使用单向链表解决哈希冲突。如果结点数量 >= 8,并且table的长度 >= 64。单向链表转换为红黑树。
- 当删除红黑树上的结点时,结点数量 <= 6 时。红黑树转换为单向链表。
7.16.5 HashMap初始化容量永远都是2的次幂
- HashMap集合初始化容量16(第一次调用put方法时初始化)
- HashMap集合的容量永远都是2的次幂,假如给定初始化容量为31,它底层也会变成32的容量。
- 将容量设置为2的次幂作用是:加快哈希计算,减少哈希冲突。
- 为什么会加快哈希计算?
- 首先你要知道,使用二进制运算是最快的。
- 当一个数字是2的次幂时,例如数组的长度是2的次幂:
hash & (length-1)
的结果和hash % length
的结果相同。- 注意:只有是2的次幂时,以上等式才会成立。因为了使用 & 运算符,让效率提升,因此建议容量一直是2的次幂。
- 为什么容量一直是2的次幂?【面试题】
- 第一:提供哈希计算的效率。(位运算肯定比%取模操作速度快)
- 第二:减少哈希冲突。让散列分布更加均匀。
- 为什么会减少哈希冲突?【面试题】
- 底层运算是:
hash & length - 1
- 如果length是偶数:length-1后一定是奇数,奇数二进制位最后一位一定是1,1和其他二进制位进行与运算,结果可能是1,也可能是0,这样可以减少哈希冲突,让散列分布更加均匀。
- 如果length是奇数:length-1后一定是偶数,偶数二进制位最后一位一定是0,0和任何数进行与运算,结果一定是0,这样就会导致发生大量的哈希冲突,白白浪费了一半的空间。
- 底层运算是:
7.16.6 关于HashMap的初始化容量的设置
- 当哈希表中的元素越来越多的时候,散列碰撞的机率也就越来越高(因为数组的长度是固定的),从而导致单链表过长,降低了哈希表的性能,此时我们就需要对哈希表进行扩容操作。
- 那么HashMap什么时候进行扩容呢?当执行put()操作的时候,如果HashMap中存储元素的个数超过“
数组长度* loadFactor
”的结果(loadFactor指的是负载因子,loadFactor的默认值一般为0.75),那么就需要执行数组扩容操作。 - 所谓的扩容操作,就是把数组的空间大小扩大一倍,然后遍历哈希表中元素,把这些元素重新均匀分散到扩容后的哈希表中。例如,默认情况下,数组大小为16,那么当HashMap中元素个数超过
16*0.75=12
的时候,就需要执行扩容操作,把数组的大小扩展为2*16=32
,然后重新计算每个元素在数组中的位置,这是一个非常消耗性能的操作。为了提高程序的执行效率,建议减少HashMap集合的扩容次数。 - 为了避免扩容带来的性能损坏,建议使用哈希表之前,先预测哈希表需要存储元素的个数,提前为哈希表中的数组设置合适的存储空间大小,避免去执行扩容的操作,进一步提升哈希表的性能。例如:我们需要存储1000个元素,按照哈希表的容量设置为2的整数次幂的思想,我们设置哈希表的容量为1024更合适。但是
0.75*1024 < 1024
,需要执行消耗性能的扩容操作,因此我们设置哈希表的容量为2048更加合适,这样既考虑了&的问题,也避免了扩容的问题。 - 思考:当我们创建一个HashMap对象,设置哈希表的容量为15,请问HashMap对象创建成功后,哈希表的实际容量为多少呢???
- 答案:12
- 解析:容量为15最接近的是16,也就是2的4次幂,
16*0.75=12
7.17 LinkedHashMap
LinkedHashMap是HashMap集合的子类
- LinkedHashMap集合和HashMap集合的用法完全相同。
- 不过LinkedHashMap可以保证元素的插入顺序。(有序的)
- LinkedHashMap集合因为可以保证插入顺序,因此效率比HashMap低一些。
- LinkedHashMap是如何保证插入顺序的?底层采用了双向链表来记录顺序。
- LinkedHashMap集合底层采用的数据结构是:哈希表 + 双向链表。
- LinkedHashMap集合的key是:有序不可重复。key部分也需要同时重写
hashCode + equals
。 - key的取值可以为null,key如果相同,value也是覆盖。
7.18 Hashtable
- Hashtable和HashMap实现原理相同,底层也是哈希表。
- Hashtable是线程安全的,方法上都有synchronized关键字。效率较低,使用较少,因为保证线程安全有其他方式。
- Hashtable的初始化容量:11。默认加载因子:0.75
- Hashtable的扩容策略:2倍。
- Hashtable中有一些传统方法,这些方法不属于集合框架:
Enumeration keys();
获取所有key的迭代器Enumeration keys = hashtable.keys();
Enumeration values = hashtable.elements();
Enumeration elements();
获取所有value的迭代器boolean has = enumeration.hasMoreElements();
Object obj = enumeration.nextElement();
- Enumeration的相关方法
boolean hasMoreElements();
是否含有元素E nextElement();
获取元素
- Hashtable和HashMap集合的区别:
- HashMap集合线程不安全,效率高,key和value允许null。
- Hashtable集合线程安全,效率低,key和value不允许null。
7.19 Properties
- Properties被称为属性类。Properties属性类一般和java程序中的属性配置文件联合使用,配置文件的扩展名是
xxx.properties
。 - Properties的父类是Hashtable。因此Properties也是线程安全的。
- Properties不支持泛型,key和value是固定类型,只能是String类型。
- Properties相关方法:
Object setProperty(String key, String value);
和put方法一样。String getProperty(String key);
通过key获取valueSet<String> propertyNames();
获取所有的key
- 目前需要掌握的Properties三个方法:
- String value = pro.getProperty(“name”);
- pro.setProperty(“name”, “value”);
- Enumeration names = pro.propertyNames();
public class PropertiesTest {
public static void main(String[] args) {
// 创建一个属性类对象
Properties pro = new Properties();
// 往属性类对象中存储key和value,类似于map.put(k, v)
pro.setProperty("jdbc.driver", "com.mysql.jdbc.Driver");
pro.setProperty("jdbc.user", "root");
pro.setProperty("jdbc.password", "123123");
pro.setProperty("jdbc.url", "jdbc:mysql://localhost:3306/powernode");
// 通过key获取value
String driver = pro.getProperty("jdbc.driver");
String user = pro.getProperty("jdbc.user");
String password = pro.getProperty("jdbc.password");
String url = pro.getProperty("jdbc.url");
System.out.println(driver);
System.out.println(user);
System.out.println(password);
System.out.println(url);
// 获取所有的key
Enumeration<?> names = pro.propertyNames();
while (names.hasMoreElements()) {
String name = (String)names.nextElement();
String value = pro.getProperty(name);
System.out.println(name + "=" + value);
}
}
}
7.20 二叉树与红黑二叉树
7.20.1 二叉树
二叉树(BinaryTree)由一个结点及两棵互不相交的、分别称作这个根的左子树和右子树的二叉树组成。
下图中展现了五种不同基本形态的二叉树。
(a) 为空树。
(b) 为仅有一个结点的二叉树。
© 是仅有左子树而右子树为空的二叉树。
(d) 是仅有右子树而左子树为空的二叉树。
(e) 是左、右子树均非空的二叉树。
7.20.2 排序二叉树
-
排序二叉树采用左小右大原则存储,按照中序遍历方式,自动就是排好序的
- 中序遍历:左根右
- 前序遍历:根左右
- 后序遍历:左右根
-
比如:我们要将数据【14, 12, 23, 4, 16, 13, 8, 3】存储到排序二叉树中,如右图所示
-
排序二叉树的问题:排序二叉树本身实现了排序功能,可以快速检索。但如果插入的节点集本身就是有序的,要么是由小到大排列,要么是由大到小排列,那么最后得到的排序二叉树将变成普通的链表,其检索效率就会很差。
-
先进行排序变成:【3, 4, 8, 12, 13, 14, 16, 23】,然后存储到排序二叉树中,显然就变成了链表,如下图所示
7.20.3 平衡二叉树(AVL)
- 为了避免出现上述一边倒的存储,科学家提出了“平衡二叉树”。
- 在平衡二叉树中任何结点的两个子树的高度最大差别为1,所以它也被称为高度平衡树。 增加和删除结点可能需要通过一次或多次树旋转来重新平衡这个树。
- 结点的平衡因子是它的左子树的高度减去它的右子树的高度(有时相反)。带有平衡因子1、0或 -1的节点被认为是平衡的。带有平衡因子-2或2的节点被认为是不平衡的,并需要重新平衡这个树。
- 比如,我们存储排好序的数据【3, 4, 8, 12, 13, 14, 16, 23】,增加结点如果出现不平衡,则通过节点的左旋或右旋,重新平衡树结构,最终平衡二叉树如下图所示(另参见:https://www.cs.usfca.edu/~galles/visualization/Algorithms.html)
7.20.4 红黑二叉树
- 红黑二叉树(简称:红黑树),它首先是一棵二叉树,同时也是一棵自平衡的排序二叉树。
- 红黑树在原有的排序二叉树增加了如下几个要求:
- 每个结点要么红色,要么黑色。
- 根结点永远是黑色。
- 所有的叶子结点都是空结点(即null),并且是黑色的。
- 每个红色结点的两个子结点都是黑色 (从每个叶子结点到根结点的路径上不会有两个连续的红色结点) 。
- 从任一结点到其子树中每个叶子结点的路径都包含相同数量的黑色结点。
- 每次新结点在插入时,颜色是红色的。插入后,会根据红黑树的约束条件进行:树的旋转和颜色的调整。
- 这些约束强化了红黑树的关键性质:从根到叶子的最长的可能路径不多于最短的可能路径的两倍长。这样就让树大致上是平衡的。
- 红黑树是一个更高效的检索二叉树,JDK 提供的集合类 TreeMap、TreeSet 本身就是一个红黑树的实现。红黑树的基本操作:插入、删除、左旋、右旋、着色。每插入或者删除一个节点,可能会导致树不在符合红黑树的特征,需要进行修复,进行 “左旋、右旋、着色” 操作,使树继续保持红黑树的特性。
7.21 TreeMap
- TreeMap底层就是红黑树。
- TreeMap和HashMap用法一样,只不过需要key排序的时候,就可以使用TreeMap。
- TreeMap的key不能是null。
- 让TreeMap集合的key可排序,有两种方式:
- 第一种方式:key实现了Comparable接口,并且提供了compareTo方法,在该方法中添加了比较规则。(比较规则不变的话建议这种。)
- 第二种方式:创建TreeMap集合时,传一个比较器,比较器实现Comparator接口,在compare方法中添加比较规则。
7.22 Set接口
- Set接口继承Collection,没有任何新增任何方法。
- Set接口常用实现类包括:HashSet、LinkedHashSet、TreeSet。
- 通过源码得知:HashSet底层就是HashMap,往HashSet集合中存储元素,实际上是放到了HashMap集合的key部分。因此放在HashSet集合中的元素,要同时重写hashCode+equals。底层当然也是哈希表。HashSet集合存储元素特点:无序不可重复。
- 通过源码得知:LinkedHashSet底层就是LinkedHashMap。所以底层是“哈希表+双向链表”。LinkedHashSet集合存储元素特点:有序不可重复。有序指的是存进去的顺序和取出的顺序一样。放进去的元素也需要重写hashCode+equals。
- 通过源码得知:TreeSet底层就是TreeMap。所以底层也是红黑树。TreeSet集合存储元素特点:有序不可重复。有序表示可排序。放在TreeSet集合中元素要想排序,要么存储的元素实现Comparable接口,要么在构造TreeSet集合的时候传一个比较器。TreeSet中不能存放null。
7.22.1 HashSet面试题
HashSet<Student> set = new HashSet<>();
Student stu = new Student("张三", 18);
set.add(stu);
set.add(new Student("李四", 21));
stu.setName("王五");
// 问题1:请问是否删除了HashSet集合中的stu对象呢???
set.remove(stu);
// 问题2:添加以下Student对象是否成功???
set.add(new Student("王五", 18));
// 问题3:添加以下Student对象是否成功???
set.add(new Student("张三", 18));
问题1:不能删除
问题2:可以
问题3:可以添加成功
7.23 Collections工具类
- 针对List集合又准备了排序方法:sort
- 混排,打乱顺序:shuffle
- 反转:reverse
- 替换所有元素:fill
小总结
- Hashtable的key和value都不能为null
- Properties的key和value都不能为null
- TreeMap的key不能为null
- TreeSet不能添加null
- 每章一句:“ 当你觉得坚持不下去的时候,恰恰是你需要坚持下去的时候。”
- 恭喜你已阅读完第七章!点个赞证明你已经挑战成功,进入第八章关卡《I/O流》吧【已更新,点击可跳转直接阅读】!