前言 今天,我们继续踏入追寻C++的冒险历程。前面我们讲了很多的树形数据结构,那么这一章我们来讲解另一类数据结构——哈希表。下面让我们一起来进入本章的学习。
- 哈希表的概念 哈希表(又称散列表)是一种基于「键值对(Key-Value)」存储的数据结构,其核心目标是通过哈希函数将「键(Key)」直接映射到对应的存储位置,从而实现 O (1) 级别的平均查找、插入和删除效率,是计算机科学中效率最高的数据结构之一。
对于我们来说,哈希表并不陌生,我们先来了解一些概念性的东西。
1.1 哈希函数(Hash Function):哈希表的 “地址映射引擎” 哈希函数是哈希表的核心组件,其本质是一个数学函数,作用是将「任意类型、任意长度的关键字(Key)」转换为「固定范围、可直接作为底层数组索引的整数(哈希值 / Hash Value)」,从而实现 “通过 Key 快速定位存储位置” 的目标。
1.2 哈希冲突(Hash Collision):哈希函数的 “必然产物” 哈希冲突是指不同的 Key 经过哈希函数计算后,得到了相同的哈希值 的现象。它不是 “设计失误”,而是数学上的必然结果。
1.3 负载因子(Load Factor):衡量 “数据拥挤程度” 的核心指标 假设哈希表中已经映射存储了N个值,哈希表的⼤⼩为M,那么 ,负载因⼦有些地⽅也翻译为载荷因⼦/装载因⼦等。负载因⼦越⼤,哈希冲突的概率越⾼,空间利⽤率越⾼;负载因⼦越⼩,哈希冲突的概率越低,空间利⽤率越低。负载因子的关键应用是触发哈希表进行扩容。
- 哈希函数 哈希函数是哈希表的 “核心引擎”,作用是:把任意类型的 “键(Key)”(比如整数、字符串、对象),转换成一个固定范围的整数(称为 “哈希值” 或 “索引”),这个索引直接对应底层存储数组的位置。
哈希函数的设计要求:
确定性:同一个 Key 每次输入哈希函数,必须得到相同的索引(若结果随机,则无法查找)。 均匀性:尽量将不同的 Key 映射到不同的索引,减少「哈希冲突」。 高效性:哈希函数的计算过程必须快速(如简单的取模、位运算),否则会抵消哈希表的效率优势。 下面让我们来认识一下常见的哈希函数:
2.1 直接定址法(Direct Addressing) 直接定址法是最直观的哈希函数构造方式,其核心是 “关键字与哈希地址直接关联”,无需复杂计算,是理解哈希函数设计的基础。
直接定址法通过关键字本身或关键字的线性变换直接作为哈希地址,它的本质是建立关键字与哈希地址的线性映射关系:每个关键字通过公式计算后,会映射到唯一的哈希地址(数组索引),且不同关键字的哈希地址一定不同,这种 “一一对应” 的特性决定了:直接定址法不会产生哈希冲突(这是它与其他哈希函数的核心区别)。
理解映射过程:例如我们要统计一个字符串中每个字符出现的次数(确保字符串中都是小写字母),我们可以以字符的ASCII码值作为关键字,因为小写字母的ASCII码值是从97到123,所以我们可以通过简单的线性变换将其关键字映射到大小为26的数组中:
优点:
计算高效:仅需一次线性运算(或直接使用关键字),几乎无额外开销,是所有哈希函数中计算最快的; 无冲突:由于映射关系是一一对应,完全避免哈希冲突,无需设计冲突解决机制; 实现简单:无需复杂逻辑,直接通过公式映射,代码实现难度低。 缺点:
空间利用率极低:仅适用于关键字范围小且连续的场景。若关键字范围大(如 0~10^9),哈希表数组容量需与关键字范围匹配,会导致大量空间浪费(例如存储 100 个数据,可能需要 10 亿大小的数组); 灵活性差:仅支持整数关键字(非整数需先转为整数,且转换后范围仍需满足 “小而连续”); 不适合动态数据:若关键字范围不固定(如新增超出原范围的关键字),哈希表需频繁调整数组大小,成本极高。 适用场景:
直接定址法的应用场景非常受限,仅适合关键字范围已知、固定且较小的场景,例如:
员工编号(如 11000 的整数,连续且范围明确);
月份(112)、日期(131)等有限范围的数值;
数据库中固定前缀的自增 ID(如 50015100,范围明确且连续)。
2.2 除留余数法(Division Method)
除留余数法(也叫除法散列法)是实际开发中最常用的哈希函数构造方法,其核心是通过 “取模运算” 将关键字压缩到固定范围的地址,能适应大多数关键字场景。
除留余数法通过关键字对 “数组大小” 取模得到哈希地址,哈希函数为:H(key) = key mod m
其中:
key 为原始关键字(整数或经转换后的整数,如字符串转整数); m 为哈希表底层数组的大小; mod 为取模运算(即求 key 除以 m 的余数),结果范围为 0~m-1,恰好对应数组的索引范围。 因此当我们用哈希表来存储数据时的一个必须的要求就是数据的类型必须能通过某种方式转为整数。
除留余数法的本质是将任意范围的关键字通过取模运算,压缩到 0m-1 的地址空间(与数组大小匹配)。其核心是通过选择合适的 m,使哈希值在 0m-1 范围内均匀分布,从而减少哈希冲突。
例如:若 m=13(数组大小 13),关键字 key=123 时,123 mod 13 = 6(因 13×9=117,123-117=6),则哈希地址为 6,落在 0~12 范围内。
当使⽤除法散列法时,要尽量避免m为某些值,如2的幂,10的幂等。如果是 ,那么key % 本质相当于保留key的后x位,那么后x位相同的值,计算出的哈希值都是⼀样的,就冲突了。如:{63 , 31}看起来没有关联的值,如果m是16,也就是 ,那么计算出的哈希值都是15,因为63的⼆进制后8位是 00111111,31的⼆进制后8位是 00011111。如果是 ,就更明显了,保留的都是10进值的后x位,如:{112, 12312},如果M是100,也就是 ,那么计算出的哈希值都是12。 因此当使⽤除留余数法时,建议m取不太接近2的整数次幂的⼀个质数(素数)。
需要说明的是,实践中也是⼋仙过海,各显神通,Java的HashMap采⽤除法散列法时就是2的整数次幂做哈希表的⼤⼩m,这样的话就不⽤取模,可以直接位运算,相对⽽⾔位运算⽐取模更⾼效⼀些。但是他不是单纯的去取模,⽐如m是2^16次⽅,本质是取后16位,那么⽤key' = key>>16,然后把key和key’ 异或的结果作为哈希值。也就是说我们映射出的值还是在[0,m)范围内,但是尽量让key所有的位都参与计算,这样映射出的哈希值更均匀⼀些即可。所以我们上⾯建议m取不太接近2的整数次幂的⼀个质数的理论是⼤多数数据结构书籍中写的理论,但是实践中需要灵活运⽤,抓住本质。
对于哈希表来说最常见的数据类型除了整数外还有字符串,那么对于字符串我们该如何处理呢?处理的方法有很多种,如ASCII码求和、多项式哈希等等,不过ASCII码求和法的冲突率太高,一般不会使用。(下面的方法了解即可)
多项式哈希法是实际开发中最常用的字符串哈希方法,核心是给字符串中不同位置的字符赋予不同权重(基于 “基数” 的幂次),让位置信息影响哈希值,大幅降低冲突率。
对于字符串 s = s[0]s[1]...s[n-1],哈希值计算为:hash = s[0] × base^(n-1) + s[1] × base^(n-2) + ... + s[n-2] × base^1 + s[n-1] × base^0
其中:
s[i] 表示第 i 个字符的 ASCII 值(或其他整数映射); base 是一个预设的基数(通常选大质数,如 31、37、10^9+7 等,避免与字符编码范围重叠); 为防止哈希值过大导致溢出,通常会对一个大质数 mod 取模(如 10^9+7、2^61-1),最终结果为 hash % mod。 示例
以 base=31,mod=10^9+7 为例,计算 “abc” 的哈希值:
s[0] = 'a' = 97,权重 31^2 = 961 → 97×961 = 93217; s[1] = 'b' = 98,权重 31^1 = 31 → 98×31 = 3038; s[2] = 'c' = 99,权重 31^0 = 1 → 99×1 = 99; 总和 = 93217 + 3038 + 99 = 96354 → 哈希值 = 96354 % (10^9+7) = 96354。 而 “cba” 的计算为:
99×961 + 98×31 + 97×1 = 95139 + 3038 + 97 = 98274 → 与 “abc” 的哈希值不同,无冲突。 除此之外还有经典的哈希函数DJB2 与 SDBM,这两种是工业界广泛使用的字符串哈希函数,由实践验证具有低冲突率和高计算效率,常用于哈希表、数据库索引等场景。
DJB2 哈希函数:hash = 5381(初始值);hash = hash * 33 + ASCII(s[i])(迭代)
特点:5381 是一个经过验证的优质初始值,33 是高效的乘数(33 = 32 + 1,可优化为 hash << 5 + hash + c),冲突率极低。
SDBM 哈希函数:hash = 0(初始值);hash = hash * 65599 + ASCII(s[i])(迭代)
特点:65599 是大质数,分布性优于小基数,适合长字符串,与 DJB2 并称 “工业级标准”。
优点:
适用范围广:可处理任意范围的关键字(整数、字符串、对象等,只需转为整数),无论关键字范围大小; 地址范围可控:哈希地址固定在 0~m-1,桶数组大小 m 可灵活设置,避免空间浪费; 实现简单:取模运算在计算机中高效(尤其 m 为 2 的幂时,可用位运算 key & (m-1) 替代,速度更快); 可通过 m 优化冲突率:选择合适的 m(如质数)可大幅降低冲突概率。 缺点:
存在哈希冲突:由于关键字范围远大于 m,必然存在不同关键字映射到同一地址的情况,需配合冲突解决方法(如链地址法); m 的选择敏感:若 m 选择不当(如偶数、小合数),会导致哈希值分布不均,冲突率激增; 对字符串等非整数关键字需额外转换:需先将非整数关键字转为整数(如字符串哈希),增加少量计算开销。 适用场景
除留余数法是工业界最通用的哈希函数构造方法,几乎适用于所有场景,尤其是:
关键字范围大且不连续(如用户 ID、订单号,可能是 1~10^9 的整数); 关键字为非整数类型(如字符串、对象,需先转为整数); 动态数据场景(数据量未知或会增长,可通过扩容调整 m 维持性能)。 典型应用:Java HashMap(底层用 key & (m-1) 替代取模,m 为 2 的幂)、Python dict、Redis 哈希表等。
2.3 其他方法 除了直接定址法与除留余数法外,还有许多成熟的哈希函数,这里再简单介绍几种:
乘法散列法:
乘法散列法对哈希表⼤⼩m没有要求,他的⼤思路第⼀步:⽤关键字 K 乘上常数 A (0<A<1),并抽取出 k * A 的⼩数部分。第⼆步:后再⽤m乘以k * A 的⼩数部分,再向下取整。
h(key) = floor(M × ((A × key)%1.0)) 其中floor表⽰对表达式进⾏下取整,A∈(0,1),这⾥最重要的是A的值应该如何设定,Knuth认为 A = ( 5 - 1)/2 = 0.6180339887.... (⻩⾦分割点)⽐较好。
乘法散列法对哈希表⼤⼩M是没有要求的,假设m为1024,key为1234,A = 0.6180339887, A * key = 762.6539420558,取⼩数部分为0.6539420558, M×((A×key)%1.0) = 0.6539420558*1024 = 669.6366651392,那么h(1234) = 669。
全域散列法:
如果存在⼀个恶意的对⼿,他针对我们提供的散列函数,特意构造出⼀个发⽣严重冲突的数据集,⽐如,让所有关键字全部落⼊同⼀个位置中。这种情况是可以存在的,只要散列函数是公开且确定的,就可以实现此攻击。解决⽅法⾃然是⻅招拆招,给散列函数增加随机性,攻击者就⽆法找出确定可以导致最坏情况的数据。这种⽅法叫做全域散列法。
hab(key) = ((a × key + b)%P )%M,P需要选⼀个⾜够⼤的质数,a可以随机选[1,P-1]之间的任意整数,b可以随机选[0,P-1]之间的任意整数,这些函数构成了⼀个P * (P-1)组全域散列函数组。假设P=17,M=6,a = 3, b = 4, 则 h34>(8) = ((3 × 8 + 4)%17)%6 = 5。
需要注意的是每次初始化哈希表时,随机选取全域散列函数组中的⼀个散列函数使⽤,后续增删查改都固定使⽤这个散列函数,否则每次哈希都是随机选⼀个散列函数,那么插⼊是⼀个散列函数,查找⼜是另⼀个散列函数,就会导致找不到插⼊的key了。
上⾯的⼏种⽅法是《算法导论》中讲解的⽅法。《殷⼈昆 数据结构:⽤⾯向对象⽅法与C++语⾔描述 (第⼆版)》和 《[数据结构(C语⾔版)].严蔚敏_吴伟⺠》等教材型书籍上⾯还给出了平⽅取中法、折叠法、随机数法、数学分析法等,这些⽅法相对更适⽤于⼀些局限的特定场景,有兴趣可以去看看这些书籍。
- 哈希冲突 哈希冲突的本质是 “Key 的取值范围” 与 “哈希表大小” 不匹配:
Key 的取值范围是无限或极大的(如所有整数、所有字符串、所有对象); 哈希表的大小是有限的(受内存限制,不可能无限大,如初始大小 16、32、64)。 根据 “鸽巢原理”(n 个鸽子放入 m 个鸽巢,n>m 时至少有一个鸽巢有 2 只鸽子),当存储的 Key 数量超过哈希表大小时,必然出现多个 Key 映射到同一位置的情况。
实践中哈希表⼀般还是选择除法散列法作为哈希函数,当然哈希表⽆论选择什么哈希函数也避免不了冲突,那么插⼊数据时,如何解决冲突呢?主要有两种两种⽅法,开放寻址法和链地址法。
3.1 开放寻址法(Open Addressing) 不使用额外数据结构(如链表),当发生冲突时,**按固定规则在数组中寻找下一个空的位置 **,将 Key-Value 对存入空位置。查询时,若当前位置的 Key 不匹配,同样按规则继续查找,直到找到目标 Key 或空位置(表示 Key 不存在)。
常见的 “寻址规则”:
线性探测(Linear Probing):冲突时,依次检查下一个位置(H_i(Key) = (H(Key) + i) % m,i=0,1,2...)
从发⽣冲突的位置开始,依次线性向后探测,直到寻找到下⼀个没有存储数据的位置为⽌,如果⾛到哈希表尾,则回绕到哈希表头的位置。
h(key) = hash0 = key % M , hash0位置冲突了,则线性探测公式为:hc(key, i) = hashi = (hash0 + i) % M, i = {1, 2, 3, ..., M - 1} ,因为负载因⼦⼩于1,则最多探测M-1次,⼀定能找到⼀个存储key的位置。
线性探测的⽐较简单且容易实现,线性探测的问题假设,hash0位置连续冲突,hash0,hash1,hash2位置已经存储数据了,后续映射到hash0,hash1,hash2,hash3的值都会争夺hash3位置,这种现象叫做群集/堆积。下⾯的⼆次探测可以⼀定程度改善这个问题。
下⾯演⽰ {19,30,5,36,13,20,21,12} 等这⼀组值映射到M=11的哈希表中的过程:
二次探测(Quadratic Probing):冲突时,按平方规律寻找下一个位置(H_i(Key) = (H(Key) + i²) % m),避免线性探测的 “连续聚集”;
从发⽣冲突的位置开始,依次左右按⼆次⽅跳跃式探测,直到寻找到下⼀个没有存储数据的位置为⽌,如果往右⾛到哈希表尾,则回绕到哈希表头的位置;如果往左⾛到哈希表头,则回绕到哈希表尾的位置;
h(key) = hash0 = key % M , hash0位置冲突了,则⼆次探测公式为: hc(key, i) = hashi = (hash0 ± i2) % M, i = {1, 2, 3, ..., }
⼆次探测当 hashi = (hash0 - i2) % M 时,当hashi<0时,需要hashi += M。
双重哈希(Double Hashing):冲突时,用第二个哈希函数计算步长(H_i(Key) = (H1(Key) + i×H2(Key)) % m),进一步降低聚集概率。
第⼀个哈希函数计算出的值发⽣冲突,使⽤第⼆个哈希函数计算出⼀个跟key相关的偏移量值,不断往后探测,直到寻找到下⼀个没有存储数据的位置为⽌。
**优点:**无需额外空间存储链表;缓存友好(数据存储在连续数组中,减少 IO)。
**缺点:**易产生 “聚集效应”(线性探测时,连续桶被占用,后续冲突概率更高);删除数据需标记 “已删除”,否则会断裂查找链。
**典型应用:**Redis 字典(部分场景)、Clang 编译器的哈希表、早期哈希表实现。
简单的代码实现: 开放寻址法解决冲突不管使⽤哪种⽅法,占⽤的都是哈希表中的空间,始终存在互相影响的问题。所以对于开放寻址法,我们选择线性探测实现即可: ————————————————
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。