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

오늘은 문자열 탐색에 특화된 자료구조 트라이(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)


 

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

1장 연습문제 5번 문제풀이를 포스팅해보겠습니다.

저도 아직 배우고 있는 입장이라, 틀린 부분이 있을 수 있습니다!

공부하실 때 참고용으로 사용해주시면 감사합니다 :)


05 Boolean 추상 자료형을 정의하고, 다음과 같은 연산자들을 포함시켜라.

 And, Or, Not, Xor

 

객체 정의 := 참 또는 거짓을 담고 있는 요소

연산 정의

   Create( ) := 부울 자료형 요소를 생성한다.

   True(b) := 부울 자료형을 참으로 초기화한다.

   False(b) := 부울 자료형을 거짓으로 초기화한다.

   And(b1, b2) := 부울 자료형 b1과 b2가 모두 참이라면 참, 아니라면 거짓을 반환한다.

   Or(b1, b2) := 부울 자료형 b1과 b2가 모두 거짓이라면 거짓, 아니라면 참을 반환한다.

   Xor(b1, b2) := 부울 자료형 b1과 b2가 다르다면 참, 같다면 거짓을 반환한다.

   Not(b) := 부울 자료형 b가 참이라면 거짓, 거짓이라면 참을 반환한다.


 

 

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

오늘은 그래프 탐색 기법 중 너비 우선 탐색(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) 포스팅을 마치겠습니다.

+ Recent posts