안녕하세요, 여행벌입니다.

오늘은 문자열 탐색에 특화된 자료구조 트라이(Trie)에 대해서 포스팅해보겠습니다.


트라이 Trie 기본 개념

 트라이는 문자열의 집합을 표현하는 트리 자료 구조로 위의 그림과 같은 형태로 이루어져 있습니다. 트라이의 한 노드를 표현하는 객체는 자손 노드들을 가리키는 포인터 목록과, 이 노드가 종료 노드인지를 나타내는 불린 값 변수로 구성됩니다.

 

● 자손 노드들을 가리키는 포인터 목록은 입력에 등장할 수 있는 모든 문자에 각각 대응되는 원소를 갖는 고정 길이 배열로 구현합니다. 

 

루트에서 한 노드까지 내려가는 경로에서 만나는 글자들을 모으면 해당 노드에 대응되는 접두사를 얻을 수 있습니다. 따라서, 각 노드에 대응되는 문자열을 따로 저장할 필요가 없습니다. 즉, Hello, Hell 은 겹치는 부분(Hell) 을 따로 저장해두는 것이 아니라 하나의 경로에 같이 저장하는 것을 확인할 수 있습니다.

 

문자열은 정수, 실수형 변수와 다르게 비교하는 데 최악의 경우 문자열의 길이에 비례하는 시간이 걸립니다. 따라서, 정수나 실수 키에 대해서는 잘 동작하는 탐색 자료 구조들도 문자열을 키로 사용할 때는 시간이 오래 걸릴 수 있습니다. 예를 들어, N개의 원소를 갖는 이진 검색 트리에서 원하는 원소를 찾으려면 O(logN)번의 비교를 해야 하지만, 문자열의 비교에서는 최대 문자열의 길이를 M이라고 하면 O(MlogN)이 최종 시간 복잡도가 됩니다. 이런 문제를 해결하기 위해 자료구조 트라이(Trie)가 등장했습니다.

 

● 즉, 트라이 자료구조를 이용하면 집합 내에서 문자열 원소를 찾는 작업을 O(M) 시간만에 할 수 있습니다.

트라이 Trie 코드 구현 (C++)

 트라이 객체는 포인터와 마지막 노드인지 아닌지 체크를 해주는 bool 변수로 이루어져 있다고 위에서 언급했습니다. 입력되는 문자열이 알파벳 대문자인 경우의 트라이 객체를 구현해보겠습니다.

struct Trie {
	bool finish;    //끝나는 지점을 표시해줌
	Trie* nextNode[26];    //26가지 알파벳에 대한 트라이 포인터 배열
	Trie() {
		finish = false;
		memset(nextNode, 0, sizeof(nextNode));
	}
	~Trie() {
		for (int i = 0; i < 26; i++)
			if (nextNode[i])
				delete nextNode[i];
	}

	void insert(const char* key) {
		if (*key == '\0')
			finish = true;    //문자열이 끝나는 지점일 경우 표시
		else {
			int next = *key - 'A';
			if (nextNode[next] == NULL)
				nextNode[next] = new Trie();    //탐색이 처음되는 지점일 경우 동적할당
			nextNode[next]->insert(key + 1);    //다음 문자 삽입
		}
		return;
	}

	Trie* find(const char* key) {
		if (*key == 0)
			return this;
		int next = *key - 'A';
		if (nextNode[next] == NULL) // 문자열에 없음.
			return NULL;
		return nextNode[next]->find(key + 1); // 다음 문자로 탐색을 진행
	}
};

트라이 Trie 복잡도 계산

 트라이에서 중요한 연산은 find 함수와 insert 함수입니다. 이 함수들은 문자열의 길이만큼 재귀 호출을 수행하기 때문에, 두 함수의 시간 복잡도는 모두 문자열의 길이(M)에 비례합니다. 즉, 트라이에 포함된 다른 문자열의 개수(N)과는 상관이 없다는 것을 알 수 있습니다. 따라서 트라이는 빠른 속도가 필요한 검색 엔진이나 기타 문자열 처리 프로그램에서 자주 사용됩니다.

 

● insert 함수 시간 복잡도 : O(M)

 

● find 함수 시간 복잡도 : O(M)

트라이 Trie 단점

 트라이 자료구조는 치명적인 단점이 있습니다. 바로, 필요로 하는 공간(메모리)이 너무 크다는 문제입니다. 그 이유는 포인터 배열 때문입니다. 알파벳 대문자만 저장한다고 해도 트라이의 각 노드는 26개의 포인터를 저장해야 합니다. 또, 입력되는 문자열들이 접두사를 전혀 공유하지 않는 최악의 경우 트라이에는 입력되는 문자열들의 전체 길이만큼의 노드가 필요합니다. 

트라이 꿀팁 Tip

 위의 단점 때문에 트라이를 사용해야 하는 알고리즘 문제에서는 대체로 입력되는 문자열들의 전체 길이를 알려줍니다! 문자열을 탐색하는 문제 + 입력되는 문자열들의 전체 길이를 알려주는 힌트가 문제에 있다면, 트라이 자료구조를 사용하는 문제인지 아닌지 의심해볼 수 있습니다.

트라이 예시

#include<iostream>
#include<string>
#include<vector>
#include<cstring>
#include<algorithm>

using namespace std;

struct Trie {
	bool finish;    //끝나는 지점을 표시해줌
	Trie* nextNode[26];    //26가지 알파벳에 대한 트라이 포인터 배열
	Trie() {
		finish = false;
		memset(nextNode, 0, sizeof(nextNode));
	}
	~Trie() {
		for (int i = 0; i < 26; i++)
			if (nextNode[i])
				delete nextNode[i];
	}

	void insert(const char* key) {
		if (*key == '\0')
			finish = true;    //문자열이 끝나는 지점일 경우 표시
		else {
			int next = *key - 'A';
			if (nextNode[next] == NULL)
				nextNode[next] = new Trie();    //탐색이 처음되는 지점일 경우 동적할당
			nextNode[next]->insert(key + 1);    //다음 문자 삽입
		}
		return;
	}

	Trie* find(const char* key) {
		if (*key == 0)
			return this;
		int next = *key - 'A';
		if (nextNode[next] == NULL) // 문자열에 없음.
			return NULL;
		return nextNode[next]->find(key + 1); // 다음 문자로 탐색을 진행
	}
};

int main(void) {
	ios::sync_with_stdio(false);
	cin.tie(0); cout.tie(0);

	Trie* trie = new Trie();

	trie->insert("HELLO");
	trie->insert("HELL");
	trie->insert("HALL");
	trie->insert("HALO");
	trie->insert("TRAVEL");


	cout << trie->find("HELLO") << endl;
	cout << trie->find("HALO") << endl;
	cout << trie->find("TRAVELBEEEE") << endl;
	cout << trie->find("BEE") << endl;

	delete trie;
}

 대문자로 이루어진 문자열 { "HELLO", "HELL", "HALL", "HALO", "TRAVEL" } 을 Trie에 삽입하고 "HELLO", "HALO", "TRAVELBEEEE", "BEE" 를 탐색해보겠습니다. 결과는 다음과 같습니다.

010AEFC0
010AF220
00000000
00000000

 "TRAVELBEEEE", "BEE" 는 Trie에 존재하지않으므로, null 값이 반환된 것을 확인할 수 있습니다.


 이상으로 문자열 탐색에 특화된 자료구조 트라이(Trie) 포스팅을 마치겠습니다.

 

 

문제 :  카카오 2020 BLIND RECRUITMENT 가사 검색


트라이

 자료구조 트라이를 이용하면 문제에 접근하기는 쉬우나, 그냥 단순하게 트라이를 이용하면 시간초과 때문에 개고생하는 문제입니다. 개고생한 과정을 다뤄보겠습니다...

 

시도 1)

 그냥 트라이를 하나 선언하고 모든 words를 트라이에 insert 한 후 모든 queries를 트라이에서 find 하면서 가능한 모든 경우의 수를 카운트하며 탐색을 진행했다.

int find(const char* key) {
	int cnt = 0;
	if (*key == '\0' && finish == true)
		return 1;
	if (*key == '\0')
		return 0;

	if (*key == '?') {
		for (int i = 0; i < 26; i++)
			if (next[i] != NULL)
				cnt += next[i]->find(key + 1);
	}
	else {
		int curr = *key - 'a';
		if (next[curr] == NULL)
			return 0;
		cnt += next[curr]->find(key + 1);
	}
	return cnt;
}

  들어온 입력을 트라이에서 find 하면서 '?' 를 만나면 지금 노드에서 갈 수 있는 모든 경우를 카운트해줘야하므로 알파벳 소문자의 경우의 수 26가지를 다 탐색하며, 현재 노드에서 갈 수 있는 모든 노드로 다 이동하며 카운트를 해준다. 이러면, 시간 초과를 마주하게 된다. 단순하게 생각해보면 앞 쪽에 물음표들이 연달아 많이 나오는 경우는 처음부터 경우의 수가 굉장히 많아져서 시간 초과에 걸린다는 것을 알 수 있다. 예를 들어, "?????abc" 같은 경우를 find 하려면 최악의 경우 26^5을 다 타고 들어가게 된다.

 

시도 2)

트라이를 하나 더 선언했다. 그냥 트라이와 단어들을 reverse해서 저장하고 있는 트라이를 2개 이용해 queries 중에 앞이 '?' 이면 reverseTrie 에서 find를 하고, 아니라면 그냥 트라이에서 find를 진행했다.

 마찬가지로, 시간 초과를 마주하게 된다... "?????" 과 같이 모두 물음표인 경우 때문에 시간 초과에 걸린다는 것을 알 수 있었다. 

 

시도 3)

 길이가 i 인 단어들이 몇 개인지 애초에 미리 다 카운트해놓고, 시도2) 처럼 트라이 2개를 이용해서 문제를 해결하려했다. queries 중에 앞도 '?', 뒤도 '?' 라면 전체가 다 물음표인 경우이므로 그냥 길이가 같은 단어가 몇 개인지 return 해주고, 아니라면 트라이 2개를 이용해서 진행했다. 당연히 시간 초과에 봉착했다. 당연히, 앞 뒤가 모두 물음표는 아니지만 "a??????" 와 같이 한 글자만 문자가 나오고 뒤가 다 물음표가 나온다면 시도2)에서 개선된 점이 없다는 것을 알 수 있었다.

 

풀이 )

 결국 물음표를 만나면 탐색을 더 진행하는 것이 아니라 답을 바로 출력해줄 수 있어야된다는 것을 깨달았고, 같은 길이를 가진 words 들끼리 Trie를 선언해줬다. 그리고 trie 에 wordsCnt 라는 int형 변수를 추가해주고, insert 함수를 다음과 같이 수정했다.

	void insert(const char* key) {
		if (*key == '\0')
			finish = true;    //문자열이 끝나는 지점일 경우 표시
		else {
			int curr = *key - 'a';
			if (next[curr] == NULL)
				next[curr] = new Trie();    //탐색이 처음되는 지점일 경우 동적할당
			wordsCnt++;
			next[curr]->insert(key + 1);    //다음 문자 삽입
		}
	}

 즉, 현재 노드까지 겹치는 단어가 몇 개인지 insert 하면서 카운트를 진행했고, find 중 물음표를 만나면 바로 wordsCnt 값을 return 해주면서 끝내도록 개선했다.

#include<string>
#include<vector>
#include<cstring>
#include<algorithm>

using namespace std;

struct Trie {
	int wordsCnt;
	bool finish;    //끝나는 지점을 표시해줌
	Trie* next[26];    //26가지 알파벳에 대한 트라이
	Trie() {
		finish = false;
		wordsCnt = 0;
		memset(next, 0, sizeof(next));
	}
	~Trie() {
		for (int i = 0; i < 26; i++)
			if (next[i])
				delete next[i];
	}

	void insert(const char* key) {
		if (*key == '\0')
			finish = true;    //문자열이 끝나는 지점일 경우 표시
		else {
			int curr = *key - 'a';
			if (next[curr] == NULL)
				next[curr] = new Trie();    //탐색이 처음되는 지점일 경우 동적할당
			wordsCnt++;
			next[curr]->insert(key + 1);    //다음 문자 삽입
		}
	}

	int find(const char* key) {
		int cnt = 0;
		//cout << "현재 " << *key << "를 탐색중이고 " << wordsCnt << "는다음과같습니다.\n";
		if (*key == '\0' && finish == true)
			return 1;
		if (*key == '\0')
			return 0;

		if (*key == '?') {
			return wordsCnt;
		}
		else{
			int curr = *key - 'a';
			if (next[curr] == NULL)
				return 0;
			cnt += next[curr]->find(key + 1);
		}
		return cnt;
	}
};

vector<int> solution(vector<string> words, vector<string> queries) {
	Trie* trie[10001];
	Trie* reverseTrie[10001];
	for (int i = 1; i < 10001; i++){
		trie[i] = new Trie();
		reverseTrie[i] = new Trie();
	}

	for (int i = 0; i < words.size(); i++){
		trie[words[i].length()]->insert(words[i].c_str());
		reverse(words[i].begin(), words[i].end());
		reverseTrie[words[i].length()]->insert(words[i].c_str());
	}

	vector<int> answer;
	for (int i = 0; i < queries.size(); i++) {
		int ans;
        if(queries[i][0] == '?'){
			reverse(queries[i].begin(), queries[i].end());
			ans = reverseTrie[queries[i].length()]->find(queries[i].c_str());
		}
		else{
			ans = trie[queries[i].length()]->find(queries[i].c_str());
		}
		answer.push_back(ans);
	}
	return answer;
}

 

문제 : https://www.acmicpc.net/problem/5052


트라이 trie 문자열

 자료구조 트라이를 이용해서 문자열을 다 insert 해놓은 후, 모든 Input 문자열에 대해서 find를 진행하며, 해당 문자열 탐색이 끝나기 전에 terminal 이 true인 상황이 발생하면 다른 완성된 문자열이 탐색된 것이므로 NO를 출력하고 아니라면 YES를 출력한다.

#include<iostream>
#include<string>
#include<vector>
#include<cstdlib>
using namespace std;

struct TrieNode {
	TrieNode* child[10];
	bool terminal;

	TrieNode() : terminal(false) {
		memset(child, 0, sizeof(child));
	}
	~TrieNode() {
		for (int i = 0; i < 10; i++)
			if (child[i])
				delete child[i];
	}

	void insert(const char* key) {
		if (*key == 0)
			terminal = true;
		else {
			int next = (*key - '0');
			if (child[next] == NULL)
				child[next] = new TrieNode();
			child[next]->insert(key + 1);
		}
	}

	bool find(const char* key) {
		if (*key == 0)
			return false;
		if (terminal)
			return true;
		int next = (*key - '0');
		if (child[next] == NULL)
			return false;
		return child[next]->find(key + 1);
	}
};

int main(void) {
	ios::sync_with_stdio(false);
	cin.tie(0); cout.tie(0);

	int t;
	cin >> t;
	while (t--) {
		int n;
		TrieNode* trie = new TrieNode();
		vector<string> input;
		cin >> n;
		for (int i = 0; i < n; i++) {
			string temp;
			cin >> temp;
			input.push_back(temp);
			trie->insert(temp.c_str());
		}
		bool flag = false;
		for (int i = 0; i < n; i++) {
			if (trie->find(input[i].c_str()) == true) {
				flag = true;
				break;
			}
		}
		if (flag) cout << "NO\n";
		else cout << "YES\n";
	}
}

 

문제 : 2020 카카오 BLINE RECRUITMENT 자물쇠와 열쇠


구현

● 열쇠를 돌리고 옮겨가며 자물쇠와 맞는지 안맞는지 체크하면 된다.

● 열쇠는 기존의 상태, 90도 회전 상태, 180도 회전 상태, 270도 회전 상태 총 4가지 모양을 가질 수 있고, 기존의 상태부터 옮겨가며 현재 자물쇠와 맞는지 안맞는지 체크하고, 90도 회전한 후 다시 옮겨가며 현재 자물쇠와 맞는지 안맞는지 체크하고, 90도 더 회전한 후 다시 옮겨가며 체크하는 과정을 반복하면 된다.

● 열쇠를 회전하는 과정은 열쇠 배열을 그대로 복사한 후, 다음과 같은 식으로 회전해서 덮으면 된다.

for (int i = 0; i < N; i++)
	for (int j = 0; j < N; j++)
		key[i][j] = tempKey[N - 1 - j][i];

● 열쇠를 옮겨가며 자물쇠와 맞는지 안맞는지 체크하는 부분이 문제의 핵심인데, 열쇠랑 자물쇠가 한 칸이라도 겹치는 모든 (i , j) 에서 N x N 크기의 열쇠를 그리고 자물쇠와 겹치는 부분을 체크해주면 된다.

아래 그림과 같이 열쇠를 한 칸씩 이동해가며 모든 경우에 대해 자물쇠와 열쇠의 상태를 확인해주면 됩니다.

열쇠가 0인 부분은 자물쇠가 0이던 1이던 상관이 없고, 열쇠가 1일 때 자물쇠가 0이면 우리가 원하는 상황이고, 열쇠가 1일 때 자물쇠가 1이면  문제가 발생하므로 (열쇠, 자물쇠) 가 (1, 0) 이면 자물쇠의 상태를 1로 바꿔주고, (1, 1) 이면 자물쇠의 상태를 0으로 바꿔준다. 

#include <string>
#include <vector>

using namespace std;

bool solution(vector<vector<int>> key, vector<vector<int>> lock) {
	int rotation = 0, N = key.size(), M = lock.size();
	do {
		// check
		for (int i = -N + 1; i < M; i++) {
			for (int j = -N + 1; j < M; j++) {
				// (i , j) 에서 N x N key를 그린다고 생각.
				vector<vector<int>> tempLock(M);
				for (int k = 0; k < M; k++)
					for (int l = 0; l < M; l++)
						tempLock[k].push_back(lock[k][l]);

				for (int k = 0; k < N; k++) {
					for (int l = 0; l < N; l++) {
						if (0 <= (i + k) && (i + k) < M && 0 <= (j + l) && (j + l) < M) {
							if (tempLock[i + k][j + l] == 0 && key[k][l] == 1)
								tempLock[i + k][j + l] = 1;
							else if (tempLock[i + k][j + l] == 1 && key[k][l] == 1)
								tempLock[i + k][j + l] = 0;
						}
					}
				}
				// 제대로 만들어졌는지 체크하자.
				bool tempCheck = true;
				for (int k = 0; k < M; k++)
					for (int l = 0; l < M; l++)
						if (tempLock[k][l] == 0)
							tempCheck = false;
				if (tempCheck == true)
					return true;
			}
		}
		// 회전
		vector<vector<int>> tempKey(N);
		for (int i = 0; i < N; i++) 
			for (int j = 0; j < N; j++)
				tempKey[i].push_back(key[i][j]);
		for (int i = 0; i < N; i++) 
			for (int j = 0; j < N; j++) 
				key[i][j] = tempKey[N - 1 - j][i];

		rotation++;
	} while (rotation < 4);

	return false;
}

 

+ Recent posts