Backend

99 Ngày Java — Ngày 33: Set — HashSet, LinkedHashSet, TreeSet

SSite Admin
29 tháng 08, 2026 10 phút đọc 0 lượt xem
99 Ngày Java — Ngày 33: Set — HashSet, LinkedHashSet, TreeSet

Ngày 31 đã hứa: Set là loài hứa duy nhất. Hôm nay ta sống trọn với lời hứa đó qua ba anh em HashSetLinkedHashSetTreeSet: giống nhau ở khử trùng lặp, khác nhau ở thứ tựcái giá. Quan trọng hơn, ta soi hai hợp đồng đứng sau: equals/hashCode nuôi họ Hash (Ngày 24), và Comparable/Comparator nuôi họ Tree — cùng những cái bẫy mà ai dùng Set với object tự viết cũng sẽ gặp.

Sketchnote Ngày 33: Set — ba anh em HashSet LinkedHashSet TreeSet, hợp đồng equals hashCode, TreeSet với Comparable và các phép toán tập hợp

Set — lời hứa duy nhất trong thực chiến

// Set — cấu trúc sinh ra cho MỘT lời hứa: không có phần tử trùng
Set<String> daXem = new HashSet<>();

daXem.add("video-1");     // true  — thêm mới
daXem.add("video-2");     // true
daXem.add("video-1");     // false — ĐÃ CÓ: Set từ chối, không ném lỗi

// add trả boolean = vừa thêm vừa kiểm tra trong MỘT thao tác:
if (!daXem.add(videoId)) {
    // đã xem rồi — tăng view? bỏ qua? tùy nghiệp vụ
}

// contains là nghề chính — O(1) với HashSet (Ngày 31):
daXem.contains("video-2");     // true — không duyệt, tra thẳng

// Khử trùng lặp một phát:
List<String> emails = List.of("a@x.com", "b@x.com", "a@x.com");
Set<String> duyNhat = new HashSet<>(emails);     // size = 2

// Đời thực Set ở khắp nơi:
// - tập user đã like / đã đọc / đã tham gia
// - tập tag của bài viết
// - tập quyền (permission) của một role
// Chung một câu hỏi: "X có trong tập không?" — đó là lúc gọi Set
  • Điểm ăn tiền của add: trả boolean thay vì ném lỗi — vừa thêm vừa kiểm tra trong một thao tác nguyên tử về logic, gọn hơn hẳn cặp contains + add rời rạc.

  • Nghề chính của Set là contains O(1) — mọi bài toán "X có trong tập không" (đã xem, đã like, có quyền) đều là đất diễn; và new HashSet<>(list) vẫn là câu thần chú khử trùng lặp một dòng của Ngày 31.

  • Chọn Set hay List là chọn lời hứa, không phải thói quen: dữ liệu có ngữ nghĩa "tập hợp" (không trùng, không quan tâm vị trí) mà lưu bằng List thì mọi chỗ dùng phải tự gác trùng lặp — trả nợ dần từng dòng code.

Ba anh em — khác nhau ở thứ tự

// Ba anh em một lời hứa — khác nhau ở THỨ TỰ
Set<String> hash   = new HashSet<>();
Set<String> linked = new LinkedHashSet<>();
Set<String> tree   = new TreeSet<>();

for (String s : List.of("chuối", "táo", "cam")) {
    hash.add(s); linked.add(s); tree.add(s);
}

hash;    // [cam, chuối, táo]?  — KHÔNG hứa gì, thứ tự có thể đổi giữa các lần chạy
linked;  // [chuối, táo, cam]   — đúng THỨ TỰ CHÈN, ổn định
tree;    // [cam, chuối, táo]   — luôn SẮP XẾP theo thứ tự tự nhiên

// Giá niêm yết (Ngày 31):
// HashSet        add/contains O(1)      — nhanh nhất, mặc định
// LinkedHashSet  O(1) + giữ thứ tự chèn — tốn thêm chút bộ nhớ (linked list ngầm)
// TreeSet        O(log n)               — đổi tốc độ lấy sắp xếp thường trực

// Chọn nhanh:
// - Chỉ cần khử trùng + hỏi contains?           → HashSet
// - Cần in ra ổn định đúng thứ tự người dùng thêm? → LinkedHashSet
// - Cần "nhỏ nhất/lớn nhất/khoảng"?              → TreeSet (phần dưới)
  • HashSet không hứa thứ tự — thứ tự in ra có thể đổi giữa các lần chạy, nên test mà assert theo thứ tự của HashSet là test tự vỡ; cần ổn định thì đổi loài, đừng cầu may.

  • LinkedHashSet giữ thứ tự chèn bằng linked list ngầm — thêm chút bộ nhớ đổi lấy đầu ra ổn định: hợp làm dữ liệu hiển thị, log, hay bất cứ chỗ nào con người đọc.

  • TreeSet trả O(log n) để luôn sắp xếp — đắt hơn Hash gấp nhiều lần cho contains thuần, nên chỉ chọn khi thật sự cần thứ tự/khoảng; quy luật đặt tên HashLinkedTree này sẽ lặp lại nguyên xi ở Map (Ngày 34–35).

HashSet đứng trên equals và hashCode

// HashSet đứng trên vai equals + hashCode (Ngày 24) — quên là "trùng mà không trùng"
class DiemToaDo {                       // KHÔNG override equals/hashCode
    int x, y;
    DiemToaDo(int x, int y) { this.x = x; this.y = y; }
}

Set<DiemToaDo> tap = new HashSet<>();
tap.add(new DiemToaDo(1, 2));
tap.add(new DiemToaDo(1, 2));           // hai object khác nhau theo default equals!
tap.size();                              // 2 — Set "thủng" lời hứa vì ta chưa dạy nó so sánh

// Cách chữa gọn nhất 2026 — record (Ngày 20): equals/hashCode chuẩn miễn phí
record Diem(int x, int y) { }
Set<Diem> tapDung = new HashSet<>();
tapDung.add(new Diem(1, 2));
tapDung.add(new Diem(1, 2));            // false — trùng thật sự bị chặn
tapDung.size();                          // 1 ✅

// Bẫy chí mạng: SỬA phần tử sau khi đã add (Ngày 24 + 29)
Set<List<String>> nguyHiem = new HashSet<>();
List<String> ds = new ArrayList<>(List.of("a"));
nguyHiem.add(ds);
ds.add("b");                             // hashCode của ds ĐỔI sau khi vào Set!
nguyHiem.contains(ds);                   // false — phần tử "lạc" ngay trong Set
// → phần tử của HashSet nên BẤT BIẾN: String, record, List.of — không phải ArrayList
  • Không override equals/hashCode thì mọi object là "chính nó" — hai DiemToaDo(1,2) vẫn là hai phần tử: Set không thủng, nó chỉ so sánh theo đúng thứ ta (chưa) dạy. Thuộc bài Ngày 24 là miễn nhiễm.

  • Cách chữa hiện đại: record (Ngày 20) — equals/hashCode theo giá trị được compiler viết hộ; class thường thì Objects.equals/Objects.hash theo đúng công thức Ngày 24.

  • Bẫy chí mạng nhất chương Collections: sửa phần tử sau khi addhashCode đổi là phần tử "lạc" ngay trong Set của nó (contains trả false dù nó nằm đó). Kết luận nối ba bài 24 + 29 + hôm nay: phần tử của Set họ Hash nên bất biến.

TreeSet — Comparable, Comparator và cái bẫy compareTo

// TreeSet — Set biết sắp xếp: trả lời cả "nhỏ nhất? lớn nhất? trong khoảng?"
TreeSet<Integer> diem = new TreeSet<>(List.of(85, 42, 97, 61, 73));

diem.first();                  // 42  — nhỏ nhất
diem.last();                   // 97  — lớn nhất
diem.headSet(70);              // [42, 61]      — mọi phần tử < 70
diem.tailSet(70);              // [73, 85, 97]  — mọi phần tử >= 70
diem.ceiling(80);              // 85  — phần tử nhỏ nhất >= 80
diem.floor(80);                // 73  — phần tử lớn nhất <= 80
// Họ câu hỏi "khoảng/lân cận" này HashSet chịu chết — đây là lý do tồn tại của Tree

// Điều kiện: phần tử phải SO SÁNH ĐƯỢC
new TreeSet<DiemToaDo>().add(new DiemToaDo(1, 2));
// 💥 ClassCastException: DiemToaDo không Comparable — nổ lúc CHẠY, không phải compile

// Hai cách dạy TreeSet so sánh:
// 1. Phần tử tự biết:  class X implements Comparable<X>
// 2. Đưa Comparator từ ngoài (Ngày 38 học sâu):
TreeSet<NhanVien> theoLuong =
        new TreeSet<>(Comparator.comparing(NhanVien::luong));

// Bẫy tinh vi: TreeSet coi "bằng" theo compareTo, KHÔNG theo equals
TreeSet<BigDecimal> tien = new TreeSet<>();
tien.add(new BigDecimal("1.0"));
tien.add(new BigDecimal("1.00"));    // compareTo bảo bằng nhau (Ngày 27!)
tien.size();                          // 1 — HashSet sẽ trả 2 (equals khác nhau)
  • Giá trị thật của TreeSet không phải "in ra có sắp xếp" — mà là họ câu hỏi khoảng/lân cận: first/last/headSet/ceiling… — leaderboard, khung giá, lịch trống — những câu HashSet không có cửa trả lời.

  • Phần tử phải so sánh được: tự implements Comparable hoặc đưa Comparator vào constructor (Ngày 38 học sâu) — quên cả hai là ClassCastException lúc chạy, một trong những runtime error "bất ngờ" kinh điển.

  • Bẫy tinh vi đáng giá của bài: TreeSet coi bằng nhau theo compareTo, không theo equalsBigDecimal("1.0")("1.00") (Ngày 27) là một phần tử trong TreeSet nhưng hai trong HashSet: cùng dữ liệu, hai kết quả — hiểu để không ngã ngửa.

Phép toán tập hợp và đồ nghề

// Phép toán tập hợp — toán lớp 6, code một dòng
Set<String> lopJava   = new HashSet<>(List.of("An", "Bình", "Chi"));
Set<String> lopSpring = new HashSet<>(List.of("Bình", "Chi", "Dũng"));

// HỢP (union): học ít nhất một lớp
Set<String> hoc = new HashSet<>(lopJava);
hoc.addAll(lopSpring);                  // [An, Bình, Chi, Dũng]

// GIAO (intersection): học CẢ hai lớp
Set<String> caHai = new HashSet<>(lopJava);
caHai.retainAll(lopSpring);             // [Bình, Chi]

// HIỆU (difference): chỉ học Java
Set<String> chiJava = new HashSet<>(lopJava);
chiJava.removeAll(lopSpring);           // [An]

// Lưu ý: các phép này SỬA set — copy trước khi tính (đúng bài Ngày 29)

// Đồ nghề quen từ Ngày 31 áp dụng nguyên xi:
Set<String> banGoc = Set.of("a", "b");            // bất biến, từ chối null
Set<String> anToan = Set.copyOf(nguonBenNgoai);   // defensive copy

// Enum? Có Set chuyên dụng nhanh hơn hẳn (Ngày 23):
EnumSet<TrangThai> dangXuLy = EnumSet.of(TrangThai.MOI, TrangThai.DANG_GIAO);
// bitmask bên trong — nhỏ và nhanh hơn HashSet nhiều lần cho enum
  • Union/intersection/difference = addAll/retainAll/removeAll — nhưng cả ba sửa set tại chỗ: copy trước khi tính (Ngày 29), nếu không "tập gốc" biến mất không dấu vết.

  • Đồ nghề Ngày 29 + 31 áp nguyên: Set.of cho hằng, Set.copyOf cho defensive copy — cùng tính cách ghét null và bất biến thật.

  • Phần tử là enum? EnumSet (Ngày 23) là bản chuyên dụng chạy bằng bitmask — nhỏ và nhanh hơn HashSet nhiều lần; cùng họ có EnumMap sẽ gặp lại ở bài Map.

Bài tập nhỏ

  • Viết class SinhVien(maSo, ten) không override gì, add 2 object cùng mã số vào HashSet — giải thích size; chuyển sang record và chạy lại.

  • Tái hiện phần tử "lạc": add một ArrayList vào Set, sửa list, chứng minh contains trả false — rồi sửa thiết kế cho đúng.

  • Cùng một list đầu vào, in ra qua HashSet/LinkedHashSet/TreeSet — chạy 3 lần và ghi nhận loài nào ổn định.

  • Dùng TreeSet điểm thi: tìm điểm cao nhất, thấp nhất, đếm số điểm trong khoảng 70–85 bằng subSet — không vòng lặp nào.

  • Chứng minh bẫy compareTo: add BigDecimal 1.01.00 vào cả TreeSet lẫn HashSet, so sánh size và giải thích.

  • Hai danh sách email marketing: dùng phép giao tìm khách nhận cả hai chiến dịch, phép hiệu tìm khách chỉ nhận một — nhớ copy trước.

Kết luận

Bốn ý mang về: Set = lời hứa duy nhất với add trả boolean và contains O(1) làm nghề chính; ba anh em khác nhau ở thứ tự — Hash nhanh nhất, Linked giữ thứ tự chèn, Tree luôn sắp xếp với giá O(log n); họ Hash đứng trên equals/hashCode và cần phần tử bất biến, họ Tree đứng trên compareTo — hai định nghĩa "bằng nhau" có thể cho hai kết quả; và phép toán tập hợp ba dòng thay cả trang vòng lặp. Ngày 34 ta mở nắp capo cấu trúc quan trọng nhất java.util: HashMap hoạt động thế nào — bucket, hash, load factor, treeify — câu hỏi phỏng vấn quốc dân. Hẹn gặp lại!

S

Site Admin

Engineer and writer. Building things with TypeScript and distributed systems.

Bình luận (0)

Bạn cần đăng nhập bằng Google để bình luận.

Hãy là người bình luận đầu tiên.

Bài viết liên quan

99 Ngày Spring — Ngày 33: Projection — chỉ lấy cột cần

Chi phí ba tầng của SELECT cả entity, interface projection đổi mỗi kiểu trả về, record projection cho DTO đi xa, dynamic projection — và bẫy open projection.

29 thg 8, 20268 phút0
99 Ngày Spring — Ngày 32: Specification & dynamic query

Bộ lọc tùy chọn làm derived query bùng nổ 2ⁿ — Specification biến mỗi điều kiện thành mảnh LEGO ghép and/or lúc chạy, mẹo null-safe, 4 bẫy và ranh giới QueryDSL.

28 thg 8, 20269 phút8
99 Ngày Java — Ngày 32: List — ArrayList vs LinkedList

Mảng động nới rộng 1.5x và bí mật amortized O(1), vì sao 'LinkedList chèn giữa nhanh' là hiểu lầm, số đo thật — và duyệt/xóa an toàn với removeIf.

28 thg 8, 20269 phút6