ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

散列函数六种构造方法详解:从原理到工程选型实战指南

散列函数六种构造方法详解:从原理到工程选型实战指南 1. 散列函数到底是什么为什么设计它这么讲究散列函数Hash Function是哈希表Hash Table这座大厦的地基。数据结构这门课里线性表、树、图讲究的是“比较”查找一个元素要么从头到尾比要么二分比要么在树里沿着路径比复杂度基本都挂在O(log n)到O(n)这个区间。哈希表不同它想做到的是给定一个关键字key直接算出它存在哪个位置而不是去“找”。这个“直接算出位置”的函数就是散列函数。我当年学到这里最直观的感受是前面学了那么多查找算法都是在“存储结构”上做文章哈希表直接把“关键字”和“存储地址”建立映射思路完全是另一个维度的。但这里就引出一个问题——映射关系怎么设计才科学这一篇就把构造散列函数的六种经典方法一次性讲透直接定址法、数字分析法、平方取中法、折叠法、除留余数法、随机数法。每一种我都会从原理、计算过程、适用场景到踩坑经验展开最后再聊一聊实际工程里怎么选型。写给正在准备考研、期末复习或者工作中突然要用哈希表却不知道怎么选散列策略的人。在展开六种方法之前有一个观念必须先立住散列函数的设计目标不是“算得快”这么简单而是“算得快 分布均匀 冲突少”三位一体。你想想如果两个不同的关键字被映射到了同一个地址就叫“冲突”collision冲突发生了就得用额外的手段去处理处理冲突是要花时间花空间的。一个设计糟糕的散列函数可以让哈希表退化成一条链表查找复杂度直接掉回O(n)那哈希表就名存实亡了。所以评价一个散列函数好坏核心就三句话计算简单不能比直接比较关键字还复杂地址分布均匀让关键字尽可能均匀地落在整个表空间中冲突尽可能少减少后续处理冲突的开销。这三句话就是下面的六种方法共同的“验收标准”。带着这个标准去理解每一种方法你会发现它们的取舍和适用场景其实非常清晰。2. 六种构造方法逐一拆解原理、计算与适用场景2.1 直接定址法最朴素也最挑数据直接定址法的函数形式非常简单H(key) a × key b其中a和b是常数。当a1b0时就是H(key) key也就是说关键字本身就直接作为存储地址。举个例子你要统计一个班60个学生的C语言成绩成绩范围是0到100分你想知道每个分数有多少人。这时候可以直接开一个长度为101的数组下标就是分数。考了85分的同学就记录在下标85的位置。这就是直接定址法最典型的应用场景——关键字的取值范围连续且密度高。优点非常明显计算开销极小一次乘法和加法几乎是常数时间不会有冲突因为每个关键字都映射到了唯一的位置实现最简单不需要任何额外处理。但它的致命短板也一眼可见关键字的取值范围必须连续且表长要能覆盖最大关键字。假设关键字是员工工号范围从1到99999但实际只有100个员工你总不能开一个10万长度的数组吧那空间浪费就到了不可接受的程度。所以说直接定址法适合“关键字分布稠密”的场景比如年龄、成绩、很小范围内的编号。一旦数据稀疏这个方法就要让位给下面的方法。还有一个细节容易被忽略——a的取值。当关键字不是从0开始时用H(key) key - base的形式会更省空间比如学号从2023001开始你可以让H(key) key - 2023000把地址空间压到最小。这其实就是a1、b为负数的直接定址法变体实际操作中很常用。2.2 数字分析法先观察再动手的“数据分析派”数字分析法的思路和直接定址法完全不一样。它不做任何数学变换而是直接分析关键字的各位数字分布挑出分布最均匀的几位组合成哈希地址。我举个例子你就明白了。假设你手头有一批电话号码形如手机号后四位完整号码示例3781138****37810523139****05238891137****8891如果直接用整个手机号作为关键字范围太大了。仔细观察会发现手机号的前几位比如138、139这些号段分布很不均匀集中在少数几个号段上而后四位反而像是随机分布的。那么就可以取后四位作为哈希地址。更进一步如果这四位里还有某一位分布仍然集中就再跳过那一位。数字分析法的核心操作流程是这样的先收集所有关键字的样本统计每一位上各个数字出现的频率找出分布均匀的若干位或者几位进行移位叠加用这些位组合成哈希地址。这个方法的优点在于它完全是数据驱动的灵活性高能根据实际数据的分布特征定制散列函数。缺点也同样明显——你得先有一批数据样本做统计分析如果数据是动态变化的、分布特性会变那分析出来的结果可能很快就失效了。一个典型的应用场景是学生学号。很多学校的学号是“入学年份 学院编号 专业编号 班级序号 个人序号”的结构其中入学年份分布在近几个值上学院、专业编号也相对集中而最后的个人序号反而是分布最均匀的几位。这时候用数字分析法提取学号的后几位作为哈希地址效果就很好。2.3 平方取中法一平方中间位就“雨露均沾”了平方取中法Mid-Square Method的思路非常巧妙它利用了乘法的一个天然性质一个数平方的中间几位受到这个数所有位的共同影响。具体操作分三步将关键字key平方取出平方结果中间的若干位位数取决于表长中间这些位就是哈希地址。我拿个具体数字走一遍。假设key 1234表长是1000也就是哈希地址需要3位十进制数1234² 1522756取中间3位1522756中间的3位是227所以H(1234) 227再试一个key 5678表长还是10005678² 32239684取中间3位32239684中间3位是396不对32239684是8位数中间3位应该是239去掉前两位32和后两位84得到239H(5678) 239你会发现平方运算像一台混音器把关键字的每一位信息都“搅拌”到了平方结果的中间位上。这就很好地解决了直接定址法“分布不匀”的问题——即使关键字本身是连续的比如101, 102, 103平方后分别是10201, 10404, 10609中间取两位就是02, 04, 06地址也能散开。平方取中法的优点实现简单地址分布相对均匀对关键字分布特征不敏感。缺点表长必须是10的幂十进制下或2的幂二进制下否则取中位会比较别扭而且当关键字位数很少时平方后的中间位可能不够均匀需要验证。一个实际的坑平方取中法对“取中间几位”的规则要定义清楚。比如平方后是8位数取哪几位叫“中间”通常的做法是先确定取n位然后在平方结果的前后各去掉(总位数-n)/2位。总位数是偶数时可以约定偏左或偏右但无论如何同一个表里规则必须一致。2.4 折叠法把大数切碎再叠加位数不够就拼“手工和”折叠法Folding Method是处理“关键字位数太长”的利器。它的思想是把关键字分割成几个等长的片段然后将这些片段叠加求和以得到的和作为哈希地址。折叠法有两种常见方式移位叠加Shift Folding将分割后的各段直接相加间界叠加Boundary Folding将奇数段和偶数段反向折叠后相加类似于把纸折叠后对齐求和。还是举例说明。假设key 9876543210我们要构造一个4位的哈希地址。先把关键字从后往前分割成若干段每段4位98 | 7654 | 3210移位叠加7654 3210 98 高位不足4位前面补0 10962取后4位0962所以H(9876543210) 0962间界叠加7654 0123 第二段3210反向折叠成0123 98 7875取后4位7875H(9876543210) 7875折叠法的优点能处理任意长度的关键字而且实现不算复杂。缺点如果关键字各段之间存在规律性关联比如每段都是递增的折叠后可能出现地址聚集分布均匀性需要实际测试。折叠法适合的典型场景是身份证号、银行卡号、长字符串转成的数字这类“又长又没有明显分布规律”的数据。我在实际中用折叠法处理过一批序列号效果不错但有个重要前提——分割的段长一定要根据表长来确定段太长会让叠加后的和溢出段太短又浪费了关键字的分布信息。2.5 除留余数法工程界的默认选择但p的选取是门学问除留余数法Division Method是六种方法里实际使用率最高的一种也是我看过的绝大多数哈希表实现Java的HashMap、Redis的哈希槽、各种自建哈希表都在用的方法。函数形式极其简单H(key) key mod p其中p是一个不大于表长m的正整数。比如表长m100取p97那么key mod 97的结果就是哈希地址。为什么它这么流行因为式子简单、计算快、分布均匀性可以通过p的选取来控制。但这里有个几乎每个初学者都会踩的坑——p不能随便选。p的选取有一条经典经验规则p应取不大于表长m的质数或者至少是一个不含小于20的质因数的合数。为什么我先给你看一个反例。假设表长m100你偷懒取了p50那么所有key的哈希地址只能落在0~49这50个位置上另一半表空间完全浪费了。更糟的是如果key本身都是偶数那么key mod 50也全是偶数冲突率直接暴涨。再看一个正例。假设m100取p97那么key分布在整个整数范围内时key mod 97的结果会比较均匀地散布在0~96之间。因为97是质数它与很多常见的key增量比如2、3、5、10等互质可以避免因为增量与p有公因子而导致的地址聚集。为什么要选质数一个直观的解释是如果p含有一个较小的质因子q那么所有能被q整除的key会全部映射到能被q整除的地址上这些地址就被“过度占用”。而质数除了1和自身没有其他因子可以最大程度避免这种系统性偏移。除留余数法的补充说明p通常取小于表长m的最大质数。比如表长1000p取997表长1024p取10211024不是质数但如果你用1024做模数在二进制计算机里可以用移位运算优化这也是一种工程取舍p等于表长也是可以的但这时要求关键字分布足够随机否则容易产生聚集实际工程中如果支持位运算表长为2的幂可以选择让p m - 1一个质数这样既保证了质数性质又能让地址空间充分利用。我用一个表格简单总结不同p的效果感受p的取值典型问题偶数key为偶数时地址全为偶数冲突严重5的倍数key尾数为0或5时地址聚集小于10的质数地址值范围过小空间浪费接近m的质数分布均匀冲突最少这个方法是“下限低上限也高”——用得好是神器用得随意全是坑。2.6 随机数法把命运交给伪随机数随机数法Random Method的思想也非常简洁H(key) random(key)这里不是每次调用都生成一个不同的随机数而是以key作为随机数生成器的种子seed生成一个稳定的伪随机数作为地址。因为伪随机数生成算法是确定的同样的种子永远生成同样的随机序列所以同一个key每次计算得到的地址是一致的。这个方法最大的优点当关键字长度不等时非常适用。比如你要对一批长短不一的字符串做哈希直接取模不方便但把它们作为种子去生成随机数长度的影响就被“搅匀”了。但这方法在面试和考试里经常被讨论的就是一个争议点伪随机数生成也是有开销的如果随机数算法本身比较复杂那“计算简单”这条评价标准就被违背了。另外伪随机数的分布质量取决于具体的生成算法不同算法效果差异很大需要实测验证。一个我自己的经验随机数法在实际工程中并不常用但有一种变体非常实用——把随机数法和其他方法结合。比如先用除留余数法算出一个初始地址再用一个伪随机数序列作为“冲突探测序列”也就是随机探测再散列这在开放定址法的实现中很常见。3. 方法选型没有最好的只有最合适的六种方法都讲完了但有一个问题回避不了真到实际写代码时到底用哪一个我的建议是不要背“XX方法适合XX场景”的条条框框而是照着三条主线去推理关键字的分布特征是什么如果是连续整数范围又不大的——直接定址法几乎是最优解如果数据稀疏但位数很长——折叠法、平方取中法可以考虑如果关键字有复杂的内部结构比如学号带院系编号——数字分析法最对口如果什么信息都不清楚——除留余数法是那个“最不坏”的选项。表长m有什么约束m是2的幂想在工程里用位运算优化——除留余数法配合位运算m是10的幂比如1000、100000——平方取中法实现起来很自然m没有特殊约束——除留余数法取接近m的最大质数。冲突之后的处理方案是什么用链地址法拉链法——散列函数设计差一点也能扛住压力被“分摊”到了链表上用开放定址法——散列函数必须设计得非常好因为一旦聚集探测序列会让问题雪上加霜用再散列法——那还要考虑第二个散列函数怎么设计随机数法就有了用武之地。为了更直观我把六种方法的核心信息整理成一个速查表方法函数形式优点缺点典型场景直接定址法H(key) a×key b无冲突、实现极简要求关键字连续且范围小年龄、成绩统计数字分析法取关键字分布均匀的若干位数据驱动、灵活需要样本分析、数据变化后失效学号、电话号码平方取中法H(key) 中间位(key²)分布均匀、实现简单需要合适的表长10或2的幂关键字连续但中间位分散折叠法各位段叠加求和可处理超长关键字各段关联性强时分布不佳身份证号、银行卡号除留余数法H(key) key mod p通用性最强、计算快p的选取很讲究通用哈希表、HashMap随机数法H(key) random(key)适合不等长关键字随机数算法开销不确定不等长字符串、探测序列4. 工程实践中的几个关键问题与避坑指南4.1 散列函数和冲突处理是“组合拳”不能分开看很多教材把“散列函数的构造”和“冲突处理的方法”分成两章讲我学的时候也这么分但后来做实际项目才发现这两个决策必须是同时做出的。举一个真实的例子。我在做一个缓存模块时表长定的是1024采用了除留余数法取p1021。从散列函数本身看1021是质数分布性没问题。但缓存模块的key有很强的业务特征——大部分key的数值是递增的比如计数器它们的差正好是1021。结果这些key全部映射到了相同的地址上冲突率暴涨。后来我把p改成了1023不含小于20的质因数叠加链地址法处理冲突就明显缓解了。这个案例说明散列函数设计得再好也挡不住数据特征和参数之间的“共振”。所以在实际工程里你选的不仅是散列函数还有冲突处理策略、表长、装载因子这一整套要一起考虑。4.2 装填因子一个必须盯紧的指标装填因子α 表中记录数 / 表长。它描述的是哈希表的“拥挤程度”。α越大冲突概率越高查找效率越低α越小空间浪费越多。经验值是这样的α ≤ 0.5开放定址法表现良好尤其是线性探测α ≤ 0.75链地址法的平均查找长度还在可接受范围这也是Java HashMap默认装填因子0.75的原因α 0.85无论什么方法冲突都会显著拖慢性能这时候就该考虑扩容了。我在项目里通常的做法是设定一个阈值比如0.7达到阈值就触发“再散列”rehash也就是把表容量扩大通常是扩到原来的两倍左右然后重新计算所有已有元素的哈希地址。这一步是工程必备的否则随着数据量增大哈希表性能会像雪崩一样下滑。4.3 字符串怎么散列六种方法之外的现实问题六种方法里的“关键字”大多被说成是数字但实际工程里哈希表的key大部分是字符串。字符串不能直接取模那怎么办一个最简单的思路是先把字符串转成一个整数比如把每个字符的ASCII码加权求和再用除留余数法。但这只是“能跑”分布质量不见得好。业界沉淀了一批专为字符串设计的哈希函数比如BKDRHash核心是 H H × 131 ch据说“131”这个乘数对英文字符串分布效果极佳APHash根据字符位置的奇偶性分别用不同方式累加分布更均匀DJBHash经典中的经典核心是 H H × 33 chSDBMHashH ch (H 6) (H 16) - H常用于开源数据库。我自己实测下来处理普通英文字符串时BKDRHash和DJBHash的冲突率都不错速度也快。但这里有一个必须强调的点这些“名家哈希”也不是万能的用在中文等宽字符上效果可能要重新验证因为它本质上是把每个字符当成一个数值来参与运算字符编码的分布特征会影响最终结果。还有一种场景值得注意——对抗性输入。如果你的哈希表暴露在用户可控的输入面前比如HTTP请求参数攻击者可能故意构造大量哈希值相同的key让哈希表执行“碰撞攻击”把O(1)查询拖成O(n)。这时候普通的散列函数就不够用了工程上有几种应对办法比如在哈希函数里掺入随机的种子盐seed让攻击者无法预测最终的哈希值。Java 8之后的HashMap在链表长度超过8时会转成红黑树本质上也是一种缓解手段。4.4 开放定址法与链地址法的选择这个问题我在面试中经常被问到实际做项目也确实绕不开。**链地址法拉链法**的思路是所有映射到同一地址的元素挂在一个链表或者红黑树上。它的优点是实现简单、对散列函数要求低、删除操作方便缺点是链表节点需要额外存储指针有内存开销。开放定址法的思路是冲突了就到下一个空闲位置去找。它不需要额外指针空间利用率高但删除元素时不能物理删除否则会截断探测序列只能打“删除标记”。线性探测容易产生“堆积”二次探测和双重散列能缓解但实现复杂度更高。我的选型经验数据量不大且能预估上限——开放定址法特别是线性探测简单又好用数据量动态增长、删除频繁——链地址法更稳妥怕被恶意构造碰撞——链地址法配合二叉树化像Java 8那样最保险。5. 常见问题排查散列函数“翻车”现场实录这里把我在学习和工作中遇到过的典型问题整理成速查表每个问题都附上排查思路。现象可能原因排查思路哈希表查找极慢接近O(n)冲突率过高记录实际“最长链表长度”或“探测次数”如果超过阈值就需要重新选散列函数或扩容地址分布严重偏向某个区间p选取不当或取位规则有问题把一批key的哈希值画成直方图看是否有明显的聚集区间同样一批key换台机器结果全部不同散列函数里用了绝对内存地址或系统时间检查是否用了随机种子且没有持久化删除元素后查找异常开放定址法物理删除了元素使用墓碑标记tombstone或改用链地址法面板显示装载因子不高但性能很差散列函数与数据特征“共振”换一个p或换方法用实测分布验证有一个案例特别值得讲。我曾经接手过一个老系统它的哈希表key是一些字符串ID表长4096用除留余数法配p4093。表面上看没问题但线上监控显示查询耗时越来越大。我拉了一批样本数据把key逐个计算哈希地址发现一个诡异的现象——将近40%的key落在了0~255这个区间而其余地址基本空置。排查了半天问题出在字符串ID本身——它前面几个字符是固定的公共前缀转成整数时公共前缀占据了高位的权重导致整数取模结果集中在低位。解决办法也简单把字符串换算成数字的时候做一次“位反转”或者先用一个字符串哈希函数比如BKDRHash打散再取模分布立刻均匀了。这个案例的教训是散列函数设计必须回归到“数据长什么样”这个源头。你以为自己在处理随机字符串实际上字符串内部有极强的结构规律数字分析法说的“先分析数据”在这里完全适用。再有一个高频问题表长到底怎么定。有些人喜欢直接取2的幂这样取模可以用位运算hash (m - 1)做到极致性能。但代价是除留余数法的p没法取质数了pm2^k本身含有一个质因子2对偶数key不友好。解决办法是先用一个质量好的字符串哈希函数搅一遍数据再做位运算取模问题就小很多。这也是很多现代哈希表比如Redis的哈希槽分配的底层思路。6. 我的实操总结从“背方法”到“选方法”的转变学了六种方法之后很长一段时间我也陷入了一个误区——总觉得有一个“最好的散列函数”存在学的时候拼命比较哪个方法“更优”。后来做了足够多的实际项目我才意识到散列函数的设计本质上是一个权衡过程而不存在脱离数据特征和工程约束的绝对最优解。如果你是一个正在准备考试的学生我的建议是六种方法的定义、计算公式、优缺点一定得背熟因为考试就考这些。但更重要的是每种方法都要亲手算几道题——比如给一个key分别用平方取中法、折叠法、除留余数法算一遍算完你会对每种方法的手感有直观认识。我自己当年备考时把教材上每种方法的例题都重算了三遍以上考场上遇到“设计一个散列函数”这种题脑子里会同时跳出好几种方案写起来特别快。如果你是一个工程师我的建议就更直白一些先选除留余数法作为默认方案除非你有明确理由否则不要为了炫技去选冷门方法。选好之后用真实的key样本做一次分布测试——把key跑一遍统计每个桶里的元素个数看看方差是不是正常水平。这一步花不了五分钟但能避免上线后性能翻车。我最后想分享的一个小技巧是散列函数写完别急着定稿一定要做一轮“数据抽样验证”。具体做法是——从线上或测试环境取1万到10万个真实key逐一计算哈希地址然后统计地址是否覆盖了整个表空间每个地址上的元素个数是否接近泊松分布如果某几个桶里堆了几百个其他桶空着那就有问题最大冲突链的长度是否在可接受范围内。这一步听起来很“工程”但其实就是把数据结构课上学到的“散列函数设计评价标准”落实到了具体数据上。我见过太多以“复杂度是O(1)”为荣的哈希表实际跑起来因为冲突太多而慢得像链表。归根结底散列函数的价值不是靠理论证明出来的而是靠数据验证出来的。把这一点想通你对散列函数的理解就真正到位了。
返回列表