字符串的哈希:BKDR 哈希算法

字符串哈希,说白了就是把一个字符串映射成一个整数。你想想看,比较两个字符串是否相等,最笨的办法是一个字符一个字符地比,O(n) 复杂度。但如果我能把每个字符串算出一个「指纹」,直接比两个整数,那就是 O(1) 了。这就是哈希的核心思想。

我个人习惯用 BKDR 哈希算法。为什么?因为它在实际工程中表现非常稳定,冲突率低,实现起来又简单。我在项目中处理过几百万条 URL 去重,用的就是它。

BKDR 哈希的原理

BKDR 算法的思路其实很朴素:把字符串看作一个 进制数。每个字符对应一个数字(比如 ASCII 码),然后乘以一个基数,累加起来。

公式长这样:

hash = 0
for each char c in string:
    hash = hash * seed + c

这里的 seed 是一个质数。为什么用质数?嗯,这里要注意:质数能减少哈希值的周期性重复,说白了就是让分布更均匀。我常用的 seed 是 131、1313、13131 这些。你问为什么是 131?其实没什么玄学,就是经验值,冲突率低。

核心要点: BKDR 的本质就是「字符串转大数,再取模」。seed 选 131 或 1313,模数选一个大质数(比如 10^9+7),基本够用。

代码实现

直接上代码。这是我项目里用过的版本,稍微改了一下拿来教学:

#include <stdio.h>
#include <string.h>

#define SEED 131
#define MOD 1000000007

unsigned long long bkdr_hash(const char *str) {
    unsigned long long hash = 0;
    while (*str) {
        hash = (hash * SEED + (unsigned char)(*str)) % MOD;
        str++;
    }
    return hash;
}

int main() {
    char s1[] = "hello";
    char s2[] = "world";
    printf("hash of '%s': %llu\n", s1, bkdr_hash(s1));
    printf("hash of '%s': %llu\n", s2, bkdr_hash(s2));
    return 0;
}

你看,核心代码就几行。但有个坑:unsigned char 的转换不能省。我曾经在解析 UTF-8 文本时忘了这个,结果中文字符的哈希值全乱套了——因为 char 默认是有符号的,负数的 ASCII 值会搞乱计算。

避坑指南: 字符串中的字符一定要转成 unsigned char 再参与运算。否则遇到高位为 1 的字节(比如中文),会先被当作负数,哈希结果直接崩掉。我曾经因为这个 bug 排查了一下午。

字符串哈希在去重中的应用

去重,说白了就是「来了一个字符串,先算哈希,看哈希表里有没有」。有就是重复,没有就存进去。

我做过一个日志分析工具,每天要处理上亿条 URL。每条 URL 都存字符串?内存扛不住。我的做法是:

  1. 对每条 URL 算 BKDR 哈希(64 位整数)
  2. 用哈希值作为 key,存到一个哈希集合里
  3. 新来的 URL 先算哈希,查集合

这样每条 URL 只占 8 字节,而不是几百字节的字符串。内存直接省了 90% 以上。

小技巧: 如果对冲突率要求极高,可以用「双哈希」——算两个不同的 seed,得到两个哈希值,组合成一个 pair。两个哈希同时冲突的概率,比中彩票还低。

字符串哈希在查找中的应用

查找和去重其实是一回事。但有一个经典场景:子串查找

比如你要在长文本里找某个模式串。暴力法是 O(n*m),用哈希可以做到 O(n)。怎么做?

  • 先算模式串的哈希值
  • 再算文本中每个等长子串的哈希值
  • 比较哈希值,相等再逐字符确认(防止冲突)

这就是 Rabin-Karp 算法 的核心。我当年面试时手写过这个算法,面试官当场就说「嗯,基本功扎实」。

// 滚动哈希:快速计算下一个子串的哈希
// 假设已知 hash(s[i..i+m-1]),求 hash(s[i+1..i+m])
// 公式:new_hash = (old_hash - s[i] * pow_seed) * seed + s[i+m]
// 其中 pow_seed = seed^(m-1) % MOD

这个滚动技巧很实用。你想想看,每次重新算一个子串的哈希是 O(m),但用滚动方式就是 O(1)。处理大文本时,性能差距是数量级的。

冲突处理

哈希不可能没有冲突。BKDR 虽然冲突率低,但你不能假设它为零。我的做法是:

场景 冲突处理方式
去重(允许极低概率误判) 直接信任哈希,不二次确认
精确查找 哈希匹配后,再逐字符比较
布隆过滤器 多个哈希函数,降低误判率

我曾经在做一个缓存系统时,偷懒没做二次确认。结果线上出现了两次哈希冲突,导致两个不同的用户名被当成同一个——用户登录时串号了。嗯,从那以后我再也不敢省略二次确认了。

SVG 流程图:BKDR 哈希与去重/查找的核心逻辑

输入字符串 BKDR 哈希计算 hash = hash * 131 + c 去重场景 查哈希集合 存在 → 重复 | 不存在 → 存入 查找场景 比较哈希值 匹配 → 二次确认 → 结果 冲突处理 允许误判?→ 直接信任 | 精确?→ 逐字符二次确认 输出结果

总结一下

BKDR 哈希,说白了就是「字符串转大数」的经典实现。它简单、高效、冲突率低,是工程中最常用的字符串哈希算法之一。

  • seed 选 131,模数选大质数
  • 去重用哈希集合,内存省 90%
  • 查找用滚动哈希,O(n) 搞定子串匹配
  • 永远不要省略二次确认——除非你能接受那百万分之一的错误

我个人习惯在项目里把 BKDR 封装成一个工具函数,seed 和模数做成可配置的宏。这样换场景时改两个数字就行,不用动逻辑。你试试看,真的很顺手。

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