Method gọi method khác — chuyện thường. Nhưng method gọi chính nó thì sao? Nghe tròn tròn kỳ lạ, nhưng đệ quy (recursion) là cách tự nhiên diễn giải nhiều bài toán: giai thừa, Fibonacci, duyệt cây, tìm kiếm trong thư mục, parse biểu thức. Code gần như đọc giống công thức toán.
Cái bẫy: mỗi lần gọi lại là một stack frame mới. Quên điều kiện dừng → StackOverflowError. Bài này giải thích cơ chế, viết đệ quy an toàn, khi nào nên đổi sang iterative, và vì sao Java chưa tối ưu tail recursion như Scala/Kotlin.
1. Analogy — hỏi người phía trước
Bạn đứng ở hàng dài chờ mua vé. Muốn biết mình đứng thứ mấy, bạn không đi đếm cả hàng — bạn hỏi người ngay trước mặt: "Anh đứng thứ mấy?". Người đó không biết → hỏi tiếp người trước. Truyền câu hỏi đến người đầu hàng (biết mình thứ 1), rồi đáp án lan ngược về: 1, 2, 3, 4... đến bạn.
| Đời thường | Recursion |
|---|---|
| Bạn hỏi người phía trước | Method gọi chính nó với input nhỏ hơn |
| Người đầu hàng biết mình thứ 1 | Base case — điều kiện dừng |
| Đáp án lan ngược về | return leo ngược qua call stack |
💡 💡 Cách nhớ
Đệ quy = "giải bài lớn bằng cách giải bài nhỏ hơn cùng dạng, và biết sẵn đáp án cho trường hợp nhỏ nhất". Hai thứ bắt buộc: base case (ngừng) + recursive case (giảm dần về base).
2. Ví dụ đầu tiên — giai thừa
Toán: n! = n × (n-1)!, với 0! = 1.
Dịch thẳng ra Java:
public static long factorial(int n) {
if (n == 0) return 1; // base case
return n * factorial(n - 1); // recursive case
}
factorial(5); // 120
Luồng chạy:
sequenceDiagram
participant main
participant f5 as factorial(5)
participant f4 as factorial(4)
participant f3 as factorial(3)
participant f2 as factorial(2)
participant f1 as factorial(1)
participant f0 as factorial(0)
main->>f5: call
f5->>f4: call 5 * factorial(4)
f4->>f3: call 4 * factorial(3)
f3->>f2: call 3 * factorial(2)
f2->>f1: call 2 * factorial(1)
f1->>f0: call 1 * factorial(0)
f0-->>f1: 1
f1-->>f2: 1
f2-->>f3: 2
f3-->>f4: 6
f4-->>f5: 24
f5-->>main: 120Mỗi call tạo 1 stack frame mới. Frame đợi kết quả của frame con, nhân xong mới return.
2.1 Hai phần bắt buộc của mọi đệ quy
- Base case — điều kiện dừng (ở đây:
n == 0). Không có → đệ quy vô hạn →StackOverflowError. - Recursive case — gọi lại với input tiệm cận base case (ở đây:
n - 1). Không tiệm cận (vdfactorial(n)gọifactorial(n)) → vô hạn.
// BUG — khong tiem can base case
public static int bad(int n) {
if (n == 0) return 0;
return 1 + bad(n); // n khong giam -> infinite recursion
}
3. Cơ chế bên dưới — stack lớn lên
Call stack của JVM mặc định khoảng 512 KB (tuỳ JDK, OS). Mỗi frame chiếm ~50–200 bytes cho method đơn giản. Giới hạn thực tế ~5.000–50.000 frame cho đệ quy chuẩn.
Chạy factorial(100000) → StackOverflowError rất nhanh. Nhưng vòng lặp for (int i = 0; i < 100000; i++) không vấn đề — vì chỉ 1 frame, không phụ thuộc số vòng.
// Iterative — 1 frame
public static long factorialIter(int n) {
long r = 1;
for (int i = 2; i <= n; i++) r *= i;
return r;
}
3.1 Đổi stack size với -Xss
JVM flag -Xss2m tăng stack lên 2MB cho mỗi thread. Không phải giải pháp — chỉ delay StackOverflowError thêm vài ngàn frame. Nếu thật sự cần đệ quy sâu, đổi sang iterative hoặc dùng explicit stack (chuyển đệ quy thành loop với Deque<State>).
4. Đệ quy rẽ nhánh — Fibonacci
public static int fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
Bài đầy đủ base case (n == 0 → 0, n == 1 → 1) và recursive case tách đôi.
Nhưng cái bẫy perf: mỗi fib(n) gọi fib(n-1) và fib(n-2) → fib(n-2) lại gọi fib(n-3) và fib(n-4)... Cây gọi phình exponentially. fib(40) đã chạy chậm, fib(50) mất phút.
flowchart TD
f5[fib-5] --> f4a[fib-4]
f5 --> f3a[fib-3]
f4a --> f3b[fib-3]
f4a --> f2a[fib-2]
f3a --> f2b[fib-2]
f3a --> f1a[fib-1]
f3b --> f2c[fib-2]
f3b --> f1b[fib-1]fib(3) tính 2 lần, fib(2) tính 3 lần. Độ phức tạp O(2^n).
Fix: memoization (cache) hoặc iterative:
// Iterative O(n)
public static int fib(int n) {
if (n <= 1) return n;
int a = 0, b = 1;
for (int i = 2; i <= n; i++) {
int c = a + b;
a = b;
b = c;
}
return b;
}
// Memoization O(n), giu duoc dang de quy
public static int fibMemo(int n, int[] cache) {
if (n <= 1) return n;
if (cache[n] != 0) return cache[n];
return cache[n] = fibMemo(n - 1, cache) + fibMemo(n - 2, cache);
}
5. Tail recursion — và vì sao Java không tối ưu
Tail recursion = recursive call là phép tính cuối cùng trước khi return (không có thao tác nào sau đó). Ngôn ngữ có optimizer TCO (tail call optimization) có thể biến tail call thành loop → không tốn stack frame.
// Tail recursion — call la buoc cuoi
public static long factorialTail(int n, long acc) {
if (n == 0) return acc;
return factorialTail(n - 1, acc * n); // return la recursive call
}
// Khong tail — sau call con phep nhan n *
public static long factorial(int n) {
if (n == 0) return 1;
return n * factorial(n - 1); // phai nhan sau khi call return
}
Scala, Kotlin, Clojure, Haskell, Scheme... tối ưu tail recursion. Java thì không — JVM giữ chiến lược "mỗi call 1 frame" kể cả với tail call. Lý do lịch sử: stack trace và security model dựa vào cả chain frame, TCO làm mất thông tin debugging.
Hệ quả: trong Java, viết tail form không tiết kiệm stack. Nếu cần perf, đổi iterative.
ℹ️ 📚 Project Loom và virtual threads — không liên quan
Virtual threads (JDK 21) cho phép hàng triệu thread rẻ, nhưng mỗi virtual thread vẫn dùng stack thường khi chạy code Java → đệ quy sâu vẫn stack overflow. Loom giải quyết bài toán khác (IO concurrency), không phải TCO.
6. Khi nào dùng đệ quy?
✅ Dùng khi:
- Bài toán tự nhiên định nghĩa đệ quy: duyệt cây (filesystem, DOM, AST), chia để trị (merge sort, quick sort, binary search), backtracking (tìm đường, sudoku).
- Độ sâu đệ quy nhỏ và đoán được — thường depth proportional log(n) cho cây cân bằng.
- Code đệ quy đọc rõ hơn hẳn iterative.
❌ Tránh khi:
- Độ sâu không bị chặn (vd depth = n cho list).
- Bài toán có công thức iterative đơn giản (tổng dãy, count, search tuyến tính).
- Trong hot path và đệ quy có overhead (vd tạo object trong mỗi frame).
Ví dụ — duyệt thư mục
public static void listFiles(File dir, int depth) {
if (!dir.isDirectory()) return;
for (File f : dir.listFiles()) {
System.out.println(" ".repeat(depth) + f.getName());
if (f.isDirectory()) {
listFiles(f, depth + 1); // de quy vao sub-dir
}
}
}
Cây thư mục thực tế hiếm sâu >20 level → đệ quy an toàn và đọc dễ. Viết iterative phải tự quản Deque<File> — dài gấp đôi, khó đọc.
7. Đệ quy gián tiếp (mutual recursion)
A gọi B, B gọi A — vẫn hợp lệ:
public static boolean isEven(int n) {
if (n == 0) return true;
return isOdd(n - 1);
}
public static boolean isOdd(int n) {
if (n == 0) return false;
return isEven(n - 1);
}
Cảnh báo: không có TCO → mỗi gọi chiếm frame. isEven(1000000) stack overflow. Đây là ví dụ minh hoạ hơn là pattern production.
8. Pitfall tổng hợp
❌ Nhầm 1: Quên base case.
int count(List<?> xs) { return 1 + count(xs); } // infinite
✅ Luôn có if (base condition) return ...; ở đầu method.
❌ Nhầm 2: Recursive case không tiệm cận base.
int f(int n) { if (n == 0) return 0; return f(n + 1); } // n tang, khong bao gio 0
✅ Đảm bảo mỗi call argument tiến gần base case (giảm, chia đôi, v.v.).
❌ Nhầm 3: Exponential blowup do trùng subproblem.
fib(n) = fib(n-1) + fib(n-2); // O(2^n)
✅ Memoize với int[] / Map, hoặc đổi iterative.
❌ Nhầm 4: Tưởng Java optimize tail call.
long factTail(int n, long acc) { ... } // van ton frame
✅ Với input lớn, phải iterative.
❌ Nhầm 5: Side effect trong đệ quy gây kết quả khó đoán.
void visit(Node n) {
sharedList.add(n.value); // thu tu add phu thuoc DFS order
for (Node c : n.children) visit(c);
}
✅ Nếu cần thứ tự cụ thể, return collection thay vì side-effect vào biến ngoài.
9. 📚 Deep Dive Oracle
ℹ️ 📚 Deep Dive Oracle (optional)
Spec / reference chính thức:
- JLS §8.4.7 — Method Body — không có rule riêng cho đệ quy; Java cho phép method gọi chính nó như bất kỳ method nào khác.
- JVMS §2.5.2 — Java Virtual Machine Stacks — stack per-thread, kích thước có thể cố định hoặc dynamic, nếu vượt ném
StackOverflowError. - JEP 425 — Virtual Threads — virtual thread có stack riêng, vẫn bị stack overflow như thread thường với đệ quy sâu.
- JVM flag
-Xss— cấu hình thread stack size.
Ghi chú: JVMS §2.5.2 cho phép implementation dynamic-expand stack, nhưng HotSpot default là fixed size. -Xss2m tăng size lên nhưng trả giá RAM (mỗi thread chiếm size đó). Cho đệ quy sâu, đổi iterative vẫn tốt hơn tăng Xss.
10. Tóm tắt
- Đệ quy = method gọi chính nó với input nhỏ hơn, có base case để dừng.
- Mỗi call = 1 stack frame mới. Stack có giới hạn (~512KB default) → đệ quy sâu →
StackOverflowError. - Fibonacci đệ quy naive là O(2^n) — memoize hoặc iterative để về O(n).
- Java không optimize tail call. Viết tail form không tiết kiệm stack.
- Dùng đệ quy khi depth nhỏ và bài toán tự nhiên đệ quy (cây, chia để trị).
- Dùng iterative khi depth lớn hoặc không dự đoán được.
- Mutual recursion hợp lệ nhưng cùng rule: depth nhỏ mới an toàn.
-Xsstăng stack size chỉ delay problem, không giải quyết gốc.
11. Tự kiểm tra
Q1Đoạn sau chạy kết quả gì với n = 4?public static int f(int n) {
if (n <= 0) return 0;
return n + f(n - 1);
}
▸
n = 4?public static int f(int n) {
if (n <= 0) return 0;
return n + f(n - 1);
}Kết quả: 10.
f(4) = 4 + f(3) = 4 + 3 + f(2) = 4+3+2 + f(1) = 4+3+2+1 + f(0) = 4+3+2+1+0 = 10.
Base case n <= 0 đảm bảo dừng. Recursive case n - 1 tiệm cận base. Đây là đệ quy "tổng từ 1 đến n" — tương đương vòng lặp for (int i = 1; i <= n; i++) sum += i;, hoặc công thức n*(n+1)/2.
Q2Vì sao fib(50) với định nghĩa naive chạy hàng phút, trong khi vòng lặp tương đương chạy tức thì?▸
fib(50) với định nghĩa naive chạy hàng phút, trong khi vòng lặp tương đương chạy tức thì?Đệ quy naive: fib(n) = fib(n-1) + fib(n-2) — cây gọi phình theo cấp số nhân. Với fib(50), tổng số lần gọi ~2^50 ≈ 10^15. Mỗi fib(k) nhỏ được tính lại rất nhiều lần (vd fib(2) tính hàng tỷ lần).
Iterative O(n): chỉ giữ 2 giá trị cuối và cộng 50 lần → 50 phép cộng. Nhanh hơn ~10^13 lần.
Nếu muốn giữ dạng đệ quy: memoize bằng mảng/Map — fibMemo(n, cache) trả về cache nếu đã tính → độ phức tạp giảm về O(n).
Q3Java có optimize tail recursion không? Vì sao?▸
Không. JVM giữ mỗi method call = 1 stack frame, kể cả khi call là phép tính cuối cùng trước return (tail position).
Lý do: JVM phụ thuộc vào đầy đủ chain frame cho stack trace (debugging, logging) và Java security manager (kiểm tra caller). TCO sẽ xóa frame và mất các thông tin đó.
Hệ quả: viết factorialTail(n, acc) dạng tail không tiết kiệm stack so với factorial(n) — cùng bị stack overflow ở depth lớn. Nếu cần đệ quy sâu trong Java, đổi iterative hoặc explicit stack với Deque.
Ngôn ngữ optimize TCO: Scala (@tailrec), Kotlin (tailrec), Haskell, Scheme, Clojure (recur).
Q4Đoạn sau có bug gì?public static int countDown(int n) {
System.out.println(n);
if (n == 0) return 0;
return countDown(n - 1);
}
countDown(-5);
▸
public static int countDown(int n) {
System.out.println(n);
if (n == 0) return 0;
return countDown(n - 1);
}
countDown(-5);Với n = -5, base case n == 0 không bao giờ match vì n giảm qua -5, -6, -7, ..., không tiệm cận 0. Infinite recursion → StackOverflowError.
Fix: dùng base case bao trùm mọi case không hợp lệ:
if (n <= 0) return 0;Bài học: base case phải chặn mọi argument mà recursive case không dẫn về. Dùng <=, >= thay vì == để an toàn với input ngoài ý.
Q5Khi nào chọn đệ quy, khi nào chọn iterative?▸
Chọn đệ quy khi:
- Cấu trúc dữ liệu tự nhiên đệ quy: cây (filesystem, DOM, AST, BST), đồ thị với DFS.
- Thuật toán chia-để-trị: merge sort, quick sort, binary search.
- Depth nhỏ / chặn được (proportional log n cho cây cân, hay hằng số).
- Code đệ quy đọc hiểu nhanh hơn iterative — giống công thức toán.
Chọn iterative khi:
- Depth không giới hạn hoặc proportional n cho list/mảng lớn.
- Bài toán có công thức loop đơn giản (tổng dãy, count, search tuyến tính).
- Hot path perf-critical — Java không TCO, đệ quy tốn frame + allocation.
- Đệ quy có subproblem trùng nhau (Fibonacci naive) mà bạn không memoize.
Bài tiếp theo: Scope, shadowing, và lifetime của biến