目录
Trie树
代码实现
Trie树的作用
KMP算法
来源:
核心思想:
前缀表:
前缀表的作用:
最长相同前后缀:
使用前缀表降低时间复杂度的原理
前缀和与next数组的关系
代码实现
算法时间复杂度分析
相应习题:
参考资料:
五一前这一周在忙一些比赛的事情,刷题不多,且内容也还没有完成整理,先挖个坑
Trie 树是一种多叉树的结构,每个节点保存一个字符,一条路径表示一个字符串。
下图表示了字符串: him 、 her 、 cat 、 no 、 nova 构成的 Trie 树。
Trie 树中每个节点存储一个字符,从根节点到叶节点的一条路径存储一个字符串。另外,有公共前缀的字符串,他们的公共前缀会共用节点。如 her、 him 共用 h 节点。
我们考虑使用数组模拟链表的方法来实现Trie树
参考了如何理解单(双)链表,Trie树和堆中的idx? - AcWing
每一个idx的值均对应着一个不同的 链表/树 结点。
在Trie树中idx相当于一个分配器,如果需要加入新的结点就用++idx分配出一个下标。
int son[N][26];
int cnt[N], idx;
son[N][26] 就是分配的N个节点构建字符串的trie树,
son[p][j] 存储 节点p 沿着 j 这条路径所走到的子节点编号
相当于每个节点有26个状态/路径,每个节点只能对应一个状态/路径
cnt[p] 用于记录以 节点p 结尾的字符串的插入次数
void insert(char *str){
int p = 0;// 从根结点开始遍历
for(int i = 0;str[i]; i++){
int j = str[i] - 'a';
// 没有所需子结点就创建一个
if(!son[p][j])
son[p][j] = ++idx;
// 继续向下遍历
p = son[p][j];
}
// 结束遍历后,该字符串出现次数+1
cnt[p]++;
}
查询操作同理:
void insert(char *str){
int p = 0;// 从根结点开始遍历
for(int i = 0;str[i]; i++){
int j = str[i] - 'a';
// 该结点没有找到则该字符串没有出现
if(!son[p][j])
return 0;
// 继续向下遍历
p = son[p][j];
}
// insert操作所维护的字符串出现次数主要起这个作用
retrurn cnt[p];
}
模板题:Acwing 835
Acwing 143.最大异或对 此题涉及到二进制位运算,顺便就做了Acwing 801
Trie 树又叫字典树。字典是用来查字的,Trie 树最基本的作用是在树上查找字符串。
例如有 5 个字符串: him 、 her 、 cat 、 no 、 nova 。现在要查找 catch 是否存在。
如果使用暴力的方法,需要用 catch 与这 5 个字符串分别进行匹配,效率较低。
如果将这 5 个字符串存储成 Trie 的结构,只需要顺着路径依次比较,比较完 cat 之后,没有节点与 c 匹配,所以字符串集合中不存在 catch。
Trie树的核心思想是空间换时间,利用字符串的公共前缀来减少无谓的字符串比较以达到提高查询效率的目的。
优点:插入和查询的效率很高,都为O(m)。其中 m 是待插入/查询的字符串的长度。
缺点:空间消耗比较大。
Trie树还可以用来词频统计, 通过insert操作中所维护的count来实现
牛客论坛项目中的敏感词过滤功能也是使用Trie树实现的,之后有时间再进行总结
由这三位学者发明的:Knuth,Morris和Pratt,所以取了三位学者名字的首字母。
进行两字符串的匹配过程中,记录一部分之前已经成功匹配的文本内容
当出现不匹配情况时,可以基于所记录的内容,以避免从头重新匹配字符串。
前缀表即为以上所记录的内容,KMP中广泛使用的next数组本质上就是前缀表
前缀表是用来回退的,它记录了模式串与主串(文本串)不匹配的时候,模式串应该从哪里开始重新匹配。前缀表中实际上存放的是模式串每一位至起始位之间的最长相同前后缀的长度。
前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串。
后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串(注意是正序而不是倒序)。
如下图所示,“aaba”的最长相同前后缀为“a”,长度为1
“aabaa”的最长相同前后缀为“aa”,长度为2
求解前后缀的代码如下:
vector get_next(string& patt){
vector next(patt.size());
next[0] = 0;
// 需要维护一个当前的最长相同前后缀的长度值
int prefix_len = 0;
// 此处next数组的含义是:子串中最长的 相同前后缀的长度
// 也刚好是 以i结尾最长前缀的下一个位置
for(int i = 1; i < patt.size();i++){
// 基于先前计算的前缀长度在前缀中重新找一段更小的前缀
while(prefix_len > 0 && patt[i] != patt[prefix_len]){
// 没找到就一直循环寻找
prefix_len = next[prefix_len - 1];
}
// 若成功匹配 则最长相同前后缀的长度+1
if(patt[prefix_len] == patt[i]){
prefix_len++;
}
// 经以上处理 将prefix_len放入next中
next[i] = prefix_len;
}
return next;
}
前缀表可以告诉我们匹配失败之后跳到哪里重新匹配
匹配过程演示可参考视频:最浅显易懂的 KMP 算法讲解_哔哩哔哩_bilibili
如下图所示,下标5之前这部分的字符串(也就是字符串aabaa)的最长相等的前缀 和 后缀字符串是 子字符串aa ,因为找到了最长相等的前缀和后缀,匹配失败的位置是后缀子串的后面,那么我们找到与其相同的前缀的后面重新匹配就可以了。
next数组本质上就是前缀表,我在此处也是直接将前缀表作为next数组,更易理解
但是很多实现都是把前缀表统一减一(右移一位,初始位置为-1)之后作为next数组。
但这并不涉及到KMP的原理,只是具体实现的不同
next数组既可以就是前缀表,也可以是前缀表统一减一(右移一位,初始位置为-1)。
int strStr(string s, string patt) {
vector next = get_next(patt);
// i用于遍历文本串 j用于遍历模式串
int j = 0;
for(int i = 0;i < s.size(); i++){
// 若不匹配 j指针则根据上一位的next数组进行跳转
while(j > 0 && s[i] != patt[j]){
j = next[j-1];
}
// 字符匹配 j指针右移
if(s[i] == patt[j])
j++;
if(j == patt.size())
return (i - j + 1);
}
return -1;
}
其中n为文本串长度,m为模式串长度,暴力解法的时间复杂度显而易见是O(n × m)
因为在匹配的过程中,根据前缀表不断调整匹配的位置,可以看出匹配的过程是O(n),之前还要单独生成next数组,时间复杂度是O(m)。所以整个KMP算法的时间复杂度是O(n+m)的。
所以KMP在字符串匹配中极大地提高了搜索的效率
Acwing 831 KMP字符串
Leetcode 28. 找出字符串中第一个匹配项的下标
Leetcode 459. 重复的子字符串
AcWing 835. Trie树图文详解 - AcWing
代码随想录