트라이(Trie / Prefix tree)는 문자열을 저장하고 검색하는 데 일반적으로 사용되는 알고리즘 방법 중 하나이다. 트리 구조를 기반으로 사용하며 효율적인 검색을 할 수 있게 해준다는 데서 검색(retrieval)에서 이름을 가져왔다.
처음에는 빈 루트 노드만이 위치한다. 여기에 chick이라는 문자열을 넣는다고 하자.
문자열 중 첫번째 문자인 c에 대응하는 노드가 없기 때문에 루트 노드로부터 새로운 노드를 생성하고 노드의 값은 문자 c가 된다. 다음으로 삽입을 진행할 h도 그에 대응하는 노드가 없으므로 c 노드의 아래에 노드를 생성하고 값을 h로 한다.
그 다음 문자들에 대해서도 각각에 대응하는 노드가 없으므로 새로운 노드를 생성하고 값은 각각의 문자를 넣는다. 따라서 'chick'이라는 문자열을 모두 삽입하고 나면 이러한 구조가 된다. 문자열의 끝인 'k'는 자신이 문자열의 끝임을 알려주어야 한다.(보통, boolean 값으로 문자열의 끝=true로 표시하거나 데이터 구조에 대한 포인터를 가리킴) 그리고 다음으로 'choco'와 'chi'를 삽입하도록 하자.
'choco'를 삽입할 때, 'c'와 'h'는 이미 트리에 삽입되어 있으므로 새로운 노드를 생성하지 않는다. 다음에 오는 'o'는 'h' 다음의 노드 중 존재하지 않으므로 노드를 생성, 값을 삽입한다.
'c', 'o'에 대해서도 그에 대응하는 값을 가진 노드가 없으므로 같은 과정이 진행된다. 또한 'o' 노드는 문자열의 끝을 가리키도록 한다. 다음으로 'chi' 문자열을 삽입하자.
'chi'는 각각의 문자에 대응되는 노드가 이미 삽입되어 있다. 'c', 'h', 'i' 모두 이미 트리에 있으므로 새로운 노드를 생성하지 않는다. 대신 문자열의 끝인 'i' 노드에 자신이 문자열의 끝임을 가리키도록 한다.
위의 과정을 통해 생성된 트라이 구조에서 문자열 'chick'과 'choc','char'를 검색해보자. 'chick'부터 루트 노드에서부터 검색을 시작한다. 'chick'을 검색할 때, 첫번째 문자인 'c'가 루트 노드의 자식 노드에 있는지 탐색한다. 'c'가 존재하므로 다음 문자인 'h'가 존재하는지 탐색한다. 이러한 방식으로 탐색을 진행하면 결국 마지막 문자인 'k'를 검색한다. 'k'가 존재하고, 자신이 문자열의 끝임을 나타내고 있으므로 'chick'은 트리에 존재함을 확인하고 검색을 종료한다. 'choc'를 검색하면 어떻게 되는가. 마찬가지로 'c', 'h', 'o', 'c'에 대해서는 각각에 대응하는 노드가 존재함을 탐색을 통해 알 수 있으므로 진행한다. 하지만 문자열의 끝이어야 하는 'c'는 문자열의 끝이 아니므로 'choc'은 트리에 존재하지 않는 단어이다. 'char'를 검색하는 과정은 어떻게 되는가. 'c', 'h'까지는 트리에 존재하는 단어이므로 검색을 진행한다. 하지만 'a'는 'h'의 하위 노드 중에 존재하지 않으므로 탐색을 진행하여 없음을 확인하고 검색이 종료된다.
이러한 방식으로 트리를 사용한 트라이 알고리즘을 구현한다.
정리하면
트라이의 장단점은 다음과 같다.
다음은 트라이를 사용한 알고리즘 문제 풀이 예시이다.
#include <iostream>
using namespace std;
typedef struct trie {
bool finish;
trie* node[15];
//생성자
trie() {
finish = false;
for (int i = 0; i < 10; i++) {
node[i] = NULL;
}
}
//소멸자
~trie() {
for (int i = 0; i < 10; i++) {
if (node[i]) {
delete node[i];
}
}
}
void insert(char* str) {
if (*str == NULL) {
finish = true;
return;
}
int k = *str - '0';
if (node[k] == NULL) {
node[k] = new trie();
}
node[k]->insert(str + 1);
}
bool find(char* str) {
if (*str == NULL) {
if (finish == true) {
return true;
}
return false;
}
if (finish == true) {
return false;
}
int k = *str - '0';
if (node[k] == NULL) {
return false;
}
return node[k]->find(str + 1);
}
};
int main() {
int t, n;
char s[10005][15];
bool ans;
ios::sync_with_stdio(false);
cin.tie(NULL);
cout.tie(NULL);
cin >> t;
while (t--) {
trie* tri = new trie();
cin >> n;
for (int i = 0; i < n; i++) {
cin >> s[i];
tri->insert(s[i]);
}
ans = true;
for (int i = 0; i < n; i++) {
if (tri->find(s[i]) == false) {
ans = false;
break;
}
}
delete tri;
cout << ((ans) ? "YES\n" : "NO\n");
}
return 0;
}
숫자(0~9)를 영문자 대신으로 트라이를 구현한 것이다. 원리는 위의 트라이 원리 설명과 같다. 번호를 삽입할 때, 각 번호의 자릿수만큼 탐색을 진행하여 번호에 해당하는 노드가 있으면 다음 자리의 번호로 이동하여 과정을 수행하고 없으면 새로운 노드를 생성, 해당 번호의 값을 부여한다. 번호를 검색할 때, 각 번호의 자릿수만큼 탐색을 진행하여 검색하려는 전화번호가 아직 끝나지 않았지만 현재 탐색 중인 노드가 문자열의 끝을 나타낼 경우, 검색을 중단하고 결과를 반환한다.
#include <iostream>
#include <vector>
#include <map>
using namespace std;
typedef struct trie {
map<string, trie*> prey;
trie() {
}
~trie() {
for (auto it = prey.begin(); it != prey.end(); it++) {
delete it->second;
}
}
void insert(vector<string> v, int idx) {
if (idx == v.size()) {
return;
}
else {
string s = v[idx];
auto it = prey.find(s);
if (it != prey.end()) {
it->second->insert(v, idx + 1);
}
else {
trie* t = new trie();
prey.insert({ s,t });
t->insert(v, idx + 1);
}
}
}
void print(int idx) {
if (prey.empty()) {
return;
}
else {
for (auto it = prey.begin(); it != prey.end(); it++) {
for (int i = 0; i < idx; i++) {
cout << "--";
}
cout << it->first << "\n";
it->second->print(idx + 1);
}
}
}
};
int main() {
int n, k;
string s;
trie* nest = new trie();
ios::sync_with_stdio(false);
cin.tie(NULL);
cout.tie(NULL);
cin >> n;
for (int i = 0; i < n; i++) {
cin >> k;
vector<string> v;
for (int j = 0; j < k; j++) {
cin >> s;
v.push_back(s);
}
nest->insert(v, 0);
}
nest->print(0);
delete nest;
return 0;
}
문자가 아닌 문자열이 하나의 노드로 들어올 수 있는가? 나는 map 구조를 사용해 각 문자열에 해당하는 트라이 포인터를 반환함으로써 이 문제를 해결했다. 이보다 더욱 효율적인 해결 방법이 반드시 있으리라 생각한다.
#include <bits/stdc++.h>
using namespace std;
struct trie {
int finish;
trie* node[30];
trie() {
finish = 0;
for (int i = 0; i < 26; i++) {
node[i] = NULL;
}
}
~trie() {
for (int i = 0; i < 26; i++) {
if (node[i]) {
delete node[i];
}
}
}
void id_insert(char* str) {
if (*str == NULL) {
finish++;
return;
}
int k = *str - 'a';
if (node[k] == NULL) {
node[k] = new trie();
}
node[k]->id_insert(str + 1);
}
bool id_find(char* str) {
if (*str == NULL) {
if (finish > 0) {
cout << finish + 1;
return true;
}
return false;
}
else {
cout << *str;
}
int k = *str - 'a';
if (node[k] == NULL) {
return false;
}
return node[k]->id_find(str + 1);
}
};
int main() {
int n;
char pname[15];
trie* tri = new trie();
ios::sync_with_stdio(false);
cin.tie(NULL);
cout.tie(NULL);
cin >> n;
while (n--) {
cin >> pname;
tri->id_find(pname);
tri->id_insert(pname);
if (n > 0) {
cout << "\n";
}
}
return 0;
}
문자열의 끝이 없음(null)에도 반환하는 실수를 깨닫지 못해 꼬박 10시간 동안 분투했던 문제이다. 트라이 외에도 더 효율적인 풀이 방법이 있다고 하니 고민해보자.