Java Core / Độ phức tạp thuật toán & Big-O
Part 01 · Java Core · Nền tảng trước Collections & Sorting

Độ phức tạp thuật toán & Big-O từ căn bản

Mục tiêu của bài này là để khi gặp O(1), O(n), O(log n), O(n log n) hay O(n²), bạn biết nó đang mô tả điều gì, tự phân tích được code đơn giản và hiểu vì sao complexity ảnh hưởng lựa chọn Collections/Sorting trong Java.

Nắm nhanh trước khi học

Độ phức tạp thuật toán (algorithm complexity) mô tả lượng tài nguyên cần dùng thay đổi thế nào khi kích thước đầu vào tăng. Hai loại thường gặp nhất là time complexity (lượng công việc/thời gian theo mô hình phân tích) và space complexity (bộ nhớ phụ cần thêm).

Trả lời phỏng vấn 30 giây: Độ phức tạp thuật toán mô tả tốc độ tăng của chi phí tính toán khi input lớn dần. Tôi thường xác định kích thước input n, đếm số bước hoặc bộ nhớ theo n, rồi giữ thành phần tăng nhanh nhất và bỏ constant/lower-order terms. Ví dụ duyệt một array là O(n), binary search là O(log n), hai vòng lặp lồng nhau mỗi vòng chạy n lần thường là O(n²).

1. Complexity thực sự đo gì?

Đừng bắt đầu bằng câu “O(n) nghĩa là chậm”. Hãy bắt đầu bằng câu hỏi: khi input lớn lên thì chi phí tăng theo tốc độ nào? Ta cố tình bỏ qua máy tính cụ thể, JVM cụ thể và số mili-giây tại một lần chạy để nhìn vào growth rate — tốc độ tăng.

static int first(int[] values) {
    return values[0];
}

Dù array có 10 hay 10 triệu phần tử, method trên vẫn truy cập một vị trí. Chi phí không tăng theo số phần tử nên ta gọi là O(1). O(1) không có nghĩa đúng một CPU instruction; nó chỉ nghĩa chi phí bị chặn bởi một hằng số không phụ thuộc n.

static boolean contains(int[] values, int target) {
    for (int value : values) {
        if (value == target) return true;
    }
    return false;
}

Nếu n = values.length, worst case phải kiểm tra tất cả n phần tử. Lượng công việc tăng tuyến tính theo n, nên worst-case running time là O(n).

Luôn định nghĩa n. Với graph có thể cần hai biến VE; với matrix có thể là rows × cols. Viết mọi thứ thành “O(n)” mà không nói n là gì dễ làm mất thông tin quan trọng.

2. Big-O, Ω và Θ: đừng học sai ngay từ đầu

O(g(n)) · cận trên

Với n đủ lớn, chi phí không tăng nhanh hơn một hằng số nhân với g(n). Đây là asymptotic upper bound.

Ω(g(n)) · cận dưới

Với n đủ lớn, chi phí ít nhất tăng nhanh như một hằng số nhân với g(n).

Θ(g(n)) · cận chặt

Vừa có cận trên vừa có cận dưới cùng bậc tăng trưởng; thường là cách diễn đạt chính xác hơn khi ta biết đúng order of growth.

Trong phỏng vấn, mọi người thường dùng “Big-O” như cách nói chung về complexity. Điều đó phổ biến, nhưng về mặt toán học nên nhớ: O là upper bound. MIT và Cornell đều trình bày asymptotic notation theo hướng này. Vì vậy câu “Big-O là worst case” là cách nói tắt không chính xác.

Ví dụ: nếu một thuật toán luôn thực hiện khoảng 3n + 10 bước thì ta có thể nói nó là Θ(n), và dĩ nhiên cũng thuộc O(n). Nếu đang phân tích worst-case input, ta có thể nói “worst-case time is O(n)”. Hai khái niệm “worst case” và “O” nằm ở hai tầng khác nhau.

3. Vì sao bỏ constant và lower-order terms?

Asymptotic analysis quan tâm tốc độ tăng khi n lớn. Các hệ số cố định và thành phần bậc thấp dần ít quyết định hình dạng tăng trưởng.

3n + 10 → Θ(n) 7n² + 3n + 100 → Θ(n²) n³ + 20n² + 1 → Θ(n³)

Không phải vì constant “không bao giờ quan trọng”. Trong production, constant factor, CPU cache, allocation, JIT, database/network I/O có thể ảnh hưởng mạnh. Ta bỏ chúng trong asymptotic model để so tốc độ tăng trưởng, sau đó benchmark/profiling mới trả lời runtime thực tế.

4. Các mức complexity thường gặp

ComplexityCách hình dungVí dụ điển hình
O(1)Input lớn lên nhưng số bước bị chặn bởi một hằng số.Đọc array[i], ArrayList.get(i).
O(log n)Mỗi bước loại bỏ một tỉ lệ lớn của không gian tìm kiếm.Binary search; thao tác trên balanced search tree.
O(n)Input tăng gấp đôi → công việc xấp xỉ gấp đôi.Linear scan, contains trên array/list.
O(n log n)Thường xuất hiện khi chia bài toán nhiều tầng và mỗi tầng xử lý tuyến tính.Merge sort; nhiều comparison sort tốt.
O(n²)Mỗi phần tử tương tác với khoảng n phần tử khác.Hai nested loops đầy đủ; bubble/selection sort.
O(2^n)Mỗi phần tử có thể sinh thêm các nhánh lựa chọn.Brute-force subset recursion.
O(n!)Thử mọi hoán vị.Brute-force permutation search.
Với logarithm: trong Big-O, đổi cơ số log chỉ tạo ra constant factor, nên log₂ nlog₁₀ n cùng một asymptotic class.

5. Đọc complexity từ code Java

Một vòng lặp

for (int i = 0; i < n; i++) {
    work();
}

Nếu work() là constant time, vòng lặp chạy n lần → O(n).

Hai vòng nối tiếp: cộng, không nhân

for (int i = 0; i < n; i++) workA();
for (int i = 0; i < n; i++) workB();

n + n = 2nO(n), không phải O(n²).

Hai vòng lồng nhau: thường nhân

for (int i = 0; i < n; i++) {
    for (int j = 0; j < n; j++) {
        compare(i, j);
    }
}

n × n = n²O(n²).

Chia đôi mỗi bước: logarithmic

for (int x = n; x > 1; x /= 2) {
    work();
}

Sau k bước ta có xấp xỉ n / 2^k = 1, nên k ≈ log₂ nO(log n).

“Có 2 vòng for” chưa đủ để kết luận O(n²). Cần nhìn số lần mỗi vòng thực sự chạy. Ví dụ vòng trong chạy từ 0 đến i vẫn cho tổng 1 + 2 + ... + n = Θ(n²); nhưng hai vòng chạy nối tiếp chỉ là O(n). Với recursion, phải nhìn số nhánh và kích thước subproblem chứ không đếm keyword.

6. Time Complexity và Space Complexity

Time complexity mô tả lượng công việc tăng thế nào. Space complexity mô tả bộ nhớ cần dùng tăng thế nào. Trong interview nên nói rõ bạn đang tính auxiliary space (bộ nhớ phụ do thuật toán cấp thêm) hay tổng không gian bao gồm input.

static long sum(int[] values) {
    long total = 0;
    for (int value : values) total += value;
    return total;
}

Với n = values.length: time O(n), auxiliary space O(1).

static int[] copy(int[] values) {
    return java.util.Arrays.copyOf(values, values.length);
}

Copy n phần tử cần time O(n) và tạo array mới kích thước n → auxiliary space O(n).

7. Best case, Average case, Worst case

Đây là ba cách chọn tình huống/input để phân tích cùng một thuật toán.

CaseVí dụ linear searchÝ nghĩa
Best caseTarget ở phần tử đầu tiên → Θ(1).Tình huống thuận lợi nhất.
Average casePhụ thuộc giả định phân phối vị trí/target; thường tuyến tính dưới mô hình đơn giản.Phải nói rõ giả định xác suất.
Worst caseTarget ở cuối hoặc không tồn tại → Θ(n).Tình huống tốn kém nhất theo input size.

Một thuật toán có thể có nhiều case khác nhau, và mỗi case lại có thể được mô tả bằng O/Ω/Θ. Do đó không ghép cứng “O = worst, Ω = best”.

8. Amortized complexity: vì sao ArrayList.add() vẫn được gọi O(1)?

Amortized analysis nhìn chi phí trung bình trên một chuỗi operation, không phải xác suất average-case của input. Một operation riêng lẻ có thể đắt, nhưng nếu operation đắt xảy ra thưa và được phân bổ trên nhiều operation rẻ, chi phí amortized vẫn thấp.

ArrayList dùng backing array có capacity. Phần lớn add(e) chỉ ghi vào vị trí trống. Khi hết capacity, implementation phải tăng storage và copy phần tử — lần đó có thể tốn tuyến tính. Tuy nhiên Java API contract nêu rõ addamortized constant time: thêm n phần tử cần tổng thời gian O(n).

Câu phỏng vấn hay gặp: “ArrayList.add là O(1) hay O(n)?”
Trả lời: append có O(1) amortized; một lần resize cụ thể có thể cần O(n) để di chuyển/copy dữ liệu. Đừng nói mọi lần add đều là O(1) worst-case.

9. Nối kiến thức Big-O sang Java Collections

Bảng dưới là cầu nối để bạn hiểu các chapter Collections tiếp theo. Hãy đọc API contract thay vì thuộc một bảng complexity không có điều kiện.

Cấu trúc / operationComplexity đáng nhớĐiều kiện / lý do
ArrayList.get(i)Constant timeRandom access trên backing array.
ArrayList.add(e)O(1) amortizedThỉnh thoảng resize/copy; tổng n append là O(n).
ArrayList.contains(x)O(n)Cần scan cho tới khi gặp phần tử hoặc hết list.
HashMap.get/putExpected/typical constant time khi hash phân bố tốtJava docs mô tả constant-time performance cho basic operations nếu hash function phân tán phần tử đúng cách; không nên biến điều kiện này thành guarantee vô điều kiện.
TreeMap.get/put/removeO(log n)Red-black tree; API cam kết logarithmic time cho các operation này.
PriorityQueue.offer/pollO(log n)Heap maintenance; peek/element/size constant time theo API.
Chọn data structure không chỉ bằng Big-O. ArrayList thường locality tốt; linked structures có allocation/pointer overhead; concurrent collections có semantics khác; database/network latency có thể áp đảo CPU complexity. Complexity giúp loại lựa chọn tệ khi scale, nhưng benchmark và workload thật vẫn cần thiết.

10. Phỏng vấn: cách tự phân tích thay vì học thuộc

Khung trả lời 5 bước

  1. Xác định input size: n là gì? Có cần thêm m, V, E không?
  2. Chọn case: best, average hay worst? Có giả định xác suất nào không?
  3. Đếm growth: operation nào lặp theo input; nối tiếp thì cộng, lồng nhau thường nhân, chia nhỏ thì xét số tầng.
  4. Rút gọn: bỏ constant và lower-order terms.
  5. Nói cả space/trade-off: nếu bài toán yêu cầu.

Câu hỏi nhanh

1. Vì sao O(2n) được viết thành O(n)?

Vì Big-O bỏ constant factor khi xét asymptotic growth. Khi n tăng, 2nn cùng tăng tuyến tính.

2. Hai vòng for có luôn là O(n²) không?

Không. Nếu hai vòng nối tiếp, tổng là O(n+n)=O(n). Nếu lồng nhau và mỗi vòng chạy khoảng n lần thì mới thường là O(n²). Phải đếm số iteration thực tế.

3. O(1) có nghĩa là chạy trong một bước không?

Không. Nó nghĩa số bước không tăng theo n. Một operation O(1) có thể gồm nhiều instruction cố định và vẫn chậm hơn một operation khác trên input nhỏ.

4. Big-O có phải worst case không?

Không. Big-O là asymptotic upper bound. Worst-case là case analysis. Bạn có thể nói “worst-case time is O(n)” hoặc “best-case time is O(1)”.

5. Average-case và amortized có giống nhau không?

Không. Average-case thường dựa trên phân phối/xác suất của input. Amortized analysis không cần input ngẫu nhiên; nó phân bổ chi phí của vài operation đắt trên một chuỗi operation.

Bài tập tự tính

// A
for (int i = 0; i < n; i++) { work(); }
for (int j = 0; j < n; j++) { work(); }

// B
for (int i = 0; i < n; i++) {
    for (int j = 0; j < n; j++) { work(); }
}

// C
for (int i = 1; i < n; i *= 2) { work(); }
Xem đáp án

A: O(n). B: O(n²). C: O(log n).

Tài liệu tham khảo

  1. MIT 6.006 · Asymptotic Complexity / Big-O, Ω, Θ — phân biệt upper/lower/tight bounds.
  2. Cornell CS · Asymptotic complexity — giải thích growth rate, bỏ constant và lower-order terms.
  3. Java SE 21 · ArrayList — constant-time access và amortized constant-time append.
  4. Java SE 21 · HashMap — performance của get/put dưới giả định hash phân tán phù hợp.
  5. Java SE 21 · TreeMap — logarithmic containsKey, get, put, remove.
  6. Java SE 21 · PriorityQueue — complexity của enqueue/dequeue, retrieval và contains/remove.