이번 포스트에서는 프로그래밍이라는 개념이 탄생할 때부터 사용된 가장 고전적인 자료구조 중 하나인 스택과 큐에 대해 알아보자. 그 중 스택은 거의 모든 애플리케이션을 만들 때 사용되는 자료구조다.
스택과 큐는 Delete 연산에 의해 집합에서 삭제되는 원소가 미리 결정되어 있는 동적 집합이다. 스택에서는 가장 최근에 삽인된 원소가 삭제된다. 이와 유사하게 큐에서는 집합에서 가장 오랜 시간 동안 존재한 원소가 삭제된다.
파이썬은 스택 자료형을 별도로 제공하지 않지만, 리스트가 사실상 스택의 모든 연산을 지원한다. 큐 또한 마찬가지다. 다만 리스트는 동적 배열로 구현됭 있어 큐의 연산을 수행하기에는 효율적이지 않기 때문에, 큐르 ㄹ위해서는 데크(deque)라는 별도의 자료형을 사용해야 좋은 성능을 낼 수 있다. 굳이 성능을 고려하지 않는다면 리스트를 잘 사용하기만 해도 충분하다.
스택은 데이터를 임시 저장할 때 사용하는 자료구조로, 데이터의 입력과 출력 순서는 후입선출(FILO)방식이다. 데이터를 제한적으로 접근할 수 있는 구조이고, 한쪽 끝에서만 자료를 넣거나 뺄 수 있는 구조이다.
스택은 콜 스택(call stack)이라 하여 컴퓨터 프로그램의 서브루틴에 대한 정보를 저장하는 자료구조에도 널리 활용된다. 컴파일러가 출력하는 에러도 스택처럼 맨 마지막 에러가 가장 먼저 출력되는 순서를 보인다. 또한 스택은 메모리 영역에서 LIFO 형태로 할당하고 접근하는 구조인 아키텍처 레벨의 하드웨어 스택의 이름으로도 널리 사용된다. 이외에도 꽉 찬 스택에 요소를 삽입하고자 할 때 스택에 요소가 넘쳐서 에러가 발생하는 것을 스택 버퍼 오버플로(stack buffer overflow)라고 부른다. 질의응답 서비스 사이트인 스택오버플로의 명칭도 여기서 유래했다.
스택은 거의 모든 애플리케이션을 만들 때 사용되는 자료구조로서, 스택과 연관된 알고리즘을 제대로 이해하느냐 못하느냐에 따라 기본 알고리즘을 설계할 수 있느냐 없느냐가 결정되기도 한다.
스택의 경우 단순하고 빠른 성능을 위해 사용되므로, 보통 배열 구조를 활용해서 구현하는 것이 일반적이다. 이경우, 위에서 열거한 단점이 있을 수 있다.
푸시한 데이터를 저장하는 스택 본체인 list형 배열, 인덱스가 0인 원소를 스택의 바닥이라고 한다.
스택의 최대 크기를 나타내는 int형 정수이다. 이 값은 배열 stk의 원소 수인 len(stk)와 일치한다.
스택에 쌓여 있는 데이터의 개수를 나타내는 정숫값을 스택 포인터라고 한다.
Fixed Stack 클래스를 사용하여 스택 작업을 수행하는 경우 스택 포인터 ptr값은 반드시 0 이상이거나 capcity이하가 된다. 따라서 is_empty(), is_full() 함수는 <,>= 연산자가 아니라 ==를 사용해서 다음과 같이 정의할 수도 있다.
def is_empty(self) -> bool:
return self.ptr == 0
def is_full(self) -> bool:
return self.ptr == self.capacity
하지만 프로그램 오류 등으로 ptr 값이 0보다 작아지거나 capacity보다 커질 가능성도 있으므로 이 방법은 추천하지 않는다. 부등호로 판단하면 스택 배열의 최솟값과 최댓값에서 벗어나 접근하는 것을 막을 수 있다. 이렇게 코드를 간단하게 설계하는 것만으로도 오류 없이 잘 작동할 수 있도록 한다.
pop() 함수 또는 peek() 함수를 호출할 때 스택이 비어 있으면 내보내는 예외 처리이다.
STACK-EMPTY(S)
if S.top == 0
return True
else return False
push() 함수를 호출할 때 스택이 가득 차 있으면 내보내는 예외 처리이다.
스택 배열을 생성하는 등의 준비 작업을 수행한다. 매개변수 capcity로 전달받은 값을 스택의 크기를 나타내는 필드인 capacity로 복사하여, 원소 수가 capacity이고 모든 원소가 None인 리스트형 stk를 생성한다. 스택이 비어 있으므로 스택 포인터의 값을 0으로 한다.
스택에 쌓여 있는 데이터 개수를 반환한다. 여기서는 스택 포인터 값을 그대로 반환한다.
비어 있으면 True, 아니면 False 반환
가득 차 있으면 True, 아니면 False 반환
from typing import Any
class FixedStack:
class Empty(Exception):
pass
class Full(Exception):
pass
def __init(self, capcity: int = 256) -> None:
self.stk = [None] * capacity
self.capacity = capacity
self.ptr = 0
def __len__(self):
return self.ptr
def is_empty(self) -> bool:
return self.ptr <= 0
def is_full(self) -> bool:
return self.ptr >= self.capacity
스택에 데이터를 추가한다. 스택이 가득 차 더 이상 푸시할 수 없는 경우 FixedStack.Full을 통해 예외 처리를 내보낸다.
PUSH(S, x)
S.top = S.top+1
S[S.top] = x
스택의 꼭대기에서 데이터를 꺼내서 그 값을 반환한다. 스택이 비어서 팝할 수 없는 경우에는 FixedStack.Empty를 통해 예외 처리를 내보낸다.
POP(S)
if STACK-EMPTY(S)
error"스택 부족"
else S.top = S.top-1
return S[S.top+1]
스택의 꼭대기 데이터(다음에 팝하는 데이터)를 들여다본다. 스택이 비어 있는 경우 FixedStack.Empty를 통해 예외 처리를 내보낸다.
스택에 쌓여 있는 데이터를 모두 삭제하여 빈 스택을 만든다. 스택 포인터 값을 0으로 하면 끝난다.
def push(self, value: Any) -> None:
if self.is_full():
raise FixedStack.FUll
self.stk[ptr] = value
self.ptr += 1
def pop(self) -> Any:
if self.is_empty():
raise FixedStack.Empty
ptr -= 1
return self.stk[ptr]
def peek(self) -> Any:
if self.is_empty():
raise FixedStack.Empty
return self.stk[ptr-1]
def clear(self) -> None:
self.ptr = 0
스택 본체의 배열 stk 안에 value와 값이 같은 데이터가 포함되어 있는지 확인하고, 포함되어 있다면 배열의 어디에 들어 있는지를 검색한다.
스택에 쌓여 있는 데이터(value)의 개수를 구하여 반환한다.
스택에 데이터(value)가 있는지 판단한다. 있으면 True, 없으면 False를 반환한다. 멤버십 판단 연산자(membership test operator)인 in을 사용할 수 있다.
스택에 쌓여 있는 ptr개의 모든 데이터를 바닥부터 꼭대기까지 순서대로 출력한다. 스택이 비어 있으면 '스택이 비어 있습니다.'를 출력한다.
def find(self, value: Any) -> int:
for i in range(self.ptr-1, -1, -1):
if self.stk[i] == value
return i
return -1
def count(self, value: Any) -> int:
c = 0
for i in range(self.ptr)
if self.stk[i] == value:
c += 1
return c
def __contains__(self, value: Any) -> bool:
if self.find(value) == -1:
return False
else:
return True
def dump(self) -> None:
if self.is_empty():
print("스택이 비어 있습니다.")
else:
print(self.stk[:self.ptr])
밑줄 2개(__)인 더블 언더스코어를 줄여서 던더라고 한다. 던더 함수로 len()함수와 contains() 함수를 정의하면 len(obj), x in obj로 사용할 수 있다.
스택과 같이 데이터를 임시 저장하는 자료구조이다. 하지만 스택처럼 가장 나중에 넣은 데이터를 가장 먼저 꺼내지 않는다. 가장 먼저 넣은 데이터를 가장 먼저 꺼내는 선입선출(FIFO) 구조이다.
멀티 태스킹을 위한 프로세스 스케줄링 방식을 구현하기 위해 많이 사용됨(운영체제 참조)
인큐 시 맨 끝에 넣고, 디큐 시 가장 앞에서 꺼낸 후 모든 원소를 하나씩 앞으로 옮긴다. 넣을 땐 처리 복잡도가 O(1)이지만, 꺼낼 때는 O(n)이다.
링버퍼 : 배열 맨 끝의 원소 뒤에 맨 앞의 원소가 연결되는 자료구조
어떤 원소가 맨 앞 원소이고, 끝 원소인지 식별하는 변수가 각각 front, rear이다.
링버퍼로 구현 시 꺼낼 때, 넣을 때 모두 처리 복잡도가 O(1)이다.
스택과 같음
큐 배열을 생성하는 등의 준비 작업을 하며 다음과 같이 5개의 변수에 값을 설정한다.
스택과 같음
from typing import Any
class FixedQueue:
class Empty(Exception):
pass
class Full(Exception):
pass
def __init__(self, capacity: int) -> None:
self.no = 0
self.front = 0
self.rear = 0
self.capcity = capacity
self.que = [None] * self.capacity
def __len__(self) -> int:
return self.no
def is_empty(self) -> bool:
return self.no <= 0
def is_full(self) -> bool:
return self.no >= self.capacity
큐에 데이터를 인큐한다. 큐가 가득 차서 인큐할 수 없는 경우 예외 처리인 FixedQueue.Full을 내보낸다.
ENQUEUE(Q,x)
Q[Q.tail] = x
if Q.tail == Q.length
Q.tail = 1
else Q.tail = Q.tail + 1
def enque(self, x: Any) -> None:
if self.is_full():
raise FixedQueue.FUll
self.que[self.rear] = x
self.rear += 1
self.no += 1
if self.rear == self.capacity:
self.rear = 0
큐의 맨 앞부터 데이터를 디큐하여 그 값을 반환한다. 그러나 큐가 비어 있어 디큐할 수가 없는 경우 예외 처리인 FixedQueue.Empty를 내보낸다.
DEQUEUE(Q)
x = Q[Q.head]
if Q.head == Q.length
Q.head = 1
else Q.head = Q.head + 1
return x
def deque(self) -> Any:
if self.is_empty():
raise FixedQueue.Empty
x = self.que[self.front]
self.front += 1
self.no -= 1
if self.front == self.capacity:
self.front = 0
return x
스택과 같음
def peek(self) -> Any:
if self.is_empty():
raise FixedQueue.Empty
return self.que[self.front]
def find(self, vlaue: Any) -> Any:
for i in range(self.no):
idx = (i + self.front) % self.capacity
if self.que[idx] == value:
return idx
return -1
def count(self, value: Any) -> int:
c = 0
for i in range(self.no):
idx = (i + self.front) % self.capacity
if self.que[idx] == value:
c += 1
return -1
def __contains__(self, value: Any) -> bool:
return self.count(value) > 0
def clear(self) -> None:
self.front = self.rear = self.no = 0
def dump(self) -> None:
if self.is_empty():
print("큐가 비었습니다.")
else:
for i in range(self.no)
print(self.que[(i+self.front)%self.capacity], end='')
print()
링 버퍼는 오래된 데이터를 버리는 용도로 활용할 수 있다. 예를 들어 원소 수가 n인 배열에 데이터를 계속해서 입력할 때 가장 최근에 들어온 데이터 n개만 저장하고 나머지 오래된 데이터는 버리는 경우에 이용하는 경우이다.
n = int(input())
a = [None] * n
cnt = 0
while True:
a[cnt % n] = int(input())
cnt += 1
retry = input("n")
if retry in {"N", "n"}
break
i = cnt - n
if i < 0: i = 0
while i < cnt:
print(a[i%n])
i += 1