在计算机眼里,字符是数,字符串是一串数,反正都是数。
如此一来,就真的好办了,查找字符串可以转变为查找一个特定的数。
比如“ABAC”,‘A’是65,’B‘是66,’C’是67,如果按照百进制的玩法,这个字符串就是65666567,不停的比数就行了,易于理解。
但是,有个小问题,从字符串转换为数字需要开销,如果每移动一个字符都要重新计算,那还不如暴力解法来的省时省力。同时,仅仅4个字符组成的字符串就有8位,40个字符组成的字符串岂不是要有80位,难道还要搞个高精度int类,那成本实在扛不住啊。
幸亏我们有数学,可以帮我们化解这个难题,通过取 hash 值实现为O(1)即可。
x i = t i R M − 1 + t i + 1 R M − 2 + … + t i + M − 1 R 0 x_{i}=t_{i}R^{M-1}+t_{i+1}R^{M-2}+…+t_{i+M-1}R^0 xi=tiRM−1+ti+1RM−2+…+ti+M−1R0
我们刚才是如何得到字符串对应的数字的?
根据上面的公式,x是我们要得到的数字,t是字符值,R是进制,65666567构造方式即:
65666567 = 65 × 10 0 4 − 1 + 66 × 10 0 3 − 1 + 65 × 10 0 2 − 1 + 67 × 10 0 1 − 1 65666567 = 65 \times 100^{4-1} + 66\times 100^{3-1}+65\times 100^{2-1}+67\times 100^{1-1} 65666567=65×1004−1+66×1003−1+65×1002−1+67×1001−1
到这里应该还好,下一步可能需要琢磨琢磨:
如果比较的字符串“ABAC”向后移动一位,变成“BACB”,我们怎么变?
x i + 1 = ( x i − t i R M − 1 ) R + t i + M x_{i+1}=(x_i-t_iR^{M-1})R+t_{i+M} xi+1=(xi−tiRM−1)R+ti+M
66656766 = ( 65666567 − 65 × 10 0 4 − 1 ) × 100 + 66 66656766=(65666567-65\times100^{4-1}) \times 100 + 66 66656766=(65666567−65×1004−1)×100+66
有了这个公式,我们貌似可以在任意字符串长度 M 情况,以常数范围(需要一些技巧)获得字符串对应的值。
下一个要解决的问题是如果有100个字符怎么办,没有这么高精度的int,就算有,比较成本恐怕也远大于字符串比较本身。
OK,我们的老朋友hash函数来了,把一个极度分散的元素集合收集到一个有限的区间,正是我们想要的。
而且我们依然使用散列表中的哈希计算方法,除留余法,连续的对每个字符进行除大质数取得余数,迭代取得整个字符串哈希值。
auto hash(const std::string &key, int patSize) const -> int
{
int hashNum = 0;
for (int j = 0; j != patSize; ++j)
{
hashNum = ((hashNum << logAlphSize) + key.at(j)) % Prime;
}
return hashNum;
}
重点理解一句话:
取余操作的一个基本性质是:
如果在每次算术操作之后都将结果除以大质数Q并取余,这等价于在完成了所有算术操作之后再将最后的结果除以Q并取余。
5 % 91 + 6 % 91 + 92 % 91 = (5 + 6 + 92) % 91
虽然是中学知识,但大多数人都忘了。
x i + 1 = ( x i − t i R M − 1 ) R + t i + M x_{i+1}=(x_i-t_iR^{M-1})R+t_{i+M} xi+1=(xi−tiRM−1)R+ti+M
这个公式两边 %Q 得到的值是一样的,于是,我们可以利用前面字符串的哈希值,在常数时间内计算出移动一位后的字符串的哈希值。
h a s h ( x i + 1 ) = x i + 1 m o d Q = [ ( x i − t i R M − 1 ) R + t i + M ] m o d Q hash(x_{i+1})= x_{i+1}\bmod Q = [(x_i-t_iR^{M-1})R+t_{i+M} ]\bmod Q hash(xi+1)=xi+1modQ=[(xi−tiRM−1)R+ti+M]modQ
将
( x i − t i R M − 1 ) m o d Q (x_i-t_iR^{M-1})\bmod Q (xi−tiRM−1)modQ
提出,为
h a s h ( x i ) − t i R M − 1 m o d Q hash(x_i)-t_iR^{M-1}\bmod Q hash(xi)−tiRM−1modQ
为确保得到正数,需要进行转换
( h a s h ( x i ) + Q − t i R M − 1 m o d Q ) m o d Q (hash(x_i)+Q-t_iR^{M-1}\bmod Q)\bmod Q (hash(xi)+Q−tiRM−1modQ)modQ
当字母表R比较大的时候,构造如下的值可以通过迭代除 Q 留余得到,其中 M = patSize 模式字符长度。
R M − 1 R^{M-1} RM−1
RM = 1;
for (int i = 1; i != patSize; ++i)
{
RM = (RM << logAlphSize) % Prime;
}
我们分步计算:
h a s h ( t e m p ) = ( h a s h ( x i ) + Q − t i R M − 1 m o d Q ) m o d Q hash(temp)=(hash(x_i)+Q-t_iR^{M-1}\bmod Q)\bmod Q hash(temp)=(hash(xi)+Q−tiRM−1modQ)modQ
h a s h ( x i + 1 ) = x i + 1 m o d Q = [ ( x i − t i R M − 1 ) R + t i + M ] m o d Q = ( h a s h ( t e m p ) R + t i + M ) m o d Q hash(x_{i+1})= x_{i+1}\bmod Q = [(x_i-t_iR^{M-1})R+t_{i+M} ]\bmod Q=(hash(temp)R+t_{i+M})\bmod Q hash(xi+1)=xi+1modQ=[(xi−tiRM−1)R+ti+M]modQ=(hash(temp)R+ti+M)modQ
数学公式是枯燥的,让我们用个简单例子辅助思考:
十进制的两个数:58,86,查看哈希构造
hash("58") = ((5 % 11) * 10 + (8 % 11) * 1) % 11 = 3; // 可以直接用 58 % 11 = 3
hash("86") = (hash(temp) * 10 + 6) % 11;
hash(temp) = (hash("58") + 11 - 5 * 10 % 11) % 11 = (3 + 11 - 6) % 11 = 8;
hash("86") = (8 * 10 + 6) % 11 = 86 % 11 = 9;
上述例子只是阐述哈希构造,我知道太简单,直接求余数更简单,但如果是100位字符串呢,那可是 256^100 的数字,你能直接算么。
最后说一下大质数,我们可以设一个大质数,也可以临时计算,我比较倾向设大质数表,随机抽,实现中是写死在初始值中的。
由于字母表 R 通常是 256 或 256 * 256 ,所以有关 R 的乘法,一律实现为位运算:
65 * 256 = 65 << 8;
66 * 65536 = 66 << 16;
为了确保匹配出错的概率极小,也就是哈希碰撞概率近乎零,我们选用 int64_t 进行计算,获取极大的质数而不溢出,这不一定可以移植,注意一下。
以下是代码的实现:
struct RabinKarp
{
explicit RabinKarp(std::string patStr) : pat(std::move(patStr))
{
patSize = static_cast<int>(pat.size());
RM = 1;
for (int i = 1; i != patSize; ++i)
{
RM = (RM << logAlphaSize) % Prime;
}
patHash = hash(pat, patSize);
}
static auto check(int /*i*/) -> bool
{
return true;
}
auto search(const std::string &txt) const -> int
{
int txtSize = static_cast<int>(txt.size());
int64_t txtHash = hash(txt, patSize);
if (patHash == txtHash && check(0))
{
return 0;
}
for (int i = patSize; i != txtSize; ++i)
{
txtHash =
(txtHash + Prime - RM * txt.at(i - patSize) % Prime) % Prime;
txtHash = ((txtHash << logAlphaSize) + txt.at(i)) % Prime;
if (patHash == txtHash)
{
if (check(i - patSize + 1))
{
return i - patSize + 1;
}
}
}
return txtSize;
}
private:
auto hash(const std::string &key, int patSize) const -> int64_t
{
int64_t hashNum = 0;
for (int j = 0; j != patSize; ++j)
{
hashNum = ((hashNum << logAlphaSize) + key.at(j)) % Prime;
}
return hashNum;
}
// 哈希表,除留余取值,没有使用。
auto longRandomPrime() const -> int
{
const std::vector<int> Prime{
12289, 24593, 49157, 98317, 196613, 393241,
786433, 1572869, 3145739, 6291469, 12582917, 25165843,
50331653, 100663319, 201326611, 402653189, 805306457, 1610612741};
return Prime[patSize % Prime.size()];
}
// 模式字符串
std::string pat;
// 模式字符哈希值
int64_t patHash = 0;
// 模式字符size
int patSize = 0;
// 大质数,用于除余留法计算哈希值
const int64_t Prime = 1610612741;
// AlphaSize = 256 字母表大小
const int logAlphaSize = 8;
// AlphaSize ^ (patSize - 1)
int64_t RM = 0;
};
测试用例:
inline void testRabinKarp(char *argv[])
{
std::string pat = argv[1];
std::string txt = argv[2];
subStr::RabinKarp RK(pat);
std::cout << "text: " << txt << std::endl;
int offset = RK.search(txt);
std::cout << "pattern: ";
for (int i = 0; i != offset; ++i)
{
std::cout << ' ';
}
std::cout << pat << std::endl;
}
// text: AAAAFDDFSDFHBGHJKJVCAACCBBBABBCAABABABAC
// pattern: BABBCAA