← Cấu trúc dữ liệu & Giải thuật← Data Structures & Algorithms
Cấu trúc dữ liệu & Giải thuậtData Structures & Algorithms7 Th8, 2026Aug 7, 202627 phút đọc23 min read

Stack & QueueStacks & Queues

Mục lục
  1. Tổng quan
  2. Kiến thức nền tảng
  3. Stack
  4. Stack dựa trên array
  5. Stack dựa trên linked list
  6. Queue
  7. Queue dựa trên array kiểu ngây thơ, và vì sao nó hỏng
  8. Circular buffer (ring buffer)
  9. Queue dựa trên linked list
  10. Tổng hợp độ phức tạp
  11. collections.deque — thứ bạn thực sự dùng
  12. Khái niệm chính
  13. Call stack
  14. DFS so với BFS chỉ là đổi stack thành queue
  15. Dấu ngoặc cân bằng — bài toán stack kinh điển
  16. Tính biểu thức
  17. Monotonic stack
  18. Hai stack tạo thành một queue
  19. Chúng xuất hiện ở đâu trong hệ thống thật
  20. Best Practices
  21. Tài liệu tham khảo
Table of contents
  1. Overview
  2. Fundamentals
  3. The stack
  4. Array-backed stack
  5. Linked-list-backed stack
  6. The queue
  7. The naive array queue, and why it is broken
  8. The circular buffer (ring buffer)
  9. Linked-list-backed queue
  10. Complexity summary
  11. collections.deque — what you actually use
  12. Key Concepts
  13. The call stack
  14. DFS versus BFS is a stack-versus-queue swap
  15. Balanced parentheses — the classic stack problem
  16. Expression evaluation
  17. Monotonic stacks
  18. Two stacks make a queue
  19. Where these show up in real systems
  20. Best Practices
  21. References

Thuộc bộ kiến thức Data Structures & Algorithms Roadmap.

Tổng quan

Stack và queue là hai cấu trúc được định nghĩa không phải bởi cách chúng lưu dữ liệu mà bởi những gì chúng từ chối cho bạn làm. Cả hai đều chứa một dãy; cả hai đều cho bạn đúng một chỗ để thêm và một chỗ để lấy ra; không cái nào cho bạn thò tay vào giữa. Chính sự hạn chế đó là toàn bộ giá trị của chúng — bằng cách từ bỏ random access, bạn nhận được một cấu trúc mà mọi thao tác đều O(1), hành vi dễ dự đoán một cách hiển nhiên, và bản thân bảo đảm về thứ tự (LIFO hoặc FIFO) chính là thuật toán trong một số lượng bài toán đáng ngạc nhiên.

Chúng là abstract data type, không phải một layout cụ thể — interface là push/pop/peekenqueue/dequeue/peek, và bạn có thể lót phía dưới bằng array hoặc linked list. Sự phân biệt giữa interface và implementation, được triển khai trong cấu trúc dữ liệu là gì, thể hiện rõ nhất ở đây: cùng một stack ADT được hiện thực bởi một list Python, bởi một linked list, bởi một array C cố định cộng một số nguyên, và bởi chính thanh ghi %rsp của CPU.

Note này dựng cả hai từ đầu theo cả hai cách biểu diễn, đi qua circular buffer — thứ làm cho queue dựa trên array thực sự là O(1) — và bao quát các mẫu hình mà chúng cung cấp sức mạnh: kiểm tra dấu ngoặc cân bằng, tính biểu thức, DFS so với BFS, monotonic stack, và deque.

Kiến thức nền tảng

Stack

   push(4)                 pop() -> 4
      |                        ^
      v                        |
   +-----+                  +-----+
   |  4  | <- top           |  3  | <- top
   +-----+                  +-----+
   |  3  |                  |  2  |
   +-----+                  +-----+
   |  2  |                  |  1  |
   +-----+                  +-----+
   |  1  | <- bottom        
   +-----+

   Mọi truy cập đều xảy ra ở một đầu. Đáy không thể chạm tới
   nếu không pop hết mọi thứ phía trên.

Các thao tác, tất cả đều O(1):

Thao tácÝ nghĩaHành vi khi stack rỗng
push(x)Đặt x lên đỉnh
pop()Lấy ra và trả về đỉnhStack underflow — phải ném lỗi hoặc trả sentinel
peek() / top()Trả về đỉnh mà không lấy raUnderflow
is_empty()Có gì trong đó không
size()Đang có bao nhiêu phần tử

Một stack dung lượng cố định còn có thể overflow khi push. Đây không chỉ là chuyện sách vở: call stack phần cứng có kích thước cố định (thường 1–8 MB), và chạy vượt quá nó chính xác là StackOverflowError / RecursionError / segfault mà bạn nhận được từ đệ quy mất kiểm soát.

Stack dựa trên array

Cách hiện thực tự nhiên. Push và pop của stack đều xảy ra ở một đầu, và đầu rẻ của array là phía sau — nên đặt đỉnh stack ở cuối array và cả hai thao tác đều O(1) amortized mà không phải dịch chuyển gì cả.

class ArrayStack:
    """Stack lót bởi một dynamic array. CUỐI array là ĐỈNH stack —
    chính lựa chọn đó làm cho mọi thao tác là O(1)."""

    def __init__(self, iterable=()):
        self._data = []                            # list của Python CHÍNH LÀ dynamic array
        for item in iterable:
            self.push(item)

    def push(self, item):
        """O(1) amortized — chỉ O(n) ở lần resize hiếm hoi bên dưới."""
        self._data.append(item)

    def pop(self):
        """O(1) — không có gì dịch chuyển, ta chỉ giảm size."""
        if not self._data:
            raise IndexError("pop from empty stack")   # stack underflow
        return self._data.pop()

    def peek(self):
        """O(1) — nhìn mà không lấy ra."""
        if not self._data:
            raise IndexError("peek at empty stack")
        return self._data[-1]

    def is_empty(self):
        return len(self._data) == 0

    def __len__(self):
        return len(self._data)

    def __repr__(self):
        return f"ArrayStack(bottom -> top: {self._data})"


s = ArrayStack([1, 2, 3])
s.push(4)
print(s)              # ArrayStack(bottom -> top: [1, 2, 3, 4])
print(s.pop())        # 4
print(s.peek())       # 3

Đừng dựng stack theo chiều ngược lại. Nếu bạn lấy index 0 làm đỉnh, push trở thành insert(0, x)pop trở thành pop(0) — cả hai đều O(n), vì mọi phần tử phải dịch chuyển. Lựa chọn đầu nào làm đỉnh chính là toàn bộ khác biệt giữa O(1)O(n). Đây là cách phổ biến nhất khiến người ta vô tình viết ra một stack bậc hai trong Python.

Stack dựa trên linked list

Push và pop ở head của một singly linked list đều O(1) mà không cần amortization và không có cú tăng vọt do resize:

class LinkedStack:
    """Stack lót bởi một singly linked list. Push/pop tại HEAD — cả hai O(1) worst case,
    không có cú tăng vọt do resize, đổi lại một pointer + một object header mỗi phần tử."""

    class _Node:
        __slots__ = ("value", "next")

        def __init__(self, value, next=None):
            self.value = value
            self.next = next

    def __init__(self, iterable=()):
        self._head = None                          # head là đỉnh của stack
        self._size = 0
        for item in iterable:
            self.push(item)

    def push(self, item):
        """O(1) worst case — không bao giờ phải copy."""
        self._head = self._Node(item, self._head)
        self._size += 1

    def pop(self):
        """O(1) worst case."""
        if self._head is None:
            raise IndexError("pop from empty stack")
        node = self._head
        self._head = node.next
        node.next = None                           # tách ra để tham chiếu cũ không đi tiếp được
        self._size -= 1
        return node.value

    def peek(self):
        if self._head is None:
            raise IndexError("peek at empty stack")
        return self._head.value

    def is_empty(self):
        return self._size == 0

    def __len__(self):
        return self._size


s = LinkedStack([1, 2, 3])
print(s.pop(), len(s))    # 3 2
Dựa trên arrayDựa trên linked list
pushO(1) amortized, O(n) worstO(1) worst case
pop / peekO(1)O(1)
Bộ nhớ mỗi phần tử8 byte (pointer) + phần dư~56 byte trong CPython
Hành vi cacheXuất sắc (liên tục)Kém (đuổi theo pointer)
Tăng vọt latencyCó — lần copy khi resizeKhông
Kết luậnLựa chọn mặc địnhChỉ khi cú tăng vọt do resize là không chấp nhận được (hard real-time)

Trong Python, cứ dùng list. appendpop vốn đã chính xác là các thao tác stack, chúng được viết bằng C, và nhanh hơn mọi thứ bạn có thể tự viết. Hãy dựng các class ở trên để hiểu chúng, còn production thì dùng list.

Queue

   enqueue ở PHÍA SAU, dequeue ở PHÍA TRƯỚC.

     dequeue <--- +-----+-----+-----+-----+ <--- enqueue
                  |  1  |  2  |  3  |  4  |
                  +-----+-----+-----+-----+
                  front                rear

   dequeue() -> 1, sau đó queue là [2, 3, 4] với front ở 2.
Thao tácÝ nghĩaHành vi khi queue rỗng
enqueue(x)Thêm x vào phía sau
dequeue()Lấy ra và trả về phần tử phía trướcQueue underflow
peek() / front()Trả về phần tử phía trước mà không lấy raUnderflow
is_empty(), size()

Queue dựa trên array kiểu ngây thơ, và vì sao nó hỏng

Cách hiện thực hiển nhiên — list.append để enqueue, list.pop(0) để dequeue — là O(n) mỗi lần dequeue, vì xoá index 0 làm mọi phần tử còn lại dịch sang trái một ô:

   Trước pop(0):  [ 1 | 2 | 3 | 4 ]
   Sau đó:        [ 2 | 3 | 4 |   ]     <- 3 phần tử bị di chuyển thật sự

Xử lý n phần tử qua một queue như vậy tốn O(n²). Đây là cách vô tình tạo ra thuật toán bậc hai phổ biến thứ hai trong Python, sau việc nối chuỗi trong vòng lặp, và nó xuất hiện liên tục trong các BFS viết tay.

Cách sửa ngây thơ còn lại — giữ một index front và không bao giờ dịch chuyển, chỉ đẩy nó tiến lên — làm dequeue thành O(1) nhưng rò rỉ bộ nhớ: các ô phía trước front không bao giờ được tái sử dụng, nên một queue đã đi qua một triệu phần tử vẫn giữ một triệu ô chết dù hiện tại nó chỉ chứa ba phần tử.

Circular buffer (ring buffer)

Cách sửa thật sự là để queue vòng lại qua cuối một array cố định. Giữ một index front và một biến count; phía sau được tính bằng (front + count) % capacity. Giờ cả hai đầu đều O(1) và vùng chết ở phía trước được thu hồi tự động.

   capacity = 8, front = 5, count = 5   ->  ô rear = (5 + 5) % 8 = 2

   index:    0     1     2     3     4     5     6     7
          +-----+-----+-----+-----+-----+-----+-----+-----+
          |  D  |  E  |     |     |     |  A  |  B  |  C  |
          +-----+-----+-----+-----+-----+-----+-----+-----+
                         ^                 ^
                  enqueue kế tiếp      front (dequeue kế tiếp)

   Thứ tự logic: A B C D E     — buffer đã vòng qua cuối array.
   enqueue(F) ghi vào index 2. dequeue() trả về A và đặt front = 6.
class CircularQueue:
    """Queue FIFO trên circular buffer: enqueue và dequeue O(1), không dịch chuyển,
    không rò rỉ ô chết. Nhân đôi khi đầy."""

    def __init__(self, capacity=8):
        self._data = [None] * capacity
        self._front = 0                            # index của phần tử phía trước
        self._count = 0                            # đang có bao nhiêu phần tử sống

    def __len__(self):
        return self._count

    def is_empty(self):
        return self._count == 0

    def enqueue(self, item):
        """O(1) amortized. Phép modulo chính là thứ làm buffer vòng lại."""
        if self._count == len(self._data):
            self._resize(2 * len(self._data))
        rear = (self._front + self._count) % len(self._data)
        self._data[rear] = item
        self._count += 1

    def dequeue(self):
        """O(1) — chỉ đẩy index front tiến lên; không gì di chuyển."""
        if self._count == 0:
            raise IndexError("dequeue from empty queue")
        item = self._data[self._front]
        self._data[self._front] = None             # bỏ tham chiếu để GC giải phóng được
        self._front = (self._front + 1) % len(self._data)
        self._count -= 1
        return item

    def peek(self):
        if self._count == 0:
            raise IndexError("peek at empty queue")
        return self._data[self._front]

    def _resize(self, new_capacity):
        """O(n). Trải buffer đang vòng ra một buffer mới bắt đầu từ index 0 —
        đây là lý do vòng copy phải đi qua modulo chứ không phải một lệnh slice đơn giản."""
        old = self._data
        self._data = [None] * new_capacity
        walk = self._front
        for i in range(self._count):
            self._data[i] = old[walk]
            walk = (walk + 1) % len(old)
        self._front = 0

    def __iter__(self):
        for i in range(self._count):
            yield self._data[(self._front + i) % len(self._data)]

    def __repr__(self):
        return f"CircularQueue(front -> rear: {list(self)})"


q = CircularQueue(capacity=4)
for x in "ABCD":
    q.enqueue(x)
print(q.dequeue(), q.dequeue())    # A B
q.enqueue("E")
q.enqueue("F")                     # hai phần tử này vòng vào ô 0 và 1
print(q)                           # CircularQueue(front -> rear: ['C', 'D', 'E', 'F'])

Hai chi tiết đáng để ý. Thứ nhất, theo dõi count thay vì một index rear tránh được sự nhập nhằng đầy-hay-rỗng kinh điển: nếu chỉ có frontrear thì front == rear vừa nghĩa là rỗng vừa nghĩa là đầy, và cách chữa thông thường là hy sinh một ô. Thứ hai, self._data[self._front] = None khi dequeue rất quan trọng trong ngôn ngữ có garbage collection — không có nó, buffer giữ một tham chiếu sống tới object đã dequeue mãi mãi, và đó là một memory leak thật sự rất khó tìm.

Circular buffer dung lượng cố định (không resize — chỉ từ chối hoặc ghi đè khi đầy) có mặt khắp nơi trong system code: buffer I/O âm thanh và mạng, ring buffer của kernel (dmesg), queue lock-free một-producer-một-consumer, và cửa sổ lưu trữ của một metrics agent. Bộ nhớ bị chặn ở đó là tính năng, không phải hạn chế.

Queue dựa trên linked list

Head là phía trước, tail là phía sau. Cả hai O(1), không giới hạn dung lượng, không resize:

class LinkedQueue:
    """Queue FIFO trên singly linked list: dequeue ở head, enqueue ở tail.
    Tail pointer là thiết yếu — không có nó, enqueue là O(n)."""

    class _Node:
        __slots__ = ("value", "next")

        def __init__(self, value):
            self.value = value
            self.next = None

    def __init__(self):
        self._head = None                          # phía trước
        self._tail = None                          # phía sau
        self._size = 0

    def enqueue(self, item):
        """O(1) worst case — nhờ tail pointer."""
        node = self._Node(item)
        if self._tail is None:
            self._head = self._tail = node
        else:
            self._tail.next = node
            self._tail = node
        self._size += 1

    def dequeue(self):
        """O(1) worst case."""
        if self._head is None:
            raise IndexError("dequeue from empty queue")
        node = self._head
        self._head = node.next
        if self._head is None:                     # queue vừa trở thành rỗng
            self._tail = None
        self._size -= 1
        node.next = None
        return node.value

    def peek(self):
        if self._head is None:
            raise IndexError("peek at empty queue")
        return self._head.value

    def __len__(self):
        return self._size

Tổng hợp độ phức tạp

Thao tácArray stackLinked stackCircular queueLinked queuecollections.deque
Push / enqueueO(1) amortizedO(1)O(1) amortizedO(1)O(1)
Pop / dequeueO(1)O(1)O(1)O(1)O(1) (cả hai đầu)
PeekO(1)O(1)O(1)O(1)O(1)
Truy cập index iO(1) (nhưng không thuộc ADT)O(n)O(1) (nhưng không thuộc ADT)O(n)O(n) ở giữa
Tìm kiếmO(n)O(n)O(n)O(n)O(n)
Bộ nhớO(n), dư ≤2×O(n), overhead ~7×O(n), dư ≤2×O(n), overhead ~7×O(n), theo block
Worst case một thao tácO(n) (resize)O(1)O(n) (resize)O(1)O(1) amortized

Mọi lựa chọn ở đây đều là O(1) đối với các thao tác mà ADT thực sự phơi ra. Việc chọn giữa chúng hoàn toàn là chuyện hằng số, bộ nhớ và tăng vọt latency — không bao giờ là chuyện tiệm cận.

collections.deque — thứ bạn thực sự dùng

Deque (double-ended queue, đọc là “deck”) cho phép push và pop ở cả hai đầu. Nó tổng quát hoá cả hai cấu trúc: dùng một đầu thì được stack, dùng cả hai đầu thì được queue. collections.deque của Python là câu trả lời production cho “tôi cần một queue”:

from collections import deque

# Dùng như queue (FIFO)
q = deque()
q.append("a")            # enqueue vào phía sau     O(1)
q.append("b")
print(q.popleft())       # dequeue từ phía trước    O(1) — KHÔNG phải O(n) như list.pop(0)

# Dùng như stack (LIFO)
st = deque()
st.append(1)             # push   O(1)
print(st.pop())          # pop    O(1)

# Có giới hạn: cửa sổ trượt kích thước cố định, tự loại bỏ ở đầu kia
last_three = deque(maxlen=3)
for x in [1, 2, 3, 4, 5]:
    last_three.append(x)
print(last_three)        # deque([3, 4, 5], maxlen=3)

# Xoay là O(k), hữu ích cho round-robin scheduling
d = deque([1, 2, 3, 4, 5])
d.rotate(2)
print(d)                 # deque([4, 5, 1, 2, 3])

Cái giá của nó: CPython hiện thực deque như một doubly linked list của các block cố định (64 pointer mỗi block). Bạn được O(1) ở cả hai đầu mà không có lần copy khi resize, và locality tạm ổn trong phạm vi một block — nhưng index vào giữa là O(n), nên d[len(d)//2] phải đi qua từng block. Nếu bạn cần cả hai đầu nhanh lẫn index nhanh, bạn cần một cấu trúc khác.

Hai thứ nữa trong thư viện chuẩn đáng nhắc tên: queue.Queue là một blocking queue thread-safe với lock và ngữ nghĩa join() — dùng cho producer/consumer giữa các thread, không dùng cho thuật toán, vì việc khoá làm nó chậm hơn nhiều. heapq cho ta một priority queue, tức queue sắp theo độ ưu tiên thay vì theo thứ tự đến, được bàn trong heap và priority queue.

Khái niệm chính

Call stack

Stack quan trọng nhất trong ngành máy tính là cái mà chương trình của bạn đang chạy trên đó. Mỗi lời gọi hàm push một stack frame chứa địa chỉ trả về, các thanh ghi được lưu, tham số và biến cục bộ; mỗi lần return pop nó ra. Kỷ luật LIFO đúng chính xác cho việc này: một hàm được gọi sau phải trả về trước hàm đã gọi nó.

   def a(): b()
   def b(): c()
   def c(): return 1

   Trong lúc c() chạy:           Sau khi c() trả về:
   +----------------+            +----------------+
   | frame for c()  | <- sp      | frame for b()  | <- sp
   +----------------+            +----------------+
   | frame for b()  |            | frame for a()  |
   +----------------+            +----------------+
   | frame for a()  |            | frame for main |
   +----------------+            +----------------+
   | frame for main |
   +----------------+

Những hệ quả đáng ghi nhớ:

def dfs_recursive(graph, start, visited=None):
    """Dùng CALL stack. Thanh lịch, nhưng bị giới hạn độ sâu."""
    if visited is None:
        visited = set()
    visited.add(start)
    for neighbour in graph[start]:
        if neighbour not in visited:
            dfs_recursive(graph, neighbour, visited)
    return visited


def dfs_iterative(graph, start):
    """Cùng một traversal với stack TƯỜNG MINH. Không giới hạn độ sâu, và biên duyệt
    là một biến thật mà bạn có thể kiểm tra, log hoặc checkpoint."""
    visited = set()
    stack = [start]
    while stack:
        node = stack.pop()                         # LIFO -> depth-first
        if node in visited:
            continue
        visited.add(node)
        for neighbour in graph[node]:
            if neighbour not in visited:
                stack.append(neighbour)
    return visited

DFS so với BFS chỉ là đổi stack thành queue

Đây là minh chứng rõ ràng nhất rằng container chính là thuật toán. Hai traversal trong cấu trúc dữ liệu đồ thị khác nhau đúng một dòng:

from collections import deque


def traverse(graph, start, use_stack):
    """Đổi LIFO thành FIFO và depth-first trở thành breadth-first. Không gì khác thay đổi."""
    frontier = deque([start])
    visited = {start}
    order = []
    while frontier:
        node = frontier.pop() if use_stack else frontier.popleft()   # <-- khác biệt duy nhất
        order.append(node)
        for neighbour in graph[node]:
            if neighbour not in visited:
                visited.add(neighbour)
                frontier.append(neighbour)
    return order


graph = {"A": ["B", "C"], "B": ["D", "E"], "C": ["F"], "D": [], "E": ["F"], "F": []}
print(traverse(graph, "A", use_stack=True))    # DFS: ['A', 'C', 'F', 'B', 'E', 'D']
print(traverse(graph, "A", use_stack=False))   # BFS: ['A', 'B', 'C', 'D', 'E', 'F']

Thứ tự FIFO của BFS chính là thứ khiến nó tìm ra đường đi ngắn nhất trên đồ thị không trọng số — các node đi ra theo thứ tự khoảng cách không giảm dần tính từ nguồn, nên lần đầu tiên bạn tới được một node là qua một đường đi ngắn nhất. Stack không cho bảo đảm nào như vậy. Đó là lý do popleft() chứ không phải pop() mới quan trọng ở đây, và lý do dùng list.pop(0) thay cho deque.popleft() lặng lẽ biến một BFS O(V + E) thành O(V² + VE).

Dấu ngoặc cân bằng — bài toán stack kinh điển

Lý do stack giải được bài này: dấu ngoặc mở gần nhất là dấu phải được đóng tiếp theo, và đó chính xác là LIFO.

def is_balanced(text):
    """O(n) thời gian, O(n) bộ nhớ (worst case '((((((' push tất cả)."""
    pairs = {")": "(", "]": "[", "}": "{"}
    stack = []
    for ch in text:
        if ch in "([{":
            stack.append(ch)                       # nhớ lại thứ sẽ phải đóng sau này
        elif ch in pairs:
            if not stack or stack.pop() != pairs[ch]:
                return False                       # sai dấu đóng, hoặc chẳng có gì đang mở
    return not stack                               # còn sót gì đang mở nghĩa là không cân bằng


print(is_balanced("{[()()]}"))   # True
print(is_balanced("{[(])}"))     # False — ']' đóng cho một '(' 
print(is_balanced("(("))         # False — stack chưa rỗng khi kết thúc

Cùng hình dạng đó giải được cả một họ bài toán: khớp tag HTML/XML, kiểm tra lồng nhau trong một config parser, kiểm tra mỗi BEGIN đều có END. Bất cứ khi nào tính đúng đắn phụ thuộc vào “thứ chưa khớp gần nhất”, đó là một stack.

Tính biểu thức

Stack là cách máy tính bỏ túi và compiler tính biểu thức số học. Ký pháp Reverse Polish (hậu tố) không cần dấu ngoặc và được tính bằng một stack giá trị duy nhất:

def eval_rpn(tokens):
    """Tính biểu thức reverse Polish. O(n) thời gian, O(n) bộ nhớ.
    '3 4 + 2 *' nghĩa là (3 + 4) * 2 = 14."""
    stack = []
    for token in tokens:
        if token in {"+", "-", "*", "/"}:
            b = stack.pop()                        # thứ tự quan trọng: b được push sau
            a = stack.pop()
            if token == "+":
                stack.append(a + b)
            elif token == "-":
                stack.append(a - b)
            elif token == "*":
                stack.append(a * b)
            else:
                stack.append(int(a / b))           # cắt về phía 0
        else:
            stack.append(int(token))
    return stack.pop()


print(eval_rpn("3 4 + 2 *".split()))         # 14
print(eval_rpn("5 1 2 + 4 * + 3 -".split())) # 14

Chuyển từ trung tố (3 + 4 * 2) sang hậu tố dùng thêm một stack thứ hai cho toán tử — thuật toán shunting-yard của Dijkstra — trong đó một toán tử nằm lại trên stack cho tới khi gặp một toán tử có độ ưu tiên thấp hơn hoặc bằng. Các máy ảo dựa trên stack (JVM, chính bytecode interpreter của CPython, WebAssembly) thực thi ở dạng hậu tố đúng vì lý do này: nó không cần cấp phát thanh ghi và operand stack lo hết phần ghi sổ.

Monotonic stack

Monotonic stack giữ cho nội dung của nó luôn có thứ tự (tăng hoặc giảm) bằng cách pop bỏ mọi thứ làm phá vỡ thứ tự đó. Nó trả lời các truy vấn “phần tử lớn hơn kế tiếp / nhỏ hơn trước đó” cho cả một array trong tổng cộng O(n), và đây là mẹo chuẩn cho một họ bài toán trông có vẻ O(n²).

def next_greater_element(nums):
    """Với mỗi phần tử, tìm phần tử kế tiếp bên phải lớn hơn nó (-1 nếu không có).
    O(n) thời gian dù trông có vẻ bậc hai: mỗi index được push đúng một lần và pop
    tối đa một lần, nên vòng while bên trong chạy TỔNG CỘNG O(n) lần qua cả vòng ngoài."""
    result = [-1] * len(nums)
    stack = []                                     # chứa INDEX, giá trị giảm dần
    for i, value in enumerate(nums):
        while stack and nums[stack[-1]] < value:
            result[stack.pop()] = value            # `value` là đáp án cho index đó
        stack.append(i)
    return result                                  # thứ còn sót trên stack giữ nguyên -1


print(next_greater_element([2, 1, 2, 4, 3]))       # [4, 2, 4, -1, -1]


def largest_rectangle_in_histogram(heights):
    """Hình chữ nhật lớn nhất dưới một histogram. O(n) với monotonic stack tăng dần —
    cách vét cạn là O(n^2). Khi một cột bị pop, ta mới biết được hình chữ nhật của nó
    có thể kéo dài về bên trái tới đâu: tới ngay sau đỉnh stack mới."""
    stack = []                                     # index, chiều cao tăng dần
    best = 0
    for i, h in enumerate(heights + [0]):          # sentinel 0 để xả sạch stack ở cuối
        while stack and heights[stack[-1]] >= h:
            height = heights[stack.pop()]
            left = stack[-1] + 1 if stack else 0   # cột vừa pop kéo dài về tới đây
            best = max(best, height * (i - left))
        stack.append(i)
    return best


print(largest_rectangle_in_histogram([2, 1, 5, 6, 2, 3]))   # 10

Lập luận amortized mới là phần quan trọng: vòng while trông như làm thuật toán thành bậc hai, nhưng mỗi index vào stack đúng một lần và ra tối đa một lần, nên tổng công qua tất cả các vòng là O(n). Đây chính là kiểu phân tích tổng gộp giống như phép nhân đôi của dynamic array trong array.

Người anh em hình dạng queue là monotonic deque, tính giá trị lớn nhất trên cửa sổ trượt trong O(n) — xem hai con trỏ và cửa sổ trượt.

Hai stack tạo thành một queue

Một kết quả thú vị với bài học thật về amortized analysis. Giữ một stack in và một stack out; enqueue push vào in, dequeue pop từ out, và nạp lại out bằng cách dốc ngược in khi out rỗng. Việc đảo ngược hai lần biến LIFO thành FIFO.

class QueueFromStacks:
    """FIFO từ hai stack LIFO. Amortized O(1) mỗi thao tác dù có bước chuyển O(n):
    mỗi phần tử được chuyển từ `in` sang `out` đúng một lần trong đời nó."""

    def __init__(self):
        self._in = []
        self._out = []

    def enqueue(self, item):
        self._in.append(item)                      # luôn luôn O(1)

    def dequeue(self):
        if not self._out:                          # chỉ nạp lại khi `out` đã cạn
            while self._in:
                self._out.append(self._in.pop())   # O(n), nhưng amortized O(1) mỗi phần tử
        if not self._out:
            raise IndexError("dequeue from empty queue")
        return self._out.pop()


q = QueueFromStacks()
for x in [1, 2, 3]:
    q.enqueue(x)
print(q.dequeue(), q.dequeue())   # 1 2 — thứ tự FIFO được giữ nguyên

Đây là một câu hỏi phỏng vấn được ưa chuộng, và đáp án được mong đợi không phải là đoạn code (vốn rất ngắn) mà là lập luận amortized: một phần tử được push vào in một lần, chuyển sang out một lần, và pop ra một lần — ba thao tác O(1) trong suốt vòng đời, nên n thao tác tốn tổng cộng O(n).

Chúng xuất hiện ở đâu trong hệ thống thật

Best Practices

Tài liệu tham khảo

Part of the Data Structures & Algorithms Roadmap knowledge base.

Overview

Stacks and queues are the two structures defined not by how they store data but by what they refuse to let you do. Both hold a sequence; both give you exactly one place to add and one place to remove; neither lets you reach into the middle. That restriction is the entire value proposition — by giving up random access you get a structure whose every operation is O(1), whose behaviour is trivially predictable, and whose ordering guarantee (LIFO or FIFO) is itself the algorithm in a surprising number of problems.

They are abstract data types, not concrete layouts — the interface is push/pop/peek and enqueue/dequeue/peek, and you can back either one with an array or a linked list. That distinction between interface and implementation, developed in what are data structures, is at its clearest here: the same stack ADT is implemented by a Python list, by a linked list, by a fixed C array plus an integer, and by the CPU’s own %rsp register.

This note builds both from scratch in both representations, works through the circular buffer that makes an array-backed queue actually O(1), and covers the patterns they power: balanced parentheses, expression evaluation, DFS versus BFS, monotonic stacks, and the deque.

Fundamentals

The stack

   push(4)                 pop() -> 4
      |                        ^
      v                        |
   +-----+                  +-----+
   |  4  | <- top           |  3  | <- top
   +-----+                  +-----+
   |  3  |                  |  2  |
   +-----+                  +-----+
   |  2  |                  |  1  |
   +-----+                  +-----+
   |  1  | <- bottom        
   +-----+

   All access happens at one end. The bottom is unreachable
   without popping everything above it.

Operations, all O(1):

OperationMeaningEmpty-stack behaviour
push(x)Add x on top
pop()Remove and return the topStack underflow — must raise or return a sentinel
peek() / top()Return the top without removingUnderflow
is_empty()Is there anything on it
size()How many items

A fixed-capacity stack can also overflow on push. This is not just a textbook concern: the hardware call stack has a fixed size (typically 1–8 MB), and running past it is exactly the StackOverflowError / RecursionError / segfault you get from runaway recursion.

Array-backed stack

The natural implementation. A stack’s push and pop both happen at one end, and an array’s cheap end is the back — so put the top of the stack at the end of the array and both operations are amortized O(1) with no shifting at all.

class ArrayStack:
    """Stack backed by a dynamic array. The array's END is the stack's TOP —
    that choice is what makes every operation O(1)."""

    def __init__(self, iterable=()):
        self._data = []                            # a Python list IS a dynamic array
        for item in iterable:
            self.push(item)

    def push(self, item):
        """O(1) amortized — O(n) only on the rare underlying resize."""
        self._data.append(item)

    def pop(self):
        """O(1) — nothing shifts, we only decrement the size."""
        if not self._data:
            raise IndexError("pop from empty stack")   # stack underflow
        return self._data.pop()

    def peek(self):
        """O(1) — look without removing."""
        if not self._data:
            raise IndexError("peek at empty stack")
        return self._data[-1]

    def is_empty(self):
        return len(self._data) == 0

    def __len__(self):
        return len(self._data)

    def __repr__(self):
        return f"ArrayStack(bottom -> top: {self._data})"


s = ArrayStack([1, 2, 3])
s.push(4)
print(s)              # ArrayStack(bottom -> top: [1, 2, 3, 4])
print(s.pop())        # 4
print(s.peek())       # 3

Do not build a stack the other way round. If you make index 0 the top, push becomes insert(0, x) and pop becomes pop(0) — both O(n), because every element shifts. The choice of which end is the top is the whole difference between O(1) and O(n). This is the single most common way people accidentally write a quadratic stack in Python.

Linked-list-backed stack

Push and pop at the head of a singly linked list are both O(1) with no amortization and no resize spike:

class LinkedStack:
    """Stack backed by a singly linked list. Push/pop at the HEAD — both O(1) worst case,
    with no resize spike, at the cost of one pointer + one object header per element."""

    class _Node:
        __slots__ = ("value", "next")

        def __init__(self, value, next=None):
            self.value = value
            self.next = next

    def __init__(self, iterable=()):
        self._head = None                          # the head is the top of the stack
        self._size = 0
        for item in iterable:
            self.push(item)

    def push(self, item):
        """O(1) worst case — no copying, ever."""
        self._head = self._Node(item, self._head)
        self._size += 1

    def pop(self):
        """O(1) worst case."""
        if self._head is None:
            raise IndexError("pop from empty stack")
        node = self._head
        self._head = node.next
        node.next = None                           # detach so a stale ref cannot walk on
        self._size -= 1
        return node.value

    def peek(self):
        if self._head is None:
            raise IndexError("peek at empty stack")
        return self._head.value

    def is_empty(self):
        return self._size == 0

    def __len__(self):
        return self._size


s = LinkedStack([1, 2, 3])
print(s.pop(), len(s))    # 3 2
Array-backedLinked-list-backed
pushO(1) amortized, O(n) worstO(1) worst case
pop / peekO(1)O(1)
Memory per element8 bytes (pointer) + slack~56 bytes in CPython
Cache behaviourExcellent (contiguous)Poor (pointer chasing)
Latency spikesYes — the resize copyNo
VerdictDefault choiceOnly when the resize spike is unacceptable (hard real-time)

In Python, just use a list. append and pop are already exactly the stack operations, they are C-implemented, and they are faster than anything you can write. Build the classes above to understand them, use the list in production.

The queue

   enqueue at the REAR, dequeue from the FRONT.

     dequeue <--- +-----+-----+-----+-----+ <--- enqueue
                  |  1  |  2  |  3  |  4  |
                  +-----+-----+-----+-----+
                  front                rear

   dequeue() -> 1, then the queue is [2, 3, 4] with front at 2.
OperationMeaningEmpty-queue behaviour
enqueue(x)Add x at the rear
dequeue()Remove and return the frontQueue underflow
peek() / front()Return the front without removingUnderflow
is_empty(), size()

The naive array queue, and why it is broken

The obvious implementation — list.append to enqueue, list.pop(0) to dequeue — is O(n) per dequeue, because removing index 0 shifts every remaining element left one slot:

   Before pop(0):  [ 1 | 2 | 3 | 4 ]
   After:          [ 2 | 3 | 4 |   ]     <- 3 elements physically moved

Processing n items through such a queue costs O(n²). This is the second-most-common accidental quadratic in Python after string concatenation in a loop, and it shows up constantly in hand-written BFS.

The other naive fix — keep a front index and never shift, just advance it — makes dequeue O(1) but leaks memory: the slots before front are never reused, so a queue that has seen a million items keeps a million dead slots even if it currently holds three.

The circular buffer (ring buffer)

The real fix is to let the queue wrap around the end of a fixed-size array. Keep a front index and a count; the rear is computed as (front + count) % capacity. Now both ends are O(1) and the dead space at the front is reclaimed automatically.

   capacity = 8, front = 5, count = 5   ->  rear slot = (5 + 5) % 8 = 2

   index:    0     1     2     3     4     5     6     7
          +-----+-----+-----+-----+-----+-----+-----+-----+
          |  D  |  E  |     |     |     |  A  |  B  |  C  |
          +-----+-----+-----+-----+-----+-----+-----+-----+
                         ^                 ^
                    next enqueue        front (next dequeue)

   Logical order: A B C D E     — the buffer wrapped past the end of the array.
   enqueue(F) writes index 2. dequeue() returns A and sets front = 6.
class CircularQueue:
    """FIFO queue on a circular buffer: O(1) enqueue and dequeue, no shifting,
    no leaked slots. Grows by doubling when full."""

    def __init__(self, capacity=8):
        self._data = [None] * capacity
        self._front = 0                            # index of the front element
        self._count = 0                            # how many elements are live

    def __len__(self):
        return self._count

    def is_empty(self):
        return self._count == 0

    def enqueue(self, item):
        """O(1) amortized. The modulo is what makes the buffer wrap."""
        if self._count == len(self._data):
            self._resize(2 * len(self._data))
        rear = (self._front + self._count) % len(self._data)
        self._data[rear] = item
        self._count += 1

    def dequeue(self):
        """O(1) — advance the front index; nothing moves."""
        if self._count == 0:
            raise IndexError("dequeue from empty queue")
        item = self._data[self._front]
        self._data[self._front] = None             # drop the reference so the GC can free it
        self._front = (self._front + 1) % len(self._data)
        self._count -= 1
        return item

    def peek(self):
        if self._count == 0:
            raise IndexError("peek at empty queue")
        return self._data[self._front]

    def _resize(self, new_capacity):
        """O(n). Unroll the wrapped buffer into a fresh one starting at index 0 —
        this is why the copy must go through the modulo, not a plain slice."""
        old = self._data
        self._data = [None] * new_capacity
        walk = self._front
        for i in range(self._count):
            self._data[i] = old[walk]
            walk = (walk + 1) % len(old)
        self._front = 0

    def __iter__(self):
        for i in range(self._count):
            yield self._data[(self._front + i) % len(self._data)]

    def __repr__(self):
        return f"CircularQueue(front -> rear: {list(self)})"


q = CircularQueue(capacity=4)
for x in "ABCD":
    q.enqueue(x)
print(q.dequeue(), q.dequeue())    # A B
q.enqueue("E")
q.enqueue("F")                     # these wrap into slots 0 and 1
print(q)                           # CircularQueue(front -> rear: ['C', 'D', 'E', 'F'])

Two details worth noticing. First, tracking count rather than a rear index avoids the classic full-versus-empty ambiguity: with only front and rear, front == rear means both empty and full, and the usual workaround is to waste one slot. Second, self._data[self._front] = None on dequeue matters in a garbage-collected language — without it the buffer holds a live reference to a dequeued object forever, which is a genuine memory leak that is hard to find.

Fixed-capacity circular buffers (no resize — just reject or overwrite when full) are ubiquitous in systems code: audio and network I/O buffers, kernel ring buffers (dmesg), lock-free single-producer/single-consumer queues, and the retention window of a metrics agent. Bounded memory is a feature there, not a limitation.

Linked-list-backed queue

Head is the front, tail is the rear. Both O(1), no capacity limit, no resize:

class LinkedQueue:
    """FIFO queue on a singly linked list: dequeue at the head, enqueue at the tail.
    The tail pointer is essential — without it, enqueue is O(n)."""

    class _Node:
        __slots__ = ("value", "next")

        def __init__(self, value):
            self.value = value
            self.next = None

    def __init__(self):
        self._head = None                          # front
        self._tail = None                          # rear
        self._size = 0

    def enqueue(self, item):
        """O(1) worst case — thanks to the tail pointer."""
        node = self._Node(item)
        if self._tail is None:
            self._head = self._tail = node
        else:
            self._tail.next = node
            self._tail = node
        self._size += 1

    def dequeue(self):
        """O(1) worst case."""
        if self._head is None:
            raise IndexError("dequeue from empty queue")
        node = self._head
        self._head = node.next
        if self._head is None:                     # queue is now empty
            self._tail = None
        self._size -= 1
        node.next = None
        return node.value

    def peek(self):
        if self._head is None:
            raise IndexError("peek at empty queue")
        return self._head.value

    def __len__(self):
        return self._size

Complexity summary

OperationArray stackLinked stackCircular queueLinked queuecollections.deque
Push / enqueueO(1) amortizedO(1)O(1) amortizedO(1)O(1)
Pop / dequeueO(1)O(1)O(1)O(1)O(1) (both ends)
PeekO(1)O(1)O(1)O(1)O(1)
Access index iO(1) (but not in the ADT)O(n)O(1) (but not in the ADT)O(n)O(n) in the middle
SearchO(n)O(n)O(n)O(n)O(n)
SpaceO(n), ≤2× slackO(n), ~7× overheadO(n), ≤2× slackO(n), ~7× overheadO(n), block-based
Worst-case single opO(n) (resize)O(1)O(n) (resize)O(1)O(1) amortized

Every one of these is O(1) in the operations the ADT actually exposes. The choice between them is entirely about constants, memory, and latency spikes — never about asymptotics.

collections.deque — what you actually use

A deque (double-ended queue, pronounced “deck”) allows push and pop at both ends. It generalizes both structures: use one end for a stack, both ends for a queue. Python’s collections.deque is the production answer to “I need a queue”:

from collections import deque

# As a queue (FIFO)
q = deque()
q.append("a")            # enqueue at the rear      O(1)
q.append("b")
print(q.popleft())       # dequeue from the front   O(1) — NOT O(n) like list.pop(0)

# As a stack (LIFO)
st = deque()
st.append(1)             # push   O(1)
print(st.pop())          # pop    O(1)

# Bounded: a fixed-size sliding window that discards from the other end automatically
last_three = deque(maxlen=3)
for x in [1, 2, 3, 4, 5]:
    last_three.append(x)
print(last_three)        # deque([3, 4, 5], maxlen=3)

# Rotation is O(k), useful for round-robin scheduling
d = deque([1, 2, 3, 4, 5])
d.rotate(2)
print(d)                 # deque([4, 5, 1, 2, 3])

What it costs: CPython implements deque as a doubly linked list of fixed-size blocks (64 pointers each). You get O(1) at both ends with no resize copy, and decent locality within a block — but indexing into the middle is O(n), so d[len(d)//2] walks blocks. If you need both fast ends and fast indexing, you need a different structure.

Two more from the standard library worth naming: queue.Queue is a thread-safe blocking queue with locks and join() semantics — use it for producer/consumer between threads, not for algorithms, since the locking makes it far slower. heapq gives a priority queue, which is a queue ordered by priority rather than arrival, and is covered in heaps and priority queues.

Key Concepts

The call stack

The most important stack in computing is the one your program runs on. Every function call pushes a stack frame holding the return address, saved registers, parameters, and local variables; every return pops it. The LIFO discipline is exactly right for this: a function called later must return before the one that called it.

   def a(): b()
   def b(): c()
   def c(): return 1

   During c():                   After c() returns:
   +----------------+            +----------------+
   | frame for c()  | <- sp      | frame for b()  | <- sp
   +----------------+            +----------------+
   | frame for b()  |            | frame for a()  |
   +----------------+            +----------------+
   | frame for a()  |            | frame for main |
   +----------------+            +----------------+
   | frame for main |
   +----------------+

Consequences worth internalizing:

def dfs_recursive(graph, start, visited=None):
    """Uses the CALL stack. Elegant, but depth-limited."""
    if visited is None:
        visited = set()
    visited.add(start)
    for neighbour in graph[start]:
        if neighbour not in visited:
            dfs_recursive(graph, neighbour, visited)
    return visited


def dfs_iterative(graph, start):
    """Same traversal with an EXPLICIT stack. No depth limit, and the frontier
    is a real variable you can inspect, log, or checkpoint."""
    visited = set()
    stack = [start]
    while stack:
        node = stack.pop()                         # LIFO -> depth-first
        if node in visited:
            continue
        visited.add(node)
        for neighbour in graph[node]:
            if neighbour not in visited:
                stack.append(neighbour)
    return visited

DFS versus BFS is a stack-versus-queue swap

This is the cleanest demonstration that the container is the algorithm. The two traversals in graph data structures differ by one line:

from collections import deque


def traverse(graph, start, use_stack):
    """Change LIFO to FIFO and depth-first becomes breadth-first. Nothing else changes."""
    frontier = deque([start])
    visited = {start}
    order = []
    while frontier:
        node = frontier.pop() if use_stack else frontier.popleft()   # <-- the only difference
        order.append(node)
        for neighbour in graph[node]:
            if neighbour not in visited:
                visited.add(neighbour)
                frontier.append(neighbour)
    return order


graph = {"A": ["B", "C"], "B": ["D", "E"], "C": ["F"], "D": [], "E": ["F"], "F": []}
print(traverse(graph, "A", use_stack=True))    # DFS: ['A', 'C', 'F', 'B', 'E', 'D']
print(traverse(graph, "A", use_stack=False))   # BFS: ['A', 'B', 'C', 'D', 'E', 'F']

BFS’s FIFO order is what makes it find shortest paths in an unweighted graph — nodes come out in non-decreasing distance from the source, so the first time you reach a node is via a shortest path. A stack gives no such guarantee. That is why popleft() and not pop() matters here, and why using list.pop(0) instead of deque.popleft() silently turns an O(V + E) BFS into an O(V² + VE) one.

Balanced parentheses — the classic stack problem

The reason a stack solves this: the most recently opened bracket is the one that must close next, which is precisely LIFO.

def is_balanced(text):
    """O(n) time, O(n) space (worst case '((((((' pushes everything)."""
    pairs = {")": "(", "]": "[", "}": "{"}
    stack = []
    for ch in text:
        if ch in "([{":
            stack.append(ch)                       # remember what must close later
        elif ch in pairs:
            if not stack or stack.pop() != pairs[ch]:
                return False                       # wrong closer, or nothing was open
    return not stack                               # anything left open means unbalanced


print(is_balanced("{[()()]}"))   # True
print(is_balanced("{[(])}"))     # False — ']' closes a '(' 
print(is_balanced("(("))         # False — stack not empty at the end

The same shape solves a large family of problems: HTML/XML tag matching, validating nesting in a config parser, checking that every BEGIN has an END. Any time correctness depends on “the most recent unmatched thing,” it is a stack.

Expression evaluation

Stacks are how calculators and compilers evaluate arithmetic. Reverse Polish (postfix) notation needs no parentheses and evaluates with a single value stack:

def eval_rpn(tokens):
    """Evaluate reverse Polish notation. O(n) time, O(n) space.
    '3 4 + 2 *' means (3 + 4) * 2 = 14."""
    stack = []
    for token in tokens:
        if token in {"+", "-", "*", "/"}:
            b = stack.pop()                        # order matters: b was pushed second
            a = stack.pop()
            if token == "+":
                stack.append(a + b)
            elif token == "-":
                stack.append(a - b)
            elif token == "*":
                stack.append(a * b)
            else:
                stack.append(int(a / b))           # truncate toward zero
        else:
            stack.append(int(token))
    return stack.pop()


print(eval_rpn("3 4 + 2 *".split()))         # 14
print(eval_rpn("5 1 2 + 4 * + 3 -".split())) # 14

Converting infix (3 + 4 * 2) to postfix uses a second stack for operators — Dijkstra’s shunting-yard algorithm — where an operator stays on the stack until an operator of lower or equal precedence arrives. Stack-based virtual machines (the JVM, CPython’s own bytecode interpreter, WebAssembly) execute in postfix form for exactly this reason: it needs no register allocation and the operand stack does all the bookkeeping.

Monotonic stacks

A monotonic stack keeps its contents sorted (increasing or decreasing) by popping anything that would break the order. It answers “next greater / previous smaller element” queries for a whole array in O(n) total, which is the standard trick for a family of problems that look O(n²).

def next_greater_element(nums):
    """For each element, the next element to its right that is larger (-1 if none).
    O(n) time even though it looks quadratic: each index is pushed once and popped
    at most once, so the inner while loop runs O(n) times TOTAL across the outer loop."""
    result = [-1] * len(nums)
    stack = []                                     # holds INDICES, values decreasing
    for i, value in enumerate(nums):
        while stack and nums[stack[-1]] < value:
            result[stack.pop()] = value            # `value` is the answer for that index
        stack.append(i)
    return result                                  # anything left on the stack keeps -1


print(next_greater_element([2, 1, 2, 4, 3]))       # [4, 2, 4, -1, -1]


def largest_rectangle_in_histogram(heights):
    """Largest rectangle under a histogram. O(n) with a monotonic increasing stack —
    the brute force is O(n^2). When a bar is popped, we finally know how far left
    its rectangle could have extended: to just after the new stack top."""
    stack = []                                     # indices, heights increasing
    best = 0
    for i, h in enumerate(heights + [0]):          # sentinel 0 flushes the stack at the end
        while stack and heights[stack[-1]] >= h:
            height = heights[stack.pop()]
            left = stack[-1] + 1 if stack else 0   # the popped bar extends back to here
            best = max(best, height * (i - left))
        stack.append(i)
    return best


print(largest_rectangle_in_histogram([2, 1, 5, 6, 2, 3]))   # 10

The amortized argument is the important part: the while loop looks like it makes the algorithm quadratic, but every index enters the stack exactly once and leaves at most once, so total work across all iterations is O(n). This is the same aggregate-analysis reasoning as the dynamic array’s doubling in arrays.

The queue-shaped sibling is the monotonic deque, which computes a sliding-window maximum in O(n) — see two pointers and sliding window.

Two stacks make a queue

A cute result with a real amortized-analysis lesson. Keep an in stack and an out stack; enqueue pushes to in, dequeue pops from out, refilling out by draining in when it is empty. The double reversal turns LIFO into FIFO.

class QueueFromStacks:
    """FIFO from two LIFO stacks. Amortized O(1) per operation despite the O(n) transfer:
    each element is moved from `in` to `out` exactly once in its lifetime."""

    def __init__(self):
        self._in = []
        self._out = []

    def enqueue(self, item):
        self._in.append(item)                      # O(1) always

    def dequeue(self):
        if not self._out:                          # only refill when `out` is exhausted
            while self._in:
                self._out.append(self._in.pop())   # O(n), but amortized O(1) per element
        if not self._out:
            raise IndexError("dequeue from empty queue")
        return self._out.pop()


q = QueueFromStacks()
for x in [1, 2, 3]:
    q.enqueue(x)
print(q.dequeue(), q.dequeue())   # 1 2 — FIFO order preserved

This is a favourite interview question, and the expected answer is not the code (which is short) but the amortized argument: an element is pushed to in once, moved to out once, and popped once — three O(1) operations over its lifetime, so n operations cost O(n) total.

Where these show up in real systems

Best Practices

References