字符串算法小结

 字符串是编程语言中表示文本的数据类型。许多字符串的问题,比如DP,统计方案数等,本质上都是要先解决字符串匹配问题。

本篇主要讲解5种算法:

  • 哈希法(最直观的方法)
  • KMP算法(最基础的方法)
  • 扩展KMP算法(KMP 算法的扩展)
  • Manacher算法(解决回文串问题)
  • AC自动机(Trie + KMP)

 将从算法的基础概念切入,循序渐进详解算法处理与实现,助你系统学习。其间还会穿插经典例题讲解,讲练结合,快速高效地掌握字符串相关知识,并能在实际编程中运用自如。

字符串最常见的应用就是字符串的匹配

目前竞赛中解决这类问题的方法主要有:

  • 哈希
  • KMP
  • 后缀数组/后缀树/后缀自动机

哈希法:

  1. 把一个字符串转换为一个数字,之后的每一次比较,比较两个字符串是否相等,只需要比较两个数字是否相等即可。
  2. 最简单直观,易实现,便于拓展(如用数据结构维护)。

KMP 算法:

  1. 线性的算法。
  2. 本质是利用模板串自身的信息,去减少匹配时的冗余比较,达到优秀的时间复杂度。

扩展 KMP 算法:

  1. 注意此方法与 KMP 算法的异同点。
  2. 利用对称性,降低时间复杂度。

Manacher 算法:

  1. 专门用来解决回文串问题。
  2. 利用对称性,降低时间复杂度。

AC 自动机:

  1. 结合字典树(Trie)和 KMP,这两者结合之后就可以解决“多串”的问题,前面的四种算法都是用来解决单串问题的。字典树里面可以存放多个字符串,再加上 KMP 的思想就得到了 AC 自动机。
  2. Trie 树与 KMP 的结合,可以解决一个串或多个串的匹配问题。

1. 哈希表

哈希表的引入:
如果要存储和使用线性表(1, 75, 324, 43, 1353, 90, 46)一般情况下我们会使用一个数组 A[1…7]来顺序存储这些数。但是这样的存储结构会给查询算法带来 O(n) 的时间开销。

对 A 排序,使用二分查询法,时间复杂度变为 O(logn)也可以用空间换时间的做法,用数组 A[1…1353] 来表示每个数是否出现,查找时间复杂度变为 O(1),但是空间上的开销变得巨大。

优化上一种做法,建立一个哈希函数 h(key) = key % 23. (1, 75, 324, 43, 1353, 90) -> (1, 6, 2, 20, 19, 21)
我们只要用一个 A[0…22] 数组就可以快速的查询。这种线性表的结构就称为哈希表(Hash Table)

可以看出,哈希表的基本原理是用一个下标范围相对比较大的数组 A 来存储元素。

设计一个函数 h,对于要存储的线性表的每个元素 node,取一个关键字 key,算出函数值 h(key) 然后把这个值作为下标,用 A[h(key)] 来存储 node。最常见的 h 就是模函数,也就是选定一个 m,令 h(key) = key % m。

哈希表的冲突:
可能存在两个 key: k1, k2 使得 h(k1) = h(k2), 这时也称产生了"冲突"。

解决冲突有很多种办法:

  1. 可以用另一个函数 I 去计算 I(k1), I(k2),找到新的位置。
  2. 可以让 A 中的每个元素都存一个链表,对于 h(k1)=h(k2) ,我们可以让这两个 node 都接在 A[h(k1)] 的链表上。

假设我们使用第二种方法解决冲突:
对于插入元素 (node, key):

  • 计算 h(key), 把 node 插入 A[h(key)] 链表。
    对于查询元素 (node, key):
  • 计算 h(key), 如果 A[h(key)] 为空,说明 node 不存在。否则遍历 A[h(key)] 链表,寻找 node.

哈希表的代码实现:

struct node{
    int next, info;
}hashNode[N];
int tot; // 哈希表节点计数
int h[M]; // 初始化为 -1

// 插入操作
void insert(int key, int info){
    int u = key % M;
    hashNode[tot] = (node){h[u], info};
    hu] = tot ++;
}

// 查找操作
int find(int key, int info){
    int u = key % M;
    for(int i=h[u]; i!=-1; i=hashNode[i].next){
        if (hashNode[i].info == info)
            return 1;
    }
    return 0;
}

边学边练:

已知 X[1…4] 是 [-T, T] 中的整数,求出满足方程:
AX[1] + BX[2] + CX[3] + DX[4] = P.
的解有多少组?
注:
∣ P ∣ ≤ 1 0 9 , ∣ A ∣ 、 ∣ B ∣ 、 ∣ C ∣ 、 ∣ D ∣ ≤ 1 0 4 , T ≤ 500. |P| \leq 10^{9}, |A|、|B|、|C|、|D| \leq 10^{4}, T \leq 500. P109,ABCD104,T500.

解:
最直观的方法枚举 X[1…4],时间复杂度 O ( n 4 ) O(n^{4}) O(n4)
适当优化,枚举了 X[1…3] 之后,实际上 X[4] 已经确定了,时间复杂度 O ( n 3 ) O(n^{3}) O(n3)
继续优化,采用 meet in the middle 策略:

  • 一边枚举 X[1], X[2]
  • 一边枚举 X[4], X[3]
    然后看有哪些方案可以组成方程的解。

枚举 X[1], X[2],然后算出 P-AX[1]-BX[2] 把这个值存入一个哈希表,注意要统计次数。这一步时间复杂度 O ( n 2 ) O(n^{2}) O(n2)
然后枚举 X[3], X[4],算出 CX[3] + DX[4] 去哈希表里面查找这个值出现了几次。
把次数加进答案,这一步时间复杂度 O ( n 2 ) O(n^{2}) O(n2) 因此,总的时间复杂度是 O ( n 2 ) O(n^{2}) O(n2).


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

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

这里我们就重点介绍 BKDRHash:

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

int k = 19, M = 1e9+7;
int BKDRHash (char *str){
    int ans = 0;
    for (int i=0; str[i]; i++){
        ans = (1LL*ans*k +str[i]) % M;
    }
    return ans;
}

假设字符串 s 的下标从 1 开始,长度为 n.

ha[0] = 0;
for (int i=1; i<=n; i++){
    ha[i] = (ha[i-1]*k + str[i]) % M;
}

我们知道 ha[i] 就是 s[1…i] 的 BKDRHash,那么现在询问 s[x…y] 的 BKDRHash ,你能快速求解吗?

注意到:
h a [ y ] = s [ 1 ] k y − 1 + s [ 2 ] k y − 2 + . . . + s [ x − 1 ] k y − x + 1 + s [ x ] k y − x + . . . + s [ y ] ha[y] = s[1]k^{y-1} + s[2]k^{y-2} + ... + s[x-1]k^{y-x+1} + s[x]k^{y-x} + ... + s[y] ha[y]=s[1]ky1+s[2]ky2+...+s[x1]kyx+1+s[x]kyx+...+s[y]

注意到:
h a [ x − 1 ] = s [ 1 ] k x − 2 + s [ 2 ] k x − 3 + . . . + s [ x − 1 ] ha[x-1] = s[1]k^{x-2} + s[2]k^{x-3} + ... + s[x-1] ha[x1]=s[1]kx2+s[2]kx3+...+s[x1]

而我们要求的 s[x…y] 的哈希值为 s [ x ] k y − x + . . . + s [ y ] s[x]k^{y-x} + ... + s[y] s[x]kyx+...+s[y]

可以发现:
s [ x . . . y ] = h a [ y ] − h a [ x − 1 ] k y − x + 1 s[x...y] = ha[y] - ha[x-1]k^{y-x+1} s[x...y]=ha[y]ha[x1]kyx+1.

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


边学边练:
阿轩在纸上写了两个字符串,分别记为 A 和 B。利用在数据结构与算法课上学到的知识,他很容易地求出了“字符串 A 从任意位置开始的后缀子串”与“字符串B”匹配的长度。
不过阿轩是一个勤学好问的同学,他向你提出了 Q 个问题:在每个问题中,他给定你一个整数 x,请你告诉他有多少个位置,满足“字符串A从该位置开始的后缀子串”与B匹配的长度恰好为x。
例如:A = aabcde, B = ab, 则 A 有 aabcde、bcde、cde、de、e 这 6 个后缀子串,它们与 B = ab 的匹配长度分别是:1、2、0、0、0、0. 因此 A 有 4 个位置与 B 的匹配长度恰好为 0,有 1 个位置的匹配长度恰好为 1,有 1 个位置的匹配长度恰好为 2.
1 ≤ N , M , Q ≤ 200000. 1 \leq N, M, Q \leq 200000. 1N,M,Q200000.

核心问题就是:给定两个字符串 A, B。
求出 A 的每个后缀子串和 B 的最长公共前缀。标准做法就是扩展 KMP,时间复杂度为线性。我们先来用 Hash 试试看:

前面已经提到,我们可以用 O(n) 预处理, O(1) 处理处一个子串的哈希值。

求字符串 A[i…n] 与字符串 B[1…m] 的最长公共前缀?
二分!
二分长度 mid
计算出 A[i…i+mid-1] 和 B[i…mid] 的哈希值,比较是否相等。
因此,时间复杂度是 O(logn) 的!

ll getha (int x, int y){
    return ha[y] - ha[x-1] * p[y-x+1];
}

ll gethb (int x, int y){
    return hb[y] - hb[x-1] * p[y-x+1];
}

int main() {
    scanf("%d%d%d", &n, &m, &);
    scanf("%s", a+1);
    scanf("%s", b+1);
    p[0] = 1;
    for (int i=1; i<=max(n,m); i++) p[i] = p[i-1] * P;
    for (int i=1; i<=n; i++) ha[i] = ha[i-1] * P + a[i];
    for (int i=1; i<=m; i++) hb[i] = hb[i-1] * P + b[i];
    
    for (int i=1; i<=n; i++){
        int L = 1, R = min(m, n-i+1), mid;
        while (L <= R) {
            mid = (L + R) >> 1;
            if (getha(i, i+mid-1) == gethb(1, mid)){
                L = mid + 1;
            }else {
                R = mid - 1;
            }
        }
        cnt[R]++;
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值