二字符串算法
1.KMP 算法
KMP 算法主要是用于解决字符串匹配问题,也就是我们常说的查找子串问题。
用kmp算两步就完成了
术语解释
- 主串(目标串): 简单来说就是被搜索的字符串,一般来说就是那个长的。
- 模式串:被匹配,是查找的目标。
- 算法目标:字符串中的模式定位问题,简单来说就是查找子串,在主串中查找匹配模式串。这一类的算法,又被称为模式匹配算法。
KMP 算法详解
写在代码最前面,KMP 算法中根据编译器版本不同,有的编译器可能开不出 next 数组,大家可以改成 Next 或者 nextt 等,换个名字。
KMP最核心的步骤就是构造next数组,(如果说暴力做法是一台只会重复运作没有思维的机器,那么KPM更像有思维的人类)下面介绍两种构造next的方法(本质都一样只不过next[i]的含义有一丢丢不同,数组起始下标不同而已)
下标从1开始的写法
#include <iostream>
using namespace std;
const int N = 100010, M = 1000010;
int n, m;
int ne[N];
char s[M], p[N];
int main()
{
cin >> n >> p + 1 >> m >> s + 1;
//构造next数组,下标从1开始
for (int i = 2, j = 0; i <= n; i ++ )
{
//更新j,还要判断j是否为0,避免死循环,可以用p[i]!=p[1]这种情况验证,
while (j && p[i] != p[j + 1]) j = ne[j];
if (p[i] == p[j + 1]) j ++ ;
ne[i] = j;
}
for (int i = 1, j = 0; i <= m; i ++ )
{
while (j && s[i] != p[j + 1]) j = ne[j];
if (s[i] == p[j + 1]) j ++ ;
if (j == n)
{
printf("%d ", i - n);
j = ne[j];
}
}
return 0;
}
下标从0开始的写法(不推荐)
#include <cstring>
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1000010;
int n, m;
char s[N], p[N];
int ne[N];
int main()
{
cin >> m >> p >> n >> s;
ne[0] = -1;
for (int i = 1, j = -1; i < m; i ++ )
{
while (j >= 0 && p[j + 1] != p[i]) j = ne[j];
if (p[j + 1] == p[i]) j ++ ;
ne[i] = j;
}
for (int i = 0, j = -1; i < n; i ++ )
{
while (j != -1 && s[i] != p[j + 1]) j = ne[j];
if (s[i] == p[j + 1]) j ++ ;
if (j == m - 1)
{
cout << i - j << ' ';
j = ne[j];
}
}
return 0;
}
2.字符串 Hash 算法
为什么使用哈希算法
Hash 算法则可以帮助我们判断是否有这个元素,虽然功能简单,但是其 复杂度O(1) 时间复杂度是具有高性能的。 Hash 是通过在记录的存储地址和它的关键码之间建立一个确定的对应关系。这样,不经过比较,一次读取就能得到所查元素的查找方法。 相比普通的查找算法来说,仅仅在比较的环节,就会大大减少查找或映射所需要的时间。
字符串 Hash 计算
算法竞赛中特别常用的字符串映射成数字的方式。
实现原理:
- 将字符串中的每一个字母都看做是一个数字(例:从 a-z ,视为 1-26 );
- 选取两个合适的互质常数 b 和 h,其中 h 要尽可能的大一点,为了降低冲突的概率。b 常用 131,h 常用 1e9+7。
这里我们需要设置公共溢出区所以,我们需要随便找一个 string 数组能开出来的数字,这里选取 999983
由于我们这次不去做映射,所有不需要开判断数组或者计数数组,那么我们就不需要考虑数组能否开出来的情况。
来源:https://planetmath.org/goodhashtableprimes
在设计一个好的散列 配置的过程中,有一个散列表大小的素数列表是很有帮助的。
下面就是这样一个列表。它具有以下特性:
- 列表中的每个数字都是素数
- 每个数字略小于前一个数字的两倍
- 每个数字都尽可能远离最近的两个 2 的幂
对哈希表使用素数是一个好主意,因为它可以最大限度地减少哈希表中的聚类。第(2)项很好,因为它方便面对扩展数据增长哈希表。据称,第 (3) 项已被证明在实践中产生了特别好的结果。
这是列表:
lwr upr %err prime 2^5 2^6 10.416667 53 2^6 2^7 1.0416670 97 2^7 2^8 0.520833 193 2^8 2^9 1.302083 389 2^9 2^10 0.130208 769 2^10 2^11 0.455729 1543 2^11 2^12 0.227865 3079 2^12 2^13 0.113932 6151 2^13 2^14 0.008138 12289 2^14 2^15 0.069173 24593 2^15 2^16 0.010173 49157 2^16 2^17 0.013224 98317 2^17 2^18 0.002543 196613 2^18 2^19 0.006358 393241 2^19 2^20 0.000128 786433 2^20 2^21 0.000318 1572869 2^21 2^22 0.000350 3145739 2^22 2^23 0.000207 6291469 2^23 2^24 0.000040 12582917 2^24 2^25 0.000075 25165843 2^25 2^26 0.000010 50331653 2^26 2^27 0.000023 100663319 2^27 2^28 0.000009 201326611 2^28 2^29 0.000001 402653189 2^29 2^30 0.000011 805306457 2^30 2^31 0.000000 1610612741 这些列依次是 2 的下界幂、2 的上界幂、素数与前两者的最优中间的相对偏差(以百分比表示),最后是素数本身。
显然这张表的设计满足了以下条件:
- 列表中的每个数字都是素数
- 每个数字略小于前一个数字的两倍
- 每个数字都尽可能远离最近的两个 2 的幂
第二条,目的是,为了能够更好地扩展 Hash 表。
而我们更多的是为了竞赛,所以我们也不需要对 Hash 表进行扩展,一开始开的容量满足要求,那么就可以直接使用,如果我们所需求的容量大于题目给的,那无论使用哪种方式最后都会大于题目给定,所以第二条并不是和竞赛。
所有引用该 Hash 表的同志,也没见他们写了动态 Hash 表,笔者认为这张 Hash 表 并不是很适合竞赛。
第三条,已被证明在实践中产生了特别好的结果。
其实蓝桥杯一般不用模p。
哈希函数
处理方式:
- C 代表一个字符串,用 C =c1 c2 c3 c4…cm 表示该字符串,其中 ci 表示从前向后数的第 i 个字符;
- 方括号[ ]内的表达式是将 C 当做 b 进制数 来处理,b 是基数;
- 关于对 h 取模,若 b、h 有公因子,那么不同的字符串取余之后的结果发生冲突的几率将大大大增加(冲突:不同的字符串但会有相同的 hash 值)。
- 计算上一步 H© 的过程是递归实现的:H(C,k)为前 k 个字符构成的字符串的哈希值, H(C,k+1)= H( C , k ) * b+c( k+1 );
实现
int Hx(string s)
{
int n = s.size();
for (int i = 0; i < n; i++)
{
sum1 = sum1 * 131 % h + (s[i] - 'a' + 1) % h;
}
return (sum1 + h) % h;
}
例题
字符串 Hash 相关题目讲解
斤斤计较的小 Z
难度: 简单
标签: 字符串Hash
题目描述:
小 Z 同学没天都喜欢斤斤计较,今天他又跟字符串杠起来了。
他看到了两个字符串 s1 s2 ,他想知道 S1 在 S2 中出现了多少次。
现在给出两个串 S 1,S 2(只有大写字母),求 S 1 在 S 2 中出现了多少次。
数据范围字符串长度len, 1<len(s1)<len(s2)<10^6
字符取值 大写字母 和 小写字母
输入描述:
共输入两行
第一行为 S 1
第二行为 S 2
输出描述:
输出 S 1 在 S 2 中出现了多少次
输入输出样例:
-
样例 1: Input:
LQYK LQYK
output:
1
-
样例 2: Input:
LQYKLQYKLQYKLQYK LQYK
output:
4
-
样例 3: Input:
AADSDFGADSWADADADD WSAD
output:
0
题目解析:
将匹配串 S1 的哈希值求出来,再将母串 S 2 的哈希值数组求出来,根据结论 H(C’) = H(C,k+n) - H(C,k)*b^n 求出与匹配串长度相等的母串子串的哈希值,与匹配串 S1 的哈希值比较,如果相等,答案+1。
答案解析:
C++ 描述:
#include <iostream>
#include <cstdio>
#include <string>
using namespace std;
typedef unsigned long long ull;
const int base = 131;
const int maxn = 1e6 + 5;
char sTemp1[maxn], sTemp2[maxn];
ull power[maxn];
string s1, s2;
ull hash2[maxn];
int n;
ull hash1;
int ans=0;
void init() {
power[0] = 1;
for (int i = 1; i <= 10002; i++)//预处理base^n
power[i] = power[i - 1] * base;
}
int main() {
init();
scanf("%s", &sTemp1);
scanf("%s", &sTemp2);
s1 = sTemp1;
s2 = sTemp2;
//强烈建议这样读取字符串,节省时间
int len1 = s1.size();
int len2 = s2.size();
for (int i = 1; i <= len1; ++i)
hash1 = hash1 * base + (ull)(s1[i - 1] - 'A' + 1);
hash2[0] = (s2[0] - 'A' + 1);
for (int i = 1; i <= len2; ++i)
hash2[i] = hash2[i - 1] * base + (ull)(s2[i - 1] - 'A' + 1);
for (int i = 0; i <= len2 - len1; ++i) {
//将字符串转化成数字
ull hash = hash2[i + len1] - hash2[i] * power[len1];
if (hash == hash1)
ans++;
}
printf("%d\n", ans);
return 0;
}
3.Manacher 算法
为什么使用 Manacher 算法
在讲 Manacher 算法之前,我们的要先补充几个概念。
回文:
回文,汉语词语,指汉语中的回文语法,即把相同的词汇或句子,在下文中调换位置或颠倒过来,产生首尾回环的情况,叫做回文,也叫回环。
这是在中文语境下的,在英文环境中,叫做 Palindromic。
回文字符串(Palindromic String):
“回文串”是一个正读和反读都一样的字符串。
如“viooiv”、“nexttexn”、“12321”、“WWWWWW”、“锅盖盖锅” 等。
回文子串:
一个字符串,他的子串大家都学过,我们之前的都了解过。
如果一个字符串的子串是回文结构的,那么我们称之为回文子串。
最长回文子串:
字符串的最长回文子串,是指一个字符串中包含的最长的回文子串。
如果在 “abcdeffgh” 包含的最长回文子串就是 “ff”;
处理回文字符串的最简单方法就是暴力,找到所有的字符串然后判断是否为回文字符串,当然应该很少有人会这么做。
(进阶版)中心扩展法:
我们知道,回文串都是回文的,即所见即所得,既然回文它就有必然有一个对称中心。
我们通过枚举对称中心的位置,进行扩展那么,就可以完成对回文字符串的判定。
那我们就要执行以下操作:
-
枚举中心位置 n 个字符和 n-1 个字符中间位置(偶数回文)
-
以中心位置向两侧延伸,直至不是回文位置
-
在枚举过程中统计最大的回文子串
上代码
#include <iostream> using namespace std; string s; int expand(int l, int r) { //字符串扩展 while (l >= 0 && r < s.length() && s[l] == s[r]) //如果回文处相同且不超过字符串的范围: { l--; r++; // 扩展 } return r - l - 1; //不能扩展返回最大长度 } int Palindromic() { if (s == "" || s.length() == 0) { return 0; } int maxn=1; for (int i = 0; i < s.length(); i++) { int l1 = expand(i, i); // 以字符作为中心点扩展 int l2 = expand(i, i + 1); // 以字符间隙作为中心点扩展 int len = max(l1, l2); maxn=max(maxn,len); } return maxn; } int main() { cin>>s; cout<<Palindromic()<<endl;
对于中心扩展的回文算法,我们分析一下:
-
由于长度的奇偶性问题,不同的对称轴要分两类情况讨论分析.
-
有多次重复计算。
如 cbcacbc 第一个 cbc(中心点为第一个b时) 被计算过,第二个 cbc(中心点为第二个b时) 也被计算过,一整个个 cbcabcbc (中心点为a时)又被计算了一次。
于是 Manacher 的提出解决了以上问题。
-
Manacher原理和实现
-
对于问题 1(解决对称轴的变化带来的复杂性):
通过对于字符串的预处理解决长度奇偶性带来的对称轴位置变化。
-
预处理方式:
在所有字符间隙中和开头结尾插入同样的符号,一般使用#,当然只要不影响题目本身的符号(即不在原来的字符串中出现)都是可以的。
这样可以使的所有的字符串都是奇数串,即消除了奇偶变化所带来的的差异化处理。
-
形如:
abcd
->#a#b#c#d#
abba
->#a#b#b#a#
abcba
->#a#b#c#b#a#
我们可以看到是字符串回文的性质没有发生改变,发生改变的只有回文串的长度。
即再求出最长的回文子串后再减去所有的 # 数量就可以得到长度。
为了防止越界处理等,我们一般在开头和结尾在缀上两个不一样的字符。
形如:
abcd
->@#a#b#c#d#%
abba
->@#a#b#b#a#%
abcba
->@#a#b#c#b#a#%
2.解决重复计算的问题:
为了解决这个问题,我们要引入几个辅助变量。
-
Max:最远标记距离
即目前最靠右的回文串的右端点
-
Pos: 最远中心点
即目前最靠右的回文串的中心点
-
length[i]:回文半径
以 i 为中心扩展的回文串的半径,恰好比不扩展的原字符串的直径大 1.
算法实现过程:
假设前 i-1 个字符已经被处理过,那么我们将处理第 i 个字符,则会有下面的情况。
设 j 为 i 关于 Pos 的对称点
j = 2 * pos - i
MaxL 为 Max 关于 Pos 的对称点。
MaxL = 2*Pos - Max
- 当以 j 为中心的回文串被 Max 串包含:
-
此时我们知道,由于 Max 串关于 Pos 回文,所以以 j 为对称轴回文的串,也会在 Pos 右侧以 i 为对称轴出现。
所以我们可以得知 length[i] = length [j] = length[ 2*Pos -i ]。
-
当以 j 为中心的回文串范围超出 Max 串:
即 j-length[j] < MaxL 时。
我们不能保证超出 MaxL 左侧的字符也会在 Max 的右侧出现,所以我们只能最大限度的考虑 i 的回文半径,那就是 Max - i:
-
即 j-length[j] < MaxL 时。
我们不能保证超出 MaxL 左侧的字符也会在 Max 的右侧出现,所以我们只能最大限度的考虑 i 的回文半径,那就是 Max - i
-
我们可以综合考虑二者,因为他们之间肯定存在一个大小关系,如果是第一种情况,那么有 length[2Pos -i ] <= Max -i , 如果是第二种情况,那么就有个 length[2Pos -i ] >= Max -i 的情况。
因此我们直接使用最短那个即可。
即当 i < Max 时:
length[i]=min(length[2*pos-i],Max-i);
-
i>=Max 的情况
20.52.52.png)]此时的 i 并不能参考 j 的处理情况,也无其他规律可循,只能按照朴素的算法处理。
由此马拉车的代码水到渠成的就写出来了。
#include<bits/stdc++.h>
using namespace std;
const int maxn=1e7+5;
string s;
int length[maxn*2];
int manacher(string p)
{
int le=p.size();
for(int i=0;i<le;i++)
{
s[i*2+2]=p[i];
s[i*2+1]='+';
}
s[0]='@',s[le*2+1]='%';//选取不可能出现的字符即可。
//预处理字符串成为一个奇数串;
int Max=0,pos=0,ans=0;
le=2*le+1;
for(int i=1;i<=le;i++)
{
if(Max>i)
{
length[i]=min(length[2*pos-i],Max-i);
}
else length[i]=1;
while(s[i-length[i]]==s[i+length[i]])length[i]++;
ans =max(length[i],ans);
if(i+length[i]>Max)
{
Max=length[i]+i;
pos=i;
}
}
return ans-1;
}
int main()
{
string in;
cin>>in;
cout<<manacher(in)<<endl;
return 0;
}