انتقل إلى المحتوى
أمين أبوزيد
منشورBEG-044-L03

التفكير بالاستدعاء الذاتي

  • الدرس 3 من 5
  • 11 دقيقة قراءة
  • مبتدئ
في هذا الدرس 11 موضوعات

هذا الدرس متاح حاليًا باللغة الإنجليزية فقط، ولم تُنشر نسخته العربية بعد.

Lesson Overview

You have already met recursion in the methods module: a method that calls itself, and the call stack that remembers each waiting call. This lesson is about the thinking part. How do you look at a new problem and see the smaller copy of it hidden inside?

The picture to hold is a set of Russian dolls. To find out how many dolls there are, you open the biggest one. Inside is a smaller set of dolls. You do not need to understand the whole set at once: you only need to know what to do with one doll and what to do when there is no doll left. Everything else is the same question again, smaller.

This lesson gives you a short recipe for writing recursive methods, then applies it to digit sums, powers, and palindromes. It also shows how a recursive method can be much faster than a simple one, and what goes wrong when the recipe is skipped. The next lesson uses these ideas to generate every subset and every ordering.

This lesson builds on methods, the call stack, and recursion basics (BEG-014), and on Big O (BEG-039).

Learning Objectives

By the end of this lesson you will be able to:

  • State the three parts of a recursive method: base case, smaller step, and combine.
  • Write a recursive method from a plain-language description of the problem.
  • Trace a recursive call by hand, both going down and coming back up.
  • Explain what causes StackOverflowError and how to prevent it.
  • Count the calls a recursive method makes and compare two recursive designs.
  • Decide when a loop is a better choice than recursion.

Written Explanation

The recipe

To write a recursive method, answer three questions in order:

  1. Base case: what is the smallest version of the problem, whose answer I know immediately?
  2. Smaller step: how do I turn a bigger problem into a slightly smaller one of the same kind?
  3. Combine: if I trust that the smaller call returns the right answer, what do I do with it to get the answer for the bigger problem?

Take the sum of the digits of a non-negative number, such as 4821 giving 4 + 8 + 2 + 1 = 15.

  1. Base case: a number with a single digit (n < 10) has a digit sum equal to itself.
  2. Smaller step: n / 10 removes the last digit (integer division); n % 10 is that last digit.
  3. Combine: digit sum of n = last digit + digit sum of the rest.
java
static int sumDigits(int n) {
    if (n < 10) return n;                       // base case
    return n % 10 + sumDigits(n / 10);          // last digit + the rest
}

The important mental move is step 3: trust the smaller call. You do not trace what happens inside sumDigits(482) while writing the method. You assume it returns 14 and ask "given that, what is the answer for 4821?" The answer: 1 + 14. When you debug, you can trace; when you design, you trust.

Tracing: down, then up

Tracing sumDigits(4821) shows two phases on the call stack:

text
sumDigits(4821)  waits for  sumDigits(482)
  sumDigits(482)  waits for  sumDigits(48)
    sumDigits(48)  waits for  sumDigits(4)
      sumDigits(4)  returns 4          <- base case
    sumDigits(48)  returns 8 + 4 = 12
  sumDigits(482)  returns 2 + 12 = 14
sumDigits(4821)  returns 1 + 14 = 15

Going down, each call waits. At the base case the first answer appears. Going up, each waiting call finishes its own combine step. The result 15 is what my run printed.

Factorial and the missing base case

java
static int factorial(int n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);
}

factorial(4) returned 24. Without a base case the method never stops:

java
static int broken(int n) { return n + broken(n - 1); }

Each call adds a new frame to the call stack and none returns. The JVM eventually throws StackOverflowError. In my run, try { broken(5); } catch (StackOverflowError e) { ... } caught it, and printed the name StackOverflowError. The Java API documentation describes this error as thrown when an application recurses too deeply. The exact depth at which it happens depends on the machine and settings, so do not rely on a particular number.

A base case that exists but is never reached has the same effect. For sumTo(n) with the base case if (n == 0), calling sumTo(-1) goes down forever, because n never equals 0. Writing n <= 0 is safer. The rule: every recursive call must move closer to the base case.

Order matters: before or after the call

Where you put the work changes the output. This code prints on the way down:

java
static void countdown(int n) {
    if (n == 0) { System.out.println("Go!"); return; }
    System.out.println(n);
    countdown(n - 1);
}

countdown(3) printed 3, 2, 1, Go!. If the print comes after the recursive call, it happens on the way up and the order reverses. A method with the print placed after countup(n - 1) printed 1, 2, 3 for countup(3). This idea (work before the call versus after) returns in the next lesson, where "undo" happens on the way back up.

Counting calls: simple versus halving

Consider power(b, e), "b to the power e":

java
static int power(int b, int e) {
    if (e == 0) return 1;
    return b * power(b, e - 1);   // e calls deep
}

For power(2, 10) the method makes 11 calls (e = 10 down to 0), so it takes about e steps: O(e).

A different smaller step: if e is even, b^e is (b^(e/2)) squared; if odd, one more factor of b.

java
static long fastPower(long b, int e) {
    if (e == 0) return 1;
    long half = fastPower(b, e / 2);
    return e % 2 == 0 ? half * half : half * half * b;
}

fastPower(2, 10) made only 5 calls (e = 10, 5, 2, 1, 0) and returned 1024, the same answer as power. Halving the problem each time takes about log₂(e) calls: O(log e). Notice we compute half once and reuse it; calling fastPower twice would lose the saving. A smaller step that cuts the problem by a fraction is far more powerful than one that cuts one item.

Recursion on text

Strings work well too: a palindrome reads the same in both directions.

  1. Base case: a string of length 0 or 1 is a palindrome.
  2. Smaller step: remove the first and last character (substring(1, length - 1)).
  3. Combine: it is a palindrome only if the first and last characters match and the middle is a palindrome.
java
static boolean isPalindrome(String s) {
    if (s.length() < 2) return true;
    if (s.charAt(0) != s.charAt(s.length() - 1)) return false;
    return isPalindrome(s.substring(1, s.length() - 1));
}

Results: "level" true, "abca" false, "" true. Note that substring(begin, end) includes begin and excludes end, according to the Java API documentation.

Recursion or a loop?

Everything above could also be written with a loop. Prefer recursion when the problem is naturally nested (folders in folders, trees) or when branching creates many paths (the next lesson). Prefer a loop when the work is a simple repetition over a long range, because each recursive call uses stack space and a very deep recursion can overflow.

Examples

Example 1: digit sum of a whole number (simple)

Calling sumDigits(4821) printed 15. The trace is the diagram above. A digit sum is a small piece of real work: some check-digit schemes for identification numbers add up digits (the specific schemes vary and are not covered here).

A boundary check: sumDigits(7) returns 7 at once, and sumDigits(10) returns 0 + sumDigits(1) = 1.

Example 2: reversing a String by recursion (practical)

Think in dolls: the reverse of "abc" is the reverse of "bc" followed by "a".

java
static String reverse(String s) {
    if (s.length() <= 1) return s;
    return reverse(s.substring(1)) + s.charAt(0);
}

reverse("abc") printed cba and reverse("") returned an empty string. This is correct but each call creates a new string, so it is slower than the loop version for long text. It is good for learning the pattern, not for production.

Example 3: fast power and counting calls (realistic)

Cost and performance questions are part of the job. I added a counter to both versions:

CallResultCalls made
power(2, 10)102411
fastPower(2, 10)10245
fastPower(2, 11)20485

Doubling e adds only about one extra call to fastPower. The values grow too: with int, factorial(13) printed 1932053504, which is not the true value (6,227,020,800), because it does not fit in an int and overflows. Recursion fixes the structure, not the number range; use long or a bigger type when results can grow.

Backend Insight

Backend developers meet recursion through nested data: a JSON document that contains objects inside objects, a folder tree on a server, a menu with submenus, or comments with replies to replies. The shape "this thing contains smaller things of the same kind" is the signal. The trees you will study in the next modules are processed with exactly the recipe in this lesson.

Real services also need to guard against runaway recursion: a malicious or malformed input can nest very deeply and cause StackOverflowError. Production code often limits the depth or switches to a loop with an explicit stack. This course mentions that as a risk; the techniques are for later.

Common Mistakes

Mistake 1: No base case, or a base case never reached

The calls never stop and you get StackOverflowError. Check the smallest inputs, including 0 and negative numbers if they are possible.

Mistake 2: The recursive call does not shrink the problem

Calling sumDigits(n) instead of sumDigits(n / 10) does not move toward the base case. Ask: "is the argument strictly closer to the base case?"

Mistake 3: Forgetting to use the result

java
static int sumTo(int n) {
    if (n <= 0) return 0;
    sumTo(n - 1);          // result ignored
    return n;
}

This compiles and returns just n. Make sure you combine the value the smaller call returns.

Mistake 4: Tracing too deep while designing

If you try to hold ten levels in your head, you will get lost. Write base case, smaller step, and combine, and test on small inputs.

Exercises

Try each exercise yourself before opening the solutions.

Level A: Foundation

A1. Name the base case and the smaller step of factorial, and compute factorial(4).

أظهر الحل A1

A1. Base case: n <= 1 returns 1. Smaller step: n - 1. factorial(4) = 4 * 3 * 2 * 1 = 24 (my run printed 24).

A2. Trace sumDigits(907). What does it return?

أظهر الحل A2

A2. sumDigits(907) = 7 + sumDigits(90); sumDigits(90) = 0 + sumDigits(9); sumDigits(9) = 9. Back up: 9, then 0 + 9 = 9, then 7 + 9 = 16. It returns 16.

A3. What happens when a recursive method has no base case? Name the error.

أظهر الحل A3

A3. The method keeps calling itself, each call adds a frame to the call stack, and eventually the JVM throws StackOverflowError.

Level B: Application

B1. Write sumTo(int n) that returns 1 + 2 + ... + n recursively (0 for n <= 0). What is sumTo(4)?

أظهر الحل B1

B1.

java
static int sumTo(int n) {
    if (n <= 0) return 0;
    return n + sumTo(n - 1);
}

sumTo(4) = 4 + 3 + 2 + 1 + 0 = 10 (my run printed 10).

B2. Write the recursive power(int b, int e) and say how many calls power(2, 10) makes.

أظهر الحل B2

B2.

java
static int power(int b, int e) {
    if (e == 0) return 1;
    return b * power(b, e - 1);
}

power(2, 10) makes 11 calls (e = 10 down to 0), which my counter confirmed.

B3. Trace the recursive isPalindrome on "abca" and say which call decides the answer.

أظهر الحل B3

B3. isPalindrome("abca"): length 4, first a equals last a, so recurse on "bc". In that call b differs from c, so it returns false immediately. The second call decides, and the result false goes back up.

Level C: Challenge

C1. Write reverse(String s) recursively without a loop, and trace it on "abc".

أظهر الحل C1

C1.

java
static String reverse(String s) {
    if (s.length() <= 1) return s;
    return reverse(s.substring(1)) + s.charAt(0);
}

reverse("abc") = reverse("bc") + 'a' = (reverse("c") + 'b') + 'a' = "c" + 'b' + 'a' = "cba". My run printed cba.

C2. Count the calls of fastPower(2, 11), and explain why half is computed once and stored.

أظهر الحل C2

C2. Calls for e = 11, 5, 2, 1, 0: 5 calls (my counter printed 5). If you wrote fastPower(b, e / 2) * fastPower(b, e / 2), each level would make two calls and the total would grow back to about e calls, losing the benefit. Storing half makes one call per level.

Mini Challenge

Write a recursive method int digitCount(int n) that returns how many digits a non-negative number has (treat 0 as 1 digit). Use the recipe and write down the base case first.

Check your work: digitCount(0) is 1, digitCount(9) is 1, digitCount(4821) is 4, digitCount(100) is 3. State how many calls it makes for a number with d digits.

Summary & Next Lesson

Key takeaways

  • A recursive method needs a base case, a smaller step, and a combine step. Write them in that order.
  • Trust the smaller call while designing; trace small inputs while debugging.
  • A missing or unreachable base case causes StackOverflowError.
  • Work placed before the recursive call happens on the way down; work placed after happens on the way up.
  • Cutting the problem by a fraction (halving) can turn O(e) calls into O(log e).
  • Use a loop for simple long repetition, recursion for nested or branching problems.

Next lesson

The next lesson, "Backtracking", combines recursion with a loop or two branches to generate all subsets and all permutations, undoing each choice on the way back up.

Self-check

  1. What are the three questions of the recursion recipe?
  2. Why can sumTo(-1) run forever if the base case is n == 0?
  3. What does "trust the smaller call" mean?
  4. How many calls does fastPower(2, 10) make, and why fewer than power(2, 10)?
  5. When is a loop a better choice than recursion?
أظهر الإجابات
  1. Base case, smaller step, combine.
  2. n goes -1, -2, ... and never equals 0, so the base case is never reached. Using n <= 0 fixes it.
  3. While writing the method, assume the recursive call returns the correct answer for the smaller problem, and only decide how to use it.
  4. 5 calls, because the exponent halves each time; power reduces it by one each time and needs 11 calls.
  5. For simple repetition over a long range, where deep recursion would use a lot of stack space and may overflow.

References

  • Oracle, StackOverflowError (Java SE 21 API). It defines the error as thrown when a stack overflow occurs because an application recurses too deeply, which supports the base-case discussion.
  • Oracle, String (Java SE 21 API). It defines substring(beginIndex, endIndex) as inclusive of the begin index and exclusive of the end index. The recipe, the call counts, and the O(log e) estimate are this course's own analysis; the Java examples were compiled and run with JDK 21. The int overflow of factorial(13) was observed in my run and not checked against a source.

التطبيق

طبّق هذا الدرس بمختبر الوحدة وتمارينها واختبارها.

المختبر والتمارين الاختبار

تعليم الدرس كمكتمل اختياري ويمكنك التراجع عنه في أي وقت. يبقى تقدمك محفوظًا في هذا المتصفح.

اكتب للبحث في المستويات والمراحل والوحدات والدروس.