Bỏ qua điều hướng
EN
Tất cả bài viết

Java cơ bản #9: Collections — List, Set, Map, Generics và sắp xếp với Comparator

Bộ sưu tập co giãn thay cho mảng: ArrayList, HashSet, HashMap và khi nào dùng loại nào; generics và kiểu bao (Integer), duyệt và xoá an toàn, danh sách bất biến List.of, sắp xếp bằng Comparable/Comparator, Deque làm ngăn xếp và hàng đợi, cùng độ phức tạp của từng thao tác.

13 phút đọc

Ôn tập bài nàyThẻ ghi nhớ & câu hỏi ôn tập7 thẻ ghi nhớ · 7 câu trắc nghiệm
Mục lục

Mảng có kích thước cố định — trong khi giỏ hàng, danh sách bạn bè, lịch sử giao dịch thì cứ lớn dần. Thư viện Java Collections Framework cung cấp các cấu trúc dữ liệu co giãn, đã tối ưu và kiểm thử kỹ, mà bạn sẽ dùng hằng ngày: List, Set, Map.

Bài này giới thiệu từng loại, cách chọn loại phù hợp, generics (<String> trong List<String> là gì), và cách sắp xếp đối tượng tuỳ ý.

Bức tranh chung

Các interface chính trong java.util

Đang vẽ sơ đồ…

flowchart TB
  C[Collection] --> L["List — có thứ tự, cho trùng"]
  C --> S["Set — không trùng"]
  C --> Q["Queue / Deque — hàng đợi, ngăn xếp"]
  L --> AL[ArrayList]
  L --> LL[LinkedList]
  S --> HS[HashSet]
  S --> LHS[LinkedHashSet]
  S --> TS["TreeSet (đã sắp xếp)"]
  Q --> AD[ArrayDeque]
  Q --> PQ[PriorityQueue]
  M["Map — cặp khoá → giá trị (không thuộc Collection)"] --> HM[HashMap]
  M --> LHM[LinkedHashMap]
  M --> TM["TreeMap (khoá đã sắp xếp)"]

Nguyên tắc: khai báo biến theo interface, tạo đối tượng theo lớp cụ thể — List<String> ds = new ArrayList<>();. Sau này đổi sang cài đặt khác chỉ cần sửa một chỗ.

List: danh sách có thứ tự

DanhSach.java
import java.util.ArrayList;
import java.util.List;
 
void main() {
    List<String> monHoc = new ArrayList<>();      // <> : trình biên dịch tự suy kiểu
    monHoc.add("Java");
    monHoc.add("SQL");
    monHoc.add("Git");
    monHoc.add(1, "HTML");                        // chèn vào vị trí 1
    IO.println(monHoc);                           // [Java, HTML, SQL, Git] — toString có sẵn
    IO.println(monHoc.get(0) + ", size = " + monHoc.size());
 
    monHoc.set(3, "GitHub");                      // thay phần tử
    monHoc.remove("HTML");                        // xoá theo giá trị (dùng equals)
    IO.println(monHoc.contains("SQL") + " " + monHoc.indexOf("GitHub"));
 
    for (String m : monHoc) IO.print(m + " | ");
    IO.println("");
 
    List<String> coDinh = List.of("Thứ 2", "Thứ 4", "Thứ 6");   // danh sách BẤT BIẾN
    // coDinh.add("Thứ 7");  → UnsupportedOperationException
    List<String> banSao = new ArrayList<>(coDinh);               // cần sửa thì sao chép sang ArrayList
    banSao.add("Thứ 7");
    IO.println(banSao);
}

ArrayList là lựa chọn mặc định cho danh sách: truy cập theo chỉ số O(1), thêm cuối nhanh. LinkedList hiếm khi nhanh hơn trong thực tế — gần như không cần dùng.

Generics và kiểu bao

List<String> nghĩa là "danh sách chỉ chứa String". Trình biên dịch kiểm tra giúp — thêm số vào sẽ báo lỗi ngay, và lấy ra không cần ép kiểu.

Generics chỉ nhận kiểu tham chiếu, nên với số ta dùng kiểu bao (wrapper): Integer cho int, Double cho double, Boolean, Character, Long… Java tự chuyển qua lại (autoboxing/unboxing):

KieuBao.java
import java.util.ArrayList;
import java.util.List;
 
void main() {
    List<Integer> diem = new ArrayList<>();
    diem.add(8);                  // int 8 tự bọc thành Integer
    diem.add(6);
    diem.add(9);
    int tong = 0;
    for (int d : diem) tong += d; // Integer tự mở thành int
    IO.println("Tổng " + tong);
 
    diem.remove(Integer.valueOf(6));   // xoá GIÁ TRỊ 6 — remove(6) sẽ hiểu là xoá CHỈ SỐ 6!
    IO.println(diem);
 
    Integer a = 1000, b = 1000;
    IO.println(a == b);           // false — so sánh địa chỉ hai đối tượng Integer
    IO.println(a.equals(b));      // true
    Integer khongCo = null;
    // int x = khongCo;           → NullPointerException khi tự mở hộp
}

Lưu ý: đừng so sánh Integer bằng ==. Nó "tình cờ đúng" với số nhỏ (−128 đến 127, được dùng chung bộ nhớ đệm) nên lỗi chỉ lộ ra khi dữ liệu thật lớn hơn.

Tự viết lớp và phương thức generic

TuVietGeneric.java
import java.util.List;
 
record Cap<A, B>(A dau, B sau) {}                     // record generic: hai kiểu tuỳ ý
 
<T extends Comparable<T>> T lonNhat(List<T> ds) {     // T phải so sánh được
    T max = ds.getFirst();
    for (T x : ds) if (x.compareTo(max) > 0) max = x;
    return max;
}
 
void main() {
    var p = new Cap<>("Tuổi", 20);
    IO.println(p.dau() + " = " + p.sau());
    IO.println(lonNhat(List.of(3, 17, 8)));
    IO.println(lonNhat(List.of("chuối", "táo", "xoài")));    // so sánh từ điển
}

Set: không trùng lặp

TapHop.java
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
 
void main() {
    List<String> email = List.of("an@x.vn", "binh@x.vn", "an@x.vn", "chi@x.vn", "binh@x.vn");
 
    Set<String> hs = new HashSet<>(email);          // không thứ tự xác định
    Set<String> lhs = new LinkedHashSet<>(email);   // giữ thứ tự thêm vào
    Set<String> ts = new TreeSet<>(email);          // tự sắp xếp
    IO.println(hs.size() + " email duy nhất");      // 3
    IO.println(lhs);
    IO.println(ts);
 
    Set<String> lopA = new HashSet<>(Set.of("An", "Bình", "Chi"));
    Set<String> lopB = Set.of("Chi", "Dũng");
    IO.println("Thêm 'An' lần nữa: " + lopA.add("An"));   // false — đã có
    lopA.retainAll(lopB);                                   // giao hai tập
    IO.println("Học cả hai lớp: " + lopA);
}

Dùng Set khi cần loại trùng hoặc kiểm tra "có chứa không" thật nhanh: HashSet.contains là O(1) trung bình, còn ArrayList.contains phải duyệt cả danh sách O(n). Nhớ bài 8: phần tử của HashSet cần equals/hashCode đúng (record có sẵn).

Map: tra cứu theo khoá

TuDien.java
import java.util.HashMap;
import java.util.Map;
import java.util.TreeMap;
 
void main() {
    Map<String, Long> gia = new HashMap<>();
    gia.put("cà phê", 35_000L);
    gia.put("trà sữa", 45_000L);
    gia.put("cà phê", 39_000L);                         // khoá đã có → GHI ĐÈ giá trị
    IO.println(gia.get("cà phê"));                      // 39000
    IO.println(gia.get("nước ép"));                     // null — không có khoá
    IO.println(gia.getOrDefault("nước ép", 0L));        // 0
    IO.println(gia.containsKey("trà sữa"));
 
    for (Map.Entry<String, Long> e : gia.entrySet()) {
        IO.println(e.getKey() + ": " + e.getValue());
    }
 
    // Đếm tần suất từ — bài toán Map kinh điển
    String van = "học java học sql học git java";
    Map<String, Integer> dem = new TreeMap<>();           // TreeMap: khoá được sắp xếp
    for (String tu : van.split(" ")) {
        dem.merge(tu, 1, Integer::sum);                   // chưa có → 1; có rồi → cộng thêm 1
    }
    IO.println(dem);                                      // {git=1, học=3, java=2, sql=1}
 
    // Nhóm: mỗi khoá ứng với một danh sách
    Map<Character, java.util.List<String>> theoChuDau = new TreeMap<>();
    for (String ten : new String[]{"An", "Anh", "Bình", "Bảo", "Chi"}) {
        theoChuDau.computeIfAbsent(ten.charAt(0), k -> new java.util.ArrayList<>()).add(ten);
    }
    IO.println(theoChuDau);
}
MapThứ tự khi duyệtDùng khi
HashMapKhông xác địnhMặc định, nhanh nhất
LinkedHashMapThứ tự thêm vàoCần giữ thứ tự, cache LRU
TreeMapKhoá tăng dầnCần khoá sắp xếp, tìm khoảng (floorKey, headMap)

Duyệt và xoá an toàn

Xoá phần tử trong lúc duyệt bằng for-each gây ConcurrentModificationException:

XoaKhiDuyet.java
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
 
void main() {
    List<Integer> so = new ArrayList<>(List.of(1, 2, 3, 4, 5, 6));
    // for (Integer x : so) if (x % 2 == 0) so.remove(x);   → ConcurrentModificationException
 
    so.removeIf(x -> x % 2 == 0);          // cách gọn nhất (Java 8+)
    IO.println(so);                        // [1, 3, 5]
 
    List<String> ten = new ArrayList<>(List.of("An", "", "Bình", " "));
    Iterator<String> it = ten.iterator();  // cách truyền thống
    while (it.hasNext()) {
        if (it.next().isBlank()) it.remove();
    }
    IO.println(ten);
}

Sắp xếp: Comparable và Comparator

SapXep.java
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
 
record SinhVien(String ten, double diem, int namSinh) implements Comparable<SinhVien> {
    @Override
    public int compareTo(SinhVien khac) {          // "thứ tự tự nhiên": theo tên
        return ten.compareTo(khac.ten);
    }
}
 
void main() {
    List<SinhVien> ds = new ArrayList<>(List.of(
        new SinhVien("Chi", 8.5, 2005),
        new SinhVien("An", 7.0, 2006),
        new SinhVien("Bình", 8.5, 2004),
        new SinhVien("Dũng", 6.5, 2005)));
 
    ds.sort(null);                                 // dùng compareTo (thứ tự tự nhiên)
    IO.println(ds.stream().map(SinhVien::ten).toList());
 
    // Comparator: thứ tự tuỳ ý, ghép nhiều tiêu chí
    ds.sort(Comparator.comparingDouble(SinhVien::diem).reversed()   // điểm giảm dần
                      .thenComparingInt(SinhVien::namSinh));         // bằng điểm → ai sinh trước đứng trước
    for (SinhVien sv : ds) IO.println(sv);
}
  • Comparable (phương thức compareTo): thứ tự tự nhiên, một cái duy nhất, cài trong chính lớp. String, Integer, LocalDate đều đã có.
  • Comparator: thứ tự bên ngoài, tạo bao nhiêu cũng được. Comparator.comparing(...), .reversed(), .thenComparing(...) ghép tiêu chí rất gọn (cú pháp SinhVien::diem là tham chiếu phương thức — bài 11).

compareTo trả về số âm / 0 / dương nếu đối tượng này nhỏ hơn / bằng / lớn hơn đối tượng kia.

Deque: ngăn xếp và hàng đợi

NganXepHangDoi.java
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.PriorityQueue;
import java.util.Queue;
 
boolean ngoacHopLe(String s) {                 // bài toán ngăn xếp kinh điển
    Deque<Character> stack = new ArrayDeque<>();
    for (char c : s.toCharArray()) {
        switch (c) {
            case '(', '[', '{' -> stack.push(c);
            case ')' -> { if (stack.isEmpty() || stack.pop() != '(') return false; }
            case ']' -> { if (stack.isEmpty() || stack.pop() != '[') return false; }
            case '}' -> { if (stack.isEmpty() || stack.pop() != '{') return false; }
            default -> { }
        }
    }
    return stack.isEmpty();
}
 
void main() {
    IO.println(ngoacHopLe("{a[b(c)]}") + " " + ngoacHopLe("(]"));
 
    Queue<String> hangCho = new ArrayDeque<>();   // hàng đợi FIFO: vào trước ra trước
    hangCho.offer("Khách 1");
    hangCho.offer("Khách 2");
    IO.println("Phục vụ: " + hangCho.poll() + ", còn chờ: " + hangCho);
 
    Queue<Integer> uuTien = new PriorityQueue<>(); // luôn lấy ra phần tử nhỏ nhất
    uuTien.addAll(java.util.List.of(5, 1, 4, 2));
    IO.print("Theo ưu tiên: ");
    while (!uuTien.isEmpty()) IO.print(uuTien.poll() + " ");
    IO.println("");
}

Dùng ArrayDeque cho cả ngăn xếp (push/pop) lẫn hàng đợi (offer/poll); lớp Stack cũ đã lỗi thời.

Chọn cấu trúc nào

CầnDùngĐộ phức tạp chính
Danh sách có thứ tự, truy cập theo vị tríArrayListget O(1), add cuối O(1), contains O(n)
Không trùng, kiểm tra "có chứa" nhanhHashSetadd/contains O(1)
Không trùng, cần sắp xếpTreeSetO(log n)
Tra cứu theo khoáHashMapget/put O(1)
Khoá sắp xếp, tìm theo khoảngTreeMapO(log n)
Ngăn xếp / hàng đợiArrayDequeO(1) hai đầu
Luôn lấy phần tử nhỏ/lớn nhấtPriorityQueueO(log n)

Bài tập

  1. Nhập một đoạn văn, in 5 từ xuất hiện nhiều nhất (gợi ý: HashMap đếm, rồi sắp xếp entrySet theo giá trị giảm dần).
  2. Viết GioHang dùng Map<String, Integer> (mã sản phẩm → số lượng) với them, bot, xoa, tongSoMon.
  3. Cho hai danh sách học viên, in người chỉ học lớp A, chỉ học lớp B, học cả hai (dùng Set).
  4. Sắp xếp danh sách sản phẩm theo danh mục (A→Z), cùng danh mục thì theo giá giảm dần.
  5. Mô phỏng hàng đợi in ấn: tài liệu có độ ưu tiên (PriorityQueue với Comparator), in theo ưu tiên rồi theo thứ tự gửi.

Tổng kết

  • Khai báo theo interface (List, Set, Map), tạo bằng lớp cụ thể (ArrayList, HashSet, HashMap).
  • Generics bảo đảm kiểu lúc biên dịch; số dùng kiểu bao (Integer…), không so sánh kiểu bao bằng ==.
  • List.of, Set.of, Map.of tạo tập hợp bất biến; cần sửa thì sao chép sang ArrayList/HashMap.
  • Xoá khi duyệt bằng removeIf hoặc Iterator.remove.
  • Comparable cho thứ tự tự nhiên; Comparator.comparing().reversed().thenComparing() cho thứ tự tuỳ ý.
  • Chọn cấu trúc theo thao tác cần nhanh: tra cứu → HashMap/HashSet; thứ tự → TreeMap/TreeSet; hai đầu → ArrayDeque.
  • Bài tiếp theo: xử lý lỗi bằng ngoại lệ và đọc ghi file.

Nguồn tham khảo: dev.java — "The Collections Framework", "Generics"; tài liệu API Java 25 — java.util; Bloch — Effective Java, mục 26–31 (generics), 64 (tham chiếu theo interface); Java Programming MOOC — University of Helsinki, phần 7–8.

Đang tải câu hỏi luyện tập…

Bài này thế nào?

Bài viết có hữu ích với bạn?

Đánh giá giúp mình biết nên viết thêm về chủ đề nào.

Đang tải đánh giá…
Chọn số sao từ 1 đến 5

Bình luận

Đăng nhập để bình luận không cần nhập tên và được duyệt nhanh hơn.

0/2000
Đang tải bình luận…

Đọc tiếp

Bài số № 55

Java cơ bản #12: dự án cuối — ứng dụng quản lý chi tiêu với Maven và JUnit

Gom toàn bộ kiến thức vào một dự án thật: cấu trúc Maven và pom.xml, chia lớp model–kho dữ liệu–service–giao diện, lưu file CSV, thống kê bằng Stream, cảnh báo ngân sách, viết kiểm thử JUnit 5, đóng gói file jar chạy được — và lộ trình học tiếp sau Java cơ bản.

19 phút đọc

Bài số № 54

Java cơ bản #11: lambda, tham chiếu phương thức, Stream API và Optional

Lập trình kiểu hàm trong Java: lambda và functional interface (Predicate, Function, Consumer, Supplier), tham chiếu phương thức ::, Stream với filter–map–reduce, sorted, collect, groupingBy, Optional thay cho null — cùng lời khuyên khi nào vòng lặp vẫn tốt hơn stream.

14 phút đọc

Bài số № 53

Java cơ bản #10: xử lý ngoại lệ và đọc ghi file với java.nio.file

Khi chương trình gặp lỗi: đọc stack trace, try/catch/finally, checked và unchecked exception, throw và throws, tự định nghĩa ngoại lệ nghiệp vụ, try-with-resources tự đóng tài nguyên; đọc ghi file văn bản và CSV bằng Files, Path, UTF-8 — kèm các thói quen xử lý lỗi nên tránh.

15 phút đọc