잠깐..! 스택 / 큐 / 리스트 각각이 무슨 차이인지 한 번에 이해해 보자!
객체 : n개의 element(요소)들의 선형 리스트
연산 : 스택에 요소를 추가/삭제하는 연산과, 현재 스택 상태를 검사하는 연산들로 구성된다.
- init() : 스택 생성 + 초기화
- push(e) : 스택의 맨 위에 요소 e 추가
- pop(s) : 스택의 맨 위 요소 삭제
- peek(s) : 스택의 맨 위 요소를 삭제하지 않고 반환
- is_empty(s) : 스택이 비어있는지 검사
- is_full(s) : 스택이 가득 찼는지 검사
▶ 실제 기본 연산
peek()
: 스택 맨 위의 데이터 반환 → 삭제 X (top
함수로도 많이 쓴다.)push()
: 스택에 데이터 삽입pop()
: 스택에서 데이터 삭제하여 반환 → 삭제 Oisempty()
: 스택에 원소가 없으면 ‘True’ 있으면 ‘False’ 반환isfull()
: 스택에 원소가 가득하면 ‘True’ 가득하지 않으면 ‘False’ 반환※ 스택 정적/동적 구현 → 1차원 배열/링크드 리스트 (C언어)
(출처 : https://roi-data.com/entry/자료구조-4-스택Stack이란-연산-구현방법)
# 1. 스택 생성
stack = [None for _ in range(MAX_SIZE = 5)]
top_pointer = -1
stack_size = 4
# 2. 스택 삽입 (push)
def push(n)-> None:
global top_pointer
if top_pointer + 1 >= stack_size:
print("stack overflow")
exit()
else:
top_pointer += 1
stack[top_pointer] = n
# 3. 스택 삭제 (pop)
def pop() -> None :
global top_pointer
del stack[top_pointer]
top_pointer -= 1
# 4. 스택 검사 (isempty / isfull)
def isempty() -> bool:
global top_pointer
if top_pointer < 0:
return True
else:
return False
def isfull() -> bool:
global top_pointer
if top_pointer == stack_size:
return True
else:
return False
def peek() -> int:
global top_pointer
return stack[top_pointer]
class stack:
def __init__(self): # 1. 스택 객체 생성
self.items = []
def push(self, item): # 2. 스택 삽입 (push)
self.items.append(item)
def pop(self): # 3. 스택 삭제 후 리턴 (pop)
return self.items.pop()
def peek(self): # 4. 스택 맨 뒤 요소 리턴
return self.items[-1]
def isempty(self): # 5. 스택이 비었는지 확인 (비었으면 True 리턴)
return not self.items
stk = stack() # 스택 객체 생성
stk.push(1)
stk.push(2)
stk.push(3)
print(stk.items) # => [1,2,3]
print(stk.pop()) # => 3 (3 삭제하고 3을 출력함)
print(stk.items) # => [1,2]
print(stk.peek()) # => 2 (2 삭제 안 하고 2를 출력함)
print(stk.items) # => [1,2]
print(stk.pop())
print(stk.pop()) # 여기까지 해서, 스택은 빈 스택이 된다.
print(stk.isempty()) # 객체에 아무것도 없으므로 True 출력
print(stk.items) # 비어 있으므로 [] 출력
공식문서 : https://docs.python.org/3.8/library/collections.html#collections.deque
from collections import deque
dq = deque() # 덱 생성
dq.apppend() # 덱의 가장 오른쪽에 원소 삽입
dq.popleft() # 가장 왼쪽 원소 반환
dq.appendleft() # 덱의 가장 왼쪽에 원소 삽입
dq.pop() # 가장 오른쪽 원소 반환
dq.clear() # 모든 원소 제거
dq.copy() # 덱 복사
dq.count(X) # x와 같은 원소의 개수를 계산
앞서 살펴본 스택과의 비교를 통해 큐를 설명하면 다음과 같다.
Insert (데이터 삽입) : 스택
push
/ 큐enqueue
Delete (데이터 삭제) : 스택pop
/ 큐dequeue
- 스택이 push와 pop을 수행하기 위해 필요한 정보는 한 가지이다.
:top
→ 가장 마지막에 들어온 데이터의 위치(인덱스)만 알면 된다.- 큐가 enqueue와 dequeue를 수행하기 위해 필요한 정보는 두 가지이다.
:rear
와front
→ 어느 인덱스로 들어와야 하는지(rear - enqueue) / 어느 인덱스로 나가야 하는지 (front - dequeue)
객체 : items(list) / front_index(int)
※ rear와 front의 기능 모두front_index
로 관리할 수 있다.
연산 : 큐 초기화 / enqueue / dequeue
class Queue:
def __init__(self):
self.items = [] # 빈 리스트
self.front_index = 0
def enqueue(self, val):
self.items.append(val)
def dequeue(self): # 리스트의 데이터 삭제 대신 인덱스의 이동으로 처리한다.
if self.front_index == len(self.itmes):
print("Queue is empty")
return None
else:
x = self.items[front_index]
front_index += 1
return x
보통은 큐를 구현할 때, 스택과 같이 '프링글스 통'으로 구현한다.
즉, 일자형 리스트(배열)의 형태로 큐의 구조와 기능을 정의하는데, 이를 '선형 큐'라고 한다.
그러나 일부 상황에서는 선형 큐가 단점을 갖기도 한다.
(길이가 고정된 리스트에서) 큐의 삽입과 삭제를 반복하다 보면, rear
는 마지막 인덱스(n-1)에 위치하게 되고 이때 isFull()
를 호출하면 True를 반환한다. front가 0이라면 맞는 리턴이지만, 만약 front가 0이 아닌 다른 인덱스에 위치한다면 틀린 리턴이다. 즉, 오류다.
이를 그림으로 이해해 보자면 다음과 같다.
오류가 생기는 이유는,
데이터가 dequeue 될 때마다 남은 원소들을 한 칸씩 앞으로 이동시키지 않고, front 포인터만 이동 시켜 큐의 연산을 진행했기 때문이다. (즉, front 앞에 있는 값들은 없는 걸로 치부한다.)
이와 같은 선형 큐의 단점을 보완하기 위해 나온 것이 바로 '원형 큐'이다.
(즉, 데이터 dequeue 후 기존의 데이터 이동 없이도, dequeue 된 공간을 지속적으로 활용할 수 있는 방안에 대한 해결책으로 원형 큐가 등장하였다.)
원형 큐는 선형 큐와 마찬가지로 1차원 배열(리스트)를 사용하지만, 추상적으로 원형의 구조를 가진다고 생각하면 된다.
원형 큐의 특징과 연산을 정리해 보자면,
- 원형 큐도 1차원 배열(리스트)로 구현한다.
front
와rear
모두 첫 인덱스부터 시작한다.
front
는 비어 있는 위치(인덱스)를 나타낸다.rear
는 top에 있는 원소의 위치(인덱스)이다.- isEmpty() / isFull() 함수가 필요 없다.
→ 데이터 삽입, 삭제 시 큐가 full 혹은 empty인지 먼저 확인할 수 있도록 한다.- 입력 데이터의 크기가 n이라면, 1차원 배열의 크기를 n + 1로 설정한다.
- 데이터의 삽입(enqueue) 시, rear가 +1이 되고 → 해당 인덱스에 데이터를 넣는다.
rear = (rear + 1) % (n + 1)
→ 0 ~ n + 1 범위에서 rear가 1씩 증가하는 것- 만약
(1 증가된) rear == front
라면, 큐는 Full이다.- 데이터의 삭제(dequeue) 시, front가 +1이 되고 → 해당 인덱스에 있는 데이터를 별도로 떼어 놓는다. (front가 위치한다는 것은 빈칸이라는 의미)
front = (front + 1) % (n + 1)
→ 0 ~ n + 1 범위에서 front가 1씩 증가하는 것- 만약
rear == front
라면, 큐는 Empty이다.
원형 큐는 다음과 같이 구현할 수 있다.
1) CircularQueue.py
n = 10 # 입력 데이터의 크기가 10이라고 가정한다.
queue = [0] * (n+1)
front, rear = 0, 0
def enQueue(data: int) -> None:
global front, rear
if (rear+1) % (n+1) == front:
print("Q if full")
return
rear = (rear+1) % (n+1)
queue[rear % (n+1)] = data
def deQueue() -> None:
global front, rear
if rear == front:
print("Q is empty")
return
data = queue[(front+1) % (n+1)]
front = (front+1) % (n+1)
def printQueue() -> None:
global front, rear
idx = (front+1) % (n+1)
while True:
if front == rear:
print('Q is empty')
break
elif idx > rear:
break
idx += 1
print(f"{queue[(idx) % (n+1)]} ")
2) CircularQueue.cpp
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>
int count = 0;
int n;
int front = 0, rear = 0;
int* queue;
void enQueue(int data) {
if((rear+1) % (n+1) == front){
printf("Q is full\n");
return;
}
rear = (rear+1) % (n+1);
queue[rear % (n+1)] = data;
}
int deQueue() {
int data;
if(front == rear){
printf("Q is empty\n");
return;
}
data = queue[(front+1) % (n+1)];
front = (front+1) % (n+1);
}
void printQueue() {
int idx = 0;
idx = (front+1) % (n+1);
do{
if(front == rear){
printf("Q is empty\n");
break;
}
else if(idx > rear) break;
printf("%d ", queue[idx++ % (n+1)]);
} while(1);
}
int main(){
printf("원형 큐의 크기 입력: ");
scanf("%d", &n);
queue = (int*)malloc(sizeof(int) * (n+1));
while(1) {
int menu, data;
printf("\n1. 삽입, 2. 삭제, 3. 출력, 4. 종료\n");
scanf("%d", &menu);
switch(menu)
{
case 1:
printf("삽입할 데이터 입력: ");
scanf("%d", &data);
enQueue(data);
break;
case 2:
deQueue();
break;
case 3:
printQueue();
break;
case 4:
exit(1);
break;
}
}
return 0;
}
“우선순위 큐는 힙의 구조를 가진다.”
(스택과 큐가 ‘리스트’라면, 우선순위 큐는 ‘힙’의 구조로 생각하면 된다.)
※ 힙이란?
- 완전이진트리의 형태로 이루어져 있다.
- 부모노드와 서브트리간 대소 관계가 성립된다.
- 이진탐색트리(BST)와 달리 중복된 값이 허용된다.
- ★ 이진탐색트리(BST)와 달리, 왼쪽/오른쪽 대소관계가 필요 없다.
(부모노드가 양쪽 자식보다는 커야 함 + 왼쪽 자식이 오른쪽 자식보다 더 커도 됨)- 최대힙 : 부모노드의 키 값이 자식노드의 키 값보다 크거나 같은 완전이진트리
- 최소힙 : 부모노드의 키 값이 자식노드의 키 값보다 작거나 같은 완전이진트리
(우선순위 큐를 힙 외에도 배열과 리스트로 구현할 수도 있다. 다만, 시간복잡도가 저리 되다 보니..힙을 사용한다.)
우선순위 큐의 ADT (클래스)
객체 : 우선순위를 가진 요소들의 모임
연산
1. insert(x) : 우선순위 큐에 요소 x 추가
2. remove() : 우선순위 큐에서 가장 우선순위가 높은 요소를 삭제하고 반환
3. find() : 우선순위 큐에서 가장 우선순위가 높은 요소를 반환
(공부하면서 느낀 거지만, 힙 정렬과 완전히 맞닿아 있는 것 같다.)
삽입 연산 : insert(x)
이때, 삽입 연산의 시간복잡도는 O(logN)이다. (최악의 경우, 루트 노드까지 올라가야 하므로)
삭제연산 : remove()
힙 트리에서 루트 노드가 가장 우선순위가 높으므로 루트 노드부터 삭제한다.
루트 노드를 삭제하면, 남은 노드들을 가지고 힙 트리를 유지하기 위해 재정렬을 시도한다.
삽입 연산과 마찬가지로, 삭제 연산 또한 시간복잡도가 O(logN)이 나온다.
👶 자식 노드의 인덱스를 구하는 방법은
🤱 부노 노드의 인덱스를 구하는 방법은
로 쉽게 구할 수 있다. (힙 소트에서 힙을 정렬할 때 많이 사용한다.)
우선순위 큐가 완전이진트리이다 보니, 중간 중간 비어 있는 요소가 없기 때문에 주로 배열(리스트)로 구현한다.
힙의 삭제/삽입은 각각 O(logN)이 걸린다고 했다.
따라서, 힙 정렬은 n개의 원소에 대해 n번의 삽입과 n번의 삭제가 이뤄지므로 정렬에 필요한 시간 복잡도는
logN + logN에 비례한다고 할 수 있다.
추가로, 최대값/최대값 찾을 때 → 최대힙/최소힙 사용해서 루트만 꺼내면 된다. O(1)
더 자세한 설명은 👉 WEEK01 WIL 2-3.정렬 여기에 정리해 놓았다.
우선순위 큐를 쉽게 구현하는 방법으로는 PriorityQueue
모듈을 사용하는 방법이 있다.
from queue import PriorityQueue
que = PriorityQueue()
que = PriorityQueue(maxsize=10) # 디폴트 사이즈는 무한대이다. maxsize로 최대 크기를 지정할 수 있다.
put()
que.put(4)
get()
que.get() # get()은 삭제된 원소를 리턴하므로, print()로 씌어서 삭제되는 원소를 출력할 수 있다.
put
(삽입) / get
(삭제) 할 수 있다.que.put((3, '장서영'))
que.put((2, '짱구'))
que.put((1, '뽀로로'))
print(que.get()[1]) # 뽀로로
print(que.get()[1]) # 짱구
print(que.get()[1]) # 장서영
추가로, 백준 문제를 풀면서 더 알게 된 것은
.empty()
를 호출하면 된다. (비어 있을 시, True 반환)que.put((-num, num))
삽입 → que.get()[1]
삭제heqpq 모듈을 사용해 우선순위 큐를 빠르게 사용할 수 있다.
-> https://docs.python.org/ko/3/library/heapq.html
추가로, PriorityQueue와 Heapq 모듈을 비교해 놓았다.
-> https://slowsure.tistory.com/130
pop()
은 del 혹은 remove를 하지 말 것.. (데이터 자체를 건드리기보다는 포인터로 처리하는 게 더 좋다고 한다.)