字符串相关算法学习总结集合
HASH
把复杂问题映射到一个容易维护的值域, 因为值域变简单, 范围变小, 可能会造成两个不同的值被hash函数映射到同一个值上,因此需要处理冲突情况
开散列:建立一个邻接表结构,以hash函数的值域作为表头数组head, 映射后的值相同的原始信息被分在同一类, 构成一个链表接在对应的表头, 链表的节点保存原始信息和统计数据(大概就是拉链式hash??)
hash的两个基本操作
1.计算hash函数的值
2.定位到对应链表依次遍历,比较
例:我们要在一个长度为n的随机整数序列A中统计每个数出现了多少次
一般思路:
直接数组计数
hash思路:
设计一个hash函数为h(x) = (x mod p) + 1, 其中p是一个比较大的质数, 但不超过n。这样,显然,我们把数列A分成了P类, 我们依次考虑数列中的每个数A[i], 定位到hash[h(A[i])]这个表头所指向的链表,如果该链表不包含A[i], 我们就在尾部新插入一个节点A[i], 并在该节点上记录A[i]出现了1次,否则直接找到已经存在的节点A[i],并将其出现次数+1。因为整数序列A是随机的,所以最终所以A[i]会比较均匀的分散在各个表头,整个算法的复杂度接近O(n)
对于非随机数列,我们可以设计更好的hash函数来保证其时间复杂度。同样的,如果我们需要维护的是比大整数复杂得多的某些性质(如是否存在,出现次数),也可以通过hash解决
emmmm....放一道基本水题感受下:
emmmm要是x小一点就可以丢到数组那当基本题了,然而很大,显然要是直接用数组会爆内存,所以我们来hash吧,这之后的问题解决就是数学的集合的事情了
丢份丑陋的代码:
#include <bits/stdc++.h> #define p 2323237 using namespace std; struct node { int v, next, num; }hash[p]; int n, sum = 0, a, b, bsum = 0; int lin[p], len = 0; bool flag1 = 0, flag = 0; inline int read() { int x = 0, y = 1; char ch = getchar(); while(!isdigit(ch)) { if(ch == '-') y = -1; ch = getchar(); } while(isdigit(ch)) { x = (x << 3) + (x << 1) + ch - '0'; ch = getchar(); } return x * y; } inline int getkey(int k) { return k % p;} inline void insert(int key, int v) { hash[++len].next = lin[key]; hash[len].v = v; hash[len].num = 1; lin[key] = len; } inline void hash_(int k, int c) { int key = getkey(k); if(c == 1) insert(key, k); else { for(int i = lin[key]; i; i = hash[i].next) if(hash[i].v == k) { hash[i].num++; flag1 = 1; flag = 1; sum++; } if(flag == 0) { insert(key, k); bsum++; } flag = 0; } } int main(){ for(int i = 1; i <= 2; i++) { n = read(); int x; if(i == 1) a = n; else b = n; for(int j = 1; j <= n; j++) { x = read(); hash_(x, i); } } if(!flag1) cout << "A and B are disjoint" << endl; else { if(bsum == 0) { if(sum == a) cout << "A equals B" << endl; else cout << "B is a proper subset of A" << endl; } else if(bsum != 0) { if(sum == a) cout << "A is a proper subset of B" << endl; else cout << "I'm confused!" << endl; } } return 0; }
字符型HASH
字符型hash,即把一个任意长度的字符映射成一个非负整数,并且冲突概率几乎为0
取一固定值P,把字符串看做P进制数,并分配一个大于0的数值, 代表每种字符。 一般来说, 我们分配的数值都远小于P, 例如对于小写字母构成的字符串,可以令:a = 1, b = 2, c = 3, .....z = 26。取一固定值M,将P对M取模,作为该字符的hash值 。
一般的说,我们取P = 131或P = 13331, 此时产生冲突的概率极低,只要hash值相同,我们就可以认为原字符串相等的。但是现实是,我们最好还是直接比较字符串是否相同,不然很容易就挂了.jpg,同样拉链式hash很重要.jpg,不然活该被卡(来自被花式卡死的人的怨念。)
一般我们采用M = 2^64, 即直接使用unsigned long long 类型存储hash值, 在计算时产生算术溢出时相当于直接的2^64取%, 这样可以避免低效的取%运算.jpg
我们也可以多取一些恰当的P和M的值(例如一些大质数,就比如如果你的企鹅号是质数...),多进行几组hash运算,当结果都相同时才认为与原字符串相等,一般来说,再毒瘤的出题人也很难构造出使这个hash产生错误的数据了,如果不行还是挂了,呵呵,出题人这辈子怕是没有rp了。但是如果你只运行1次,emmm,不被卡才怪。
对于字符串的各种操作,可以直接对P进制数进行算数运算反映到hash上
比如我们已知一个字符串S的hash值为Hash(S), 那么在S后添加一个字符c构成新字符S + c的hash值就是
Hash(S + c) = (Hash(S) * P + value[c]) % M。其中乘P相当于P进制下的左移运算, value数组是我们预先处理的字母的映射数组, value[c]就是我们选定的c的代表数值。
再如我们已知字符串S的hash值为Hash(S), 字符串S + T的hash值为Hash(S + T),那么字符串T的hash值
Hash(T) = (Hash(S + T) - Hash(S) * P^length(T)) % M
其中 Hash(S) * P^length(T)相当于把Hash(S)在P进制下再S后补0的方式进行算术左移,是S的左端与S + T的左端对齐,这样进行相减后得到的就是字符串T的hash值Hash(T)
例如: S = "abc", c = "d", T = "xyz", value["a, b, c.....z"] = {1, 2, 3, ....26}
S表示为P进制数为1 2 3
Hash(S) = 1 * P^2 + 2 * P + 3
Hash(S + c) = Hash(S) * P + value[c] = (1 * P^2 + 2 * P + 3) * P + 4 = 1 * P^3 + 2 * P^2 + 3 * P + 4
S + T表示为P进制数为1 2 3 24 25 26
Hash(S + T) = 1 * P^5 + 2 * P^4 + 3 * P^3 + 24 * P^2 + 25 * P + 26
Hash(S) * P^length(T) = (1 * P^2 + 2 * P + 3) * P^3 = 1 * P^5 + 2 * P^4 + 3 * P^3
即Hash(S) * P^length(T)表示为P进制数为 1 2 3 0 0 0
显然相减以后我们就得到了T的hash值
即Hash(T) = 1 * P^5 + 2 * P^4 + 3 * P^3 + 24 * P^2 + 25 * P + 26 - (1 * P^5 + 2 * P^4 + 3 * P^3)
表示为P进制数为 1 2 3 24 25 26 - 1 2 3 0 0 0 = 24 25 26
也就是说运算出来的Hash(T)表示为P进制数为24 25 26
Hash(T) = 24 * P^2 + 25 * P + 26
根据以上两种操作,我们可以通过O(n)的时间预处理字符串甚至所有前缀Hash值,并在O(1)的时间内查询任意子串的hash值
丢一道题:
emmm字符串hash,然而切记不要去比较什么hash值,直接比较原字符,顺便把拉链式用上
不然你就会和我一样,WA掉这道题
日常丢代码(emmm刚刚发现博客园是可以插入代码的)
#include <bits/stdc++.h> #define maxn 500086 #define p 131 #define m 2323237 #define ull unsigned long long using namespace std; struct node { char c[510]; int next; }hash[50010]; char ch[maxn]; int ans[maxn], top = 0; int n; int lin[3000010], le = 0; inline void insert(int key) { hash[++le].next = lin[key]; strcpy(hash[le].c, ch); lin[key] = le; } inline void hash_(int k, int c) { register bool flag = 0; for(register int i = lin[k]; i; i = hash[i].next) if(strcmp(hash[i].c, ch) == 0) { ans[++top] = c; flag = 1; cout << c << "\n"; } if(!flag) insert(k); } int main() { ios::sync_with_stdio(false); cin.tie(NULL); cout.tie(NULL); cin >> n; for(register int i = 1; i <= n; ++i) { cin >> ch; register int len; register ull key = 0; len = strlen(ch); for(register int j = 0; j < len; ++j) { register int h = ch[j] - 'a' + 1; key = (key * p + h) % m; } hash_(key, i); } return 0; }
KMP
KMP算法,又称模式匹配算法,能够在线性时间内判定长度为n的字符串A是否为长度为m的字符串B的子串
O(nm)暴力算法,二重循环枚举,逐个扫描A[1]...A[n]与B[i],B[i + 1]....B[i + n - 1]是否相同,我们把这个比较的过程称为“A与B进行尝试匹配”,
KMP算法分为两步:
1.对字符串A进行自我匹配,求出一个数组next,其中next[i]表示“A中以i结尾的非前缀子串”与A的前缀能够匹配的最长长度。
即:next[i] = max{j}, j < i && a[(i - j + 1) ~ i] = a[1 ~ j]
2.对字符串A与B进行匹配,求出一个数组f,其中f[i]表示“B中以i结尾的子串”与“A的前缀”能够匹配的最长长度
即:f[i] = max{j}, j <= i && b[(i - j + 1) ~ i] = a[1 ~ j]
以字符串abababaac为例
以i = 7结尾的“非前缀子串有6个”,分别是a[2~7], a[3~7], a[4~7], a[5~7], a[6~7], a[7]
如果使用暴力算法求出next数组,我们可以枚举下列几种情况
a[2~7] = “bababa”,它与前缀与a[1~6] = “ababab”不匹配
a[3~7] = “ababa”,它与前缀a[1~6] = “ababa”匹配,长度为5
a[4~7] = “baba”, 它与前缀a[1~4] = “abab”不匹配
a[5~7] = “aba”, 它与前缀a[1~3] = “aba”匹配,长度为3
a[6~7] = “ba”,它与前缀a[1~2] = “ab”不匹配
a[7] = “a”, 它与前缀a[1] = “a”匹配,长度为1
所以,以i = 7结尾, 最多与A的前缀匹配到5,next[7] = 5;
如何更快的求出next数组?
我们可以假设next[1~6]已经求出, 按照上述定义,next[6]=4,即a[3~6]与a[1~4]匹配
接下来a[7] = a[5] = 'a',在该字符上能够继续匹配,有next[6]匹配的长度的最优解为4可知,在a[7]的位置继续匹配,所以next[7] = 5,同理,next[5] = 3
我们接着考虑next[8],发现a[8] = 'a',与a[6] = 'b'两者不相等,不能把匹配长度从5增长为6.我们只好把匹配长度缩短。以i = 7结尾的匹配长度除了j = 5之外,a[5~7]与a[1~3]还能进行长度为3的匹配,a[7]与a[1]还能进行长度为1的匹配
我们尝试用这两种稍短的进行匹配,然而我们会发现,a[8]与a[4]或是a[8]与a[2]都不相等,并不能延伸到i = 8,我们只能让i = 8从A字符串开头重新匹配,a[8] = a[1],匹配长度为1,next[8] = 1
那么我们如何得知要考虑5, 3, 1这些长度的呢。已知next[7] = 5,这说明从7往前5个字符与a[1~5]是相等的,如果存在一个新的j,使得从5往前的j个字符与a[1~j]相等,那么从7往前j个字符与a[1~j]也是相等的。这样的j的最大自然就是next[5]
同理,考虑完j = next[5] = 3之后,下一个要考虑的匹配长度就是next[3]。
以下为代码解释
/* 假设next[1~(i - 1)]已求出,求next[i] 如果相等,就j + 1,如果a[i] != a[j + 1],即扩展失败,令j变为next[j],即从第a[j]的位置再尝试向a[j + 1]扩展 直至j等于0(应该从头开始匹配) */ next[1] = 0; for(int i = 2, j = 0; i <= n; i++) { while(j > 0 && a[i] != a[j + 1]) j = next[j]; if(a[i] == a[j + 1]) j++; next[i] = j; } /* 求解f数组方式 因为定义的相似性,求解过程基本一致 */ for(int i = 1, j = 0; i <= m; i++) { while(j > 0 && (j == n || b[i] != a[j + 1])) j = next[j];//当匹配了n个字符(扩展完成)或是扩展失败时,移到a[j]接着扩展a[j + 1] if(b[i] == a[j + 1]) j++;//字符相等,扩展成功,长度j 加上1 f[i] = j; }
最小表示法
给定一个长度为n的字符串S,我们如果不断把它的最后一个字符放到开头,最终会得到n个字符串,称这n个字符串是循环同构的。这些字符串中字典序最小的一个,称为字符串S的循环同构。这些字符串中字典序最小的一个,称为字符串S的最小表示。
例如S = “abca”,那么它的循环同构字符串为:abca, bcaa, caab, aabc。这个字符串的最小表示就是aabc
因为与S循环同构的字符串可以用该字符串在S中的起始下标表示,所以我们用b[i]表示从i开始的循环同构字符串,即:
s[1~n] + s[1 ~ (i - 1)]
如何求出一个字符串的最小表示?
暴力算法:依次比较这n个循环同构的字符串,找到字典序最小的一个。比较两个循环同构字符串b[i]与b[j]时,我们也采用直接向后扫描的方式,依次去k = 0, 1, 2.....,比较b[i + k]与b[j + k]是否相等,直至找到一个不相等的位置,从而确定b[i]与b[j]的大小关系。
实际上,一个字符串的最小表示可以在O(n)的线性时间内求出。我们首先把S复制一份接在它的尾部得到一个新字符串,我们表示为S2,显然b[i] = s2[i~(i + n - 1)]。
我们举一个例子:
S = “bacacabc”,i = 2,j = 4, k = 3
b a c a c a b c b a c a c a b c
i i+k
b a c a c a b c b a c a c a b c
j j+k
如果在i+k与j+k处不相等,假设s2[i + k] > s2[j + k],那么我们可以得知b[i]不是S的最小表示。初此之外,我们还可以得知b[i+1],b[i+2]....b[i+k]又都不是S的最小表示,因为对于1<=p<=k,存在比b[i+p]更小的循环同构串b[j+p](i+p与j+p开始向后扫描同样会在p = k时发现不相等),并且s2[i+k]>s2[j+k]
同理,如果s2[i+k]<s2[j+k],那么b[j],b[j+1],b[j+2]....b[j+k]也都不是S的最小表示,直接跳过这些位置不存在遗漏最小表示的情况。于是我们可以得到以下求最小表示的方法:
1.初始化i = 1, j = 2.
2.向后扫描比较b[i]和b[j]两个循环同构串
(1)如果扫描了n个字符后仍然相等,说明S只由一种字符构成,任意b[i]都是它的最小表示
(2)如果i+k与j+k处发现不相等
若s2[i+k]>s2[j+k],令i = i + k + 1。若此时i = j,再令i = i + 1
若s2[i+k]<s2[j+k],令j = j + k + 1。若此时j = i,再令j = j + 1
3.若i > n,b[j]为最小表示;若j > n,b[i]为最小表示;否则重复第二步
int n = strlen(s + 1); for(int i = 1; i <= n; i++) s[n + i] = s[i]; int i = 1, j = 2, k; while(i <= n && j <= n) { for(k = 0; k <= n && s[i + k] == s[j + k]; k++); if(k == n) break; if(s[i + k] > s[j + k]) { i = i + k + 1; if(i == j) i++; } else if(s[i + k] < s[j + k]) .{ j = j + k + 1; if(i == j) j++; } } ans = min(i, j);