Thuật toán & Cấu trúc dữ liệu — Thực chiến/Độ phức tạp Big-O từ bản chất — Theta, Omega, amortized intro
~22 phútNền tảng — Cách đo và suy nghĩ về thuật toánMiễn phí lượt xem

Độ phức tạp Big-O từ bản chất — Theta, Omega, amortized intro

Big-O không chỉ là ký hiệu — đó là công cụ dự đoán hành vi thuật toán ở quy mô lớn. Bài này giải thích tại sao bỏ constants, phân biệt O / Theta / Omega, 7 complexity class với Java 21, và những pitfall khiến code O(n log n) chạy chậm hơn O(n²).

Bạn nhận một ticket: tính xem danh sách 100.000 user ID có phần tử trùng không. Bạn viết vòng lặp lồng nhau, test vài trăm phần tử — chạy tốt. Deploy lên production với 100.000 bản ghi thật, request đó mất 10 giây. Cùng lúc đó, đồng nghiệp commit một đoạn thay thế chỉ vài dòng — chạy 5 mili giây. Gấp 2.000 lần. Cùng kết quả đúng, cùng ngôn ngữ, cùng máy chủ.

Sự chênh lệch đó không đến từ constant, không đến từ JVM warm-up. Nó đến từ cấu trúc tăng trưởng khác nhau khi n lớn — thứ mà Big-O đo chính xác.

Bài này giải thích Big-O từ bản chất toán học và kỹ thuật: tại sao bỏ constants vẫn đúng, phân biệt O / Theta / Omega, 7 complexity class với code Java 21 thực tế, và những pitfall khiến dev hiểu nhầm "Big-O thấp hơn là luôn nhanh hơn".

1. Analogy — Tìm tên trong danh bạ điện thoại

Bạn cần tìm số điện thoại của Nguyễn Văn Trung trong cuốn danh bạ 10.000 trang, sắp xếp theo tên.

Cách 1 — Linear scan: lật từng trang từ đầu, đọc tên, so sánh. Xui thì lật hết 10.000 trang. Danh bạ dày gấp đôi → mất gấp đôi thời gian.

Cách 2 — Binary divide: mở giữa sách. Trung đứng sau trang giữa → bỏ nửa đầu. Mở giữa nửa còn lại. Tiếp tục. Sau 14 lần chia đôi, tìm thấy (vì 2^14 = 16.384). Danh bạ dày gấp đôi → chỉ cần thêm 1 bước.

Đời thườngThuật toán
Cuốn danh bạInput array kích thước n
Lật từng trangLinear scan — O(n)
Chia đôi mỗi bướcBinary search — O(log n)
Số trang danh bạInput size n
Số bước cần thực hiệnThời gian chạy T(n)
Tăng gấp đôi số trangTăng n lên 2n
💡 Cách nhớ

Big-O đo tốc độ tăng của số bước khi n tăng — không phải số bước tuyệt đối. Danh bạ 10.000 trang hay 20.000 trang, binary search vẫn chỉ thêm 1 bước.

2. Hai cách giải hasDuplicate — gấp 2.000 lần

Đây là code gây ra ticket 10 giây nói ở đầu bài:

// O(n^2) — naive nested loop
// For each pair (i, j), compare. Total: n*(n-1)/2 comparisons.
public boolean hasDuplicateNaive(int[] arr) {
    for (int i = 0; i < arr.length; i++) {
        for (int j = i + 1; j < arr.length; j++) {
            if (arr[i] == arr[j]) return true;
        }
    }
    return false;
}

Với n = 100.000, vòng lặp trong chạy tổng cộng 100.000 * 99.999 / 2 ≈ 5 tỷ lần so sánh. Ở tốc độ ~500M phép/giây → 10 giây.

Và đây là đoạn thay thế 5ms:

// O(n) — HashSet lookup
// One pass: add each element, return true if add() returns false (already present).
public boolean hasDuplicateFast(int[] arr) {
    Set<Integer> seen = new HashSet<>();
    for (int x : arr) {
        if (!seen.add(x)) return true; // add() returns false if already present
    }
    return false;
}

HashSet.add() chạy O(1) amortized (sẽ giải thích ở mục 7). Vòng lặp chạy tối đa n lần → tổng O(n). Với n = 100.000: 100.000 phép thay vì 5 tỷ — 50.000 lần ít hơn.

Đây chính là Big-O trong thực tế: cùng đúng, khác nhau về cấu trúc tăng trưởng.

3. Định nghĩa — Big-O, Theta, Omega

3.1 Big-O đo gì

Big-O không đo thời gian chạy tuyệt đối. Nó đo upper bound của tốc độ tăng khi n tiến đến vô cực.

Nói chính thức: T(n) = O(f(n)) nếu tồn tại hằng số c > 0n₀ sao cho với mọi n ≥ n₀, T(n) ≤ c * f(n).

Nghĩa thực tế: dù constants cụ thể là bao nhiêu, khi n đủ lớn, T(n) bị chặn trên bởi một bội số của f(n).

3.2 Tại sao bỏ constants

Xét hàm thực tế: T(n) = 3n² + 100n + 5.

Khi n tăng lên:

  • n = 10: 3*100 + 100*10 + 5 = 1.305. Hạng 100n chiếm 76%.
  • n = 1.000: 3*1.000.000 + 100.000 + 5 ≈ 3.100.005. Hạng 3n² chiếm 97%.
  • n = 1.000.000: 3n² chiếm 99,997%.

Ở n lớn, 3n² nuốt toàn bộ các hạng còn lại. Constant 3 cũng mờ dần — nếu phần cứng nhanh gấp đôi, 3 thành 1.5, nhưng bậc vẫn là n². Phần cứng tốt hơn cải thiện constant, không cải thiện bậc.

Kết quả: T(n) = 3n² + 100n + 5 = O(n²). Ta bỏ hạng bậc thấp và constant của hạng cao nhất.

3.3 Ba ký hiệu — O, Theta, Omega

Ký hiệuÝ nghĩaMô tả
O(f(n))Upper boundT(n) không tăng nhanh hơn f(n) ở mức chặn trên
Ω(f(n))Lower boundT(n) không tăng chậm hơn f(n) ở mức chặn dưới
Θ(f(n))Tight boundT(n) tăng đúng bằng f(n) — cả trên lẫn dưới

Ví dụ với Merge Sort:

  • Best caseworst case đều là n log nΘ(n log n).
  • Ta viết O(n log n) — đúng nhưng không tight (nói "không tệ hơn", không nói "chính xác").

Ví dụ với Linear Search:

  • Best case: tìm thấy ngay phần tử đầu → Ω(1).
  • Worst case: phần tử cuối cùng → O(n).
  • Không có Θ đơn giản vì best/worst case khác nhau.

Trong thực tế, dev dùng O gần như toàn bộ thời gian vì:

  1. Ta quan tâm worst case nhiều hơn — đó là SLA production.
  2. Nói "không tệ hơn O(n log n)" an toàn hơn cam kết tight bound.
  3. API doc, Javadoc đều dùng O — nhất quán với ecosystem.

4. Bảy complexity class — code và tăng trưởng

4.1 O(1) — Constant

Số bước không phụ thuộc n. HashMap lookup là ví dụ kinh điển:

// O(1) amortized — hash computation + array index, independent of map size
Map<String, Integer> scores = new HashMap<>();
scores.put("alice", 95);
int score = scores.get("alice"); // does not scan all entries

Lưu ý: "amortized O(1)" — sẽ giải thích nghĩa tại mục 7.

4.2 O(log n) — Logarithmic

Mỗi bước loại bỏ nửa input còn lại. Binary search:

// O(log n) — halves the search space each iteration
public int binarySearch(int[] sorted, int target) {
    int lo = 0, hi = sorted.length - 1;
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2; // avoid integer overflow
        if (sorted[mid] == target) return mid;
        else if (sorted[mid] < target) lo = mid + 1;
        else hi = mid - 1;
    }
    return -1;
}

n = 1.000.000 → tối đa 20 bước (vì log₂(1.000.000) ≈ 20).

4.3 O(n) — Linear

Đọc qua từng phần tử một lần:

// O(n) — visits every element exactly once
public int sumArray(int[] arr) {
    int total = 0;
    for (int x : arr) total += x;
    return total;
}

4.4 O(n log n) — Linearithmic

Merge Sort: chia đôi O(log n) lần, mỗi lần merge mất O(n):

// O(n log n) — divide and conquer: log n levels, each level merges O(n) total
public void mergeSort(int[] arr, int lo, int hi) {
    if (lo >= hi) return;
    int mid = (lo + hi) / 2;
    mergeSort(arr, lo, mid);
    mergeSort(arr, mid + 1, hi);
    merge(arr, lo, mid, hi); // O(n) merge of two sorted halves
}

Collections.sort()Arrays.sort() cho object đều là O(n log n) (TimSort).

4.5 O(n²) — Quadratic

Vòng lặp lồng, mỗi vòng chạy n lần:

// O(n^2) — for each of n elements, scan remaining n elements
public int[] bubbleSort(int[] arr) {
    int n = arr.length;
    for (int i = 0; i < n - 1; i++) {
        for (int j = 0; j < n - i - 1; j++) {
            if (arr[j] > arr[j + 1]) {
                int tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp;
            }
        }
    }
    return arr;
}

4.6 O(2ⁿ) — Exponential

Mỗi lần gọi đệ quy, tạo ra 2 nhánh con — cây nhị phân:

// O(2^n) — each call spawns two recursive calls, exponential call tree
public long fibNaive(int n) {
    if (n <= 1) return n;
    return fibNaive(n - 1) + fibNaive(n - 2); // two branches per level
}

n = 50 → khoảng 2^50 ≈ 10^15 lần gọi. Không thực tế dùng với n vừa phải.

4.7 O(n!) — Factorial

Sinh mọi hoán vị:

// O(n!) — generates all n! permutations of the input array
public void permute(int[] arr, int start, List<int[]> result) {
    if (start == arr.length) {
        result.add(arr.clone());
        return;
    }
    for (int i = start; i < arr.length; i++) {
        int tmp = arr[start]; arr[start] = arr[i]; arr[i] = tmp;
        permute(arr, start + 1, result);
        tmp = arr[start]; arr[start] = arr[i]; arr[i] = tmp; // swap back
    }
}

Bảng tăng trưởng

Complexityn=1n=10n=100n=1.000n=1.000.000
O(1)11111
O(log n)0371020
O(n)1101001.0001.000.000
O(n log n)0336649.96619.931.568
O(n²)110010.0001.000.00010^12
O(2ⁿ)21.02410^3010^301
O(n!)13.628.80010^157

Dấu nghĩa là con số vượt xa khả năng tính toán thực tế.

5. Cơ chế bên dưới — tại sao hạng bậc cao thống trị

Hãy nhìn ba hàm cùng lúc khi n tăng:

n=1:       f1 = 3n^2 + 100n + 5 =    108
           f2 = 100n              =    100
           f3 = 3n^2              =      3

n=100:     f1 = 3*10000 + 10000 + 5 = 40005
           f2 = 100*100            =  10000
           f3 = 3*100^2            =  30000

n=1000:    f1 = 3*1M + 100K + 5  = 3100005
           f2 = 100*1000         =  100000    <-- 3% of f1
           f3 = 3*1000^2         = 3000000    <-- 97% of f1

Khi n đạt 1.000, hạng 100n chỉ còn 3% tổng — và tỷ lệ này tiếp tục thu nhỏ khi n tăng. Hạng "nuốt" hạng n theo tỷ lệ n/1 → tỷ lệ đó tăng vô hạn.

Sơ đồ phân kỳ của ba hàm:

xychart-beta
    title "Tang truong theo n"
    x-axis [1, 10, 50, 100, 200, 500]
    y-axis "So buoc" 0 --> 800000
    line [1, 100, 2500, 10000, 40000, 250000]
    line [1, 10, 50, 100, 200, 500]
    line [0, 33, 282, 664, 1530, 4483]
Chú thích biểu đồ

Đường cao nhất: O(n²). Đường giữa: O(n). Đường thấp nhất: O(n log n).

💡 Quy tắc bỏ hạng

Với bất kỳ đa thức a*n^k + b*n^(k-1) + ... + c: Big-O chỉ giữ hạng bậc cao nhất, bỏ constant. Lý do: tỷ lệ hạng bậc thấp so với hạng bậc cao tiến về 0 khi n tiến đến vô cực.

6. Pitfall tổng hợp

Pitfall 1: O(n) + O(n) = O(2n) — sai.

// Two separate O(n) passes over same array
// Total: O(n) + O(n) = O(n), NOT O(2n)
public void processTwice(int[] arr) {
    for (int x : arr) System.out.println(x);  // O(n)
    for (int x : arr) doSomething(x);         // O(n)
}

Hai vòng lặp tuần tự: O(n) + O(n) = O(2n). Nhưng O(2n) = O(n) vì constant 2 bị bỏ. Tổng là O(n).

Quy tắc: vòng lặp tuần tự thì cộng complexity, nhưng sau đó bỏ constant và hạng thấp.

Pitfall 2: Vòng lặp lồng không phải lúc nào cũng O(n²).

// Outer loop: O(n). Inner loop: O(log n) — binary search.
// Total: O(n log n), NOT O(n^2).
public int[] findTargets(int[] data, int[] queries) {
    int[] result = new int[queries.length];
    Arrays.sort(data); // O(n log n)
    for (int i = 0; i < queries.length; i++) {           // O(n) outer
        result[i] = binarySearch(data, queries[i]);      // O(log n) inner
    }
    return result; // total: O(n log n)
}

Quy tắc: vòng lặp lồng thì nhân complexity — O(n) * O(log n) = O(n log n). Phân tích từng vòng riêng.

Pitfall 3: "Big-O thấp hơn không luôn nhanh hơn ở n nhỏ — constant matter."

Insertion Sort có O(n²) worst case, Merge Sort có O(n log n). Tưởng Merge Sort luôn thắng. Thực tế:

  • Insertion Sort có constant rất thấp: ít phép tính, cache-friendly (in-place), không cần allocate bộ nhớ phụ.
  • Merge Sort có constant cao hơn: phân bổ mảng phụ, overhead recursive call, copy data.

Với n dưới 32, Insertion Sort nhanh hơn Merge Sort trong thực đo. Đây là lý do TimSort (thuật toán Java, Python dùng) luôn dùng Insertion Sort cho run nhỏ hơn 32 phần tử, Merge Sort cho phần còn lại — kết hợp tốt nhất của hai thế giới.

Quy tắc: Big-O dự đoán tương đối ở n lớn. Với n nhỏ, đo thực tế và xem xét constant.

7. Amortized O(1) và ứng dụng thực tế

Collections.sort() contract rõ ràng trong Javadoc. Javadoc ghi O(n log n) — đó là contract, không chỉ là note. Developer có thể tin rằng sort một million phần tử sẽ mất khoảng 20 * 1.000.000 = 20M đơn vị công việc, không phải 10^12. Complexity là ngôn ngữ để đặt SLA.

HashMap.get()O(1) amortized — không phải O(1) tuyệt đối. HashMap lưu entry theo hashCode() % capacity. Nếu hash function tốt, mỗi bucket có vài entry — lookup gần như O(1). Nhưng worst case, tất cả n entry hash vào cùng bucket → duyệt linked list dài n → O(n). Java 8 treeify bucket dài trên 8 node thành cây đỏ-đen → worst case O(log n) (Module 3 sẽ phân tích chi tiết). "Amortized O(1)" nghĩa là trung bình qua nhiều lần gọi là O(1), dựa trên giả định hash function phân phối đều.

Database query plan — EXPLAIN cho biết O(n) vs O(log n). Khi thêm WHERE user_id = 123 vào query, có hai kịch bản:

  • Không có index: PostgreSQL dùng Seq Scan — quét toàn bộ bảng, O(n). Bảng 10M row → 10M bước.
  • Có index B-tree: Index ScanO(log n). 10M row → khoảng 24 bước.

Chạy EXPLAIN ANALYZE SELECT ... để thấy plan. Đây là debug Big-O trong production thực tế.

Stream.sorted()O(n log n) — cẩn thận trong hot loop.

// Bad: calling sorted() on an already-sorted list inside a tight loop
// Each call allocates, sorts — O(n log n) per invocation
for (Request req : requests) {
    List<Item> ordered = req.getItems().stream()
        .sorted(Comparator.comparing(Item::getPriority))
        .toList(); // O(n log n) allocation + sort every iteration
    process(ordered);
}

Nếu getItems() đã sorted, gọi sorted()O(n log n) lãng phí. Kiểm tra trước hoặc giữ items đã sorted từ lúc insert.

8. 📚 Deep Dive tài liệu gốc

📚 Deep Dive — nguồn gốc và tài liệu tham khảo

Spec / reference chính thức:

Ghi chú: CLRS Chapter 3 là nền toán học để đọc phần còn lại của sách. Nếu bạn muốn chứng minh tại sao 3n² + 100n + 5 = O(n²) theo định nghĩa hình thức ở mục 3.1 với c và n₀ cụ thể, Chapter 3 là nơi để bắt đầu.

9. Tóm tắt

  • Big-O đo tốc độ tăng của thời gian chạy theo n — upper bound của growth rate, không phải thời gian tuyệt đối.
  • Bỏ constants và hạng bậc thấp vì ở n đủ lớn, hạng bậc cao thống trị toàn bộ — tỷ lệ hạng thấp/hạng cao tiến về 0.
  • O (upper bound), Theta (tight bound), Omega (lower bound). Thực tế dùng O vì quan tâm worst case.
  • 7 class chuẩn: O(1), O(log n), O(n), O(n log n), O(n²), O(2ⁿ), O(n!) — khác nhau cơ bản về khả năng scale.
  • Vòng lặp tuần tự → cộng complexity rồi bỏ constant. Vòng lặp lồng → nhân complexity.
  • Big-O thấp hơn không luôn nhanh hơn ở n nhỏ — TimSort dùng Insertion Sort cho n < 32 vì constant thấp hơn.
  • HashMap.get()O(1) amortized — giả định hash function tốt. Worst case O(log n) sau Java 8 treeify.
  • Production: Complexity là contract trong Javadoc, là ngôn ngữ chung để dự đoán scale. Database EXPLAIN cho thấy O(n) vs O(log n) trực tiếp.

10. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao Big-O bỏ constants dù trong thực tế constants quan trọng (ví dụ: constant 1000 vs constant 1 ở O(n))?

Big-O mô tả tốc độ tăng asymptotic — hành vi khi n tiến đến vô cực. Với hàm T(n) = 1000nU(n) = n, tỷ lệ T(n)/U(n) = 1000 là hằng số bất kể n. Cả hai tăng theo cùng "hình dạng" — tuyến tính.

Ngược lại, n có tỷ lệ n²/n = n tăng vô hạn — khác nhau về bậc, không thể so sánh bằng hằng số.

Điều này không có nghĩa constants vô nghĩa trong thực tế. Khi hai thuật toán cùng Big-O (ví dụ cả hai O(n log n)), constants quyết định cái nào nhanh hơn thực tế — QuickSort nhanh hơn MergeSort trong thực đo dù cùng O(n log n) trung bình vì constant nhỏ hơn (cache-friendly, in-place). Big-O cho biết "sẽ scale thế nào", profiler cho biết "nhanh bao nhiêu ở đây và bây giờ".

Q2
Khi nào O(n²) thực tế nhanh hơn O(n log n) — cho ví dụ cụ thể?

Khi n đủ nhỏ, constant của thuật toán O(n²) có thể nhỏ hơn constant của O(n log n) khiến tổng số bước ít hơn.

Ví dụ cụ thể: Insertion Sort (O(n²) worst case) vs Merge Sort (O(n log n)).

  • Insertion Sort: in-place, không allocate bộ nhớ phụ, cache-friendly (truy cập bộ nhớ liên tiếp), rất ít overhead mỗi bước.
  • Merge Sort: cần allocate mảng phụ O(n), overhead recursive call, copy data nhiều lần.

Thực đo cho thấy với n < 32, Insertion Sort nhanh hơn. TimSort (thuật toán Java và Python dùng) khai thác insight này: chia mảng thành "run" kích thước 32, sort mỗi run bằng Insertion Sort, sau đó merge run bằng Merge Sort.

Bài học: với input kích thước nhỏ và cố định, đo thực tế trước khi kết luận từ Big-O.

Q3
HashMap.get() được mô tả là O(1) — assumption ngầm nào cần đúng để đảm bảo điều này?

HashMap.get(key) tính hashCode() của key, chia modulo capacity để xác định bucket, rồi duyệt linked list (hoặc cây) trong bucket đó.

Assumption để O(1) đúng:

  • Hash function phân phối đều: mỗi bucket có số entry không đổi (constant), không phụ thuộc n. Nếu tất cả key hash vào cùng bucket → duyệt n entry → O(n).
  • Load factor hợp lý: HashMap tự resize khi vượt load factor (mặc định 0.75). Resize là O(n) nhưng xảy ra hiếm → amortized vẫn O(1) mỗi operation.

Worst case sau Java 8: bucket vượt 8 node → treeify thành cây đỏ-đen → lookup O(log n) thay vì O(n). Vẫn không phải O(1), nhưng tránh được tấn công hash collision cố ý (hash flooding attack). Amortized O(1) là cam kết trung bình, không phải đảm bảo mọi lần gọi.

Q4
Đoạn code sau có Big-O bao nhiêu? Giải thích từng vòng.
for (int i = 0; i < n; i++) {          // outer
  for (int j = 0; j < n; j++) {       // inner-1
      process(arr[i][j]);
  }
}
for (int k = 0; k < n; k++) {           // separate
  log(arr[0][k]);
}

Phân tích từng phần:

  • Vòng lặp lồng (outer + inner-1): outer chạy n lần, inner chạy n lần cho mỗi outer → n * n = O(n²).
  • Vòng lặp riêng (separate): chạy n lần → O(n).

Tổng: O(n²) + O(n). Bỏ hạng bậc thấp: O(n²).

Lý do bỏ O(n): ở n lớn, chiếm áp đảo. Ví dụ n = 1.000: phần lồng xử lý 1.000.000 bước, phần riêng chỉ 1.000 bước — tức 0.1% tổng. Tỷ lệ này tiếp tục thu nhỏ khi n tăng.

Q5
Sự khác biệt giữa O, Theta, Omega — vì sao dev thường chỉ dùng O trong tài liệu và Javadoc?

O(f(n)) (upper bound): T(n) không tăng nhanh hơn f(n) ở worst case. Câu hỏi: "tệ nhất bao nhiêu?"

Ω(f(n)) (lower bound): T(n) không tăng chậm hơn f(n). Câu hỏi: "tốt nhất bao nhiêu?" — hữu ích cho phân tích lý thuyết (ví dụ: bất kỳ sorting algorithm nào cũng cần ít nhất Ω(n log n) so sánh).

Θ(f(n)) (tight bound): cả upper lẫn lower đều là f(n). Câu hỏi: "chính xác bao nhiêu?"

Dev dùng O vì:

  • Engineering quan tâm worst case: bạn muốn biết request chậm nhất mất bao lâu — đó là O.
  • O an toàn hơn để cam kết: nói "không tệ hơn O(n log n)" đúng ngay cả khi best case là O(n). Nói Theta buộc phải chứng minh cả lower bound.
  • Javadoc ecosystem nhất quán: `ArrayList.get()` là O(1), `LinkedList.get()` là O(n) — ngôn ngữ chung.

Trong nghiên cứu và phân tích lý thuyết thuật toán, Theta và Omega thường xuyên xuất hiện vì cần bound chặt hơn để so sánh optimality.

Q6
Production tip: khi nào đáng trade space cho time (dùng thêm bộ nhớ để tăng tốc)?

Trade space for time hợp lý khi:

  • Computation tốn kém và kết quả có thể cache: ví dụ tính Fibonacci bằng memoization — lưu kết quả đã tính, giảm từ O(2ⁿ) xuống O(n) với O(n) space. Dynamic programming là tên chính thức.
  • Lookup thay thế scan: xây dựng HashMap từ danh sách một lần (O(n) space + time), các query sau chỉ mất O(1) thay vì O(n) linear scan mỗi lần.
  • Precomputed index: database index là ví dụ kinh điển. B-tree index tốn thêm đĩa và thời gian write, đổi lại query O(log n) thay vì O(n).

Không nên trade khi:

  • Memory là bottleneck (thiết bị nhúng, container 128MB).
  • Data thay đổi liên tục — cache stale, chi phí maintain index vượt lợi ích.
  • n nhỏ và constant của giải pháp đơn giản đã đủ nhanh.

Quy tắc thực tế: đo trước khi optimize. Nếu profiler cho thấy hot path là linear scan trên collection cố định, xây HashMap một lần là trade space for time đơn giản và hiệu quả nhất.

Bài tiếp theo: Recursion và call stack

Bài này có giúp bạn hiểu bản chất không?