10. 字符串哈希:从需求到实现

字符串哈希,说白了就是把一个字符串映射成一个整数。你可能会问:好好的字符串,干嘛非要转成数字?

我刚开始学哈希表的时候也有这个疑问。后来在做一个关键词过滤系统时,发现用字符串直接做查找,效率低得让人抓狂。每次比较都要逐字符比对,数据量一上来,CPU 都在哀嚎。这时候我才意识到——字符串哈希,是解决这个问题的关键。

为什么需要字符串哈希?

哈希表的核心是「键到索引的映射」。如果键是整数,直接取模就行。但现实世界中的键,大多是字符串——用户名、URL、IP 地址、文件路径……

你想想看,如果哈希表直接拿字符串当键,每次查找都要做字符串比较。两个字符串哪怕只差一个字符,也得从头比到尾。这效率,能忍?

字符串哈希的作用,就是把任意长度的字符串,压缩成一个固定长度的整数。然后这个整数就可以作为哈希表的键,或者直接用来计算桶索引。

核心需求:字符串哈希算法需要满足三个条件——

  • 高效:计算速度要快,不能比字符串比较还慢
  • 均匀:不同字符串尽量映射到不同的哈希值
  • 确定性:同一个字符串,每次计算的结果必须相同

三种经典的字符串哈希算法

我这些年用过的字符串哈希算法不下十种,但真正经得起考验的,也就那么几个。下面这三个是我个人最常用的,也是工业界公认的经典。

BKDR 哈希

BKDR 哈希的思路很简单:把字符串当成一个「以某个基数为底的数值」。比如字符串 "abc",可以看成是 a * 131^2 + b * 131 + c。这个 131 就是种子(seed)。

为什么选 131?嗯,这是个经验值。我试过 31、131、1313,发现 131 在大多数场景下碰撞率最低。当然,你也可以用 31,效果也不错。

unsigned long BKDRHash(const char *str) {
    unsigned long hash = 0;
    unsigned long seed = 131; // 31, 131, 1313 都可以
    while (*str) {
        hash = hash * seed + (*str++);
    }
    return hash;
}

这段代码看起来简单,但有个细节要注意:hash * seed 可能会溢出。不过没关系,C 语言的无符号整数溢出是定义良好的行为,相当于自动取模 2^32。这反而帮我们做了哈希值的范围控制。

我的经验:BKDR 哈希适合处理中短字符串,比如 URL、文件名。我在做静态资源缓存系统时用的就是它,碰撞率控制在 0.1% 以内。

DJB 哈希

DJB 哈希是 Daniel J. Bernstein 设计的。它的核心思想是:每次迭代都让哈希值乘以 33,然后加上当前字符的 ASCII 码。

unsigned long DJBHash(const char *str) {
    unsigned long hash = 5381;
    int c;
    while ((c = *str++)) {
        hash = ((hash << 5) + hash) + c; // hash * 33 + c
    }
    return hash;
}

注意看这行:(hash << 5) + hash。左移 5 位相当于乘以 32,再加一次自身,就是乘以 33。这种写法比直接写 hash * 33 快一点——虽然现代编译器会帮你优化,但写出来更显功力。

为什么初始值是 5381?我记得 DJB 本人说过,这个数字的二进制形式「看起来比较随机」。嗯,有时候算法设计就是这么玄学。

适用场景:DJB 哈希在短字符串(比如 8 个字符以内)上表现极好。我在做命令解析器时用过它,几千条命令零碰撞。

FNV 哈希

FNV 哈希的全称是 Fowler–Noll–Vo 哈希。它有两种变体:FNV-1 和 FNV-1a。区别在于,FNV-1 先乘后异或,FNV-1a 先异或后乘。

// FNV-1a 版本(推荐)
unsigned long FNV1aHash(const char *str) {
    unsigned long hash = 2166136261UL;
    while (*str) {
        hash ^= (unsigned char)(*str++);
        hash *= 16777619UL;
    }
    return hash;
}

FNV 哈希的种子和乘数都是精心挑选的质数。2166136261 和 16777619,这两个数字看起来随意,但经过大量测试,它们能让哈希值分布得特别均匀。

我曾经在日志分析系统中对比过这三种算法。处理 100 万条 URL 时,FNV-1a 的碰撞率最低,只有 BKDR 的 60% 左右。但 BKDR 的计算速度更快,比 FNV 快大约 15%。

算法 速度(相对) 碰撞率(相对) 推荐场景
BKDR 中等 中长字符串、通用场景
DJB 很快 较低 短字符串、命令键
FNV-1a 中等 很低 对碰撞敏感的场景

字符串哈希的碰撞问题

说白了,哈希碰撞是躲不开的。因为哈希值空间有限(比如 32 位整数只有 4G 个可能值),而字符串的数量是无限的。根据鸽巢原理,碰撞必然存在。

但真正让我头疼的,不是理论上的碰撞,而是实际中的「恶意碰撞」。我在做 Web 防火墙时遇到过一种攻击:攻击者故意构造大量哈希值相同的字符串,让哈希表退化成链表,导致服务器 CPU 飙升到 100%。

避坑指南:我曾经在一个高并发项目中,只用了一个简单的 BKDR 哈希。结果上线第二天,就被恶意请求打挂了。后来我加了随机种子——每次进程启动时,随机生成一个种子值。这样攻击者无法预知哈希函数的行为,也就没法构造碰撞了。

处理碰撞的常见策略有几种:

  • 链地址法:每个桶里挂一个链表。简单,但链表太长时性能下降。
  • 开放地址法:碰撞了就往下一个空位放。适合数据量不大的场景。
  • 双重哈希:用两个不同的哈希函数。第一个算位置,第二个算步长。

我个人习惯用链地址法,配合一个阈值——当链表长度超过 8 时,自动转换成红黑树。这样既保证了小数据量时的简单性,又防止了大数据量时的性能退化。

知识结构总览

下面这张图,把字符串哈希的核心逻辑串起来了。你可以看到,从原始字符串到最终的哈希表索引,中间经历了哪些步骤。

字符串哈希核心流程 原始字符串 选择哈希算法 BKDR 哈希 DJB 哈希 FNV 哈希 32位哈希值

从图中可以看到,字符串哈希的流程并不复杂:拿到字符串,选一个算法,算出一个整数。但真正的挑战在于——选对算法,并且处理好碰撞。

我个人建议:如果你刚开始做项目,先用 BKDR 哈希,它简单、稳定、够用。等你对性能有更高要求时,再根据数据特征切换到 DJB 或 FNV。记住,没有万能的哈希算法,只有最适合你场景的算法。


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