Chương 07 · Collections Framework

Collections Framework — List, Set, Queue

Mảng có kích thước cố định và gần như không có phương thức nào hữu ích. Collections Framework (nằm trong java.util) là bộ interface và class dựng sẵn để lưu trữ, thao tác và truy xuất nhóm đối tượng — thứ bạn sẽ dùng trong mọi chương trình Java từ giờ trở đi. Điều quan trọng nhất của chương này không phải là nhớ tên class, mà là chọn đúng cấu trúc cho đúng bài toán — vì sai lầm ở đây khiến chương trình chậm hàng trăm lần mà không có lỗi nào được báo ra.

Độ dài: ~1600 dòng Bài tập: 5 Quiz: 8 Prerequisites: Chương 1-6
🎯 Mục tiêu chương
  • Đọc được cây phân cấp Iterable → Collection → List/Set/Queue.
  • Phân biệt bốn nhóm: List (có thứ tự, cho trùng), Set (duy nhất), Queue (FIFO), Map (khoá-giá trị).
  • Biết khi nào ArrayList và khi nào LinkedList — và vì sao ArrayList gần như luôn thắng.
  • Chọn đúng giữa HashSet, LinkedHashSet, TreeSet.
  • Dùng ArrayDeque làm cả ngăn xếp lẫn hàng đợi; hiểu PriorityQueue.
  • Hiểu Iterator và nguồn gốc của ConcurrentModificationException.
  • Phân biệt Comparable và Comparator; viết bộ so sánh nhiều tiêu chí.
  • Thuộc bảng độ phức tạp — công cụ ra quyết định quan trọng nhất.
  • Biết các collection bất biến (List.of) và cạm bẫy của chúng.
  • Tránh các lỗi kinh điển: chọn sai cấu trúc, sửa khi đang duyệt, quên equals/hashCode.

1. Vì sao cần Collections Framework

MangVsList.java
// ❌ Với mảng: thêm một phần tử là cả một thủ tục
String[] ds = new String[3];
ds[0] = "a"; ds[1] = "b"; ds[2] = "c";
// Muốn thêm "d"? Phải tạo mảng mới và chép lại toàn bộ:
String[] moi = Arrays.copyOf(ds, 4);
moi[3] = "d";
// Muốn xoá phần tử giữa? Tự dịch chuyển mọi phần tử phía sau.
// Muốn kiểm tra "đã có chưa"? Tự viết vòng lặp.
// Muốn sắp xếp theo tiêu chí riêng? Tự cài thuật toán.

// ✅ Với Collections: tất cả đã có sẵn
List<String> ds2 = new ArrayList<>();
ds2.add("a"); ds2.add("b"); ds2.add("c");
ds2.add("d");                     // tự nới rộng
ds2.remove("b");                  // tự dịch chuyển
ds2.contains("c");                // có sẵn
Collections.sort(ds2);            // có sẵn
System.out.println(ds2);           // [a, c, d] — toString() tử tế

Collection chỉ chứa đối tượng, không chứa kiểu nguyên thủy

List<int> là bất hợp lệ — phải viết List<Integer>. Java tự autoboxing khi bạn add(5), nhưng phải trả giá: mỗi số là một object trên heap, tốn bộ nhớ hơn nhiều và tạo áp lực cho garbage collector.

Với dữ liệu số lượng lớn và thuần số, mảng nguyên thủy int[] vẫn nhanh hơn List<Integer> khoảng 4-6 lần và tốn bộ nhớ ít hơn khoảng 4 lần. Lý do sâu xa là type erasure — Chương 9 sẽ giải thích.

2. Cây phân cấp

Iterable<E> ← cho phép dùng for-each │ └── Collection<E> add, remove, size, contains, isEmpty, stream │ ├── List<E> CÓ thứ tự · CHO trùng · truy cập theo chỉ số │ ├── ArrayList ⭐ mảng động — mặc định │ ├── LinkedList danh sách liên kết đôi (cũng là Deque) │ ├── Vector ⚠️ cũ, đồng bộ — đừng dùng │ │ └── Stack ⚠️ cũ — dùng ArrayDeque thay thế │ └── CopyOnWriteArrayList an toàn luồng, đọc nhiều ghi ít │ ├── Set<E> KHÔNG trùng · không truy cập theo chỉ số │ ├── HashSet ⭐ không thứ tự — nhanh nhất │ │ └── LinkedHashSet giữ thứ tự thêm vào │ └── SortedSet → NavigableSet │ └── TreeSet đã sắp xếp — cây đỏ đen │ └── Queue<E> xử lý theo thứ tự — thường FIFO ├── PriorityQueue ưu tiên theo thứ tự sắp xếp (heap) └── Deque<E> hàng đợi hai đầu ├── ArrayDeque ⭐ dùng làm Stack VÀ Queue └── LinkedList vừa List vừa Deque Map<K,V> ⚠️ KHÔNG kế thừa Collection — xem Chương 8
📌 Vì sao Map không nằm trong Collection

Vì Collection<E> mô tả "một nhóm phần tử đơn lẻ" — phương thức add(E) nhận một giá trị. Map lưu cặp khoá-giá trị, nên put(K, V) có chữ ký hoàn toàn khác. Ép chúng vào cùng một interface sẽ khiến cả hai đều xấu.

Nhưng chúng liên hệ chặt chẽ: map.keySet() trả về Set<K>, map.values() trả về Collection<V>, và map.entrySet() trả về Set<Map.Entry<K,V>> — đó là ba cây cầu nối hai thế giới. Chương 8 dành trọn cho Map.

3. Bảng so sánh bốn nhóm

Tiêu chíListSetQueueMap
Định nghĩa Dãy phần tử có thứ tự Tập phần tử duy nhất Xử lý theo thứ tự (thường FIFO) Tập cặp khoá – giá trị
Cho trùng lặp ✅ Có ❌ Không ✅ Có Khoá: ❌ · Giá trị: ✅
Thứ tự Đúng thứ tự thêm vào HashSet: không
LinkedHashSet: thứ tự thêm
TreeSet: đã sắp xếp
FIFO (vào trước ra trước)
PriorityQueue: theo ưu tiên
HashMap: không
LinkedHashMap: thứ tự thêm
TreeMap: sắp theo khoá
Cách truy cập Theo chỉ số: get(i)/set(i,v) Không có chỉ số — chỉ duyệt hoặc contains Ở hai đầu: poll()/peek() Theo khoá: get(k)/put(k,v)
Class tiêu biểu ArrayList, LinkedList HashSet, LinkedHashSet, TreeSet ArrayDeque, PriorityQueue HashMap, LinkedHashMap, TreeMap
Dùng khi Thứ tự quan trọng; cần truy cập theo vị trí Cần tính duy nhất; kiểm tra "đã có chưa" thật nhanh Lập lịch, bộ đệm, hàng đợi công việc Tra cứu nhanh theo khoá
Ví dụ thực tế Danh sách sinh viên theo thứ tự điểm danh Tập email đã đăng ký, URL đã thăm Hàng đợi in, hàng đợi tác vụ maSV → SinhVien

4. List

ListCoBan.java
// ⭐ Khai báo theo INTERFACE, khởi tạo bằng CLASS cụ thể
List<String> ds = new ArrayList<>();
// Nhờ vậy đổi sang LinkedList sau này chỉ cần sửa MỘT dòng

// --- Thêm ---
ds.add("b");                    // thêm vào cuối
ds.add(0, "a");                 // chèn vào vị trí 0 (dịch chuyển phần còn lại)
ds.addAll(List.of("c", "d"));  // thêm nhiều

// --- Đọc / sửa ---
ds.get(0);                     // "a"
ds.set(0, "A");                 // thay thế, trả về giá trị cũ
ds.size();                     // 4
ds.isEmpty();                  // false

// --- Tìm ---
ds.contains("c");              // true  — O(n), duyệt tuần tự
ds.indexOf("c");               // 2     — -1 nếu không có
ds.lastIndexOf("c");

// --- Xoá: CẨN THẬN với List<Integer> ---
ds.remove(0);                  // xoá theo CHỈ SỐ
ds.remove("c");                // xoá theo GIÁ TRỊ
ds.removeIf(s -> s.startsWith("x"));   // ⭐ Java 8+, an toàn khi duyệt
ds.clear();

// --- Khung nhìn con (view) — chia sẻ dữ liệu với list gốc! ---
List<String> con = ds.subList(1, 3);   // [1, 3) — sửa `con` là sửa `ds`

// --- Sắp xếp ---
ds.sort(null);                       // thứ tự tự nhiên
ds.sort(Comparator.reverseOrder());
Collections.sort(ds);                 // cách cũ, tương đương
⚠️ Bẫy kinh điển: remove() trên List<Integer>
RemoveBay.java
List<Integer> so = new ArrayList<>(List.of(10, 20, 30));

so.remove(1);                  // gọi remove(int index) → xoá phần tử ở vị trí 1 (số 20)
System.out.println(so);        // [10, 30]

so.remove(Integer.valueOf(10));  // gọi remove(Object) → xoá GIÁ TRỊ 10 ✅
System.out.println(so);            // [30]

Hai phương thức remove(int) và remove(Object) là nạp chồng, và với List<Integer> thì cả hai đều nhận được số nguyên. Java chọn remove(int) vì khớp chính xác được ưu tiên hơn autoboxing (chương 4). Muốn xoá theo giá trị, phải ép tường minh: remove(Integer.valueOf(10)).

4.1. ArrayList vs LinkedList

ArrayList — mảng động
[A][B][C][D][ ][ ][ ]
Bộ nhớ liền kề, có chỗ dự phòng.
Đầy thì tạo mảng mới gấp 1,5× và chép sang.
LinkedList — liên kết đôi
A ⇄ B ⇄ C ⇄ D
Mỗi node nằm rải rác, chứa giá trị
+ 2 con trỏ (trước/sau).
Thao tácArrayListLinkedListGiải thích
get(i) / set(i,v)O(1)O(n)Mảng tính địa chỉ trực tiếp; liên kết phải đi từng node
add(e) cuốiO(1)*O(1)* trung bình — thỉnh thoảng phải nới rộng
add(0, e) đầuO(n)O(1)Mảng phải dịch chuyển mọi phần tử
remove(i) giữaO(n)O(n)LinkedList tốn O(n) để tìm, dù nối lại chỉ O(1)
contains(e)O(n)O(n)Cả hai đều duyệt tuần tự
Bộ nhớ cho mỗi phần tử1 tham chiếu1 node = 3 tham chiếuLinkedList tốn gấp ~3 lần
Thân thiện với cache CPU✅ Rất tốt❌ KémYếu tố quyết định trong thực tế
Dùng ArrayList. Gần như luôn luôn. Về lý thuyết LinkedList thắng ở việc chèn/xoá đầu danh sách, nhưng trên phần cứng thật, ArrayList vẫn thường nhanh hơn ngay cả ở những thao tác đó — vì bộ nhớ liền kề tận dụng được cache CPU, còn con trỏ nhảy lung tung thì không. Nếu bạn cần chèn/xoá ở hai đầu, hãy dùng ArrayDeque, không phải LinkedList.
💡 Đặt trước dung lượng khi biết kích thước
DungLuong.java
// Mặc định dung lượng 10; đầy thì tạo mảng mới 1,5× và chép lại
List<String> a = new ArrayList<>();
for (int i = 0; i < 1_000_000; i++) a.add("x");
// → khoảng 30 lần cấp phát lại + chép toàn bộ

// ✅ Biết trước thì nói luôn
List<String> b = new ArrayList<>(1_000_000);
// → một lần cấp phát duy nhất, nhanh hơn rõ rệt

4.2. List bất biến — và cạm bẫy

BatBien.java
// ✅ Java 9+: List.of() — BẤT BIẾN hoàn toàn
List<String> a = List.of("a", "b", "c");
// a.add("d");        // UnsupportedOperationException
// a.set(0, "x");     // UnsupportedOperationException
// List.of("a", null) // NullPointerException — KHÔNG cho null!

// ⚠️ Arrays.asList() — KÍCH THƯỚC cố định nhưng SỬA được phần tử
List<String> b = Arrays.asList("a", "b", "c");
b.set(0, "x");       // ✅ được — nó là KHUNG NHÌN của mảng gốc
// b.add("d");     // UnsupportedOperationException

// ✅ Muốn list sửa được từ giá trị có sẵn
List<String> c = new ArrayList<>(List.of("a", "b"));
c.add("c");          // ✅

// ✅ Bản sao bất biến từ list đang có (Java 10+)
List<String> d = List.copyOf(c);

// ⚠️ unmodifiableList chỉ là KHUNG NHÌN chỉ đọc, không phải bản sao
List<String> goc = new ArrayList<>(List.of("a"));
List<String> view = Collections.unmodifiableList(goc);
goc.add("b");                          // sửa list GỐC
System.out.println(view);              // [a, b] ⚠️ view thấy thay đổi!

5. Set

SetCoBan.java
Set<String> s = new HashSet<>();

s.add("a");          // true  — thêm thành công
s.add("a");          // false — đã có, không thêm nữa
s.contains("a");     // true  — O(1) ⚡ đây là lý do dùng Set
s.remove("a");       // true

// --- Phép toán tập hợp ---
Set<Integer> A = new HashSet<>(List.of(1, 2, 3, 4));
Set<Integer> B = new HashSet<>(List.of(3, 4, 5, 6));

Set<Integer> hop = new HashSet<>(A);
hop.addAll(B);                        // A ∪ B = [1,2,3,4,5,6]

Set<Integer> giao = new HashSet<>(A);
giao.retainAll(B);                    // A ∩ B = [3,4]

Set<Integer> hieu = new HashSet<>(A);
hieu.removeAll(B);                    // A \ B = [1,2]

// Ứng dụng thường gặp nhất: loại trùng lặp
List<String> coTrung = List.of("a", "b", "a", "c", "b");
List<String> duyNhat = new ArrayList<>(new LinkedHashSet<>(coTrung));
System.out.println(duyNhat);   // [a, b, c] — giữ nguyên thứ tự xuất hiện
HashSetLinkedHashSetTreeSet
Thứ tựKhông xác địnhThứ tự thêm vàoĐã sắp xếp
Cấu trúc bên dướiBảng bămBảng băm + liên kết đôiCây đỏ đen
add/contains/removeO(1)O(1)O(log n)
Cho null✅ Một cái✅ Một cái❌ NullPointerException
Yêu cầu phần tửequals+hashCodeequals+hashCodeComparable hoặc Comparator
Bộ nhớÍt nhấtNhiều hơn (thêm liên kết)Trung bình
Dùng khiMặc địnhCần giữ thứ tự xuất hiệnCần duyệt theo thứ tự, tìm khoảng
TreeSet.java — sức mạnh riêng của cây
TreeSet<Integer> t = new TreeSet<>(List.of(10, 20, 30, 40, 50));

// Truy vấn thứ tự — HashSet KHÔNG làm được
t.first();            // 10
t.last();             // 50
t.floor(35);         // 30 — phần tử lớn nhất ≤ 35
t.ceiling(35);       // 40 — phần tử nhỏ nhất ≥ 35
t.lower(30);         // 20 — nhỏ hơn hẳn
t.higher(30);        // 40 — lớn hơn hẳn

// Khoảng giá trị
t.headSet(30);       // [10, 20]     — nhỏ hơn 30
t.tailSet(30);       // [30, 40, 50] — từ 30 trở lên
t.subSet(20, 40);    // [20, 30]     — [20, 40)
t.descendingSet();   // [50, 40, 30, 20, 10]

// Sắp xếp theo tiêu chí riêng
TreeSet<SinhVien> theoDiem =
    new TreeSet<>(Comparator.comparingDouble(SinhVien::getDiem).reversed());
⚠️ TreeSet dùng compareTo, không dùng equals
TreeSetBay.java
record SinhVien(String ma, String ten, double diem) { }

TreeSet<SinhVien> set =
    new TreeSet<>(Comparator.comparingDouble(SinhVien::diem));

set.add(new SinhVien("SV1", "An",  8.0));
set.add(new SinhVien("SV2", "Bình", 8.0));   // điểm TRÙNG

System.out.println(set.size());   // 1 ⚠️ Bình bị coi là TRÙNG với An!

TreeSet coi hai phần tử là "bằng nhau" khi compare(a, b) == 0 — hoàn toàn bỏ qua equals(). Bộ so sánh chỉ dựa trên điểm nên hai sinh viên khác nhau bị gộp làm một.

Sửa: luôn thêm tiêu chí phụ để bộ so sánh nhất quán với equals: Comparator.comparingDouble(SinhVien::diem).thenComparing(SinhVien::ma).

6. Queue và Deque

QueueDeque.java
// ⭐ ArrayDeque — dùng làm CẢ hàng đợi lẫn ngăn xếp, nhanh nhất cho cả hai

// --- Dùng như QUEUE (FIFO — vào trước ra trước) ---
Queue<String> q = new ArrayDeque<>();
q.offer("A");          // thêm vào cuối (add() cũng được nhưng ném lỗi khi đầy)
q.offer("B");
q.offer("C");
q.peek();              // "A" — xem đầu, KHÔNG xoá
q.poll();              // "A" — lấy và XOÁ khỏi đầu
System.out.println(q);  // [B, C]

// --- Dùng như STACK (LIFO — vào sau ra trước) ---
Deque<String> st = new ArrayDeque<>();
st.push("A");           // thêm vào ĐẦU
st.push("B");
st.push("C");
st.peek();             // "C"
st.pop();              // "C"
System.out.println(st); // [B, A]

// --- Hai đầu đầy đủ ---
Deque<Integer> d = new ArrayDeque<>();
d.offerFirst(1);  d.offerLast(2);
d.peekFirst();     d.peekLast();
d.pollFirst();     d.pollLast();
Thao tácNém ngoại lệ khi lỗiTrả giá trị đặc biệt ⭐
Thêmadd(e) → IllegalStateExceptionoffer(e) → false
Xoáremove() → NoSuchElementExceptionpoll() → null
Xemelement() → NoSuchElementExceptionpeek() → null
📌 Đừng dùng Stack — dùng ArrayDeque

Class java.util.Stack có từ Java 1.0 và mắc ba vấn đề: nó kế thừa Vector nên đồng bộ hoá (chậm) dù bạn không cần; nó lộ luôn cả API của List nên có thể get(0) vào giữa ngăn xếp — phá vỡ khái niệm; và thứ tự duyệt của nó ngược với trực giác (từ đáy lên đỉnh).

Chính Javadoc của JDK khuyến nghị dùng ArrayDeque thay thế. Tương tự, Vector và Hashtable đều là di sản — dùng ArrayList và HashMap.

PriorityQueue.java
// PriorityQueue — luôn lấy ra phần tử "nhỏ nhất" theo thứ tự sắp xếp
PriorityQueue<Integer> pq = new PriorityQueue<>();
pq.offer(30); pq.offer(10); pq.offer(20);
pq.poll();   // 10 — nhỏ nhất trước
pq.poll();   // 20

// ⚠️ toString() KHÔNG theo thứ tự sắp xếp — nó in cấu trúc heap bên trong
System.out.println(pq);   // đừng tin cái này

// Ưu tiên ngược (lớn nhất trước)
PriorityQueue<Integer> maxPq = new PriorityQueue<>(Comparator.reverseOrder());

// Ứng dụng thật: hàng đợi công việc theo độ ưu tiên
record CongViec(String ten, int uuTien) { }
PriorityQueue<CongViec> hangDoi =
    new PriorityQueue<>(Comparator.comparingInt(CongViec::uuTien));
hangDoi.offer(new CongViec("Sao lưu", 5));
hangDoi.offer(new CongViec("Khẩn cấp", 1));
hangDoi.poll();    // CongViec[ten=Khẩn cấp, uuTien=1]

// Mẫu "top K phần tử lớn nhất" — chỉ giữ K phần tử trong bộ nhớ
PriorityQueue<Integer> topK = new PriorityQueue<>();   // min-heap
for (int x : duLieuRatLon) {
    topK.offer(x);
    if (topK.size() > K) topK.poll();   // loại phần tử nhỏ nhất
}   // Bộ nhớ O(K) thay vì O(n) — quan trọng với dữ liệu hàng tỷ phần tử

7. Iterator và ConcurrentModificationException

Iterator.java
List<String> ds = new ArrayList<>(List.of("a", "b", "c", "d"));

// ❌ Xoá khi đang duyệt bằng for-each
for (String s : ds) {
    if (s.equals("b")) ds.remove(s);
}
// 💥 ConcurrentModificationException

// ✅ Cách 1: Iterator.remove() — cách chuẩn từ Java 1.2
Iterator<String> it = ds.iterator();
while (it.hasNext()) {
    if (it.next().equals("b")) it.remove();
}

// ✅ Cách 2: removeIf() — ngắn gọn nhất (Java 8+)
ds.removeIf(s -> s.equals("b"));

// ✅ Cách 3: duyệt ngược bằng chỉ số
for (int i = ds.size() - 1; i >= 0; i--) {
    if (ds.get(i).equals("b")) ds.remove(i);
}

// ✅ Cách 4: gom vào list mới rồi xoá một lượt
List<String> canXoa = ds.stream().filter(s -> s.equals("b")).toList();
ds.removeAll(canXoa);

modCount — cơ chế "fail-fast" bên dưới

Mỗi collection giữ một bộ đếm modCount, tăng lên mỗi khi cấu trúc thay đổi (thêm/xoá phần tử). Khi bạn tạo iterator, nó chụp lại giá trị hiện tại vào expectedModCount. Ở mỗi lần next(), iterator so sánh hai số — khác nhau nghĩa là ai đó đã sửa collection sau lưng nó, và nó ném ConcurrentModificationException ngay.

Đây gọi là hành vi fail-fast: thà báo lỗi ngay còn hơn duyệt sai âm thầm và trả kết quả không đúng. Iterator.remove() hoạt động được vì nó cập nhật cả hai bộ đếm.

Tên gọi hơi gây nhầm: ngoại lệ này xảy ra ngay cả trong chương trình một luồng — nó không chỉ liên quan tới đa luồng.

8. Comparable và Comparator

Comparable<T>Comparator<T>
Ý nghĩaThứ tự tự nhiên của chính lớpThứ tự bên ngoài, tuỳ ngữ cảnh
Phương thứcint compareTo(T o)int compare(T a, T b)
Cài ở đâuTrong chính lớp đóLớp riêng / lambda
Số lượngMộtBao nhiêu cũng được
Ví dụString, Integer, LocalDateSắp theo tên, theo điểm, theo ngày…
SoSanh.java
// --- Comparable: thứ tự tự nhiên, gắn liền với lớp ---
record SinhVien(String ma, String ten, double diem, int tuoi)
        implements Comparable<SinhVien> {
    @Override
    public int compareTo(SinhVien khac) {
        return ma.compareTo(khac.ma);     // mặc định sắp theo mã
    }
}
Collections.sort(danhSach);        // dùng compareTo()

// --- Comparator: nhiều cách sắp xếp khác nhau ---
danhSach.sort(Comparator.comparing(SinhVien::ten));
danhSach.sort(Comparator.comparingDouble(SinhVien::diem).reversed());

// ⭐ Nhiều tiêu chí: điểm giảm dần, cùng điểm thì tên tăng dần, rồi tới tuổi
danhSach.sort(
    Comparator.comparingDouble(SinhVien::diem).reversed()
        .thenComparing(SinhVien::ten)
        .thenComparingInt(SinhVien::tuoi)
);

// Xử lý null an toàn
Comparator<SinhVien> anToan =
    Comparator.nullsFirst(Comparator.comparing(SinhVien::ten));

// Không phân biệt hoa thường
danhSach.sort(Comparator.comparing(SinhVien::ten, String.CASE_INSENSITIVE_ORDER));
⚠️ Đừng viết compare bằng phép trừ
TruBay.java
// ❌ SAI — tràn số nguyên
Comparator<Integer> xau = (a, b) -> a - b;
xau.compare(Integer.MIN_VALUE, 1);
// -2147483648 - 1 = 2147483647 (tràn) → trả DƯƠNG
// → nói rằng MIN_VALUE > 1 → sắp xếp SAI hoàn toàn

// ✅ ĐÚNG
Comparator<Integer> tot = Integer::compare;
// hoặc: Comparator.comparingInt(x -> x)

// ❌ Với double thì còn tệ hơn — mất chính xác
Comparator<SinhVien> xau2 = (a, b) -> (int)(a.diem() - b.diem());
// 8.5 - 8.2 = 0.3 → ép về int = 0 → coi là BẰNG NHAU!
Comparator<SinhVien> tot2 = Comparator.comparingDouble(SinhVien::diem);  // ✅
📌 Hợp đồng của compareTo
  • Đối xứng dấu: sgn(a.compareTo(b)) == -sgn(b.compareTo(a))
  • Bắc cầu: a > b và b > c ⇒ a > c
  • Nhất quán: a.compareTo(b) == 0 ⇒ mọi so sánh với c đều cho kết quả như nhau
  • Khuyến nghị mạnh: a.compareTo(b) == 0 nên tương đương a.equals(b)

Vi phạm ba điều đầu khiến Collections.sort ném IllegalArgumentException: Comparison method violates its general contract!. Vi phạm điều cuối gây ra bẫy TreeSet ở mục 5.

9. Lớp tiện ích Collections

CollectionsUtil.java
import java.util.Collections;

List<Integer> ds = new ArrayList<>(List.of(3, 1, 4, 1, 5, 9));

Collections.sort(ds);                  // sắp xếp tại chỗ
Collections.reverse(ds);               // đảo ngược
Collections.shuffle(ds);               // xáo trộn ngẫu nhiên
Collections.swap(ds, 0, 1);          // hoán đổi hai vị trí
Collections.max(ds);                   // 9
Collections.min(ds);                   // 1
Collections.frequency(ds, 1);         // 2 — số lần xuất hiện
Collections.nCopies(3, "x");         // [x, x, x] — bất biến
Collections.binarySearch(ds, 4);      // ⚠️ list PHẢI đã sắp xếp
Collections.disjoint(ds1, ds2);       // true nếu KHÔNG có phần tử chung

// Collection rỗng bất biến — tránh trả về null
List<String> rong = Collections.emptyList();
Set<String>  rong2 = Collections.emptySet();

// Khung nhìn chỉ đọc
List<Integer> chiDoc = Collections.unmodifiableList(ds);

// Bọc đồng bộ — thô sơ; chương 12 có lựa chọn tốt hơn
List<Integer> dongBo = Collections.synchronizedList(ds);

10. Bảng độ phức tạp — công cụ ra quyết định

Cấu trúcThêm cuốiThêm đầuLấy theo chỉ số Tìm giá trịXoáThứ tự
ArrayList O(1)*O(n) O(1)O(n) O(n)Thêm vào
LinkedList O(1)O(1) O(n)O(n) O(n)Thêm vào
ArrayDeque O(1)O(1) —O(n) O(1) ở hai đầuThêm vào
HashSet O(1)— —O(1) O(1)Không
LinkedHashSet O(1)— —O(1) O(1)Thêm vào
TreeSet O(log n)— —O(log n) O(log n)Đã sắp xếp
PriorityQueue O(log n)— —O(n) O(log n) ở đỉnhTheo ưu tiên
HashMap O(1)— O(1) theo khoáO(1) O(1)Không
TreeMap O(log n)— O(log n)O(log n) O(log n)Sắp theo khoá

* Trung bình khấu hao (amortized): phần lớn lần thêm là O(1), thỉnh thoảng phải cấp phát lại mảng và chép — nhưng chi phí đó trải đều ra thì vẫn là hằng số.

Vì sao chọn đúng cấu trúc lại quan trọng
// ❌ Dùng List để kiểm tra tồn tại — O(n) cho MỖI lần kiểm tra
List<String> daThay = new ArrayList<>();
for (String url : 100_000 URL) {
    if (!daThay.contains(url)) {   // O(n) — duyệt cả list
        daThay.add(url);
        xuLy(url);
    }
}
// Tổng: O(n²) = 10 tỷ phép so sánh → khoảng vài phút

// ✅ Dùng Set — O(1) cho mỗi lần kiểm tra
Set<String> daThay2 = new HashSet<>();
for (String url : 100_000 URL) {
    if (daThay2.add(url)) {       // add trả false nếu đã có — gộp 2 bước làm 1
        xuLy(url);
    }
}
// Tổng: O(n) = 100 nghìn phép → vài mili giây
// Chênh lệch: hàng NGHÌN lần, chỉ vì đổi một từ khoá.

11. Cây quyết định — chọn cấu trúc nào

Cây quyết định
Dữ liệu là cặp KHOÁ → GIÁ TRỊ?
├── CÓ  →  Map (Chương 8)
│         ├── Cần sắp theo khoá?      → TreeMap
│         ├── Cần giữ thứ tự thêm?    → LinkedHashMap
│         └── Còn lại                 → HashMap ⭐
│
└── KHÔNG  →  Cần loại trùng lặp?
    ├── CÓ  →  Set
    │         ├── Cần sắp xếp / tìm khoảng?  → TreeSet
    │         ├── Cần giữ thứ tự thêm?       → LinkedHashSet
    │         └── Còn lại                    → HashSet ⭐
    │
    └── KHÔNG  →  Xử lý theo thứ tự vào/ra?
        ├── CÓ  →  Cần độ ưu tiên?
        │         ├── CÓ     → PriorityQueue
        │         └── KHÔNG  → ArrayDeque ⭐ (cả FIFO lẫn LIFO)
        │
        └── KHÔNG  →  List
                  └── ArrayList ⭐ (gần như luôn luôn)
Ba lựa chọn mặc định: ArrayList cho danh sách, HashSet cho tập duy nhất, HashMap cho tra cứu. Chỉ đổi khi có yêu cầu cụ thể về thứ tự hoặc thao tác hai đầu — và hãy đo trước khi tối ưu.

12. Cheat sheet chương 7

Tình huốngCách viết
Khai báo chuẩnList<X> ds = new ArrayList<>(); — interface bên trái
List bất biếnList.of("a", "b") — không cho null
List sửa được từ giá trị có sẵnnew ArrayList<>(List.of(...))
Loại trùng, giữ thứ tựnew ArrayList<>(new LinkedHashSet<>(ds))
Kiểm tra "đã có chưa"Set.contains() — không List.contains()
Thêm nếu chưa cóif (set.add(x)) { … } — add trả false nếu trùng
Xoá khi đang duyệtds.removeIf(x -> …)
Ngăn xếpDeque<X> st = new ArrayDeque<>(); + push/pop
Hàng đợiQueue<X> q = new ArrayDeque<>(); + offer/poll
Top-K phần tử lớn nhấtPriorityQueue min-heap giữ đúng K phần tử
Sắp nhiều tiêu chíComparator.comparing(A).thenComparing(B)
Sắp giảm dần.reversed() hoặc Comparator.reverseOrder()
Viết compareInteger.compare(a,b) — không bao giờ a - b
Xoá theo giá trị trong List<Integer>remove(Integer.valueOf(10))
Trả về "không có gì"List.of() hoặc Collections.emptyList() — không null
Biết trước kích thướcnew ArrayList<>(soLuong)
Cần StackArrayDeque, không java.util.Stack

Bài tập

Đo hiệu năng ArrayList vs LinkedList

Viết benchmark so sánh hai cấu trúc với n = 10.000 / 100.000 / 1.000.000:

  1. Thêm n phần tử vào cuối.
  2. Thêm n phần tử vào đầu (add(0, x)).
  3. Truy cập ngẫu nhiên n lần bằng get(i).
  4. Duyệt tuần tự toàn bộ bằng for-each.
  5. Xoá n/2 phần tử ở giữa.

Lập bảng kết quả và trả lời: kết quả nào khớp với bảng độ phức tạp lý thuyết, kết quả nào không khớp? Giải thích chỗ không khớp.

Gợi ý

Bạn sẽ thấy phép "duyệt tuần tự" của ArrayList nhanh hơn LinkedList đáng kể dù cả hai đều là O(n) — vì bộ nhớ liền kề tận dụng được cache CPU, còn LinkedList phải nhảy tới địa chỉ ngẫu nhiên ở mỗi node (cache miss). Big-O không mô tả được yếu tố này, và đó là lý do ArrayList thắng trong thực tế nhiều hơn lý thuyết dự đoán.
Nhớ warm-up JIT: chạy vài lượt và bỏ kết quả đầu tiên.

Hệ thống quản lý thư viện

Xây dựng ThuVien dùng đúng cấu trúc cho từng nhu cầu:

Yêu cầu
// Với MỖI yêu cầu, chọn cấu trúc và GIẢI THÍCH lý do:
// a) Danh sách sách theo thứ tự nhập kho
// b) Tập ISBN duy nhất, kiểm tra trùng thật nhanh
// c) Hàng đợi người đăng ký mượn (ai đăng ký trước được trước)
// d) Danh sách sách sắp theo năm xuất bản, luôn tự sắp
// e) Hàng đợi yêu cầu ưu tiên (giáo viên trước sinh viên)
// f) Lịch sử 10 thao tác gần nhất (để hoàn tác)
// g) Tập thể loại của mỗi cuốn sách, giữ thứ tự nhập

Cài đặt đầy đủ với các thao tác: thêm sách, mượn, trả, tìm theo ISBN, liệt kê sách quá hạn, hoàn tác thao tác cuối.

Gợi ý

(f) là bài toán "cửa sổ trượt cố định" — ArrayDeque với if (size() > 10) pollLast(); sau mỗi addFirst().
(e) cần PriorityQueue với comparator hai tầng: ưu tiên trước, rồi tới thời điểm đăng ký — nếu chỉ so ưu tiên thì hai người cùng mức sẽ có thứ tự không xác định (heap không ổn định).

Tự cài MyArrayList

Cài đặt MyArrayList<E> implements Iterable<E> từ đầu, chỉ dùng mảng:

API
public class MyArrayList<E> implements Iterable<E> {
    private Object[] mang;      // vì sao Object[] chứ không phải E[]? (chương 9)
    private int soLuong;
    private int modCount;

    public void add(E e);
    public void add(int i, E e);
    public E    get(int i);
    public E    set(int i, E e);
    public E    remove(int i);
    public int  size();
    public Iterator<E> iterator();
}
  • Tự nới rộng khi đầy — nhân dung lượng với 1,5 (giống JDK) và so sánh với chiến lược nhân đôi.
  • Kiểm tra chỉ số, ném IndexOutOfBoundsException với thông điệp rõ ràng.
  • Iterator phải là fail-fast: dùng modCount để ném ConcurrentModificationException.
  • Hỗ trợ Iterator.remove().
  • Viết test: thêm 1 triệu phần tử, so tốc độ với ArrayList thật.
Gợi ý

Nới rộng: Arrays.copyOf(mang, mang.length + (mang.length >> 1)) — >> 1 là chia đôi, nên đây là nhân 1,5.
Xoá phần tử i: System.arraycopy(mang, i+1, mang, i, soLuong-i-1) rồi đặt mang[--soLuong] = null — bước gán null rất quan trọng, nếu quên thì object bị giữ tham chiếu và không được GC thu hồi (rò rỉ bộ nhớ).
Vì sao Object[]: không tạo được new E[n] do type erasure — chương 9 sẽ giải thích, và bản thân JDK cũng làm y hệt.

Bộ so sánh nhiều tiêu chí

Cho record NhanVien(String hoTen, String phongBan, long luong, LocalDate ngayVao, Integer tuoi) (chú ý tuoi có thể null), viết các bộ so sánh:

  1. Theo lương giảm dần.
  2. Theo phòng ban (A→Z), cùng phòng thì lương giảm dần.
  3. Theo thâm niên (vào lâu nhất trước), cùng ngày thì theo tên.
  4. Theo tuổi tăng dần, null xếp cuối.
  5. Theo họ (từ cuối trong hoTen), rồi tới tên.
  6. Theo độ dài tên, cùng độ dài thì theo bảng chữ cái, không phân biệt hoa thường.

Với mỗi bộ so sánh, viết test khẳng định thứ tự đúng — kể cả trường hợp có giá trị bằng nhau.

Gợi ý

(4): Comparator.comparing(NhanVien::tuoi, Comparator.nullsLast(Comparator.naturalOrder())).
(5): trích họ bằng hoTen.substring(hoTen.lastIndexOf(' ') + 1) — cẩn thận trường hợp tên chỉ có một từ (lastIndexOf trả −1, cộng 1 thành 0, vẫn đúng).
(6): Comparator.comparingInt(String::length).thenComparing(String.CASE_INSENSITIVE_ORDER) áp lên NhanVien::hoTen.
Kiểm tra hợp đồng: với mỗi comparator, khẳng định sgn(cmp(a,b)) == -sgn(cmp(b,a)) trên vài cặp.

Máy tính biểu thức bằng ngăn xếp

Dùng ArrayDeque cài đặt:

  1. boolean canBang(String s) — kiểm tra ngoặc ()[]{} cân bằng.
  2. List<String> sangHauTo(String trungTo) — chuyển trung tố sang hậu tố (thuật toán shunting-yard).
  3. double tinhHauTo(List<String> hauTo) — tính giá trị biểu thức hậu tố.
  4. double tinh(String bieuThuc) — ghép cả ba, hỗ trợ + - * / ^, ngoặc, số thực và số âm.

Xử lý lỗi tử tế: ngoặc không cân bằng, chia cho 0, ký tự lạ — mỗi loại một exception riêng với thông điệp chỉ rõ vị trí.

Gợi ý

Shunting-yard: duyệt token; số thì đẩy thẳng ra output; toán tử thì pop mọi toán tử có độ ưu tiên lớn hơn hoặc bằng ra output trước khi push (riêng ^ kết hợp phải sang trái nên chỉ pop khi lớn hơn hẳn); ( thì push; ) thì pop tới khi gặp (.
Số âm là ca khó nhất: dấu - là phép trừ hay dấu âm? Quy tắc: nếu nó ở đầu biểu thức, hoặc ngay sau (, hoặc ngay sau một toán tử khác → đó là dấu âm.

Quiz

Q1

List, Set, Queue, Map khác nhau thế nào? Vì sao Map không kế thừa Collection?

Xem đáp án
✓ Đáp án
  • List — dãy có thứ tự, cho trùng lặp, truy cập theo chỉ số. Dùng khi thứ tự quan trọng.
  • Set — tập phần tử duy nhất, không có chỉ số. Dùng khi cần tính duy nhất và kiểm tra tồn tại nhanh.
  • Queue — xử lý theo thứ tự, thường FIFO; truy cập ở hai đầu. Dùng cho hàng đợi công việc, lập lịch.
  • Map — tập cặp khoá-giá trị; khoá duy nhất, giá trị được trùng. Dùng để tra cứu theo khoá.

Map không kế thừa Collection vì hai interface có hình dạng dữ liệu khác nhau: Collection<E> là nhóm phần tử đơn lẻ với add(E), còn Map lưu cặp với put(K, V). Ép chung một interface sẽ khiến cả hai đều gượng ép.

Chúng vẫn nối với nhau qua ba khung nhìn: keySet() → Set<K>, values() → Collection<V>, entrySet() → Set<Map.Entry>.

Q2

Khi nào dùng LinkedList thay ArrayList?

Xem đáp án
✓ Đáp án

Trong thực tế: gần như không bao giờ. Đây là câu trả lời gây bất ngờ nhưng đúng.

Về lý thuyết, LinkedList thắng ở chèn/xoá đầu danh sách (O(1) so với O(n)). Nhưng trên phần cứng thật, ArrayList vẫn thường nhanh hơn ngay cả ở đó, vì:

  • Cache CPU — mảng nằm liền kề, đọc một phần tử là kéo luôn cả vùng lân cận vào cache. LinkedList nhảy tới địa chỉ ngẫu nhiên ở mỗi node, gây cache miss liên tục.
  • Bộ nhớ — mỗi node LinkedList tốn 3 tham chiếu (giá trị + trước + sau) thay vì 1.
  • System.arraycopy được tối ưu ở mức lệnh máy, dịch chuyển hàng nghìn phần tử rất nhanh.

Nếu bạn cần thêm/xoá ở hai đầu, hãy dùng ArrayDeque — nó nhanh hơn LinkedList ở mọi mặt cho mục đích đó.
Chính Joshua Bloch (tác giả Collections Framework) từng nói đùa rằng ông không nghĩ ra được lý do nào để dùng LinkedList.

Q3

HashSet, LinkedHashSet, TreeSet khác nhau ra sao?

Xem đáp án
✓ Đáp án
HashSetLinkedHashSetTreeSet
Thứ tựKhông xác địnhThứ tự thêmĐã sắp xếp
Bên dướiBảng bămBảng băm + liên kết đôiCây đỏ đen
Tốc độO(1)O(1)O(log n)
null✅✅❌ NPE
Yêu cầuhashCode+equalshashCode+equalsComparable/Comparator

Mặc định HashSet — nhanh nhất và tốn ít bộ nhớ nhất. LinkedHashSet khi cần kết quả ổn định để hiển thị hoặc test. TreeSet khi cần thứ tự hoặc các truy vấn khoảng (floor, ceiling, subSet) — thứ hai loại kia không làm được.

Bẫy TreeSet: nó xác định trùng lặp bằng compareTo, không phải equals. Comparator chỉ so một trường sẽ khiến các phần tử khác nhau bị gộp — luôn thêm tiêu chí phụ để bộ so sánh nhất quán với equals.

Q4

ConcurrentModificationException xảy ra khi nào? Cách tránh?

Xem đáp án
✓ Đáp án

Xảy ra khi bạn sửa cấu trúc collection (thêm/xoá phần tử) trong lúc đang duyệt nó bằng iterator hoặc for-each.

Cơ chế: collection giữ bộ đếm modCount, tăng mỗi lần cấu trúc thay đổi. Iterator chụp giá trị này lúc tạo và so sánh ở mỗi next(). Khác nhau nghĩa là collection đã bị sửa sau lưng nó → ném ngoại lệ ngay (hành vi fail-fast: báo lỗi còn hơn duyệt sai âm thầm).

Bốn cách tránh:

  • ds.removeIf(x -> dieuKien) — ngắn gọn nhất (Java 8+).
  • Iterator.remove() — cách chuẩn, nó cập nhật cả hai bộ đếm.
  • Duyệt ngược bằng chỉ số — xoá phần tử i không ảnh hưởng các chỉ số nhỏ hơn.
  • Gom vào list mới rồi removeAll() một lượt.

Lưu ý: tên gọi gây hiểu lầm — ngoại lệ này xảy ra cả trong chương trình một luồng. Với đa luồng thật thì dùng CopyOnWriteArrayList hoặc ConcurrentHashMap (chương 12).

Q5

Comparable và Comparator khác nhau thế nào?

Xem đáp án
✓ Đáp án

Comparable<T> định nghĩa thứ tự tự nhiên của chính lớp đó. Cài đặt bên trong lớp, phương thức int compareTo(T o). Mỗi lớp chỉ có một thứ tự tự nhiên. Ví dụ: String theo bảng chữ cái, Integer theo giá trị.

Comparator<T> định nghĩa thứ tự bên ngoài, tuỳ ngữ cảnh. Viết thành lambda hoặc lớp riêng, phương thức int compare(T a, T b). Viết bao nhiêu cũng được, và dùng được cả với lớp bạn không sửa được mã nguồn.

Dung.java
ds.sort(null);                                // dùng Comparable
ds.sort(Comparator.comparing(SinhVien::ten));  // dùng Comparator
ds.sort(Comparator.comparingDouble(SinhVien::diem)
             .reversed().thenComparing(SinhVien::ma));   // nhiều tiêu chí

Cả hai đều cấm viết bằng phép trừ: (a, b) -> a - b tràn số với Integer.MIN_VALUE, và (int)(a - b) với double thì làm mất phần thập phân — 8.5 - 8.2 ép về int thành 0, tức "bằng nhau". Dùng Integer.compare / Double.compare / Comparator.comparingDouble.

Q6

Vì sao list.remove(1) và list.remove(Integer.valueOf(1)) cho kết quả khác nhau?

Xem đáp án
✓ Đáp án

Vì List có hai phương thức remove nạp chồng: remove(int index) — xoá theo vị trí, và remove(Object o) — xoá theo giá trị.

Với List<Integer>, gọi remove(1) thì trình biên dịch chọn remove(int) — vì khớp chính xác được ưu tiên hơn autoboxing (quy tắc chọn phương thức nạp chồng ở chương 4). Nên nó xoá phần tử ở vị trí 1, không phải giá trị 1.

Remove.java
List<Integer> ds = new ArrayList<>(List.of(10, 20, 30));
ds.remove(1);                      // xoá vị trí 1 → [10, 30]
ds.remove(Integer.valueOf(10));  // xoá giá trị 10 → [30]

Với List<String> thì không có vấn đề này, vì String không thể là int. Bẫy chỉ xuất hiện với List<Integer> — và nó là một trong những lỗi âm thầm khó chịu nhất, vì code vẫn chạy và chỉ cho kết quả sai.

Q7

List.of(), Arrays.asList(), new ArrayList<>() khác nhau ra sao?

Xem đáp án
✓ Đáp án
List.of()Arrays.asList()new ArrayList<>()
Thêm/xoá❌ UOE❌ UOE✅
set(i, v)❌ UOE✅ được✅
Cho null❌ NPE✅✅
Liên kết mảng gốcKhông✅ là khung nhìnKhông
Từ Java91.21.2

Arrays.asList() là thứ dễ gây bất ngờ nhất: nó tạo một khung nhìn của mảng — kích thước cố định nhưng set() được, và sửa nó là sửa luôn mảng gốc.

Nên dùng: List.of() cho hằng số và giá trị trả về bất biến; new ArrayList<>(List.of(...)) khi cần list sửa được; List.copyOf(x) để tạo bản sao bất biến từ collection có sẵn. Tránh Arrays.asList() trừ khi bạn cố ý muốn khung nhìn của mảng.

Q8

Vì sao không nên dùng java.util.Stack?

Xem đáp án
✓ Đáp án

Ba lý do, và chính Javadoc của JDK cũng khuyến nghị thay thế:

  • Đồng bộ hoá không cần thiết — Stack kế thừa Vector, nên mọi phương thức đều synchronized. Bạn trả giá về tốc độ cho tính năng gần như chẳng bao giờ dùng tới.
  • Rò rỉ trừu tượng — vì là List, nó lộ luôn get(i), add(i, e), remove(i). Người dùng chọc được vào giữa ngăn xếp, phá vỡ chính khái niệm LIFO.
  • Thứ tự duyệt ngược trực giác — for (x : stack) duyệt từ đáy lên đỉnh, ngược với thứ tự pop(). Nguồn bug rất khó nhận ra.

Thay bằng ArrayDeque:

Stack.java
Deque<String> st = new ArrayDeque<>();
st.push("a");   st.pop();   st.peek();
// Nhanh hơn, API sạch hơn, duyệt đúng thứ tự đỉnh → đáy

Cùng lý do đó, tránh Vector (dùng ArrayList) và Hashtable (dùng HashMap) — cả ba đều là di sản từ Java 1.0, giữ lại chỉ vì tương thích ngược.

Tổng kết

Sau chương 7, bạn đã nắm:

  • Cây phân cấp: Iterable → Collection → List/Set/Queue; Map đứng riêng.
  • Bốn nhóm và tiêu chí phân biệt: thứ tự, trùng lặp, cách truy cập.
  • ArrayList là mặc định — LinkedList gần như không bao giờ cần, vì cache CPU.
  • Ba loại Set và bẫy TreeSet dùng compareTo thay equals.
  • ArrayDeque làm cả ngăn xếp lẫn hàng đợi; PriorityQueue cho ưu tiên và mẫu top-K.
  • Iterator và cơ chế fail-fast qua modCount; bốn cách xoá an toàn.
  • Comparable vs Comparator; chuỗi thenComparing; cấm viết compare bằng phép trừ.
  • Bảng độ phức tạp — và bài học "sai cấu trúc = chậm hàng nghìn lần mà không có lỗi nào".
  • Collection bất biến: List.of vs Arrays.asList vs unmodifiableList.
  • Class di sản cần tránh: Vector, Stack, Hashtable.

Kết nối

  • Chương 8 (Map) — HashMap hoạt động bên trong, và vì sao hợp đồng equals/hashCode quyết định tất cả.
  • Chương 9 (Generics) — List<E>, wildcard ? extends/? super, và vì sao MyArrayList phải dùng Object[].
  • Chương 10 (Stream) — collection.stream(), filter/map/collect, Collectors.groupingBy.
  • Chương 12 (Đa luồng) — ConcurrentHashMap, CopyOnWriteArrayList, BlockingQueue.
  • Chương 4-5 (OOP) — equals/hashCode là điều kiện tiên quyết để dùng Set và Map đúng.
  • Trụ cột DSA — ArrayList = mảng động, LinkedList = danh sách liên kết, TreeSet = cây đỏ đen, PriorityQueue = heap, HashSet = bảng băm.
  • Python Chương 3 — đối chiếu: Python gộp tất cả vào bốn kiểu dựng sẵn (list, tuple, dict, set) và collections; không tách interface khỏi cài đặt.