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

오늘은 문자열 탐색에 특화된 자료구조 트라이(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) 포스팅을 마치겠습니다.

 

 

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

오늘은 그래프 탐색 기법 중 깊이 우선 탐색(DFS)에 대해서 포스팅해보겠습니다.


깊이 우선 탐색(DFS)

 깊이 우선 탐색은 탐색할 때 한 방향으로 갈 수 있을 때까지 계속 가다가 더 이상 갈 수 없게 되면 다시 가장 가까운 갈림길로 돌아와서 다른 방향으로 탐색을 진행하는 순회 방법입니다. 깊이 우선 탐색은 재귀 함수를 이용해 구현하고 너비 우선 탐색과 마찬가지로 방문한 정점들을 체크하는 bool 배열이 필요합니다.

예시

 DFS 탐색은 다음과 같은 순서로 진행됩니다.

 1) 현재 탐색 중인 노드와 연결된 노드 중, 방문하지 않은 노드가 있다면 방문하지 않은 노드로 재귀 함수를 호출하며 탐색을 진행.

 2) 1의 과정을 가능한 모든 노드를 탐색할 때까지 반복한다.

 너비 우선 탐색과는 다르게 현재 탐색 중인 노드에서 연결된 노드로 탐색을 진행하지, 시작 노드에서 가까운 노드부터 탐색을 진행하지 않습니다.

코드 구현 (C)

인접 행렬로 표현된 그래프에 대한 DFS

bool visited[MAX_VERTIECES];

void dfs_mat(GraphType* g, int v) {
	visited[v] = TRUE;
	printf("%d 노드 탐색 중 \n", v);
	for (int w = 0; w < g->n; w++)
		if (g->adj_mat[v][w] && !visited[w])
			dfs_mat(g, w); // 재귀점으로 다음 노드 호출.
}

인접 리스트로 표현된 그래프에 대한 DFS

void dfs_list(GraphType* g, int v) {
	GraphNode* w;
	visited[v] = TRUE;
	printf("%d 노드 방문 중\n", v);
	for (w = g->adj_list[v]; w; w = w->link)
		if (!visited[w->vertex])
			dfs_list(g, w->vertex);
}

코드 구현 (C++)

2차원 배열로 표현된 그래프에 대한 DFS

int graph[MAX_VERTICES][MAX_VERTICES];
bool visited[MAX_VERTICES];

void dfs_mat(int v) {
	visited[v] = TRUE;
	cout << v << "노드 탐색 중 \n";
	for (int w = 0; w < MAX_VERTICES; w++)
		if (graph[v][w] && !visited[w])
			dfs_mat(w);
}

벡터 배열로 표현된 그래프에 대한 DFS

vector<int> graph[MAX_VERTICES];
bool visited[MAX_VERTICES];

void dfs_mat(int v) {
	visited[v] = TRUE;
	cout << v << "노드 탐색 중 \n";
	for (int w = 0; w < graph[v].size(); w++)
		if (graph[v][w] && !visited[w])
			dfs_mat(w);
}

 정점의 수가 너무 커 그래프를 2차원 배열로 표현할 수 없는 경우, 벡터 배열로 표현해야만 그래프를 표현할 수 있으므로, 벡터 배열로 표현하는 그래프에 대한 DFS를 익혀두시면 알고리즘 문제를 푸는데 도움이 될 것 같습니다.

깊이 우선 탐색의 시간 복잡도 분석

 정점의 수가 n이고 간선의 수가 e인 그래프

● 그래프가 인접 리스트로 표현되어 있는 경우 O(n + e)

● 그래프가 인접 행렬로 표시되어 있는 경우 O(n^2)


 

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

오늘은 그래프 탐색 기법 중 너비 우선 탐색(BFS)에 대해서 포스팅해보겠습니다.


그래프(graph)

 그래프(graph)는 연결되어 있는 객체 간의 관계를 표현할 수 있는 자료 구조입니다. 수학자 오일러에 의해 처음 창안되었으며, 정점(vertex)와 간선(edge)로 그래프를 표현합니다. 정점은 노드(node)라고도 불리며, 간선은 링크(link) 라고도 불립니다. 아래 그림과 같이 지도를 나타낼 때 그래프가 많이 이용됩니다.

그래프 예시

너비 우선 탐색(BFS)

 너비 우선 탐색은 시작 정점으로부터 가까운 정점을 먼저 방문하고 멀리 떨어져 있는 정점을 나중에 방문하는 순회 방법입니다. 너비 우선 탐색을 위해서는 방문한 정점들을 차례로 저장한 후 꺼낼 수 있는 자료 구조 큐(queue)가 필요하고, 방문한 정점들을 체크하는 bool 배열이 필요합니다.

 예시를 통해서 BFS 탐색 순서를 익혀보겠습니다.

 BFS 탐색은 다음과 같은 순서로 진행됩니다.

1)  큐의 가장 앞에 있는 노드(현재 방문 중인 노드)와 연결된 노드 중, 방문하지 않은 노드가 있다면 큐에 삽입하고 방문 표시를 한다.

2) 1)의 과정을 큐에 더 이상 노드가 없을 때까지 반복한다.

코드 구현 ( C )

 인접 행렬로 표현된 그래프에 대한 BFS

void bfs_mat(GraphType* g, int v) {
	QueueType q;
	init(&q);
	visited[v] = TRUE; // v정점에서부터 탐색 시장
	enqueue(&q, v);
	while (!is_empty(&q)) { // 큐에 남은 정점이 없을 때까지 진행
		v = dequeue(&q);
		printf("%d 정점을 방문\n", v);
		for (int w = 0; w < g->n; w++) {
			if (g->adj_mat[v][w] && !visited[w]) { // 간선이 존재하고 방문하지 않은 정점
				visited[w] = TRUE;
				enqueue(&q, w);
			}
		}
	}
}

 인접 리스트로 표현된 그래프에 대한 BFS

void bfs_list(GraphType* g, int v) {
	GraphNode* w;
	QueueType q;
	init(&q);
	visited[v] = TRUE;
	enqueue(&q, v);
	while (!is_empty(&q)) {
		v = dequeue(&q);
		printf("%d 정점을 방문\n", v);
		for (w = g->adj_list[v]; w; w = w->link) {
			if (!visited[w->vertex]) {
				visited[w->vertex] = TRUE;
				enqueue(&q, w->vertex);
			}
		}
	}
}

코드 구현 ( C++ )

 인접 행렬로 표현된 그래프에 대한 BFS

void bfs_mat(int v) { // 정점의 수 N개 graph[x][y] 는 x에서 y로 가는 간선을 의미.
	queue<int> q;
	bool visited[N] = {};
	q.push(v);
	visited[v] = 1;
	while (!q.empty()) {
		int cur = q.front();
		cout << cur << "노드를 방문 중\n";
		q.pop();
		for(int i = 0; i < N; i++)
			if (graph[cur][i] && !visited[i]) {
				visited[i] = true;
				q.push(i);
			}
	}
}

 벡터로 표현된 그래프에 대한 BFS

void bfs_mat(int v) { // 정점의 수 N개 graph[x][y] 는 x에서 y로 가는 간선을 의미.
	queue<int> q;
	bool visited[N] = {};
	q.push(v);
	visited[v] = 1;
	while (!q.empty()) {
		int cur = q.front();
		cout << cur << "노드를 방문 중\n";
		q.pop();
		for(int i = 0; i < graph[cur].size() ; i++)
			if (graph[cur][i] && !visited[i]) {
				visited[i] = true;
				q.push(i);
			}
	}
}

너비 우선 탐색의 복잡도

● 인접 행렬로 표현된 경우 N개의 노드에 대해서 N 개의 다른 노드와 연결되어있고, 방문되지 않은 노드를 체크해야되므로 O(N^2)의 시간 복잡도를 가집니다.

● 자료구조 vector 혹은 연결 리스트로 표현된 경우 N개의 노드에 대해서 연결된 M개의 간선만큼만 탐색을 진행하므로 O(N + M)의 시간 복잡도를 가집니다.


이상으로 유명한 그래프 탐색 방법 중 하나 인 너비 우선 탐색(BFS) 포스팅을 마치겠습니다.

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

오늘은 자료구조 서로소 집합(Disjoint set)과 유니온-파인드(Union-find) 기법에 대해서 다뤄보겠습니다.


Disjoint Set( 서로소 집합 )

 고등학교 때, 수학 시간에 배운 집합에서 서로 공통 원소가 없는 집합을 Disjoint Set 이라 합니다. 집합을 구현하는 데는 벡터, 배열, 연결리스트를 이용할 수 있으나 보통 그중 가장 효율적인 트리 구조를 이용하여 구현합니다.

● 어떤 집합 S에 대해서 공통 원소가 없는 부분 집합들로 나눠진 원소들에 대한 자료구조

 

 U = { 1, 2, 3, 4, 5, 6 } 일 때, A = { 1, 2 }, B = { 3, 4 } , C = { 5, 6 } 과 같은 경우를 Disjoint Set 이라고 표현한다. 쉽게 말하면 원소 N개를 서로 겹치지 않게 나눠가져 간다고 생각하면 됩니다. 


Union-Find (유니온-파인드)

 Disjoint Set 을 표현할 때 사용하는 알고리즘이 Union-Find 알고리즘입니다. 

● Union(x, y)

 - x가 속한 집합과 y가 속한 집합을 합친다. 즉, x 와 y가 속한 두 집합을 합집합하는 연산

● Find(x)

 - x가 속한 집합의 대푯값을 반환한다. 즉, x가 어떤 집합에 속해 있는지 찾아주는 연산

 - 트리를 이용해서 구현하므로 대푯값은 루트 노드 번호를 의미한다.


Union-Find 코드 구현

 Disjoint set 은 트리 구조를 이용해서 표현한다고 위에서 언급했습니다. 따라서, parent[x] := x 노드의 부모 노드의 번호를 저장하는 배열을 먼저 선언해야 합니다. x 노드가 루트 노드인 경우에는 자기 자신의 번호를 저장합니다.

 Find(x) := x가 속한 집합의 루트 노드를 반환합니다. 이때, 루트 노드는 자기 자신의 번호를 저장하고 있으므로, 재귀적으로 Find 함수를 호출하며 parent[x] 와 x가 같을 때 값을 return 해주면 됩니다

 Union(x, y) := x가 속한 집합과 y가 속한 집합을 합집합 해줍니다. 따라서 먼저 x와 y 노드의 루트 노드를 find 하고 그 둘이 다르다면(다른 집합이라면) 하나의 루트 노드를 다른 루트 노드로 새롭게 갱신해주면 됩니다.

int find(int node) {
	if (node == parent[node])
		return node;
	return find(parent[node]);
}

void merge(int node1, int node2) { // union 키워드는 이미 존재해서 보통 merge로 함수명을 선언합니다.
	int parentNode1 = find(node1);
	int parentNode2 = find(node2);
	if (parentNode1 == parentNode2)
		return;
	parent[parentNode1] = parentNode2;
	return;
}

 위의 예시를 parent 배열까지 함께 나타내며 다시 보겠습니다.


Find 최적화

 트리의 높이가 작아지면 작아질수록 루트 노드를 탐색하는 게 빨라집니다. 

 예를 들어 위와 같이 같은 Set을 나타내는 트리가 있다고 해봅시다. 이때 find(6) 을 수행하려면 왼쪽 트리는 루트 노드(1)을 찾기 위해 탐색을 여러 번 수행해야 하고, 오른쪽 트리는 단 한 번에 탐색을 끝낼 수 있습니다. 지금이야 원소가 6개뿐인 집합이라 탐색 횟수가 많이 차이 나지는 않지만, 트리가 커지면 커질수록 이 차이는 점점 커지게 됩니다. 따라서 우리는 트리의 노드들을 최대한 루트 노드와 직접 연결시켜야 합니다.

int find(int node) {
	if (node == parent[node])
		return node;
	return parent[node] = find(parent[node]);
}

 find 함수를 정말 조금만 수정하면 위와 같은 문제를 해결할 수 있습니다!

 parent[node] 에 find(parent[node]) 를 저장하면 됩니다! 정말 간단하죠?

 예를 들어 위의 왼쪽 그림에서 find(6) 을 수행하면, parent[6] = parent[5] = parent[3] = 1; 이 됩니다. 즉, 왼쪽 그림이 오른쪽 그림으로 바로 바뀌게 되는 것을 볼 수 있습니다.


Union 최적화

 Union 함수 최적화에 대해서도 생각을 해봐야 합니다. Find 함수와 마찬가지로 우리는 노드들의 높이가 낮으면 낮을수록 연산을 수행하기 좋습니다.

 위와 같은 상황에서 2개의 집합을 Union 할 때, 왼쪽 트리를 오른쪽 트리에 합치는 경우와 오른쪽 트리를 왼쪽 트리에 합치는 경우 2가지가 존재합니다.

 노드의 수가 더 많은 트리에 더 작은 트리를 합친 오른쪽의 경우가 전체 노드의 Level을 합했을 때 더 작은 값이 나오는 걸 알 수 있습니다. 즉, Union 을 더 큰 트리에 작은 트리를 합쳐가며 진행을 한다면 최적화를 할 수 있습니다.

 그렇다면 트리의 Size 를 따로 저장해야되는데 어떻게 하면 트리의 Size 를 저장할 수 있을까요?

 제일 간단한 방법은 Size를 저장하는 배열을 하나 더 선언하는 방법입니다.

void merge(int node1, int node2) {
	int parentNode1 = find(node1);
	int parentNode2 = find(node2);
	if (parentNode1 == parentNode2)
		return;
	if (size[parentNode1] > size[paretNode2]) {
		parent[parentNode2] = parentNode1;
		size[parentNode1] += size[parentNode2];
	}
	else {
		parent[parentNode1] = parentNode2;
		size[parentNode2] += size[parentNode1];
	}
	return;
}

Weighted-Union-Find

  위의 방법은 parent 배열과 size 배열을 2개 선언해야 해서 메모리 낭비가 심합니다. parent 배열 하나로 트리의 크기 정보까지 저장하는 방법인 Weighted-Union-Find 방법을 소개해드리겠습니다. Weighted-Union-Find 는 parent 배열에 루트 노드라면 -(집합의크기), 루트 노드가 아니라면 부모 노드를 저장합니다. 따라서, parent 배열을 -1 로 초기화합니다. 즉, 자기가 루트 노드인 경우에는 parent 값이 음수이고 아니라면 부모 노드를 가리킵니다. 따라서, find 함수는 parent 값이 음수가 나올 때까지 진행하면 됩니다. 그리고 Union 함수는 서로 다른 집합일 경우 root 노드의 parent 값이 더 작은 경우(음수이므로 절댓값은 더 큰 경우)가 더 덩치가 큰 트리이므로 작은 트리를 큰 트리에 합쳐줍니다.

int find(int num) {
	if (p[num] < 0)
		return num;
	return p[num] = find(p[num]);
}

void merge(int num1, int num2) {
	int pNum1 = find(num1), pNum2 = find(num2);
	if (pNum1 == pNum2)
		return;

	if (p[pNum1] <= p[pNum2]) {
		p[pNum1] = p[pNum1] + p[pNum2];
		p[pNum2] = pNum1;
	}
	else{
		p[pNum2] = p[pNum1] + p[pNum2];
		p[pNum1] = pNum2;
	}
	return;
}

이상으로 Disjoint Set(서로소 집합) & Union-Find(유니온 파인드) 포스팅을 마무리하도록 하겠습니다.

굉장히 자주 쓰이는 개념과 알고리즘으로 최적화까지 완벽하게 익혀두시면 좋을 것 같습니다!

+ Recent posts