Skip to content
Ameen Abozaid
PublishedBEG-044-L04

Backtracking

  • Lesson 4 of 5
  • 11 min read
  • Beginner
On This Lesson 11 topics

Lesson Overview

Some problems ask for everything: every subset of a list, every ordering of some letters, every combination that reaches a target. You cannot loop over those with a fixed number of nested loops, because the depth depends on the input. Recursion solves this neatly, and the pattern that makes it work is called backtracking.

Think of exploring a maze with a ball of string. At each junction you pick a path and unroll some string. If the path is a dead end, or you have finished exploring it, you roll the string back to the junction and try the next path. The string is your current partial answer, and rolling it back is the step beginners forget.

This lesson teaches the three-step pattern choose, explore, un-choose, applies it to subsets, permutations, and a target-sum search, and shows how to cut off dead ends early (pruning). It also shows how fast these results grow, which every developer should know before running them on real data.

This lesson builds on "Thinking recursively" (previous lesson), on ArrayList (BEG-040), and on boolean arrays and loops (BEG-013 and BEG-015).

Learning Objectives

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

  • Describe backtracking as choose, explore, un-choose, and point to each part in code.
  • Generate all subsets of an array and state that there are 2ⁿ of them.
  • Generate all permutations of an array and state that there are n! of them.
  • Explain why a saved result must be a copy of the current list.
  • Use pruning to skip branches that cannot succeed, and measure the saving.
  • Judge from the growth of 2ⁿ and n! when brute force is unrealistic.

Written Explanation

The pattern

A backtracking method has a current answer (a list built so far) and repeats this loop of decisions:

  1. Choose: add one option to the current answer.
  2. Explore: call the method recursively to complete the answer from there.
  3. Un-choose: remove the option you added, so the current answer is exactly as it was before.

The base case is "the answer is complete": save it and return. Because step 3 restores the state, the next choice starts from a clean slate. Un-choosing on the way back up the call stack is where the name "backtracking" comes from.

Subsets: take it or skip it

A subset of {1, 2, 3} keeps some items and drops the others, in any combination. For each item there are two decisions: skip or take. With n items, that is 2 × 2 × ... × 2 = 2ⁿ subsets (the empty subset and the full set both count).

java
static void subsets(int[] a, int i, List<Integer> cur, List<List<Integer>> out) {
    if (i == a.length) {                       // base case: decided about every item
        out.add(new ArrayList<>(cur));         // save a COPY
        return;
    }
    subsets(a, i + 1, cur, out);               // skip a[i]
    cur.add(a[i]);                             // choose: take a[i]
    subsets(a, i + 1, cur, out);               // explore
    cur.remove(cur.size() - 1);                // un-choose
}

Here i is the item we are deciding about; cur is the subset so far. On {1, 2, 3} my run printed:

text
[[], [3], [2], [2, 3], [1], [1, 3], [1, 2], [1, 2, 3]]

Eight subsets, 2³. The order looks odd because we try "skip" first and recursion goes deepest first; the set of results is what matters.

cur.remove(cur.size() - 1) removes the last item by index. For a List<Integer>, careful: remove(int index) removes by position, while remove(Object o) removes a value. Passing an int expression, as here, picks the position version. The List documentation describes remove(int) as removing the element at the given position.

Why copy the list?

out.add(cur) stores a reference to the same list that keeps changing. After the search finishes, cur has been un-chosen back to empty, so every saved entry shows the same empty list. To confirm, I ran a version with out.add(cur) on {1, 2} and printed:

text
[[], [], [], []]

Four entries (correct count), all empty (wrong contents). The fix is new ArrayList<>(cur), which makes an independent snapshot at that moment.

Permutations: every ordering

A permutation is an ordering of all the items. For n items there are n × (n-1) × ... × 1 = n! of them: n choices for the first position, n-1 for the second, and so on. Now each step is a loop over the items not yet used, so we keep a boolean[] used to mark what is already in cur.

java
static void perms(int[] a, boolean[] used, List<Integer> cur, List<List<Integer>> out) {
    if (cur.size() == a.length) { out.add(new ArrayList<>(cur)); return; }
    for (int i = 0; i < a.length; i++) {
        if (used[i]) continue;                 // already in the answer
        used[i] = true; cur.add(a[i]);         // choose
        perms(a, used, cur, out);              // explore
        cur.remove(cur.size() - 1);            // un-choose
        used[i] = false;
    }
}

For {1, 2, 3} my run printed:

text
[[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]

Six results, 3! = 6. Notice that un-choose now restores two things: the list and the used flag. Every piece of state you change when choosing must be undone.

Growth: 2ⁿ and n!

nSubsets 2ⁿPermutations n!
386
41624
101,0243,628,800
201,048,576about 2.4 × 10¹⁸

For n = 4 my run gave 16 subsets and 24 permutations. The 10 and 20 rows are arithmetic. Generating all of something with 20 items as permutations is impossible in practice. Backtracking is for small inputs, or for problems where pruning cuts most of the search away.

Pruning: stop dead ends early

Problem: find every group of numbers from {1, 2, 3, 4, 5, 6}, each used at most once, that adds up to 7.

A plain search tries all subsets. But if the numbers are sorted and positive, as soon as the next number is larger than the amount still needed, no later number can work either, so we can stop that loop.

java
static void combos(int[] a, int start, int remaining, List<Integer> cur,
                   List<List<Integer>> out) {
    if (remaining == 0) { out.add(new ArrayList<>(cur)); return; }
    for (int i = start; i < a.length; i++) {
        if (a[i] > remaining) break;           // prune: a is sorted and positive
        cur.add(a[i]);                         // choose
        combos(a, i + 1, remaining - a[i], cur, out);   // explore
        cur.remove(cur.size() - 1);            // un-choose
    }
}

start makes sure we only look at items after the one we just took, so the same group is not produced in two orders. The pruning relies on two assumptions (sorted, positive); with negative numbers it would be wrong.

Examples

Example 1: subsets of {1, 2, 3} (simple)

Follow the first calls: the method skips 1, skips 2, skips 3, and at i = 3 saves []. Back at i = 2 it takes 3 and saves [3], then un-chooses 3. Back at i = 1 it takes 2 and explores again: skipping 3 saves [2], taking 3 saves [2, 3]. This matches the first four printed results; the rest all contain 1.

Each level makes two calls, skip first and take second, so you can predict the output without running the code.

Example 2: permutations of letters (practical)

Orderings of letters appear in puzzle solvers and test-data generators. Using the same pattern with a StringBuilder for "abc" (I used append to choose and deleteCharAt to un-choose) printed:

text
[abc, acb, bac, bca, cab, cba]

Six results again. Only the type of the "current answer" changed; the three-step pattern did not.

Example 3: target sum with and without pruning (realistic)

I ran combos on {1, 2, 3, 4, 5, 6} with target 7, and a second version without the break line, counting every call to combos:

VersionResultsCalls
Without pruning[[1,2,4],[1,6],[2,5],[3,4]]57
With pruning[[1,2,4],[1,6],[2,5],[3,4]]18

Same four answers, about a third of the work. On larger inputs the gap grows much further. The lesson is not the number 18 but the habit: before you generate everything, ask "when can I be sure this branch cannot work?"

Backend Insight

Backend developers rarely generate every permutation of anything large, but the pattern appears in several places: a search for combinations of discount rules that reach a limit, a scheduling tool that tries slot assignments and undoes them when they conflict, a test generator that produces every combination of a few settings, and a validator that tries parsing alternatives. In all of them, the most important engineering question is the one from the growth table: how large can the result get?

Before running an "all combinations" job on user-supplied data, check the size of the input. A request with 25 optional items already means over thirty million subsets. Services protect themselves with limits on input size or with early exits like pruning.

Common Mistakes

Mistake 1: Saving the current list instead of a copy

Every saved result ends up identical (often empty). Use new ArrayList<>(cur).

Mistake 2: Forgetting to un-choose

If you add an item and never remove it, the next branch starts with leftover items, producing wrong and too-long answers. In permutations, also forget to reset used[i] = false, and later choices will think items are still taken.

Mistake 3: Un-choosing the wrong thing

cur.remove(x) with an Integer value removes the first matching value, not the last item. Remove the last element by index with cur.size() - 1.

Mistake 4: Ignoring the growth

The code is correct for n = 20 and still never finishes for permutations. Estimate 2ⁿ or n! before running.

Mistake 5: Pruning on an assumption that is false

The a[i] > remaining break is only valid for sorted, positive numbers. State the assumptions next to the pruning rule.

Exercises

Try each exercise yourself before opening the solutions.

Level A: Foundation

A1. How many subsets does a 5-item set have? How many permutations?

Show solution A1

A1. 2⁵ = 32 subsets and 5! = 120 permutations.

A2. List all permutations of {1, 2, 3} in the order your code would produce them.

Show solution A2

A2. [1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1] (matches my run).

A3. In the perms code, mark the three lines that are choose, explore, and un-choose. Which two pieces of state are restored?

Show solution A3

A3. Choose: used[i] = true; cur.add(a[i]);. Explore: perms(a, used, cur, out);. Un-choose: cur.remove(cur.size() - 1); used[i] = false;. The list cur and the array used are both restored.

Level B: Application

B1. subsets is changed to out.add(cur). For {1, 2}, what do you see in out and why?

Show solution B1

B1. out is [[], [], [], []]. Four subsets were saved (2² = 4), but each entry points to the same list cur, which was un-chosen back to empty by the time the search ended (my run printed exactly this).

B2. Use combos for {1, 2, 3, 4, 5, 6} and target 7. List the four groups it finds.

Show solution B2

B2. [1, 2, 4], [1, 6], [2, 5], [3, 4].

B3. Explain why if (a[i] > remaining) break; is safe in combos, and name two conditions the data must meet.

Show solution B3

B3. Once a[i] is larger than remaining, taking it would overshoot. Because the array is sorted ascending, every later item is at least as large, so all later items would overshoot too: we can leave the loop. The data must be sorted ascending and have positive numbers.

Level C: Challenge

C1. Change subsets so that it saves only those subsets whose items sum to a given target. Test on {1, 2, 3, 4} with target 5.

Show solution C1

C1.

java
static void withSum(int[] a, int i, int sum, int target,
                    List<Integer> cur, List<List<Integer>> out) {
    if (i == a.length) {
        if (sum == target) out.add(new ArrayList<>(cur));
        return;
    }
    withSum(a, i + 1, sum, target, cur, out);
    cur.add(a[i]);
    withSum(a, i + 1, sum + a[i], target, cur, out);
    cur.remove(cur.size() - 1);
}

My run printed [[2, 3], [1, 4]] for {1, 2, 3, 4} and target 5. The sum is passed as a parameter, so it needs no un-choosing: the old value is still in the caller.

C2. A program must print all orderings of a list with 10 items. About how many lines is that? Is it realistic to do it for 20 items? Explain briefly.

Show solution C2

C2. 10! = 3,628,800 lines, which is feasible but large. For 20 items it is about 2.4 × 10¹⁸ orderings, which is not realistic to print or even generate; you would need a different approach or a smaller input.

Mini Challenge

Write int countSubsetsWithSum(int[] a, int target) that returns how many subsets have the given sum, without storing them: the base case adds 1 to a counter when the sum matches.

Check your work: on {1, 2, 3, 4} with target 5 it returns 2. Then test target 0, which has exactly one subset (the empty one) if all numbers are positive, and say in a comment how many calls the method makes for n items (about 2ⁿ⁺¹).

Summary & Next Lesson

Key takeaways

  • Backtracking is recursion with the pattern choose, explore, un-choose.
  • A set of n items has 2ⁿ subsets and n! permutations; both grow very fast.
  • Always save a copy of the current list when recording a result.
  • Undo everything you changed when choosing, including flags such as used[i].
  • Pruning stops branches that cannot succeed. Check the assumptions that make it safe.

Next lesson

The next lesson, "Demo: solving a problem with a sliding window", is a hands-on walkthrough: you will solve "longest substring without repeated characters" step by step, including a deliberate off-by-one bug, which prepares you for the module lab.

Self-check

  1. What are the three steps of the backtracking pattern?
  2. How many subsets does a 6-item set have?
  3. Why does out.add(cur) give wrong results?
  4. What must you reset after a recursive call in the permutation code?
  5. What does pruning do?
Show answers
  1. Choose, explore, un-choose.
  2. 2⁶ = 64.
  3. It stores a reference to a list that is later changed back, so every saved entry shows the same final state.
  4. The list cur (remove the last item) and the flag used[i] (set back to false).
  5. It stops searching a branch as soon as it is certain that the branch cannot lead to a valid answer.

References

  • Oracle, List (Java SE 21 API). It documents remove(int index) as removing the element at the given position, and add as appending to the end.
  • Oracle, ArrayList (Java SE 21 API). It documents the constructor that copies the elements of another collection, which is why new ArrayList<>(cur) is an independent snapshot. The counts 2ⁿ and n! and the call counts are this course's own analysis and my runs with JDK 21. The StringBuilder permutation example was compiled and run but not checked against a StringBuilder documentation page.

Practice

Apply this lesson with the module's lab, exercises and quiz.

Lab and exercises Quiz

Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.

Continue to next lesson

Type to search levels, phases, modules and lessons.