• data-structure
  • python

스택과 큐: 원형 큐 구현과 덱

스택·큐·덱의 특징을 정리하고, 선형 큐의 빈 공간 문제를 해결하는 원형 큐를 파이썬 배열로 구현한다.

시리즈 · 자료구조 · 알고리즘4 / 13
  1. 시간 복잡도와 공간 복잡도
  2. 재귀 함수와 꼬리 재귀 최적화
  3. 연결 리스트(Linked List)
  4. 스택과 큐: 원형 큐 구현과 덱
  5. 해시 테이블: 해시 함수와 충돌 처리
  6. 트리와 이진 탐색 트리
  7. 힙(Heap)과 우선순위 큐
  8. 균형 이진 탐색 트리: AVL, Red-Black, B-Tree
  9. 그래프: 인접 행렬과 인접 리스트
  10. 최단 경로 알고리즘: BFS, 다익스트라, 벨만-포드, 플로이드-워셜, A*
  11. 최소 신장 트리(MST): Kruskal, Prim, Union-Find
  12. 정렬 알고리즘: 퀵·병합·기수 정렬부터 Timsort까지
  13. Thread-Safe 자료구조: Python GIL과 Java ConcurrentHashMap

스택(Stack)

LIFO(Last In First Out) 구조이다. 자료를 정해진 한 방향으로만 쌓을 수 있다.

  • top을 통해서만 접근할 수 있고, top은 가장 최근에 추가된 자료를 가리킨다.
  • 사용 예시: 브라우저 방문 기록의 뒤로 가기, 실행 취소

배열로 구현한 스택의 push는 정말 O(1)인가

배열로 스택을 구현하면 push와 pop이 O(1)이라고 한다. 그런데 배열이 꽉 차면 더 큰 배열을 새로 할당해 전체를 복사해야 한다. 이 경우에는 O(n)이 아닐까?

그 한 번의 push만 보면 O(n)이 맞다. 하지만 배열 크기를 두 배씩 늘리면 재할당은 드물게 일어나고, n번의 push에 든 전체 복사 비용을 합쳐도 O(n)이다. 이를 push 한 번당으로 나누면 평균 O(1)이 되며, 이렇게 계산한 것을 분할 상환(amortized) O(1) 이라고 한다.

큐(Queue)

FIFO(First In First Out) 구조이다. 한쪽에서는 삽입이, 다른 한쪽에서는 삭제가 이루어진다.

  • 프론트(Front): 삭제 연산만 일어나는 곳
  • 리어(Rear): 삽입 연산만 일어나는 곳
  • 인큐(enQueue): 리어에서 일어나는 삽입 연산
  • 디큐(deQueue): 프론트에서 일어나는 삭제 연산
  • 사용 예시: 들어온 순서대로 처리해야 하는 작업

원형 큐(Circular Queue)

원형 큐는 선형 큐의 단점을 보완한 자료구조이다.

배열로 만든 선형 큐에서는 rear가 배열의 마지막 인덱스에 도달하면, 앞쪽에 디큐로 생긴 빈 공간이 있어도 활용할 수 없다. 원형 큐는 배열의 끝과 처음을 이어 붙여 이 공간을 다시 사용한다.

  • 핵심은 index % max_size로 front와 rear의 인덱스를 조정하는 것이다.
  • 크기가 고정되어 있으므로 가득 차면 더 넣을 수 없다.

구현

LeetCode 622번(Design Circular Queue) 문제를 《파이썬 알고리즘 인터뷰》를 참고해 배열로 구현했다.

가장 막혔던 부분은 isEmpty와 isFull의 조건이다. 두 경우 모두 front_idx == rear_idx가 되므로 인덱스만으로는 구분할 수 없다. 그 위치의 값이 None인지까지 확인해야 비어 있는 상태와 가득 찬 상태를 구분할 수 있다.

class MyCircularQueue:

    def __init__(self, k: int):
        self.max_size = k
        self.array = [None] * k
        self.front_idx = 0
        self.rear_idx = 0

    def enQueue(self, value: int) -> bool:
        if self.array[self.rear_idx] is None:
            self.array[self.rear_idx] = value
            self.rear_idx = (self.rear_idx + 1) % self.max_size
            return True
        else:
            return False

    def deQueue(self) -> bool:
        if self.array[self.front_idx] is None:
            return False
        else:
            self.array[self.front_idx] = None
            self.front_idx = (self.front_idx + 1) % self.max_size
            return True

    def Front(self) -> int:
        if self.array[self.front_idx] is None:
            return -1
        else:
            return self.array[self.front_idx]

    def Rear(self) -> int:
        if self.array[self.rear_idx - 1] is None:
            return -1
        else:
            return self.array[self.rear_idx - 1]

    def isEmpty(self) -> bool:
        return self.front_idx == self.rear_idx and self.array[self.front_idx] is None

    def isFull(self) -> bool:
        return self.front_idx == self.rear_idx and self.array[self.front_idx] is not None

덱(Deque)

Double-Ended Queue의 줄임말로, 양쪽 끝 모두에서 삽입과 삭제가 가능한 자료구조이다.

양쪽 끝에서 삽입과 삭제가 모두 가능한 덱 구조

C++ deque의 임의 접근이 O(1)인 이유

덱을 이중 연결 리스트로 구현하면 임의 접근(Random Access)이 O(n)이지만, 동적 배열 기반으로 구현하면 O(1)에 가능하다.

C++의 std::deque는 데이터를 여러 개의 작은 고정 크기 배열에 나누어 저장하고, 앞이나 뒤에 공간이 필요해지면 작은 배열을 추가로 할당한다. 그리고 각 작은 배열을 가리키는 포인터들을 별도의 동적 배열로 관리한다.

인덱스가 주어지면 “몇 번째 작은 배열의 몇 번째 칸인지”를 계산으로 바로 구할 수 있으므로 O(1)에 접근할 수 있다.

Reference