Trie字典树

\(Trie\)字典树

概念

设想一个问题:给定一篇文章 \(T\) 和一系列字符串 \(S_i\),求每一个 \(S_i\)\(T\) 中的出现次数。

显然如果对于每一个 \(S_i\),均与 \(T\) 中与每一个字符串 \(T_i\) 比较,其时间复杂度达到了 \(O(|S|·|T|)\),显然无法处理大量的数据。

我们再观察每一次比较步骤:

  • 先比较 \(S_i\)\(T_j\) 的第一个字符
  • 如果相等则继续比较第二个字符,以此类推
  • 如果不相等则直接返回

显然,我们希望 \(S_i\)\(T_j\)尽量靠前的地方产生差异。而对于每一个 \(S_i\),所有 \(T_j\) 中与 \(S_i\) 相等的前缀都没有用,每次反而重复计算,于是我们有了字典树:

字典树 \(Trie\),指的是将一个字符串集合 \(S\) 转换为一种特殊的有根树,如下图:

graph TB 1(( )) 2((A)) 3((A)) 4((A)) 5((G)) 6((T)) 7((C)) 8((G)) 9((A)) 1--->2 2--->3 3--->4 3--->5 1--->6 6--->7 6--->8 7--->9

\(S = \{AAA,AAG,T,TCA,TG\}\)

很容易看出。\(Trie\)字典树很好的利用了公共前缀节约了储存空间和访问时间

算法实现

对于每一个节点 \(u\),我们有两个属性:

  1. \(u\) 相连的子节点 \(v\)

    注意:每一次访问节点都会查找它的所有子节点,这时我们直接用数组下标表示这个字母,节约时间

  2. 这个节点作为字符串末尾的次数,值为 \(0\) 则代表这不是一个字符串末尾

const int maxn = 1e4,maxl = 10,maxc = 26; // 最大字符串数量,最大字符串长度,最大字符值域(0~9 = 10,a~z = 26)
struct node {
  int cnt; // 作为末尾的次数
  int to[maxc + 5]; // to[t]:值为t的子节点的编号
};
int nodecnt = 0; // 节点计数器
node tree[maxn + 5]; // 树

插入字符串

我们从根节点超级原点,确保第一位不相同也能插入)开始插入,当前为 \(u\),插入字符串为 \(S_t\),当前为第 \(t\) 位:

  1. 寻找 \(u\) 的子节点 \(v_{S_t}\),如果没有编号则新建节点
  2. \(u\) 向下移动到 \(v_{S_t}\)\(t\) 向后向后移一位至 \(S_{t+1}\)
  3. 重复上述步骤直到整个 \(S\) 插入完毕,末尾计数器增加
void insert(char *s) { // 插入s
  int u = 0,len = strlen(s); // 当前节点(0为超级原点)
  for (int i = 0;i < len;i ++) { // 依次插入s的每一个字符
    int t = s[i] - 'a'; // 当前字符对应值
    if (tree[u].to[t] == 0) tree[u].to[t] = ++ nodecnt; // 新建节点
    u = tree[u].to[t]; // 向下转移
  }
  tree[u].cnt ++; // 末尾计数器增加
}

查询字符串(字符串存在与否,字符串数量)

查询字符串基本逻辑与插入差不多,从根开始查找:

  1. 寻找 \(u\) 的子节点 \(v_{S_t}\),如果没有编号则直接返回 \(0\)(这个节点下方没有匹配的子节点,代表没有这个字符串
  2. \(u\) 向下移动到 \(v_{S_t}\)\(t\) 向后向后移一位至 \(S_{t+1}\)
  3. 重复上述步骤直到整个 \(S\) 插入完毕,返回末尾计数器(如果等于 \(0\) 则相当于没有这个字符串
 int count(char *s) { // 查询s
  int u = 0,len = strlen(s); // 当前节点(0为超级原点)
  for (int i = 0;i < len;i ++) { // 依次访问s的每一个字符
    int t = s[i] - 'a'; // 当前字符对应值
    if (tree[u].to[t] == 0) return 0; // 没有找到
    u = tree[u].to[t]; // 向下转移
  }
  return tree[u].cnt; // 返回末尾计数器
}

查询前缀关系

定义:查找文章 \(T\) 中有多少个字符串 \(T_i\) 是给定字符串 \(S_i\) 的前缀

查询字符串的前缀关系基本逻辑与查询差不多,从根开始查找:

  1. 寻找 \(u\) 的子节点 \(v_{S_t}\),如果没有编号则直接返回答案 \(ans\)(这个节点下方没有匹配的子节点,代表查询结束
  2. \(u\) 向下移动到 \(v_{S_t}\)\(t\) 向后向后移一位至 \(S_{t+1}\)
  3. 答案 \(ans\) 增加 \(u\)(当前为 \(v_{s_t}\)) 的末尾计数器注意:一定在转移后增加(根没有意义
  4. 重复上述步骤直到整个 \(S\) 插入完毕,返回记录的答案 \(ans\)
int countPre(char *s) { // 查询s的前缀数量
  int u = 0,len = strlen(s); // 当前节点(0为超级原点)
  int ans = 0; // 答案记录
  for (int i = 0;i < len - 1;i ++) { // 依次访问s的每一个字符
    int t = s[i] - 'a'; // 当前字符对应值
    if (tree[u].to[t] == 0) break; // 查找完毕
    u = tree[u].to[t]; // 向下转移
    ans += tree[u].cnt; // 记录答案
  }
  return ans; // 返回答案
}

例题

洛谷P2580 于是他错误的点名开始了

#include <bits/stdc++.h>
using namespace std;
const int maxn = 5e5,maxc = 26;
struct TRIE {
	struct node {
		int cnt;
		int to[maxc + 5];
		int flag;
	};
	int nodecnt = 0;
	node tree[maxn + 5];
	void insert(char *s) {
		int u = 0,len = strlen(s);
		for (int i = 0;i < len;i ++) {
			int t = s[i] - 'a';
			if (tree[u].to[t] == 0) tree[u].to[t] = ++ nodecnt;
			u = tree[u].to[t];
		}
		tree[u].cnt ++;
		tree[u].flag = 1;
	}
	bool find(char *s) {
		int u = 0,len = strlen(s);
		for (int i = 0;i < len;i ++) {
			int t = s[i] - 'a';
			if (tree[u].to[t] == 0) return 0;
			u = tree[u].to[t];
		}
		return tree[u].flag;
	}
	int count(char *s) {
		int u = 0,len = strlen(s);
		for (int i = 0;i < len;i ++) {
			int t = s[i] - 'a';
			if (tree[u].to[t] == 0) return 0;
			u = tree[u].to[t];
		}
		return tree[u].cnt;
	}
};
TRIE trie;
char s[55];
int main() {
	int n; scanf("%d",&n);
	for (int i = 1;i <= n;i ++) {
		scanf("%s",s);
		trie.insert(s);
	}
	int m; scanf("%d",&m);
	for (int i = 1;i <= m;i ++) {
		scanf("%s",s);
		int t = trie.count(s);
		if (t == 0) printf("WRONG\n");
		else if (t == 1) {
			printf("OK\n");
			trie.insert(s);
		} else printf("REPEAT\n");
	}
	return 0;
}
posted @ 2025-03-10 22:45  nightmare_lhh  阅读(13)  评论(0)    收藏  举报