BOJ 17298 오큰수

https://www.acmicpc.net/problem/17298

 

17298번: 오큰수

첫째 줄에 수열 A의 크기 N (1 ≤ N ≤ 1,000,000)이 주어진다. 둘째 줄에 수열 A의 원소 A1, A2, ..., AN (1 ≤ Ai ≤ 1,000,000)이 주어진다.

www.acmicpc.net

 

문제 설명

어떤 수열이 있는데요. 이 수열의 한 원소를 기준으로 오른쪽에 있는 자신보다 큰 원소 중 가장 왼쪽에 있는 수가 오큰수라고 합니다. 이때 수열에 대해 모든 원소의 오큰수를 구하는 것이 문제입니다. 

이때 오큰수가 없는 경우도 있습니다. 가장 큰 원소이거나 가장 오른쪽에 있는 원소는 오큰수가 없을 수가 있죠. 이때는 -1로 오큰수를 정해줍니다.

예를 들어 3 5 2 7 6 8이라는 수열이 있다면 5 7 7 8 8 -1이 해답이 됩니다. 5의 오른쪽에 있는 큰 수들은 7 6 8이 있는데, 이때 가장 왼쪽에 있는 수가 7이므로 5의 오큰수는 7이 됩니다. 여기까지 이해를 했으면 이제 문제를 풀어보겠습니다.

 

풀이

이 문제에 대해서 왼쪽부터 시작하지 말고 오른쪽부터 시작하면 접근이 쉽습니다. 가장 오른쪽에 있는 오큰수는 없으니까 -1로 시작하게 됩니다.

 

가장 최근에 큰 수(A)을 기억하고 있다가 그것보다 작은 수(B)가 나오게 되면 B의 오큰수는 A가 됩니다. 이때 B는 그냥 버리면 안됩니다. 왜냐면 B가 왼쪽 어떤 C의 오큰수 일 수 있으니까요. 그래서 B를 기억하고 있어야합니다. 아래와 같은 상황에서 B는 C의 오큰수가 됩니다. 이때 C도 역시 기억하고 있어야됩니다. C도 어떤 수의 오큰수가 될 수 있게 되니까요. 아래의 그림은 이해하기 쉽게 도형의 크기로 수의 크기를 표현하였습니다.

 

하지만 C가 B보다 큰 경우 B는 오큰수가 아니게 되겠죠? C 이전의 수들은 B가 필요없게 됩니다. 왜냐면 C가 B보다 크면서 더 왼쪽에 있으니까요. 그래서 B는 이제 기억에서 사라지고 C를 기억하고 있어야됩니다.

 

자, 이제 뭔가 좀 감이 잡힐 수 있습니다. 가장 최근에 것을 상황에 따라 보관하고 있다가 비교하거나 버리고 하는 것을 알 수 있게 됩니다. 그래서 스택이라는 자료구조가 이 문제에서 사용됩니다.

Stack에는 가장 오른쪽부터 시작해서 오큰수들이 들어가게 됩니다. 이때 value는 현재 수열의 비교할 값을 말한다고 합시다. 스택의 가장 위(top)는 어떠한 오큰수가 들어가게 되는데, value보다 작다면 이 오큰수는 쓸모가 없게 되므로 스택에서 pop하면 됩니다. stack의 top이 value보다 더 클때까지 계속 pop하게 되면 결국 stack이 비게 되거나, value보다 큰 오큰수가 존재하게 됩니다. 

이때 스택이 비어있다면 value의 오큰수가 없다는 것, 비어있지 않다면 스택에 있는 top의 값이 오큰수가 되게 됩니다.

위 설명을 따른 예제에 대한 그림 풀이가 아래에 있습니다. 참고하시기 바랍니다.

 

전체코드

전체 코드는 아래와 같습니다. 주석처리하여 추가설명하였습니다.

#include <cstdio>
#include <stack>

using namespace std;
int n, numbers[1000001], ans[1000001];

int main() {

	stack<int> st = stack<int>();

	scanf("%d", &n);

	for (int i = 0; i < n; i++) 
		scanf("%d", &numbers[i]);
	
	st.push(numbers[n - 1]);	//가장 오른쪽에 있는 수는 stack에 push
	ans[n - 1] = -1;			//가장 오른쪽의 있는 수는 오큰수가 없으므로 -1
	for (int i = n - 2; i >= 0; i--) {	//거꾸로 for문
		int value = numbers[i];			//stack의 top과 비교할 숫자
		while (!st.empty() && value >= st.top()) {	//스택이 비어있지 않고, 스택의 top이 value보다 작거나 같으면
			st.pop();								//그 수는 버린다.
		}
		//이렇게 되면 두가지 상황이 발생하는데,
		//스택이 비어있으면 value 오른쪽에 오큰수가 없다는 것으로 value의 오큰수는 -1
		//숫자가 남아있다면 value보다 큰 수가 있는 것으로 오큰수는 st.top
		ans[i] = st.empty() ? -1 : st.top();
		
		//그리고 value를 stack에 push
		st.push(value);
	}

	for (int i = 0; i < n; i++) printf("%d\n", ans[i]);
	printf("\n");
}

 

이상으로 포스팅을 마치겠습니다.

반응형
블로그 이미지

REAKWON

와나진짜

,

BOJ 괄호의 값 - 2504

www.acmicpc.net/problem/2504

 

2504번: 괄호의 값

4개의 기호 ‘(’, ‘)’, ‘[’, ‘]’를 이용해서 만들어지는 괄호열 중에서 올바른 괄호열이란 다음과 같이 정의된다. 한 쌍의 괄호로만 이루어진 ‘()’와 ‘[]’는 올바른 괄호열이다.  만일

www.acmicpc.net

 

괄호와 관련해서 원래 스택의 기본 문제가 짝이 지어지느냐 마느냐인데, 이 문제는 연산까지 더해집니다. 괄호는 (, ), [, ]를 사용하며 A와 B는 이미 연산된 수를 의미합니다. 연산의 규칙은 다음과 같습니다. 

1. () = 2

2. [] = 3

3. (A) = 2*A

4. [A] = 3*A

5 AB = A+B

 

스택을 이용하면서 연산까지 같이 하면서 집어넣어줘야합니다. 어떻게 할 수 있을까요? 입력 예를 보면서 어떻게 풀어나갈지 생각해봅시다.

(()[[]])([])

 

큰 틀은 아래와 같은데요. 

1. 우리가 여는 괄호를 만나면 우선 스택에 집어넣습니다.

2. 닫는 괄호가 나오면 매칭되는 괄호가 나올때까지 이전에 계산한 수를 모두 더합니다. 그리고 여는 괄호가 '('이냐, '[' 이냐에 따라서 2를 곱하거나 3을 곱해서 다시 스택에 집어넣습니다.

3. 모든 괄호를 전부 만났다면 스택에 있는 모든 수를 더합니다.

 

이제 그림을 통해서 보도록 하겠습니다. 우선 처음 ( ( 는 여는 괄호이므로 전부 스택이 집어 넣습니다. 이 후 최초로 닫는 괄호 )가 나오니 연산해서 다음 데이터를 집어넣을 차례입니다.

 

여는 괄호 (를 만날때까지 스택에 있는 모든 수를 더하지만 아직 더할 수는 없습니다. 그래서 ()에 해당하는 2를 집어넣습니다. 하지만 여기서는 음수로 집어넣지요. 다음 '[', '[' 역시 여는 괄호이기 때문에 스택에 집어넣습니다.

 

그 후 닫는 괄호 ']'가 나오니까 여는 괄호가 나올때까지 있는 수를 모두 더하고 3을 곱한 수를 넣어야하지만 없으므로 []에 해당하는 -3을 집어넣습니다. 그리고 다시 닫는 괄호 ]이 나오네요. 이때 이제 그 전에 더할 수 -3이 있지요. 

 

그래서 -3을 더하고 난 후 [X]에 해당하는 3을 곱해줍니다. -9가 되겠네요. 이 값을 다시 스택에 집어넣습니다. 다시 닫는 괄호 )가 나오면 앞에 있던 방법으로 모두 더한후 (X)에 해당하는 연산을 하면 -22가 스택에 들어가게 됩니다.

 

 

남아있는 ([])는 아래와 같이 연산됩니다. 

 

이제 괄호의 식이 전부 끝났죠? 그러면 스택에 남아있는 모든 수를 더하고 -1을 곱하면 그게 답입니다. 아래는 위의 풀이를 코드로 구현한 것입니다. 큰 틀은 이렇습니다. N은 문자열의 길이입니다. 여는 괄호가 나오면 모두 스택에 집어넣습니다. 닫는 괄호가 나오면 getNextSum()을 통해서 스택에 있는 모든 수를 더하고 난 이후 맞는 괄호에 해당하는 수를 곱한 값을 받아옵니다. 이후 이 값을 스택에 다시 집어넣죠.

stack<int> st;

int main() {
	char str[31];
	scanf(" %s", str);

	int N = strlen(str);
	for (int i = 0; i < N; i++) {
		char next = str[i];
		switch (next) {
			case '(':
			case '[':
				st.push(next);
				break;

			case ')':
			case ']':
			{
				int s = getNextSum(next);
				if (s == IMPOSSIBLE) {
					printf("0\n");
					return 0;
				}
				st.push(s);
				break;
			}
		}
	}
    
    //...//
 }

 

getNextSum() 함수는 아래와 같이 구현이 되었습니다. 만약 next에 해당하는 데이터가 문자(양수)라면 여는 괄호를 찾고 음수이면 그 수를 더합니다. 여기서 왜 숫자를 음수로 집어넣는지 이해가 가나요? 문자 자체도 ascii 코드로 숫자로 표시될 수 있기 때문에 문자와 숫자를 구분하기 위해서 음수로 집어넣었던 것입니다.

 

만약 아무런 값도 들어있지 않다면 (), []이기 때문에 -2나 -3을 출력해주면 되고 값이 있다면 모두 음수로 더해서 2, 3을 곱한 값을 출력해주면 됩니다. 이때 식은 (X) 이거나 [X] 에 해당합니다.

 

이때 매칭이 되지 않는 괄호는 impossibleOpen이라고 하며 스택을 돌다가 이 괄호가 걸리게 되면 IMPOSSIBLE이라는 -1을 반환합니다. 

#define IMPOSSIBLE -1
int getNextSum(char next) {
	int sum = 0;
	char open = next == ')' ? '(' : '[';
	char impossibleOpen = next == ')' ? '[' : '(';

	while (!st.empty()) {
		int prev = st.top();
		st.pop();
		if (prev == open) 
			return sum == 0 ? (next == ')' ? -2 : -3) : (next == ')' ? sum * (-2) : sum * (-3));
		
		if (prev == impossibleOpen)  return IMPOSSIBLE;
		
		sum -= prev;
	}
	
	return IMPOSSIBLE;
}

 

마지막 과정에는 스택에 괄호가 있는지 없는 지 검사한 후 괄호가 있으면 이 괄호식은 매칭이 되지 않는 식이므로 0을 출력하고 종료하면 되고, 그렇지 않고 숫자만 있는 상태면 모두 더해서 출력해주면 됩니다.

	int ans = 0;
	while (!st.empty()) {
		if (st.top() == '[' || st.top() == ']' || st.top() == '(' || st.top() == ')') {
			printf("0\n");
			return 0;
		}
		ans -= st.top();
		st.pop();
	}

	printf("%d\n", ans);

 

전체코드입니다.

#include <cstdio>
#include <stack>
#include <string.h>
using namespace std;

stack<int> st;
#define IMPOSSIBLE -1
int getNextSum(char next) {
	int sum = 0;
	char open = next == ')' ? '(' : '[';
	char impossibleOpen = next == ')' ? '[' : '(';

	while (!st.empty()) {
		int prev = st.top();
		st.pop();
		if (prev == open) 
			return sum == 0 ? (next == ')' ? -2 : -3) : (next == ')' ? sum * (-2) : sum * (-3));
		
		if (prev == impossibleOpen)  return IMPOSSIBLE;
		
		sum -= prev;
	}
	
	return IMPOSSIBLE;
}

int main() {
	char str[31];
	scanf(" %s", str);

	int N = strlen(str);
	for (int i = 0; i < N; i++) {
		char next = str[i];
		switch (next) {
			case '(':
			case '[':
				st.push(next);
				break;

			case ')':
			case ']':
			{
				int s = getNextSum(next);
				if (s == IMPOSSIBLE) {
					printf("0\n");
					return 0;
				}
				st.push(s);
				break;
			}
		}
	}

	int ans = 0;
	while (!st.empty()) {
		if (st.top() == '[' || st.top() == ']' || st.top() == '(' || st.top() == ')') {
			printf("0\n");
			return 0;
		}
		ans -= st.top();
		st.pop();
	}

	printf("%d\n", ans);

}

 

반응형
블로그 이미지

REAKWON

와나진짜

,

 

상호배타적 집합(Disjoint Set)

오리엔테이션이나 수학 여행 등에서 서로 친해지기 위해서 하는 활동 중에 이런 활동들을 한번쯤 해보셨을 겁니다. 다들 서로 낯서고 모르는 상태이기 때문에 어색어색하죠. 이 순간은 서로가 각각 혼자서 있습니다. 그럴때 사회자가 혈액형이 같은 사람끼리 모이라고 했을때, A, B, AB, O 형으로 나뉘어진 각 사람들은 같은 혈액형을 찾으려고 시도하겠죠? 그래서 같으면 같은 조로 묶이게 됩니다.

이처럼 같은 특성끼리 서로 모이는 집합이 여러 집합이 있는데, 각 집합에서는 공통 원소가 존재하지 않는 그런 집합을 상호배타적 집합(Disjoint Set)이라고 합니다. 한 집합에서 다른 특성을 갖는 집합을 배제한다는 뜻입니다. 이때 쓰이는 자료구조가 유니온-파인드(Union-Find) 자료구조라고 합니다.

구현

상호배타적 집합을 구현하기 위해서는 트리의 자료구조를 사용합니다. 각 트리 노드들은 루트 노드를 가지고 있지요. 그래서 그 루트노드를 기준으로 같다면 같은 집합에 속해있는 것이고, 다르다면 다른 집합에 속해있다는 것을 알 수 있습니다.

1. 초기화

초기의 상태는 각 노드들이 자신이 루트가 됩니다. 아직 어떤 노드도 만난적이 없으니까 집합에서의 리더는 자기 자신일테니까요. 그러다가 같은 속성이 있다면 서로를 합치게 되는 것이죠. 아래의 코드가 초기화 코드를 나타냅니다. parent는 자신의 상위 트리 노드를 말하며 처음에는 자기 자신이 됩니다. 나머지 rank 변수는 이 후 설명하도록 하겠습니다.

 

struct DisjointSet {
	vector<int> parent, rank;
	DisjointSet(int n) :parent(n), rank(n, 1) {
		for (int i = 0; i < n; i++)
			parent[i] = i;
	}
};

 

아래는 두 트리가 있음을 보여줍니다. 두 트리의 루트는 1과 6이라서 이 트리들은 서로 다른 집합을 나타내는 것이라고 할 수 있습니다.

루트가 다른 트리

 

2. 루트 찾기

여기서 각 루트를 찾는 코드는 아래와 같습니다. 

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

 

find는 현재 노드가 속해있는 트리의 루트를 구하는 함수인데요. 여기서 재귀적으로 find를 호출해서 부모를 계속 찾다보면 나중에는 결국 부모가 자기 자신인 노드를 발견하게 됩니다. 왜냐면 루트의 부모는 없으며 루트는 위의 초기화에서 자기 자신이 부모 노드입니다. 그리고 반복적으로 find를 계속 호출하지 않기 위해서 parent[node] = find(parent[node]) 로 부모를 계산된 루트로 기록해둡니다. 그러면 나중에 루트를 찾을때 단번에 루트로 가서 반환되게 때문에 최적화를 할 수 있습니다.

3. 트리 합치기

루트가 다른 이 트리, 알보고니 공통 속성이 있어서 합쳐야될 것 같습니다. 합쳐보긴할텐데 어느쪽으로 합쳐야될까요? 우리는 루트가 1인 트리를 루트가 6인 트리에 합쳐야할까요? 아니면 6인 트리를 1인 트리 밑으로 가게 만들까요? 우리가 find를 구현했을때 루트를 찾이 위해서 계속 재귀적으로 부모를 호출해가면서 확인해보았습니다. 

그렇다면 루트를 빨리 찾기 위해서는 노드의 부모수가 적으면 속도가 빨라지겠죠. 그런 원리로 최적화하면 됩니다. 즉, 트리의 높이를 작게 만들수록 유리합니다. 

루트가 다른 트리

 

1이 루트인 트리에 6이 루트인 트리를 밑에 합쳐놓은 상황입니다. 트리의 높이가 증가했음을 알 수 있죠? 그렇다면 반대로 합쳐놓는다면, 즉 6이 루트인 트리에 1이 루트인 트리를 보면 어떨까요?

 

트리의 높이 증가

 

아래의 그림이 그 상황을 보여줍니다. 트리의 높이가 증가하지 않았음을 볼 수 있습니다. 그렇다면 결국에는 트리의 높이가 큰 트리 밑에 작은 트리를 합쳐 놓는게 높이를 증가시키지 않는 방법이네요. 이제 구현해봅시다.

트리의 높이 증가하지 않음

 

아래의 구현이 병합하는 코드를 구현한 것입니다.

void merge(int left, int right) {
	left = find(left), right = find(right);
	if (left == right) return;
	if (rank[left] > rank[right]) swap(left, right);
	parent[left] = right;
	if (rank[left] == rank[right]) ++rank[right];
}

 

왼쪽 트리가 항상 오른쪽 트리의 자식이 될 수 있도록 구현한 코드인데요. 왼쪽 트리의 루트를 구하고, 오른쪽 트리의 루트를 구해서 같으면 이미 같은 트리에 속해있는 것으로 종료합니다. 

그 후 rank라는 배열을 통해서 트리의 높이를 얻어옵니다. 항상 왼쪽에 작아서 오른쪽으로 합칠 구현을 해야하니까 값이 왼쪽이 크다면 교환해줍니다. 그래서 결국 왼쪽 부모는 오른쪽의 부모와 같게 만들죠.

트리의 높이가 증가하는 경우는 왼쪽, 오른쪽 트리의 rank값이 같을때만 존재합니다.

전체 소스코드는 아래와 같게됩니다. 아래의 코드는 알고리즘 문제해결 전략, 구종만 님의 코드를 참고했습니다.

 

struct DisjointSet {
	vector<int> parent, rank;
	DisjointSet(int n) :parent(n), rank(n, 1) {
		for (int i = 0; i < n; i++)
			parent[i] = i;
	}

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

	void merge(int left, int right) {
		left = find(left), right = find(right);
		if (left == right) return;
		if (rank[left] > rank[right]) swap(left, right);
		parent[left] = right;
		if (rank[left] == rank[right]) ++rank[right];
	}
};

 

예제 : BOJ 1717번, 집합의 표현

정확한 이해를 돕기 위해서 아래의 문제를 풀어봅시다. DisjointSet을 이용해서 풀 수 있는 문제로 위의 동작을 이해할 수 있으면 거뜬하게 풀 수 있는 문제입니다.

www.acmicpc.net/problem/1717

 

1717번: 집합의 표현

첫째 줄에 n(1 ≤ n ≤ 1,000,000), m(1 ≤ m ≤ 100,000)이 주어진다. m은 입력으로 주어지는 연산의 개수이다. 다음 m개의 줄에는 각각의 연산이 주어진다. 합집합은 0 a b의 형태로 입력이 주어진다. 이는

www.acmicpc.net

 

문제의 입력은 아래와 같습니다.

7 8
0 1 3
1 1 7
0 7 6
1 7 1
0 3 7
0 4 2
0 1 1
1 1 1

 

처음 N과 M이 주어지며 N은 집합의 원소 중 가장 마지막 원소, M은 질의를 나타냅니다.

다음은 질의가 M가 나오는데 질의의 유형은 0 a b, 1 a b가 있습니다. 0 a b는 a가 포함된 집합, b가 포함된 집합을 합치는 연산이며 1 a b는 a와 b가 같은 집합에 속해있는지 확인하는 연산입니다. 이때 1 a b의 질의에서 같은 집합에 속한다면 YES를 출력, 아니면 NO를 출력하면 됩니다.

위의 입력에 대한 출력은 아래와 같습니다.

NO
NO
YES

 

풀이 

맨 처음에는 각각의 수들은 다른 집합에 속해있다가 연산 0이 나오면 합치면 됩니다. 이 역할이 DisjointSet의 merge가 되겠군요. 그리고 연산 1이면 a와 b가 같은 집합에 속해있는지 확인하는 연산이므로 find(a), find(b)를 통해 두 집합의 root를 구한후 같은지 다른지 확인하면 되는 간단한 문제입니다.

문제의 정답 코드는 이렇습니다.

#include <cstdio>
#include <vector>
using namespace std;
#define MERGE 0
#define SAME_GROUP 1

struct DisjointSet {
	vector<int> parent, rank;
	DisjointSet(int n) :parent(n), rank(n, 1) {
		for (int i = 0; i < n; i++)
			parent[i] = i;
	}

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

	void merge(int left, int right) {
		left = find(left), right = find(right);
		if (left == right) return;
		if (rank[left] > rank[right]) swap(left, right);
		parent[left] = right;
		if (rank[left] == rank[right]) ++rank[right];
	}
};

int main() {
	int N, M;
	scanf("%d %d", &N, &M);
	DisjointSet djs(N+1);
	for (int i = 0; i < M; i++) {
		int query, a, b;
		scanf("%d %d %d", &query, &a, &b);
		if (query == MERGE) djs.merge(a, b);
		
		if (query == SAME_GROUP) 
			printf("%s\n", djs.find(a) == djs.find(b) ? "YES" : "NO");
	}

}

 

이상으로 상호배타적 집합에 대한 개념과 코드 구현, DisjointSet을 이용해서 문제를 풀어보았습니다. DisjointSet 알고 있으면 도움이 될 것 같나요?

반응형
블로그 이미지

REAKWON

와나진짜

,

 

 

해싱(Hashing)

해싱(Hashing)은 데이터를 관리하는 고급 기법으로 검색(search), 삽입(insert), 삭제(delete)를 단시간(O(1))에 할 수 있게 만들어주는 기법입니다.

예를 들면, 리스트에서 단어를 찾는 시간이 O(n)시간이 걸리고, 이진 트리의 경우 O(nlogn)이 걸리죠. 그런데 우리는 이것을 해싱이란 기법을 사용하여 O(1)만에 해결할 수 있습니다. 진짜 죠낸 빠르네요.

해싱은 산술적 연산을 이용해서 구현해야합니다. 이런 해싱은 크게 두 가지 종류가 있는데요. 하나는 정적 해싱(static hasing)이고 다른 하나는 동적 해싱(dynamic hasing)입니다. 이번 포스팅은 정적 해싱에 대한 이야기를 합니다.

 

정적 해싱(Static Hashing)

정적 해싱이라는 기법은 고정 크기의 버킷을 갖는 해시 테이블(담을 그릇)에 데이터를 담는 것을 말합니다.

해시 테이블(Hash Table)

설명 전에 해시 테이블(Hash Table)에 관해서 알고 가야합니다. 해시 테이블은 키(Key)값(Value)으로 구성되어 있는 데이터가 저장된 테이블이라고 보시면 됩니다. 해시 테이블은 행과 열로 구성되어 있다고 생각하세요. 여기서 두 가지 용어가 있는데 버킷(bucket)슬롯(slot)입니다.

 

버킷(bucket) : 버킷은 해시 테이블의 행 인덱스(주소)라고 입니다. 

슬롯(slot) : 슬롯은 그 행을 열의 인덱스(주소)라고 생각하시면 됩니다.

 

아래의 그림은 버킷이 11, 슬롯이 3인 해시 테이블입니다. 여기서 data가 전부 차있는 버킷도 존재하고 그렇지 않은 곳도 존재합니다. bucket[0]을 보시면 이미 데이터가 가득차있네요. 다음 들어올 데이터가 bucket[0]에 들어갈 데이터라면 어떻게 될까요? 넣을 수 없는 상황이 되겠죠? 

그것을 우리는 오버플로(Overflow)라고 하며 이 문제를 해결해야합니다. 잠시후 설명하도록 하겠습니다.

 

 

쳐넣는거는 알겠는데 어떻게 넣냐구요? 해시 함수를 이용해서 넣을 수 있습니다.

 

해시 함수(Hash Function)

해시 함수는 h()라 하고 찾을 키를 k라고 한다면 해시 함수는 h(k)라 표현합니다. 해시 함수는 k를 이용해서 해시 테이블에 매핑(mapping)하는 역할을 하게 됩니다. 즉, 데이터를 집어넣을 장소(주소)를 정합니다. 좋은 해시 함수는 계산이 쉬워야하고, 출돌을 최소화 시켜야합니다. 그리고 모든 버킷에  데이터가 고르게 분포되어야합니다. 앞서말한 오버플로를 최소화하기 위함이죠. 해시 함수에는 어떤 것들이 있을까요?

 

참고로 아래의 해시함수들은 모두 키가 양의 정수를 갖는데, 만약 키를 문자나 문자열로 쓰고 싶다면 문자(열)을 정수로 변환시키는 작업이 필요합니다.

1) Division

나눗셈을 이용하는 해시 함수입니다. 모듈러 연산(나머지를 구하는 연산으로 % 아시죠?)을 이용하여 집어 넣을 곳을 정합니다. 키들은 음수이면 안된다는 가정이 있습니다.

 

h(k) = k % D

 

해시 테이블은 적어도 D개의 버킷을 가지고 있어야합니다. k가 15이고 D가 7이라면 h(15) = 15 % 7 = 1 이 됩니다.

 

2) Mid-Square

키를 제곱하여 버킷을 정하는 건데요. 키를 제곱한 후 중간에 몇 비트를 선택하여 버킷의 주소를 구합니다. r비트를 선택하면 해시 함수 결과로 나올 수 있는 값의 범위는 0 ~ (2^r) -1이 되겠죠?

r을 3로 정해보고,  key값을 121으로 정해서 계산해보면 121^2 = 14,641인데 이것의 가운데 3개를 가져오면 h(121) = 464가 됩니다.

 

 

 

3) Folding

폴딩은 키를 몇몇 부분(Part)로 나눈후 그 값을 더하여 해시 함수의 결과를 도출합니다. 방식에 따라 두가지가 있는데요. 그냥 부분을 일정하게 나눈 후 더하는 방식인 시프트 폴딩(Shift Folding)과 부분의 경계를 뒤집어서 계산하는 경계폴딩(Boundary Folding)이 있습니다.

 

1. 시프트 폴딩(Shift Folding)

k가 12345678910일때 3개의 10진수로 나눈다면 h(12345678910) = 123 + 456 + 789 + 10이 되고 더하게 되면 1378이 되므로 h(12345678910) = 1,378 입니다.

 

 

2. 경계 폴딩(Boundary Folding)

k가 아까와 같이 12345678910이고 3개의 10진수로 나눈다면 h(12345678910) = 123 + 654 + 789 + 01이며 결과는 1,567입니다. 456과 10이 뒤집혀진 것을 알 수 있네요.

 

 

4) Digit Analysis

이 방식은 이미 모든 키들을 알고 있는 정적 파일에 유용한 방법으로 각 키의 주소를 결정할 때 많이 나오는 수는 제외하고 적게 나오는 키의 수만 선택됩니다. 

예를 하나 들어보겠습니다. 키가 5개가 미리 정해져있다고 칩시다. 각 키는 0112311, 0234522, 0167811, 0291022, 0111222 에서 숫자 3개를 선택해서 해시 함수의 결과를 꺼내면 hash(0112311) = 123, hash(0234522) = 345, hash(0167811) = 678, hash(0291022)= 910, hash(0111222) = 112 가 됩니다. 01 11 02 22는 많이 겹치는 수이기 때문에 고르지 않습니다.

이름에서 알 수 있듯이 키의 숫자들을 분석을 해야하는데요. 분석하려면 미리 결정된 데이터들이 있어야합니다. 즉, 키가 미리 결정되어져 있어야한다는 의미입니다. 그러니까 왜 정적 파일에 유용한지 이해가 가시죠?

 

오버플로 핸들링(Overflow Handling)

앞서 오버플로에 대해서 이야기했었죠? 오버플로를 해결하기 위해서는 두 가지 핸들링 기법이 있는데, 개방 주소법(Open Address)체이닝(Chaining) 방식이 있습니다.

 

1) 개방 주소(Open Address)

오버플로가 발생했을때 공간이 남는 버킷에 집어 넣는 것으로 공간이 남는 버킷 어느 곳이나 있으면 넣는 다고 하여 개방 주소라고 합니다. 남는 곳을 어떻게 구햐냐에 따라 4 가지 방식이 존재합니다.

 

1. 선형 탐사(Linear Probing)

선형 탐사만 알아도 나머지 probing방식은 이해할 수 있습니다. 고정된 폭을 정해서 그 폭만큼 버킷의 주소를 이동해서 남는 공간이 있는지 확인 후에 있으면 집어넣는 방식입니다. 그래도 남는 공간이 없다면 다시 그 폭만큼 버킷의 주소를 이동하죠. 만약 해시 테이블의 공간이 모자란다면 해시 테이블의 크기를 증가시키는데, 전부 찰때 증가시키는 것이 아니고 75% 정도 채워지면 해시 테이블의 크기를 증가시킵니다.

 

만약 폭을 i라고 하고 버킷의 크기를 b라고 한다면 해시 테이블은 (h(k) + i ) % b 의 식으로 계산됩니다.

 

 

 

2. 제곱 탐사(Quadratic Probing)

감 오시죠? 폭을 제곱하여 오버플로를 핸들링합니다. (h(k) + (i^2)) %b 로 계산됩니다. 만약 오버플로가 발생하면 i=1로 한칸 이동하여 찾고 그래도 실패하면 그 다음은 i=2가 되어 4칸을 이동하고, 그 다음은 i=3이 되어 9칸을 이동하여 검사하는 방식입니다.

 

아래 그림은 선형 탐사와 제곱 탐사를 비교한 그림입니다.

3. 무작위 탐사(Qandom Probing)

감 오시죠? 랜덤한 값을 폭으로 두고 계산합니다.

 

4. 이중 해시(Double Hashing)

해싱한 값을 한 번 더 해싱하는 방식입니다. 다른 방식보다 연산하는 시간이 걸립니다. 해시 연산을 한 번 더 하기 때문이죠.

 

2) 체이닝(Chaining)

체이닝은 이름과 같이 체인같이 연결하여 오버플로를 막는 방법인데요. 연결 리스트(Linked List) 배우셨죠? 각 버킷이 연결 리스트가 되고 원소가 계속 추가 될 수 있습니다.

아래는 체이닝 기법을 적용한 버킷이 6개있는 해시 테이블입니다.

이때 삽입할때 연결리스트의 앞에 삽입합니다. 이유는 매번 끝에 원소를 삽입하면 연결리스트에 끝까지 도달해야되기 때문에 삽입 시간이 오래 걸리기 때문입니다.

 

 

개방주소법이나 체이닝 방식이나 최악의 경우(Worst Case) 성공할때까지 검색하는 시간은 O(n)의 시간이 걸릴 수 있습니다. 하지만 균형잡힌 검색 트리(Balanced Search Tree)를 이용하면 이 시간은 O(logn)까지 줄일 수 있습니다.

 

다음 포스팅은 동적 해싱에 대한 이야기를 하도록 하겠습니다.

 

 

 

반응형
블로그 이미지

REAKWON

와나진짜

,

중위 표기법(Infix)


중위 표기라는 것은 우리한테 상당히 익숙한 식입니다. 우리는 수식을 쓸때 피연산자 사이에 연산자를 포함시켜 계산을 하게 되죠.

바로 이렇게요.


1+3*2+4/2


이렇게 피연산자(숫자) 사이에 연산자(덧셈, 곱셈, 뺄셈, 나눗셈)가 있는 식을 우리는 중위표기, 바로 Infix라고 부릅니다.


하지만 우리는 왜 prefix라던가 postfix를 쓰는 것일까요?

우리가 계산하는 방식과 컴퓨터가 계산하는 방식이 다르기 때문이지요.

만약 위의 식을 우리가 계산한다면 1+6+2=9라는 것을 금방 알 수 있습니다. 하지만 컴퓨터는 1+3 이후 다음 연산자를 보고 결정해야하기 때문에 계산하기 어려워져 전위표기법이나 후위표기법을 사용합니다.



전위 표기법(Prefix)

이름에서도 알 수 있듯이 전위표기는 연산자가 피연산자 앞에 나오는 방식입니다. 이를테면 3+4는 +34라고 표현할 수 있게 되는 것이죠.



전위 표기식부터는 조금 헷갈릴 수 있으므로 조금 예를 들어 설명하도록 하는 것이 좋겠습니다.


1+2*3+1+2/2는 다음과 같은 변환을 합니다.


우선 연산자 우선순위에 맞게 괄호를 쳐줍니다.


((1+(2*3))+(1+(2/2)))


이제 이 괄호안에 있는 연산자들을 앞으로 빼줍니다.


+((+1*(23))(+1/(22)))


이제 괄호를 모두 없애줍니다.


++1*23+1/22


이렇게 나온 식이 전위표기법으로 나타낸 식입니다.


후위표기법(Postfix)

마찬가지로 연산자가 뒤에 오는 수식입니다. 피연산자는 그대로 쓰면되는 것이죠.

후위표기법은 컴파일러가 사용하는 것으로 스택을 사용하는 예들 중 가장 빈번하게 등장하지요. 따라서 저는 사용빈도가 매우 높은 postfix를 집중적으로 설명하려고 합니다.


우선 1+2*3+(4+2)/2를 마찬가지로 후위표기식으로 바꾸어봅시다.


다시 연산자 우선순위에 맞게 괄호를 처줍니다.


((1+(2*3)+((4+2)/2)))


이제 이 괄호에 있는 연산자를 괄호뒤로 빼줍니다.


((1(23)*)+((42)+2)/))+


이제 괄호를 모두 없애면 후위 표기로 바뀌게 되는 겁니다.


123*+42+2/+


또한 이런 스택을 사용해서도 위의 식을 표현해 낼 수 있습니다.


이제 스택을 이용해서 위의 식을 구해보도록 하지요.

다음과 같은 원칙을 지키면서요. 


1. 숫자가 나오면 그대로 출력한다.

2. (나오면 스택에 push한다.

3. * / 나오면 스택에 push한다.

4. + - 연산이 나오면 여는 괄호('('), 여는 괄호가 없다면 스택의 끝까지 출력하고 그 연산자를 스택에 push한다.

5. 닫는 괄호(')')가 나오면 여는 괄호('(')가 나올때까지 pop하여 출력한다.


          


우선 1이 나오니까 그대로 출력시킵니다. 이제 +가 나오지요. 이것은 연산자이므로 스택에 push하는데 스택에 남아있는 연산자가 없으므로 +만 그냥 push하도록 합니다.



          


이제 2나 나오는 군요. 2는 숫자이므로 그대로 출력합시다. 그 이후에는 *연산자가 나오는데 이것은 규칙 3에 의해 스택에 push합니다.


 

          

 

이제 다시 3을 만났습니다. 숫자니까 그냥 출력합시다.


다음은 +연산자인데요. 규칙4에 의해서 *+를 순서대로 pop하여 출력합니다. 그 후 방금 나온 +연산자를 스택에 집어 넣지요.


          


이제 여는 괄호를 만났군요. 그냥 스택에 집어넣습니다. 


이 후 피연산자 4는 그대로 출력하게 됩니다.


          


이제 +연산자를 스택에 집어넣기 전에 ( 위에 있는 모든 연산자를 출력합니다. 하지만 보시다시피 (이전에 연산자는 없었으니 그냥 +만 push하면 됩니다.


이제 피연산자 2는 그대로 출력해줍니다.

            


닫는 괄호 )를 만나는 군요. 이제 (까지 모든 연산을 비워줍니다. 그러니까 출력에 +를 출력해주는 것이죠.


이 후 나눗셈 연산(/)를 스택에 집어넣습니다. 규칙 3에 의해서요. 


         


2는 그저 숫자이므로 규칙 1에 의해 출력합니다. 이제 식은 끝났습니다. 그렇다면 스택에 남아있는 연산자들은 어떻게 할까요?


식이 끝나게 되면 스택에 있는 연산자들은 모조리 pop하면 됩니다.

그 결과 123*+42+2/+가 되는 것이죠. 위에서 우리가 구한 후위표기식과 같은 것을 알 수 있죠?




이런 후위수식을 어떻게 풀 수 있을까요? 이것 역시 스택을 쓰면 쉽게 풀 수 있습니다.

왜냐면 주제가 스택을 응용하는 것이니까요.

 


        


우선 숫자는 모조리 스택에 집어 넣습니다. 1, 2, 3이 숫자이므로 스택이 집어넣지요. 그 후 연산자가 나오게 되면 스택에 두 수를 꺼내 계산하고 다시 스택에 집어 넣습니다. 2와 3을 꺼내 곱하면 6이 되니 6을 다시 스택에 push하는 것이죠.


        


이 후에 +를 만나게 됩니다. 스택에는 1과 6이 존재하므로 1과 6을 더해 7을 만들어 다시 스택에 집어넣습니다. 그 후 2까지는 숫자이므로 스택에 push하죠.



        


다시 +연산을 만났습니다. 아까 수행했던 것처럼 두 수를 스택에서 pop하여 6을 만들어 스택에 다시 push합니다. 그 후 2는 숫자이니 스택에 push합니다.


     


이제 / 연산이네요. 두 수를 꺼내 6/2연산을 수행하고 3을 스택에 push합니다. 그 후 스택에 넣고 +를 만나게 됩니다. 이제 스택에 있는 7과 3을 꺼내 더하기 연산을 한 후 스택에 다시 집어넣습니다.


이제 모든 식은 종료되었고 스택에는 10이 남았습니다. 마지막에 남은 10이 정답이 되는 것입니다.


이제 어떻게 후위식이 연산되는지 알겠죠?



반응형
블로그 이미지

REAKWON

와나진짜

,

구간트리(Segment Tree)


구간트리는 특정 구간에서 특정한 값을 뽑아올때 유용하게 사용됩니다. 세그먼트트리라고도 하지요. 한 가지 예를 들어보도록 할게요. 어떤 배열이 아래와 같이 있다고 치구요.


int arr[] = {7, 4, 5, 1, 9, 5, 2, 11, 10};


0번 요소부터 5번째 요소까지의 가장 작은 값은 얼마인가요? 1이네요. 그쵸?

그렇다면 2번 요소부터 7번째 요소까지 가장 큰 값은 얼마일까요? 11이 됩니다. 


어떻게 찾을 수 있죠? 구간트리를 배우지 않았다면 for루프를 통해서 가장 작은 값이나 가장 큰 값을 구할겁니다.




for (i = from; i <= to; i++) {
	minVal = min(arr[i], minVal);
}


이렇게 말이죠. 아주 쉽네요!! 시간 복잡도는 O(n)입니다. 


하지만 구간에서 가장 작은 값을 계속해서 뽑아내야하는 상황이라면 구간트리를 사용해야합니다. 구간트리를 사용한다면 최소, 최대값을 찾는데 O(log n)이면 충분합니다.


구간트리의 노드는 특정 구간에서 가장 작은 값을 가지고 있습니다. 아래 트리가 구간 트리를 보여줍니다. 위의 배열을 구간트리로 표현한 모습이죠.




파란색 원 안의 숫자는 노드의 번호, 사각형 안의 숫자는 배열의 범위를 나타냅니다. 우리는 트리를 배열로 표현하기 위해서 가장 첫번째(root)는 1번 인덱스를 갖습니다. 자식 노드의 번호는 2와 3이 됩니다.

그렇다면 어떤 노드 i의 왼쪽 자식은 i*2, 오른쪽 자식은 i*2+1이 되는 것이죠.


우리가 3번 요소부터 7번 요소까지 가장 작은 값을 갖는 값을 뽑아오려면 5번, 6번, 14번 노드를 통해서 구할 수 있습니다.


이제 본격적으로 구현해보도록 합시다. 



구현(C++)


구간 트리에서 특정 구간에서 최소값을 찾는 것을 구간 최소 트리(Range Minimum Query, RMQ) 라고 합니다. 그래서 이 구조체를 만드는 것에서부터 시작합니다.




struct RMQ {
	int size;
	vector<int> minValues;
	RMQ(int *arr,int arrSize) {
		size = arrSize;
		minValues.resize(size * 4);
		init(arr, 0, size - 1,1);
	}
}

size는 배열의 size를 의미합니다. minValues는 해당 노드에서 가장 작은 값을 저장하는 벡터입니다.


왜 minValues의 사이즈를 배열의 사이즈 * 4를 할까요? 위의 트리를 다시 보게 되면 배열의 크기보다 많은 노드를 볼 수 있습니다. 완전 이진 트리를 아신다면 마지막 leaf의 개수 * 2가 트리의 노드수를 의미한다는 것을 알겁니다.

하지만 귀찮으니 4를 곱하면 된다고 하네요.


이제 이 구조체를 초기화하는 함수 init을 불러서 구간트리의 모양을 잡아보도록 합시다.


init

잘 생각해보면 간단합니다. 왼쪽 자식, 오른쪽 자식의 값을 비교해서 가장 작은 값이 지금 이 노드의 값이 됩니다.

만약 leaf노드까지 도달했다면 그 값만을 반환해주면 되죠.

그리고 구간트리의 인덱스 node라는 값도 함께 넘겨주어 현재 노드에 가장 작은 값을 저장할 수 있게끔 하면 됩니다.




int init(int *arr, int left, int right,int node) { if (left == right) return minValues[node] = arr[left]; int mid = (left + right) / 2; int leftMinValue = init(arr, left, mid, node * 2); int rightMinValue = init(arr, mid + 1, right, node * 2 + 1); return minValues[node] = min(leftMinValue, rightMinValue); }



query

이 함수는 질의, 즉 물어보는 함수입니다. 특정 구간에 가장 작은 값을 반환하여라! 라고 질문을 던져 답을 받습니다. 이 함수도 역시 잘 생각해보면 별 어려움은 없습니다. 

질의하는 범위가 노드가 커버할 수 있는 범위를 완전히 포함한다면 그 값을 내주면 됩니다.


그것이 아니라면 아주 큰 값을 리턴하면 되지요.


만약 위의 배열에서 3-7 구간에 대해 질의를 한다면 5번, 6번, 14번 노드가 3-7구간에 완전히 포함되므로 그 세개의 노드만이 자신의 값을 반환합니다. 그 후 가장 작은 값이 답이 되겠죠?


헷갈릴 수 있습니다. 노드가 커버하는 범위가 질의하는 범위에 완전히! 속해있어야합니다. 




int query(int left, int right, int node, int nodeLeft, int nodeRight) {
	if (right < nodeLeft || nodeRight < left) return INF;
	if (left <= nodeLeft&&nodeRight <= right)
		return minValues[node];

	int mid = (nodeLeft + nodeRight) / 2;
	int leftMinValue = query(left, right, node * 2, nodeLeft, mid);
	int rightMinValue = query(left, right, node * 2 + 1, mid + 1, nodeRight);

	return min(leftMinValue, rightMinValue);
}


너무 함수가 섹시하지가 않군요. 인자가 너무 많습니다. C++에 지원되는 오버로딩을 사용하여 좀 더 간편하게 부를 수 있도록 하죠.


int query(int left, int right) {
	return query(left, right, 1, 0, size - 1);
}



update

구간 트리에서 값이 바뀌면 구간의 최소값도 바뀌어여합니다. 특정 index와 새로운 value를 받게되면 구간트리의 해당 노드의 값을 바꾸고 차례대로 값을 갱신해주어야합니다. 여기서 노드의 값이 바뀌는 순서는 해당 leaf노드부터 루트까지 올라오게 됩니다.


만약 5번 인덱스가 새로운 값으로 바뀌게 되었다면 해당하는 노드의 번호 12번노드부터 6번 노드, 3번 노드, 1번 노드가 갱신되어야 하죠.



int update(int index, int value, int node, int nodeLeft, int nodeRight) {
	if (index < nodeLeft || nodeRight < index) return minValues[node];

	if (nodeLeft == nodeRight) return minValues[node] = value;
	int mid = (nodeLeft + nodeRight) / 2;
	int leftMinValue = update(index, value, node * 2, nodeLeft, mid);
	int rightMinValue = update(index, value, node * 2 + 1, mid + 1, nodeRight);
	return minValues[node]=min(leftMinValue, rightMinValue);
}

그러니까 nodeLeft==nodeRight가 같은 경우, 즉 해당하는 leaf인 경우 그 노드의 값을 갱신합니다.index의 범위 밖이면 그냥 가지고 있는 값을 반환해주면 되고, index가 포함되어 있는 경우라면 왼쪽 자식 값, 오른쪽 자식 값을 비교해서 가장 작은 값을 갖게 해주면 됩니다.


int update(int index, int value) { return update(index, value, 1, 0, size - 1); }



깔끔하게 함수를 호출할 수 있도록 오버로딩했구요.


전체코드

#include <iostream>
#include <vector>
#include <algorithm>
#define INF 99999999
using namespace std;
struct RMQ {
	int size;
	vector<int> minValues;

	RMQ(int *arr,int arrSize) {
		size = arrSize;
		minValues.resize(size * 4);
		init(arr, 0, size - 1,1);
	}

	int init(int *arr, int left, int right,int node) {
		if (left == right) return minValues[node] = arr[left];

		int mid = (left + right) / 2;
		int leftMinValue = init(arr, left, mid, node * 2);
		int rightMinValue = init(arr, mid + 1, right, node * 2 + 1);

		return minValues[node] = min(leftMinValue, rightMinValue);
	}

	int query(int left, int right, int node, int nodeLeft, int nodeRight) {
		if (right < nodeLeft || nodeRight < left) return INF;
		if (left <= nodeLeft&&nodeRight <= right)
			return minValues[node];

		int mid = (nodeLeft + nodeRight) / 2;
		int leftMinValue = query(left, right, node * 2, nodeLeft, mid);
		int rightMinValue = query(left, right, node * 2 + 1, mid + 1, nodeRight);

		return min(leftMinValue, rightMinValue);
	}

	int query(int left, int right) {
		return query(left, right, 1, 0, size - 1);
	}

	int update(int index, int value, int node, int nodeLeft, int nodeRight) {
		if (index < nodeLeft || nodeRight < index) return minValues[node];

		if (nodeLeft == nodeRight) return minValues[node] = value;
		int mid = (nodeLeft + nodeRight) / 2;
		int leftMinValue = update(index, value, node * 2, nodeLeft, mid);
		int rightMinValue = update(index, value, node * 2 + 1, mid + 1, nodeRight);
		return minValues[node]=min(leftMinValue, rightMinValue);
	}

	int update(int index, int value) {
		return update(index, value, 1, 0, size - 1);
	}
};

int main() {

	int arr[] = { 7, 4, 5, 1, 9, 5, 2, 11, 10 };
	RMQ rmq(arr, sizeof(arr) / sizeof(int));

	printf("query(0-8) : %d\n", rmq.query(0, 8));
	printf("query(1-6) : %d\n", rmq.query(1, 6));
	printf("query(7-8) : %d\n", rmq.query(7, 8));
	printf("query(3-7) : %d\n", rmq.query(3, 7));
	printf("query(0-2) : %d\n", rmq.query(0, 2));
	printf("query(0-2) : %d\n", rmq.query(4, 8));
	printf("update(index 4, value 0)) : %d\n", rmq.update(4,0));
	
}



-- 내용과 코드는 구종만의 알고리즘 문제해결 전략을 참고했습니다 

반응형
블로그 이미지

REAKWON

와나진짜

,

힙(Heap) 개념


힙이라는 자료구조는 무엇일까요? 

힙은 일종의 트리로 수의 집합에서 가장 작은 수(키)나 가장 큰 수만을 자주 꺼내올때 유용한 자료구조입니다. 


우리는 어떤 정수형 배열이 있다고 가정해볼게요.


{ 9, 8, 7, 6, 5, 4, 3, 2, 1}


이 배열에서 가장 작은 원소를 구하려면 어떻게 해야할까요? 우선 for루프를 돌겠죠? 배열 하나하나마다 비교해서 가장 작은 값을 찾아오면 시간복잡도는 O(n)이 될겁니다. 


하지만 우리의 힙은 이 작업은 logn의 속도로 해준답니다. 

어떻게 이것이 가능할까요?




힙은 트리를 쓴다고 했죠? 이 트리는 평범한 트리는 아니고 완전 이진 트리라는 트리인데요. 항상 자식은 두개밖에 없고, leaf의 가장 왼쪽부터 채우게 됩니다.


         


그림에서 왼쪽은 완전 이진 트리인데, 오른쪽은 완전 이진 트리가 아니군요. 왼쪽 트리는 잎 노드의 왼쪽부터 전부 채워져있는 형태인데, 오른쪽 트리는 그렇지 않으니까요.


힙은 크게 두가지가 있습니다. 최소힙과 최대힙이 그것들인데요. 

최소힙은 가장 작은 값이 루트이고, 최대힙은 가장 큰 값이 루트입니다. 


우리는 그 중에서도 가장 작은 것이 루트인 최소힙을 구현해보도록 할게요. 어차피 최대힙의 구현도 거의 똑같거든요. 



최소힙


그림을 보면서 최소힙을 이해하도록 합시다. 






Ο 루트는 가장 작은 값이어야합니다. 1이 가장 작으니까 루트가 되었죠?


Ο 자식은 자신보다 크기만 하면 됩니다. 그러니까 왼쪽, 오른쪽 따질 필요없이 그냥 자식으로 넣기만 하면 된다는 거지요.


Ο 완전이진트리의 규칙을 그대로 적용해야합니다. 그림도 완전이진트리 맞죠? 이 다음 추가될 노드가 8이라면 4의 자식으로 삽입되어야합니다.


Ο 아주 중요한 개념이 있는데요. 우리는 이 힙을 배열의 형태로 구현하는 것입니다. 지금부터는 위의 노드에 써있는 숫자를 배열의 인덱스라고 보세요. 

맨 위의 루트는 1번 인덱스를 가지고 있고, 그 자식의 인덱스는 어떻게 알 수 있을까요?


위의 트리를 보면 짐작이 가지 않나요?? 왼쪽 자식은 자신의 인덱스*2오른쪽 자신은 자신의 인덱스*2 + 1이라는 사실을요. 


그러니 어떤 노드의 배열 인덱스를 parent라고 할때 자식을 구하는 식은 다음과 같습니다.


leftChild = parent*2

rightChild = parent*2+1



반대로 부모는 어떻게 알 수 있을까요? 자신의 인덱스 / 2로 부모의 인덱스를 구할 수 있습니다.

그러니까 어떤 노드를 child라고 할때, 부모의 인덱스는 다음과 같은 식으로 구할 수 있는 거죠.


parent = child/2




규칙을 알았다면 이제 본격적으로 구현해볼까요? 우선 heap이라는 구조체부터 정의해볼까요?

typedef struct heap {
	int arr[MAX_N];
	int size;
} heap;


아까 말한대로 heap을 배열의 형태로 구현할 것이기 때문에 arr이라는 배열이 구조체에 선언되어 있죠. 그리고 heap의 사이즈 역시 선언되어있습니다.


1. insert


이제 heap의 삽입과정을 보도록 합시다. 어떤 수가 추가되려면 일단 잎노드부터 계속 비교하여 자리를 찾아야되는데요, 우리는 최소힙을 구현하니까 지금 삽입될 노드가 트리의 노드보다 크면 멈춰야합니다. 하지만 루트까지 비교했는데 자신보다 작은 노드를 찾을 수 없다면 자신이 가장 작은 값이라는 것이니 루트가 되겠죠? 




아래 그림은 트리에서 1이 삽입되는 과정을 보여줍니다.




우선 맨 아래의 한자리, 즉 here가 현재 삽입될 삽입될 자리를 의미합니다. here의 인덱스는 8이 되겠네요. 8의 부모인덱스는 8/2=4 입니다. 그러니까 5와 1을 비교합니다. 1이 더 작네요. 그렇다면 5는 here의 자리로 오게됩니다.




이제 here자리는 인덱스 4가 되었습니다. 부모 인덱스는 2로써 4라는 값을 갖고 있네요. 4와 1을 비교합니다. 1이 더 작네요. 그러면 here의 자리는 부모인덱스 자리인 2가 됩니다.




부모 인덱스는 1이 됩니다. 그 값은 2로 현재 추가될 1보다 크네요. here는 1의 인덱스 위치로 옮겨집니다. 





이제 부모는 없습니다. 그러므로 here의 자리에 1이 삽입되는 것이죠.






이 상황을 구현한 코드가 아래에 있습니다. 


void insert(heap* hp,int data) {
	int here = ++hp->size;
	
	while ((here!=1)&&(data<hp->arr[here/2])) {
		hp->arr[here] = hp->arr[here / 2];
		here /= 2;
	}
	hp->arr[here] = data;
}



의외로 코드가 길지 않죠?? 


hp는 heap 구조체 포인터를 의미하는 것이고 data는 현재 추가될 데이터를 말합니다. 그래서 data는 트리의 노드 값과 비교하여 data가 더 작다면 계속 올라갑니다.

언제까지요? 자신보다 작은 트리의 노드를 만날때까지요.

하지만 더이상 트리에서 data보다 작은 값을 만날 수 없다면 here는 1이 되므로 루트자리가 됩니다.




2. deleteData


이제 heap에서 꺼내오는 함수를 구현해보도록 하죠. 우선 root 위치에 있는 값을 꺼냅니다. 그 후 가장 끝에 있는 노드를 root 위치에 다시 붙이죠. 그리고 나서 계속 자식들과 비교하여 위치를 찾습니다. 


위의 heap에서 1을 꺼내오는 과정을 아래에서 보여주고 있습니다. 







1을 우선 꺼냅니다. 1은 나가있어 뒤지기 시르면 

루트가 비어있네요. 루트를 가장 끝에 있는 노드 5로 매꿉니다.




이제 5라는 노드의 자식을 비교해서 위치를 찾는데요. 두 자식보다 자신이 더 작다면 멈추면 됩니다. 두 자식보다 가장 작은 녀석은 2인데, 2가 5보다 작으므로 5와 2의 위치를 바꿔줍니다. 


그러면 아래와 같은 트리가 되겠죠?






5의 자식은 4와 7이 되었습니다. 둘 중 가장 작은 자식은 4인데, 5는 4보다 크지요? 그러니까 4와 5의 위치를 바꿔줍니다. 




이제 더 이상 내려갈 곳이 없군요. 5의 위치는 이제 정해졌습니다.

자 보세요. 1을 꺼냈어도 완전 이진트리의 모양을 유지하고 있고 가장 작은 값이 루트인것이 보이죠? 이제 코드로 구현하면 됩니다.



int deleteData(heap *hp) {
	if (hp->size == 0) return -1;
	int ret = hp->arr[1];
	hp->arr[1]=hp->arr[hp->size--];
	int parent = 1;
	int child;

	while (1) {
		child = parent * 2;
		if (child + 1 <= hp->size && hp->arr[child]>hp->arr[child + 1])
			child++;

		if (child>hp->size||hp->arr[child] > hp->arr[parent]) break;
		
		swap(&hp->arr[parent], &hp->arr[child]);
		parent = child;
	}
	
	return ret;
	
}


우선 heap의 사이즈가 0이면 -1을 리턴하도록 했습니다.
그리고 root의 요소를 반환할 변수에 저장하고 난 후 새로운 루트를 마지막 노드로 바꾸는게 보이세요? 이 구간이요.

int ret = hp->arr[1];
	hp->arr[1]=hp->arr[hp->size--];

이후 while루프 안에서 가장 작은 자식과 자신을 비교해서 자신이 더 작을때까지 돌거나 더 이상 자식이 없을 때까지 돕니다.




전체 코드


이제 전체코드를 통해서 직접 실행해 보세요.


#include <stdio.h>
#define MAX_N 1024
typedef struct heap {
	int arr[MAX_N];
	int size;
} heap;

void swap(int *a, int *b) {
	int temp = *a;
	*a = *b;
	*b = temp;
}
void initHeap(heap *hp) {
	hp->size = 0;
}
void insert(heap* hp,int data) {
	int here = ++hp->size;
	
	while ((here!=1)&&(data<hp->arr[here/2])) {
		hp->arr[here] = hp->arr[here / 2];
		here /= 2;
	}
	hp->arr[here] = data;
}

int deleteData(heap *hp) {
	if (hp->size == 0) return -1;
	int ret = hp->arr[1];
	hp->arr[1]=hp->arr[hp->size--];
	int parent = 1;
	int child;

	while (1) {
		child = parent * 2;
		if (child + 1 <= hp->size && hp->arr[child]>hp->arr[child + 1])
			child++;

		if (child>hp->size||hp->arr[child] > hp->arr[parent]) break;
		
		swap(&hp->arr[parent], &hp->arr[child]);
		parent = child;
	}
	
	return ret;
	
}
int main() {
	heap hp;
	initHeap(&hp);
	
	insert(&hp, 3);
	insert(&hp, 5);
	insert(&hp, 1);
	insert(&hp, 2);
	insert(&hp, 8);
	
	printf("%d\n", deleteData(&hp));
	printf("%d\n", deleteData(&hp));
	printf("%d\n", deleteData(&hp));

	printf("\n");
	insert(&hp, 9);
	insert(&hp, 3);
	printf("%d\n", deleteData(&hp));
	printf("%d\n", deleteData(&hp));

	printf("\n");
	insert(&hp, 1);
	insert(&hp, 9);
	insert(&hp, 10);
	printf("%d\n", deleteData(&hp));
	printf("%d\n", deleteData(&hp));
	printf("%d\n", deleteData(&hp));
	printf("%d\n", deleteData(&hp));
	printf("%d\n", deleteData(&hp));
	printf("%d\n", deleteData(&hp));
	printf("%d\n", deleteData(&hp));

}


제가 직접 구현했기때문에 버그가 있을 수 있습니다.
혹시나 있다면 알려주세요^^


반응형
블로그 이미지

REAKWON

와나진짜

,

트리

트리는 비선형 자료구조로 노드로 구성되어 있습니다. 노드들이 링크(선)를 통해 연결되어 있으며 계층적 구조를 나타내고자 할때 사용됩니다.


아래의 트리 그림을 가지고 하나하나 기본적인 설명을 하도록 하겠습니다.






⊙루트노드 (root) : 트리의 가장 위, 즉 최상위 노드를 말합니다. 트리에서 가장 중요한 노드입니다. 여기서 A가 루트노드입니다.




잎 또는 단말 노드 (leaf) : 트리의 가장 끝자락에 위치한 노드입니다. 위 트리에서는 G, H, E, I, J가 해당됩니다.


부모노드 (parent) : 현재노드의 한단계 위 계층에 있는 노드를 말합니다. I의 부모노드는 F, C의 부모노드는 A입니다. 


형제노드 (sibling) : 부모가 같은 노드를 의미합니다. D의 형제노드는 E, G의 형제노드는 H입니다.


내부노드 (internal) : leaf가 아닌 노드를 의미합니다.

노드 깊이 (depth) : 루트에서 시작해서 얼마나 깊이 들어가야 그 노드를 만날 수 있는지를 깊이라고 합니다. 기준이 루트노드이므로 A의 깊이는 0, C의 깊이는 1, H의 깊이는 3입니다.

노드 레벨 (level) : 같은 깊이를 갖는 노드의 집합입니다. 단, 깊이와는 다르게 1부터 시작합니다. 레벨 1의 노드는 A, 레벨 2의 노드들은 B, C, 레벨 3의 노드들은 D, E, F가 되겠죠.

트리 높이 (height) : 가장 긴 깊이를 의미합니다. 위 트리의 높이는 3이 됩니다. 트리 높이는 트리에서 가장 중요한 요소인데, 노드의 갯수만큼 높이가 커지면 linkedlist와 성능이 같게 되죠. 그래서 높이가 트리에서 성능을 좌우합니다.

노드 차수 (degree) : 각 노드에서 나가는 선을 의미합니다. 즉, 가지수를 말하겠죠? A의 차수는 2, C의 차수는 1이 됩니다.

트리의 차수 :  가장 큰 차수를 갖고 있는 노드의 차수가 됩니다. 위의 트리에서는 2보다 큰 차수는 없으니, 트리의 차수는 2가 됩니다.

트리의 노드가 N이라고 할때 가지수는 무조건 N-1이 됩니다. 왜냐면 트리에서는 사이클이 존재하지 않기 때문인데, 위 트리에서 노드와 노드사이에 선을 한개만 더 추가하게 되면 사이클이 발생합니다. 특정 노드와 노드사이를 계속 돌 수 있다는 이야기죠. 그러니, 사이클이 발생하는 트리는 없습니다.


트리 순회


트리에는 크게 세가지의 순회 방법이 있습니다. 그 노드를 어떤 순서로 방문하느냐에 따라서 달라집니다. 그 전에 트리를 구현하는 방법을 알아보아야겠죠?


노드가 어떤 식으로 구성되어 있는 지 잠깐만 살펴보겠습니다.

typedef struct node {
	char data;
	struct node *left;
	struct node *right;
} Node;



각 노드는 data라는 변수를 갖고 있습니다. 이 data의 변수는 위 그림에서 알파벳을 저정하고 있습니다.

그리고 left는 그 노드의 왼쪽 자식 노드, right는 그 노드의 오른쪽 자식노드를 의미합니다. 가장 끝에 있는 노드는 left와 right가 NULL이라는 점입니다.


위 트리를 3가지 순회방법인 전위순회, 후위순회, 중위순회 순으로 탐색해봅시다




전위순회(preorder traversal)

전위순회는 현재 노드를 가장 먼저 방문합니다. 그러니 왼쪽 자식과 오른쪽 자식을 현재 자식보다 나중에 방문합니다.

위 그래프를 전위순회로 방문하게 된다면 

A -> B -> D -> G -> H -> E -> C ->F ->I -> J

순으로 트리가 방문됩니다.

void preorder(Node *node) {
	if (node == NULL) return;
	printf(" %c ", node->data);
	preorder(node->left);
	preorder(node->right);
}



후위순회(postorder traversal)

이름에서부터 짐작하셨겠지만 현재 노드를 가장 나중에 방문하는 방식의 순회입니다. 그러니 왼쪽 자식, 오른쪽 자식 노드를 우선적으로 방문합니다.

그러니 후위순회의 결과는

G -> H -> D -> E -> B ->I -> J -> F -> C -> A

입니다.

void postorder(Node *node) {
	if (node == NULL) return;
	postorder(node->left);
	postorder(node->right);
	printf(" %c ", node->data);
}




중위순회(inorder traversal)

우선 왼쪽 자식을 방문하고, 현재 노드를 방문합니다. 그리고 나서 오른쪽 자식 노드를 방문하게 됩니다. 현재 노드를 중간에 방문한다고 해서 중위순회가 됩니다.

방문 순서는 이렇습니다.

G -> D -> H -> B -> E -> A -> C ->I -> F -> J


void inorder(Node *node) {
	if (node == NULL) return;
	inorder(node->left);
	printf(" %c ", node->data);
	inorder(node->right);
}




전체코드

전체코드는 아래와 같습니다. 위의 트리를 그대로 코드로 옮긴거죠. 입력받으면서 동적으로 할 수도 있겠지만 그런 귀찮은 부분까지는 고려하지 않았습니다. 여러분들이 더 잘하실테니까요.

#include <stdio.h>
#include <stdlib.h>
typedef struct node {
	char data;
	struct node *left;
	struct node *right;
} Node;

Node *createNode(char data) {
	Node *node = (Node*)malloc(sizeof(Node));
	node->data = data;
	node->left = NULL;
	node->right = NULL;
	return node;
}
void preorder(Node *node) {
	if (node == NULL) return;
	printf(" %c ", node->data);
	preorder(node->left);
	preorder(node->right);
}

void postorder(Node *node) {
	if (node == NULL) return;
	postorder(node->left);
	postorder(node->right);
	printf(" %c ", node->data);
}

void inorder(Node *node) {
	if (node == NULL) return;
	inorder(node->left);
	printf(" %c ", node->data);
	inorder(node->right);
}


int main() {
	Node *A = createNode('A');
	Node *B = createNode('B');
	Node *C = createNode('C');
	Node *D = createNode('D');
	Node *E = createNode('E');
	Node *F = createNode('F');
	Node *G = createNode('G');
	Node *H = createNode('H');
	Node *I = createNode('I');
	Node *J = createNode('J');
	A->left = B;
	A->right = C;
	B->left = D;
	B->right = E;
	D->left = G;
	D->right = H;
	C->right = F;
	F->left = I;
	F->right = J;


	printf("전위 순회: ");
	preorder(A);
	printf("\n");

	printf("후위 순회: ");
	postorder(A);
	printf("\n");

	printf("중위 순회: ");
	inorder(A);
	printf("\n");

	return 0;
}


반응형
블로그 이미지

REAKWON

와나진짜

,

연결형 큐


큐를 링크드리스트 형태로 구현할 수 있습니다. 스택에서처럼 말이죠. 스택과는 다르게 큐는 front와 rear가 있기 때문에 큐는 두개의 노드를 갖고 있어야합니다.


아래 그림처럼 말이죠.




큐의 특징은 알아보았으니(또는 이미 알고있거나) 어떻게 구현을 할 지 생각해보도록 합시다.




구현

이제 Node라는 구조체 변수를 갖는 LinkedQueue를 구현해보겠습니다. 하나하나 차근차근히요.

Node구조체는 아래와 같습니다. data와 다음 노드를 가리키기 위한 next변수를 가지고 있지요.


struct node {
	int data;
	struct node *next;
};

이제 LinkedQueue를 간단하게 정의해보지요.

struct LinkedQueue {
	Node *front, *rear;
	int len;
	LinkedQueue() {
		front = rear = NULL;
                len=0;
	}
};


구조체의 초기값을 정합니다. front와 rear를 갖고 있고 이 둘은 처음에는 가리키는 노드가 없으니까 NULL이 됩니다. 그리고 길이 역시 0입니다.


우리는 이제 size, isEmpty, enqueue, dequeue 4개의 연산을 구현해보도록 할겁니다. 링크드리스트로 구현하는 형태이기 때문에 그 크기가 가변적입니다. 그렇기 때문에 isFull연산은 없습니다.


1. size

뭐 간단하죠? 그냥 len만 반환하면 되니까요.

int size() {
	return len;
}


2. isEmpty

간단합니다. len이 0이면 그냥 비어있는 겁니다. 바로 이해할겁니다. 허나, front로도 구할 수 있습니다. front는 이제 꺼내야될 노드를 가리키는 녀석입니다. front가 NULL이라면 꺼낼 노드가 없게 되니까 큐가 비어있는 상태로 보면 되겠죠. 


코드는 아래와 같습니다. 주석을 해제하고 같은 결과가 나오는지 확인해보세요.

bool isEmpty() {
	//return front == NULL;
	return len == 0;
}




3. enqueue

큐에 노드를 집어 넣습니다. 일단 큐가 비어있다면 front와 rear는 처음 들어오는 노드를 가리킵니다.


하지만 큐가 비어있지 않는다면 rear을 이용해야합니다. rear의 다음 노드(rear->next)가 새로 들어온 노드가 됩니다. 이후 rear을 새로 들어온 노드로 가리키면 되는 것이죠.


큐에 데이터가 추가되었으니 길이는 하나 증가합니다.


void enqueue(int data) {
	Node *node = (Node*)malloc(sizeof(Node));
	node->data = data;
	node->next = NULL;
	if (isEmpty()) {
		front = rear = node;
	}
	else {
		rear->next = node;
		rear = rear->next;
	}
	len++;
}


4. dequeue

큐에서 하나 꺼내는 연산인데요. 어디서 꺼내느냐. front가 가리키는 노드를 꺼내면 됩니다. 아참, 그전에 큐가 비어있는지 확인하는게 먼저겠죠. 큐에 내용이 있다면 front노드의 데이터를 하나 꺼내고, 임의로 node라는 포인터를 써 front가 가리키는 노드를 같이 가리키게 합니다. 그 이후 front->next로 front를 다음 노드로 가리킵니다. 그리고 메모리를 해제하면 됩니다. 


큐에서 데이터가 삭제됐으니 길이는 줄겠죠?


int dequeue() {

	if (isEmpty()) {
		cout << "Q is empty" << endl;
		return INF;
	}
	Node *delNode = front;
	int ret = delNode->data;
	front = delNode->next;
	free(delNode);
	len--;
	return ret;
}




전체코드


이제 전체코드를 보도록 해요. 메인에서는 큐에 1부터 5까지 삽입하고 다시 10을 삽입합니다. 결과는 어떻게 될까요?

#include <iostream>
#define INF 987654321
using namespace std;
struct Node {
	int data;
	Node *next;
};

struct LinkedQueue {
	Node *front, *rear;
	int len = 0;
	LinkedQueue() {
		front = rear = NULL;
	}
	int size() {
		return len;
	}

	bool isEmpty() {
		return len ==0;
	}

	void enqueue(int data) {
		Node *node = (Node*)malloc(sizeof(Node));
		node->data = data;
		node->next = NULL;
		if (isEmpty()) {
			front = rear = node;
		}
		else {
			rear->next = node;
			rear = rear->next;
		}
		len++;
	}

	int dequeue() {
		
		if (isEmpty()) {
			cout << "Q is empty" << endl;
			return INF;
		}

		Node *delNode = front;
		int ret = delNode->data;
		front = delNode->next;
		free(delNode);
		len--;
		return ret;
	}
};

int main() {
	
	LinkedQueue q;
	for (int i = 1; i <= 5; i++)
		q.enqueue(i);

	q.enqueue(10);

	while (!q.isEmpty()) 
		cout << q.dequeue() << endl;
	
}

원하는 대로 결과가 나왔나요?




여기까지 큐를 링크드리스트로 구현하는 방법을 알아보았습니다. 링크드리스트로 구현한 큐이든, 선형큐이든 항상 무슨 프로그램을 구현할 것인지 고려하여 쓰기 바랍니다.

반응형
블로그 이미지

REAKWON

와나진짜

,

큐(Queue)


스택과 양대산맥인 큐는 스택과 반대의 성격을 지닌 녀석입니다. 스택이 후입선출(Last In First Out)방식이었다면 큐는 선입선출(First In First Out)방식이 되지요. 스택과 큐는 컴퓨터 과학에서 아주 중요한 도구들입니다. 스택의 개념과 사용처는 지난 포스팅에서 했으니, 큐에 대해서만 설명해보도록 하겠습니다.


큐는 개념은 정말 쉽습니다. 첫번째로 들어오는 녀석을 가장 먼저 내보내는 것이 큐입니다. 현실 세계에서도 역시 큐에 개념이 사용됩니다. 버스 기다릴때나 은행 업무를 기다릴때, 가장 먼저 도착한 사람이 버스를 타거나 은행업무를 보게 되지요. 새치기하지마라 진짜





'


위의 그림 처럼 말이죠. 그냥 쭉 줄서있죠? 이런 큐를 선형큐라고 합니다.


그림에서는 보이진 않지만 가장 앞에 있는 원소를 Front, 그리고 가장 뒤에 있는 원소를 Rear라고 합니다.


큐에는 기본적으로 두 가지의 연산이 있습니다. 바로 enqueue와 dequeue입니다.


enqueue 큐에 데이터를 삽입합니다.

dequeue 큐에서 데이터를 꺼내옵니다.


선형큐는 구현하는 게 매우 간단하지만( 단지 배열로 일직선으로 구현) 치명적인 약점이 있는데요.


1) 큐에 데이터가 꽉 찼다면 데이터를 더 추가할 수 없습니다(물론 큐 사이즈를 늘리고 원소를 다시 복사하면 됩니다. 근데 시간이 안습이죠.). 


2)공간의 효율성을 고려해야합니다. 배열로 단순히 구현하면 front가 뒤로 계속 밀려 앞의 공간이 남게 되죠. 그러니까 하나의 원소가 빠져나가면 그 다음부터 모든 녀석들을 앞으로 당겨와야하니까 속도 측면에서 상당히 느립니다. 작은 데이터들이라면 물론 상관없지만, 요즘처럼 사이즈가 크고 많은 데이터를 처리하기에는 무리죠.


사용하기에는 단점이 좀 아프니까 선형큐는 개념 정도만 알고 가겠습니다.




원형큐(Circular Queue)

선형큐의 단점을 보완할 수 있는 큐로는 바로 원형큐가 있습니다. 큐를 직선 형태로 놓는 것 보다 원형으로 생각해서 큐를 구현하는 것이죠. 큐를 원형으로 생각해야되기 때문에 모듈러 연산(나머지 연산)을 해야합니다.


그림으로 설명해보도록 하지요.





우선 큐의 시작점은 어느 점이라도 됩니다. 그냥 단순히 보기 위해서 가장 아래에 있는 점을 시작 점이라고 정의해보겠습니다.


그리고 색이 칠해져있는 부분이 노드가 삽입된 상태입니다.


또한, front가 노드를 가리키고 있지 않고 그 전의 점을 가리키고 있는 이유는 우선 증가 연산 후에 데이터를 꺼내오기 때문입니다.

 


우리는 아래 구조체를 코딩을 통해서 큐를 구현할 겁니다. SIZE는 원형큐의 SIZE를 의미합니다. 그리고 front와 rear가 배열의 마지막 원소로 지정한 것은 배열 인덱스 0부터 시작하려고 하기 때문입니다(선증가 후 원소를 삽입한다는 사실만 지금은 기억하기 바랍니다.). 뭐 front와 rear를 0으로 하건 1로하건 상관없습니다.


struct cirQueue {
	
	int arr[SIZE];
	int front, rear;
	
	cirQueue() {
		front = SIZE-1;
		rear = SIZE-1;
	}
};


cirQueue()는 front와 rear의 초기값을 지정합니다. 이렇게 하고 싶지 않다면  변수를 선언할때 일반적인 구조체를 다루듯이 

cirQueue q;

q.front=q.rear=SIZE-1;

이런식으로 코딩하면 됩니다.


우리는 이제 4가지의 연산을 정의할 건데요. 잘 따라오세요.




1. isEmpty

큐가 비어있냐 아니냐를 질의하는 연산입니다. 처음 큐의 rear와 front는 같은 자리에 위치하게 됩니다. 그러니 rear와 front는 당연히 같은 값이 되지요. 그리고 큐가 삽입되고 꺼내어지고 할 때 역시 큐가 비어있게 된다면 rear와 front는 역시 같은 값이 됩니다. front를 하나 증가하고 난 이후에 데이터를 꺼내오니까요.




코드도 역시 간단하게 구현이 되는 군요.

bool isEmpty() {
		return rear == front;
}


2. isFull

큐에 데이터가 꽉차있는 상태인지 알아보는 연산입니다. rear를 하나 증가시키고 데이터를 넣으려보니까 그 자리에는 front라는 것이 자리잡고 있는 겁니다. 


여기서 중요한 사실은 큐에서 1개는 쓸 수 없다는 사실입니다. rear+1자리에 front가 있는 지 알아보기 위해서지요.





bool isFull() { return ((rear + 1) % SIZE) == front; }


여기서 큐의 SIZE로 나누는 이유는 계속 rear+1이 큐의 SIZE보다 클 수 있다는 겁니다. rear가 SIZE-1의 원소(배열의 마지막 원소)를 가리키고 있고 다음 rear+1은 SIZE가 아니라 0이 되어야합니다. 그래야 원형큐가 되니까요.


아참! 간간히 눈에 보이지 않는 오류가 있는데 수식을 잘 보셔야됩니다. 덧셈보다 모듈러 연산의 우선순위가 더 높기 때문에 (rear +1 % SIZE) == font 를 하게 되면 1%SIZE 부터 계산하게 됩니다. 우리가 원하는 것은 rear를 먼저 1증가시킨 후 SIZE로 나눈 나머지를 구하는 것이니 괄호를 처리해야 합니다. 

( (rear + 1) % SIZE ) == front 로요.




3. enqueue

큐에 원소를 삽입하는 연산입니다. 우선 큐가 꽉차있는 상태인지 아닌지 부터 알아봅니다. 삽입하기 충분한 공간이 있다면 현재 rear에서 1을 증가시킵니다 다음 원소에 추가시키기 위해서요. 그자리에 원소를 삽입하면 됩니다. 


여기서도 역시 나머지 연산이 들어갑니다. 원형큐이기 때문에 SIZE보다 크면 다시 처음부터 원소가 삽입되어야 하기 때문입니다.


void enqueue(int data) {
		if (isFull()) {
			cout << "Q is full" << endl;
			return;
		}
		rear = (rear + 1) % SIZE;
		arr[rear] = data;
}

4. dequeue

큐에 원소를 꺼내어 오는 연산입니다. 맨 처음 들어간 원소는 front+1이 됩니다. 그러니까 우선 큐가 비어있는 지 확인하는 작업이 필요합니다. 비어있는 큐에 원소를 꺼내올 수 없으니까요.


이후 front를 한칸 증가시켜 그 원소를 빼내옵니다. 그러고 나면 front는 빈 원소를 가리키게 되는 것이죠.


역시 모듈러 연산이 쓰이는 군요. 이유는 위와 같습니다.



int dequeue() {
		if (isEmpty()) {
			cout << "Q is empty" <<endl;
			return INF;
		}
		return arr[front = (front + 1) % SIZE];
}



전체 코드

이렇게 큐의 연산을 모두 끝냈습니다. 전체 코드는 아래에 있습니다.

#include <iostream>
#define INF 987654321
#define SIZE 6
using namespace std;

struct cirQueue {
	
	int arr[SIZE];
	int front, rear;
	
	cirQueue() {
		front = SIZE-1;
		rear = SIZE-1;
	}
	
	bool isEmpty() {
		return rear == front;
	}
	
	bool isFull() {
		return ((rear + 1) % SIZE) == front;
	}

	void enqueue(int data) {
		if (isFull()) {
			cout << "Q is full" << endl;
			return;
		}
		rear = (rear + 1) % SIZE;
		arr[rear] = data;
	}

	int dequeue() {
		if (isEmpty()) {
			cout << "Q is empty" << endl;
			return INF;
		}
		return arr[front = (front + 1) % SIZE];
	}
};

int main() {
	
	cirQueue q;
	for (int i = 1; i <= 6; i++)
		q.enqueue(i);
	q.enqueue(10);
	while (!q.isEmpty())
		cout << q.dequeue() << endl;
	cout << q.isEmpty() << endl;
	cout << q.isFull() << endl;
}


결과는?


메인함수에서 코드를 보면 왜 이렇게 결과가 나오는지 이해할 수 있을 겁니다.

반응형
블로그 이미지

REAKWON

와나진짜

,