Queue & Deque — FIFO scheduler, sliding window, và ArrayDeque thay LinkedList
Queue là cấu trúc dữ liệu nền tảng của thread pool, BFS, message broker, và OS scheduler. Bài này phân biệt 4 biến thể queue trong Java, giải thích vì sao ArrayDeque thay thế LinkedList, và trình bày pattern sliding window dùng monotonic deque đạt O(n).
Bạn ra ga mua vé tàu. Có 50 người xếp hàng — người đến trước được phục vụ trước. Không ai được chen ngang, không ai được phục vụ từ giữa hàng. Đây là FIFO: First In, First Out.
Cùng cơ chế đó chạy bên dưới rất nhiều hệ thống: ThreadPoolExecutor nhận task theo thứ tự, BFS duyệt đồ thị theo từng lớp, Kafka giữ event theo offset, máy in xử lý tài liệu theo thứ tự nộp, OS scheduler đưa process vào CPU theo ready queue. Queue không phải khái niệm trừu tượng — nó là thứ quyết định thứ tự phục vụ trong mọi hệ thống có nhiều request cạnh tranh.
Bài này phân biệt 4 biến thể queue trong Java (Queue, Deque, PriorityQueue, BlockingQueue), giải thích khi nào chọn ArrayDeque thay LinkedList, và trình bày pattern sliding window dùng monotonic deque để đạt O(n).
1. Analogy — Hàng đợi mua vé
Hình dung hàng người xếp tại quầy vé tàu:
- Người mới đến đứng vào cuối hàng (
enqueue/offer). - Người đứng đầu hàng được phục vụ và rời đi (
dequeue/poll). - Không ai được nhảy vào giữa hoặc bị gọi ngẫu nhiên.
Đây là FIFO — phần tử vào trước sẽ ra trước.
| Hàng vé tàu | Queue |
|---|---|
| Người đứng vào cuối hàng | offer(x) — thêm vào tail |
| Người đầu hàng được phục vụ | poll() — lấy và xóa head |
| Nhìn người đầu hàng không phục vụ | peek() — xem head, không xóa |
| Hàng trống | isEmpty() — kiểm tra rỗng |
| Đếm số người | size() — số phần tử |
Queue: hai đầu, vào cuối ra đầu. Ngược với stack (LIFO — vào sau ra trước, một đầu duy nhất).
2. Bốn biến thể queue trong Java
Java cung cấp nhiều hơn một loại queue. Lựa chọn sai dẫn đến performance kém hoặc bug tinh vi:
| Loại | Đặc điểm | Java implementation |
|---|---|---|
| Queue (basic) | FIFO, 1 đầu vào 1 đầu ra | ArrayDeque, LinkedList (qua interface Queue) |
| Deque (double-ended) | Vào ra cả hai đầu — stack lẫn queue | ArrayDeque, LinkedList |
| PriorityQueue | Vào theo thứ tự bất kỳ, ra theo priority (min-heap) | java.util.PriorityQueue |
| BlockingQueue | Thread-safe, block khi full hoặc empty | LinkedBlockingQueue, ArrayBlockingQueue |
Queue basic là trường hợp đơn giản nhất — bài này tập trung vào đây và Deque.
PriorityQueue dùng cấu trúc heap bên dưới — sẽ phân tích chi tiết ở Module 4 khi học về heap sort và Dijkstra.
BlockingQueue dùng trong concurrent programming — producer/consumer pattern, ThreadPoolExecutor task queue. Sẽ deep dive trong Java Concurrency course (JCIP Chapter 5).
3. Hai API của Queue — throw vs return-special
Java Queue interface cung cấp hai phiên bản cho mỗi thao tác cơ bản: một ném exception khi fail, một trả về giá trị đặc biệt. Hiểu sự khác biệt tránh NPE và NoSuchElementException bất ngờ trong production:
| Thao tác | Ném exception | Trả về giá trị đặc biệt | Ý nghĩa |
|---|---|---|---|
| Thêm vào tail | add(x) | offer(x) | add ném IllegalStateException nếu queue full (bounded); offer trả false |
| Lấy và xóa head | remove() | poll() | remove ném NoSuchElementException nếu empty; poll trả null |
| Xem head không xóa | element() | peek() | element ném NoSuchElementException nếu empty; peek trả null |
Rule thực tế: chọn một phong cách và nhất quán trong codebase. Production code thường ưa offer/poll/peek vì trả null an toàn hơn là throw — dễ kiểm tra if (result != null) hơn là bắt exception trong hot path. Chỉ dùng add/remove/element khi muốn fail nhanh và rõ ràng (contract violation là lỗi nghiêm trọng, không phải condition thông thường).
4. Implement Queue bằng linked list
Linked list là cấu trúc tự nhiên nhất để implement queue: enqueue thêm vào tail, dequeue lấy từ head — cả hai đều O(1) nếu giữ tham chiếu đến cả hai đầu.
public class MyQueue<T> {
// Doubly linked node -- prev not needed for queue, but next is
private static class Node<T> {
T data;
Node<T> next;
Node(T data) { this.data = data; }
}
private Node<T> head; // front -- dequeue from here
private Node<T> tail; // back -- enqueue here
private int size;
// O(1): add new node at tail
public void enqueue(T item) {
Node<T> node = new Node<>(item);
if (tail != null) tail.next = node;
tail = node;
if (head == null) head = node; // first element
size++;
}
// O(1): remove node from head
public T dequeue() {
if (head == null) throw new IllegalStateException("Queue is empty");
T item = head.data;
head = head.next;
if (head == null) tail = null; // queue is now empty
size--;
return item;
}
public T peek() {
if (head == null) throw new IllegalStateException("Queue is empty");
return head.data;
}
public boolean isEmpty() { return head == null; }
public int size() { return size; }
}
Key detail: giữ cả head và tail pointer. Nếu chỉ giữ head, mỗi enqueue phải traverse đến cuối — O(n). Cross-link: cấu trúc Node và trade-off singly linked list đã phân tích ở bài 02 Module 2.
5. ArrayDeque internals — vì sao thay LinkedList
java.util.LinkedList implement cả List và Deque — nghe có vẻ tiện. Nhưng trong thực tế, ArrayDeque thắng đáng kể về throughput và memory. Hiểu cơ chế bên dưới giải thích tại sao.
5.1 Circular array backing
ArrayDeque dùng một circular array — array thông thường nhưng với head và tail index có thể "quay vòng". Khi tail đến cuối array, nó wrap around về index 0 thay vì làm array grow.
Circular array với capacity=8, head=5, tail=2:
Index: 0 1 2 3 4 5 6 7
[D] [E] [ ] [ ] [ ] [A] [B] [C]
^tail ^head
Elements in queue order: A, B, C, D, E
addLast tăng tail: tail = (tail + 1) & (capacity - 1).
pollFirst tăng head: head = (head + 1) & (capacity - 1).
Bit trick (index + 1) & (capacity - 1) thay cho (index + 1) % capacity — hoạt động vì capacity luôn là power of 2. Bit AND nhanh hơn modulo đáng kể trong tight loop. Cross-link: tại sao power-of-2 và cache layout quan trọng — bài 04 Module 1.
5.2 OpenJDK source — addFirst và addLast
// Trich tu OpenJDK 21 -- java.util.ArrayDeque
public void addFirst(E e) {
if (e == null)
throw new NullPointerException();
final Object[] es = elements;
es[head = dec(head, es.length)] = e;
if (head == tail)
grow(1);
}
public void addLast(E e) {
if (e == null)
throw new NullPointerException();
final Object[] es = elements;
es[tail] = e;
if (head == (tail = inc(tail, es.length)))
grow(1);
}
// Circular increment: (i + 1) & (length - 1)
static final int inc(int i, int modulus) {
if (++i >= modulus) i = 0;
return i;
}
// Circular decrement: (i - 1) & (length - 1)
static final int dec(int i, int modulus) {
if (--i < 0) i = modulus - 1;
return i;
}
Lưu ý addFirst kiểm tra null ngay đầu — đây là lý do ArrayDeque không cho phép null elements (xem Pitfall section).
5.3 Grow và amortized O(1)
Khi backing array đầy (head == tail), grow() double capacity và copy toàn bộ array. Amortized O(1) — giống ArrayList.grow() ở bài 07 Module 1, nhưng circular array phức tạp hơn vì phải unwrap vòng tròn khi copy.
5.4 So sánh ArrayDeque vs LinkedList
| Tiêu chí | ArrayDeque | LinkedList |
|---|---|---|
| Memory layout | Liên tiếp — cache-friendly | Rải rác — pointer chasing |
| Insert/remove at ends | Amortized O(1), no alloc | O(1) nhưng alloc Node object mỗi lần |
| Memory overhead | Slack tối đa 2x capacity | 2 pointer (prev, next) + object header ~40 byte/phần tử |
| GC pressure | Thấp — ít object | Cao — mỗi enqueue tạo 1 Node |
| Throughput insert | Khoảng 5x nhanh hơn | Baseline |
| Cache miss | Thấp | Cao — mỗi next là pointer chase |
| Null elements | Không cho phép | Cho phép (nhưng là dấu hiệu thiết kế kém) |
Rule thực tế: luôn dùng ArrayDeque thay LinkedList cho queue/deque trong production Java, trừ khi cần random access (dùng ArrayList) hoặc cần null sentinel (thiết kế lại — dùng Optional).
6. Sliding window pattern — monotonic deque
Đây là ứng dụng nổi tiếng nhất của Deque trong competitive programming và system coding interview.
6.1 Bài toán
Sliding Window Maximum (LeetCode 239): Cho array nums kích thước n và window size k, trả về mảng max của từng window có size k.
Ví dụ: nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3.
Window [1, 3, -1] -> max = 3
Window [3, -1, -3] -> max = 3
Window [-1, -3, 5] -> max = 5
Window [-3, 5, 3] -> max = 5
Window [5, 3, 6] -> max = 6
Window [3, 6, 7] -> max = 7
Naive: với mỗi trong n - k + 1 window, scan k phần tử → O(n*k). Với n = 100.000 và k = 10.000, đây là 1 tỷ phép tính — quá chậm.
6.2 Ý tưởng monotonic deque
Dùng Deque<Integer> lưu index (không phải value), duy trì tính chất: các index trong deque ứng với value giảm dần từ đầu đến cuối deque. Front của deque luôn là index của max trong window hiện tại.
Tại mỗi bước i:
- Loại index cũ hơn window: nếu
dq.peekFirst() <= i - k, pop front. - Duy trì giảm dần: pop back cho đến khi
nums[dq.peekLast()] >= nums[i], rồi pushivào back. - Ghi kết quả: khi
i >= k - 1, max của window lànums[dq.peekFirst()].
6.3 Code Java đầy đủ
public int[] maxSlidingWindow(int[] nums, int k) {
Deque<Integer> dq = new ArrayDeque<>();
int[] result = new int[nums.length - k + 1];
for (int i = 0; i < nums.length; i++) {
// Step 1: remove indices that have fallen out of window
while (!dq.isEmpty() && dq.peekFirst() <= i - k) {
dq.pollFirst();
}
// Step 2: remove from back while back value is smaller than nums[i]
// (they can never be the max of any future window)
while (!dq.isEmpty() && nums[dq.peekLast()] < nums[i]) {
dq.pollLast();
}
dq.offerLast(i);
// Step 3: record max for current window (once first window is complete)
if (i >= k - 1) {
result[i - k + 1] = nums[dq.peekFirst()];
}
}
return result;
}
6.4 Trace ví dụ
nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3:
i | nums[i] | Deque (indices) | Deque (values) | result |
|---|---|---|---|---|
| 0 | 1 | [0] | [1] | — |
| 1 | 3 | [1] | [3] | — (3 masuk, pop 0 karena nums[0]=1 < 3) |
| 2 | -1 | [1, 2] | [3, -1] | result[0] = 3 |
| 3 | -3 | [1, 2, 3] | [3, -1, -3] | result[1] = 3 |
| 4 | 5 | [4] | [5] | result[2] = 5 (pop 1,2,3 karena value kecil; pop 1 karena out of window) |
| 5 | 3 | [4, 5] | [5, 3] | result[3] = 5 |
| 6 | 6 | [6] | [6] | result[4] = 6 (pop 4,5 karena value kecil) |
| 7 | 7 | [7] | [7] | result[5] = 7 (pop 6 karena value kecil) |
Output: [3, 3, 5, 5, 6, 7] — đúng.
6.5 Tại sao O(n)?
Mỗi index i được push vào deque đúng một lần và pop ra tối đa một lần (từ front hoặc back). Tổng số thao tác push + pop trên toàn bộ vòng lặp là 2n. Vòng lặp bên ngoài chạy n lần, hai vòng while bên trong không chạy thêm n lần — chúng chia sẻ budget 2n tổng cộng. Kết quả: O(n) time, O(k) space.
7. Pitfall tổng hợp
Pitfall 1 — LinkedList.poll() vs Queue.remove()
Queue<String> q = new LinkedList<>();
// q is empty
q.remove(); // throws NoSuchElementException -- dangerous in production
q.poll(); // returns null -- safe, but watch for NPE if you unbox
Chọn API nhất quán trong codebase. offer/poll/peek an toàn hơn cho business logic thông thường. add/remove/element phù hợp cho contract violation (invariant bị phá là bug nghiêm trọng, không phải case thông thường).
Pitfall 2 — ArrayDeque không cho null elements
Deque<String> dq = new ArrayDeque<>();
dq.offer(null); // throws NullPointerException immediately
// LinkedList cho phep null -- nhung day la dau hieu thiet ke kem
Deque<String> linked = new LinkedList<>();
linked.offer(null); // OK, but poll() returns null -- indistinguishable from "empty"
Lý do ArrayDeque từ chối null: null được dùng làm sentinel bên trong implementation để detect "slot trống" trong circular array. Nếu cho phép null element, code không thể phân biệt slot chưa dùng với phần tử null hợp lệ.
Nếu cần biểu diễn "không có giá trị", dùng Optional<T> hoặc thiết kế lại để tránh null trong queue.
Pitfall 3 — Nhầm Deque.push() với Deque.offer()
Deque có thể dùng cả làm stack (LIFO) lẫn queue (FIFO). Hai convention thao tác ở hai đầu khác nhau:
Deque<Integer> dq = new ArrayDeque<>();
// Stack semantics (LIFO): push/pop/peek -- thao tac o HEAD
dq.push(1); dq.push(2); dq.push(3);
dq.pop(); // returns 3 -- LIFO
// Queue semantics (FIFO): offer/poll/peek -- offer vao TAIL, poll tu HEAD
dq.offer(1); dq.offer(2); dq.offer(3);
dq.poll(); // returns 1 -- FIFO
push(x) tương đương addFirst(x) — thêm vào HEAD. offer(x) tương đương addLast(x) — thêm vào TAIL. poll() tương đương pollFirst() — lấy từ HEAD.
Nếu mix push và offer, queue lẫn lộn LIFO/FIFO — kết quả không đoán được. Chọn một convention và nhất quán. Khi review code thấy Deque dùng lẫn lộn push và offer, đó là bug.
8. Applied to tech
Java ThreadPoolExecutor: task queue quyết định loại thread pool. LinkedBlockingQueue unbounded → newCachedThreadPool. ArrayBlockingQueue bounded với capacity cố định → kiểm soát backpressure. SynchronousQueue không buffer → mỗi task cần thread available ngay. Chọn sai queue type có thể dẫn đến OOM (unbounded queue với producer nhanh hơn consumer) hoặc task rejection (bounded queue quá nhỏ).
Kafka topic partition: mỗi partition là append-only log — về bản chất là queue không giới hạn. Consumer đọc theo offset — giống poll nhưng không xóa, nhiều consumer group có thể đọc cùng offset độc lập. Module 8 (Big Data & Streaming) sẽ phân tích chi tiết.
BFS shortest path: BFS dùng queue để đảm bảo duyệt theo từng lớp — phần tử gần source được duyệt trước. Module 5 (Graph) sẽ implement BFS đầy đủ. Nếu thay queue bằng stack, thuật toán biến thành DFS — không còn đảm bảo tìm đường ngắn nhất.
ConcurrentLinkedQueue: lock-free queue dùng thuật toán Michael & Scott (1996) với CAS (Compare-And-Swap). Không block — producer và consumer có thể chạy concurrent mà không cần synchronized. Phù hợp khi cần non-blocking concurrency. Sẽ phân tích trong Module 2 extension về concurrent data structures.
OS scheduler ready queue: process chờ CPU được đưa vào ready queue. FIFO scheduler đơn giản. Round-robin thêm time quantum. Priority scheduler dùng PriorityQueue. Linux Completely Fair Scheduler (CFS) dùng red-black tree — nhưng nguyên lý cơ bản vẫn là "queue của process chờ được phục vụ".
9. Deep Dive
Source code và spec:
- OpenJDK 21 — ArrayDeque.java — Đọc
addFirst(),addLast(),grow(), và circular index arithmetic. So sánh vớiLinkedList.javađể thấy sự khác biệt về memory allocation. - OpenJDK 21 — LinkedList.java — Xem cách
Nodeđược alloc mỗi lầnadd. - JCIP (Java Concurrency in Practice, Goetz) — Chapter 5:
BlockingQueuepatterns, producer-consumer, bounded buffer. - Effective Java Item 49 (Check parameters for validity) — lý do
ArrayDequenémNullPointerExceptionkhioffer(null).
Cross-link trong khóa học:
- Bài 04 Module 1 — Cache locality và memory layout: vì sao array liên tiếp nhanh hơn pointer-chasing.
- Bài 07 Module 1 —
ArrayList.grow(): amortized analysis — cùng nguyên lý vớiArrayDeque.grow(). - Bài 02 Module 2 — Linked list: cấu trúc
Node, trade-off singly vs doubly linked list. - Module 5 (Graph) — BFS dùng queue để traverse breadth-first.
- Module 8 (Big Data & Streaming) — Kafka partition như append-only queue.
10. Tóm tắt
- Queue là FIFO: phần tử vào đầu tiên sẽ ra đầu tiên.
offer/poll/peeklà API an toàn hơnadd/remove/element. - 4 biến thể:
Queue(FIFO),Deque(two-ended),PriorityQueue(heap-backed),BlockingQueue(concurrent). Chọn đúng loại theo use case. ArrayDequethayLinkedListtrong mọi trường hợp queue/deque thông thường: cache-friendly, ít GC, throughput cao hơn khoảng 5x.ArrayDequekhông chonull:nulllà sentinel nội bộ cho "slot trống". DùngOptional<T>nếu cần biểu diễn giá trị vắng mặt.- Circular array với
head/tailindex và bit trick(i + 1) & (capacity - 1)— hiệu quả hơn modulo, capacity luôn là power of 2. - Đừng mix
pushvàoffertrên cùng mộtDeque—push = addFirst(LIFO),offer = addLast(FIFO), trộn lẫn → undefined ordering. - Monotonic deque giải bài Sliding Window Maximum trong
O(n): mỗi index push/pop tối đa 1 lần, tổng thao tác là2n.
11. Tự kiểm tra
Q1Khác biệt giữa Queue.offer() và Queue.add()? Khi nào nên chọn cái nào?▸
offer(x) trả về false nếu queue không thể nhận thêm phần tử (bounded queue đầy). add(x) ném IllegalStateException trong cùng tình huống. Với unbounded queue như ArrayDeque, cả hai luôn thành công.
Chọn offer/poll/peek khi "queue full/empty" là điều kiện bình thường cần xử lý trong logic — tránh exception overhead. Chọn add/remove/element khi full/empty là vi phạm contract nghiêm trọng — exception làm rõ bug ngay lập tức. Production code thường ưa offer/poll/peek để dễ kiểm tra null return hơn là try-catch.
Q2Vì sao ArrayDeque không cho null elements? LinkedList cho phép — đó có phải lợi thế không?▸
ArrayDeque dùng null làm sentinel nội bộ để phân biệt slot trống trong circular array. Nếu cho phép element null, code không thể biết slot đó rỗng hay chứa null hợp lệ.
Việc LinkedList cho phép null không phải lợi thế — đó là dấu hiệu thiết kế kém. Khi poll() trả null, caller không biết đó là "queue rỗng" hay "phần tử null hợp lệ" — ambiguous hoàn toàn. Giải pháp đúng: dùng Optional<T> nếu giá trị có thể vắng mặt, hoặc thiết kế lại để null không cần xuất hiện trong queue.
Q3Sliding Window Maximum dùng deque đạt O(n) bằng cơ chế nào? Mỗi index thực sự push/pop bao nhiêu lần tổng cộng?▸
Deque duy trì tính chất monotonic decreasing về value: front luôn là index của max trong window hiện tại, các index phía sau ứng với value nhỏ dần. Mỗi element nhỏ hơn element hiện tại bị pop ngay khi element hiện tại được thêm vào — vì chúng không thể là max của bất kỳ window nào trong tương lai (element mới lớn hơn và nằm trong window lâu hơn).
Mỗi index i được push đúng một lần (tại bước i) và pop tối đa một lần (từ front khi out-of-window, hoặc từ back khi có element lớn hơn đến). Tổng push + pop trên toàn bộ n phần tử là tối đa 2n — dù có hai vòng while lồng bên trong, chúng không tăng độ phức tạp vì budget là 2n tổng. Kết quả: O(n) time.
Q4Đoạn code sau dùng Deque làm stack hay queue? Kết quả poll() đầu tiên là gì?
Deque<Integer> dq = new ArrayDeque<>();
dq.push(1); dq.offer(2); dq.push(3);▸
Deque<Integer> dq = new ArrayDeque<>();dq.push(1); dq.offer(2); dq.push(3);Đây là ví dụ mix nguy hiểm giữa stack và queue semantics.
dq.push(1)=addFirst(1)→ head=1, state:[1]dq.offer(2)=addLast(2)→ tail=2, state:[1, 2]dq.push(3)=addFirst(3)→ head=3, state:[3, 1, 2]
dq.poll() = pollFirst() trả về 3. Không phải 1 (FIFO) cũng không phải 2 (LIFO thuần). Kết quả không đoán được khi mix hai convention. Rule: chọn hoàn toàn push/pop (LIFO) hoặc hoàn toàn offer/poll (FIFO) — không mix.
Q5Production: consumer xử lý chậm hơn producer đưa task vào queue — dấu hiệu gì quan sát được? Mitigation là gì?▸
Dấu hiệu: queue size tăng liên tục (monitor queue depth metric), latency của task tăng (task chờ lâu hơn trong queue), nếu dùng ArrayBlockingQueue bounded thì task rejection tăng, nếu dùng unbounded queue thì heap usage tăng dần đến OOM.
Mitigation theo thứ tự: (1) Scale consumer — thêm worker thread hoặc process. (2) Đặt backpressure — dùng bounded ArrayBlockingQueue và CallerRunsPolicy để slow down producer khi queue full. (3) Rate limit producer. (4) Tối ưu consumer (profiling, batch processing). (5) Circuit breaker — shed load thay vì tích lũy. Không để queue unbounded trong production với producer nhanh — OOM crash toàn bộ service.
Q6Cho BFS skeleton dùng queue — vì sao không thể thay queue bằng stack và vẫn tìm đường ngắn nhất?▸
BFS skeleton:
Queue<Node> q = new ArrayDeque<>();
q.offer(start);
while (!q.isEmpty()) {
Node curr = q.poll(); // FIFO: nearest node first
for (Node neighbor : curr.neighbors) {
if (!visited(neighbor)) {
q.offer(neighbor); // add to tail -- farther nodes wait
}
}
}Queue đảm bảo duyệt theo từng lớp (level-order): tất cả node cách source 1 bước được duyệt trước node cách 2 bước, v.v. Khi một node được poll() lần đầu, đó là path ngắn nhất đến nó.
Nếu thay queue bằng stack, thuật toán biến thành DFS — đi sâu vào một nhánh trước, không duyệt theo lớp. Node đầu tiên được "visit" không phải qua path ngắn nhất mà qua path sâu nhất trong nhánh đó. DFS không đảm bảo shortest path trong unweighted graph.
Bài tiếp theo: Circular buffer / ring queue
Bài này có giúp bạn hiểu bản chất không?