Bài 34: Queue - Hàng Đợi¶
Tác giả: FPTOJ Team
Nội dung tham khảo từ: GeeksforGeeks, VNOI Wiki, CP-Algorithms
1. Bản chất vấn đề¶
Hàng đợi và Nguyên lý FIFO¶
Trong nhiều bài toán lập trình và hệ thống thực tế, ta cần quản lý một tập hợp đối tượng theo nguyên lý ai đến trước, được phục vụ trước. Ví dụ: hàng người xếp hàng chờ thanh toán, các tiến trình đợi CPU xử lý trong hệ điều hành, hay các nút của đồ thị chờ được duyệt theo chiều rộng. Đây chính là mô hình của cấu trúc dữ liệu Hàng đợi (Queue).
Queue hoạt động theo nguyên tắc FIFO (First In, First Out): phần tử được thêm vào trước sẽ là phần tử được lấy ra trước tiên.
So sánh Stack và Queue¶
| Tiêu chí | Stack (Ngăn xếp) | Queue (Hàng đợi) |
|---|---|---|
| Nguyên tắc hoạt động | LIFO: Vào sau, ra trước | FIFO: Vào trước, ra trước |
| Vị trí thêm phần tử | Đỉnh ngăn xếp (Top) | Cuối hàng đợi (Rear) |
| Vị trí lấy phần tử | Đỉnh ngăn xếp (Top) | Đầu hàng đợi (Front) |
| Ứng dụng chủ đạo | Đệ quy, duyệt DFS, tính toán biểu thức | Duyệt BFS, mô phỏng hàng chờ, thuật toán luồng |
Các thao tác cơ bản và độ phức tạp¶
Một cấu trúc Queue tiêu chuẩn cần hỗ trợ các thao tác cơ bản sau với độ phức tạp \(O(1)\):
push(x): Thêm phần tử \(x\) vào cuối hàng đợi.pop(): Loại bỏ phần tử ở đầu hàng đợi.front(): Xem giá trị của phần tử ở đầu hàng đợi mà không xóa nó.back(): Xem giá trị của phần tử ở cuối hàng đợi mà không xóa nó.empty(): Kiểm tra hàng đợi có đang trống hay không.size(): Trả về số lượng phần tử hiện có trong hàng đợi.
2. Tư duy cốt lõi: Các phương pháp cài đặt Queue¶
Có hai phương pháp chính để tự cài đặt cấu trúc dữ liệu Queue:
2.1. Cài đặt bằng Mảng vòng (Circular Array)¶
Nếu cài đặt Queue bằng một mảng tĩnh thông thường, sau mỗi thao tác pop(), ta phải dịch chuyển toàn bộ các phần tử còn lại sang trái một vị trí, tốn thời gian \(O(N)\).
Để tối ưu về \(O(1)\), ta sử dụng hai chỉ số \(front\_idx\) và \(rear\_idx\) di chuyển tự do trên mảng. Khi chỉ số chạm cuối mảng, ta áp dụng phép toán modulo để quay vòng chỉ số trở lại vị trí \(0\).
- Trạng thái cây: \(front\_idx = 1\), \(rear\_idx = 0\) (đã quay vòng).
- Các phần tử thực tế trong Queue: \([2, 3, 4, 5, 6]\).
Trace chi tiết mảng vòng qua chuỗi thao tác (\(MAXN = 5\)):¶
| Thao tác | \(front\_idx\) | \(rear\_idx\) | Phần tử trong mảng | Trạng thái / Kết quả |
|---|---|---|---|---|
| Khởi tạo | \(0\) | \(0\) | [_, _, _, _, _] |
Hàng đợi rỗng |
push(1) |
\(0\) | \(1\) | [1, _, _, _, _] |
— |
push(2) |
\(0\) | \(2\) | [1, 2, _, _, _] |
— |
push(3) |
\(0\) | \(3\) | [1, 2, 3, _, _] |
— |
pop() |
\(1\) | \(3\) | [_, 2, 3, _, _] |
Trả về \(1\) |
push(4) |
\(1\) | \(4\) | [_, 2, 3, 4, _] |
— |
push(5) |
\(1\) | \(0\) | [_, 2, 3, 4, 5] |
Chỉ số quay vòng về \(0\) |
push(6) |
\(2\) | \(0\) | [6, 2, 3, 4, 5] |
Ghi đè lên vị trí \(0\) đã trống |
2.2. Cài đặt bằng Danh sách liên kết đơn (Linked List)¶
Ta duy trì hai con trỏ:
* head: Trỏ vào nút đầu tiên của danh sách (vị trí lấy phần tử).
* tail: Trỏ vào nút cuối cùng của danh sách (vị trí thêm phần tử mới).
3. Mã nguồn Cài đặt Queue hoàn chỉnh¶
Dưới đây là mã nguồn cài đặt chi tiết của cả hai cách tiếp cận:
3.1. Cài đặt bằng Mảng vòng¶
3.2. Cài đặt bằng Danh sách liên kết¶
3.3. Sử dụng thư viện chuẩn của ngôn ngữ (priority dùng trong thi đấu)¶
4. Chứng minh toán học và Tính đúng đắn của Thuật toán¶
4.1. Chứng minh toán học về tính đúng đắn của mảng vòng (Circular Array)¶
Trong cài đặt hàng đợi bằng mảng vòng với kích thước \(MAXN\), ta cần chứng minh các thuộc tính sau luôn được đảm bảo:
- Giới hạn chỉ số (Index Boundedness):
Tại mọi thời điểm, chỉ số \(front\_idx\) và \(rear\_idx\) luôn nằm trong khoảng hợp lệ \([0, MAXN - 1]\).
- Chứng minh: Phép toán dịch chuyển chỉ số chỉ sử dụng công thức tăng modulo: $\(idx_{\text{mới}} = (idx_{\text{cũ}} + 1) \bmod MAXN\)$ Theo tính chất của phép chia lấy dư đối với số nguyên dương \(MAXN\), giá trị của \((a \bmod MAXN)\) luôn thuộc tập hợp \(\{0, 1, \ldots, MAXN-1\}\). Do đó chỉ số không bao giờ vượt biên mảng.
- Không ghi đè dữ liệu chưa xử lý (No Data Overwriting):
Một phần tử mới chỉ được thêm vào khi hàng đợi chưa đầy (\(cnt < MAXN\)).
- Chứng minh: Khoảng dữ liệu hợp lệ trên mảng vòng chiếm các ô chỉ số thuộc tập hợp: $\(I = \{ (front\_idx + k) \bmod MAXN \mid 0 \leq k < cnt \}\)$ Khi \(cnt < MAXN\), phần tử mới sẽ được chèn vào vị trí \(rear\_idx = (front\_idx + cnt) \bmod MAXN\). Do \(cnt < MAXN\), ta có \(rear\_idx \notin I\). Như vậy vị trí chèn mới luôn là ô trống, không đè lên bất kỳ phần tử hợp lệ nào có sẵn.
- Bất biến FIFO (FIFO Invariant):
Phần tử được đưa vào đầu tiên (\(k=0\) trong tập hợp \(I\)) sẽ là phần tử được lấy ra khi thực hiện
pop().- Chứng minh: Thao tác
pop()lấy giá trị tại chỉ số \(front\_idx\) (ứng với \(k=0\)), sau đó dịch chuyển \(front\_idx \leftarrow (front\_idx + 1) \bmod MAXN\). Phần tử kế tiếp (\(k=1\)) trở thành đầu hàng mới (\(k=0\) mới). Bất biến FIFO được bảo toàn.
- Chứng minh: Thao tác
4.2. Tại sao BFS sử dụng Queue đảm bảo đường đi ngắn nhất?¶
Khi duyệt đồ thị không trọng số bằng thuật toán duyệt theo chiều rộng (BFS): * Bất biến của hàng đợi BFS: Khoảng cách từ đỉnh nguồn \(S\) đến các đỉnh trong Queue luôn có tính đơn điệu tăng dần. Cụ thể, nếu Queue chứa các đỉnh \([v_1, v_2, \ldots, v_m]\) thì: $\(dist[v_1] \leq dist[v_2] \leq \ldots \leq dist[v_m] \leq dist[v_1] + 1\)$ * Hệ quả: Khi đỉnh \(u\) được ghé thăm lần đầu tiên thông qua cung \((v, u)\), ta có \(dist[u] = dist[v] + 1\). Vì các đỉnh được lấy ra theo thứ tự tăng dần của khoảng cách, ta đảm bảo \(dist[v]\) là đường đi ngắn nhất từ \(S\) đến \(v\), dẫn tới \(dist[u]\) cũng là đường đi ngắn nhất từ \(S\) đến \(u\).
5. Ứng dụng cốt lõi: Thuật toán duyệt theo chiều rộng (BFS)¶
BFS là ứng dụng quan trọng nhất của Queue trên đồ thị vô hướng/có hướng không trọng số.
Cài đặt Thuật toán BFS mẫu¶
Trace chi tiết các bước duyệt BFS trên đồ thị mẫu¶
Đồ thị gồm các cạnh: 1-2, 1-3, 2-4, 2-5, 3-4. Duyệt BFS bắt đầu từ đỉnh \(1\):
| Bước | Trạng thái Queue (trước khi pop) | Đỉnh được xử lý (\(v\)) | Thêm vào Queue | Trạng thái khoảng cách (\(dist\)) |
|---|---|---|---|---|
| 1 | [1] |
\(1\) | \(2, 3\) | \(dist[1]=0, dist[2]=1, dist[3]=1\) |
| 2 | [2, 3] |
\(2\) | \(4, 5\) | \(dist[4]=2, dist[5]=2\) |
| 3 | [3, 4, 5] |
\(3\) | — | Đỉnh \(4\) đã được thăm, không thêm lại |
| 4 | [4, 5] |
\(4\) | — | — |
| 5 | [5] |
\(5\) | — | — |
Thứ tự duyệt đỉnh: \(1 \to 2 \to 3 \to 4 \to 5\).
6. Các lỗi và cạm bẫy thường gặp trong thi đấu¶
6.1. Dùng kiểu dữ liệu list thay cho deque trong Python¶
6.2. Đánh dấu visited sai thời điểm trong BFS¶
- Lỗi: Đánh dấu đỉnh đã duyệt khi lấy đỉnh đó ra khỏi Queue (
pop). - Hậu quả: Trong đồ thị mật độ cao, một đỉnh có thể được nối với nhiều đỉnh khác đang cùng nằm trong Queue. Do chưa bị
popnên nó chưa được đánh dấuvisited, dẫn đến việc nó bị chèn lặp lại rất nhiều lần vào Queue, làm bùng nổ bộ nhớ và gây ra lỗi quá thời gian (TLE). - Cách sửa: Luôn đánh dấu
visited = truengay tại bước thêm vào Queue (push).
7. Bài tập luyện tập phân cấp¶
7.1. Cấp độ Cơ bản¶
- CSES - Message Route: BFS cơ bản tìm đường đi ngắn nhất và truy vết đường đi.
- CSES - Labyrinth: BFS trên lưới ô vuông 2D tìm đường đi ngắn nhất.
- LeetCode - Binary Tree Level Order Traversal: Duyệt cây nhị phân theo từng tầng.
7.2. Cấp độ Trung bình & Nâng cao¶
- CSES - Monsters: Multi-source BFS (tìm khoảng cách từ nhiều điểm xuất phát của quái vật đồng thời).
- CSES - Course Schedule: Thuật toán Kahn tìm thứ tự sắp xếp topo sử dụng Queue.
- CSES - Game Routes: Sử dụng BFS kết hợp Quy hoạch động đếm số đường đi trên đồ thị có hướng không chu trình (DAG).