散列表:散列函数的构造方法、处理散列冲突的方法、散列表的查找性能分析

散列表,也叫哈希表。这东西我刚开始学的时候觉得挺玄乎,后来在项目中做缓存系统,才发现它简直是性能救星。说白了,散列表就是通过一个函数,把关键字直接映射到存储位置。理想情况下,查找时间复杂度能到 O(1)。

但现实没那么完美。你想想看,如果两个不同的关键字映射到了同一个位置,怎么办?这就是散列冲突。今天我们就来聊聊散列函数怎么设计、冲突怎么处理、以及散列表的查找性能到底怎么样。

一、散列函数的构造方法

散列函数的目标很简单:计算快、分布均匀。我见过不少新手一上来就写个取模完事,结果数据一分布不均匀,性能直接崩了。

1. 直接定址法

取关键字本身或它的线性函数作为散列地址。比如 H(key) = a * key + b。这种方法简单,但只适合关键字分布连续的情况。我在做学号管理系统时用过,因为学号是连续的,效果不错。

2. 除留余数法

最常用的方法。公式:H(key) = key % p。这里的 p 一般选一个不大于表长的质数。为什么?因为质数能减少冲突。我曾经踩过坑,选了 p = 100,结果所有 key 都是 10 的倍数,全挤到 0、10、20... 这几个位置上了。

关键点: p 的选择直接影响散列质量。一般建议 p 为小于或等于表长的最大质数。

3. 数字分析法

分析关键字的各位数字分布,选取分布较均匀的几位作为散列地址。比如电话号码,后四位往往分布更随机。我在做用户 ID 映射时用过这招,效果比直接取模好不少。

4. 平方取中法

先计算关键字的平方,然后取中间几位。为什么取中间?因为平方的中间几位受关键字各位的影响,分布更均匀。适合关键字位数不多的情况。

5. 折叠法

把关键字分成几段,然后叠加求和。比如关键字 123456789,分成 123、456、789 三段,加起来得到 1368,再取后几位。这种方法适合关键字位数很长的情况。

二、处理散列冲突的方法

冲突是散列表绕不开的问题。我刚开始做项目时,天真地以为选个好函数就万事大吉,结果数据量一上来,冲突还是啪啪打脸。下面说两种主流方法。

1. 开放定址法

冲突了?那就再找个空位。具体有几种探测方式:

  • 线性探测法: 冲突后依次往后找。简单,但容易产生「堆积」现象——冲突的数据挤成一团。
  • 二次探测法: 探测序列是 1²、-1²、2²、-2²... 能缓解堆积,但可能找不到空位。
  • 伪随机探测法: 用随机数决定步长。嗯,这个我实际用得少,因为随机数生成也有开销。
注意: 开放定址法删除元素时不能直接删,得做个「已删除」标记。否则会断掉探测链。我曾经因为这个 bug 排查了一下午。

2. 链地址法

每个散列位置挂一个链表。冲突的元素直接链在后面。这是我最喜欢的方法,实现简单,删除也方便。

// 链地址法节点定义
typedef struct Node {
    int key;
    struct Node *next;
} Node;

// 散列表
typedef struct {
    Node **heads;  // 每个槽位一个链表头
    int size;
} HashTable;

链地址法的缺点是,如果某个槽位链表太长,查找就退化成 O(n)。所以一般会设定一个负载因子,超过阈值就扩容。

三、散列表的查找性能分析

散列表的查找性能,主要看三个因素:散列函数、冲突处理方法、负载因子。负载因子 α = 表中元素个数 / 表长。α 越大,冲突概率越高,查找越慢。

1. 平均查找长度(ASL)

对于链地址法,成功查找的 ASL 大约是 1 + α/2。不成功查找的 ASL 大约是 α。对于开放定址法,情况复杂一些,但大致趋势是 α 越大,ASL 增长越快。

经验值: 我一般把负载因子控制在 0.7 左右。超过 0.8 性能就开始明显下降,超过 0.9 基本就崩了。

2. 查找性能对比

方法 成功查找 ASL 不成功查找 ASL 适用场景
线性探测 约 (1 + 1/(1-α))/2 约 (1 + 1/(1-α)²)/2 表长较大,α 较小
二次探测 略优于线性探测 略优于线性探测 需要避免堆积
链地址法 约 1 + α/2 约 α 插入删除频繁

3. 性能优化建议

  • 选择合适的表长: 最好是质数,或者 2 的幂(配合位运算)。我习惯用质数,因为分布更均匀。
  • 动态扩容: 当负载因子超过阈值时,重新分配一个更大的表,重新散列所有元素。这个过程开销大,但能保证长期性能。
  • 避免退化: 如果发现某个槽位链表特别长,可以考虑换散列函数,或者用红黑树代替链表(Java 8 的 HashMap 就是这么干的)。
小技巧: 如果你不确定选什么散列函数,先用除留余数法 + 链地址法。这是最稳妥的组合,我大部分项目都这么用。

四、知识体系图

下面这张图总结了散列表的核心知识点,方便你快速回顾。

散列表 散列函数构造 直接定址法 除留余数法 数字分析法 平方取中法 折叠法 冲突处理方法 开放定址法 线性探测 二次探测 伪随机探测 链地址法 链表实现 动态扩容 查找性能分析 平均查找长度 成功 ASL 不成功 ASL 影响因素 负载因子 α 散列函数质量 冲突处理方法 核心:散列函数 + 冲突处理 + 负载因子控制 目标:O(1) 查找,均匀分布,避免退化

散列表这东西,用好了是神器,用不好就是灾难。我见过有人把负载因子设到 0.95,结果查找比链表还慢。记住一句话:散列函数要均匀,冲突处理要得当,负载因子要控制。做到这三点,你的散列表基本就稳了。


公众号:蓝海资料掘金营,微信deep3321