- 前言
- Map映射
- Map映射的特点
- Map接口结构
- Map接口常见Api解析
- put(K key, V value)
- putAll(Map extends K,? extends V> m)
- remove(Object key)
- clear()
- isEmpty()
- containsKey(Object key)
- containsValue(Object value)
- get(Object key)
- entrySet()
- keySet()
- values()
- size()
- Map接口常见实现类
- 散列表
- 什么是散列表
- 散列表的工作原理
- 散列冲突
- 负载因子
- 哈希表的两个问题
- 红黑树
- 什么是红黑树
- 红黑树原型2-3树
- 2-3到红黑树
- 红黑树是如何保证平衡
- 红黑树如何定义
- 总结
前言
首先声明本文使用的是jdk1.8
Map映射
Map即双列集合。但我们一般称它为映射,即键值对的形式。
Map映射的特点
- 键值对形式存储元素。
- Collection集合子接口List元素可以重复,Set不可以重复,而Map是键唯一,值可以重复。
- Map集合的数据结构针对于键,和值无关。Collection的数据结构针和元素有关。
Map接口结构
图中叙述的很清楚。
- Entry作为Map映射的特有接口而存在于Map接口中属于内部接口。
- 重写了Object类的hashcode和equals函数。
- 自定义了一套接口函数
- 提供了系列可直接使用的函数
Map接口常见Api解析
由于是顶级Map接口,官方只由注释定义了其实现规则,所以我们做的也只是解读注释而已。
put(K key, V value)
将指定的值与此映射中的指定键关联。如果此映射以前包含一个该键的映射关系,则用指定值替换旧值(当且仅当 m.containsKey(k) 返回 true 时,才能说映射 m 包含键 k 的映射关系)。
putAll(Map extends K,? extends V> m)
从指定映射中将所有映射关系复制到此映射中(可选操作)。对于指定映射中的每个键 k 到值 v 的映射关系,此调用等效于对此映射调用一次 put(k, v)。
remove(Object key)
如果存在一个键的映射关系,则将其从此映射中移除。
返回此映射中以前关联该键的值,如果此映射不包含该键的映射关系,则返回 null。
需要注意的是:
如果此映射允许 null 值,则返回 null 值并不一定 表示该映射不包含该键的映射关系;也可能该映射将该键显示地映射到 null。
clear()
从此映射中移除所有映射关系(可选操作)。此调用返回后,该映射将为空。
isEmpty()
如果此映射未包含键-值映射关系,则返回 true。
containsKey(Object key)
如果此映射包含指定键的映射关系,则返回 true。
containsValue(Object value)
如果此映射将一个或多个键映射到指定值,则返回 true。
get(Object key)
返回指定键所映射的值;如果此映射不包含该键的映射关系,则返回 null。
需要注意的是:
如果此映射允许 null 值,则返回 null 值并不一定 表示该映射不包含该键的映射关系;也可能该映射将该键显示地映射到 null。使用 containsKey 操作可区分这两种情况。
entrySet()
返回此映射中包含的映射关系的Set集合。即Entry集合
需要注意的是:
如果对该Set进行迭代的同时修改了映射。则迭代结果是不确定的。迭代结果可能由于并发操作的影响超出我们的预期。
但如果通过迭代器自己的remove操作,或者通过对迭代器返回的映射项Entry执行setValue操作除外。因为这样的修改结果在我们的预知范围之内。
keySet()
返回此映射中包含的键的Set集合。
需要注意的是:
如果对该set进行迭代的同时修改了映射,则迭代结果是不确定的。因为是线程不同步的,所以并发操作其他线程的修改结果我们不会预知,可能会发生异常,可能正常进行但是结果不是我们预期的结果
但通过迭代器自己的remove操作除外。还是那个原因,通过Iterator的迭代方式进行remove的时候是可以保证在remove的时候expectedModCount = modCount的。
所以如果在使用Iterator迭代的时候如果其他线程并发的修改了映射类的话。Iteratore会返回一个并发修改的异常。相比于其他迭代方式对Set集合遇到并发修改的行为的返回结果是确定的。
values()
返回此映射中包含的值的Collection集合。
size()
返回此映射中的键-值映射关系数。如果该映射包含的元素大于Integer.MAX_VALUE
,则返回Integer.MAX_VALUE
。
Map接口常见实现类
红框所标都是Map接口关键的实现类。
其中需要针对HashMap和TreeMap两种需要提前了解一下他们底层的数据结构实现。分别是哈希表(散列表)和红黑树
散列表
针对于Map接口的HashMap实现类以及Set接口的HashSet我们来学习一下散列表。
什么是散列表
散列表其实也很好理解,就是一个链表数组。数组的每一个元素都是一个链表。
散列表最重要的就是它当中的元素之如何存储的。在我们之前所了解的无论是链表还是数组,如果想查找某一元素都是量元素间相互比较,通过比较的方式来迭代遍历元素的,知道找到那个元素。这样的方式无疑当数据量上去之后对我们时间上的开销非常的巨大。所以散列表的存储方式就诞生了
散列表的工作原理
哈希表的存储过程如下:
- 根据 key 计算出它的哈希值 h。
- 假设箱子的个数为 n,那么这个键值对应该放在第 (h % n) 个箱子中。
- 如果该箱子中已经有了键值对,就使用开放寻址法或者拉链法解决冲突。
散列冲突
我们刚才说了,底层容器是一个链表数组。为什么是链表呢,那肯定是数组的一个位置可以存不止一个元素。必然,我们还没有了解过散列表是如何为每个要存入的元素计算其散列值的呢,会不会出现两个元素散列值一样的情况。但既然我们知道散列表底层的数据结构了,肯定就会知道一定是会出现这种情况的。我们一般称这种情况为哈希冲突(hash碰撞),但一般情况下的哈希函数设计都十分合理,这种现象很少出现。即便出现了,也有对应的措施。
此时的处理方式:
- 判断该数组的链表元素中是否存储了该元素。如果没有就放入,如果有就跳过。
哈希冲突解决办法:
- 开放寻址法:即散列到冲突地址的下一空闲位置插入即可。
- 缺点:散列表空间不足时,无法处理冲突也无法插入数据
- 解决办法:由于需要负载因子(空间/插入数据)>=1。所以进行扩容(数组扩容)
- 拉链法:即如果遇到冲突,他就会在原地址新建一个空间,然后以链表结点的形式插入到该空间。
- 缺点:这种解决办法的哈希表在极端情况下会变成线性表,性能极低。
- 解决办法:Java8中Java官方的处理方案是当链表太长时,转换成红黑树。
负载因子
负载因子(load factor),它用来衡量哈希表的 空/满 程度,一定程度上也可以体现查询的效率
负载因子 = 总键值对数 / 箱子个数(数组元素)
负载因子越大,意味着哈希表越满,越容易导致冲突,性能也就越低。因此,一般来说,当负载因子大于某个常数(可能是 1,一般为 0.75 等)时,哈希表将自动扩容。
哈希表在自动扩容时,一般会创建两倍于原来个数的箱子,因此即使 key 的哈希值不变,对箱子个数取余的结果也会发生改变,因此所有键值对的存放位置都有可能发生改变,这个过程也称为重哈希(rehash)。
哈希表的扩容并不总是能够有效解决负载因子过大的问题。假设所有 key 的哈希值都一样,那么即使扩容以后他们的位置也不会变化。虽然负载因子会降低,但实际存储在每个箱子中的链表长度并不发生改变,因此也就不能提高哈希表的查询性能。
哈希表的两个问题
我们可以从刚才的分析中得出哈希表的如下两个问题:
- 如果哈希表中本来箱子就比较多,扩容时需要重新哈希并移动数据,性能影响较大。
- 如果哈希函数设计不合理,哈希表在极端情况下会变成线性表,性能极低。
这两个问题在Java8中Java官方已经为我们解决了。
- 对于箱子,给定了一个合理的初始容量,既没有太多也没有太少。既不会影响哈希表的遍历速度也很少触发扩容操作
- 当箱子成了长链表之后,在链表长度超过8之后会变成红黑树。
红黑树
我们知道JDK1.8之后HashMap底层的哈希表的箱子中元素大于或等于8时会从链表转化成红黑树,并且Map接口的TreeMap实现类和Set接口的TreeSet实现类底层也是由红黑树实现的。我们由此先来学习一下红黑树。
什么是红黑树
红黑树也是平衡二叉树的一种。
他为什么会出生呢,究其原因肯定是在某些方面该数据结构具有更好地效率。
我们知道,由于二叉查找树在特殊情况下会变成链表大大影响查找性能的原因我们有了AVL树,而紧接着由于AVL树高度平衡的性质,频繁的插入和删除,会引起频繁的reblance,导致效率下降的原因我们就有了红黑树这个数据结构。
红黑树不是高度平衡的,算是一种折中,它是局部平衡的。插入最多两次旋转,删除最多三次旋转。
二叉查找树偏向问题:
在二叉查找树上,我们插入节点的过程是这样的:小于节点值往右继续与左子节点比,大于则继续与右子节点比,直到某节点左或右子节点为空,把值插入进去。这样无法避免偏向问题
所以我们就有了平衡树这么一个概念。这样的树结构能够使我们任何的元素在插入与删除的时候依旧能够保持任意节点左右子树高度不相差1。
红黑树原型2-3树
红黑树明显很好地解决了这个问题。但是它的原型其实是2-3树。
如图即是一个典型的2-3树。
在2-3树中,共有两种节点。第一种是“2-节点”:
该节点性质和二叉查找树种节点性质一致。左孩子小于该节点,右孩子大于该节点
第二种是“3-节点”:
该节点和2-节点不同的是多了一条分支。最左边的儿子表示比a小的子树,中间的儿子表示大于a但小于b的子树,右边的儿子表示比b大的子树。
虽然这是2-3树,我们能直观的看到数中是不存在“4-节点”的。但是为了保持树的平衡性,我们将会利用4-节点来在插入和删除过程中保持树的完美平衡。下面是一个4-节点:
该节点的分支思路和3-节点一致。
那么2-3树是如何避免二叉查找树中偏向的问题的呢?
很简单,我们来参考一个节点插入步骤图。
总的概括起来就是当有新节点插入时
- 合并2-节点为3-节点,扩充将3-节点扩充为一个4-节点
- 分解4-节点为3-节点,再分解3-节点为2-节点,分解后多出的元素向上回溯。
- 重复不断调整,致使整个树平衡。
当然我们一个树的平衡,不光是要插入时保持平衡同时在做删除操作操作的时候我们也要保持它的持续平衡。2-3树的删除操作平衡调整比较复杂。我也没有完全理解,有兴趣可以参考:
2-3树与红黑树
2-3到红黑树
我们在了解了2-3树对于解决节点偏移的问题的处理思路之后可以很明显的感觉到,其中需要大量的节点变换。这些变换在实际代码中是很复杂的。所以现在几乎没有2-3树的具体实现。
红黑树也是一种平衡二叉树,但相比于2-3树,红黑树只有一种2-节点。但既然我们要想做和2-3树一样的工作却不给它3-节点,无疑我们把3节点都用合理的方式拆分了。
如何表示3-节点呢?我们尝试一种特殊的边:默认情况下节点的颜色均为黑色。我们将某个节点染为红色,表示它和父亲的的链接是红色的,就像下图:
当我们将红链接画平时
可以看到,这不就是我们刚才2-3树种的3-节点吗。而事实上,我们完全可以用这样的方式来表示2-3树中的3-节点。
如下即是一颗典型的红黑树:
将该树种所有红链接画平,将得到一棵完美平衡的“2-3树”。
红黑树是如何保证平衡
相比于2-3树不断变换节点的缺陷来说。由于红黑树的节点都是2-节点所以其处理方式更简洁。
- 旋转:顺时针旋转和逆时针旋转
- 反色:交换红黑的颜色
这两个操作相对于2-3树种的节点变换要简单许多。
具体可以参考:2-3树与红黑树
红黑树如何定义
- 红黑树是二叉搜索树。
- 根节点必须为黑色。毕竟根节点没有父亲。
- 红链接必须在左侧。将红链接统一在一个方向是为了方便其它操作。
- 不允许两个连续的红链接。因为连续的连个红链接表示的是4-节点。
- 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点(每一条树链上的黑色节点数量(称之为“黑高”)必须相等)。
总结
- 简单介绍了Map接口的结构及其常用Api
- 介绍了Map接口的常用实现类并为后面研究其实现类做铺垫了解了其实现类HashMap和TreeMap的底层数据结构,散列表和红黑树