哈希表Hash

「哈希表 hash table」,又称「散列表」,其通过建立键 key 与值 value 之间的映射,实现高效的元素查询。具 体而言,我们向哈希表输入一个键 key ,则可以在 𝑂(1) 时间内获取对应的值 value
如图 所示,给定 𝑛 个学生,每个学生都有“姓名”和“学号”两项数据。假如我们希望实现“输入一个
学号,返回对应的姓名”的查询功能,则可以采用图 6‑1 所示的哈希表来实现。

除哈希表外,数组和链表也可以实现查询功能,它们的效率对比如图所示。  

观察发现, 在哈希表中进行增删查改的时间复杂度都是 𝑂(1) ,非常高效。

1.HashMap

public static void main(String[] args) {
       Map<String,String> map=new HashMap<>();
        //添加数据
        map.put("name","张三");
        map.put("age","18");
        map.put("price","100");
        //取出数据
        System.out.println(map.get("name"));

        //删除
        map.remove("name");

        //三种遍历方式

        /*    遍历键      */
        for (String s : map.keySet()) {
            System.out.println(map.get(s));
        }
        /*    遍历值      */
        for (String value : map.values()) {
            System.out.println(value);
        }
        /*遍历键值对 key->value*/
        for (Map.Entry <String, String> kv: map.entrySet()) {
            System.out.println(kv.getKey() + " -> " + kv.getValue());
        }



    }

2.哈希表的简单实现

我们先考虑最简单的情况, 仅用一个数组来实现哈希表 。在哈希表中,我们将数组中的每个空位称为「桶 bucket」,每个桶可存储一个键值对。因此,查询操作就是找到 key 对应的桶,并在桶中获取 value
那么,如何基于 key 来定位对应的桶呢?这是通过「哈希函数 hash function」实现的。哈希函数的作用是将 一个较大的输入空间映射到一个较小的输出空间。在哈希表中,输入空间是所有 key ,输出空间是所有桶(数 组索引)。换句话说,输入一个 key 我们可以通过哈希函数得到该 key 对应的键值对在数组中的存储位置
输入一个 key ,哈希函数的计算过程分为以下两步。
1. 通过某种哈希算法 hash() 计算得到哈希值。
2. 将哈希值对桶数量(数组长度) capacity 取模,从而获取该 key 对应的数组索引 index
index = hash(key) % capacity
随后,我们就可以利用 index 在哈希表中访问对应的桶,从而获取 value
设数组长度 capacity = 100 、哈希算法 hash(key) = key ,易得哈希函数为 key % 100 。下图 以 key 学号 和 value 姓名为例,展示了哈希函数的工作原理

3.哈希冲突与扩容

本质上看,哈希函数的作用是将所有 key 构成的输入空间映射到数组所有索引构成的输出空间,而输入空间 往往远大于输出空间。因此,理论上一定存在“多个输入对应相同输出”的情况
对于上述示例中的哈希函数,当输入的 key 后两位相同时,哈希函数的输出结果也相同。例如,查询学号为 12836 和 20336 的两个学生时,我们得到
12836 % 100 = 36
20336 % 100 = 36
如图  所示,两个学号指向了同一个姓名,这显然是不对的。我们将这种多个输入对应同一输出的情况称 为「哈希冲突 hash collision」。

容易想到,哈希表容量 𝑛 越大,多个 key 被分配到同一个桶中的概率就越低,冲突就越少。因此, 我们可以 通过扩容哈希表来减少哈希冲突

如图 所示,扩容前键值对 (136, A) (236, D) 发生冲突,扩容后冲突消失。

类似于数组扩容,哈希表扩容需将所有键值对从原哈希表迁移至新哈希表,非常耗时。并且由于哈希表容量 capacity 改变,我们需要通过哈希函数来重新计算所有键值对的存储位置,这进一步提高了扩容过程的计算 开销。为此,编程语言通常会预留足够大的哈希表容量,防止频繁扩容。
「负载因子 load factor」是哈希表的一个重要概念,其定义为哈希表的元素数量除以桶数量,用于衡量哈希 冲突的严重程度,也常被作为哈希表扩容的触发条件 。例如在 Java 中,当负载因子超过 0.75 时,系统会将 哈希表容量扩展为原先的 2 倍。

3.1哈希冲突

上节提到, 通常情况下哈希函数的输入空间远大于输出空间 ,因此理论上哈希冲突是不可避免的。比如,输 入空间为全体整数,输出空间为数组容量大小,则必然有多个整数映射至同一桶索引。
哈希冲突会导致查询结果错误,严重影响哈希表的可用性。为解决该问题,我们可以每当遇到哈希冲突时就 进行哈希表扩容,直至冲突消失为止。此方法简单粗暴且有效,但效率太低,因为哈希表扩容需要进行大量 的数据搬运与哈希值计算。为了提升效率,我们可以采用以下策略。
1. 改良哈希表数据结构, 使得哈希表可以在存在哈希冲突时正常工作
2. 仅在必要时,即当哈希冲突比较严重时,才执行扩容操作。
哈希表的结构改良方法主要包括“链式地址”和“开放寻址”。

3.1.1链式地址

在原始哈希表中,每个桶仅能存储一个键值对。「链式地址 separate chaining」将单个元素转换为链表,将 键值对作为链表节点,将所有发生冲突的键值对都存储在同一链表中。下图展示了一个链式地址哈希表的 例子。
基于链式地址实现的哈希表的操作方法发生了以下变化。
查询元素 :输入 key ,经过哈希函数得到桶索引,即可访问链表头节点,然后遍历链表并对比 key 以查 找目标键值对。
添加元素 :先通过哈希函数访问链表头节点,然后将节点(即键值对)添加到链表中。
删除元素 :根据哈希函数的结果访问链表头部,接着遍历链表以查找目标节点,并将其删除。
链式地址存在以下局限性。
占用空间增大 ,链表包含节点指针,它相比数组更加耗费内存空间。
查询效率降低 ,因为需要线性遍历链表来查找对应元素。
值得注意的是,当链表很长时,查询效率 𝑂(𝑛) 很差。 此时可以将链表转换为“AVL 树”或“红黑树” ,从而 将查询操作的时间复杂度优化至 𝑂( log 𝑛)

3.1.2开放寻址

「开放寻址 open addressing」不引入额外的数据结构,而是通过“多次探测”来处理哈希冲突,探测方式主 要包括线性探测、平方探测、多次哈希等。
下面将主要以线性探测为例,介绍开放寻址哈希表的工作机制

1.线性探测:

线性探测采用固定步长的线性搜索来进行探测,其操作方法与普通哈希表有所不同。
插入元素 :通过哈希函数计算桶索引,若发现桶内已有元素,则从冲突位置向后线性遍历(步长通常为 1 ),直至找到空桶,将元素插入其中。
查找元素 :若发现哈希冲突,则使用相同步长向后线性遍历,直到找到对应元素,返回 value 即可;如
果遇到空桶,说明目标元素不在哈希表中,返回 None 。
下图展示了开放寻址(线性探测)哈希表的键值对分布。根据此哈希函数,最后两位相同的 key 都会被映 射到相同的桶。而通过线性探测,它们被依次存储在该桶以及之下的桶中。

然而, 线性探测容易产生“聚集现象” 。具体来说,数组中连续被占用的位置越长,这些连续位置发生哈希冲 突的可能性越大,从而进一步促使该位置的聚堆生长,形成恶性循环,最终导致增删查改操作效率劣化。
值得注意的是, 我们不能在开放寻址哈希表中直接删除元素 。这是因为删除元素会在数组内产生一个空桶 None ,而当查询元素时,线性探测到该空桶就会返回,因此在该空桶之下的元素都无法再被访问到,程序 可能误判这些元素不存在。

为了解决该问题,我们可以采用「懒删除 lazy deletion」机制:它不直接从哈希表中移除元素, 而是利用一 个常量 TOMBSTONE 来标记这个桶 。在该机制下,None 和 TOMBSTONE 都代表空桶,都可以放置键值对。但不 同的是,线性探测到 TOMBSTONE 时应该继续遍历,因为其之下可能还存在键值对。
然而, 懒删除可能会加速哈希表的性能退化 。这是因为每次删除操作都会产生一个删除标记,随着 TOMBSTONE 的增加,搜索时间也会增加,因为线性探测可能需要跳过多个 TOMBSTONE 才能找到目标元素。
为此,考虑在线性探测中记录遇到的首个 TOMBSTONE 的索引,并将搜索到的目标元素与该 TOMBSTONE 交换位 置。这样做的好处是当每次查询或添加元素时,元素会被移动至距离理想位置(探测起始点)更近的桶,从而优化查询效率。

2. 平方探测
平方探测与线性探测类似,都是开放寻址的常见策略之一。当发生冲突时,平方探测不是简单地跳过一个固 定的步数,而是跳过“探测次数的平方”的步数,即 1, 4, 9, … 步。
平方探测主要具有以下优势。
‧ 平方探测通过跳过平方的距离,试图缓解线性探测的聚集效应。
‧ 平方探测会跳过更大的距离来寻找空位置,有助于数据分布得更加均匀。
然而,平方探测也并不是完美的。
‧ 仍然存在聚集现象,即某些位置比其他位置更容易被占用。
‧ 由于平方的增长,平方探测可能不会探测整个哈希表,这意味着即使哈希表中有空桶,平方探测也可能 无法访问到它。
3. 多次哈希
多次哈希使用多个哈希函数 𝑓 1 (𝑥) 𝑓 2 (𝑥) 𝑓 3 (𝑥) 进行探测。
插入元素 :若哈希函数 𝑓 1 (𝑥) 出现冲突,则尝试 𝑓 2 (𝑥) ,以此类推,直到找到空桶后插入元素。
查找元素 :在相同的哈希函数顺序下进行查找,直到找到目标元素时返回;或当遇到空桶或已尝试所有
哈希函数,说明哈希表中不存在该元素,则返回 None 。
与线性探测相比,多次哈希方法不易产生聚集,但多个哈希函数会增加额外的计算量

不同编程语言的选择:

各个编程语言采取了不同的哈希表实现策略,以下举几个例子。
‧ Java 采用链式地址。自 JDK 1.8 以来,当 HashMap 内数组长度达到 64 且链表长度达到 8 时,链表会 被转换为红黑树以提升查找性能。
‧ Python 采用开放寻址。字典 dict 使用伪随机数进行探测。
‧ Golang 采用链式地址。Go 规定每个桶最多存储 8 个键值对,超出容量则连接一个溢出桶。当溢出桶 过多时,会执行一次特殊的等量扩容操作,以确保性能。

4.哈希算法

在上两节中,我们了解了哈希表的工作原理和哈希冲突的处理方法。然而无论是开放寻址还是链地址法, 们只能保证哈希表可以在发生冲突时正常工作,但无法减少哈希冲突的发生
如果哈希冲突过于频繁,哈希表的性能则会急剧劣化。如图所示,对于链地址哈希表,理想情况下键值 对平均分布在各个桶中,达到最佳查询效率;最差情况下所有键值对都被存储到同一个桶中,时间复杂度退 化至 𝑂(𝑛)

键值对的分布情况由哈希函数决定 。回忆哈希函数的计算步骤,先计算哈希值,再对数组长度取模:
index = hash(key) % capacity
观察以上公式,当哈希表容量 capacity 固定时, 哈希算法 hash() 决定了输出值 ,进而决定了键值对在哈希 表中的分布情况。
这意味着,为了减小哈希冲突的发生概率,我们应当将注意力集中在哈希算法 hash() 的设计上。

4.1哈希算法的目标

为了实现“既快又稳”的哈希表数据结构,哈希算法应包含以下特点。
确定性 :对于相同的输入,哈希算法应始终产生相同的输出。这样才能确保哈希表是可靠的。
效率高 :计算哈希值的过程应该足够快。计算开销越小,哈希表的实用性越高。
均匀分布 :哈希算法应使得键值对平均分布在哈希表中。分布越平均,哈希冲突的概率就越低。
实际上,哈希算法除了可以用于实现哈希表,还广泛应用于其他领域中。
密码存储 :为了保护用户密码的安全,系统通常不会直接存储用户的明文密码,而是存储密码的哈希 值。当用户输入密码时,系统会对输入的密码计算哈希值,然后与存储的哈希值进行比较。如果两者匹 配,那么密码就被视为正确。
数据完整性检查 :数据发送方可以计算数据的哈希值并将其一同发送;接收方可以重新计算接收到的 数据的哈希值,并与接收到的哈希值进行比较。如果两者匹配,那么数据就被视为完整的。
对于密码学的相关应用,为了防止从哈希值推导出原始密码等逆向工程,哈希算法需要具备更高等级的安全
特性。
单向性 :无法通过哈希值反推出关于输入数据的任何信息。
抗碰撞性 :应当极其困难找到两个不同的输入,使得它们的哈希值相同。
雪崩效应 :输入的微小变化应当导致输出的显著且不可预测的变化。
请注意, “均匀分布”与“抗碰撞性”是两个独立的概念 ,满足均匀分布不一定满足抗碰撞性。例如,在随机 输入 key 下,哈希函数 key % 100 可以产生均匀分布的输出。然而该哈希算法过于简单,所有后两位相等的 key 的输出都相同,因此我们可以很容易地从哈希值反推出可用的 key ,从而破解密码。

4.2哈希算法的设计

哈希算法的设计是一个需要考虑许多因素的复杂问题。然而对于某些要求不高的场景,我们也能设计一些简
单的哈希算法。
加法哈希 :对输入的每个字符的 ASCII 码进行相加,将得到的总和作为哈希值。
乘法哈希 :利用了乘法的不相关性,每轮乘以一个常数,将各个字符的 ASCII 码累积到哈希值中。
异或哈希 :将输入数据的每个元素通过异或操作累积到一个哈希值中。
旋转哈希 :将每个字符的 ASCII 码累积到一个哈希值中,每次累积之前都会对哈希值进行旋转操作
/* 加法哈希 */
int addHash(String key) {
    long hash = 0;
    final int MODULUS = 1000000007;
    for (char c : key.toCharArray()) {
        hash = (hash + (int) c) % MODULUS;
       }
    return (int) hash;
}
/* 乘法哈希 */
int mulHash(String key) {
    long hash = 0;
    final int MODULUS = 1000000007;
    for (char c : key.toCharArray()) {
        hash = (31 * hash + (int) c) % MODULUS;
    }
    return (int) hash;
}
/* 异或哈希 */
int xorHash(String key) {
    int hash = 0;
    final int MODULUS = 1000000007;
    for (char c : key.toCharArray()) {
        hash ^= (int) c;
    }
    return hash & MODULUS;
}
/* 旋转哈希 */
int rotHash(String key) {
    long hash = 0;
    final int MODULUS = 1000000007;
    for (char c : key.toCharArray()) {
        hash = ((hash << 4) ^ (hash >> 28) ^ (int) c) % MODULUS;
    }
    return (int) hash;
}
观察发现,每种哈希算法的最后一步都是对大质数 1000000007 取模,以确保哈希值在合适的范围内。值得 思考的是,为什么要强调对质数取模,或者说对合数取模的弊端是什么?这是一个有趣的问题。
先抛出结论: 当我们使用大质数作为模数时,可以最大化地保证哈希值的均匀分布 。因为质数不会与其他数 字存在公约数,可以减少因取模操作而产生的周期性模式,从而避免哈希冲突。
举个例子,假设我们选择合数 9 作为模数,它可以被 3 整除。那么所有可以被 3 整除的 key 都会被映射到 0、 3、 6 这三个哈希值。
        
modulus = 9
key = {0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, … }
hash = {0, 3, 6, 0, 3, 6, 0, 3, 6, 0, 3, 6, … }
值得说明的是,如果能够保证 key 是随机均匀分布的,那么选择质数或者合数作为模数都是可以的,它们都 能输出均匀分布的哈希值。而当 key 的分布存在某种周期性时,对合数取模更容易出现聚集现象。 总而言之,我们通常选取质数作为模数,并且这个质数最好足够大,以尽可能消除周期性模式,提升哈希算 法的稳健性。

5.常见哈希算法

不难发现,以上介绍的简单哈希算法都比较“脆弱”,远远没有达到哈希算法的设计目标。例如,由于加法和异或满足交换律,因此加法哈希和异或哈希无法区分内容相同但顺序不同的字符串,这可能会加剧哈希冲突, 并引起一些安全问题。
在实际中,我们通常会用一些标准哈希算法,例如 MD5、SHA‑1、SHA‑2、SHA3 等。它们可以将任意长度 的输入数据映射到恒定长度的哈希值。
近一个世纪以来,哈希算法处在不断升级与优化的过程中。一部分研究人员努力提升哈希算法的性能,另一 部分研究人员和黑客则致力于寻找哈希算法的安全性问题。下图 展示了在实际应用中常见的哈希算法。
‧ MD5 和 SHA‑1 已多次被成功攻击,因此它们被各类安全应用弃用。
‧ SHA‑2 系列中的 SHA‑256 是最安全的哈希算法之一,仍未出现成功的攻击案例,因此常被用在各类安
全应用与协议中。 ‧ SHA‑3 相较 SHA‑2 的实现开销更低、计算效率更高,但目前使用覆盖度不如 SHA‑2 系列。

6.小结

1. 重点回顾
‧ 输入 key ,哈希表能够在 𝑂(1) 时间内查询到 value ,效率非常高。
‧ 常见的哈希表操作包括查询、添加键值对、删除键值对和遍历哈希表等。
‧ 哈希函数将 key 映射为数组索引,从而访问对应桶并获取 value
‧ 两个不同的 key 可能在经过哈希函数后得到相同的数组索引,导致查询结果出错,这种现象被称为哈 希冲突。
‧ 哈希表容量越大,哈希冲突的概率就越低。因此可以通过扩容哈希表来缓解哈希冲突。与数组扩容类 似,哈希表扩容操作的开销很大。
‧ 负载因子定义为哈希表中元素数量除以桶数量,反映了哈希冲突的严重程度,常用作触发哈希表扩容 的条件。
‧ 链式地址通过将单个元素转化为链表,将所有冲突元素存储在同一个链表中。然而,链表过长会降低查 询效率,可以进一步将链表转换为红黑树来提高效率。
‧ 开放寻址通过多次探测来处理哈希冲突。线性探测使用固定步长,缺点是不能删除元素,且容易产生聚 集。多次哈希使用多个哈希函数进行探测,相较线性探测更不易产生聚集,但多个哈希函数增加了计算 量。
‧ 不同编程语言采取了不同的哈希表实现。例如,Java 的 HashMap 使用链式地址,而 Python 的 Dict 采 用开放寻址。
‧ 在哈希表中,我们希望哈希算法具有确定性、高效率和均匀分布的特点。在密码学中,哈希算法还应该 具备抗碰撞性和雪崩效应。
‧ 哈希算法通常采用大质数作为模数,以最大化地保证哈希值的均匀分布,减少哈希冲突。
‧ 常见的哈希算法包括 MD5、SHA‑1、SHA‑2 和 SHA3 等。MD5 常用于校验文件完整性,SHA‑2 常用 于安全应用与协议。
‧ 编程语言通常会为数据类型提供内置哈希算法,用于计算哈希表中的桶索引。通常情况下,只有不可变 对象是可哈希的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值