이번 포스팅에서는 저번 포스팅에 이어서 Queue에 대한 소스코드 및 시간복잡도에 알아보고자 한다. 이를 배열과 연결리스트일 때로 나누어서 설명하겠다.
(1) 배열
Queue가 배열로 구현된 경우 배열이기 때문에 index에 바로 접근이 가능하여 탐색이 없고 삽입만 해주면 돼서 삽입의 시간복잡도는 O(1)이다. 하지만 삭제는 O(1)이 아니다.
삭제 역시 삽입처럼 배열이기 때문에 index에 바로 접근이 가능하여 탐색자체는 필요가 없지만 문제는 배열로 Queue를 구현하는 경우 데이터 삭제 시 각 요소들을 한 칸씩 이동시켜줘야 하기 때문에 이 경우는 시간복잡도가 O(1)이 아니다. 배열의 크기를 n이라고 할 때 1개의 데이터를 한 칸 옮기는 데 드는 시간복잡도를 O(1)이라고 하면 n-1개를 옮겨야 하므로 “삭제(O(1)) + 이동(O(n-1))” 이라고 생각해서 약 O(n)이라고 하겠다.
P.S : 사실 1개의 데이터를 한 칸 옮기는 것을 O(1)이라고 해도 되는지를 모르겠다. 삭제의 시간 복잡도가 O(1)이 아니라는 것을 직관적으로 설명하기 위해 이렇게 표현했는데, 어쨌든 핵심은 삭제의 시간복잡도가 O(1)보다는 훨씬 더 걸린다는 것이다.
삽입은 쉽다. top을 +1 시켜주면서 그 자리에 데이터를 삽입하면 된다.
Queue에서의 삭제 역시 원래 구현하려고 했던 것은 연결리스트처럼 실제로 데이터를 삭제하는 방향으로 하려고 했는데 C언어로 하려다보니 생각보다 배열의 요소를 제거하는 것이 여간 귀찮은 게 아니었다. 또한 배열로 구현한 Queue의 삭제는 삭제하고 나서 나머지 데이터를 모두 한 칸씩 옮겨줘야 한다. 따라서, 필자는 데이터를 직접 지우는 방식이 아니라 2번째 데이터부터 top의 위치에 있는 데이터까지 모두 -1칸씩 이동시켜 덮어쓰기를 했다. 그 다음 top 값 역시 -1을 해주어 top 위치도 이동시킨 후 처음부터 top의 위치까지만 출력해 Queue를 보여주게 했다.
그럼 Queue가 꽉 찬 다음 데이터를 하나 제거하고 새로운 데이터가 들어오는 경우는 어떻게 될까? 아래 그림에도 나와있지만 위에서 설명한 대로 모두 덮어쓰기를 했다.
#include <stdio.h>
#include <stdlib.h>
int top;
void print_queue(int queue[]) { // Queue에 있는 데이터를 출력해주는 함수
for (int i=top; i>=0; i--){ // 배열의 마지막->처음 순으로 출력 => 데이터가 put될 때마다 위로 쌓이도록 출력
printf("%d\n", queue[i]);
}
printf("\n");
}
int main()
{
int queue_data;
int queue_size;
int num;
printf("생성할 Queue의 크기를 입력하세요. : ");
scanf("%d", &queue_size);
int queue[queue_size]; // Queue 초기화 및 생성
top = -1; // 배열은 처음 데이터가 저장될 때 index=0에 저장되므로 top은 -1로 초기화
while(1){
printf("해당 번호를 선택해주세요(1 -> 데이터 추가, 2 -> 데이터 삭제) : ");
scanf("%d", &num);
if(num == 1){ // 1번, 즉, 데이터를 put하는 경우
printf("Queue에 넣을 데이터를 입력하세요 : ");
scanf("%d", &queue_data);
if (top == queue_size-1) { // 배열은 0부터 시작하므로 배열의 마지막 index는 (배열의 크기 - 1)
printf("Queue가 가득 찼습니다.\n");
}
else{
queue[++top] = queue_data;
}
print_queue(queue);
}
else if(num == 2){ // 2번, 즉, 데이터를 get하는 경우
if (top == -1) {
printf("Queue가 비어 있습니다.\n");
}
else{
for(int i=0; i<queue_size-1; i++){ // 여기서 구현한 get기능은 데이터를 삭제하는 것이 아닌 2->1, 3->2, ..., 이렇게 데이터를 이동시켜 덮어쓰게 했고 top의 위치도 -1칸 이동시켜 처음부터 top까지 출력
queue[i] = queue[i+1];
}
--top;
}
print_queue(queue);
}
else{ // 1번과 2번을 고르지 않고 다른 숫자를 입력한 경우
printf("1번과 2번만 선택 가능합니다.\n");
}
}
return 0;
}
위는 Queue를 배열로 구현한 소스코드이다. 실행을 해보면
11 => 22 => 33 => 44 순으로 put해서 화면에는 44 => 33 => 22 => 11으로 보인다. 또한 Queue이기 때문에 삭제를 하면 11 => 22 => 33 순으로 삭제된다.
(2) 연결리스트
Stack이 연결리스트로 구현된 경우 top의 위치에서만 삽입과 삭제를 하면 되므로 삽입/삭제의 시간복잡도가 O(1)이지만 Queue가 연결리스트로 구현된 경우는 그렇지가 않다. 다음 그림들을 보면서 이해해보자.
Queue의 삽입은 Stack과 같이 top의 위치에서만 삽입을 하므로 탐색을 따로 할 필요가 없어 시간복잡도가 O(1)이다.
Queue의 삭제는 Stack과 다르다. Stack은 top의 위치에서만 삭제를 했지만 Queue는 연결리스트의 끝부분을 삭제해야한다. 이 말은 연결리스트의 끝부분까지 탐색을 해야한다는 뜻이므로 시간복잡도가 O(n)이다.
방금 필자가 Queue의 삽입 시 시간복잡도가 O(1)이라고 했고 삭제 시 시간복잡도가 O(n)이라고 했는데 이는 top을 연결리스트의 맨 앞으로 잡았기 때문이다. 아래에서 설명하겠지만 맨 뒤를 top으로 놓게 되면 삽입 시 시간복잡도는 O(n)이고 삭제 시 시간복잡도는 O(1)이 된다. 결론을 내보면, Queue는 어느 쪽을 기준으로 잡느냐에 따라 삽입/삭제의 시간복잡도는 O(1)/O(n) 또는 O(n)/O(1)이 된다. 즉, 한쪽이 O(1)이면 다른 한쪽은 반드시 O(n)이 될 수 밖에 없는 구조인 것이다.
#include <stdio.h>
#include <stdlib.h>
struct NODE { // 연결 리스트의 노드 구조체
struct NODE *next; // 다음 노드의 주소를 저장할 포인터
int data; // 데이터를 저장할 공간
};
void Node_Insert(struct NODE *top, int list_data) // 노드를 삽입하는 함수 => 매개변수로 삽입할 노드의 이전 노드, 삽입할 데이터를 받음
{
struct NODE *newNode = malloc(sizeof(struct NODE)); // 새 노드 생성
newNode->next = top->next; // 새 노드의 next값에 이전 노드(top)의 next값을 저장 => 쉽게 말해, 노드1->노드2를 노드3(삽입)->노드2로 연결
newNode->data = list_data; // 데이터 저장
top->next = newNode; // 이전 노드의 next값에 삽입 노드의 주소값 저장 => 쉽게 말해, 노드1->노드3(삽입)로 연결
}
void Node_Delete(struct NODE *prev_node) // 노드를 삭제하는 함수 => 매개변수로 삭제할 노드의 이전 노드를 받음
{
struct NODE *removeNode = prev_node->next; // 이전 노드의 next값, 즉, 다음 노드 주소를 저장(이전 노드는 head가 될 수도 있음)
if (removeNode == NULL){ // 삭제할 노드가 없는 경우 => main함수에서 맨 처음에 head를 생성하고 head의 next값을 NULL로 초기화하므로 아무 노드도 없으면 head->next=NULL로 됨
printf("삭제할 노드가 없습니다.\n");
return;
}
else{
prev_node->next = removeNode->next; // 이전 노드의 next값에 삭제할 노드의 next값, 즉, 삭제할 노드의 다음 노드 주소값을 저장 => 쉽게 말해, 노드1->노드2(삭제)->노드3를 노드1->노드3로 연결
free(removeNode); // 노드 메모리 해제
}
}
void Queue_Print(struct NODE *queue_node) // Queue 출력
{
while (queue_node != NULL){ // 각 노드를 보여주는 부분, 포인터가 NULL일 때까지 계속 반복 => 즉, 리스트의 끝부분으로 갈 때까지 계속 print함으로써 리스트의 요소들을 보여줌 -> 만약, 아무 노드도 없다면 while문이 돌지 않아 아무것도 출력 X
printf("%d\n", queue_node->data); // 현재 노드의 데이터 출력
queue_node = queue_node->next; // 포인터에 다음 노드의 주소 저장
}
}
int main()
{
int list_data;
int num;
int list_num;
struct NODE *head = malloc(sizeof(struct NODE)); // head 노드 생성
head->next = NULL; // head 노드의 next는 NULL로 초기화
while(1){
printf("해당 번호를 선택해주세요(1 -> 데이터 추가, 2 -> 데이터 삭제) : ");
scanf("%d", &num);
if(num == 1){ // 1번, 즉, node를 삽입하는 경우 (Queue에 데이터를 삽입하는 경우)
printf("큐에 넣을 데이터를 입력하세요 : ");
scanf("%d", &list_data);
struct NODE *top = head;
Node_Insert(top, list_data); // 리스트의 첫 번째 부분에 새 노드 삽입 => Queue에 마지막으로 들어온 값이 첫 번째 노드로 가게끔 코드를 짰기 때문 => 따라서, 인자값으로 head를 넣어줌
Queue_Print(top->next); // 각 노드들을 보여주기 위한 첫 번째 부분 => head->next부터 첫 노드가 시작되므로 head->next가 첫 부분
printf("\n");
}
else if(num == 2){ // 2번, 즉, node를 삭제하는 경우 (Queue에 데이터를 삭제하는 경우)
int list_count = 0;
struct NODE *prev_node = head; // prev_node를 NULL이 아니라 head로 초기화하는 이유는 노드가 비어 있을 때 아래 while문이 돌지 않아 Node_Delete에 NULL이 들어가기 때문
struct NODE *current_node = head;
while(current_node->next != NULL){ // 포인터가 NULL일 때까지 계속 반복, 즉, 리스트의 마지막 부분까지 감
list_count++;
prev_node = current_node; // prev_node는 이전 노드
current_node = prev_node->next; // current_node는 prev_node->next, 즉, prev_node의 다음 노드
}
Node_Delete(prev_node); // 리스트의 마지막 노드 삭제 => Queue에 처음 들어온 값이 마지막 노드로 가게끔 코드를 짰기 때문 => 단, 이전 노드를 마지막 노드로 바꿔주기 위해 인자값으로 이전 노드를 넣음
struct NODE *top = head;
Queue_Print(top->next); // 각 노드들을 보여주기 위한 첫 번째 부분 => head->next부터 첫 노드가 시작되므로 head->next가 첫 부분
printf("\n");
}
else{ // 1번과 2번을 고르지 않고 다른 숫자를 입력한 경우
printf("1번과 2번만 선택 가능합니다.\n");
}
}
return 0;
}
위는 Queue를 연결리스트로 구현한 소스코드이다. 실행을 해보면
아까 배열과는 다르게 Queue의 크기를 지정하는 부분이 없다. 연결리스트이기 때문에 Queue의 크기에 제한이 없기 때문이다. 그래서 Overflow가 발생할 일도 없다. 보면, 배열로 구현한 Queue처럼 11 => 22 => 33 => 44 순으로 put해서 화면에는 44 => 33 => 22 => 11으로 보인다. Queue의 삭제는 맨 처음 들어온 데이터가 삭제되는 것이므로 출력화면에서는 맨 아래부터 삭제되는 것을 확인할 수 있다.
(3) 반대방향의 Queue (연결리스트이긴 하지만 위의 연결리스트와 방향이 반대)
이 경우가 아까 위에서 언급했던 경우이다. 위에서 구현한 연결리스트와 반대로 출력 시 데이터가 아래로 쌓이고 삭제 시 맨 위의 데이터가 삭제되는 구조이다. 방향만 반대지 사실 위에서 구현한 연결리스트와 똑같다. 이로 인해 삽입의 시간복잡도가 O(n), 삭제의 시간복잡도는 O(1)이다. 아래 그림들을 보며 이해해보자.
그림을 보면 삽입을 할 시 연결리스트의 마지막 부분에 삽입을 하는데 그림처럼 삽입을 하려면 연결리스트의 맨 뒤까지 탐색한 다음 삽입을 해야한다. 따라서, O(n)의 시간복잡도가 소요된다. 또한 아래 그림에서 나오겠지만 단일 연결리스트이기 때문에 위에서 쌓이는 것처럼 보이게끔 마지막 값(Data n)부터 출력할 수가 없다. 마지막 값부터 출력을 하려면 그 이전값으로 이동해야하는데 이전 노드의 주소를 가지고 있지 않기 때문이다. 이를 가능하게 한 것이 양방향 연결리스트이다. 양방향 연결리스트는 추후에 포스팅할 예정이다.
그래서, 출력은 Data1 => Data2 => Data3 => … => Data n으로 나와 배열과 연결리스트와 다르게 데이터가 아래로 쌓이는 것처럼 보인다. 물론 방향만 반대일 뿐 첫 번째로 들어온 데이터가 제일 먼저 삭제되는 것은 똑같다.
삭제는 연결리스트의 맨 앞 부분만 삭제하기 때문에 O(1)의 시간복잡도가 소요된다.
#include <stdio.h>
#include <stdlib.h>
struct NODE { // 연결 리스트의 노드 구조체
struct NODE *next; // 다음 노드의 주소를 저장할 포인터
int data; // 데이터를 저장할 공간
};
void Node_Insert(struct NODE *prev_node, int list_data) // 노드를 삽입하는 함수 => 매개변수로 삽입할 노드의 이전 노드, 삽입할 데이터를 받음
{
struct NODE *newNode = malloc(sizeof(struct NODE)); // 새 노드 생성
newNode->next = prev_node->next; // 새 노드의 next값에 이전 노드의 next값을 저장 => 쉽게 말해, 노드1->노드2를 노드3(삽입)->노드2로 연결
newNode->data = list_data; // 데이터 저장
prev_node->next = newNode; // 이전 노드의 next값에 삽입 노드의 주소값 저장 => 쉽게 말해, 노드1->노드3(삽입)로 연결
}
void Node_Delete(struct NODE *prev_node) // 노드를 삭제하는 함수 => 매개변수로 삭제할 노드의 이전 노드를 받음
{
struct NODE *removeNode = prev_node->next; // 이전 노드의 next값, 즉, 다음 노드 주소를 저장(이전 노드는 head가 될 수도 있음)
if (removeNode == NULL){ // 삭제할 노드가 없는 경우 => main함수에서 맨 처음에 head를 생성하고 head의 next값을 NULL로 초기화하므로 아무 노드도 없으면 head->next=NULL로 됨
printf("삭제할 노드가 없습니다.\n");
return;
}
else{
prev_node->next = removeNode->next; // 이전 노드의 next값에 삭제할 노드의 next값, 즉, 삭제할 노드의 다음 노드 주소값을 저장 => 쉽게 말해, 노드1->노드2(삭제)->노드3를 노드1->노드3로 연결
free(removeNode); // 노드 메모리 해제
}
}
void Queue_Print(struct NODE *queue_node) // Queue 출력
{
while (queue_node != NULL){ // 각 노드를 보여주는 부분, 포인터가 NULL일 때까지 계속 반복 => 즉, 리스트의 끝부분으로 갈 때까지 계속 print함으로써 리스트의 요소들을 보여줌 -> 만약, 아무 노드도 없다면 while문이 돌지 않아 아무것도 출력 X
printf("%d\n", queue_node->data); // 현재 노드의 데이터 출력
queue_node = queue_node->next; // 포인터에 다음 노드의 주소 저장
}
}
int main()
{
int list_data;
int num;
int list_num;
struct NODE *head = malloc(sizeof(struct NODE)); // head 노드 생성
head->next = NULL; // head 노드의 next는 NULL로 초기화
while(1){
printf("해당 번호를 선택해주세요(1 -> 데이터 추가, 2 -> 데이터 삭제) : ");
scanf("%d", &num);
if(num == 1){ // 1번, 즉, node를 삽입하는 경우 (Queue에 데이터를 삽입하는 경우)
printf("큐에 넣을 데이터를 입력하세요 : ");
scanf("%d", &list_data);
int list_count = 0;
struct NODE *current_node = head;
while(current_node->next != NULL){ // 포인터가 NULL일 때까지 계속 반복, 즉, 리스트의 마지막 부분까지 감
list_count++;
current_node = current_node->next;
}
Node_Insert(current_node, list_data); // 리스트의 마지막 부분에 새 노드 삽입 => Queue는 마지막 부분에 데이터를 삽입
struct NODE *queue_node = head->next; // 각 노드들을 보여주기 위한 첫 번째 부분 => head->next부터 첫 노드가 시작되므로 head->next가 첫 부분
Queue_Print(queue_node);
printf("\n");
}
else if(num == 2){ // 2번, 즉, node를 삭제하는 경우 (큐에 데이터를 삭제하는 경우)
struct NODE *current_node = head;
Node_Delete(current_node); // 리스트의 첫 번째 노드 삭제 => Queue는 맨 앞의 데이터를 삭제
struct NODE *queue_node = head->next; // 각 노드들을 보여주기 위한 첫 번째 부분 => head->next부터 첫 노드가 시작되므로 head->next가 첫 부분
Queue_Print(queue_node);
printf("\n");
}
else{ // 1번과 2번을 고르지 않고 다른 숫자를 입력한 경우
printf("1번과 2번만 선택 가능합니다.\n");
}
}
return 0;
}
이 코드가 방금까지 보여준 반대방향 Queue 그림을 그대로 구현한 것이다. top이 연결리스트 마지막에 있기 때문에 (물론 위에서 구현한 연결리스트처럼 top 부분을 포인터로 만들진 X) 출력 시에도 위에서 연결리스트로 구현한 Queue를 뒤집어놓은 모양으로 출력이 된다.
하지만 방향만 바뀌어서 그렇지 구현 자체는 문제가 없는 Queue이다. 11 => 22 => 33 => 44 순으로 put했는데 맨 마지막이 top이므로 아래로 쌓이는 것을 확인할 수 있다. 따라서, 화면에도 11 => 22 => 33 => 44로 보이고 Queue의 삭제는 맨 처음 들어온 데이터가 삭제되는 것이므로 출력화면에서는 맨 위부터 삭제되는 것을 확인할 수 있다.
참고 사이트 :