Home DSA Backtracking Explained: How to Solve Problems by Undoing Bad Choices

Backtracking Explained: How to Solve Problems by Undoing Bad Choices

In Plain English 🔥
Imagine you're navigating a maze. You walk down a corridor and hit a dead end — so you turn around, go back to the last fork, and try a different path. That 'turn around and try again' move is exactly what backtracking is. It's a systematic way of exploring all possible options by trying a choice, checking if it still makes sense, and undoing it if it doesn't — rather than blindly trying every combination from scratch.
⚡ Quick Answer
Imagine you're navigating a maze. You walk down a corridor and hit a dead end — so you turn around, go back to the last fork, and try a different path. That 'turn around and try again' move is exactly what backtracking is. It's a systematic way of exploring all possible options by trying a choice, checking if it still makes sense, and undoing it if it doesn't — rather than blindly trying every combination from scratch.

Most real-world problems don't come with an obvious answer. Finding all valid ways to place chess queens on a board, generating every possible password combination, or solving a Sudoku puzzle — these all share one thing: there are many possible paths, and most of them are dead ends. Backtracking is the algorithmic strategy that lets you explore those paths efficiently without manually tracking which ones you've already failed at.

What Backtracking Actually Does (and Why Brute Force Isn't Enough)

Brute force tries every possible combination regardless of whether it's already heading toward failure. Backtracking is smarter — it builds a solution incrementally, and the moment a partial solution violates a constraint, it stops pursuing that branch entirely. This is called 'pruning.'

Think of a decision tree. Every node is a choice. Brute force visits every single node. Backtracking visits a node, checks 'can this possibly lead to a valid answer?', and if the answer is no, it skips the entire subtree rooted there. That's the efficiency win.

The core loop is always the same: choose a candidate, place it, recurse deeper, then unchoose (undo the placement). That 'unchoose' step is what makes backtracking different from plain recursion — you're restoring state so the next candidate gets a clean slate to work with.

This pattern works beautifully for constraint satisfaction problems: puzzles, permutations, combinations, and graph coloring — anywhere the solution space is large but constraints filter most of it out early.

SubsetGenerator.java · JAVA
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
import java.util.ArrayList;
import java.util.List;

/**
 * Generates all subsets (the power set) of a given array of integers.
 * This is the simplest real showcase of the backtracking pattern:
 * choose -> explore -> unchoose.
 */
public class SubsetGenerator {

    public static void main(String[] args) {
        int[] numbers = {1, 2, 3};
        List<List<Integer>> allSubsets = new ArrayList<>();

        generateSubsets(numbers, 0, new ArrayList<>(), allSubsets);

        System.out.println("All subsets of " + java.util.Arrays.toString(numbers) + ":");
        for (List<Integer> subset : allSubsets) {
            System.out.println(subset);
        }
        System.out.println("Total subsets: " + allSubsets.size());
    }

    /**
     * @param numbers       The input array we're building subsets from
     * @param startIndex    The index we're currently deciding to include or skip
     * @param currentSubset The subset we're building up incrementally
     * @param results       Accumulator — stores every complete subset we find
     */
    private static void generateSubsets(
            int[] numbers,
            int startIndex,
            List<Integer> currentSubset,
            List<List<Integer>> results) {

        // BASE CASE: record a snapshot of the current subset as a valid result.
        // We do this at EVERY level, not just leaves — every partial build is a valid subset.
        results.add(new ArrayList<>(currentSubset));

        // EXPLORE: try adding each remaining number to the current subset
        for (int i = startIndex; i < numbers.length; i++) {

            // CHOOSE: add numbers[i] to the current subset
            currentSubset.add(numbers[i]);

            // RECURSE: explore all subsets that start with what we've chosen so far
            // We pass i+1 so we never re-use the same element
            generateSubsets(numbers, i + 1, currentSubset, results);

            // UNCHOOSE: remove numbers[i] — restore state for the next iteration
            // Without this line, each recursive call would pollute the next one
            currentSubset.remove(currentSubset.size() - 1);
        }
    }
}
▶ Output
All subsets of [1, 2, 3]:
[]
[1]
[1, 2]
[1, 2, 3]
[1, 3]
[2]
[2, 3]
[3]
Total subsets: 8
⚠️
The Golden Rule of Backtracking:Every 'add' must have a matching 'remove'. Every 'mark as visited' must have a matching 'unmark'. If your state changes going in, it must be restored coming out — otherwise your next candidate inherits corrupted state and your results will be silently wrong.

Pruning: The Feature That Makes Backtracking Fast

Generating all subsets is pure exploration — there are no invalid paths to prune. But backtracking really earns its keep when constraints let you eliminate huge branches early.

The N-Queens problem is the classic demonstration. Place N queens on an N×N chessboard so that no two queens attack each other (same row, column, or diagonal). A brute force approach would try all N^N placements. With pruning, the moment placing a queen creates a conflict, you stop and backtrack — you never explore any of the millions of board states that would follow from that illegal placement.

For an 8×8 board, brute force checks 16 million+ configurations. Backtracking with pruning checks roughly 15,000. That's the power of cutting entire subtrees.

The constraint check — the 'is this placement still valid?' question — is called the bounding function or feasibility check. Writing a tight, fast feasibility check is the single biggest lever you have for making backtracking solutions performant.

NQueensSolver.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
import java.util.ArrayList;
import java.util.List;

/**
 * Solves the N-Queens problem using backtracking with pruning.
 * Places N queens on an N x N board so no two queens threaten each other.
 */
public class NQueensSolver {

    public static void main(String[] args) {
        int boardSize = 4;
        List<int[]> solutions = new ArrayList<>();

        // queenInRow[col] = the row where we placed the queen in column 'col'
        int[] queenInRow = new int[boardSize];

        solveNQueens(boardSize, 0, queenInRow, solutions);

        System.out.println("Solutions for " + boardSize + "-Queens:\n");
        for (int solutionNumber = 0; solutionNumber < solutions.size(); solutionNumber++) {
            System.out.println("Solution " + (solutionNumber + 1) + ":");
            printBoard(solutions.get(solutionNumber), boardSize);
        }
        System.out.println("Total solutions: " + solutions.size());
    }

    /**
     * Tries to place a queen in each row of the current column.
     *
     * @param boardSize   Size of the board (N)
     * @param currentCol  The column we're currently trying to fill
     * @param queenInRow  Records which row holds the queen for each column
     * @param solutions   Accumulates all valid complete board configurations
     */
    private static void solveNQueens(
            int boardSize,
            int currentCol,
            int[] queenInRow,
            List<int[]> solutions) {

        // BASE CASE: we've successfully placed queens in all columns
        if (currentCol == boardSize) {
            solutions.add(queenInRow.clone()); // snapshot — not a reference!
            return;
        }

        // Try placing a queen in each row of the current column
        for (int candidateRow = 0; candidateRow < boardSize; candidateRow++) {

            // PRUNING: skip this row if it conflicts with any previously placed queen
            if (isPlacementSafe(queenInRow, currentCol, candidateRow)) {

                // CHOOSE: place the queen
                queenInRow[currentCol] = candidateRow;

                // RECURSE: move on to the next column
                solveNQueens(boardSize, currentCol + 1, queenInRow, solutions);

                // UNCHOOSE: no explicit reset needed here because we overwrite
                // queenInRow[currentCol] on the next iteration anyway.
                // For clarity in more complex problems, you'd zero it out here.
            }
            // If not safe, we skip the entire subtree — this is the pruning step
        }
    }

    /**
     * Checks whether placing a queen at (candidateRow, currentCol) conflicts
     * with any queen already placed in columns 0..currentCol-1.
     */
    private static boolean isPlacementSafe(
            int[] queenInRow,
            int currentCol,
            int candidateRow) {

        for (int placedCol = 0; placedCol < currentCol; placedCol++) {
            int placedRow = queenInRow[placedCol];

            // Same row? Conflict.
            boolean sameRow = (placedRow == candidateRow);

            // Same diagonal? The row difference equals the column difference.
            int rowDiff = Math.abs(placedRow - candidateRow);
            int colDiff = Math.abs(placedCol - currentCol);
            boolean sameDiagonal = (rowDiff == colDiff);

            if (sameRow || sameDiagonal) {
                return false; // Prune: this placement is immediately illegal
            }
        }
        return true; // No conflicts found — safe to place
    }

    /** Renders a board configuration as a grid of Q (queen) and . (empty). */
    private static void printBoard(int[] queenInRow, int boardSize) {
        for (int row = 0; row < boardSize; row++) {
            StringBuilder line = new StringBuilder();
            for (int col = 0; col < boardSize; col++) {
                line.append(queenInRow[col] == row ? " Q " : " . ");
            }
            System.out.println(line);
        }
        System.out.println();
    }
}
▶ Output
Solutions for 4-Queens:

Solution 1:
. Q . .
. . . Q
Q . . .
. . Q .

Solution 2:
. . Q .
Q . . .
. . . Q
. Q . .

Total solutions: 2
🔥
Why Column-by-Column?We place one queen per column by design — it immediately eliminates the 'same column' conflict check, making our feasibility function simpler and faster. Structuring your state so some constraints are satisfied by construction (rather than checked at runtime) is a key optimization technique in backtracking.

Recognizing When Backtracking Is the Right Tool

Backtracking isn't always the right choice. It's best when the problem has all three of these properties:

  1. You need all solutions (or need to verify one exists), not just a single optimal one — for a single optimal solution, dynamic programming or greedy is often better.
  2. The solution is built incrementally — you make a series of choices, and each choice narrows the remaining options.
  3. Constraints can eliminate branches early — if there's no way to prune, you're just doing brute force with extra steps.

Classic backtracking problems: N-Queens, Sudoku, generating permutations/combinations/subsets, word search on a grid, graph coloring, and the rat-in-a-maze problem.

Not a great fit for backtracking: finding the shortest path (use BFS/Dijkstra), problems with overlapping subproblems (use DP), or cases where you need statistical probability across all paths (use DP again).

A fast mental test: 'Do I have constraints that let me reject partial solutions?' If yes, backtracking is likely on the table.

PermutationGenerator.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
import java.util.ArrayList;
import java.util.List;

/**
 * Generates ALL permutations of a string using backtracking.
 * Demonstrates using a boolean array to track 'visited' state,
 * which must be carefully restored on unchoose.
 */
public class PermutationGenerator {

    public static void main(String[] args) {
        String input = "ABC";
        List<String> permutations = new ArrayList<>();

        boolean[] characterUsed = new boolean[input.length()];

        buildPermutations(input.toCharArray(), characterUsed, new StringBuilder(), permutations);

        System.out.println("All permutations of \"" + input + "\":");
        permutations.forEach(System.out::println);
        System.out.println("Total: " + permutations.size() + " (expected: " + factorial(input.length()) + ")");
    }

    private static void buildPermutations(
            char[] characters,
            boolean[] characterUsed,
            StringBuilder currentPermutation,
            List<String> results) {

        // BASE CASE: we've used every character — record this permutation
        if (currentPermutation.length() == characters.length) {
            results.add(currentPermutation.toString());
            return;
        }

        for (int i = 0; i < characters.length; i++) {

            // PRUNING: skip characters already in the current permutation
            if (characterUsed[i]) {
                continue;
            }

            // CHOOSE: use this character and mark it as taken
            characterUsed[i] = true;
            currentPermutation.append(characters[i]);

            // RECURSE: fill the next position
            buildPermutations(characters, characterUsed, currentPermutation, results);

            // UNCHOOSE: remove the character and free it for other permutations
            // Forgetting either of these two lines is the #1 backtracking bug
            currentPermutation.deleteCharAt(currentPermutation.length() - 1);
            characterUsed[i] = false;
        }
    }

    private static int factorial(int n) {
        return (n <= 1) ? 1 : n * factorial(n - 1);
    }
}
▶ Output
All permutations of "ABC":
ABC
ACB
BAC
BCA
CAB
CBA
Total: 6 (expected: 6)
⚠️
Watch Out: Shared Mutable StateNotice we call `currentPermutation.toString()` to snapshot the result, not just add the StringBuilder directly. If you added the StringBuilder object itself, every result in your list would point to the same object — and every subsequent modification would corrupt all previously stored results. Always snapshot mutable state before storing it.
AspectBacktrackingBrute Force
Explores invalid branches?No — prunes them earlyYes — tries everything
Time complexityDepends on pruning qualityAlways worst-case O(N^N) or similar
Memory usageO(depth of recursion)O(total candidates)
Best forConstraint satisfaction, all-solutions problemsTiny input, no constraints
Needs feasibility check?Yes — core to its efficiencyNo
Typical problemsN-Queens, Sudoku, permutationsPassword cracking (no constraints)
Difficulty to implementModerate — state management is trickySimple — nested loops

🎯 Key Takeaways

  • Backtracking = incremental building + feasibility check + undo. Remove any one of those three and you no longer have backtracking.
  • The efficiency of backtracking lives entirely in the quality of your pruning. A tight feasibility check that eliminates branches early is worth more than any micro-optimization elsewhere.
  • Always snapshot mutable state before storing it as a result — storing a reference to a live ArrayList or StringBuilder will corrupt all your stored solutions silently.
  • Backtracking shines when you need all valid solutions and constraints exist to prune the search space. For a single optimal solution, think greedy or DP first.

⚠ Common Mistakes to Avoid

  • Mistake 1: Forgetting to undo state changes — Symptom: results contain correct AND corrupted entries, often growing longer than expected — Fix: every mutation (list.add, visited[i]=true, grid[r][c]='#') must be paired with its exact inverse after the recursive call returns. Treat it like a transaction: always roll back.
  • Mistake 2: Storing a reference instead of a snapshot — Symptom: all entries in your results list look identical (usually matching the final state) — Fix: when adding to your results list, always copy: results.add(new ArrayList<>(currentList)) or results.add(currentString.toString()). Never add the live mutable object itself.
  • Mistake 3: Writing an incorrect or over-eager feasibility check — Symptom: valid solutions are missing from the output (false positives in the constraint check prune valid branches) — Fix: unit-test your isValid / isSafe function independently on known good and bad inputs before hooking it into the recursive logic. A wrong pruning function is harder to debug than no pruning at all.

Interview Questions on This Topic

  • QWhat is the difference between backtracking and dynamic programming — when would you choose one over the other?
  • QWalk me through how you'd solve Sudoku using backtracking. What does your feasibility check look like, and how do you ensure efficient pruning?
  • QIf your backtracking solution is too slow for large inputs, what are three concrete techniques you can apply to speed it up without changing the core algorithm?

Frequently Asked Questions

Is backtracking the same as recursion?

No — recursion is a programming technique (a function calling itself), while backtracking is an algorithmic strategy that uses recursion as its vehicle. The defining feature of backtracking is the explicit undo step: after recursing, you restore state so the next candidate starts clean. Plain recursion doesn't require that.

What is the time complexity of backtracking?

It depends heavily on the problem and the quality of your pruning. In the worst case (no pruning at all) it's equivalent to brute force — O(N!) for permutations, O(2^N) for subsets. In practice, good pruning can reduce the explored space by orders of magnitude, which is why backtracking is practical for problems where brute force is not.

How do I know when to stop recursing and record a result?

Your base case defines a 'complete' solution — typically when you've placed all items, filled all positions, or exhausted the input. For problems like subset generation, every state (not just leaf nodes) is a valid result, so you record at every level. For problems like N-Queens, you only record when all N queens are placed without conflict. Understanding exactly what 'done' means for your problem is the first thing to nail down before writing a single line of backtracking code.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousHuffman CodingNext →N-Queens Problem
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged