Nền Tảng Trước Khi Grind LeetCode: 6 Lớp Kiến Thức Mọi Junior Cần Master
Nền Tảng Trước Khi Grind LeetCode: 6 Lớp Kiến Thức Mọi Junior Cần Master
Chào bạn, tôi là một kỹ sư phần mềm đã có nhiều năm làm việc và phỏng vấn, đặc biệt trong môi trường product và các công ty công nghệ lớn. Tôi từng chứng kiến rất nhiều bạn junior Việt Nam apply vào FAANG hay các công ty global tech, và một thực tế đáng buồn là 90% thất bại không phải vì không biết Dynamic Programming hay Graph Algorithm phức tạp. Vấn đề nằm ở chính 6 lỗ hổng nền tảng cơ bản mà các bạn ấy chưa thực sự “solid”. Bài viết này sẽ giúp bạn lấp đầy những lỗ hổng đó, để khi bước vào luyện tập LeetCode hay phỏng vấn thực chiến, bạn sẽ tự tin và nhanh nhạy hơn gấp bội.
Nếu bạn nghiêm túc với mục tiêu trở thành một Software Engineer thực thụ, không chỉ “code chạy được”, hãy dành 2-3 tuần để master toàn bộ nội dung dưới đây.
Vì Sao Bạn Cần Bài Viết Này?
Hãy thử nhìn vào đoạn code của một bạn junior 1 năm kinh nghiệm khi được yêu cầu: “Cho một danh sách giao dịch, hãy nhóm theo userId và trả về những userId có tổng amount lớn hơn 1 triệu.”
// Code junior viết ra:
public List<String> findTopUsers(List<Transaction> txns) {
List<String> result = new ArrayList<>();
HashMap<String, Long> sums = new HashMap<>();
for (int i = 0; i < txns.size(); i++) {
Transaction t = txns.get(i);
if (sums.containsKey(t.getUserId())) {
sums.put(t.getUserId(), sums.get(t.getUserId()) + t.getAmount());
} else {
sums.put(t.getUserId(), t.getAmount());
}
}
for (String userId : sums.keySet()) {
if (sums.get(userId) > 1000000) {
result.add(userId);
}
}
return result;
}
Nhìn qua thì code chạy được, nhưng dưới con mắt của một senior interviewer, đây là một “thảm họa” tiềm ẩn. Chỉ trong 30 giây, họ sẽ thấy:
- Việc dùng
for (int i = 0; ...)rồitxns.get(i): NếutxnslàLinkedList, thao tác.get(i)có độ phức tạp O(n), biến toàn bộ vòng lặp thành O(n²). - Ba lần lookup vào Map: kiểm tra
containsKey, rồiget, rồiput– hoàn toàn có thể giải quyết bằng một lần gọimergehoặcgetOrDefault. - Duyệt
keySet()rồi gọiget(userId)một lần nữa, thay vì dùngentrySet()để lấy trực tiếp cả khóa và giá trị. - Dùng
Longcho tiền tệ: mất phần thập phân và không chính xác về mặt nghiệp vụ tài chính, đáng lẽ phải dùngBigDecimal. - Không có bất kỳ null guard nào ở đầu hàm.
Đó chính là “gap” giữa một junior chỉ biết code và một senior hiểu rõ công cụ mình đang dùng. Để trở thành một kỹ sư phần mềm thực thụ, bạn cần fluency với nền tảng. Dưới đây là 6 lớp kiến thức bạn nhất định phải nắm vững.
Mental Model: 6 Lớp Nền Cho Mọi Junior
Lớp 1: Java Collections – Biết Khi Nào Dùng Cái Nào
Không thể phỏng vấn giỏi nếu bạn phải phân vân giữa ArrayList và LinkedList. Hãy ghi nhớ bản đồ quyết định tối giản này:
- Cần thứ tự?
- Có: Cần cho phép trùng lặp? → Có → List. Cần truy xuất theo index nhanh? → ArrayList (O(1) random access). Thường xuyên thêm/xóa ở đầu/cuối? → ArrayDeque (dùng như stack/queue, nhanh hơn LinkedList).
- Không: Cần cho phép trùng lặp? → Không → Set. Cần duyệt có sắp xếp? → TreeSet (O(log n)). Chỉ cần
containsnhanh? → HashSet (O(1)).
- Map: Tương tự như Set nhưng lưu trữ cặp key-value. Cần nhanh:
HashMap. Cần sắp xếp theo key:TreeMap. Cần giữ thứ tự thêm vào:LinkedHashMap(cực kỳ hữu ích cho LRU Cache). Cần thread-safe:ConcurrentHashMap.
Những sai lầm kinh điển cần tránh:
- Đừng bao giờ dùng
Stack: LớpStacktrong Java kế thừaVector, đồng bộ hóa mặc định và hiệu suất kém. Hãy dùngArrayDequevớipush(),pop(),peek().Deque<Integer> stack = new ArrayDeque<>(); stack.push(1); int top = stack.pop(); - Cẩn thận với
Arrays.asList(): Nó trả về một fixed-size list, không thể thêm/xóa phần tử.List<String> fixedList = Arrays.asList("a", "b", "c"); fixedList.add("d"); // → UnsupportedOperationException! // Luôn wrap sang ArrayList để có mutable list: List<String> mutableList = new ArrayList<>(Arrays.asList("a", "b", "c")); // Hoặc dùng List.of() (immutable) từ Java 9+.
Lớp 2: Generics – Thuộc Nằm Lòng PECS
Generics là một trong những chủ đề khiến nhiều bạn e ngại nhất. Nhưng nếu bạn muốn đọc hiểu source code của Spring hay Java standard library, bạn buộc phải hiểu wildcards. May mắn thay, có một quy tắc cực kỳ đơn giản tên là PECS: Producer Extends, Consumer Super.
- Producer (đọc dữ liệu từ cấu trúc): Dùng
? extends T. Bạn chỉ có thể đọc ra, không thể thêm vào (vì không biết chính xác subtype là gì).public double sumOfList(List<? extends Number> list) { double sum = 0; for (Number n : list) sum += n.doubleValue(); // Đọc OK // list.add(1); // Compile error return sum; } - Consumer (ghi dữ liệu vào cấu trúc): Dùng
? super T. Bạn có thể thêmThoặc subtype củaTvào, nhưng khi đọc ra chỉ đảm bảo làObject.public void addIntegers(List<? super Integer> list) { list.add(1); // OK vì list có thể là List<Integer>, List<Number>, List<Object> // Integer x = list.get(0); // Compile error }
Hãy thử áp dụng PECS để đọc hiểu signature của Collections.copy(): public static <T> void copy(List<? super T> dest, List<? extends T> src). Dữ liệu chảy từ src (producer, extends) sang dest (consumer, super) – thật logic!
Lớp 3: Recursion – Tập “Tin” Vào Lời Gọi Đệ Quy
Đây là lỗ hổng lớn nhất tôi thấy ở các bạn khi học giải thuật. Các bạn viết được code đệ quy, nhưng lại cố gắng trace từng bước nhảy stack, và sẽ rối ngay khi cây có độ sâu 5. Bí quyết của senior nằm ở “leap of faith” – cú nhảy của niềm tin.
Khi viết hàm đệ quy, hãy giả định rằng lời gọi đệ quy với input nhỏ hơn đã chạy đúng và trả về kết quả chính xác. Việc của bạn chỉ là:
- Xác định base case (điểm dừng) thật chính xác.
- Kết hợp kết quả từ các lời gọi đệ quy sao cho đúng ở node hiện tại.
- Đảm bảo mỗi lần gọi, input tiến gần hơn về base case.
Ví dụ, khi đếm số node trong cây nhị phân:
public int countNodes(TreeNode root) {
if (root == null) return 0; // Base case
// Niềm tin: countNodes(root.left) và countNodes(root.right) đã trả đúng số node của cây con
return 1 + countNodes(root.left) + countNodes(root.right);
}
Đừng cố gắng trace root.left.left.... Chỉ cần base case đúng, và phép kết hợp 1 + ... là hợp lý, thì toàn bộ cây sẽ đúng. Ba câu hỏi tự kiểm tra: Nó có dừng không? Input có nhỏ dần không? Cách kết hợp con cho ra kết quả cha có đúng không? Nếu cả ba đều “có”, code của bạn đúng.
Lớp 4: Big-O – Phản Xạ Tính Độ Phức Tạp
Senior interviewer mong đợi bạn tự động tính ra time/space complexity ngay khi viết code, mà không cần họ nhắc. Đây là một vài quy tắc phải thuộc lòng:
| Thao tác | Độ phức tạp |
|---|---|
| Duyệt mảng 1 vòng lặp | O(n) |
| Hai vòng lặp lồng nhau trên n | O(n²) |
ArrayList.get(i) / HashMap.get(k) | O(1) |
LinkedList.get(i) / ArrayList.contains() | O(n) |
TreeMap.get(k) / PriorityQueue.offer() | O(log n) |
String concatenation (+=) trong vòng lặp | O(n²) (tạo đối tượng mới và copy) |
Hãy tập thói quen thêm comment // O(?) bên cạnh mỗi dòng code. Sau một tuần, việc nhẩm Big-O sẽ trở thành phản xạ. Và nhớ rằng, những thao tác tưởng chừng O(1) như list.contains() thực chất là O(n) – đây là bẫy khiến bạn tính sai độ phức tạp của cả thuật toán.
Lớp 5: Equals, HashCode, Comparable – Bộ Ba Quyền Lực
Nếu bạn muốn object của mình làm key trong HashMap hoặc phần tử trong HashSet, bạn phải tôn trọng những “hợp đồng” (contract) sau:
- equals() phải đảm bảo tính phản xạ, đối xứng, bắc cầu và nhất quán.
- Nếu
a.equals(b) == true, thìa.hashCode()vàb.hashCode()PHẢI bằng nhau. Ngược lại không bắt buộc (hash collision có thể xảy ra). - Nếu implement
Comparable, kết quả củacompareTonên nhất quán vớiequals(không bắt buộc nhưng tránh bug trongSortedSet).
May mắn thay, từ Java 16 trở đi, bạn chỉ cần dùng record:
public record AccountId(String value) { }
// equals, hashCode, toString đều được tự động sinh ra chính xác.
Một bẫy phỏng vấn nổi tiếng khác: So sánh Integer với ==. Do cơ chế cache của JVM từ -128 đến 127:
Integer a = 127, b = 127;
System.out.println(a == b); // true (cùng reference trong cache)
Integer c = 128, d = 128;
System.out.println(c == d); // false (khác reference, không cache)
// Luôn dùng .equals() cho các wrapper class.
Lớp 6: Exception Handling – Nguyên Tắc “Fail Loud, Fail Fast”
Xử lý ngoại lệ cẩu thả là dấu hiệu của một lập trình viên thiếu chuyên nghiệp. Hãy nhớ 4 anti-pattern sau và tránh bằng mọi giá:
- Nuốt exception:
try { ... } catch (Exception e) {}– Bạn đang giấu đi một quả bom nổ chậm. - Bắt Exception quá rộng rồi chỉ log: Đồng nghĩa với việc nói với caller rằng “mọi thứ vẫn ổn”, trong khi thực sự không phải.
- Rethrow nhưng mất stack trace gốc:
throw new RuntimeException(e.getMessage());– Bạn đã xóa sạch dấu vết để điều tra bug.- Cách đúng:
throw new RuntimeException("Failed to process", e);(luôn truyền original exception làm cause).
- Cách đúng:
- Khai báo
throws Exception: Quá mơ hồ. Hãy khai báo các ngoại lệ cụ thể.
Nguyên tắc vàng của tôi: Nếu không biết phải xử lý ngoại lệ thế nào tại tầng hiện tại, hãy để nó “bubble up” lên tầng cao hơn (controller/global handler). Đừng bao giờ catch chỉ để log.
Từ Junior Code đến Production-Grade Code
Quay lại bài toán gom nhóm giao dịch lúc đầu. Đây là cách một senior engineer sẽ viết:
public List<String> findHighValueUsers(List<Transaction> txns, BigDecimal threshold) {
if (txns == null || txns.isEmpty()) return List.of(); // Fail fast
Map<String, BigDecimal> sumByUser = new HashMap<>();
for (Transaction t : txns) {
// merge: nếu chưa có key -> put (key, amount); nếu có -> remapping function BigDecimal::add
sumByUser.merge(t.getUserId(), t.getAmount(), BigDecimal::add);
}
List<String> result = new ArrayList<>();
for (var entry : sumByUser.entrySet()) { // Dùng entrySet, không keySet+get
if (entry.getValue().compareTo(threshold) > 0) {
result.add(entry.getKey());
}
}
return result;
}
Những điểm sáng “senior” trong đoạn code này:
- Null guard ngay đầu, trả về immutable list khi không có dữ liệu.
- Sử dụng
BigDecimalcho nghiệp vụ tài chính, dùngcompareTothay vì toán tử>. - Sử dụng
Map.mergethần thánh: code ngắn gọn, một lần lookup. - Dùng
entrySet()để duyệt, tránh việc tìm kiếm lại.
Những Cái Bẫy Chờ Bạn Trong Thực Tế
Trước khi kết thúc, hãy điểm qua 5 anti-pattern phổ biến mà tôi vẫn thấy hàng ngày trong code review:
- “Tối ưu hóa sớm”: Đừng khởi tạo
HashMapvới capacity 10 triệu khi chưa biết dữ liệu. Mặc định là đủ tốt, chỉ tối ưu khi profiler nói bạn cần. - Sửa collection đang duyệt:
list.remove(x)trongfor-eachgâyConcurrentModificationException. Hãy dùngIterator.remove()hoặc tiện lợi hơn:list.removeIf(n -> n % 2 == 0);(Java 8+). - So sánh wrapper bằng
==:Long a = 200L; Long b = 200L; if (a == b)là sai. Hãy dùng.equals()hoặc unbox về primitive. - Stream “quá liều”: Một pipeline gồm 5-6 thao tác
filter,maplồng nhau sẽ rất khó debug. Đôi khi một vòng lặpforvớicontinuecòn dễ đọc và hiệu quả hơn. - Mutable state trong stateless class: Tuyệt đối không dùng field của class để lưu trạng thái tạm thời trong service/controller nếu không có chủ đích về concurrency. Hãy dùng biến cục bộ.
Lộ Trình 2 Tuần Để Master Nền Tảng
Bài viết này sẽ trở nên vô ích nếu bạn chỉ đọc. Hãy hành động với kế hoạch cụ thể:
Tuần 1: Học và Áp Dụng (Đi theo 6 lớp trong bài)
- Ngày 1-2: Ôn luyện Collections, thực hành 5 bài LeetCode Easy về Array và HashMap.
- Ngày 3-4: Học PECS và Recursion, giải 5 bài LeetCode Easy cơ bản về Tree (như 104, 226, 100…).
- Ngày 5-7: Hoàn thiện Big-O, equals/hashCode, Exception. Giải 10 bài LeetCode Easy hỗn hợp.
Tuần 2: Luyện Tập Chuyên Sâu
- Mục tiêu: Giải 30 bài LeetCode Easy một cách thuần thục.
- Yêu cầu: Không xem lời giải, mỗi bài dưới 20 phút.
- Tracking: Dùng Notion hoặc Excel để ghi lại tên bài, thời gian giải, và những lỗi sai.
Bạn có thể chọn từ danh sách 50 bài đã được tinh lọc dành cho giai đoạn này (các bài về Arrays, Strings, HashMap, Trees, Recursion), đảm bảo bao phủ mọi nền tảng cần thiết.
Checklist Tự Đánh Giá
Trước khi chuyển sang các pattern LeetCode phức tạp, hãy chắc chắn bạn có thể trả lời “Có” cho tất cả những câu hỏi sau:
- Collections Fluency: Có thể tự tay cài đặt LRU Cache bằng
LinkedHashMap, tìm Top-K bằngPriorityQueue, hay dùngArrayDequelàm Stack mà không cần nhìn IDE gợi ý. - PECS Reading: Giải thích được vì sao
Collections.copy(List<? super T> dest, List<? extends T> src)lại dùng wildcards như vậy. - Recursion Confidence: Giải xong 5 bài LeetCode Tree cơ bản dưới 15 phút/bài mà không cần xem lời giải.
- Big-O Reflex: Nhìn vào một đoạn code bất kỳ, nói được time và space complexity trong 30 giây.
- Equals/HashCode Contract: Giải thích được hậu quả của việc ghi đè
equalsmà không ghi đèhashCodekhi dùngHashMap. - Exception Hygiene: Liệt kê và sửa được 4 anti-pattern trong bài. Hiểu rõ quy tắc “let it bubble up”.
Hành trình trở thành một kỹ sư phần mềm xuất sắc bắt đầu từ một nền móng vững chắc. Đừng vội lao vào những thứ hào nhoáng khi những viên gạch cơ bản nhất vẫn chưa được đặt đúng chỗ. Chúc bạn vững vàng trên con đường của mình!