字符串算法大整理!你能想到的都能找到(补全中)

字符串算法大整理!你能想到的都能找到(吧)。

2018.7.16 Chengdu

今天学习了字符串相关的一些算法,种类挺多的,特来整理一波。

字符串哈希(Hash)

简介

哈希( Hash H a s h )是一种神奇的查找算法,广泛运用于计算机领域,它的强大在于设计得好的哈希算法可以使对一个对象的查找时间复杂度降为 O(1) O ( 1 ) ,这是朴素查找的 O(n) O ( n ) 和二分查找的 O(logn) O ( log ⁡ n ) 所远不能及的。

第一次了解到哈希这一种技术是在吴军 dalao d a l a o 的《数学之美》一书上(感谢 MasterYin M a s t e r Y i n 的借阅!)讲解网络爬虫的数学原理的一章。有兴趣的话可以去读一读。

原理

哈希查找

为什么哈希查找的时间复杂度可以这么低呢?考虑下面一个问题:

设计一个程序,以完成以下输入输出:
第一行,输入两个数字 n,m n , m
第二行,输入 n n 个数字。
第三行,输入 m 个数字。对于每一个数字,询问其是否存在于第二行输入 n n 个数中,若是则输出”YES”,若不是则输出”NO”。
数据说明:输入的数字的范围在 [ 1 , 10 5 ]

对于朴素算法,我们把第二行输入的数字储存在一个数组中。对于每一次询问,我们从左到右遍历整个数组来查询,每次询问的时间复杂度为 O(n) O ( n )

对于二分查找算法,我们把第二行输入的数字进行排序。对于每一次询问,我们用二分查找的方法找到这个数字处在的位置,排序的时间复杂度为 O(logn) O ( log ⁡ n ) ,每次询问的时间复杂度也为 O(logn) O ( log ⁡ n )

可是这一题真的有这么复杂吗?我们可以使用一个大小为 105 10 5 的辅助数组 vis v i s 来表示一个数字是否出现过。对于第二行中输入的每一个数字 a a ,我们在 v i s 数组中进行记录: vis(a)=true v i s ( a ) = t r u e 。这样,对于每一次询问,我们只用 O(1) O ( 1 ) 的时间访问 vis v i s 数组,就可以得到询问的结果了。而哈希,就是运用了这样的思想:我们多开一个数组,用这些多申请的空间去解决时间上的复杂。这也正是应了那句名言:

以空间换时间。 —-蒋介石

字符串哈希

那么如果我们把题改一下呢?

设计一个程序,以完成以下输入输出:
第一行,输入两个数字 n,m n , m
第二行,输入 n n 个字符串。
第三行,输入 m 个字符串。对于每一个字符串,询问其是否存在于第二行输入 n n 个字符串中,若是则输出”YES”,若不是则输出”NO”。
数据说明:输入的字符串的长度范围在 [ 1 , 10 5 ]

如果输入的是字符串的话,我们不就不能用 vis v i s 数组来储存了吗?这可怎么办?当然,如果你很熟悉 STL S T L 这一神奇的模板库,那么你一定会毫不犹豫地打出一行这样的代码:

map<string,bool>vis;

是的, map m a p 可以完美的解决这个问题。不过,我们可以试试用自己的力量来完成对字符串的处理:将字符串转换为数字。

怎样转换呢?我们知道,每一个字符都有它对应的 ASCII A S C I I 码,也就是说,一个字符可以表示为一个数字。那么,如果把字符串看作是字符的连续,不就可以把每个字符的 ASCII A S C I I 值连续起来表示字符串吗?我们可以运用这一点特性,将输入的字符串转换为一个 P P 进制数。在这里, P 是一个大数。处理完后得到的 P P 进制数,就叫做这个字符串的哈希值。按照我的习惯,我会取 P = 131 。当然,如果你乐意,取点什么 P=233 P = 233 P=666 P = 666 也是可以的,不过很显然,计算难度也会随着 P P 的增大而逐步增大。

接下来给出一个函数 H a s h ,来获取一个字符串的哈希值:

const int P=131;

int Hash(string tmp)
{
    int hash=0;
    for(int i=0;i<tmp.length();i++)
        hash=hash*P+tmp[i];
    return hash;
}

可是我们又面临了一个问题,这是因为题目中写到:

数据说明:输入的字符串的长度范围在 [1,105] [ 1 , 10 5 ]

字符串的最大长度是 105 10 5 !那 int i n t 不就爆炸了吗?

所以我们还要定义一个大数 M M ,在 H a s h 函数的运算过程中令运算结果对它取模,作为最后的字符串哈希值。依照我的习惯,我会取 M=99991 M = 99991 。当然,如果你乐意,取其它的值也是可以的。那么这个函数的运算就变成了:

const int P=131;
const int M=99991;

int Hash(string tmp)
{
    int hash=0;
    for(int i=0;i<tmp.length();i++)
        hash=(hash*P+tmp[i])%M;
    return hash;
}

这样我们就得到了一个字符串的哈希值,不过问题又出现了:怎样保证两个字符串的哈希各不相同?

这确实是哈希的一个大问题,具体来说有三种解决方法:

  1. 通过增加哈希池大小来降低两个字符串的哈希值冲突的概率。哈希池就是储存字符哈希值的地方,对应上述问题中的 vis v i s 数组。我们可以通过多模哈希或者增大 M M 的方式来达到这一结果,不过这样就会使得哈希计算过于复杂而且难以储存。
  2. 对于每个字符串的哈希值记录原有字符串,在冲突的情况下对原有字符串进行逐次比较。

第二种方法是我的常用方法,不过怎么实现呢?这里我们可以不使用 v i s 数组,而是转而使用神奇的 vector v e c t o r 来达到这一点。不过这样的话,每次查找要调用一个函数 Add A d d ,每次询问要调用一个函数 Query Q u e r y ,具体来说这样写:

vector<string>v[M];

bool Query(string tmp)
{
    int pos=Hash(tmp);
    for(int i=0;i<v[pos].size();i++)
        if(v[pos][i]==tmp) return true;
    return false;
}
void Add(string tmp)
{
    if(Query(tmp)) return ;
    int pos=Hash(tmp);
    G[pos].push_back(tmp);
}

这样就达到了我们的目的。这里给出一道字符串哈希的模板题:

Luogu P3370 【模板】字符串哈希

哈希的弊端 & 如何卡哈希

可是还有的人为了图代码书写方便而拒绝使用强大的 vector v e c t o r 来进行储存,这样就会被毒瘤出题人卡,因为会有人思考出卡掉各种字符串哈希的方法,具体可见几道神题:

BZOJ 3097 Hash Killer I
BZOJ 3098 Hash Killer II
BZOJ 3099 Hash Killer III
BZOJ 4917 [Lydsy1706月赛]Hash Killer IV (付费警告!)

在这些题目里,你被要扮演一个毒瘤出题人。因为你的后缀自动机神题被人用字符串哈希水掉了,所以你很不开心,决定用一组自造数据来卡掉他的代码。在这些题目中,给出水题的人的C++代码,要求你输出一组 hack h a c k 数据。

题目挺有意思,但是我们如何来操作呢?即,我们如何构造出两个相同的字符串,使它们的哈希值相同呢?

先拿第一题做例子。下面是题目大意:

Hash killer 1:
本来的神题:给你一个长度为 N N 的字符串 S ,求有多少个不同的长度为 L L 的子串?
水过的人:给出一份 cpp ,其采用进制哈希配合 unsigned long long 的自然溢出来求出每一个长度为 L 的子串的哈希值,然后使用排序去重得到不同的子串个数。
你的输出:你现在需要给出一组可以卡掉这个算法的数据。
数据大小: 1N105 1 ≤ N ≤ 10 5

自然溢出是什么意思呢?就是不去取 M M 的模,而是任由运算结果爆出数据范围,让储存范围自动地帮你取模。这也就相当于令 M = 2 64 。他的代码如下:

typedef unsigned long long u64;
const int MaxN = 100000;
inline int hash_handle(const char *s, const int &n, const int &l, const int &base)
{
    u64 hash_pow_l = 1;
    for(int i=1;i<=l;i++)
        hash_pow_l *= base;
    int li_n = 0;
    static u64 li[MaxN];
    u64 val = 0;
    for(int i=0;i<l;i++)
        val=val*base+s[i]-'a';
    li[li_n++]=val;
    for(int i=l;i<n;i++)
    {
        val=val*base+s[i]-'a';
        val-=(s[i-l]-'a')*hash_pow_l;
        li[li_n++]=val;
    }
    sort(li,li+li_n);
    li_n=unique(li,li+li_n)-li;
    return li_n;
}

需要注意的事,这一代码中的基数 base b a s e 值(也就是我代码中的 P P 值)是随机的。那么我们就需要分类讨论:

  • 如果基数 b a s e 是偶数,那么 base b a s e 就可以写作一个整数与 2 2 相乘,而我们知道 M = 2 64 。那么字符串长度超过 64 64 即可卡掉。例如这两个字符串的哈希值相同:

    • "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
    • "baaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
    • (别数了,两个字符串长度都是 65 65
  • 如果基数 base b a s e 是奇数::
    • 我们可以构造两个字符串 str1str2 ,我们队最终目的就是使它们满足 hash(str1)mod264=hash(str2)mod264 h a s h ( s t r 1 ) mod 2 64 = h a s h ( s t r 2 ) mod 2 64
    • (hash(str1)hash(str2))mod264=0 ( h a s h ( s t r 1 ) – h a s h ( s t r 2 ) ) mod 2 64 = 0 ,就能把代码卡掉。
    • 01 01 字符串 S[i]=S[i1]+not(S[i1]),S[0]=0 S [ i ] = S [ i − 1 ] + n o t ( S [ i − 1 ] ) , S [ 0 ] = ′ 0 ′ 。其中,中括号中的 i i 仅表示字符串长度为 2 i not(S[i1]) n o t ( S [ i − 1 ] ) 表示对 01 01 字符串 S[i1] S [ i − 1 ] 的每一位取反。
    • 接下来定义 h(x)=hash(S[x]),hn(x)=hash(not(S[x])) h ( x ) = h a s h ( S [ x ] ) , h n ( x ) = h a s h ( n o t ( S [ x ] ) )
    • 根据之前的定义以及进制哈希的特性,显然有:
      h(i)hn(i)=h(i1)×base2i1+hn(i1)hn(i1)×base2i1h(i1)=(h(i1)hn(i1))×(base2i11) h ( i ) – h n ( i ) = h ( i − 1 ) × b a s e 2 i − 1 + h n ( i − 1 ) − h n ( i − 1 ) × b a s e 2 i − 1 – h ( i − 1 ) = ( h ( i − 1 ) – h n ( i − 1 ) ) × ( b a s e 2 i − 1 − 1 )
    • 对于 base2i11 b a s e 2 i − 1 − 1 ,我们可以用神奇的欧拉定理得到:
      ==(base2i11)mod2i(base01)mod2i0 ( b a s e 2 i − 1 − 1 ) mod 2 i = ( b a s e 0 − 1 ) mod 2 i = 0
    • 所以我们就可以把上面的式子进行转换:
      (h(i1)hn(i1))×(base2i11)=(h(i1)hn(i1))×2ik ( h ( i − 1 ) – h n ( i − 1 ) ) × ( b a s e 2 i − 1 − 1 ) = ( h ( i − 1 ) – h n ( i − 1 ) ) × 2 i k
    • 其中的k是个没什么用的常数。然后我们用相同的方法拆啊拆啊拆,便可以得到:
      ==h(i)hn(i)21+2+3++ik2
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值