cv.cm

Lập trình · Cấu trúc dữ liệu

Ngăn xếp và hàng đợi: LIFO và FIFO kèm ví dụ Python

6 phút đọc

Trả lời nhanh

Ngăn xếp (stack) là một tập hợp trong đó phần tử thêm vào sau cùng được lấy ra đầu tiên (LIFO, last in first out), giống một chồng đĩa. Hàng đợi (queue) là tập hợp trong đó phần tử thêm vào đầu tiên được lấy ra đầu tiên (FIFO, first in first out), giống hàng người xếp ở quầy thanh toán. Cả hai đều thêm và lấy phần tử trong O(1). Trong Python, dùng list làm ngăn xếp và collections.deque làm hàng đợi.

Ngăn xếp và hàng đợi giải quyết vấn đề gì

Nhiều chương trình cần nhớ các phần tử rồi xử lý sau theo một thứ tự cố định. Ngăn xếp trả về phần tử mới nhất trước, phù hợp với công việc lồng nhau: lời gọi hàm, lịch sử hoàn tác, ghép cặp ngoặc và tìm kiếm theo chiều sâu. Hàng đợi trả về phần tử cũ nhất trước, phù hợp với việc chờ công bằng: hàng chờ in, bộ lập lịch tác vụ, hàng đợi thông điệp và tìm kiếm theo chiều rộng.

Cả hai đều là kiểu dữ liệu trừu tượng: chúng được định nghĩa bằng các thao tác, không phải bằng cách lưu trữ. Bạn có thể xây dựng chúng trên mảng hoặc danh sách liên kết.

Các thao tác và tên gọi

Thao tácNgăn xếp (LIFO)Hàng đợi (FIFO)
Thêm một phần tửpush: đặt lên đỉnhenqueue: thêm vào cuối
Lấy một phần tử rapop: lấy từ đỉnhdequeue: lấy từ đầu
Xem mà không lấy rapeek / toppeek / front
Kiểu trong Pythonlist với append và popcollections.deque với append và popleft
Thứ tự lấy ra với đầu vào 1, 2, 33, 2, 11, 2, 3

Ví dụ minh họa: push và pop từng bước

Đẩy 1, 2 và 3 vào cả hai cấu trúc, rồi lấy ra hai phần tử.

BướcNội dung ngăn xếp (đỉnh ở bên phải)Nội dung hàng đợi (đầu ở bên trái)
thêm 1[1][1]
thêm 2[1, 2][1, 2]
thêm 3[1, 2, 3][1, 2, 3]
lấy ratrả về 3, còn lại [1, 2]trả về 1, còn lại [2, 3]
lấy ratrả về 2, còn lại [1]trả về 2, còn lại [3]

Cài đặt bằng Python

list của Python đã là một ngăn xếp tốt: append và pop() làm việc ở cuối danh sách trong O(1) khấu hao. Với hàng đợi, đừng dùng list.pop(0), vì nó dịch mọi phần tử còn lại và tốn O(n). Hãy dùng collections.deque, có O(1) ở cả hai đầu.

from collections import deque
stack = []
stack.append(1)
stack.append(2)
stack.append(3)
print(stack.pop(), stack[-1])   # 3 2
queue = deque()
queue.append("a")
queue.append("b")
queue.append("c")
print(queue.popleft(), queue[0])  # a b
print(len(stack), len(queue))     # 2 2

Nếu muốn có phương thức đặt tên rõ ràng và thông báo lỗi dễ hiểu, hãy bọc các kiểu dựng sẵn trong một lớp nhỏ:

from collections import deque
class Queue:
    def __init__(self):
        self._items = deque()
    def enqueue(self, item):
        self._items.append(item)
    def dequeue(self):
        if not self._items:
            raise IndexError("dequeue from empty queue")
        return self._items.popleft()
    def __len__(self):
        return len(self._items)
q = Queue()
q.enqueue("job1")
q.enqueue("job2")
print(q.dequeue(), len(q))  # job1 1

Bài toán kinh điển của ngăn xếp: ngoặc cân bằng

Để kiểm tra mọi dấu ngoặc đều được đóng đúng thứ tự, hãy đẩy từng ngoặc mở vào ngăn xếp. Khi gặp ngoặc đóng, phần tử trên đỉnh ngăn xếp phải là ngoặc mở tương ứng.

def is_balanced(text):
    pairs = {")": "(", "]": "[", "}": "{"}
    stack = []
    for ch in text:
        if ch in "([{":
            stack.append(ch)
        elif ch in pairs:
            if not stack or stack.pop() != pairs[ch]:
                return False
    return not stack
print(is_balanced("f(a[1], {b: 2})"))  # True
print(is_balanced("(]"))               # False
print(is_balanced("(("))               # False

Cùng ý tưởng này dùng để tính biểu thức ở dạng ký pháp Ba Lan ngược, chuyển đệ quy thành vòng lặp, và là cơ sở của tìm kiếm theo chiều sâu: thay ngăn xếp lời gọi bằng list của riêng bạn.

Bài toán kinh điển của hàng đợi: tìm kiếm theo chiều rộng

Tìm kiếm theo chiều rộng (BFS) duyệt đồ thị từng tầng một. Hàng đợi giúp điều đó xảy ra: node được phát hiện trước sẽ được xử lý trước.

from collections import deque
def bfs_order(graph, start):
    seen = {start}
    queue = deque([start])
    order = []
    while queue:
        node = queue.popleft()
        order.append(node)
        for nxt in graph[node]:
            if nxt not in seen:
                seen.add(nxt)
                queue.append(nxt)
    return order
graph = {"A": ["B", "C"], "B": ["D"], "C": ["D"], "D": []}
print(bfs_order(graph, "A"))  # ['A', 'B', 'C', 'D']

Độ phức tạp thời gian và không gian

Thao tácNgăn xếp (list)Hàng đợi (deque)Hàng đợi (list.pop(0))
ThêmO(1) khấu haoO(1)O(1) khấu hao
Lấy raO(1)O(1)O(n), các phần tử dịch sang trái
XemO(1)O(1)O(1)
Không gian cho n phần tửO(n)O(n)O(n)

Khấu hao (amortized) nghĩa là đôi khi một lần append phải đổi kích thước mảng bên dưới và tốn O(n), nhưng tính trung bình qua nhiều thao tác thì mỗi thao tác là O(1).

Các biến thể và lỗi thường gặp

  • Deque (hàng đợi hai đầu): thêm và lấy ở cả hai đầu. collections.deque chính là một deque, và nó có thể đóng vai cả ngăn xếp lẫn hàng đợi.
  • Hàng đợi ưu tiên (priority queue): lấy phần tử nhỏ nhất (hoặc lớn nhất) trước, không phải phần tử cũ nhất. Dùng heapq; xem trang về heap nhị phân.
  • Bộ đệm vòng (circular buffer): hàng đợi kích thước cố định lưu trong mảng với chỉ số quay vòng. deque(maxlen=n) loại bỏ phần tử cũ nhất khi đầy.
  • Hàng đợi an toàn luồng: queue.Queue cho thread và asyncio.Queue cho coroutine sẽ chặn (block) khi chờ phần tử.
  • Hàng đợi xây từ hai ngăn xếp: push vào ngăn xếp vào, và khi ngăn xếp ra rỗng thì chuyển hết sang. Mỗi phần tử chỉ được chuyển một lần, nên các thao tác là O(1) khấu hao.

Câu hỏi thường gặp

Ngăn xếp và hàng đợi khác nhau thế nào?

Ngăn xếp lấy ra phần tử được thêm gần nhất trước (LIFO). Hàng đợi lấy ra phần tử cũ nhất trước (FIFO). Cả hai đều thêm và lấy trong O(1).

Trong Python nên dùng list hay deque làm hàng đợi?

Dùng collections.deque. Lấy ra ở đầu list bằng pop(0) là O(n) vì mọi phần tử khác phải dịch chuyển; deque.popleft() là O(1).

Ngăn xếp được dùng ở đâu trong các chương trình thực tế?

Ngăn xếp lời gọi lưu các lần gọi hàm, lịch sử hoàn tác và làm lại, nút quay lại của trình duyệt, phân tích biểu thức, ghép cặp ngoặc và tìm kiếm theo chiều sâu.

Stack overflow là gì?

Đó là khi ngăn xếp lời gọi hết chỗ, thường do đệ quy quá sâu hoặc vô hạn. Trong Python, nó hiện ra dưới dạng RecursionError khi vượt quá giới hạn đệ quy.

queue.Queue có giống collections.deque không?

Không. queue.Queue là hàng đợi an toàn luồng có khóa và các thao tác get, put có chặn, dùng để chuyển công việc giữa các thread. collections.deque là container đa dụng nhanh hơn, không chặn.

Tất cả hướng dẫn lập trình