Câu hỏi phỏng vấn Big-O và Hash Table
Bộ câu hỏi này harvest từ hai khoá Thuật toán căn bản và Thuật toán cốt lõi — mỗi câu đều link tới bài học mổ cơ chế bên dưới, để bạn trả lời được cả những câu vặn tiếp.
01
Big-O thực chất đo gì — và nó KHÔNG đo gì?
JuniorBig-O đo tốc độ tăng trưởng của số bước khi
nlớn, không phải thời gian tuyệt đối tính bằng mili giây. Chính xác hơn, nó là chặn trên:T(n) = O(f(n))nghĩa là tồn tại hằng sốcvàn0sao cho với mọintừn0trở đi,T(n)không vượt quácnhânf(n). Vì thế nó không nói gì về hằng số, về cache miss, hay về hành vi ởnnhỏ — hai đoạn code cùngO(n)vẫn có thể chênh nhau hàng chục lần chỉ vì memory layout. Cách dùng đúng trong công việc: Big-O trả lời "code này scale thế nào", còn profiler trả lời "nó nhanh bao nhiêu ở đây và bây giờ".Trả lời "Big-O là thời gian chạy của thuật toán" — sai bản chất: nó đo tốc độ tăng, nênO(1)không có nghĩa là nhanh, chỉ có nghĩa là không phụ thuộcn.02
Vì sao Big-O bỏ hằng số và hạng bậc thấp — làm vậy có mất thông tin không?
JuniorVì ở
nđủ lớn, hạng bậc cao nuốt trọn phần còn lại: vớiT(n) = 3n² + 100n + 5, ởn = 10hạng100ncòn chiếm 76%, nhưng ởn = 1.000thì3n²đã chiếm 97%. Hằng số cũng mờ dần vì nó phản ánh phần cứng chứ không phản ánh cấu trúc: máy nhanh gấp đôi biến hệ số3thành1.5nhưng bậc vẫn làn². Nói cách khác, tỷ lệ hạng thấp trên hạng cao tiến về 0 khintiến ra vô cực, nên giữ chúng lại cũng không đổi được kết luận về khả năng scale. Có mất thông tin, và mất đúng phần mà Big-O cố tình không nhận trách nhiệm: khi hai thuật toán cùng bậc thì hằng số mới là thứ quyết định, lúc đó phải đo chứ không suy từ ký hiệu.Nói "hằng số không quan trọng" — Big-O bỏ hằng số không có nghĩa thực tế bỏ được nó; hai giải pháp cùngO(n log n)thì chính hằng số quyết định cái nào thắng.03
Hai vòng lặp lồng nhau có phải luôn là O(n²)? Lúc nào cộng, lúc nào nhân độ phức tạp?
JuniorKhông, vòng lặp lồng thì nhân độ phức tạp của hai tầng chứ không mặc định ra
O(n²). Nếu tầng trong là binary search thì tổng làO(n)nhânO(log n), tứcO(n log n); chỉ khi tầng trong duyệt tuyến tính mới raO(n²). Ngược lại, hai vòng lặp tuần tự thì cộng:O(n) + O(n) = O(2n), rồi bỏ hằng số cònO(n). Quy tắc thực hành là phân tích từng vòng riêng rồi mới ghép — lồng thì nhân, nối tiếp thì cộng rồi bỏ hạng bậc thấp.Đếm số vòngforrồi kết luận số mũ — bỏ qua chi phí thật của thao tác bên trong, chẳng hạn một lệnhcontainstrên list cũng làO(n)ẩn nằm trong vòng lặp.04
O, Theta, Omega khác nhau ra sao? Vì sao tài liệu API gần như chỉ dùng O?
JuniorOlà chặn trên (không tăng nhanh hơn),Ωlà chặn dưới (không tăng chậm hơn),Θlà chặn chặt khi cả trên lẫn dưới đều là cùng một hàm. Merge Sort có best case và worst case đều làn log nnênΘ(n log n)là mô tả chính xác; linear search thì best caseΩ(1)còn worst caseO(n)nên không cóΘđơn giản. Dev dùngOgần như mọi lúc vì kỹ thuật quan tâm worst case — đó mới là con số đặt SLA được. Ngoài raOan toàn hơn khi cam kết: nói "không tệ hơnO(n log n)" vẫn đúng kể cả khi best case tốt hơn, còn nóiΘlà buộc phải chứng minh cả chặn dưới.Đọc thuộc ba định nghĩa nhưng không nêu được vì sao thực tế chỉ dùngO, và nhầm rằngΘ"chính xác hơn nên tốt hơn" — cam kếtΘlà tự buộc mình chứng minh thêm chặn dưới mà production không cần.05
Một hash function tốt cho hash table cần những tính chất gì?
JuniorBốn tính chất: deterministic (cùng input luôn cùng output), nhanh cỡ
O(1), phân phối đều để không bucket nào thành điểm nóng, và avalanche — đổi 1 bit input thì khoảng 50% bit output đổi theo. Phân phối đều giữ chiều dài mỗi chain là hằng số; avalanche khiến các key "gần nhau" không dồn vào cùng bucket, vì nhiều hash chuỗi có entropy tập trung ở bit cao trong khi bucket index chỉ lấy vài bit thấp. Đó là lý do nhiều Map trộn thêm một bước supplemental hash — XORhvới chínhhdịch phải 16 bit — trước khi tínhh AND (capacity - 1). Hai thứ hash table KHÔNG cần là one-way và collision-resistant: đó là yêu cầu của hash mật mã nhưSHA-256, còn ở đây va chạm chỉ cần hiếm là đủ.Gộp hai họ hash làm một — đòi hash table phải one-way hay collision-resistant, trong khi nó chỉ cần deterministic, nhanh, phân phối đều và có avalanche.06
Contract giữa equals và hashCode là gì, và vì sao vi phạm nó lại là bug im lặng?
JuniorContract bắt buộc là một chiều: nếu
a.equals(b)đúng thìa.hashCode()phải bằngb.hashCode(); chiều ngược lại không bắt buộc vì va chạm hash là chuyện bình thường. Map định vị bucket bằnghashCoderồi mới dùngequalsđể xác nhận đúng key, nên hai object "bằng nhau" mà hash khác nhau sẽ nằm ở hai bucket khác nhau và không bao giờ gặp nhau. Khi overrideequalsmà quênhashCode, object rơi về identity hash gắn với từng instance, nênset.add(u1)rồiset.contains(u2)trảfalsedùu1.equals(u2)là true. Bug im lặng vì không có exception nào được ném:containstrảfalse,gettrảnull, đều là giá trị hợp lệ — test dùng lại đúng một reference vẫn pass, chỉ vỡ khi object được tạo mới từ JSON hoặc database.Chỉ đọc thuộc "override equals thì override hashCode" mà không nêu được hệ quả cụ thể, và bỏ qua bẫy anh em: đưa field mutable vàohashCodekhiến key lạc bucket sau khi mutate, entry vẫn nằm trong map nhưnggettrảnull.07
Vì sao nói hash table lookup là O(1) trung bình nhưng worst case O(n)?
JuniorO(1)chỉ đúng dưới giả định hàm hash phân phối đều: mỗi bucket chứa số entry là hằng số, không phụ thuộcn. Nếu hàm hash tệ hoặc bị tấn công có chủ đích, cảnkey rơi vào cùng một bucket và lookup thoái hoá thành duyệt một chain dàin, đúngO(n). Thư viện hiện đại chặn đáy bằng treeify: bucket có chain dài vượt ngưỡng (từ 8 node trở lên) được chuyển thành cây đỏ-đen, worst case cònO(log n). Cộng thêm resize — khi vượt load factor, bảng phải rehash toàn bộ với chi phíO(n), nhưng vì hiếm nên trung bình mỗi thao tác vẫn làO(1).Nói "HashMap là O(1)" như một sự thật tuyệt đối — không nêu được giả định phân phối đều, cũng không biết bảng có lúc phải resize và rehash toàn bộ.08
Mảng động thỉnh thoảng tốn O(n) để resize — vì sao add() vẫn được gọi là O(1)?
JuniorVì
O(1)ở đây là amortized: tính tổng chi phí của cả dãynlần add rồi chia chon, chứ không phải chi phí của một lần riêng lẻ. Với chiến lược nhân đôi, tổng chi phí sao chép qua mọi lần resize là1 + 2 + 4 + ... + n/2— một chuỗi hình học bằngn - 1. Cộng thêmnlần ghi phần tử, tổng dưới2n; chia chonra hằng số 2, tứcO(1)mỗi thao tác. Điểm mấu chốt là mỗi lần resize đắt được trả trước bởi rất nhiều lần add rẻ trước đó, nên chi phí đắt bị rải mỏng thay vì cộng dồn.Trả lời "vì resize hiếm nên coi như O(1)" — đó mới là lập luận average case; amortized mạnh hơn vì chứng minh được bằng tổng chi phí trên dãy thao tác xấu nhất, không cần giả định gì về input.09
Binary search có bắt buộc mảng phải sorted không?
JuniorKhông — điều kiện thật sự là predicate đơn điệu: tồn tại một điểm chuyển tiếp sao cho không gian tìm kiếm có dạng
[F F F T T T], không cóTnào đứng trướcF. Mảng sorted chỉ là trường hợp đặc biệt khi predicate làA[i] >= target. Nhờ vậy binary search chạy được cả trên không gian không phải mảng, ví dụ tìm capacity nhỏ nhất để ship hết hàng trongDngày: capacity lớn hơn thì số ngày cần ít hơn, nên predicate đơn điệu. Đó là pattern search-on-answer, đưa brute force xuốngO(N log(hi - lo))mà không cần dữ liệu sắp xếp sẵn.Trả lời "phải sorted" rồi dừng lại — bỏ mất cả họ bài search-on-answer, và không nhận ra thứ cần kiểm tra là tính đơn điệu của predicate chứ không phải thứ tự của mảng.10
Big-O bỏ hằng số — vậy khi nào hằng số vẫn quyết định trong thực tế?
MidKhi
nnhỏ và khi memory layout chi phối, vì cả hai đều nằm trong phần hằng số mà ký hiệu Big-O cố tình bỏ đi. Ví dụ thứ nhất: Insertion Sort làO(n²)nhưng in-place và cache-friendly nên vớindưới 32 nó nhanh hơn Merge SortO(n log n)— chính vì thế TimSort dùng Insertion Sort cho các đoạn nhỏ. Ví dụ thứ hai: duyệt mảng số nguyên và duyệt danh sách liên kết cùng làO(n)nhưng chênh nhau khoảng 25 lần, vì mảng nạp một cache line 64 byte được 16 phần tử và hardware prefetcher đoán trước, còn đuổi theo con trỏ thì mỗi node là một cache miss phải đợi khoảng 100ns. Nguyên tắc: Big-O dự đoán tương đối ởnlớn, còn ởnnhỏ hoặc trong hot path thì phải đo thay vì suy luận từ ký hiệu.Kết luận "Big-O thấp hơn thì luôn nhanh hơn" — vớincỡ vài trăm, mảng quét tuyến tính thường thắng cây tìm kiếmO(log n)vì toàn bộ dữ liệu nằm gọn trong L1 cache.11
Phân biệt amortized, average case và worst case per operation.
MidWorst case per operation là chi phí của một lần gọi trong tình huống tệ nhất — với add của mảng động là
O(n)đúng lúc resize. Average case là trung bình trên input ngẫu nhiên theo một phân phối giả định, nên giả định sai thì con số vô nghĩa. Amortized là trung bình trên một dãy thao tác xấu nhất liên tiếp, không cần giả định gì về phân phối input — vì thế nó là đảm bảo mạnh hơn average. QuickSort minh hoạ rõ ranh giới: nó có averageO(n log n)nhưng không có amortizedO(n log n), vì luôn tồn tại dãy input đẩy nó vềO(n²). Ngược lại mảng động thực sự có amortizedO(1)chứng minh được, còn "amortizedO(1)" của hash table thực chất là average case dựa trên giả định hàm hash tốt.Dùng amortized và average như từ đồng nghĩa — nói "HashMap amortized O(1)" và "ArrayList amortized O(1)" như cùng một loại đảm bảo, trong khi cái đầu phụ thuộc chất lượng hàm hash còn cái sau thì không.12
Amortized O(1) sụp đổ trong tình huống nào?
MidKhi workload ép thao tác đắt xảy ra liên tiếp: cứ insert đến đúng ngưỡng resize rồi remove, rồi insert lại, thì mỗi vòng đều trigger một lần sao chép
O(n)mà không tích được đủ "credit" từ các thao tác rẻ. Trường hợp thứ hai không phải sụp đổ về tổng chi phí mà về độ trễ: amortized nói về trung bình, nhưng lần tệ nhất vẫn làO(n)và nó chắc chắn sẽ xảy ra. Với render loop, audio buffer hay hot path nhạy latency, một spike lúc resize là đủ để drop frame hoặc vỡ stream — lúc đó worst case per operation mới là con số phải cam kết. Cách xử lý là pre-allocate với capacity biết trước để loại bỏ resize hoàn toàn; amortized tốt cho throughput, không tốt cho jitter.Coi amortized O(1) là đảm bảo cho từng lần gọi — khi SLA đòi P99 latency thấp, con số phải cam kết là worst case per operation chứ không phải trung bình.13
Chaining và open addressing khác nhau thế nào? Vì sao Java chọn chaining còn Python, Go, Rust chọn open addressing?
MidChaining để mỗi bucket trỏ ra một danh sách bên ngoài, còn open addressing đặt toàn bộ entry thẳng trong một mảng duy nhất và khi va chạm thì probe sang ô kế tiếp theo quy tắc linear, quadratic hoặc double hashing. Open addressing thắng về cache locality (một mảng liên tục, thay vì đuổi con trỏ qua các node rải rác trên heap) và tốn ít memory hơn vì không có node object, nhưng buộc load factor phải dưới 1 và delete thì phức tạp vì cần tombstone. Java giữ chaining không phải vì nó vượt trội kỹ thuật, mà vì lý do lịch sử và kiến trúc: thiết kế từ 1998 khi cache chưa là ưu tiên,
LinkedHashMap extends HashMapdựa vào node có con trỏ before/after, treeify của Java 8 là biện pháp phòng thủ hợp với chaining, và backward compatibility vớiMap.Entry. Python, Go và Rust ra đời sau nên ưu tiên cache locality và tận dụng được SIMD — SwissTable mà Rust dùng so sánh 16 control byte trong một instruction.Kết luận "open addressing tốt hơn nên Java làm sai" — đây là ràng buộc lịch sử và contract kế thừa, không phải lỗi thiết kế; và open addressing nhạy cảm với hàm hash tệ hơn hẳn vì cluster lan sang cả key không liên quan.14
Load factor là gì, và vì sao ngưỡng của chaining khác ngưỡng của open addressing?
MidLoad factor là tỷ lệ số entry trên capacity; vượt ngưỡng thì bảng cấp phát mảng lớn hơn và rehash lại toàn bộ, chi phí
O(n)nhưng vì hiếm nên amortized vẫnO(1)mỗi thao tác. Chaining chịu được load factor cao hơn (0.75 là mặc định quen thuộc) vì va chạm ở một bucket chỉ kéo dài chain của chính bucket đó, không làm chậm các bucket khác. Open addressing thì bắt buộc dưới 1 — không còn ô trống nào là probe lặp vô hạn — và thực tế giữ dưới 0.7 vì probe length tăng phi tuyến. Theo công thức Knuth cho linear probing, số probe kỳ vọng của một lần tìm hụt là khoảng 2.5 ở load factor 0.5, khoảng 6 ở 0.7 và lên tới khoảng 50 ở 0.9 — vẫn là amortizedO(1)về lý thuyết nhưng hằng số lớn tới mức thực tế gầnO(n). Ngoài ra open addressing còn tích tụ tombstone sau nhiều lần delete, nên cần rehash định kỳ để dọn chứ không chỉ để mở rộng.Chỉ nhớ con số 0.75 mà không gắn được nó với chaining — áp thẳng 0.75 cho open addressing là chấp nhận probe chain dài tới mức lookup gần như tuyến tính.15
Lỗi kinh điển nào làm hỏng binary search, và invariant nào tránh được hết?
MidBa lỗi hay gặp nhất là tràn số ở
mid = (lo + hi) / 2, off-by-one do trộn hai convention khoảng, và vòng lặp vô hạn khi gánlo = midtrong khoảng đóng đúng lúclobằnghi - 1. Tràn số fix bằngmid = lo + (hi - lo) / 2; đây chính là bug từng sống trongArrays.binarySearchcủa JDK suốt 1998 đến 2006 cho tới khi Joshua Bloch phát hiện. Invariant gọn nhất là nửa mở[lo, hi): khởi tạolo = 0, hi = n, lặpwhile lo < hi, predicate đúng thìhi = mid, sai thìlo = mid + 1, và khilobằnghithì đó chính là điểm chuyển tiếp. Mỗi nhánh đều thu hẹp khoảng ít nhất 1 nên không có trường hợp đứng yên, và lower bound với upper bound chỉ khác nhau đúng một ký tự so sánh:>=cho lower,>cho upper. Với số thực thì bỏ hẳn điều kiện hội tụ, chạy số vòng lặp cố định khoảng 100 lần để tránh không dừng do float precision.Học thuộc code thay vì nắm invariant — mỗi biến thể (lower bound, upper bound, đếm số lần xuất hiện) lại viết lại từ đầu và lại dính off-by-one.
Trả lời trôi chảy bắt đầu từ hiểu cơ chế
Mỗi câu ở trên đều có bài học đứng sau. Học tuần tự cả khoá Thuật toán Căn bản — Big-O & Cấu trúc tuyến tính để không chỉ trả lời được, mà giải thích được vì sao.