Thuật toán & Cấu trúc dữ liệu — Thực chiến/PriorityQueue — Top-K, scheduler, Dijkstra cần queue thứ tự
~18 phútCấu trúc tuyến tínhMiễn phí lượt xem

PriorityQueue — Top-K, scheduler, Dijkstra cần queue thứ tự

ArrayList scan max là O(n) mỗi lần — với 1 triệu task và 1000 lookup/giây, đây là bottleneck rõ ràng. PriorityQueue dùng binary heap để đảm bảo O(log n) insert và O(log n) extract-min. Bài này giới thiệu API, pattern top-K, và use case thực tế.

Task scheduler nhận 1 triệu task với priority khác nhau từ 1 đến 100. Mỗi khi một worker rảnh, scheduler phải chọn task có priority cao nhất đang chờ. Cách đơn giản nhất: duyệt toàn bộ ArrayList, tìm max — O(n) mỗi lookup. Với 1 triệu task và 1000 lookup mỗi giây, mỗi lookup tốn 1ms — 1000 lookup chiếm hết một giây CPU chỉ để tìm task tiếp theo. Worker chờ, throughput sụt.

Cần cấu trúc dữ liệu hỗ trợ O(log n) insert và O(log n) extract theo thứ tự priority — không cần scan toàn bộ. PriorityQueue trong Java làm đúng điều này, dùng binary heap bên dưới.

Bài này giới thiệu PriorityQueue qua API và use case (top-K, Dijkstra, scheduler) — chi tiết binary heap structure để Module 4 lesson 05 dive vào.

1. Analogy — Phòng khám ưu tiên

Phòng khám có nhiều bệnh nhân đang chờ, mỗi người có mức độ ưu tiên khác nhau: cấp cứu ưu tiên cao nhất, tái khám thông thường ưu tiên thấp. Y tá không phục vụ theo thứ tự đến — y tá luôn gọi bệnh nhân có mức ưu tiên cao nhất tiếp theo.

Không cần scan toàn bộ phòng chờ mỗi lần gọi — danh sách đã được tổ chức sao cho bệnh nhân ưu tiên cao nhất luôn ở vị trí đầu. Khi có bệnh nhân mới đến, họ được chèn vào đúng vị trí theo priority.

Phòng khámPriorityQueue
Bệnh nhân mới đến đăng kýoffer(x) — insert, O(log n)
Y tá gọi tên bệnh nhân ưu tiên nhấtpoll() — lấy và xóa top, O(log n)
Nhìn bảng để biết ai tiếp theopeek() — xem top, không xóa, O(1)
Phòng chờ không aiisEmpty() — kiểm tra rỗng
Priority cấp cứu cao hơn tái khámComparator — định nghĩa thứ tự
💡 Cách nhớ

PriorityQueue: không phải FIFO — phần tử có priority cao nhất (mặc định: nhỏ nhất) luôn được poll trước, bất kể thứ tự insert.

2. API qua góc người dùng

Để dùng PriorityQueue hiệu quả, không cần biết binary heap là gì — chỉ cần hiểu bốn operation và complexity của chúng.

OperationMethodComplexityGhi chú
Insertoffer(x) hoặc add(x)O(log n)offer trả false nếu có capacity limit; add ném exception
Xem toppeek()O(1)Trả null nếu rỗng
Lấy và xóa toppoll()O(log n)Trả null nếu rỗng
Duyệtfor (T x : pq)O(n)Thứ tự heap, không phải sorted

Demo cơ bản:

PriorityQueue<Integer> pq = new PriorityQueue<>();
pq.offer(3);
pq.offer(1);
pq.offer(2);

// poll() extracts minimum each time -- default is min-heap
while (!pq.isEmpty()) {
    System.out.println(pq.poll()); // prints: 1, 2, 3 (sorted ascending)
}
⚠️ Iteration không sorted

for (int x : pq) KHÔNG trả về thứ tự sorted. Nó duyệt theo thứ tự heap nội bộ — gần như ngẫu nhiên. Chỉ dùng poll() lặp lại để lấy thứ tự sorted.

3. Min-heap vs max-heap và custom comparator

Java PriorityQueue mặc định là min-heap: phần tử nhỏ nhất theo natural ordering sẽ được poll() trước.

// Default: min-heap -- natural ordering ascending
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
minHeap.offer(5); minHeap.offer(1); minHeap.offer(3);
minHeap.poll(); // returns 1 (minimum)

// Max-heap: reverse the comparator
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Comparator.reverseOrder());
maxHeap.offer(5); maxHeap.offer(1); maxHeap.offer(3);
maxHeap.poll(); // returns 5 (maximum)

Với custom object, dùng comparator để định nghĩa thứ tự:

record Task(String name, int priority) {}

// Lower priority number = higher urgency (e.g. priority 1 runs before priority 10)
PriorityQueue<Task> scheduler = new PriorityQueue<>(
    Comparator.comparingInt(Task::priority)
);
scheduler.offer(new Task("backup",   10));
scheduler.offer(new Task("alert",     1));
scheduler.offer(new Task("report",    5));
scheduler.poll(); // returns Task("alert", 1) -- lowest number = highest urgency
⚠️ Pitfall: comparator trả a - b có thể overflow

Viết (a, b) -> a.priority - b.priority có vẻ ngắn gọn nhưng tiềm ẩn bug: khi a.priority = Integer.MAX_VALUEb.priority âm, phép trừ tràn số nguyên (integer overflow) — kết quả âm giả, thứ tự sai hoàn toàn. Dùng Comparator.comparingInt(Task::priority) hoặc Integer.compare(a.priority, b.priority) thay thế.

4. Cấu trúc bên dưới — binary heap (preview)

PriorityQueue lưu dữ liệu trong một array thông thường, nhưng tổ chức theo cây nhị phân ngầm định:

Min-heap với các phần tử [1, 3, 2, 7, 5, 4]:

         1                   array representation:
        / \                  index: [0, 1, 2, 3, 4, 5]
       3   2                 value: [1, 3, 2, 7, 5, 4]
      / \ /
     7  5 4                  parent of i: (i-1)/2
                             children of i: 2*i+1 (left), 2*i+2 (right)

Tính chất heap: mỗi node nhỏ hơn hoặc bằng cả hai con — đảm bảo root luôn là phần tử nhỏ nhất. Insert và extract-min dùng thao tác "sift-up" và "sift-down" trên array này để khôi phục tính chất heap sau mỗi thay đổi.

Cấu trúc này là lưu trong array, không dùng pointer — cache-friendly và không tốn overhead per-node như linked tree. Chi tiết sift-up, sift-down, và buildHeap O(n) để Module 4 lesson 05 phân tích.

5. Top-K pattern

Tìm K phần tử nhỏ nhất trong stream 1 triệu số nguyên. Đây là bài toán cổ điển xuất hiện trong interview và production analytics.

Naive approach: sort toàn bộ array → O(n log n) time, O(n) space. Không thể dùng với streaming data vì phải load toàn bộ vào RAM.

PriorityQueue approach: dùng max-heap kích thước K.

Ý tưởng: heap luôn giữ K phần tử nhỏ nhất đã thấy cho đến nay. Top của max-heap là phần tử lớn nhất trong nhóm K đó — gọi là "threshold". Khi có phần tử mới nhỏ hơn threshold, nó đủ điều kiện vào nhóm K: insert vào heap và pop threshold ra.

public int[] topKSmallest(int[] nums, int k) {
    // Max-heap of size k: top = largest among the k smallest seen so far
    PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Comparator.reverseOrder());

    for (int n : nums) {
        maxHeap.offer(n);
        if (maxHeap.size() > k) {
            maxHeap.poll(); // evict the largest -- it does not belong in top-k
        }
    }

    // Drain heap into result array in ascending order
    int[] result = new int[k];
    for (int i = k - 1; i >= 0; i--) {
        result[i] = maxHeap.poll();
    }
    return result;
}

Complexity: O(n log k) time — mỗi trong n phần tử tốn O(log k) cho insert/poll vào heap kích thước K. O(k) space — heap không bao giờ vượt kích thước K, không cần load toàn bộ n vào RAM. Với n = 1 triệu và k = 10, thực tế gần O(n)log 10 là hằng số.

LeetCode 215 (Kth Largest Element in an Array) dùng cùng pattern với min-heap thay max-heap.

6. Pitfall tổng hợp

Pitfall 1 — Iteration không cho sorted output

PriorityQueue<Integer> pq = new PriorityQueue<>();
pq.offer(3); pq.offer(1); pq.offer(2);

// WRONG: for-each iterates in heap order, NOT sorted order
for (int x : pq) {
    System.out.print(x + " "); // may print: 1 3 2 (heap order, unpredictable)
}

// CORRECT: use poll() repeatedly
while (!pq.isEmpty()) {
    System.out.print(pq.poll() + " "); // prints: 1 2 3 (sorted ascending)
}

Lý do: iterator() của PriorityQueue duyệt backing array theo thứ tự index — đây là heap order, không phải sorted order. Chỉ poll() mới đảm bảo thứ tự đúng.

Pitfall 2 — Comparator trả a - b overflow

// WRONG: subtraction can overflow with extreme int values
PriorityQueue<Integer> pq1 = new PriorityQueue<>((a, b) -> a - b);
// If a = Integer.MAX_VALUE (2147483647) and b = -1:
// a - b = 2147483647 - (-1) = 2147483648 -- overflows to -2147483648
// Comparator returns negative -> wrong ordering

// CORRECT: use Integer.compare or Comparator.comparingInt
PriorityQueue<Integer> pq2 = new PriorityQueue<>(Integer::compare);
PriorityQueue<Integer> pq3 = new PriorityQueue<>(Comparator.comparingInt(x -> x));

Pitfall 3 — PriorityQueue không thread-safe

// WRONG: concurrent offer/poll corrupts the heap
PriorityQueue<Task> shared = new PriorityQueue<>();
// Thread 1: shared.offer(new Task(...));
// Thread 2: shared.poll();
// Race condition: internal array can be in inconsistent state

// CORRECT option 1: PriorityBlockingQueue for concurrent producer-consumer
PriorityBlockingQueue<Task> concurrent = new PriorityBlockingQueue<>();

// CORRECT option 2: external synchronization (if access pattern is simple)
// synchronized(lock) { shared.offer(task); }

PriorityBlockingQueue là unbounded blocking queue với ordering đảm bảo thread-safe. poll() block khi empty (khác PriorityQueue.poll() trả null). Phù hợp cho concurrent task scheduler pattern.

7. Applied to tech

Dijkstra shortest path (Module 5): mỗi vertex có distance estimate ban đầu là vô cực, trừ source = 0. PriorityQueue chứa các (distance, vertex) pair, poll ra vertex có distance nhỏ nhất để relax tiếp. Không dùng PQ thì phải scan toàn bộ V vertices mỗi vòng lặp — O(V²). Dùng PQ giảm xuống O((V+E) log V) — với graph thưa (sparse), cải thiện đáng kể.

Task scheduler / cron daemon — danh sách job được đưa vào min-heap theo trigger time. Top của heap luôn là job cần chạy sớm nhất tiếp theo. Scheduler peek() để biết thời gian chờ đến job kế, poll() khi đến giờ. Không cần scan toàn bộ danh sách job mỗi tick.

Kafka producer batch — messages được nhóm theo partition, flush theo priority dựa trên oldest unflushed timestamp (TTL pattern). Min-heap theo timestamp đảm bảo partition chờ lâu nhất được flush trước — tránh starvation khi throughput cao.

Event-driven simulation (discrete event simulation) — tất cả future events được đặt vào min-heap theo timestamp. Mỗi bước: poll() event sớm nhất, xử lý, và các event mới phát sinh từ đó được offer() vào heap. PQ là "clock" của toàn bộ simulation.

8. Deep Dive

📚 Deep Dive — nguồn tham khảo

Source code và tài liệu:

Cross-link trong khóa học:

  • Module 4 lesson 05 (sắp ship) — Binary heap deep dive: sift-up, sift-down, buildHeap O(n) proof, heapsort, indexed PQ.
  • Module 5 lesson 05 (sắp ship) — Dijkstra với PriorityQueue: O((V+E) log V) implementation đầy đủ.
  • Bài 04 Module 2 — ArrayDeque circular array: PQ cũng dùng backing array, cùng nguyên lý cache-friendly layout.

9. Tóm tắt

  • PriorityQueue không phải FIFO — phần tử có priority cao nhất (mặc định: giá trị nhỏ nhất) luôn được poll() trước. O(log n) insert và extract, O(1) peek.
  • Mặc định min-heap: poll() trả minimum. Max-heap dùng Comparator.reverseOrder(). Custom object dùng Comparator.comparingInt(...).
  • Iteration không sorted: for-each duyệt heap order — dùng poll() liên tiếp để lấy thứ tự sorted.
  • Comparator a - b overflow: dùng Integer.compare(a, b) hoặc Comparator.comparingInt(...) để tránh integer overflow với giá trị cực đại.
  • Không thread-safe: dùng PriorityBlockingQueue cho concurrent producer-consumer.
  • Top-K pattern: max-heap kích thước K, O(n log k) time, O(k) space — không cần load toàn bộ n vào RAM.
  • Use case thực tế: Dijkstra shortest path, task scheduler, event-driven simulation, producer batch flush theo TTL.

10. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao Java chọn min-heap làm mặc định thay max-heap? Để có max-heap thì làm thế nào?

Java theo quy ước natural ordering: đối tượng nhỏ hơn (theo compareTo hoặc Comparator) được coi là "higher priority". Với số nguyên, nhỏ hơn = priority cao hơn — min-heap phù hợp với convention này. Dijkstra cũng cần extract vertex có distance nhỏ nhất — min-heap tự nhiên.

Để có max-heap: new PriorityQueue<>(Comparator.reverseOrder()). Với custom object: Comparator.comparingInt(obj -> -obj.score) (đảo dấu) hoặc (a, b) -> Integer.compare(b.score, a.score) (đảo thứ tự a, b). Cách đảo dấu dễ gây nhầm — nên dùng reversed() hoặc reverseOrder() tường minh hơn.

Q2
Top-K dùng max-heap kích thước K thay vì sort toàn bộ — tradeoff time và space cụ thể là gì?

Sort full: O(n log n) time, O(n) space — phải load toàn bộ n phần tử vào memory trước khi sort. Không dùng được với streaming data (vô hạn hoặc quá lớn).

Max-heap kích thước K: O(n log k) time, O(k) space. Mỗi phần tử tốn O(log k) cho insert/poll vào heap kích thước K. Heap không bao giờ vượt K phần tử — xử lý từng phần tử một, không cần toàn bộ dataset trong RAM. Với K nhỏ (ví dụ K=10), log k gần như hằng số — thuật toán gần O(n) thực tế.

Q3
Đoạn sau in gì? PriorityQueue<Integer> pq = new PriorityQueue<>(); pq.offer(5); pq.offer(2); pq.offer(8); pq.offer(1); — sau đó dùng for-each để in?

Kết quả không phải 1 2 5 8 (sorted). For-each duyệt backing array theo thứ tự index — heap order, không phải sort order. Output có thể là 1 2 8 5 hoặc thứ tự khác tùy cách heap tổ chức nội bộ.

Tính chất heap chỉ đảm bảo: root (index 0) là minimum, và mỗi parent nhỏ hơn hoặc bằng hai con. Không có đảm bảo gì về thứ tự giữa các sibling hay các node cùng cấp. Để in sorted: dùng while (!pq.isEmpty()) { System.out.print(pq.poll()); }.

Q4
Comparator viết (a, b) -> a - b có gì sai? Trường hợp nào gây bug production?

Phép trừ a - b có thể bị integer overflow khi a và b có dấu khác nhau và cách xa nhau. Ví dụ: a = 2147483647 (MAX_VALUE) và b = -1a - b = 2147483648 — vượt quá giới hạn int, overflow thành -2147483648. Comparator trả giá trị âm → thuật toán xếp sai thứ tự, heap mất tính chất, poll() không trả minimum.

Bug tinh vi vì với data thông thường (priority 1-100) không bao giờ xảy ra — chỉ bộc lộ khi test với giá trị cực đại hoặc âm. Fix: Integer.compare(a, b) hoặc Comparator.comparingInt(x -> x) — không dùng phép trừ.

Q5
Khi nào nên dùng PriorityBlockingQueue thay vì PriorityQueue với external synchronization?

Dùng PriorityBlockingQueue khi: có nhiều producer thread và consumer thread cùng truy cập queue, hoặc consumer cần block và chờ khi queue rỗng (thay vì polling liên tục). PriorityBlockingQueue.poll(timeout) block tối đa timeout ms — không tốn CPU spin. Phù hợp cho thread pool executor với priority task.

External sync phù hợp khi: code đã có lock bao quanh nhiều thao tác liên quan đến queue (ví dụ kiểm tra điều kiện rồi mới offer — cần atomic), hoặc cần dùng chung lock với resource khác để tránh deadlock phức tạp. Nhược điểm: dễ quên đồng bộ, khó audit. Rule chung: nếu chỉ cần thread-safe queue, dùng PriorityBlockingQueue — ít error-prone hơn external sync.

Bài tiếp theo: Mini-challenge: LRU Cache thủ công

Bài này có giúp bạn hiểu bản chất không?