题解 P2580 【于是他错误的点名开始了】
Ciyang
2019-01-06 06:53:54
### 声明:
此题解非正常Trie,推荐先学习最普通的Trie并了解指针的使用
### 废话:
很早之前就想到了一种字典树,节点存储字符串,代表Trie树上以某字符开头的所有子树最长公共前缀.好像说的不清楚,那就再听我仔细讲解吧
写题解的上午有了灵感,然后根据思路模拟了一下,可行性挺高的.
写完代码后提交AC,跑了最优解第四,然而前三用的hash算法没法比啊.
我现暂且命名它为~~Ciyang_Trie~~(Lumpy_Trie块状字典树),怀疑已经有人发明过了.
代码比普通的复杂一些,我使用了指针.
按任意顺序插入abcd,abcde,bcde,bcef四个字符串的Trie树长这样:
![Trie01](https://cdn.luogu.com.cn/upload/pic/48086.png)
红色节点代表有插入的字符串结尾的节点.
### 证明:
通过与比较普通指针版和非指针版Trie来证明一下可行性.
指针版Trie可使用动态内存,缺点是每个节点只保存一个字符,而同时又要保存26个子节点的指针,会有大量的空指针来占用额外的内存,且new节点多了,内存分配常数较大.
为了减少常数,可以自己写new方法来分配,但无论是什么数据,只要稍带随机性,就会有很多只有一个子节点的节点,这无疑有25个空指针浪费内存.
非指针版常数小,但空间分配也是很大的问题.
然而仍有很多一条链的树,空间最大浪费N\*25啊...~~先不说影响美观~~而且时间复杂度依然很高,毕竟查询也是O(N).
Lumpy_Trie的复杂度是会改变的,就是对一条链情况的优化,理论最大时间复杂度是O(N)带有一些常数.
Lumpy_Trie缺点是写起来比普通的更复杂,优点是经得起大数据考量,而且插入最小是O(1),空间上也少了很多空指针.
Lumpy_Trie巧妙利用字符串指针,赋值、继承等操作只需要指针或长度变化就好了.
#### 太多证明不如一句代码,我放上代码继续分析.
### 解析:
节点的结构体,相比普通Trie多了length和字符串指针:
```cpp
#define clear(a) memset(a, 0, sizeof(a))
#define copy(a, b) memcpy(a, b, sizeof(a))
struct Lumpy_Tnode {
const char *pStr;
//指向当前存储字符串首元素
int length, isEnd;
//length存储字符串的长度 isEnd代表是否是结尾节点
Lumpy_Tnode *children[26];
//26个子节点
inline Lumpy_Tnode() {
pStr= 0, length= isEnd= 0, clear(children);
}
inline Lumpy_Tnode(const char *str, int len, int end) {
pStr= str, length= len, isEnd= end, clear(children);
}
//构造函数
} mNode;
```
#### 插入操作:
使用递归和循环,判断比较多,先看代码(看起来常数很大)
```cpp
inline void insert(const char *str, int length, Lumpy_Tnode *bNode) {
//str是指针,指向当前插入字符串的第一个元素
if(!length) {
//字符串长度为0代表结尾
//其实是为了优化代码美观,当作递归边界
bNode->isEnd= 1;
return;
}
int ch= str[0] - 'a';
if(bNode->children[ch]) {
//子节点已存在
bNode= bNode->children[ch];
register int sptr= 0;
while(sptr < length && sptr < bNode->length && bNode->pStr[sptr] == str[sptr]) ++sptr;
//循环来找当前字符串和节点存储的字符串最长前缀
if(sptr != bNode->length) {
//节点存储的字符串不是插入字符串的子串
Lumpy_Tnode *nNode= new Lumpy_Tnode(bNode->pStr + sptr, bNode->length - sptr, bNode->isEnd);
//拆树,运用字符串指针连续地址的特性来操作
copy(nNode->children, bNode->children), clear(bNode->children);
//继承原有子节点的各种信息
bNode->isEnd= 0, bNode->children[bNode->pStr[sptr] - 'a']= nNode;
//清空原有节点,重新初始化
}
bNode->length= sptr;
//更新当前节点存储的字符串长度,从而更改当前存储的字符串
insert(str + sptr, length - sptr, bNode);
}
else
bNode->children[ch]= new Lumpy_Tnode(str, length, 1);
//不存在当前首字母的子节点,直接new并且赋值
//因为是指针操作,所以不需要O(n)复制字符串,理论上复杂度O(3)?
return;
}
//调用方式:insert(插入字符串, 字符串长度, Trie根节点);
```
如果代码看懂了,第一反应可能认为指针操作有问题.
的确插入的字符串在插入后就不能进行改变了,所以就只要开一个char\[N\]\[K\]的数组来保存输入的字符串,K为最长字符串的长度.
相比较空间复杂度总体仍然较小,其实是把原来每个节点存的char放到了一起,每个节点多了一个指针.(不过创建的节点更少了,所以浪费空间少)
这其中其实有个很巧妙的事,树上的一条链可能指向的地址是连续的.仔细想了想,其实也有空间浪费,不管是节点上还是树上的最长公共前缀都只指向一个字符串,其他字符串中相同的字符占用的空间就永远浪费掉了,这句话不懂没事,因为这个浪费造成的影响很小.
如果代码都没看懂,还有图解:
1. 向空树插入abc,再插入ab:
![Trie02](https://cdn.luogu.com.cn/upload/pic/48101.png)
2. 再向此树插入ac:
![Trie03](https://cdn.luogu.com.cn/upload/pic/48103.png)
解释一下图2:
比较ab和ac,最长公共前缀为a
新建一个字符串指针指向ab中b的节点,长度为1,继承ab的颜色和ab的子节点.
清空ab的子节点,颜色改为黑(黑表示不为结尾节点),ab的首字母b子节点指向b.
在我实现时,先更改长度使ab变为a,再向a中插入c.
因为没有首字母为c的子节点,直接new一个新的.
#### 查找操作:
```cpp
inline int find(const char *str, int length, Lumpy_Tnode *bNode) {
if(!length) {
//递归到查找的字符串长度为0
//判断当前节点是否为结尾,是否是第一次查询
if(bNode->isEnd == 1) return bNode->isEnd++;
return bNode->isEnd;
}
int ch= str[0] - 'a';
if(bNode->children[ch]) {
bNode= bNode->children[ch];
if(length < bNode->length) return 0;
//自带剪枝,若当前查找字符串长度小于当前公共前缀,那么字典树中不存在当前查找的字符串
register int sptr= 0;
while(sptr < bNode->length && bNode->pStr[sptr] == str[sptr]) ++sptr;
if(sptr != bNode->length) return 0;
//最长公共前缀必须是当前查找的字符串的子串
return find(str + sptr, length - sptr, bNode);
}
return 0;
//没有子节点,字典树中不存在当前查找的字符串
}
//调用方式:find(查询字符串, 字符串长度, Trie根节点);
```
比插入的代码简单多了,并且自带剪枝,所以比较快.
### 完整代码:
#### (开启O2)用时: 127ms / 内存: 4248KB
#### (关闭O2)用时: 144ms / 内存: 4128KB
```cpp
#include <iostream>
#include <stdio.h>
#include <string.h>
using namespace std;
#define clear(a) memset(a, 0, sizeof(a))
#define copy(a, b) memcpy(a, b, sizeof(a))
struct Lumpy_Trie {
struct Lumpy_Tnode {
const char *pStr;
int length, isEnd;
Lumpy_Tnode *children[26];
inline Lumpy_Tnode() {
pStr= 0, length= isEnd= 0, clear(children);
}
inline Lumpy_Tnode(const char *str, int len, int end) {
pStr= str, length= len, isEnd= end, clear(children);
}
} mNode;
inline void insert(const char *str, int length, Lumpy_Tnode *bNode) {
if(!length) {
bNode->isEnd= 1;
return;
}
int ch= str[0] - 'a';
if(bNode->children[ch]) {
bNode= bNode->children[ch];
register int sptr= 0;
while(sptr < length && sptr < bNode->length && bNode->pStr[sptr] == str[sptr]) ++sptr;
if(sptr != bNode->length) {
Lumpy_Tnode *nNode= new Lumpy_Tnode(bNode->pStr + sptr, bNode->length - sptr, bNode->isEnd);
copy(nNode->children, bNode->children), clear(bNode->children);
bNode->isEnd= 0, bNode->children[bNode->pStr[sptr] - 'a']= nNode;
}
bNode->length= sptr;
insert(str + sptr, length - sptr, bNode);
}
else
bNode->children[ch]= new Lumpy_Tnode(str, length, 1);
return;
}
inline int find(const char *str, int length, Lumpy_Tnode *bNode) {
if(length == 0) {
if(bNode->isEnd == 1) return bNode->isEnd++;
return bNode->isEnd;
}
int ch= str[0] - 'a';
if(bNode->children[ch]) {
bNode= bNode->children[ch];
if(length < bNode->length) return 0;
register int sptr= 0;
while(sptr < bNode->length && bNode->pStr[sptr] == str[sptr]) ++sptr;
if(sptr != bNode->length) return 0;
return find(str + sptr, length - sptr, bNode);
}
return 0;
}
} t;
char allstr[10001][51], tmp[51];
int n;
int main() {
scanf("%d", &n);
for(register int i= 0; i < n; i++) {
scanf("%s", allstr[i]);
t.insert(allstr[i], strlen(allstr[i]), &t.mNode);
}
scanf("%d", &n);
for(register int i= 0, j; i < n; i++) {
scanf("%s", tmp);
j= t.find(tmp, strlen(tmp), &t.mNode);
switch(j) {
case 0: printf("WRONG\n"); break;
case 1: printf("OK\n"); break;
case 2: printf("REPEAT\n"); break;
}
}
return 0;
}
```
最需要注意的事,要用数组保存输入的字符串,不能更改!!!
### 后续:
我写的常数可能挺大,希望dalao们试试各种卡常优化...
不知道能不能搞个大事情,~~这个字典树出名了别忘了Ciyang~~(出名我就女装了)
又想了一下,应该很容易改成可持久化Trie.
如果这个算法早就有人发明了告诉我一下.
希望题解能过!!!