算法 - 字符串算法 - 字符串哈希算法介绍

介绍字符串中的哈希算法。

假设有 n 个长度为 L 的字符串,问其中最多有几个字符串是相等的。

直接比较两个长度为 L 的字符串是否相等的时间复杂度是 O(L) 的。因此需要枚举 O(n2) 对字符串进行比较,时间复杂度为 O(n2L)。如果我们把每个字符串都用一个哈希函数映射成一个整数。问题就变成了查找一个序列中的众数,时间复杂度变为了 O(nL)

字符串哈希函数

注:本小节代码仅为演示,请不要实际运行。代码模板在下小节的代码实现中介绍。

一个设计良好的的字符串哈希函数可以让我们先用 O(L) 的时间复杂度预处理,之后每次获取这个字符串的一个子串的哈希值都只要 O(1) 的时间。这里重点介绍 BKDRHash

BKDRHash 的基本思想就是把一个字符串当作一个 k 进制数来处理。

代码如下:

const int HASH_K = 131;
const int HASH_M = 1e9 + 7;

int BKDRHash(char* str)
{
    int ans = 0;
    for (int i = 0; str[i]; ++i) {
        ans = (ans * HASH_K + str[i]) % HASH_M;
    }
    return ans;
}

例如,处理字符串 abac

第一个循环,ans(97)

第二个循环,ans((97)19+98)

第三个循环,ans(((97)19+98)19+97)

第四个循环,ans((((97)19+98)19+97)19+99)

其中,模运算是为了保证运算过程中数的范围,防止指数爆炸。且 HASH_KHASH_M 最好取成质数,这样可以减少哈希冲突。

现在我们考虑子串的哈希,假设字符串 s 的下标从 1 开始,长度为 n,我们得到 s[1..i]BKDRHashha[i]。令哈希函数为 hash,则有定义

ha[i]:=hash(s[1..i])

计算代码如下:

const int HASH_K = 131;
const int HASH_M = 1e9 + 7;

p[0] = 1;
ha[0] = 0;
for (int i = 1; i <= n; ++i) {
    p[i] = p[i - 1] * HASH_K;
    ha[i] = (ha[i - 1] * HASH_K + s[i]) % HASH_M;
}

现在询问 s[x..y]BKDRHash 可以得到(加的括号仅是为了方便展示后面得到的关系)

(1)ha[y]=s[1]ky1+s[2]ky2++s[x1]kyx+1+(s[x]kyx++s[y]),y1

又注意到

(2)ha[x1]=s[1]kx2+s[2]kx3++s[x1],x2

而我们要求的 s[x..y] 的哈希值为

(3)hash(s[x..y])=s[x]kyx++s[y]

可以发现得到如下关系式

(4)hash(s[x..y])=ha[y]ha[x1]kyx+1

因此我们预处理出 ha 数组和 k 的幂次 p 数组,之后每次询问 s[x..y] 的哈希值,只要 O(1) 的时间。

哈希函数代码实现

每次迭代后取模

如果我们按照我们上面推导的公式表述的式子做代码实现,会带来一个问题:指数爆炸。即 k 的幂次可能很高,以致超出了 C 语言的数据表示范围。因此我们需要每迭代一次,就对结果取一次模,就像上节的代码展示的那样,但这需要重新计算关系式 (4)

每一步的迭代表示为

(5)ha[i]=(ha[i1]k+s[i])%P

注意到上式参与计算的数都大于等于 0,因此计算机上的 % 与数学上的 mod 一致,此时 对任意 a,b0 有性质

(6)(a%Pk)%P=(ak)%P

(7)(a+b)%P=(a%P+b%P)%P

上述性质可参见相关的数学教材即可,或者自己尝试证明也行,并不困难。由式 (6) 和式 (7) 可将我们要计算哈希值的表达式进行化简

(8)hash(s[l..r])=(((0k+s[l])%Pk+s[l+1])%Pk+)%Pp+s[r])%P=(s[l]klr+s[l+1]klr1+s[r])%P

可将 ha[r]:=hash(s[1..r])ha[l1] 看作上式的特例,因此我们得到了 ha[r]ha[l1] 的结果,此时你应该会发现这些结果与 (1)(4) 非常相似,你也许会认为有下式成立

(9)hash(s[l..r])=(ha[r]ha[l1]krl+1)%P

但是并不正确,因为式 (9) 中的 ha[r]ha[l1] 是由式 (5) 迭代产生的,两者相减可能为负,而被除数为负数,除数为正数时,产生的余数会不一致(数学上与 C 语言上的模运算,见链接)。我们只需将式 (9) 调整为

(10)hash(s[l..r])=((ha[r]ha[l1]krl+1)%P+P)%P

(10) 就是最终得到的关系式,利用该关系式,仍然可在 O(1) 时间内计算子串的哈希值。

数据溢出与数据表示范围

在我们循环过程中,我们计算如下语句

ha[i] = (ha[i - 1] * HASH_K + s[i]) % HASH_M;

会出现的一种情况是,在计算 ha[i - 1] * HASH_K + s[i] 过程中,数据过大导致数据溢出,相当于对计算结果做了一次模运算。如果你构造哈希函数时就用的是数据的自然溢出来作为模运算,那计算过程中导致的溢出并不影响最终结果;但若不是如此,很可能导致哈希值计算不正确。

因此一般要开数据范围在 long long,且 HASH_KHASH_M 值要保证不发生溢出。

举一个例子,取 HASH_K131,取 HASH_M1e9+7,由于每次迭代后都取模,因此保证了 ha[i - 1] 不超过 1e9+7,即使之后每次运算都尽量取到最大,也能保证计算过程 ha[i - 1] * HASH_K + s[i] 未溢出(都在 long long 的数据范围内)。

双哈希减小冲突

在计算一个字符串的哈希值的过程中,用两个不同的 k 和两个不同的模数 m 分别运算,将这两个结果用一个二元组表示,作为哈希的结果。

即每一步迭代产生两个哈希

ha1[i]=(ha1[i1]k1+s[i])%P1

ha2[i]=(ha2[i1]k2+s[i])%P2

此时将二元组作为哈希的结果,即

hash(s[1..i])=<ha1[i],ha2[i]>

给定一个子串,计算其哈希,就是对该子串按式 (10) 分别做两次哈希,然后将这两个哈希值组合为二元组的结果。

posted on   Black_x  阅读(351)  评论(0编辑  收藏  举报

相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!
< 2025年3月 >
23 24 25 26 27 28 1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31 1 2 3 4 5

统计

点击右上角即可分享
微信分享提示