[数据结构] 哈希表
1.概念
顺序结构以及平衡树中 , 元素关键码 与 其存储位置之间没有对应关系 , 因此在 查找一个元素时 , 必须要经过关键码的多次比较
顺序查找的时间复杂度为 O(N) , 平衡树为 O(log2N) (树的高度) , 搜索的效率取决于搜索过程中的元素比较的次数
理想的搜索方法 : 可以不经过任何比较 , 一次直接从表中得到要搜索的元素 . 如果构造一种存储结构 , 通过某种函数 使元素的存储位置与它的关键码之间能够建立 一一映射的关系 , 那么在查找时通过该函数可以很快找到该元素
当向该结构中 :
- 插入元素时 : 根据待插入元素的关键码 , 以此函数计算出该元素的存储位置并按次位置进行存放
- 搜索元素时 : 对元素的关键码进行同样的计算 , 把求得的函数值当作元素的存储位置 , 在结构中按此位置取元素比较 , 若关键码相等 , 则搜索成功
该方式为哈希(散列)方法 , 哈希方法中使用的转换函数称为哈希(散列)函数 , 构造出来的结构称为哈希表(HashTable)(哈希散列表)
用该方法进行搜索 不必进行多次关键码的比较 , 因此搜索的速度比较快
但是 按上述哈希方式 , 向集合 中插入元素 14 , 会出现什么问题?
2.哈希冲突
对于两个数据元素的关键字 ki 和 kj(i!=j ),有 ki != kj,但有:Hash(ki) == Hash(kj) , 即 :不同关键字通过相同的哈希函数计算出相同的哈希地址 ,该种现象称为哈希冲突或者哈希碰撞
2.1 避免哈希冲突
由于哈希表底层数组的容量往往是小于实际要存储的关键字的数量的,这就导致-哈希冲突的发生是必然的,但我们能做的就是应该尽量降低冲突率
2.2 哈希冲突避免-哈希函数设计
引起哈希冲突的一个原因可能是 : 哈希函数 设计不合理
哈希函数的设计原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有 m 个地址时,其值域必须在 0~m-1 之间
- 哈希函数计算出来的地址能均匀分布在整个空间中
- 哈希函数应该比较简单
常见的哈希函数
① 直接定制法
取关键字的某个线性函数为散列地址 : Hash(key) = A*key+B
优点 : 简单 , 均匀
缺点 : 需要事先知道关键字的分布情况 使用场景 : 适合查找比较小且连续的情况
② 除留余数法
设散列表中允许的地指数为 m , 取一个不大于 m , 但最接近或者等于 m 的质数 P 作为除数 , 按照哈希函数 : Hash(key) = key%P (P<=m) , 将关键码转换成哈希地址
......
2.3 哈希冲突-负载因子
散列表的荷载因子 :α = 填入表中的元素个数 / 散列表的长度
负载因子与冲突率的关系粗略演示 :
所以当冲突率达到一个无法忍受的程度时,需要通过降低负载因子来变相的降低冲突率
由于哈希表中已有的关键字个数是不可变动,那么我们只能通过调整 哈希表中的数组大小来降低冲突率
2.4 哈希冲突-解决
2.4.1 闭散列
闭散列 : 也叫开放地址法 , 当发生哈希冲突时 , 如果哈希表未被装满 , 说明在哈希表中必然还有空位置 , 那么可以把 key 存放到冲突位置的 "下一个" 空位置去 ; 如何寻找下一个空位置?
① 线性探测
比如上面的场景 插入元素 14 , 想通过哈希函数计算哈希地址 , 与元素 4 发生哈希冲突
线性探测 : 从发生冲突的位置开始 , 依次向后探测 , 知道寻到下一个空位置为止
插入 :
通过哈希函数获取待插入元素 在哈希表中的位置
如果该位置中没有元素则直接插入新元素 , 如果过该位置中有元素 发生哈希冲突 , 使用 线性探测找到下一个空位置 , 插入新元素
采用闭散列处理哈希冲突时 , 不能随便物理删除哈希表中已有的元素 , 若直接删除元素会音响其他元素的搜索 , 比如删除 4 , 如果直接删掉 , 44 查找起来可能会有音响 ; 因此线性探测 采用 标记的 伪删除法来删除一个元素
② 二次探测
线性探测的缺陷 : 产生冲突的数据堆积在一起 ,这与其找下一个空位置有关系 , 因为找空位置的方式就是 挨个往后逐个去找 , 因此二次探测为了避免该问题 , 找下一个空位置的方法 : Hi = (H0 + i^2)%m 或者 Hi = (H0 - i^2)%m 其中 i = 1,2,3... ; H0 是通过散列函数 Hash(x)对元素的关键码 key 进行计算得到的位置 , m 是表大小
散列对最大的缺陷是 空间利用率低 , 也是哈希的缺陷
2.4.2 开散列(哈希桶)
开散列法又称为 链地址法(开链法) , 首先对关键码集合用散列函数计算散列地址 , 具有相同地址的关键码 归于同一子集合 , 每一个自己和称为一个通 , 各个通中的元素通过一个单链表链接起来 , 各单链表的头结点存储在哈希表中
从上图可以看出 , 开散列中每个桶中放的都是发生哈希冲突的元素
开散列 , 可以把它认为是一个在大集合中的搜索问题转化为在小集合中搜索
哈希桶中冲突严重时的解决方法
每个桶的背后是另一个哈希表
每个痛的背后是一颗搜索树
哈希桶的实现
import java.util.Arrays;public class HashBucket_db {static class Node {public int key;public int val;public Node next;public Node(int key, int val) {this.key = key;this.val = val;}}public int usedSize = 0;public Node[] array = new Node[10];public static final double DEFAULT_LOAD_FACTOR = 0.75f;/*** 插入或更新键值对(仅支持key ≥ 0)* @param key 键(需≥0,否则视为无效)* @param val 值* @return 若key已存在,返回旧值;若为新插入,返回-1;若key无效,返回Integer.MIN_VALUE*/public int push(int key, int val) {if (key >= 0) {// 计算索引(因key≥0,无需额外取绝对值)int index = key % array.length;Node cur = array[index];// 查找并更新已有keywhile (cur != null) {if (cur.key == key) {int olderValue = cur.val; // 修正变量命名cur.val = val;return olderValue;}cur = cur.next;}// 头插法插入新节点(简化冗余代码)Node node = new Node(key, val);node.next = array[index]; // 直接指向原头节点,无需临时变量curarray[index] = node;usedSize++;// 检查负载因子并扩容(修正方法名)if (getLoadFactor() > DEFAULT_LOAD_FACTOR) {resize();}return -1; // 新节点插入成功}// 无效key(<0)返回特殊值,避免与有效场景混淆return Integer.MIN_VALUE;}/*** 扩容并重新哈希所有节点*/private void resize() {Node[] newArray = new Node[array.length * 2];for (int i = 0; i < array.length; i++) {Node cur = array[i];while (cur != null) {Node curNext = cur.next; // 修正变量命名(curN→curNext)// 计算新索引(因key≥0,无需取绝对值)int newIndex = cur.key % newArray.length;// 头插法插入新数组cur.next = newArray[newIndex];newArray[newIndex] = cur;cur = curNext;}}array = newArray; // 简化赋值,去除冗余copy}/*** 计算负载因子* @return 当前负载因子(usedSize / array.length)*/private double getLoadFactor() { // 修正方法名和拼写return usedSize * 1.0 / array.length;}/*** 根据key获取值(仅支持key ≥ 0)* @param key 键(需≥0,否则返回-1)* @return 若key存在,返回对应值;否则返回-1*/public int getValue(int key) {// 处理可能的负数key,避免负索引if (key < 0) {return -1;}int index = key % array.length;Node cur = array[index];while (cur != null) {if (cur.key == key) {return cur.val;}cur = cur.next;}return -1; // 未找到该key}
}
Integer.MIN_VALUE
是 Java 中 int
类型的最小取值,其值为 -2147483648
(即 -2^31
),它是 int
类型范围(-2^31
到 2^31 - 1
,也就是 -2147483648
到 2147483647
)的边界值,也是哈希桶代码中需要单独处理的特殊值,核心原因是它的绝对值会触发整数溢出
性能分析
虽然哈希表一直在和冲突作斗争 , 但在实际使用的过程中 , 我们认为哈希表的冲突率是不高的 , 冲突的个数是可控的 , 每个桶中的链表长度是一个常数 , 所以在通常情况下 , 认为哈希表的 插入/删除/查找的时间复杂度是 O(1)
和 Java 类集的关系
- HashMap 和 HashSet 即 Java 中利用哈希表实现的 Map 和 Set
- Java 中使用的是哈希桶解决冲突的
- Java 会在冲突链表长度大于一定阈值后 , 将链表转变为搜索树 (红黑树)
- Java 中计算哈希值实际上是调用类的 hashCode 方法 , 进行 key 的相等性比较是 调用 key 的 equals 方法 ; 所以如果要定义类作为 HashMap 的 key 或者 HashSet 的值 , 必须覆写 HashCode 和 equals 方法 , 而且要做到 equals 相等的对象 , hashCode 一定是一致的