728x90
반응형

1. 탐색이란

 - 탐색(search)은 컴퓨터가 가장 많이 하는 작업 중의 하나이다.

 - 탐색은 많은 시간이 요구되므로 효율적으로 수행하는 것은 매우 중요하다.

 - 탐색의 단위는 항목이다. 항목 안에는 항목과 항목을 구별시켜주는 키(key)가 존재한다. 이를 탐색 키(search key)라고 한다.

 

2. 정렬되지 않은 배열에서의 탐색

 1) 순차 탐색(sequential search)

int seq_search(int key, int low, int high)
{
	int i;
	for (i = low; i <= high; i++)
		if (list[i] == key)
			return i;  // 탐색에 성공하면 키 값의 인덱스 반환 
	return -1;    // 탐색에 실패하면 -1 반환 
}

 2) 개선된 순차 탐색

  - 리스트의 끝에 찾고자 하는 키값을 저장, 비교 연산의 수를 반으로 줄일 수 있다.

int seq_search2(int key, int low, int high)
{
	int i;
	list[high + 1] = key;
	for (i = low; list[i] != key; i++) // 키값을 찾으면 종료  
		;
	if (i == (high + 1)) return -1;   // 탐색 실패 
	else     return i;           // 탐색 성공 
}

 

3. 정렬된 배열에서의 탐색

 1) 이진 탐색

  - 정렬된 배열의 탐색에는 이진 탐색(binary search)이 가장 적합하다.

  - 10억 명의 이름이 정렬된 배열에서 이진 탐색을 이용하면 단지 30번의 비교만으로 검색이 완료된다.

  - 이진 탐색은 데이터의 삽입이나 삭제가 빈번할 시에는 적합하지 않고, 주로 고정된 데이터에 대한 탐색에 적합하다.

  - 재귀 호출을 이용한 이진 탐색

int search_binary(int key, int low, int high)
{
	int middle;
	if (low <= high) {
		middle = (low + high) / 2;
		if (key == list[middle])    // 탐색 성공
			return middle;
		else if (key<list[middle]) // 왼쪽 부분리스트 탐색 
			return search_binary(key, low, middle - 1);
		else                   // 오른쪽 부분리스트 탐색 
			return search_binary(key, middle + 1, high);
	}
	return -1;        // 탐색 실패
}

 

  - 반복을 이용한 이진 탐색

int search_binary2(int key, int low, int high)
{
	int middle;

	while (low <= high) {       // 아직 숫자들이 남아 있으면
		middle = (low + high) / 2;
		if (key == list[middle])
			return middle;
		else if (key > list[middle])
			low = middle + 1;
		else
			high = middle - 1;
	}
	return -1;   // 발견되지 않음 
}

 

 2) 색인 순차 탐색

  - 색인 순차 탐색(indexed sequential search) 방법은 인덱스(index)라 불리는 테이블을 사용하여 탐색의 효율을 높이는 방법이다.

  - 인덱스 테이블은 주자료 테이블에서 일정 간격으로 발췌한 자료를 가지고 있다.

  - 이때 주자료 테이블과 인덱스 테이블은 모두 정렬되어 있어야 한다.

int search_index(int key)
{
	int i, low, high;
	// 키 값이 리스트 범위 내의 값이 아니면 탐색 종료 
	if (key<list[0] || key>list[n - 1])
		return -1;

	// 인덱스 테이블을 조사하여 해당키의 구간 결정
	for (i = 0; i<INDEX_SIZE; i++)
		if (index_list[i].key <= key &&
			index_list[i + 1].key>key)
			break;
	if (i == INDEX_SIZE) {  // 인덱스테이블의 끝이면 
		low = index_list[i - 1].index;
		high = n;
	}
	else {
		low = index_list[i].index;
		high = index_list[i + 1].index;
	}
	// 예상되는 범위만 순차 탐색 
	return seq_search(key, low, high);
}

 

 3) 보간 탐색

  - 보간 탐색(interpolation search)은 사전이나 전화번호부를 탐색하는 방법과 같이 탐색 키가 존재할 위치를 예측하여 탐색하는 방법이다.

  - 탐색 위치 = $$ \frac{k-list[low]}{list[high]-list[low]}*(high-low)+low $$

  - 위의 식은 탐색 위치를 결정할 때 찾고자 하는 키 값이 있는 곳에 근접하게 되도록 가중치를 주는 것이다.

  - 값과 위치는 비례한다는 가정

int search_interpolation(int key, int n)
{
	int low, high, j;
	low = 0;
	high = n - 1;
	while ((list[high] >= key) && (key > list[low])) {
		j = ((float)(key - list[low]) / (list[high] - list[low])
			*(high - low)) + low;
		if (key > list[j]) low = j + 1;
		else if (key < list[j]) high = j - 1;
		else low = j;
	}
	if (list[low] == key) return(low);  // 탐색성공
	else return -1;  // 탐색실패
}

 

4. 균형 이진 탐색 트리

 - 이진 탐색 트리는 비교적 빠른 시간 안에 삽입과 삭제를 끝마칠 수 있는 구조

 - 삽입, 삭제가 빈번히 이루어진다면 이진 탐색 트리를 사용하는 것이 적합하다.

 - 이진 탐색 트리가 경사 트리가 되면 효율이 매우 떨어지므로 균형을 유지하는 것이 무엇보다 중요하다.

 

 1) AVL 트리

  - 각 노드에서 왼쪽 서브 트리의 높이와 오른쪽 서브 트리의 높이 차이가 1 이하인 이진 탐색 트리를 말한다.

  - 균형 인수(balance factor) (왼쪽 서브 트리의 높이 - 오른쪽 서브 트리의 높이)로 정의된다.

  - 모든 노드의 균형 인수가 ±1이하이면 AVL 트리이다.

 

  (1) AVL 트리의 탐색 연산

   - AVL 트리도 탐색에서는 일반 이진 탐색 트리의 탐색 연산과 동일하다. 따라서 시간 복잡도는 O(log2n)이다.

  (2) AVL 트리의 삽입 연산

   - 균형 상태의 이진 탐색 트리에서 균형이 깨지는 것은 삽입 연산과 삭제 연산 때문이다.

   - 균형 인수가 ±2가 된 가장 가까운 조상 노드의 서브 트리들에 대하여 다시 균형을 잡아야 한다. 그 외의 다른 노드들은 일체 변경할 필요가 없다.

   - A는 ±2가 된 가장 가까운 조상 노드, N은 새로 삽입된 노드

   - LL 회전 : A부터 N까지의 경로상의 노드들을 오른쪽으로 회전시킨다.

   - RR 회전 : A부터 N까지의 경로상의 노드들을 왼쪽으로 회전시킨다.

   - RL 회전 : A부터 N까지의 경로상의 노드들을 오른쪽 - 왼쪽으로 회전시킨다.

   - LR 회전 : A부터 N까지의 경로상의 노드들을 왼쪽 - 오른쪽으로 회전시킨다.

 

   - AVL 트리에서의 단순 회전 알고리즘

rotate_LL(A)

           B(중간 노드)의 오른쪽 자식을 A의 왼쪽 자식으로 만든다.

           A B의 오른쪽 자식 노드로 만든다.

rotate_RR(A)

           B(중간 노드)의 왼쪽 자식을 A의 오른쪽 자식으로 만든다.

           A B의 왼쪽 자식 노드로 만든다.

 

  - AVL 트리에서의 이중 회전 알고리즘

rotate_RL(A)

           rotate_LL(B)가 반환하는 노드를 A의 오른쪽 자식으로 만든다.

           rotate_RR(A)

rotate_LR(A)

           rotate_RR(B)가 반환하는 노드를 A의 왼쪽 자식으로 만든다.

           rotate_LL(A)

 

#include <stdio.h>
#include <stdlib.h>
#include <memory.h>

struct avl_node   {
    struct avl_node *left_child, *right_child;  /* Subtrees. */
    int data;                   /* Pointer to data. */
};

struct avl_node *root;

// 오른쪽 회전 함수
struct avl_node *
rotate_right(struct avl_node *parent)
{
	struct avl_node *child = parent->left_child;
	parent->left_child = child->right_child;
	child->right_child = parent;
	return child;
}
// 왼쪽 회전 함수
struct avl_node *
rotate_left(struct avl_node *parent)
{
	struct avl_node *child = parent->right_child;
	parent->right_child = child->left_child;
	child->left_child = parent;
	return child;
}
// 오른쪽-왼쪽 회전 함수
struct avl_node *
rotate_right_left(struct avl_node *parent)
{
	struct avl_node *child = parent->right_child;
	parent->right_child = rotate_right(child);
	return rotate_left(parent);
}
// 왼쪽-오른쪽 회전 함수
struct avl_node *
rotate_left_right(struct avl_node *parent)
{
	struct avl_node *child = parent->left_child;
	parent->left_child = rotate_left(child);
	return rotate_right(parent);
}
// 트리의 높이를 반환
int get_height(struct avl_node *node)
{
	int height=0;
	if( node != NULL )
		height = 1 + __max(get_height(node->left_child), get_height(node->right_child));
	return height;
}

// 노드의 균형인수를 반환
int get_height_diff(struct avl_node *node) 
{
	if( node == NULL ) return 0;
	return get_height(node->left_child) - get_height(node->right_child);
} 

// 트리를 균형트리로 만든다
struct avl_node *
rebalance(struct avl_node **node)
{
	int height_diff = get_height_diff(*node);
	if( height_diff > 1 ){
		if( get_height_diff((*node)->left_child) > 0 )
			*node = rotate_right(*node);
		else
			*node = rotate_left_right(*node);
	}
	else if ( height_diff < -1 ){
		if( get_height_diff((*node)->right_child) < 0 )
			*node = rotate_left(*node);
		else
			*node = rotate_right_left(*node);
	}
	return *node;
}

// AVL 트리의 삽입 연산
struct avl_node *
avl_add(struct avl_node **root, int new_key)
{
	if( *root == NULL ){
		*root = (struct avl_node *)
			malloc(sizeof(struct avl_node));
		if( *root == NULL ){
			fprintf(stderr, "메모리 할당 에러\n");
			exit(1);
		}
		(*root)->data = new_key;
		(*root)->left_child = (*root)->right_child = NULL;
	}
	else if( new_key < (*root)->data ){
		(*root)->left_child=avl_add(&((*root)->left_child), new_key);
		*root = rebalance(root);
	}
	else if( new_key > (*root)->data ){
		(*root)->right_child=avl_add(&((*root)->right_child), new_key);
		(*root) = rebalance(root);
	}
	else{
		fprintf(stderr, "중복된 키 에러\n");
		exit(1);
	}
	return *root;
}

// AVL 트리의 탐색함수
struct avl_node *avl_search(struct avl_node *node, int key) 
{ 
	if( node == NULL ) return NULL; 
	printf("%d ", node->data);
	if( key == node->data ) return node; 
	else if( key < node->data ) 
	   	return avl_search(node->left_child, key); 
	else 
		return avl_search(node->right_child, key); 
} 

void main()
{
	//8,9,10,2,1,5,3,6,4,7,11,12
	avl_add(&root, 8);
	avl_add(&root, 9);
	avl_add(&root, 10);
	avl_add(&root, 2);
	avl_add(&root, 1);
	avl_add(&root, 5);
	avl_add(&root, 3);
	avl_add(&root, 6);
	avl_add(&root, 4);
	avl_add(&root, 7);
	avl_add(&root, 11);
	avl_add(&root, 12);
	avl_search(root, 12);
}

 

 2) 2-3 트리

  - 2-3트리는 차수가 2또는 3인 노드를 가지는 트리

  - 2-노드는 일반 이진 탐색 트리처럼 하나의 데이터 k1과 두 개의 자식 노드를 가진다.

  - 3-노드는 2개의 데이터 k1, k2 3개의 자식 노드를 가진다.

  - 2-3 트리에서의 탐색

tree23_search(Tree23Node *root, int key)
{
	if (root == NULL)			// 트리가 비어 있으면
		return FALSE;
	else if (key == root->data)// 루트의 키==탐색키 
		return TRUE;
	else if (root->type == TWO_NODE) {  // 2-노드
		if (key < root->key)
			return tree23_search(root->left, key)
		else
			return tree23_search(root->right, key)
	}
	else {				// 3-노드
		if (key < root->key1)
			return tree23_search(root->left, key)
		else if (key > root->key2)
			return tree23_search(root->right, key)
		else
			return tree23_search(root->middle, key)
	}
}

 

  (1) 2-3 트리의 삽입 연산 리스트 ADT의 구현

   - 2-3 트리의 노드는 2개의 데이터 값을 저장할 수 있다. 2-3 트리에 데이터 추가 시에 노드에 추가할 수 있을 때까지 데이터는 추가되고 더 이상 저장할 장소가 없는 경우에는 노드를 분리하게 된다.

   - 중간 값을 한 레벨 위로 올리고 제일 작은 값을 왼쪽 노드로, 제일 큰 값을 오른쪽 노드로 만드는 것이다.

   - 3가지의 경우로 나누어진다. 단말 노드를 분리 / 비단말 노드를 분리 / 루트 노드를 분리

   - 2-3 트리에서 트리의 높이가 증가하게 되는 경우는 루트 노드를 분리하는 경우뿐이다.

 

 3) 2-3-4 트리

  - 2-3-4 트리는 하나의 노드가 4개의 자식까지 가질 수 있도록 2-3 트리를 확장한 것이다.

  - 4-노드는 4개의 자식을 가질 수 있고, 3개의 데이터(small, middle, large)를 가질 수 있다.

  - 2-3-4 트리에서 키를 삽입해야 할 단말 노드가 만약 2-노드 또는 3-노드이면 간단하게 삽입만 하면 된다. 문제는 삽입해야 할 단말 노드가 4-노드이면 후진 분할이 일어나게 된다.

  - 이를 방지하기 위하여 삽입 노드를 찾는 순회(루트 단말)시에 4-노드를 만나면 미리 분할을 수행한다.

  - 3가지 경우를 고려해서 알고리즘을 만들어야 한다.

  - 4-노드가 루트인 경우 / 4-노드의 부모가 2-노드인 경우

  - 4-노드의 부모가 3-노드인 경우

 

 

[Reference]

  • 천인국, 공용해, 하상호, 「C언어로 쉽게 풀어쓴 자료구조」, 생능출판, 2009년
반응형

'DataStructure' 카테고리의 다른 글

Hashing(해싱)  (0) 2022.03.14
Graph(그래프) - 2  (0) 2022.03.11
Graph(그래프) - 1  (0) 2022.03.11
Sorting(정렬)  (0) 2022.03.11
Priority Queue(우선순위 큐)와 Heap(힙)  (0) 2022.03.11
728x90
반응형

1. 해싱이란

 1) 해싱은 키 값에 직접 산술적인 연산을 적용하여 항목이 저장되어 있는 테이블의 주소를 계산하여 항목에 접근

 2) 키 값의 연산에 의해 직접 접근이 가능한 구조를 해시 테이블(hash table)이라 부르며, 해시 테이블을 이용한 탐색을 해싱(hashing)이라 한다.

 3) 해싱은 보통 사전(dictionary)과 같은 자료 구조를 구현할 때에 최상의 선택이 된다.

 

2. 추상 자료형 사전 구조

 1) 사전 구조는 맵(map)이나 테이블(table)로 불리기도 한다. 사전 구조는 탐색 키(key)와 값(value) 2가지 종류의 필드를 가진다.

 2) 리스트는 근본적으로 위치에 의하여 관리되는 자료구조이다. 반면 사전 구조는 오직 탐색 키에 의해서만 관리된다.

 3) 사전 구조에서는 유일한 탐색 키를 사용하기도 하고 동일한 탐색 키를 허용하기도 한다.

 4) 사전 구조에서는 탐색 키에 따라 정렬되어 있기도 하고 그렇지 않기도 하다.

 

3. 해싱의 구조

 1) 현실적으로 탐색 키들이 문자열이거나 매우 큰 숫자이기 때문에 탐색 키를 작은 정수로 사상(mapping) 시키는 어떤 함수가 필요하다.

 2) 해싱에서는 자료를 저장하는데 배열을 사용한다.

 3) 해시 함수(hash function)란 탐색 키를 입력으로 받아 해시 주소(hash address)를 생성하고 이 해시 주소가 배열로 구현된 해시 테이블(hash table)의 인덱스가 된다.

 4) 하나의 버켓에 여러 개의 슬롯을 두는 이유는 서로 다른 두 개의 키가 해시 함수에 의해 동일한 해시 주소로 변환될 수 있으므로 여러 개의 항목을 동일한 버켓에 저장하기 위함이다.

 5) 대부분의 경우 해시 테이블의 버켓 수는 키가 가질 수 있는 모든 경우의 수보다 매우 작으므로 충돌(collision)이 발생한다. 이러한 키들을 동의어(synonym)라 한다.

 6) 실제의 해싱

  - 보통의 경우는 탐색 키는 매우 많고 해시 테이블의 크기는 상당한 제약을 받는 것이 일반적인 상황이다.

  - 간단하면서도 강력한 방법은 탐색 키를 해시 테이블의 크기로 나누어서 그 나머지를 해시 테이블의 주소로 하는 것이다.(제산 함수)

  - h(k) = k mod M

  - 해싱에서는 충돌을 해결하는 일이 무엇보다도 중요하다.

 

4. 해시 함수

 1) 좋은 해시 함수의 조건

  - 충돌이 적어야 한다.

  - 해시 함수 값이 해시 테이블의 주소 영역 내에서 고르게 분포되어야 한다.

  - 계산이 빨라야 한다.

 

 2) 제산함수

  - h(k) = k mod M

  - 해시 테이블의 크기 M은 주로 소수(prime number)로 나누어지지 않는 수로 선택한다. 소수는 골고루 사용하는 값을 만들어낸다.

 

 3) 폴딩함수

  - 이동 폴딩(shift folding) : 탐색 키를 여러 부분으로 나눈 값들을 더하여 해시 주소로 사용

  - 경계 폴딩(boundary folding) : 탐색 키의 이웃한 부분을 거꾸로 더하여 해시 주소를 얻는다.

  - 폴딩 함수는 주로 탐색 키가 해시 테이블의 크기보다 더 큰 정수일 경우에 사용된다.

  - 비트별로 XOR 같은 불(bool) 연산을 하는 경우도 있다.

 

 4) 중간 제곱 함수

  - 탐색 키를 제곱한 다음, 중간의 몇 비트를 취해서 해시 주소를 생성한다.

 

 5) 비트 추출 방법

  - 해시 테이블의 크기가 M=2k일 때 탐색 키를 이진수로 간주하여 임의의 위치의 k개의 비트를 해시 주소로 사용하는 것

  - 해시 주소의 집중 현상이 일어날 가능성이 높음

 

 6) 숫자 분석 방법

  - 각각의 위치에 있는 수의 특징을 미리 알고 있을 때 유용

  - ex) 학번이 200812345라면 뒤의 앞의 연도 숫자는 빼고 뒤만 사용

 

 7) 탐색 키가 문자열일 경우 주의할 점

  - 보편적인 방법은 각 문자의 아스키 코드 값을 모두 더하는 것이다. 하지만 아스키 문자 코드의 범위가 65~122이기 때문에 3자리로 이루어진 탐색 키의 경우, 195~366에 해시주소가 집중될 것이다.

  - 위 문제를 해결하기 위해 아스키 코드 값에 위치에 기초한 값을 곱하는 것이다.

  - u0gn-1 + u1gn-2 + … + un-2g + un-1

  - 위 식의 계산량을 줄이기 위해 호너의 방법(Horner’s method)를 사용할 수 있다.

 

5. 충돌 해결책

 - 서로 다른 탐색 키가 같은 해시 주소를 갖는 충돌 현상을 해결하기 위해 두 가지를 생각할 수 있다.

 - 선형 조사법 : 충돌이 일어난 항목을 해시 테이블의 다른 위치에 저장한다.

 - 체이닝 : 하나의 위치가 여러 개의 항목을 저장할 수 있도록 해시 테이블의 구조를 변경한다.

 

 1) 선형 조사법(linear probing)

  - 비어 있는 버켓을 찾는 방법으로 이를 조사(probing)라 한다.

  - h(k), h(k)+1, h(k)+2, h(k)+3, ...

  - 간단하다는 장점이 있으나, overflow가 자주 발생하면 집중과 결합에 의해 탐색의 효율이 크게 저하될 수 있다.

 

#include "stdafx.h"
#include <stdlib.h>
#include <string.h>

#pragma warning(disable:4996)
#define KEY_SIZE	   10	// 탐색키의 최대길이  
#define TABLE_SIZE  13	// 해싱 테이블의 크기=소수 

typedef struct
{
	char key[KEY_SIZE];
	// 다른 필요한 필드들을 여기에 넣는다. 
} element;
element hash_table[TABLE_SIZE];	// 해싱 테이블 

void init_table(element ht[])
{
	int i;
	for (i = 0; i<TABLE_SIZE; i++) {
		ht[i].key[0] = NULL;
	}
}

// 문자로 된 탐색키를 숫자로 변환
int transform(char *key)
{
	int number = 0;
	while (*key)	      // 간단한 덧셈 방식 사용 자연수 생성
		number += *key++;
	return number;
}
// 제산 함수를 사용한 해싱 함수
int hash_function(char *key)
{
	return transform(key) % TABLE_SIZE;
}

#define empty(e) (strlen(e.key)==0)
#define equal(e1, e2) (!strcmp(e1.key,e2.key))

// 선형 조사법을 이용하여 테이블에 키를 삽입하고, 
// 테이블이 가득 찬 경우는 종료     
void hash_lp_add(element item, element ht[])
{
	int i, hash_value;
	hash_value = i = hash_function(item.key);
	while (!empty(ht[i])) {
		if (equal(item, ht[i])) {
			fprintf(stderr, "탐색키가 중복되었습니다\n");
			return;
		}
		i = (i + 1) % TABLE_SIZE;
		if (i == hash_value) {
			fprintf(stderr, "테이블이 가득찼습니다\n");
			return;
		}
	}
	ht[i] = item;
}

// 선형조사법을 이용하여 테이블에 저장된 키를 탐색
void hash_lp_search(element item, element ht[])
{
	int i, hash_value;
	hash_value = i = hash_function(item.key);
	while (!empty(ht[i])) {
		if (equal(item, ht[i])) {
			fprintf(stderr, "탐색성공: 위치 = %d\n", i);
			return;
		}
		i = (i + 1) % TABLE_SIZE;
		if (i == hash_value) {
			fprintf(stderr, "찾는 값이 테이블에 없음\n");
			return;
		}
	}
	fprintf(stderr, "찾는 값이 테이블에 없음\n");
}

// 해싱 테이블의 내용을 출력
void hash_lp_print(element ht[])
{
	int i;
	for (i = 0; i<TABLE_SIZE; i++)
		printf("[%d]	%s\n", i, ht[i].key);
}
// 해싱 테이블을 사용한 예제 
int main()
{
	element tmp;
	int op;
	while (1) {
		printf("원하는 연산을 입력하시오(0=입력, 1=탐색, 2=종료)=");
		scanf("%d", &op);
		if (op == 2) break;
		printf("키를 입력하시오=");
		scanf("%s", tmp.key);
		if (op == 0)
			hash_lp_add(tmp, hash_table);
		else if (op == 1)
			hash_lp_search(tmp, hash_table);
		hash_lp_print(hash_table);
	}
}

 

 (1) 이차 조사법(quadratic probing)

  - ( h(k) + inc*inc ) mod M   for inc = 0, 1, …, M-1

  - 모든 위치를 조사하게 만들려면 여전히 테이블 크기는 소수여야 한다.

 

 (2) 이중 해싱법(double hashing) 또는 재해싱(rehashing)

  - 더욱 균일하게 분포시킬 수 있다.

  - 선형 조사법에서는 더해지는 값이 1, 이차 조사법에서는 i2이 되므로 해시 함수 값이 같으면 차후에 조사되는 위치도 같게 된다.

  - 이중 해싱법에서는 탐색 키를 참조하여 더해지는 값이 결정된다.

  - step = C - (k mod C)  (C는 테이블의 크기인 M보다 약간 작은 소수)

  - h(k), h(k)+step, h(k)+2*step, h(k)+3*step, …

  - 이 경우도 마찬가지로 테이블의 모든 위치가 조사되기 위해서는 테이블의 크기가 소수일 경우만 가능하다.

  - 따라서 반드시 해시 테이블의 크기는 소수가 되어야 한다.

선형 조사법의 문제점은 한 번도 사용되지 않은 위치가 있어야만 탐색이 빨리 끝나게 된다.

 

 2) 체이닝

  - 해시 테이블의 구조를 변경하여 각 버켓이 하나 이상의 값을 저장할 수 있도록 하는 것

  - 크기를 예측할 수 없으므로 연결리스트로 구현

#define equal(e1,e2) (!strcmp(e1.key,e2.key))

// 체인법을 이용하여 테이블에 키를 삽입
void hash_chain_add(element item, ListNode *ht[])
{
  
   int hash_value = hash_function(item.key);
   ListNode *ptr; 			// 새로운 노드
   ListNode *node_before=NULL;	// 이전노드
   ListNode *node = ht[hash_value]; //현재노드
   for(; node; node_before=node, node=node->link){  
	  	if(equal(node->item, item)){  
	     	fprintf(stderr,"이미 탐색키가 저장되어 있음\n");
     	return;
    	}
   }
   ptr = (ListNode *)malloc(sizeof(ListNode));
   ptr->item = item;
   ptr->link = NULL;
   if(node_before)		//기존의 연결리스트가 있으면
      node_before->link = ptr;
   else				//기존의 연결리스트가 없으면
      ht[hash_value] = ptr; 
}

// 체인법을 이용하여 테이블에 저장된 키를 탐색
void hash_chain_find(element item, ListNode *ht[])
{
   ListNode *node;

   int hash_value = hash_function(item.key);
   for(node=ht[hash_value]; node; node=node->link){  
	  		if(equal(node->item, item)){  
				printf("키를 찾았음\n");
				return;
      	}
   }
   printf("키를 찾지못했음\n");
}

나머지 소스는 선형 조사법 소스 참조

 

6. 해싱의 성능 분석

 - 해시 테이블의 적재 밀도(loading density) 또는 적재 비율(loading factor) : α = 저장된 항목의 개수 / 해싱테이블의 버켓의 개수 = n / M

 - 선형 조사법에서는 α 0.5를 넘어갈수록 실패한 탐색은 급격하게 탐색 시간이 증가한다.

 - 제산 해시 함수와 함께 체이닝을 사용하는 방법이 가장 효율적이다. (Lum, Yuen, Dodd의 연구결과)

 - 해싱은 사전, 맞춤법 검사기, 방문했던 웹페이지 기록 저장, 투표, 컴파일러의 심볼 테이블 등을 구현하는 데 유용하게 사용할 수 있다.

 

[Reference]

  • 천인국, 공용해, 하상호, 「C언어로 쉽게 풀어쓴 자료구조」, 생능출판, 2009년
반응형

'DataStructure' 카테고리의 다른 글

Search(탐색)  (0) 2022.03.14
Graph(그래프) - 2  (0) 2022.03.11
Graph(그래프) - 1  (0) 2022.03.11
Sorting(정렬)  (0) 2022.03.11
Priority Queue(우선순위 큐)와 Heap(힙)  (0) 2022.03.11
728x90
반응형

4. 그래프의 탐색

 - 그래프의 탐색은 그래프의 가장 기본적인 연산으로서 하나의 정점으로부터 시작하여 차례대로 모든 정점들을 한 번씩 방문하는 것이다.

 - 도시를 나타내는 그래프가 있을 때, 특정 도시에서 다른 도시로 갈 수 있는지 없는지는 그래프를 특정 노드에서 시작하여 탐색하여보면 알 수 있다.

 - 탐색 방법에는 깊이 우선 탐색과 너비 우선 탐색의 두 가지 탐색 방법이 있다.

 

 1) 깊이 우선 탐색(depth first search : DFS)

  - 미로 탐색 시 한 방향으로 갈 수 있을 때까지 계속 가다가 더 이상 갈 수 없게 되면 다시 가장 가까운 갈림길로 돌아와서 이곳으로부터 다른 방향으로 다시 탐색을 진행하는 방법과 유사하다.

  - 깊이 우선 탐색도 자기 자신을 다시 호출하는 순환 알고리즘의 형태를 가지고 있음을 알 수 있다.

  - 깊이 우선 탐색을 구현하는 데는 2가지의 방법이 있는데 순환 호출을 이용하는 것이 첫 번째, 명시적인 스택을 사용하여 방문한 정점들을 스택에 저장하였다가 다시 꺼내어 작업하는 것이다.

  - 인접 행렬로 표현된 그래프에 대한 깊이 우선 탐색 함수

int visited[MAX_VERTICES];

// 인접 행렬로 표현된 그래프에 대한 깊이 우선 탐색
void dfs_mat(GraphType *g, int v)
{
   int w;
   visited[v] = TRUE;		// 정점 v의 방문 표시 
   printf("%d ", v);		// 방문한 정점 출력
   for(w=0; w<g->n; w++) 		// 인접 정점 탐색
 	 	if( g->adj_mat[v][w] && !visited[w] )
	   		dfs_mat(g, w);	//정점 w에서 DFS 새로시작
}

 

  - 인접 리스트로 표현된 그래프에 대한 깊이 우선 탐색 함수

int visited[MAX_VERTICES];

// 인접 리스트로 표현된 그래프에 대한 깊이 우선 탐색
void dfs_list(GraphType *g, int v)
{
	GraphNode *w;
	visited[v] = TRUE;   		// 정점 v의 방문 표시 
	printf("%d ", v);   		// 방문한 정점 출력 
	for (w = g->adj_list[v]; w; w = w->link)// 인접 정점 탐색 
		if (!visited[w->vertex])
			dfs_list(g, w->vertex); //정점 w에서 DFS 새로시작
}

  - 탐색 시간은 리스트의 경우 O(n+e)이고 행렬의 경우 O(n2)이다. 따라서 희소 그래프의 경우 인접 리스트의 사용이 유리하다.

 

 2) 너비 우선 탐색(breadth first search : BFS)

  - 시작 정점으로부터 가까운 정점을 먼저 방문하고 멀리 떨어져 있는 정점을 나중에 방문하는 순회 방법이다.

  - 방문한 정점들을 차례로 저장한 후 꺼낼 수 있는 자료구조인 큐(queue)가 필요하다.

  - 초기 상태의 큐에는 시작 정점만이 저장되고, 너비 우선 탐색 과정은 큐가 소진될 때까지 계속한다.

  - 특징은 시작 정점으로부터 거리가 가까운 정점이 순서로 탐색이 진행된다는 것이다.

  - 인접 행렬로 표현된 그래프에 대한 너비 우선 탐색 함수

void bfs_mat(GraphType *g, int v)
{
	int w;
	QueueType q;
	init(&q);     // 큐 초기화 
	visited[v] = TRUE;          // 정점 v 방문 표시 
	printf("%d ", v);
	enqueue(&q, v);			// 시작 정점을 큐에 저장 
	while (!is_empty(&q)) {
		v = dequeue(&q);		// 큐에 정점 추출 
		for (w = 0; w<g->n; w++)	// 인접 정점 탐색 
			if (g->adj_mat[v][w] && !visited[w]) {
				visited[w] = TRUE;    // 방문 표시
				printf("%d ", w);
				enqueue(&q, w); 	// 방문한 정점을 큐에 저장
			}
	}
}

 

  - 인접 리스트로 표현된 그래프에 대한 너비 우선 탐색 함수

void bfs_list(GraphType *g, int v)
{
	GraphNode *w;
	QueueType q;
	init(&q);    				// 큐 초기 화 
	visited[v] = TRUE;      // 정점 v 방문 표시 
	printf("%d ", v);          // 방문한 정점 출력 
	enqueue(&q, v);			// 시작정점을 큐에 저장 
	while (!is_empty(&q)) {
		v = dequeue(&q);		// 큐에 저장된 정점 선택 
		for (w = g->adj_list[v]; w; w = w->link) //인접 정점 탐색
			if (!visited[w->vertex]) {	// 미방문 정점 탐색 
				visited[w->vertex] = TRUE;   // 방문 표시
				printf("%d ", w->vertex);
				enqueue(&q, w->vertex);	//정점을 큐에 삽입
			}
	}
}

  - 탐색의 수행시간은 리스트의 경우 O(n+e), 행렬의 경우 O(n2)

 

5. 연결 성분(connected component)

 - 연결 성분이란 최대로 연결된 부분 그래프를 말한다.

void find_connected_component(GraphType *g)
{
	int i;

	count = 0;
	for (i = 0; i<g->n; i++)
		if (!visited[i]) {    // 방문되지 않았으면 
			count++;
			dfs_mat(g, i);
		}
}

 

6. 신장 트리(spanning tree)

 - 신장 트리란 그래프 내의 모든 정점을 포함하는 트리다.

 - 모든 정점들이 연결되어 있어야 하고 사이클을 포함해서는 안된다.

 - 신장 트리는 깊이 우선 탐색(DFS)이나 너비 우선 탐색(DFS) 도중에 사용된 간선만 모으면 만들 수 있다.

 - 신장 트리는 그래프의 최소 연결 부분 그래프가 된다. 여기서 최소의 의미는 간선의 수가 가장 적다는 의미이다.

 - 신장 트리는 통신 네트워크 구축에 사용된다.

 

7. 최소 비용 신장 트리(MST : minimum spanning tree)

 - 모든 정점들을 가장 적은 수의 간선과 비용으로 연결하는 것이 최소 비용 신장 트리이다.

 - 최소 비용 신장 트리는 신장 트리 중에서 사용된 간선들의 가중치 합이 최소인 신장 트리를 말한다.

 - 이를 구하는 방법으로는 Kruskal Prim이 제안한 알고리즘이 대표적이다.

 

 1) Kruskal MST 알고리즘

  - Kruskal의 알고리즘은 탐욕적인 방법(greedy method)을 이용한다.

  - 탐욕적인 방법이 그 당시에는 최적이지만, 전체적인 관점에서 최적이라는 보장은 없다.

  - 따라서 탐욕적인 방법이 최적의 해답을 주는지를 반드시 검증해야 한다. 다행히 Kruskal의 알고리즘은 최적의 해답을 주는 것으로 증명되어 있다.

  - Kruskal의 알고리즘은 먼저 그래프의 e개의 간선들을 가중치의 오름차순으로 정렬한다. 이 순서대로 사이클을 형성하지 않는 간선을 선택하여 현재의 MST 집합에 추가한다.

  - 정점의 개수를 n이라고 했을 때 선택된 간선의 개수가 n-1개가 되면 알고리즘이 종료된다.

 

  - Kruskal의 최소 비용 신장 트리 알고리즘

// 입력 : 가중치 그래프 G=(V, E), n은 노드의 개수
// 출력 :ET, 최소 비용 신장 트리를 이루는 간선들의 집합
Kruskal (G)
	E를 w(e1) ≤ … ≤ w(ee) 가 최도록 정렬 (오름차순 정렬)
	ET ← Φ; ecounter ← 0
	k ← 0
	while ecounter < (n - 1) do
		k ← k + 1
		if ET ∪ {ek} 가 사이클을 포함하지 않으면
			 then ET ← ET ∪ {ek}; ecounter ← ecounter + 1
	return ET

- 간선들을 집합에 추가할 때 사이클을 생성하는지를 체크하여야 한다. 그래서 간선의 양끝 정점이 같은 집합에 속해 있는지를 먼저 검사하여야 한다.

- 이 검사를 위한 알고리즘을 union - find 알고리즘이라 부른다.

 

(1) union - find 연산

 - Kruskal의 알고리즘뿐만 아니라 일반적으로 널리 사용된다.

 - {1, 4}, {5, 2}, {3}, {6} 에서 union(4, 5) union(3, 6)을 한다면 {1, 4, 5, 2}, {3, 6}이 된다.

 - 가장 효율적인 방법은 트리 형태를 사용하는 것이다.

 - 여기서 트리는 포인터로 구현되는 것이 아니고 배열의 인덱스를 포인터처럼 생각하여 구현된다.

int parent[MAX_VERTICES];	// 부모 노드(해당 vertex의 부모 vertex 값을 갖게 됨)
int num[MAX_VERTICES];		// 각 집합의 크기
// 초기화
void set_init(int n)
{
	int i;
	for(i=0;i<n;i++){
		parent[i] = -1;
		num[i] = 1;
	}
}
// vertex가 속하는 집합을 반환한다.
int set_find(int vertex)
{
	int p, s, i=-1;
	for(i=vertex;(p=parent[i])>=0;i=p)// 루트 노드까지 반복
		;
	s = i;			// 집합의 대표 원소
	for(i=vertex;(p=parent[i])>=0;i=p)
		parent[i] = s;	// 집합의 모든 원소들의 부모를 p로 설정
	return s;
}
// 두개의 원소가 속한 집합을 합친다.
void set_union(int s1, int s2)
{
	if( num[s1] < num[s2] ){
		parent[s1] = s2;
		num[s2] += num[s1];
	}
	else {
		parent[s2] = s1;
		num[s1] += num[s2];
	}
}

 

  - Kruskal의 최소 비용 신장 트리 프로그램 (최소 heap 사용)

#define MAX_VERTICES 100
#define INF 1000

int parent[MAX_VERTICES];	// 부모 노드
int num[MAX_VERTICES];		// 각 집합의 크기
							// 초기화
void set_init(int n)
{
	int i;
	for (i = 0; i<n; i++) {
		parent[i] = -1;
		num[i] = 1;
	}
}
// vertex가 속하는 집합을 반환한다.
int set_find(int vertex)
{
	int p, s, i = -1;
	for (i = vertex; (p = parent[i]) >= 0; i = p)// 루트 노드까지 반복
		;
	s = i;			// 집합의 대표 원소
	for (i = vertex; (p = parent[i]) >= 0; i = p)
		parent[i] = s;	// 집합의 모든 원소들의 부모를 p로 설정
	return s;
}
// 두개의 원소가 속한 집합을 합친다.
void set_union(int s1, int s2)
{
	if (num[s1] < num[s2]) {
		parent[s1] = s2;
		num[s2] += num[s1];
	}
	else {
		parent[s2] = s1;
		num[s1] += num[s2];
	}
}

typedef struct {
	int key;	// 간선의 가중치
	int u;		// 정점 1
	int v;		// 정점 2
} element;

#define MAX_ELEMENT 100
typedef struct {
	element heap[MAX_ELEMENT];
	int heap_size;
} HeapType;

// 초기화 함수
void init(HeapType *h)
{
	h->heap_size = 0;
}
// 히프 내용 출력 함수
void print_heap(HeapType *h)
{
	int i;
	int level = 1;
	printf("\n===================");
	for (i = 1; i <= h->heap_size; i++) {
		if (i == level) {
			printf("\n");
			level *= 2;
		}
		printf("\t%d", h->heap[i].key);
	}
	printf("\n===================\n");
}
// 삽입 함수
void insert_min_heap(HeapType *h, element item)
{
	int i;
	i = ++(h->heap_size);

	//  트리를 거슬러 올라가면서 부모 노드와 비교하는 과정
	while ((i != 1) && (item.key < h->heap[i / 2].key)) {
		h->heap[i] = h->heap[i / 2];
		i /= 2;
	}
	h->heap[i] = item;     // 새로운 노드를 삽입
}
// 삭제 함수
element delete_min_heap(HeapType *h)
{
	int parent, child;
	element item, temp;

	item = h->heap[1];
	temp = h->heap[(h->heap_size)--];
	parent = 1;
	child = 2;
	while (child <= h->heap_size) {
		// 현재 노드의 자식노드중 더 작은 자식노드를 찾는다.
		if ((child < h->heap_size) &&
			(h->heap[child].key) > h->heap[child + 1].key)
			child++;
		if (temp.key <= h->heap[child].key) break;
		// 한단계 아래로 이동
		h->heap[parent] = h->heap[child];
		parent = child;
		child *= 2;
	}
	h->heap[parent] = temp;
	return item;
}
void insert_heap_edge(HeapType *h, int u, int v, int weight)
{
	element e;
	e.u = u;
	e.v = v;
	e.key = weight;
	insert_min_heap(h, e);
}
// 인접 행렬이나 인접 리스트에서 간선들을 읽어서 최소 히프에 삽입 
// 현재는 예제 그래프의 간선들을 삽입한다.
void insert_all_edges(HeapType *h)
{
	insert_heap_edge(h, 0, 1, 29);
	insert_heap_edge(h, 1, 2, 16);
	insert_heap_edge(h, 2, 3, 12);
	insert_heap_edge(h, 3, 4, 22);
	insert_heap_edge(h, 4, 5, 27);
	insert_heap_edge(h, 5, 0, 10);
	insert_heap_edge(h, 6, 1, 15);
	insert_heap_edge(h, 6, 3, 18);
	insert_heap_edge(h, 6, 4, 25);
}
// kruskal의 최소 비용 신장 트리 프로그램
void kruskal(int n)
{
	int edge_accepted = 0;	// 현재까지 선택된 간선의 수	
	HeapType h;				// 최소 히프
	int uset, vset;			// 정점 u와 정점 v의 집합 번호
	element e;				// 히프 요소

	init(&h);					// 히프 초기화
	insert_all_edges(&h);		// 히프에 간선들을 삽입
	set_init(n);				// 집합 초기화
	while (edge_accepted < (n - 1))	// 간선의 수 < (n-1)
	{
		e = delete_min_heap(&h);	//	최소 히프에서 삭제
		uset = set_find(e.u);		// 정점 u의 집합 번호 
		vset = set_find(e.v);		// 정점 v의 집합 번호
		if (uset != vset) {			// 서로 속한 집합이 다르면
			printf("(%d,%d) %d \n", e.u, e.v, e.key);
			edge_accepted++;
			set_union(uset, vset);	// 두개의 집합을 합친다.
		}
	}
}
//
int main()
{
	kruskal(7);

	return 0;
}

  - 간선 e개를 퀵 정렬과 같은 효율적인 알고리즘으로 정렬한다면 시간 복잡도는 O(elog2e)이 된다.

 

 2) Prim MST 알고리즘

  - Prim의 알고리즘은 시작 정점에서부터 출발하여 신장 트리 집합을 단계적으로 확장해나가는 방법이다.

  - Kruskal의 알고리즘은 간선 선택을 기반으로 하는 알고리즘인 반면, Prim의 알고리즘은 정점 선택을 기반으로 하는 알고리즘이다.

  - Prim의 최소 비용 신장 트리 알고리즘 #1

// 입력 : 네트워크 G=(V, E), S는 시작 정점
// 출력 : VT, 최소 비용 신장 트리를 이루는 정점들의 집합
Prim(G, s)
	VT ← {s}; vcounter ← 1
	while vcounter < n do
		{u, v}는 u ∈ VT and v ∉ VT인 최저 비용 간선;
		if ( 그러한 (u, v)가 존재하면 )
			then VT ← VT ∪ v; vcounter ← vcounter + 1
		else 실패
	return VT

 

 

[Reference]

  • 천인국, 공용해, 하상호, 「C언어로 쉽게 풀어쓴 자료구조」, 생능출판, 2009년
반응형

'DataStructure' 카테고리의 다른 글

Search(탐색)  (0) 2022.03.14
Hashing(해싱)  (0) 2022.03.14
Graph(그래프) - 1  (0) 2022.03.11
Sorting(정렬)  (0) 2022.03.11
Priority Queue(우선순위 큐)와 Heap(힙)  (0) 2022.03.11
728x90
반응형

1. 그래프란

 - 그래프(graph)는 연결되어 있는 객체 간의 관계를 표현할 수 있는 자료 구조다.

 - 트리도 그래프의 하나의 특수한 종류로 볼 수 있다.

 - 그래프는 수학자 오일러(Euler)에 의해 처음 창안되었다. (모든 다리를 한 번만 건너서 출발했던 장소로 돌아올 수 있는가?)

 - 위치는 정점(vertex), 위치간의 관계인 다리는 간선(edge)로 표현

 - 그래프에 존재하는 모든 간선을 한 번만 통과하면서 처음 정점으로 되돌아오는 경로를 오일러 경로(Eulerian tour)라 정의하고 모든 정점에 연결된 간선의 개수가 짝수일 때만 오일러 경로가 존재한다는 오일러 정의를 증명하였다.

 

 1) 그래프의 용어

  - 그래프는 정점(vertex)과 간선(edge)들의 집합으로 구성된다.

  - 정점은 노드(node)라고도 불리며, 간선은 링크(link)라고도 불린다.

  - 그래프는 오직 정점과 간선의 집합일 뿐이며 그래프의 시각적 표현은 이해를 돕는 역할만을 함에 주의해야 한다.

  - 간선의 종류에 따라 무방향 그래프(undirected graph)와 방향 그래프(directed graph)로 구분된다.

  - 간선에 비용이나 가중치가 할당된 그래프를 가중치 그래프(weighted graph) 또는 네트워크(network)라 한다.

  - 네트워크는 도로의 길이, 회로 소자의 용량, 통신망의 사용료 등을 표현할 수 있다.

  - 인접 정점(adjacent vertex)이란 간선에 의해 직접 연결된 정점

  - 차수(degree)는 하나의 정점에 인접한 정점의 수

  - 방향 그래프에서는 외부에서 오는 간선의 수를 진입 차수(in-degree), 외부로 향하는 간선의 수를 진출 차수(out-degree)

  - 경로 길이(path length)란 경로를 구성하는 데 사용된 간선의 수, 경로 중에서 반복되는 간선이 없을 경우에 단순 경로(simple path), 시작 정점과 종료 정점이 동일하다면 사이클(cycle)

  - 무방향 그래프 G에 있는 모든 정점쌍에 대하여 항상 경로가 존재한다면 G는 연결되었다고 하며, 이러한 무방향 그래프를 연결 그래프(connected graph)라 부른다.

  - 트리는 그래프의 특수한 형태로서 사이클을 가지지 않는 연결 그래프이다.

  - 그래프에 속해 있는 모든 정점이 서로 연결되어 있는 그래프를 완전 그래프(complete graph)라 한다.

 

2. 그래프 추상 데이터 타입

 - 객체 : 정점의 집합과 간선의 집합

 - 연산 :

  •  create_graph()
  •  init(g)
  •  insert_vertex(g, v) - 그래프 g에 정점 v를 삽입한다.
  •  insert_edge(g, u, v) - 그래프 g에 간선 (u, v)를 삽입한다.
  •  delete_vertex(g, v) - 그래프 g의 정점 v를 삭제한다.
  •  delete_edge(g, u, v) - 그래프 g의 간선 (u, v)를 삭제한다.
  •  is_empty(g) - 그래프 g가 공백 상태인지 확인한다.
  •  adjacent(v) - 정점 v에 인접한 정점들의 리스트를 반환한다.
  •  destroy_graph(g) - 그래프 g를 제거한다.

 

3. 그래프의 표현 방법

 - 2차원 배열을 사용하는 인접 행렬(adjacency matrix)과 연결 리스트를 사용하는 인접 리스트(adjacency list)의 두 가지 방법이 있다.

 

 1) 인접 행렬

  - 무방향 그래프의 인접 행렬은 대칭 행렬이 된다.

  - 배열의 상위 삼각이나 하위 삼각만 저장하면 메모리를 절약할 수 있다.

  - 밀집 그래프(dense graph)를 표현하는 경우에는 적합하나 희소 그래프(sparse graph)의 경우에는 메모리의 낭비가 크므로 적합하지 않다

  - 모든 간선의 수를 알아내려면 n2번의 조사가 필요하게 되어 O(n2)의 시간이 요구된다.

#define MAX_VERTICES 50
typedef struct GraphType {
	int n;
	int adj_mat[MAX_VERTICES][MAX_VERTICES];
} GraphType;

// 그래프 초기화 
void graph_init(GraphType *g)
{
	int r,c;
	g->n=0;
	for(r=0;r<MAX_VERTICES;r++)
		for(c=0;c<MAX_VERTICES;c++)
			g->adj_mat[r][c]=0;
}
// 정점 삽입 연산
void insert_vertex(GraphType *g, int v)
{
	if( ((g->n)+1) > MAX_VERTICES ){ 
		fprintf(stderr,"그래프: 정점의 개수 초과");
		return;
	}
	g->n++;
}
// 간선 삽입 연산
void insert_edge(GraphType *g, int start, int end)
{
	if( start >= g->n || end >= g->n ){
		fprintf(stderr,"그래프: 정점 번호 오류");
		return;
	}
	g->adj_mat[start][end] = 1;
	g->adj_mat[end][start] = 1;
}

 

 2) 인접 리스트

  - 헤드 포인터들은 하나의 배열로 구성되어 있다.

  - 정점의 수가 n개이고 간선의 수가 e개인 무방향 그래프를 표시하기 위해서는 n개의 헤드 포인터와 2e개의 노드가 필요하다. 따라서 인접 리스트 표현은 희소 그래프의 표현에 적합하다.

#define MAX_VERTICES 50

typedef struct GraphNode {  
   int vertex;
   struct GraphNode *link;
} GraphNode;

typedef struct GraphType {
	int n;	// 정점의 개수
	GraphNode *adj_list[MAX_VERTICES];
} GraphType;

// 그래프 초기화 
void graph_init(GraphType *g)
{
	int v;
	g->n=0;
	for(v=0;v<MAX_VERTICES;v++)
		g->adj_list[v]=NULL;
}

// 정점 삽입 연산
void insert_vertex(GraphType *g, int v)
{
	if( ((g->n)+1) > MAX_VERTICES ){ 
		fprintf(stderr, "그래프: 정점의 개수 초과");
		return;
	}
	g->n++;
}

// 간선 삽입 연산, v를 u의 인접 리스트에 삽입한다.
// 매번 리스트의 젤 앞에 삽입
void insert_edge(GraphType *g, int u, int v)
{
	GraphNode *node;
	if( u >= g->n || v >= g->n ){
		fprintf(stderr, "그래프: 정점 번호 오류");		
		return;
	}
	node = (GraphNode *)malloc(sizeof(GraphNode));
	node->vertex = v;
	node->link = g->adj_list[u];
	g->adj_list[u] = node;
}

 

 

[Reference]

  • 천인국, 공용해, 하상호, 「C언어로 쉽게 풀어쓴 자료구조」, 생능출판, 2009년
반응형

'DataStructure' 카테고리의 다른 글

Hashing(해싱)  (0) 2022.03.14
Graph(그래프) - 2  (0) 2022.03.11
Sorting(정렬)  (0) 2022.03.11
Priority Queue(우선순위 큐)와 Heap(힙)  (0) 2022.03.11
Tree(트리) - 2 [Binary Search Tree]  (0) 2022.03.04
728x90
반응형

1. 정렬이란

 - 정렬(sorting)은 물건을 크기순으로 오름차순(ascending order)이나 내림차순(descending order)으로 나열하는 것을 의미한다.

 - 어떤 형태의 것도 서로 비교만 가능하면 정렬할 수 있다.

 - 정렬은 컴퓨터공학을 포함한 모든 과학 기술 분야에서 가장 기본적이고 중요한 알고리즘

 - 정렬이란 레코드들을 키 값의 순서로 재배열하는 것이다.

 - 정렬 알고리즘을 내부 정렬(internal sorting)과 외부 정렬(external sorting)로 구분할 수도 있다.

 - 내부 정렬은 모든 데이터가 주기억장치에 저장된 상태에서 정렬하는 것, 외부 정렬은 외부기억장치(하드디스크)에 대부분의 데이터가 있고 일부만 주기억장치에 저장된 상태에서 정렬을 하는 방법

 - 안정성(stability)은 같은 키 값을 갖는 레코드들이 정렬 후에 위치가 바뀌게 되면 안정하지 않다고 한다.

 - 안정성을 충족하는 것에는 삽입 정렬, 합병 정렬 등이 있다.

 

2. 선택 정렬(selection sort)

 - 선택 정렬은 오른쪽 리스트에서 가장 작은 숫자를 선택하여 왼쪽 리스트로 이동하는 작업을 되풀이한다.

 - 다른 추가 메모리를 요구하지 않는 정렬 방법을 제자리 정렬(in-place sorting)이라고 한다.

 - 선택 정렬 함수

#define SWAP(x, y, t) ( (t)=(x), (x)=(y), (y)=(t) )
void selection_sort(int list[], int n)
{
	int i, j, least, temp;
	for (i = 0; i<n - 1; i++) {
		least = i;
		for (j = i + 1; j<n; j++) 	// 최소값 탐색
			if (list[j]<list[least]) least = j;
		SWAP(list[i], list[least], temp);
	}
}

 

 - 선택 정렬의 시간복잡도는 외부 루프는 n-1번 내부 루프는 0에서 n-2까지 변하는 i에 대하여 (n-1)-i번 반복되므로 n(n-1)/2 = O(n2)

 

3. 삽입 정렬(insertion sort)

 - 삽입 정렬은 손안의 카드를 정렬하는 방법과 유사하다.

 - 새로운 카드를 기존의 정렬된 카드 사이의 올바른 자리를 찾아 삽입

 - 삽입 정렬 함수

void insertion_sort(int list[], int n)       	
{   
   int i, j, key;
   for(i=1; i<n; i++){
   	key = list[i];
	for(j=i-1; j>=0 && list[j]>key; j--) 
	  list[j+1] = list[j]; /* 레코드의 오른쪽 이동 */
    	list[j+1] = key;
   }
}

 - 삽입 정렬의 시간복잡도는 외부 루프는 n-1번 실행되고 최악의 경우 외부 루프 안의 각 반복마다 i번의 비교가 수행되므로 n(n-1)/2 = O(n2)

 - 삽입 정렬은 대부분의 레코드가 이미 정렬되어 있는 경우에 매우 효율적일 수 있다.

 

4. 버블 정렬(bubble sort)

 - 버블 정렬은 인접한 2개의 레코드를 비교하여 크기가 순서대로 되어 있지 않으면 서로 교환하는 비교-교환 과정을 리스트의 왼쪽 끝에서 시작하여 오른쪽 끝까지 진행

 - 비교-교환 과정(스캔)이 한 번 완료되면 가장 큰 레코드가 리스트의 오른쪽 끝으로 이동된다.

 - 버블 정렬 함수

#define SWAP(x, y, t) ( (t)=(x), (x)=(y), (y)=(t) )
void bubble_sort(int list[], int n)
{
	int i, j, temp;
	for (i = n - 1; i>0; i--) {
		for (j = 0; j<i; j++)
			/* 앞뒤의 레코드를 비교한 후 교체 */
			if (list[j]>list[j + 1])
				SWAP(list[j], list[j + 1], temp);
	}
}

 - 버블 정렬의 비교 횟수는 최상, 평균, 최악의 어떠한 경우에도 항상 일정하고 다음과 같다.

 - n(n-1)/2 = O(n2)

 - 버블 정렬은 그 단순성에도 불구하고 거의 쓰이지 않고 있다.

 

5. 셸 정렬(shell sort)

 - 삽입 정렬이 어느 정도 정렬된 배열에 대해서는 대단히 빠른 것에 착안한 방법이다.

 - 정렬해야 할 리스트를 일정한 기준에 따라 분류하여 연속적이지 않은 여러 개의 부분 리스트를 만들고, 각 부분 리스트를 삽입 정렬을 이용하여 정렬한다.

 - 간격은 처음에는 n/2으로 하고 각 패스마다 간격을 절반으로 줄이는 방식을 사용한다.

 - gap은 홀수가 좋다.

 - 셸 정렬 함수

// gap 만큼 떨어진 요소들을 삽입 정렬
// 정렬의 범위는 first에서 last
inc_insertion_sort(int list[], int first, int last, int gap)
{
	int i, j, key;
	for (i = first + gap; i <= last; i = i + gap) {
		key = list[i];
		for (j = i - gap; j >= first && key<list[j]; j = j - gap)
			list[j + gap] = list[j];
		list[j + gap] = key;
	}
}
//
void shell_sort(int list[], int n)   // n = size
{
	int i, gap;
	for (gap = n / 2; gap>0; gap = gap / 2) {
		if ((gap % 2) == 0) gap++;
		for (i = 0; i<gap; i++)		// 부분 리스트의 개수는 gap
			inc_insertion_sort(list, i, n - 1, gap);
	}
}

 - 연속적이지 않은 부분 리스트에서 자료의 교환이 일어나면 더 큰 거리를 이동하므로 최종 위치에 더 가까이 있을 가능성이 높아진다.

 - 시간복잡도는 평균적인 경우 O(n1.5)로 나타난다.

 

6. 합병 정렬(merge sort)

 - 입력 데이터가 많으면서 자주 정렬해야 할 필요가 있을 때 앞의 방법들보다 훨씬 빠른 방법이다.

 - 분할 정복(divide and conquer) 방법에 바탕을 두고 있다.

 - 분할 정복 방법은 대개 순환 호출을 이용하여 구현된다.

 - 합병 정렬에서 실제로 정렬이 이루어지는 시점은 2개의 리스트를 합병(merge)하는 단계이다.

 - 합병 정렬 함수

int sorted[MAX_SIZE];   // 추가 공간이 필요

/* i는 정렬된 왼쪽리스트에 대한 인덱스
j는 정렬된 오른쪽리스트에 대한 인덱스
k는 정렬될 리스트에 대한 인덱스 */
void merge(int list[], int left, int mid, int right)
{
	int i, j, k, l;
	i = left;  j = mid + 1;  k = left;

	/* 분할 정렬된 list의 합병 */
	while (i <= mid && j <= right) {
		if (list[i] <= list[j])
			sorted[k++] = list[i++];
		else
			sorted[k++] = list[j++];
	}
	if (i > mid)	/* 남아 있는 레코드의 일괄 복사 */
		for (l = j; l <= right; l++)
			sorted[k++] = list[l];
	else	/* 남아 있는 레코드의 일괄 복사 */
		for (l = i; l <= mid; l++)
			sorted[k++] = list[l];
	/* 배열 sorted[]의 리스트를 배열 list[]로 재복사 */
	for (l = left; l <= right; l++)
		list[l] = sorted[l];
}
//
void merge_sort(int list[], int left, int right)
{
	int mid;
	if (left < right) {
		mid = (left + right) / 2;     /* 리스트의 균등 분할 */
		merge_sort(list, left, mid);    /* 부분 리스트 정렬 */
		merge_sort(list, mid + 1, right); /* 부분 리스트 정렬 */
		merge(list, left, mid, right);    /* 합병 */
	}
}

 - 합병 정렬의 시간복잡도 :

 레코드의 개수 n 2의 거듭제곱이라고 가정하고 n=2k이라고 하면 순환 호출의 깊이가 k가 될 것임을 쉽게 알 수 있다.

 부분 배열로 나누어지는 단계에서는 비교나 이동 연산이 수행되지 않고 부분 배열이 합쳐지는 merge 함수에서 비교 연산과 이동 연산이 수행되는 것이다.

 n=23인 경우 크기 1인 부분 배열 2개를 합병하는 데는 최대 2개의 비교 연산이 필요하고, 부분 배열의 쌍이 4개이므로 2*4=8번의 비교 연산이 필요하다.

 마지막 합병 단계인 크기가 4인 부분 배열 2개를 합병하는 데는 최대 8번의 비교 연산이 필요하다.

 일반적인 경우를 유추해보면 하나의 합병 단계에서는 최대 n번의 비교 연산이 필요함을 알 수 있다. 그러한 합병 단계가 k=log2n번만큼 있으므로 총 비교 연산은 최대 nlog2n번 필요하다.

 

 - 크기가 큰 레코드를 정렬할 경우에 연결 리스트를 사용한다면, 합병 정렬은 퀵 정렬을 포함한 다른 어떤 정렬 방법보다 효율적일 수 있다.

 

7. 퀵 정렬(quick sort)

 - 평균적으로 매우 빠른 수행 속도를 자랑하는 정렬 방법이다. 퀵 정렬도 분할 정복(divide and conquer) 방법에 근거한다.

 - 리스트 안에 있는 한 요소를 피벗(pivot)으로 선택한다. 일단 리스트의 첫 번째 요소를 피벗으로 하기로 한다.

 - 피벗보다 작은 요소들은 모두 피벗의 왼쪽으로 옮겨지고 피벗보다 큰 요소들은 모두 피벗의 오른쪽으로 옮겨진다.

 - 피벗을 제외한 왼쪽 리스트와 오른쪽  리스트를 다시 정렬하게 되면 전체 리스트가 정렬된다.

 - 합병 정렬과 마찬가지로 퀵 정렬 함수가 다시 부분 리스트에 대하여 순환 호출된다.

 - 퀵 정렬 알고리즘

void quick_sort(int list[], int left, int right)
{  
  if(left<right){     
     int q=partition(list, left, right);
     quick_sort(list, left, q-1);   
     quick_sort(list, q+1, right);  
   }
}

 - 퀵 정렬에서 가장 중요한 함수가 partition 함수이다.

 - partition 함수

int partition(int list[], int left, int right)
{
	int pivot, temp;
 	int low,high;
 
 	low = left;                 
	high = right+1;
 	pivot = list[left]; 	
	do {
 		do
  			low++;
		while(list[low]<pivot);
		do
  			high--;
		while(list[high]>pivot);
		if(low<high) SWAP(list[low], list[high], temp); 
	} while(low<high);	 
                
	SWAP(list[left], list[high], temp); 
	return high;
}

 - 퀵 정렬의 복잡도 분석 :

 n 2의 거듭제곱이라고 가정하고 합병 정렬과 마찬가지로 k=log2n개의 패스가 필요하게 된다.

 평균 n번 정도의 비교가 이루어지므로 O(nlog2n)의 복잡도를 가지는 알고리즘이 된다.

 최악의 경우는 리스트가 계속 불균형하게 나누어지는 경우이고 이 경우 레코드의 수만큼 총 n번의 패스가 실행되고, 각 패스에서 n번의 비교가 이루어져 O(n2)의 시간복잡도를 가지게 된다.

 

 - 정렬된 리스트에 대해서는 오히려 수행시간이 더 많이 걸리는 등의 단점도 가진다.

 - 불균형 분할을 방지하기 위해 중간 값을 피벗으로 선택하는 방법을 사용한다.

 - 일반적으로 리스트의 왼쪽, 오른쪽, 중간의 3개의 데이터 중에 중간 값을 선택하는 방법(median of three)이 많이 사용된다.

 

8. 히프 정렬(heap sort)

 - https://bottlesik.tistory.com/22

 

9. 기수 정렬(radix sort)

 - 기수 정렬은 레코드를 비교하지 않고도 정렬하는 방법이다.

 - 정렬에 기초한 방법들은 절대 O(nlog2n)이라는 이론적인 하한선을 깰 수 없는 데 반하여 기수 정렬은 이 하한선을 깰 수 있는 유일한 기법이다.

 - 기수 정렬은 O(dn)의 시간 복잡도를 가지는데 대부분 d<10 이하이다. 다만 추가적인 메모리를 필요로 하지만 다른 정렬보다 빠르기 때문에 상당히 인기 있는 정렬 기법 중의 하나이다.

 - 단점은 정렬할 수 있는 레코드의 타입이 한정된다는 것인데 기수 정렬을 사용하려면 레코드의 키들이 동일한 길이를 가지는 숫자나 문자열로 구성되어 있어야 한다.

 - 십진수에서는 각 자릿수가 0에서 9까지의 값만 있으므로, 이것에 착안하여 10개의 버켓(bucket)을 만들어서 입력 데이터를 각 자릿수의 값에 따라 버켓에 넣는다.

 - 여기서는 먼저 낮은 자릿수로 정렬한 다음 차츰 높은 자릿수로 정렬하면 된다.

 - 기수 정렬 알고리즘

#include <stdio.h>
#include <malloc.h>
#include <stdlib.h>

typedef int element;	// 요소의 타입
typedef struct QueueNode {	// 큐의 노드의 타입 
	element item;
	struct QueueNode *link;
} QueueNode;
typedef struct {		// 큐 ADT 구현
	QueueNode *front, *rear;
} QueueType;
// 오류 함수
void error(char *message)
{
	fprintf(stderr, "%s\n", message);
	exit(1);
}
// 초기화 함수
void init(QueueType *q)
{
	q->front = q->rear = 0;
}
// 공백 상태 검출 함수
int is_empty(QueueType *q)
{
	return (q->front == NULL);
}
// 포화 상태 검출 함수
int is_full(QueueType *q)
{
	return 0;
}
// 삽입 함수
void enqueue(QueueType *q, element item)
{
	QueueNode *temp = (QueueNode *)malloc(sizeof(QueueNode));
	if (temp == NULL)
		error("메모리를 할당할 수 없습니다");
	else {
		temp->item = item; 		// 데이터 저장
		temp->link = NULL; 		// 링크 필드를 NULL
		if (is_empty(q)) { 		// 큐가 공백이면
			q->front = temp;
			q->rear = temp;
		}
		else { 		// 큐가 공백이 아니면
			q->rear->link = temp;  // 순서가 중요
			q->rear = temp;
		}
	}
}
// 삭제 함수
element dequeue(QueueType *q)
{
	QueueNode *temp = q->front;
	element item;
	if (is_empty(q))			// 공백상태
		error("큐가 비어 있읍니다");
	else {
		item = temp->item; 		// 데이터를 꺼낸다.
		q->front = q->front->link; // front를 다음노드를 가리키도록 한다.
		if (q->front == NULL) 	// 공백 상태
			q->rear = NULL;
		free(temp); 			// 동적메모리 해제
		return item; 			// 데이터 반환
	}
}
// peek 함수
element peek(QueueType *q)
{
	element item;
	if (is_empty(q))
		error("큐가 비어 있읍니다");
	else {
		item = q->front->item;	// 데이터를 꺼낸다.
		return item; 			// 데이터 반환
	}
}



#define BUCKETS 10
#define DIGITS  4
void radix_sort(int list[], int n)
{
	int i, b, d, factor = 1;
	QueueType queues[BUCKETS];

	for (b = 0; b<BUCKETS; b++) init(&queues[b]);  // 큐들의 초기화

	for (d = 0; d<DIGITS; d++) {
		for (i = 0; i<n; i++)			// 데이터들을 자리수에 따라 큐에 삽입
			enqueue(&queues[(list[i] / factor) % 10], list[i]);

		for (b = i = 0; b<BUCKETS; b++)  // 버킷에서 꺼내어 list로 합친다.
			while (!is_empty(&queues[b]))
				list[i++] = dequeue(&queues[b]);
		factor *= 10;					// 그 다음 자리수로 간다.
	}
}

#define MAX_SIZE 10000
#define SWAP(x, y, t) ( (t)=(x), (x)=(y), (y)=(t) )

int list[MAX_SIZE];
int n;

//
void main()
{
	int i;
	n = MAX_SIZE;
	for (i = 0; i<n; i++)      	/* 난수 생성 및 출력 */
		list[i] = rand() % n; /*난수 발생 범위 0~n*/

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

 - 기수 정렬 분석 :

 입력 리스트가 n개의 정수를 가지고 있다면 알고리즘 내부 루프는 n번 반복될 것이다.

 만약 각 정수가 d개의 자리수를 가지고 있다고 하면 외부 루프는 d번 반복된다. 따라서 기수 정렬은 O(dn)의 시간 복잡도를 가진다.

 일반적으로 컴퓨터 안에서는 정수의 크기가 제한된다. 32bit 컴퓨터의 경우 대개 10개 정도의 자릿수만을 가지게 된다. 따라서 d n에 비하여 아주 작은 수가 되므로 O(n)이라고 하여도 무리가 없다.

 다만 문제점은 기수 정렬은 정렬에 사용되는 키 값이 숫자로 표현되어야만이 적용 가능하다는 것이다.

 

 

[Reference]

  • 천인국, 공용해, 하상호, 「C언어로 쉽게 풀어쓴 자료구조」, 생능출판, 2009년
반응형

'DataStructure' 카테고리의 다른 글

Graph(그래프) - 2  (0) 2022.03.11
Graph(그래프) - 1  (0) 2022.03.11
Priority Queue(우선순위 큐)와 Heap(힙)  (0) 2022.03.11
Tree(트리) - 2 [Binary Search Tree]  (0) 2022.03.04
Tree(트리) - 1 [Binary Tree]  (0) 2022.03.04
728x90
반응형

1. 우선순위 큐 추상 데이터 타입

 - 우선순위 큐는 사실상 가장 일반적인 큐라 할 수 있다.

 - 적절한 우선순위만 부여하면 우선순위 큐는 스택이나 큐로 동작할 것이다.

 - 가장 효율적인 구조는 힙(heap)이다.

 - 우선순위는 경우에 따라 적절하게 적용하여 사용하면 된다.

 

 1) 우선순위 큐 추상 자료형(ADT)

  - 객체 : n개의 element형의 우선순위를 가진 요소들의 모임

  - 연산 :

  • create() : 우선순위 큐를 생성한다.
  • init(q) : 우선순위 큐 q를 초기화
  • is_empty(q) : 우선순위 큐 q가 비어 있는지를 검사한다.
  • is_full(q) : 우선순위 큐 q가 가득 찼는지를 검사한다.
  • insert(q, x) : 우선순위 큐 q에 요소 x를 추가한다.
  • delete(q) : 우선순위 큐로부터 가장 우선순위가 높은 요소를 삭제하고 이 요소를 반환한다.
  • find(q) : 우선순위가 가장 높은 요소를 반환한다.

 

2. 우선순위 큐의 구현 방법

 1) 배열을 사용하는 방법

  (1) 정렬이 안 된 배열의 경우 : 삽입 O(1), 삭제 O(n)

  (2) 정렬이 되어 있는 배열의 경우 : 삽입 O(n), 삭제 O(1)

 

 2) 연결 리스트를 사용하는 방법 : 배열과 리스트의 특징이 다른 것을 제외하고 복잡도는 배열과 같다.

 

 3) 히프를 사용하는 방법 : 히프는 완전 이진 트리의 일종으로 효율은 O(log2n)

 

3. 힙

 1) 힙의 개념

  - 힙은 여러 개의 값들 중에서 가장 큰 값이나 가장 작은 값을 빠르게 찾아내도록 만들어진 자료 구조이다.

  - A B의 부모 노드라고 한다면 key(A) key(B) -> 최대 힙

  - A B의 부모 노드라고 한다면 key(A) key(B) -> 최소 힙

  - 힙을 저장하는 표준적인 자료 구조는 배열이다. (인덱스를 이용하여 연산하기 쉬우므로)

 

 2) 힙의 구현

typedef struct {
	int key;
} element;
typedef struct {
	element heap[MAX_HEAP_SIZE];
	int heap_size;
} HeapType;

 3) 삽입 연산

  - 가장 마지막 노드에 삽입하고 히프의 성질을 만족시켜주면 된다.

void insert_heap(HeapType *h, element item)
{
	int i;
	i = ++(h->heap_size);

	while (i != 1 && item.key > h->heap[i / 2].key)
	{
		h->heap[i] = h->heap[i / 2];
		i = i / 2;
	}

	h->heap[i] = item;
}

 4) 삭제 연산

  - 알고리즘

delete_max_heap(A)
1  item ← A[1];
2  A[1] ← A[heap_size];
3  heap_size ← heap_size - 1;
4  i ← 2;
5  while i ≤ heap_size do
6  	if i ≤ heap_size and A[i + 1] > A[i]
7  		then largest ← i + 1;
8  		else largest ← i;
9  	if A[parent(largest)] > A[largest]
10  		then break;
11  	A[parent(largest)] ↔ A[largest];
12  	i ← child(largest);
13  return item;

  - 설명

1 : 루트 노드 값을 반환을 위하여 item 변수로 옮긴다.

2 : 말단 노드를 루트 노드로 옮긴다.

3 : 히프의 크기를 하나 줄인다.

4 : 루트의 왼쪽 자식부터 비교를 시작한다.

5 : i가 히프 트리의 크기보다 작으면 (즉 히프 트리를 벗어나지 않았으면)

6 : 오른쪽 자식이 더 크면

7-8 : 두 개의 자식 노드 중 큰 값의 인덱스를 largest로 옮긴다.

9 : largest의 부모 노드가 largest보다 크면

10 : 중지

11 : 그렇지 않으면 largest largest 부모 노드를 교환한다.

12 : 한 레벨 밑으로 내려간다.

13 : 최댓값을 반환한다.

 

 - 소스

element delete_max_heap(HeapType *h) 
{ 
    int parent, child; 
    element item, temp;

    item = h->heap[1];
    temp = h->heap[(h->heap_size)--];
    parent = 1;	
    child = 2;
    while( child <= h->heap_size ){
	  // 현재 노드의 자식노드중 더 작은 자식노드를 찾는다.
	  if( ( child < h->heap_size ) && 
	      (h->heap[child].key) < h->heap[child+1].key)
	      child++;
	  if( temp.key >= h->heap[child].key ) break;
	  // 한단계 아래로 이동
	  h->heap[parent] = h->heap[child];
	  parent = child;
	  child *= 2;
    }
    h->heap[parent] = temp;
    return item;
}

 

4. 힙의 응용

 - 큐가 많이 사용되는 곳은 시뮬레이션 분야이다.

 - 시뮬레이션은 시간을 기반으로 해서 연속 시간 시뮬레이션, 이산 시간 시뮬레이션, 이산 이벤트 시뮬레이션으로 나누어진다.

 

5. 허프만 코드(Huffman codes)

 - 이진 트리는 각 글자의 빈도가 알려져 있는 메시지의 내용을 압축하는 데 사용될 수 있다.

 - 가장 많이 등장하는 글자에는 짧은 비트열을 사용하고 잘 나오지 않는 글자에는 긴 비트열을 사용하여 전체의 크기를 줄이자는 것이다.

 - 각각의 글자를 어떤 비트 코드로 표현했는지를 알려주는 테이블이 있어야 한다.

 - 압축한 데이터를 복원하는 방법에서 포인트는 모든 코드가 다른 코드의 첫 부분이 아니라는 것이다.

 

 

[Reference]

  • 천인국, 공용해, 하상호, 「C언어로 쉽게 풀어쓴 자료구조」, 생능출판, 2009년
반응형

'DataStructure' 카테고리의 다른 글

Graph(그래프) - 1  (0) 2022.03.11
Sorting(정렬)  (0) 2022.03.11
Tree(트리) - 2 [Binary Search Tree]  (0) 2022.03.04
Tree(트리) - 1 [Binary Tree]  (0) 2022.03.04
Queue(큐) - 2 [Deque(덱)]  (0) 2022.03.04
728x90
반응형

6. 이진 탐색 트리

 - 이진 트리 기반의 탐색을 위한 자료 구조이다.

 - 탐색은 레코드(record)의 집합에서 특정한 레코드를 찾아내는 작업을 의미

 - 레코드는 하나 이상의 필드(field)로 구성된다.

 - 레코드들의 집합을 테이블(table)이라고 한다.

 - 레코드들을 구분할 수 있는 값을 주요 키(primary key)라고 한다.

 - 이진 탐색 트리는 위와 같은 탐색 작업을 효율적으로 하기 위한 자료 구조이다.

 

 1) 이진 탐색 트리의 정의

  - 모든 노드의 키는 유일하다.

  - 왼쪽 서브 트리의 키들은 루트의 키보다 작다.

  - 오른쪽 서브 트리의 키들은 루트의 키보다 크다.

  - 왼쪽과 오른쪽 서브 트리도 이진 탐색 트리이다.

 

 2) 순환적인 탐색 함수

TreeNode *search(TreeNode *node, int key) 
{ 
	if( node == NULL ) return NULL; 
	if( key == node->key ) return node; 
	else if( key < node->key ) 
	    	return search(node->left, key); 
	else 
		return search(node->right, key); 
}

 

 3) 반복적인 탐색 함수

TreeNode *search(TreeNode *node, int key) 
{    
	while(node != NULL){ 
	    if( key == node->key ) return node; 
	    else if( key < node->key ) 
		node = node->left; 
	    else 
		node = node->right; 
    } 
    return NULL; 	// 탐색에 실패했을 경우 NULL 반환
}

  효율성을 따지면 반복적인 함수가 훨씬 우수하다.

 함수의 매개 변수 node를 직접 사용하였는데, 사실 매개 변수는 원본 포인터의 복사본이므로 변경하여도 호출한 함수에는 별 영향이 없다.

 

 4) 이진 트리 삽입 연산

void insert_node(TreeNode **root, int key)
{
	TreeNode *p, *t; // p는 부모노드, t는 현재노드 
	TreeNode *n;	    // n은 새로운 노드

	t = *root;
	p = NULL;
	// 탐색을 먼저 수행 
	while (t != NULL) {
		if (key == t->key) return;
		p = t;
		if (key < t->key) t = t->left;
		else t = t->right;
	}
	// key가 트리 안에 없으므로 삽입 가능
	n = (TreeNode *)malloc(sizeof(TreeNode));
	if (n == NULL) return;
	// 데이터 복사
	n->key = key;
	n->left = n->right = NULL;
	// 부모 노드와 링크 연결
	if (p != NULL)
		if (key < p->key)
			p->left = n;
		else p->right = n;
	else *root = n;
}

 

 5) 이진 트리 삭제 연산

  (1) 단말 노드일 경우

  (2) 하나의 서브 트리만 가지고 있는 경우

  (3) 두 개의 서브 트리를 가지고 있는 경우 : 가장 값이 비슷한 노드를 후계자로 선택하는데, 왼쪽 서브 트리의 가장 오른쪽 값 or 오른쪽 서브 트리의 가장 왼쪽 값

void delete_node(TreeNode **root, int key)
{
	TreeNode *p, *child, *succ, *succ_p, *t;

	// key를 갖는 노드 t를 탐색, p는 t의 부모노드
	p = NULL;
	t = *root;
	// key를 갖는 노드 t를 탐색한다.
	while( t != NULL && t->key != key )
	{
		p = t;
		t = ( key < t->key ) ? t->left : t->right;
	}
	// 탐색이 종료된 시점에 t가 NULL이면 트리안에 key가 없음
	if( t == NULL )
	{ 	// 탐색트리에 없는 키
		printf("key is not in the tree");
		return;
	}
	// 첫번째 경우: 단말노드인 경우
	if( (t->left==NULL) && (t->right==NULL) )
	{ 
		if( p != NULL ){
			// 부모노드의 자식필드를 NULL로 만든다.
			if( p->left == t )	 
				p->left = NULL;
			else   p->right = NULL;
		}
		else	// 만약 부모노드가 NULL이면 삭제되는 노드가 루트
			*root = NULL;
	}
	// 두번째 경우: 하나의 자식만 가지는 경우
	else if((t->left==NULL)||(t->right==NULL))
	{
		child = (t->left != NULL) ? t->left : t->right;
		if( p != NULL ){
			if( p->left == t )	// 부모를 자식과 연결 
				p->left = child;
			else p->right = child;
		}
		else   // 만약 부모노드가 NULL이면 삭제되는 노드가 루트
			*root = child;
	}
	// 세번째 경우: 두개의 자식을 가지는 경우
	else
	{		
		// 오른쪽 서브트리에서 후계자를 찾는다.
		succ_p = t;
		succ = t->right;
		// 후계자를 찾아서 계속 왼쪽으로 이동한다.
		while(succ->left != NULL){
			succ_p = succ;
			succ = succ->left;
		}
		// 후속자의 부모와 자식을 연결 
		if( succ_p->left == succ )
			succ_p->left = succ->right;
		else 
			succ_p->right = succ->right;
		// 후속자가 가진 키값을 현재 노드에 복사
		t->key = succ->key;
		// 원래의 후속자 삭제
		t = succ;
	}
	free(t);
}

 

 

[Reference]

  • 천인국, 공용해, 하상호, 「C언어로 쉽게 풀어쓴 자료구조」, 생능출판, 2009년
반응형

'DataStructure' 카테고리의 다른 글

Sorting(정렬)  (0) 2022.03.11
Priority Queue(우선순위 큐)와 Heap(힙)  (0) 2022.03.11
Tree(트리) - 1 [Binary Tree]  (0) 2022.03.04
Queue(큐) - 2 [Deque(덱)]  (0) 2022.03.04
Queue(큐) - 1 [Array Queue, Linked Queue]  (0) 2022.03.04
728x90
반응형

1. 트리의 개념

 - 자료들이 선형 자료 구조가 아니라 계층적인 구조를 가지고 있을 때 이용되는 자료구조

 - 인공 지능 문제에서도 트리가 사용되는데 대표적인 것이 결정 트리(decision tree)이다.

 

 1) 트리에서 나타나는 용어들

  - 트리를 구성하고 있는 것들을 노드(node)라고 한다.

  - 루트와 서브 트리는 선으로 연결되는데 이 연결선을 간선(edge)라고 한다.

  - 부모노드, 자식노드, 형제 관계(같은 레벨) 조상 노드, 자손 노드가 있다.

  - 자식 노드가 없는 노드를 단말 노드(terminal node, leaf node), 그 반대가 비단말 노드(nonterminal node)이다.

  - 노드의 차수(degree)는 그 노드의 자식 노드의 개수를 의미한다.

  - 트리의 차수는 트리가 가지고 있는 노드의 차수 중에서 가장 큰 차수이다.

  - 레벨은 루트의 레벨이 1이고 그 밑으로 1단계씩 내려갈수록 1씩 증가한다.

  - 트리의 높이(height)는 트리가 가지고 있는 최대 레벨을 말한다.

 

2. 이진 트리의 소개

 

 1) 이진 트리의 정의(순환적 정의)

  - 공집합이거나

  - 루트와 왼쪽 서브 트리, 오른쪽 서브 트리로 구성된 노드들의 유한 집합으로 정의된다. 이진 트리의 서브 트리들은 모두 이진 트리여야한다.

 

 2) 이진 트리의 성질

  - n개의 노드를 가진 이진 트리는 정확하게 n-1개의 간선을 가진다.

  - 높이가 h인 이진 트리의 경우, 최소 h개의 노드를 가지며 최대 2h-1개의 노드를 가진다.

  - 레벨 i에서의 노드의 최대 개수는 2i-1이다.

  - n개의 노드를 가지는 이진 트리의 높이는 최대 n이거나 최소 log2(n+1) (소수점 올림)이 된다.

  - 증명 : 높이 h의 이진트리가 가질 수 있는 노드의 최댓값은 2h-1이므로 n2h-1의 부등식이 성립하고 양변에 로그취하면 h log2(n+1)이 된다.

 

 3) 이진 트리의 분류

  - 포화 이진 트리 : 모든 레벨에 노드가 꽉 참

  - 완전 이진 트리 : 마지막 레벨이전까지 노드가 꽉 차고 마지막 레벨에서는 왼쪽부터 채워져 있을 때

  - 기타 이진 트리

 

3. 이진 트리의 표현

 1) 배열 표현법

  - 인덱스 0을 사용하지 않는 편이 계산을 간단하게 만든다.

  - 완전 이진 트리가 아닌 일반적인 이진 트리의 경우 기억 공간 낭비가 심해진다.

  - 노드 i의 부모 노드 인덱스 = i/2 (소수점 내림)

  - 노드 i의 왼쪽 자식 노드 인덱스 = 2i

  - 노드 i의 오른쪽 자식 노드 인덱스 = 2i+1

 

 2) 링크 표현법

typedef struct TreeNode {
	int data;
	struct TreeNode *left, *right;
} TreeNode;

 

4. 이진 트리의 순회

 - 이진 트리를 순회(traversal)한다는 것은 이진 트리에 속하는 모든 노드를 한 번씩 방문하여 노드가 가지고 있는 데이터를 목적에 맞게 처리하는 것을 의미한다.

 - 자료를 순차적으로 순회하는 것은 이진 트리에서 중요한 연산이다.

 1) 이진 트리 순회 방법

  - 루트를 서브 트리에 앞서서 먼저 방문하면 전위 순회

  - 루트를 왼쪽과 오른쪽 서브 트리 중간에 방문하면 중위 순회

  - 루트를 왼쪽과 오른쪽 서브 트리 방문 후에 방문하면 후위 순회

  - 항상 왼쪽 서브 트리를 오른쪽 서브 트리에 앞서서 방문한다고 가정

  - 순회 알고리즘은 순환 기법을 사용한다.

// 중위 순회
void inorder( TreeNode *root ){
    if ( root ){
	 inorder( root->left );// 왼쪽서브트리 순회
	 printf("%d", root->data );  // 노드 방문
	 inorder( root->right );// 오른쪽서브트리 순회
    }
}
// 전위 순회
void preorder( TreeNode *root ){
    if ( root ){
	 printf("%d", root->data );  // 노드 방문
	 preorder( root->left );// 왼쪽서브트리 순회
	 preorder( root->right );// 오른쪽서브트리 순회
    }
}
// 후위 순회
void postorder( TreeNode *root ){
    if ( root ){
	 postorder( root->left );// 왼쪽서브트리 순회
	 postorder( root->right );// 오른쪽서브트리순회
	 printf("%d", root->data );  // 노드 방문
    }
}

 2) 레벨 순회

  - 이전 순회에서는 스택을 사용했던 것에 비해 레벨 순회는 큐를 사용하는 순회법이다.

  - 먼저 큐에 있는 노드를 꺼내어 방문한 다음, 그 노드의 자식 노드를 큐에 삽입하는 것으로 한번의 반복

// 레벨 순회 
void level_order(TreeNode *ptr)
{
	QueueType q;

	init(&q);
	if (!ptr) return;
	enqueue(&q, ptr);
	while (is_empty(&q)) {
		ptr = dequeue(&q);
		printf(" %d ", ptr->data);
		if (ptr->left)
			enqueue(&q, ptr->left);
		if (ptr->right)
			enqueue(&q, ptr->right);
	}
}

 3) 이진 트리 순회의 응용

  (1) 수식 트리 처리

   - 피연산자들은 단말 노드가 되며 연산자는 비단말 노드가 된다.

   - 가장 적합한 순회 방식은 자식 노드를 먼저 방문하는 후위 순회라 할 수 있을 것이다.

  (2) 디렉터리 용량 계산

   - 후위 순회 사용

 

5. 이진 트리의 연산

 1) 노드의 개수

int get_node_count(TreeNode *node)
{
	int count = 0;
	if (node != NULL)
		count = 1 + get_node_count(node->left) + get_node_count(node->right);
	return count;
}

 2) 단말 노드 개수 구하기

int get_leaf_count(TreeNode *node)
{
	int count = 0;
	if (node != NULL)
	{
		if (node->left == NULL && node->right == NULL) return 1;
		else count = get_leaf_count(node->left) + get_leaf_count(node->right);
	}
	return count;
}

 3) 높이 구하기

int get_height(TreeNode *node)
{
	int height = 0;
	if (node != NULL)
		height = 1 + max(get_height(node->left), get_height(node->right));
	return height;
}

 

 

[Reference]

  • 천인국, 공용해, 하상호, 「C언어로 쉽게 풀어쓴 자료구조」, 생능출판, 2009년
반응형

'DataStructure' 카테고리의 다른 글

Priority Queue(우선순위 큐)와 Heap(힙)  (0) 2022.03.11
Tree(트리) - 2 [Binary Search Tree]  (0) 2022.03.04
Queue(큐) - 2 [Deque(덱)]  (0) 2022.03.04
Queue(큐) - 1 [Array Queue, Linked Queue]  (0) 2022.03.04
Stack(스택)  (0) 2022.03.02
728x90
반응형

4. (Deque)

 - Double-ended queue의 줄임말로서 큐의 전단과 후단에서 모두 삽입과 삭제가 가능한 큐를 의미한다.

 

 1) 덱 추상 데이터 타입

  - 객체 : n개의 element형의 요소들의 순서 있는 모임

  - 연산 :

  • create()
  • init(dq)
  • is_empty(dq)
  • is_full(dq)
  • add_front(dq, e) : 덱의 앞에 요소를 추가한다.
  • add_rear(dq, e) : 덱의 뒤에 요소를 추가한다.
  • delete_front(dq) : 덱의 앞에 있는 요소를 반환한 다음 삭제한다.
  • delete_rear(dq) : 덱의 뒤에 있는 요소를 반환한 다음 삭제한다.
  • get_front(dq) : 덱의 앞에서 삭제하지 않고 앞에 있는 요소를 반환한다.
  • get_rear(dq) : 덱의 뒤에서 삭제하지 않고 앞에 있는 요소를 반환한다.

 

 2) 덱의 구현

  - 덱은 보통 이중 연결 리스트로 구현된다. 그 이유는 전단과 후단에서 모두 삽입, 삭제가 가능해야 하기 때문에 양쪽으로 링크를 가지고 있어야 편리하기 때문이다.

 

#define TRUE 1
#define FALSE 0
typedef int element;		// 요소의 타입

typedef struct DlistNode {	// 노드의 타입
	element data;
	struct DlistNode *llink;
	struct DlistNode *rlink;
} DlistNode;

typedef struct DequeType {	// 덱의 타입
	DlistNode *head;
	DlistNode *tail;
} DequeType;
//
void error(char *message)
{
	fprintf(stderr, "%s\n", message);
	exit(1);
}
//
void init(DequeType *dq)
{
	dq->head = dq->tail = NULL;
}

//
DlistNode *create_node(DlistNode *llink, element item, DlistNode *rlink)
{
	DlistNode *new_node = (DlistNode *)malloc(sizeof(DlistNode));
	if (new_node == NULL)
		error("메모리 할당 오류");
	new_node->llink = llink;
	new_node->data = item;
	new_node->rlink = rlink;
	return new_node;
}
//
int is_empty(DequeType *dq)
{
	if (dq->head == NULL) return TRUE;
	else return FALSE;
}
//
void add_rear(DequeType *dq, element item)
{
	DlistNode *new_node = create_node(dq->tail, item, NULL);

	if (is_empty(dq))
		dq->head = new_node;
	else
		dq->tail->rlink = new_node;
	dq->tail = new_node;
}
//
void add_front(DequeType *dq, element item)
{
	DlistNode *new_node = create_node(NULL, item, dq->head);

	if (is_empty(dq))
		dq->tail = new_node;
	else
		dq->head->llink = new_node;
	dq->head = new_node;
}
//
element delete_front(DequeType *dq)
{
	element item;
	DlistNode *removed_node;

	if (is_empty(dq))  error("공백 덱에서 삭제");
	else {
		removed_node = dq->head;
		item = removed_node->data;
		dq->head = dq->head->rlink;
		free(removed_node);
		if (dq->head == NULL)
			dq->tail = NULL;
		else
			dq->head->llink = NULL;
	}
	return item;
}
//
element delete_rear(DequeType *dq)
{
	element item;
	DlistNode *removed_node;

	if (is_empty(dq))  error("공백 덱에서의 삭제");
	else {
		removed_node = dq->tail;
		item = removed_node->data;
		dq->tail = dq->tail->llink;
		free(removed_node);

		if (dq->tail == NULL)
			dq->head = NULL;
		else
			dq->tail->rlink = NULL;
	}
	return item;
}
//
void display(DequeType *dq)
{
	DlistNode *p;
	printf("( ");
	for (p = dq->head; p != NULL; p = p->rlink) {
		printf("%d ", p->data);
	}
	printf(")\n");
}

void main()
{
	DequeType deque;

	init(&deque);
	add_front(&deque, 10);
	add_front(&deque, 20);
	add_rear(&deque, 30);
	display(&deque);

	delete_front(&deque);
	delete_rear(&deque);
	display(&deque);
}

 

5. 큐의 응용

 - 큐는 서로 다른 속도로 실행되는 두 프로세스 간의 상호 작용을 조화시키는 버퍼 역할을 담당한다.

 - 은행 서비스 시뮬레이션 프로그램

 

/* simulation.c */

#include <stdlib.h>
#include <stdio.h>
#include <math.h>

#define TRUE 1
#define FALSE 0
#define MAX_QUEUE_SIZE 100
typedef struct {
	int id;
	int arrival_time;
	int service_time;
} element;

typedef struct {
	element  queue[MAX_QUEUE_SIZE];
	int  front, rear;
} QueueType;
QueueType queue;

// 초기화 함수
void init(QueueType *q)
{
	q->front = q->rear = 0;
}
// 공백 상태 검출 함수
int is_empty(QueueType *q)
{
	return (q->front == q->rear);
}
// 포화 상태 검출 함수
int is_full(QueueType *q)
{
	return ((q->rear + 1) % MAX_QUEUE_SIZE == q->front);
}
// 삽입 함수
void enqueue(QueueType *q, element item)
{
	if (is_full(q)) {
		fprintf(stderr, "큐가 포화상태입니다.\n");
		return;
	}
	q->rear = (q->rear + 1) % MAX_QUEUE_SIZE;
	q->queue[q->rear] = item;
}
// 삭제 함수
element dequeue(QueueType *q)
{
	if (is_empty(q)) {
		fprintf(stderr, "큐가 공백상태입니다.\n");
		exit(1);
	}
	q->front = (q->front + 1) % MAX_QUEUE_SIZE;
	return q->queue[q->front];
}
// [0..1] 사이의 난수 생성 함수
double random()
{
	return rand() / (double)RAND_MAX;
}

// 시뮬레이션에 필요한 여러가지 상태 변수
int duration = 10; // 시물레이션 시간 
double arrival_prob = 0.7; // 하나의 시간 단위에 도착하는 평균 고객의 수
int max_serv_time = 5; // 하나의 고객에 대한 최대 서비스 시간
int clock;

// 시뮬레이션의 결과
int customers;			// 전체고객수
int served_customers;	// 서비스받은 고객수
int waited_time;	// 고객들이 기다린 시간

					// 랜덤 숫자를 생성하여 고객이 도착했는지 도착하지 않았는지를 판단
int is_customer_arrived()
{
	if (random() < arrival_prob)
		return TRUE;
	else return FALSE;
}
// 새로 도착한 고객을 큐에 삽입
void insert_customer(int arrival_time)
{
	element customer;

	customer.id = customers++;
	customer.arrival_time = arrival_time;
	customer.service_time = (int)(max_serv_time * random()) + 1;
	enqueue(&queue, customer);
	printf("고객 %d이 %d분에 들어옵니다. 서비스시간은 %d분입니다.\n",
		customer.id, customer.arrival_time, customer.service_time);
}
// 큐에서 기다리는 고객을 꺼내어 고객의 서비스 시간을 반환한다.
int remove_customer()
{
	element customer;
	int service_time = 0;

	if (is_empty(&queue)) return 0;
	customer = dequeue(&queue);
	service_time = customer.service_time - 1;
	served_customers++;
	waited_time += clock - customer.arrival_time;
	printf("고객 %d이 %d분에 서비스를 시작합니다. 대기시간은 %d분이었습니다.\n",
		customer.id, clock, clock - customer.arrival_time);
	return service_time;
}
// 통계치를 출력한다.
void print_stat()
{
	printf("서비스받은 고객수 = %d\n", served_customers);
	printf("전체 대기 시간 = %d분\n", waited_time);
	printf("1인당 평군 대기 시간 = %f분\n", (double)waited_time / served_customers);
	printf("아직 대기중인 고객수 = %d\n", customers - served_customers);
}
// 시뮬레이션 프로그램
void main()
{
	int service_time = 0;

	clock = 0;
	while (clock < duration) {
		clock++;
		printf("현재시각=%d\n", clock);
		if (is_customer_arrived()) {
			insert_customer(clock);
		}
		if (service_time > 0)
			service_time--;
		else {
			service_time = remove_customer();
		}
	}
	print_stat();
}

 

 

[Reference]

  • 천인국, 공용해, 하상호, 「C언어로 쉽게 풀어쓴 자료구조」, 생능출판, 2009년
반응형
728x90
반응형

1. 큐 추상 데이터 타입

 - 선입 선출(FIFO : First-In First-Out)

 - 삽입이 일어나는 곳을 후단(rear), 삭제가 일어나는 곳을 전단(front)이라고 한다.

 - 객체 : n개의 element 타입의 요소들의 순서 있는 모임

 - 연산 :

  •  create()
  •  init(q)
  •  is_empty(q)
  •  is_full(q)
  •  enqueue(q, e) : 큐의 뒤에 요소를 추가한다.
  •  dequeue(q) : 큐의 앞에 있는 요소를 반환한 다음 삭제한다.
  •  peek(q) : 큐에서 삭제하지 않고 앞에 있는 요소를 반환한다.

 - 사용 예로는 은행에서 기다리는 사람들의 대기열, 공항에서 이륙하는 비행기들, 인터넷에서 전송되는 데이터 패킷들을 모델링하는 데 큐가 이용된다.

 - 운영체제에서는 인쇄 작업큐, 사용자가 키보드를 누르면 키 스트로크 데이터가 큐에 저장된다.

 

2. 배열로 구현된 큐

 1) 선형 큐(linear queue)

  - front rear의 초기값은 -1이다.

  - 삭제할 때는 front를 하나 증가하고 front가 가리키는 위치에 있는 데이터를 삭제한다.

 

 2) 원형 큐

  - 원형 큐에서는 front rear의 개념이 약간 변경된다. 먼저 초기값은 -1이 아닌 0이다.

  - front rear의 값이 같으면 원형 큐가 비어 있음을 나타낸다.

  - 원형 큐에서는 하나의 자리는 항상 비워둔다. 왜냐하면 포화 상태와 공백 상태를 구별하기 위해서이다.

 

#include <stdio.h>

#define MAX_QUEUE_SIZE 100
typedef int element;
typedef struct {
	element  queue[MAX_QUEUE_SIZE];
	int  front, rear;
} QueueType;
//
void error(char *message)
{
	fprintf(stderr, "%s\n", message);
	exit(1);
}
// 초기화 함수
void init(QueueType *q)
{
	q->front = q->rear = 0;
}
// 공백 상태 검출 함수
int is_empty(QueueType *q)
{
	return (q->front == q->rear);
}
// 포화 상태 검출 함수
int is_full(QueueType *q)
{
	return ((q->rear + 1) % MAX_QUEUE_SIZE == q->front);
}
// 삽입 함수
void enqueue(QueueType *q, element item)
{
	if (is_full(q))
		error("큐가 포화상태입니다");
	q->rear = (q->rear + 1) % MAX_QUEUE_SIZE;
	q->queue[q->rear] = item;
}
// 삭제 함수
element dequeue(QueueType *q)
{
	if (is_empty(q))
		error("큐가 공백상태입니다");
	q->front = (q->front + 1) % MAX_QUEUE_SIZE;
	return q->queue[q->front];
}
// 삭제 함수
element peek(QueueType *q)
{
	if (is_empty(q))
		error("큐가 공백상태입니다");
	return q->queue[(q->front + 1) % MAX_QUEUE_SIZE];
}
// 주 함수
void main()
{
	QueueType q;
	init(&q);
	printf("front=%d rear=%d\n", q.front, q.rear);
	enqueue(&q, 1);
	enqueue(&q, 2);
	enqueue(&q, 3);
	printf("dequeue()=%d\n", dequeue(&q));
	printf("dequeue()=%d\n", dequeue(&q));
	printf("dequeue()=%d\n", dequeue(&q));
	printf("front=%d rear=%d\n", q.front, q.rear);
}

 

3. 연결 리스트로 구현된 큐

 - 연결 리스트로 만들어진 큐를 연결된 큐(linked queue)라고 한다.

 - 크기가 제한되지 않는다는 장점, 링크 필드 때문에 메모리 공간을 더 많이 사용한다.

typedef int element;	// 요소의 타입
typedef struct QueueNode {	// 큐의 노드의 타입 
	element item;
	struct QueueNode *link;
} QueueNode;
typedef struct {		// 큐 ADT 구현
	QueueNode *front, *rear;
} QueueType;
// 오류 함수
void error(char *message)
{
	fprintf(stderr, "%s\n", message);
	exit(1);
}
// 초기화 함수
void init(QueueType *q)
{
	q->front = q->rear = NULL;
}
// 공백 상태 검출 함수
int is_empty(QueueType *q)
{
	return (q->front == NULL);
}
// 포화 상태 검출 함수
int is_full(QueueType *q)
{
	return 0;
}
// 삽입 함수
void enqueue(QueueType *q, element item)
{
	QueueNode *temp = (QueueNode *)malloc(sizeof(QueueNode));
	if (temp == NULL)
		error("메모리를 할당할 수 없습니다");
	else {
		temp->item = item; 		// 데이터 저장
		temp->link = NULL; 		// 링크 필드를 NULL
		if (is_empty(q)) { 		// 큐가 공백이면
			q->front = temp;
			q->rear = temp;
		}
		else { 		// 큐가 공백이 아니면
			q->rear->link = temp;  // 순서가 중요
			q->rear = temp;
		}
	}
}
// 삭제 함수
element dequeue(QueueType *q)
{
	QueueNode *temp = q->front;
	element item;
	if (is_empty(q))			// 공백상태
		error("큐가 비어있습니다");
	else {
		item = temp->item; 		// 데이터를 꺼낸다.
		q->front = q->front->link; // front를 다음노드를 가리키도록 한다.
		if (q->front == NULL) 	// 공백 상태
			q->rear = NULL;
		free(temp); 			// 동적메모리 해제
		return item; 			// 데이터 반환
	}
}
// peek 함수
element peek(QueueType *q)
{
	element item;
	if (is_empty(q))
		error("큐가 비어 있읍니다");
	else {
		item = q->front->item;	// 데이터를 꺼낸다.
		return item; 			// 데이터 반환
	}
}
// 연결된 큐 테스트 함수
void main(void)
{
	QueueType q;

	init(&q);
	enqueue(&q, 1);
	enqueue(&q, 2);
	enqueue(&q, 3);
	printf("dequeue()=%d\n", dequeue(&q));
	printf("dequeue()=%d\n", dequeue(&q));
	printf("dequeue()=%d\n", dequeue(&q));
}

 

 

[Reference]

  • 천인국, 공용해, 하상호, 「C언어로 쉽게 풀어쓴 자료구조」, 생능출판, 2009년
반응형