Thuật toán & Cấu trúc dữ liệu — Thực chiến/Queue & Deque — FIFO scheduler, sliding window, và ArrayDeque thay LinkedList
~22 phútCấu trúc tuyến tínhMiễn phí lượt xem

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àuQueue
Người đứng vào cuối hàngoffer(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ốngisEmpty() — kiểm tra rỗng
Đếm số ngườisize() — số phần tử
💡 Cách nhớ

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ểmJava implementation
Queue (basic)FIFO, 1 đầu vào 1 đầu raArrayDeque, LinkedList (qua interface Queue)
Deque (double-ended)Vào ra cả hai đầu — stack lẫn queueArrayDeque, LinkedList
PriorityQueueVào theo thứ tự bất kỳ, ra theo priority (min-heap)java.util.PriorityQueue
BlockingQueueThread-safe, block khi full hoặc emptyLinkedBlockingQueue, 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ácNém exceptionTrả về giá trị đặc biệtÝ nghĩa
Thêm vào tailadd(x)offer(x)add ném IllegalStateException nếu queue full (bounded); offer trả false
Lấy và xóa headremove()poll()remove ném NoSuchElementException nếu empty; poll trả null
Xem head không xóaelement()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ả headtail 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ả ListDeque — 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 headtail 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íArrayDequeLinkedList
Memory layoutLiên tiếp — cache-friendlyRải rác — pointer chasing
Insert/remove at endsAmortized O(1), no allocO(1) nhưng alloc Node object mỗi lần
Memory overheadSlack tối đa 2x capacity2 pointer (prev, next) + object header ~40 byte/phần tử
GC pressureThấp — ít objectCao — mỗi enqueue tạo 1 Node
Throughput insertKhoảng 5x nhanh hơnBaseline
Cache missThấpCao — mỗi next là pointer chase
Null elementsKhông cho phépCho 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.000k = 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:

  1. Loại index cũ hơn window: nếu dq.peekFirst() <= i - k, pop front.
  2. Duy trì giảm dần: pop back cho đến khi nums[dq.peekLast()] >= nums[i], rồi push i vào back.
  3. 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:

inums[i]Deque (indices)Deque (values)result
01[0][1]
13[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
45[4][5]result[2] = 5 (pop 1,2,3 karena value kecil; pop 1 karena out of window)
53[4, 5][5, 3]result[3] = 5
66[6][6]result[4] = 6 (pop 4,5 karena value kecil)
77[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ầnpop 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 pushoffer, 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 pushoffer, đó 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

📚 Deep Dive — nguồn tham khảo

Source code và spec:

  • OpenJDK 21 — ArrayDeque.java — Đọc addFirst(), addLast(), grow(), và circular index arithmetic. So sánh với LinkedList.java để thấy sự khác biệt về memory allocation.
  • OpenJDK 21 — LinkedList.java — Xem cách Node được alloc mỗi lần add.
  • JCIP (Java Concurrency in Practice, Goetz) — Chapter 5: BlockingQueue patterns, producer-consumer, bounded buffer.
  • Effective Java Item 49 (Check parameters for validity) — lý do ArrayDeque ném NullPointerException khi offer(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ới ArrayDeque.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/peek là API an toàn hơn add/remove/element.
  • 4 biến thể: Queue (FIFO), Deque (two-ended), PriorityQueue (heap-backed), BlockingQueue (concurrent). Chọn đúng loại theo use case.
  • ArrayDeque thay LinkedList trong mọi trường hợp queue/deque thông thường: cache-friendly, ít GC, throughput cao hơn khoảng 5x.
  • ArrayDeque không cho null: null là sentinel nội bộ cho "slot trống". Dùng Optional<T> nếu cần biểu diễn giá trị vắng mặt.
  • Circular array với head/tail index và bit trick (i + 1) & (capacity - 1) — hiệu quả hơn modulo, capacity luôn là power of 2.
  • Đừng mix pushoffer trên cùng một Dequepush = 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

Tự kiểm tra
Q1
Khá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.

Q2
Vì 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.

Q3
Sliding 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);

Đâ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.

Q5
Production: 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 ArrayBlockingQueueCallerRunsPolicy để 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.

Q6
Cho 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?