一、哈希表(Hash Table)
1、概念
- 哈希表也叫做散列表。
- 哈希表的原理:
- 利用哈希函数生成
key
对应的index
,时间复杂度O(1)
。 - 根据
index(索引)
操作定位数组元素,时间复杂度O(1)
。 - 哈希表的
空间换时间
的典型应用。
2、哈希冲突
- 哈希冲突也叫做
哈希碰撞
。- 2个不同的
key
,经过哈希函数计算出相同的结果。 -
key1 != key2
,hash(key1) = hash(key2)
- 2个不同的
- 解决哈希冲突的常见方法
- 按照一定规则(
线性探测
,平方探测
)向其他地址探测,直到遇到空桶。(开放定址法
) - 设计多个哈希函数。(
再哈希法
) - 通过链表将同一index的元素串起来。(
链地址法
)
- 按照一定规则(
3、JDK1.8的哈希冲突解决方案
- 默认使用
单向链表
将元素串起来。 - 当
单向链表
的节点数量大于8
时,将转为红黑树
来存储元素。在调整容量时,如果树节点数量小于6
,又会转为单向链表
。 - 为什么不使用
双向链表
,而是单向链表
?- 每次查找都要从
单向链表
头节点开始遍历,首先确认链表中是否已有该元素,若没有则插入。 -
单向链表
比双向链表
少一个指针,可以节省内存空间。
- 每次查找都要从
4、哈希函数
- 哈希表中哈希函数的实现步骤:
- 生成
key
的哈希值
(必须是整数)。 - 再让
key
的哈希值
跟数组的大小进行相关运算,生成一个索引值。
- 生成
- 为了提高效率,可以使用
&
与运算取代%
运算(前提将数组的长度设计为2^n
)。
- 因为数组长度为为
2^n
,转换为二进制是10000
或100
之类的值,table.length - 1
的结果转换成二进制即为01111
或011
之类的值。 - 将
hash_code(key)
和01111
做与运算,比如10100&00111
,结果为00101
,值肯定小于00111
,且最小为00000
,最大为00111
。 - 结论就是无论
hash_code(key)
有多大,当它与
上table.length - 1
,它的值都是在0
到table.length - 1
之间。 - 良好的哈希函数是让哈希值更加
均匀
的分布,减少哈希冲突
次数,提升哈希表的性能
。
5、如何生成hash_code(key)
-
key
的常见种类可能是有整数,浮点数,字符串,自定义对象。 - 不同种类的
key
,哈希值的生成方式不一样,但目标是一致的:- 尽量让每个
key
的哈希值是唯一的。 - 尽量让
key
的所有信息参与运算。
- 尽量让每个
5.1、整数的哈希值
-
整数
的哈希值则直接为它的值,比如10
的哈希值就是10
。
5.2、浮点数的哈希值
-
浮点数
的哈希值是将存储的二进制格式转为整数值。 - 比如
10.6
在内存中是01010010101
,那么将01010010101
转为整数即为1093245338
。
5.2、Long
和Double
的哈希值
-
java
中的hash
值必须是整数,即是32
位。 -
Long
和Double
都是64
位,可以将value
的高32
位和低32
位混合计算出32
位的值,然后再用value
的值与这个32
位值做亦或
运算(相同为0,不同为1)。 -
亦或
运算之后再将64
位的结果强制转换为32
位。
- 最终获取的值是
橙色
部分。 - 这样即充分利用所有信息计算出了哈希值。
5.3、字符串的哈希值
- 比如字符串jack,由j、a、c、k四个字符组成(字符的本质就是一个整数,因为可以转换成ascII码)
- 因此jack的哈希值可以表示为
j*n^3 + a*n^2 + c*n^1 + k*n^0
。
5.4、自定义对象的哈希值
- 默认情况下,自定义对象的
哈希值
是跟内存地址
有关系的。 - 所以两个对象即使各个属性值都相同,但他们的
哈希值
是不同的,如果用一个字典
保存这两个对象,字典
内会有两个对象。 - 如何能让字典中只保存相同对象中的一个呢?这就需要我们重写
hashCode
函数。
- 重写的
hashCode
函数,我们将对象所有属性都计算进来,这样即充分利用所有信息计算出了哈希值。 - 当
哈希冲突
的时候,我们会调用对象的equals
函数进行对象间的比较。如果两个对象相同,则覆盖,如果不同,则加入到value
链表中。所以我们也需要重写equals
函数。 -
哈希值
相同的两个对象,不一定相等,只能说两个对象通过hashCode
算出的索引值相同。
5.5、自定义对象存储举例
- 假设我们创建三个对象存入
map
中,并且p1
和test
的哈希值相同:
- 当存入
p1
和test
时,map中的结构如下:
- 当存入
p2
时,会调用equals
函数比较p1
和p2
,因为重写了equals
函数,所以会返回true
,在map
中,当确认两个对象相等时,则会执行替换:
二、哈希表的接口设计(HashMap)
- 用Map实现一个哈希表(HashMap)
- 通过一个数组保存
key(索引)
。 -
key
对应的value(数据)
则直接保存红黑树
的根节点
。
public class HashMap implements Map {
private Node[] table;
protected static class Node //声明一个节点类
private static final int DEFAULT_CAPACITY = 1 << 4; //数组默认长度,16
public HashMap() {
table = new Node[DEFAULT_CAPACITY]; //建议长度为2^n
}
}
复制代码
三、哈希表的实现(HashMap)
1、声明节点
protected static class Node {
int hash;
K key;
V value;
boolean color = RED;
Node left;
Node right;
Node parent;
public Node(K key, V value, Node parent) {
this.key = key;
int hash = key == null ? 0 : key.hashCode();
this.hash = hash ^ (hash >>> 16);
this.value = value;
this.parent = parent;
}
public boolean hasTwoChildren() {
return left != null && right != null;
}
public boolean isLeftChild() {
return parent != null && this == parent.left;
}
public boolean isRightChild() {
return parent != null && this == parent.right;
}
public Node sibling() {
if (isLeftChild()) { return parent.right; }
if (isRightChild()) { return parent.left; }
return null;
}
@Override
public String toString() {
return "Node_" + key + "_" + value;
}
}
复制代码
2、clean实现
- 遍历数组,清空数组的每一个节点。
@Override
public void clear() {
if (size == 0) return;
size = 0;
for (int i = 0; i < table.length; i++) {
table[i] = null;
}
}
复制代码
3、put实现
- 在进行插入操作时,通过比较
key
和key.parent
的哈希值
大小,来决定插入位置。 - 如果
哈希值
相同,则比较equals
。 - 如果
equals
相同,则比较类名
。 - 如果
类名
相同(同一种类型),则查看是否实现comparable
,如果实现,则直接通过该函数比较。 - 如果相同
类型
,不具有可比较性
,或其中一个数据为空
,则比较内存地址
。 - 直接比较
内存地址
会导致结果不稳定,应该先扫码树中是否有该值,如果没有,再比较内存地址
。
@Override
public V put(K key, V value) {
resize();
int index = index(key); // 获取key对应的索引
Node root = table[index]; // 取出index位置的红黑树根节点
if (root == null) { // 如果根节点为空
root = createNode(key, value, null);
table[index] = root;
size++;
fixAfterPut(root);
return null;
}
// 如果根节点不为空,添加新的节点到红黑树上面
Node parent = root;
Node node = root;
int cmp = 0;
K k1 = key;
int h1 = hash(k1);
Node result = null;
boolean searched = false; // 是否已经搜索过这个key
do {
parent = node;
K k2 = node.key;
int h2 = node.hash;
// 根据哈希值来进行比较,大的放右边,小的放左边。
if (h1 > h2) {
cmp = 1;
} else if (h1 < h2) {
cmp = -1;
} else if (Objects.equals(k1, k2)) {
cmp = 0;
} else if (k1 != null && k2 != null
&& k1 instanceof Comparable
&& k1.getClass() == k2.getClass()
&& (cmp = ((Comparable)k1).compareTo(k2)) != 0) {
} else if (searched) { // 已经扫描了
cmp = System.identityHashCode(k1) - System.identityHashCode(k2);
} else { // searched == false; 还没有扫描,然后再根据内存地址大小决定左右
if ((node.left != null && (result = node(node.left, k1)) != null)
|| (node.right != null && (result = node(node.right, k1)) != null)) {
// 已经存在这个key
node = result;
cmp = 0;
} else { // 不存在这个key
searched = true;
cmp = System.identityHashCode(k1) - System.identityHashCode(k2); //根据内存地址计算出的hashcode
}
}
if (cmp > 0) { // 大于
node = node.right;
} else if (cmp < 0) { //小于
node = node.left;
} else { // 相等
V oldValue = node.value;
node.key = key;
node.value = value;
node.hash = h1;
return oldValue;
}
} while (node != null);
// 看看插入到父节点的哪个位置
Node newNode = createNode(key, value, parent);
if (cmp > 0) {
parent.right = newNode;
} else {
parent.left = newNode;
}
size++;
// 新添加节点之后的处理
fixAfterPut(newNode);
return null;
}
/**
* 根据key生成对应的索引(在桶数组中的位置)
*/
private int index(K key) {
return hash(key) & (table.length - 1); //先求哈希值,再做位运算
}
private int hash(K key) {
if (key == null) return 0;
int hash = key.hashCode();
return hash ^ (hash >>> 16); //高16位与低16位做一次运算
}
复制代码
3、get实现
- 首先确定
key
的索
,然后再寻找索引
下的树
。
@Override
public V get(K key) {
Node node = node(key);
return node != null ? node.value : null;
}
private Node node(K key) {
//根据key生成对应的索引
//根据索引在数组中找到根节点。
Node root = table[index(key)];
return root == null ? null : node(root, key);
}
private Node node(Node node, K k1) {
int h1 = hash(k1);
// 存储查找结果
Node result = null;
int cmp = 0;
while (node != null) {
K k2 = node.key;
int h2 = node.hash;
// 先比较哈希值
if (h1 > h2) {
node = node.right;
} else if (h1 < h2) {
node = node.left;
} else if (Objects.equals(k1, k2)) { //可比较性
return node;
} else if (k1 != null && k2 != null
&& k1 instanceof Comparable
&& k1.getClass() == k2.getClass()
&& (cmp = ((Comparable)k1).compareTo(k2)) != 0) {
node = cmp > 0 ? node.right : node.left;
}
// 走到这里,表示哈希值相等,不具备可比较性,也不 equals
else if (node.right != null && (result = node(node.right, k1)) != null) { //先找右子树
return result;
} else { // 再去左边扫码
node = node.left;
}
}
return null;
}
复制代码
4、remove实现
@Override
public V remove(K key) {
return remove(node(key));
}
protected V remove(Node node) {
if (node == null) return null;
Node willNode = node;
size--;
V oldValue = node.value;
if (node.hasTwoChildren()) { // 度为2的节点
// 找到后继节点
Node s = successor(node);
// 用后继节点的值覆盖度为2的节点的值
node.key = s.key;
node.value = s.value;
node.hash = s.hash;
// 删除后继节点
node = s;
}
// 删除node节点(node的度必然是1或者0)
Node replacement = node.left != null ? node.left : node.right;
int index = index(node);
if (replacement != null) { // node是度为1的节点
// 更改parent
replacement.parent = node.parent;
// 更改parent的left、right的指向
if (node.parent == null) { // node是度为1的节点并且是根节点
table[index] = replacement;
} else if (node == node.parent.left) {
node.parent.left = replacement;
} else { // node == node.parent.right
node.parent.right = replacement;
}
// 删除节点之后的处理
fixAfterRemove(replacement);
} else if (node.parent == null) { // node是叶子节点并且是根节点
table[index] = null;
} else { // node是叶子节点,但不是根节点
if (node == node.parent.left) {
node.parent.left = null;
} else { // node == node.parent.right
node.parent.right = null;
}
// 删除节点之后的处理
fixAfterRemove(node);
}
// 交给子类去处理
afterRemove(willNode, node);
return oldValue;
}
复制代码
5、containsValue实现
- 检测
哈希表
中是否存在某值,只有遍历
每一个树,使用层序遍历
实现。
@Override
public boolean containsValue(V value) {
if (size == 0) return false;
Queue> queue = new LinkedList<>();
for (int i = 0; i < table.length; i++) {
if (table[i] == null) continue;
queue.offer(table[I]);
while (!queue.isEmpty()) {
Node node = queue.poll();
if (Objects.equals(value, node.value)) return true;
if (node.left != null) {
queue.offer(node.left);
}
if (node.right != null) {
queue.offer(node.right);
}
}
}
return false;
}
复制代码
6、扩容
- 当哈希表的
table
数组中添加元素过多后,哈希冲突概率增大,效率变低。所以要根据装填因子
判断是否需要扩容。 - 装填因子:
节点总数量 / 哈希表桶数组长度
,也叫做负载因子
。 - 在JDK1.8的
HashMap
中,如果装填因子超过0.75
,就扩容为原来的2
倍。 - 如果装填因子超过
0.75
,就将数组长度扩大为原来的两倍
,并将原来的数据迁移进新数组。 - 扩容之后,原来数据算出的节点值就有可能不一样了(
保持不变
或index = index + 旧容量
),因为节点的计算需要涉及到table.length
。
private void resize() {
// 装填因子 <= 0.75
if (size / table.length <= DEFAULT_LOAD_FACTOR) return;
Node[] oldTable = table;//保留旧的数组
table = new Node[oldTable.length << 1];//将数组长度变为原来的2倍
//层序遍历
Queue> queue = new LinkedList<>();
for (int i = 0; i < oldTable.length; i++) {
if (oldTable[i] == null) continue;
queue.offer(oldTable[I]);
while (!queue.isEmpty()) {
Node node = queue.poll();
if (node.left != null) {
queue.offer(node.left);
}
if (node.right != null) {
queue.offer(node.right);
}
// 挪动代码得放到最后面
moveNode(node);
}
}
}
private void moveNode(Node newNode) {
// 重置节点属性
newNode.parent = null;
newNode.left = null;
newNode.right = null;
newNode.color = RED;
int index = index(newNode);
// 取出index位置的红黑树根节点
Node root = table[index];
if (root == null) {
root = newNode;
table[index] = root;
fixAfterPut(root);
return;
}
// 添加新的节点到红黑树上面
Node parent = root;
Node node = root;
int cmp = 0;
K k1 = newNode.key;
int h1 = newNode.hash;
do {
parent = node;
K k2 = node.key;
int h2 = node.hash;
if (h1 > h2) {
cmp = 1;
} else if (h1 < h2) {
cmp = -1; // 不用再比较equals,因为不会存在重复数据。
} else if (k1 != null && k2 != null
&& k1 instanceof Comparable
&& k1.getClass() == k2.getClass()
&& (cmp = ((Comparable)k1).compareTo(k2)) != 0) {
} else { // 搜索也不需要,原因同上。
cmp = System.identityHashCode(k1) - System.identityHashCode(k2);
}
if (cmp > 0) {
node = node.right;
} else if (cmp < 0) {
node = node.left;
}
} while (node != null);
// 看看插入到父节点的哪个位置
newNode.parent = parent;
if (cmp > 0) {
parent.right = newNode;
} else {
parent.left = newNode;
}
// 新添加节点之后的处理
fixAfterPut(newNode);
}
复制代码
7、equals的优化
- 我们在添加元素时,要谨防因
equals
的函数实现有问题,导致a.equals(b)
和b.equals(a)
的结果不一致。 - 所以在实现
equals
函数时,要遵循以下条件:
四、TreeMap VS HashMap
-
TreeMap
增删改查都是O(logn)
级别,而哈希表是O(1)
级别。 - 当元素具备
可比较性
且要求升序遍历
时,使用TreeMap
。当对遍历没有要求时,选择HashMap
。
五、LinkedHashMap
- 在HashMap的基础上维护元素的添加顺序,使得遍历的结果是遵循添加顺序的。
- 假设添加顺序是
37,21,31,41,97,95,52,42,83
六、LinkedHashMap的接口实现
- 新增
first
和last
两个属性。
public class LinkedHashMap extends HashMap {
private LinkedNode first;
private LinkedNode last;
}
复制代码
- 给Node增加前驱和后继两个指针。
private static class LinkedNode extends Node {
LinkedNode prev;
LinkedNode next;
public LinkedNode(K key, V value, Node parent) {
super(key, value, parent);
}
}
复制代码
- 添加一个构造节点的函数。
@Override
protected Node createNode(K key, V value, Node parent) {
LinkedNode node = new LinkedNode(key, value, parent);
if (first == null) { //没有头节点
first = last = node;
} else { //有头节点
last.next = node;
node.prev = last;
last = node;
}
return node;
}
复制代码
- clear函数需要清空first和last指针。
@Override
public void clear() {
super.clear();
first = null;
last = null;
}
复制代码
- 遍历函数
@Override
public void traversal(Visitor visitor) {
if (visitor == null) return;
LinkedNode node = first;
while (node != null) {
if (visitor.visit(node.key, node.value)) return;
node = node.next;
}
}
复制代码
- 删除函数,不仅仅需要删除数据,还需要修改
指针
指向。 - 删除度为
2
的节点时,需要注意更换node
与前驱/后继
节点的链接位置。 - 例如,删除
31
@Override
protected void afterRemove(Node willNode, Node removedNode) {
LinkedNode node1 = (LinkedNode) willNode;
LinkedNode node2 = (LinkedNode) removedNode;
if (node1 != node2) {
// 交换linkedWillNode和linkedRemovedNode在链表中的位置
// 交换prev
LinkedNode tmp = node1.prev;
node1.prev = node2.prev;
node2.prev = tmp;
if (node1.prev == null) {
first = node1;
} else {
node1.prev.next = node1;
}
if (node2.prev == null) {
first = node2;
} else {
node2.prev.next = node2;
}
// 交换next
tmp = node1.next;
node1.next = node2.next;
node2.next = tmp;
if (node1.next == null) {
last = node1;
} else {
node1.next.prev = node1;
}
if (node2.next == null) {
last = node2;
} else {
node2.next.prev = node2;
}
}
LinkedNode prev = node2.prev;
LinkedNode next = node2.next;
if (prev == null) {
first = next;
} else {
prev.next = next;
}
if (next == null) {
last = prev;
} else {
next.prev = prev;
}
}
复制代码
- 核心就是交换。
- 遍历节点
@Override
public boolean containsValue(V value) {
LinkedNode node = first;
while (node != null) {
if (Objects.equals(value, node.value)) return true;
node = node.next;
}
return false;
}
复制代码