一、散列表
1、散列思想
散列表用的是数组支持按照下标随机访问数据的时候,时间复杂度是
O
(
1
)
O(1)
O(1)的特性。通过散列函数把元素的键值映射为下标,然后把数据存储在数组中对应下标的位置。当按照键值查询元素时,用同样的散列函数,将键值转化为数组下标,从对应的数组下标的位置取数据
2、散列函数
散列函数hash(key),其中key表示元素的键值,hash(key)的值表示经过散列函数计算得到的散列值
散列函数设计的基本要求:
-
散列函数计算得到的散列值是一个非负整数
-
如果
key1=key2
,那hash(key1)==hash(key2)
-
如果
key1!=key2
,那hash(key1)!=hash(key2)
针对于第三点,即便像业界著名的MD5、SHA、CRC等哈希算法,也无法完全避免散列冲突。而且,因为数组的存储空间有限,也会加大散列冲突的概率
3、散列冲突
常用的散列冲突解决方法有两类:开放寻址法和链表法
1)、开放寻址法
开放寻址法的核心思想:如果出现了散列冲突,就重新探测一个空闲位置,将其插入
1)线性探测
插入操作:当往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止
下图中黄色的色块表示空闲位置,橙色的色块表示已经存储了数据
从图中可以看出,散列表的大小为10,在元素x插入散列表之前,已经6个元素插入到散列表中。x经过hash算法之后,被散列到位置下标为7的位置,但是这个位置已经有数据了,所以就产生了冲突。于是就顺序地往后一个一个找,看有没有空闲的位置,遍历到尾部都没有找到空闲的位置,于是再从表头开始找,直到找到空闲位置2,将其插入到这个位置
查找操作:在散列表中查找元素,通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素。如果相等,则说明就是我们要找的元素;否则就顺序往后依次查找。如果遍历到数组中的空闲位置,还没有找到,就说明要查找的元素并没有在散列表中
删除操作:对于使用线性探测法解决冲突的散列表,删除操作稍微有些特别。不能单纯地把要删除的元素设置为空。因为在查找的过程中,一旦通过线性探测方法,找到一个空闲位置,就可以认定散列表中不存在这个数据。但是,如果这个空闲位置是我们后来删除的,就会导致原来的查找算法失效。我们可以将删除的元素,特殊标记为deleted。当线性探测查找的时候,遇到标记为deleted的空间,并不是停下来,而是继续往下探测
线性探测法其实存在很大问题。当散列表中插入的数据越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久
散列表的装载因子=填入表中的元素个数/散列表的长度
装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降
2)、链表法
在散列表中,每个桶或者槽会对应一条链表,所有散列值相同的元素都放到相同槽位对应的链表中
当插入的时候,只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度是
O
(
1
)
O(1)
O(1)。当查找、删除一个元素时,同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除。查找或删除的时间复杂度跟链表的长度k成正比,也就是
O
(
k
)
O(k)
O(k)。对于散列比较均匀的散列函数来说,理论上,
k
=
n
/
m
k=n/m
k=n/m,其中n表示散列中数据的个数,m表示散列表中“槽”的个数
二、如何打造一个工业级水平的散列表
1、如何设计散列函数?
散列函数的设计不能太复杂,过于复杂的散列函数势必会消耗很多计算时间,也就间接的影响到散列表的性能
散列函数生成的值要尽可能随机并且均匀分布,这样才能避免或者最小化散列冲突,而且即便出现冲突,散列到每个槽里的数据也会比较平均,不会出现某个槽里数据特别多的情况
2、装载因子多大怎么办?
装载因子越大,说明散列表中的元素越多,空闲位置越少,散列冲突的概率越大
当装载因子过大时,可以进行动态扩容,重新申请一个更大的散列表,将数据搬移到这个新散列表中。假设每次扩容都申请一个原来散列表大小两倍的空间。如果原来散列表的装载因子是0.8,那经过扩容之后,新散列表的装载因子就降为原来的一半,变成了0.4
当散列表的装载因子超过某个阈值时,就需要进行扩容。装载因子阈值需要选择得当,如果太大,会导致冲突过多;如果太小,会导致内存浪费严重
3、如何避免低效地扩容?
为了解决一次性扩容耗时过多的情况,可以将扩容操作穿插在插入操作的过程中,分批完成。当装载因子触达阈值之后,只申请新空间,但并不将老的数据搬移到新散列表中
当有新数据要插入时,将新数据插入新散列表中,并且从老的散列表中拿出一个数据放入到新散列表。每次插入一个数据到散列表,都重复上面的过程。经过多次插入操作之后,老的散列表中的数据就一点一点全部搬移到新散列表中。这样没有了集中的一次性数据搬移,插入操作就都变得很快了
这期间的查询操作怎么来做呢?
对于查询操作,为了兼容新、老散列表中的数据,先从新散列表中查找,如果没有找到,再去老的散列表中查找
通过这样均摊的方法,将一次性扩容的代价,均摊到多次插入操作中,就避免了一次性扩容耗时过多的情况。这种实现方式,任何情况下,插入一个数据的时间复杂度都是 O ( 1 ) O(1) O(1)
Redis底层数据结构字典的渐进式rehash:http://redisbook.com/preview/dict/incremental_rehashing.html
4、如何选择冲突解决方案?
Java中LinkedHashMap采用了链表法解决冲突,ThreadLocalMap是通过线性探测的开放寻址法来解决冲突
1)、开放寻址法
当数据量比较小、装载因子小的时候,适合采用开放寻址法。这也是Java中的ThreadLocalMap使用开放寻址法解决散列冲突的原因
2)、链表法
对链表法稍加改造,可以实现一个更加高效的散列表。将链表法中的链表改造为其他高效的动态数据结构,比如跳表、红黑树。这样,即便出现散列冲突,极端情况下,所有的数据都散列到同一个桶内,那最终退化成的散列表的查找时间也只不过是
O
(
l
o
g
n
)
O(logn)
O(logn)
基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表
三、LinkedHashMap详解
LinkedHashMap直接继承自HashMap,这也就说明了HashMap一切重要的概念LinkedHashMap都是拥有的,这就包括了,hash算法定位hash桶位置,哈希表由数组和单链表构成,并且当单链表长度超过8的时候转化为红黑树,扩容体系,这一切都跟HashMap一样。除此之外,LinkedHashMap比HashMap更加强大,这体现在:
- LinkedHashMap内部维护了一个双向链表,解决了HashMap不能随时保持遍历顺序和插入顺序一致的问题
- LinkedHashMap元素的访问顺序也提供了相关支持,也就是常说的LRU(最近最少使用)原则
图片中红黄箭头代表元素添加顺序,蓝箭头代表单链表各个元素的存储顺序。head表示双向链表头部,tail代表双向链表尾部
LinkedHashMap和HashMap相比,唯一的变化是使用双向链表(图中红黄箭头部分)记录了元素的添加顺序,HashMap的Node节点只有next指针,LinkedHashMap对于Node节点进行了扩展:
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
LinkedHashMap基本存储单元Entry继承自HashMap.Node
,并在此基础上添加了before和after这两个指针变量。before变量在每次添加元素的时候将会指向上一次添加的元素,而上一次添加元素的after变量将指向该次添加的元素,来形成双向链接
1、put方法
LinkedHashMap并没有覆写任何关于HashMap的put方法。所以调用LinkedHashMap的put方法实际上调用了父类HashMap的方法
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;
//判断当前桶是否为空,空的就需要初始化(resize中会判断是否需要初始化)
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//根据当前key的hashcode定位到具体的桶中并判断是否为空,为空表明没有Hash冲突就直接在当前位置创建一个新桶即可
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//如果当前桶有值(Hash冲突),那么就要比较当前桶中的key、key的hashcode与写入的key是否相等,相等就赋值给e,后面统一进行赋值及返回
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果当前桶为红黑树,按照红黑树的方式写入数据
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//如果是个链表,就需要将当前的key、value封装成一个新节点写入当前桶的后面(采用尾插法)
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//判断当前链表的大小是否大于预设的阈值,大于时就要转换为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果在遍历链表的过程中,找到key相同时直接退出遍历
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//如果e!=null就相当于存在相同的key,那就需要将值覆盖
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//最后判断是否需要进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
每次添加新节点的时候实际上调用了newNode方法生成了一个新节点,放到指定hash桶中,LinkedHashMap中重写了该方法,所以LinkedHashMap调用put方法时实际上调用的是LinkedHashMap中的newNode方法,下面来看一下LinkedHashMap中的newNode方法的具体实现:
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
LinkedHashMap.Entry<K,V> p =
new LinkedHashMap.Entry<K,V>(hash, key, value, e);
linkNodeLast(p);
return p;
}
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
//添加新节点之前双向链表的尾部节点
LinkedHashMap.Entry<K,V> last = tail;
//tail指向新添加的节点
tail = p;
//如果添加之前尾部节点为null,那就说明之前map为null,将head指向新节点
if (last == null)
head = p;
//如果添加之前尾部节点不为null,新节点的before指向之前链表尾部节点,之前链表尾部节点的after指向新节点
else {
p.before = last;
last.after = p;
}
}
put方法调用顺序:
LinkedHashMap链表创建步骤,可用上图几个步骤来描述,蓝色部分是HashMap的方法,而橙色部分为 LinkedHashMap重写或独有的方法
2、remove方法
和put方法一样,remove方法实际上调用了父类HashMap的方法
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
//判断哈希表是否为空,哈希表长度是否大于0,根据要删除key的hashcode定位到具体的桶中并判断是否为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
//node表示要删除的节点
Node<K,V> node = null, e; K k; V v;
//如果第一个节点就是要删除的节点直接赋值给node
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
//遍历红黑树找到对应的节点
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
//遍历链表找到对应的节点,p表示链表中要删除节点的前驱结点
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
//删除节点
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
//afterNodeRemoval在HashMap中是一个空实现,LinkedHashMap中重写了该方法
afterNodeRemoval(node);
return node;
}
}
return null;
}
LinkedHashMap中重写的afterNodeRemoval方法:
void afterNodeRemoval(Node<K,V> e) { // unlink
//p为删除的节点,b为删除节点的前驱节点,a为删除节点的后继节点
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
//将节点p的前后指针引用置为null便于内存释放
p.before = p.after = null;
//b为null,表示p是头节点
if (b == null)
head = a;
else
b.after = a;
//a为null,表示p是尾节点
if (a == null)
tail = b;
else
a.before = b;
}
LinkedHashMap删除节点的方法分为三个步骤:
1)确认待删除的节点
2)删除待删除的节点
3)从双向链表中删除待删除的节点
参考:
https://juejin.im/post/5ace2bde6fb9a028e25deca8
HashMap相关博客:
为什么使用HashMap需要重写hashcode和equals方法?
HashMap和ConcurrentHashMap源码分析(基于JDK1.7和1.8)
四、散列表相关题目
1、LeetCode242:有效的字母异位词
给定两个字符串s和t,编写一个函数来判断t是否是s的字母异位词(字符串只包含小写字母)
示例1:
输入: s = "anagram", t = "nagaram"
输出: true
示例2:
输入: s = "rat", t = "car"
输出: false
题解:
public boolean isAnagram(String s, String t) {
if (s.length() != t.length()) {
return false;
}
int[] counter = new int[26];
for (int i = 0; i < s.length(); i++) {
counter[s.charAt(i) - 'a']++;
counter[t.charAt(i) - 'a']--;
}
for (int count : counter) {
if (count != 0) {
return false;
}
}
return true;
}
2、LeetCode49:字母异位词分组
给定一个字符串数组,将字母异位词组合在一起。字母异位词指字母相同,但排列不同的字符串
示例:
输入: ["eat", "tea", "tan", "ate", "nat", "bat"],
输出:
[
["ate","eat","tea"],
["nat","tan"],
["bat"]
]
说明:
- 所有输入均为小写字母
- 不考虑答案输出的顺序
题解:
public List<List<String>> groupAnagrams(String[] strs) {
Map<String, List<String>> map = new HashMap<>();
for (int i = 0; i < strs.length; ++i) {
char[] charArray = strs[i].toCharArray();
Arrays.sort(charArray);
String str = String.valueOf(charArray);
if (map.containsKey(str)) {
map.get(str).add(strs[i]);
} else {
List<String> temp = new ArrayList<String>();
temp.add(strs[i]);
map.put(str, temp);
}
}
return new ArrayList<List<String>>(map.values());
}
常用数据结构的时间、空间复杂度:
https://www.bigocheatsheet.com/