赞
踩
哈希表是一种数据结构,它的特点是新增、删除和修改的时间复杂度都是 O ( 1 ) O(1) O(1),而查询的时间复杂度根据数据的规模而定,也与底层的实现有关系。
哈希是将数据通过某种方式转化为一个数字,这个数字就是该数据在哈希数组中存储的下标。
哈希碰撞指的是两个数据通过某种哈希的方式转化的数字相同,这会导致哈希数组的一个下标存储两个及以上的数据,会降低哈希表的查询效率。
参考java
的HashTable
的实现,哈希表的底层一般是数组+链表
或数组+红黑树
的实现,当哈希表中链表太长并且数组的长度超过某个限制后就会出现树化的情况,就是将链表变成红黑树,这样可以提高查询效率,因为红黑树的查询比链表快。
本文哈希表的底层是使用数组+链表
的方式实现的(因为红黑树太难了)。
初次学哈希表应该很难理解其中的有些代码,可以去看看本文的末尾的问题及解答,如果还是不理解,可以将不理解的点发在评论区里。
/** * 哈希表 */ public class HashTable { /** * 哈希表的节点类 */ private static class Entry { int hash; // 哈希码 Object key; // 键 Object value; // 值 Entry next; // 下一个节点的指针 public Entry(int hash, Object key, Object value) { this.hash = hash; this.key = key; this.value = value; } } /** * 储存数据的数组,大小是2的n次方 */ private Entry[] table = new Entry[16]; /** * 储存的元素个数 */ private int size = 0; /** * 负载因子,当 存储元素的个数 与 数组的大小 的比值 超过了它就需要扩容 */ private static final float LOAD_FACTOR = 0.75f; /** * 阈值,当 存储元素的个数 超过了它就需要扩容 */ private int threshold = (int) (LOAD_FACTOR * table.length); /** * 获取key的哈希码 * @param key 键 * @return 键对应的哈希码 */ private int hash(Object key) { if (key instanceof String k) { return k.hashCode(); } return key.hashCode() & 0x7fffffff; } /** * 获取key在数组中的下标 * @param key 键 * @return 键在数组中对应的下标 */ private int getIndex(Object key) { return hash(key) & (table.length - 1); } /** * 根据key获取元素的值 * @param key 键 * @return 键对应的值,如果找不到,就返回null */ public Object get(Object key) { // 1. 获取key在数组中对应的下标 int index = getIndex(key); // 2. 在数组中这个下标的链表中查找 Entry curr = table[index]; while (curr != null) { // 3. 如果找到了,就返回它的值 if (curr.key.equals(key)) { return curr.value; } curr = curr.next; } // 4. 如果找不到,就返回null return null; } /** * 新增或修改元素,如果key不重复,则是新增;否则就是修改 * @param key 键 * @param value 键对应的值 */ public void put(Object key, Object value) { // 1. 获取key在数组中对应的下标 int index = getIndex(key); // 2. 如果有数组的index处为空,则直接添加;否则就沿着链表查找空位 if (table[index] == null) { table[index] = new Entry(hash(key), key, value); } else { Entry curr = table[index]; while (true) { // 2.1 有重复key则更新,更新时不需要让数量增加,退出本方法 if (curr.hash == hash(key)) { curr.value = value; return; } // 2.2 如果找到空位,就退出循环 if (curr.next == null) { break; } curr = curr.next; } // 2.3 无重复key则新增 curr.next = new Entry(hash(key), key, value); } // 3. 让数量增加,并检查是否需要扩容,如果需要,就扩容 if (++size > threshold) { resize(); } } /** * 根据key删除元素,并返回它的值 * @param key 键 * @return 键对应的值,如果找不到,就返回null */ public Object remove(Object key) { // 1. 获取key在数组中对应的下标 int index = getIndex(key); // 2. 沿链表查找这个key对应的元素 Entry prev = null; Entry curr = table[index]; while (curr != null) { // 2.1 如果找到了,就进行删除 if (curr.key.equals(key)) { // 2.2 如果待删除元素是链表的首元素 if (prev == null) { // 2.3 则让链表指向它的下一个元素即可 table[index] = curr.next; } else { // 2.4 否则就让 待删除元素的上一个元素 指向 它的下一个元素 prev.next = curr.next; } // 2.5 删除完毕后让数量减少 size--; // 2.6 返回待删除元素的value return curr.value; } prev = curr; curr = curr.next; } // 3. 找不到这个key对应的元素,返回null return null; } /** * 扩容 */ private void resize() { // 1. 先构造出新的数组,容量为原来的二倍 Entry[] newTable = new Entry[table.length << 1]; // 2. 对原数组每个索引处挂的链表进行拆分,将其拆分成2个子链表 for (int i = 0; i < table.length; i++) { // 2.1 获取链表头部 Entry curr = table[i]; // 2.2 如果链表头部为空,则不需要拆分 if (curr == null) { continue; } // 2.3 将链表拆分成2个子链表 Entry subList1Head = null; // 子链表1的头部 Entry subList2Head = null; // 子链表2的头部 Entry subList1 = null; // 子链表1的节点 Entry subList2 = null; // 子链表2的节点 while (curr != null) { if ((curr.hash & table.length) == 0) { if (subList1 == null) { subList1Head = curr; } else { subList1.next = curr; } subList1 = curr; } else { if (subList2 == null) { subList2Head = curr; } else { subList2.next = curr; } subList2 = curr; } curr = curr.next; } // 2.4 将(hash & table.length) == 0的放在newTable[i]处 if (subList1 != null) { subList1.next = null; // 由于原先的节点可能指向别的节点,所以让它的next指向null newTable[i] = subList1Head; } // 2.5 将(hash & table.length) == 1的放在newTable[i + table.length]处 // 注意此处的table.length是原先链表的长度 if (subList2 != null) { subList2.next = null; // 由于原先的节点可能指向别的节点,所以让它的next指向null newTable[i + table.length] = subList2Head; } } // 3. 替换原先的数组 table = newTable; // 4. 更新阈值 threshold = (int) (LOAD_FACTOR * table.length); } }
因为这样获取一个数据的下标更快,从而提高数据增删改查的效率。
一般来说,对于一个哈希表table
和一个数据的哈希码hash
,获取下标的方式为index = hash % table.length
。
但对于长度是2的n次方的数组table
和一个数据的哈希码hash
,hash & (table.length - 1)
操作正好能够得到这个数据在数组table
中的下标。
这是位运算的知识,假如table.length
是2的4次方(也就是16),则table.length
的二进制是0001 0000
,那么table.length - 1
的二进制就是0000 1111
。对于一个哈希码hash = 57
(二进制为0011 1001
),它按照取余hash % table.length
获取的下标为9;它按照与运算hash & (table.length - 1)
获取的下标的二进制为0000 1001
,而这个二进制恰好就是9。
负载因子的作用是防止哈希表的查询效率太低,因为数组长度越小,越可能发生哈希碰撞。
一般在 存储元素的个数 / 数组长度 > 0.75
时进行扩容,这样会将原先挂在相同索引处的元素分散到不同的索引处。
例如原先的数组长度为16,在数组索引为1的位置挂了三个元素ele1, ele2, ele3
,其中ele1
的哈希码hash = 17
,ele2
的哈希码hash = 33
,ele3
的哈希码hash = 49
。在扩容之后,数组的长度为32,此时ele1
的新索引为17,ele2
的新索引为1,ele3
的新索引为17。这就将数据分散开来,从而使得查询的效率更高。
这个问题很简单,因为每次新增操作都需要检查是否需要扩容,而这个检查一般是size / table.length > LOAD_FACTOR
,但是这样每次都需要计算一下,而size > threshold
只需要在数组长度改变时(也就是扩容时)改变一次就行了,这样做可以减少时间的消耗。
因为如果是Object的hashCode()方法,会导致只要字符串中有相同的字符,无论顺序如何,都会得到相同的哈希码,例如"name"和"aemn"使用Object的hashCode()方法得到的哈希码相同。但是如果是String的hashCode(),这两个元素的哈希码就不同了。
所以建议在使用自建的数据类型作为本文实现的哈希表的键key
时,自己先重写equals()和hashCode()
方法,然后在HashTable
类中的hash
方法中仿照String
类加上自定义类的判断,这样就减少两个不同的对象有一模一样的哈希码的可能性了。
key.hashCode()一般指的是java中Object对象的方法,而这个方法可能返回一个负值,众所周知,负值(int类型)的最高位(第32位)是1,而0x7fffffff
的二进制是0111 1111 1111 1111 1111 1111 1111 1111
,任何数和它进行与运算都会得到一个正数,也就是最高位为0。所以说key.hashCode() & 0x7fffffff
保证返回的结果一定是正数。
例如原先的数组长度为16,在数组索引为1的位置挂了五个元素ele1, ele2, ele3, ele4, ele5
,其中ele1
的哈希码hash = 17
,ele2
的哈希码hash = 33
,ele3
的哈希码hash = 49
,ele4
的哈希码hash = 65
,ele5
的哈希码hash = 81
。在扩容之后,数组的长度为32,此时ele1
的新索引为17,ele2
的新索引为1,ele3
的新索引为17,ele4
的新索引为1,ele5
的新索引为17。
由此可以发现一个规律:对于hash & table.length == 0
的元素,它的新索引与原索引一样(注意:此处的table.length
指的是原数组的长度),例如
e
l
e
2
ele2
ele2:0010 0001 & 0001 0000 == 0
,
e
l
e
4
ele4
ele4:0100 0001 & 0001 0000 == 0
;然而对于hash & table.length == 1
的元素,它的新索引等于原索引加上原数组的长度,即newIndex = lastIndex + table.length
(注意:此处的table.length
指的是原数组的长度),例如
e
l
e
1
ele1
ele1:0001 0001 & 0001 0000 == 1
,
e
l
e
3
ele3
ele3:0011 0001 & 0001 0000 == 1
和
e
l
e
5
ele5
ele5:0101 0001 & 0001 0000 == 1
。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。