Week 8: Recursion & Backtracking

Weeks 6 and 7 built linked lists and stacks using loops and explicit data structures — this week gives you the other core tool for exploring problems: recursion, where a function solves a problem by solving smaller instances of itself. You'll formalize the discipline that keeps recursion correct (a reachable base case, and recursive calls that shrink toward it), then extend it into backtracking — recursion that tries a choice, explores forward, and undoes the choice if it doesn't pan out. Subsets, permutations, and N-Queens are the canonical backtracking problems, and the pruning techniques you learn here for cutting off doomed branches early are exactly what makes tree traversal starting Week 9, graph DFS in Week 12, and the memoized recursion behind dynamic programming in Week 13 all tractable.

Module 6 of 17 Week 8 of 20 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Trace a recursion tree and identify why a function needs a reachable base case
  • Write backtracking solutions for subsets, permutations, and N-Queens using the choose/explore/un-choose pattern
  • Prune a search space to cut off branches that can't lead to a valid or optimal answer

1. The Recursion Tree & Base-Case Discipline

A recursive function is one that calls itself on a smaller version of its own input. Every correct recursive function needs two things: at least one base case that returns an answer directly without recursing, and a recursive case whose call always moves strictly closer to a base case. Skip either one and you get infinite recursion, which shows up at runtime as a stack overflow rather than a compile error.

It helps to picture a recursive function's execution as a tree: the root is the original call, and each call's children are the recursive calls it makes. The classic example that makes this visible is naive Fibonacci, where each call branches into two smaller calls:

recursion_tree.cpp
#include <iostream>
using namespace std;

// Base-case discipline: fib(0) and fib(1) return directly -- no further
// recursion. Every other call reduces n by at least 1, so the recursion
// is guaranteed to reach a base case.
long long fib(int n) {
    if (n <= 1) return n;                 // base cases
    return fib(n - 1) + fib(n - 2);       // recursive case
}

int main() {
    for (int i = 0; i < 10; i++) cout << fib(i) << " ";
    cout << "\n";
}

Each call to fib(n) spawns two more calls until it hits a base case, so the recursion tree has branching factor 2 and height n — that gives roughly 2²​​, precisely O(2ⁿ), total calls, dominated by massive repeated work (fib(5) gets computed inside fib(7) multiple separate times). Time complexity is O(2ⁿ); space complexity is only O(n), because at any instant only one root-to-leaf path of the tree is actually on the call stack — the rest of the tree hasn't been built yet or has already returned.

Trust the recursive leap of faith

When writing a recursive function, don't try to mentally unroll the whole call tree. Instead, write the base case, then assume the recursive call already correctly solves the smaller subproblem, and write the code that combines that trusted result into the answer for your current input. This "leap of faith" is what makes recursive code readable, and it's exactly how you should reason through backtracking below.

2. Backtracking for Subsets

Backtracking is recursion with a specific shape: at each step you choose an option, explore forward by recursing with that choice made, and then un-choose it (undo the choice) before trying the next option. That undo step is what lets a single mutable container represent every path through the search tree without allocating a fresh copy at each level.

Generating all subsets of a set is the cleanest illustration: at each index you either exclude the element or include it, so the two branches trace out every possible subset:

subsets.cpp
#include <vector>
using namespace std;

void backtrack(int idx, vector<int>& nums, vector<int>& current,
               vector<vector<int>>& result) {
    if (idx == (int)nums.size()) {
        result.push_back(current);        // record a completed subset
        return;
    }

    // Choice 1: exclude nums[idx]
    backtrack(idx + 1, nums, current, result);

    // Choice 2: include nums[idx]
    current.push_back(nums[idx]);          // choose
    backtrack(idx + 1, nums, current, result);  // explore
    current.pop_back();                    // un-choose (backtrack)
}

vector<vector<int>> subsets(vector<int>& nums) {
    vector<vector<int>> result;
    vector<int> current;
    backtrack(0, nums, current, result);
    return result;
}

There are 2ⁿ subsets of an n-element set, and copying current into result costs up to O(n), so this runs in O(n · 2ⁿ) time. Space is O(n) beyond the output, matching the recursion depth.

The push_back/pop_back symmetry is non-negotiable

The single most common backtracking bug is an unbalanced choose/un-choose pair — a push_back with no matching pop_back on every return path (including early returns), or a swap that isn't undone. If your output looks corrupted or duplicated in ways you can't explain, check first that every mutation to your shared state is undone exactly once per branch.

3. Backtracking for Permutations

Permutations need a different choice structure than subsets: instead of an include/exclude decision per element, you decide which unused element goes into the current position. A clean way to implement this without extra bookkeeping is to swap the candidate into place, recurse, then swap back:

permutations.cpp
#include <vector>
using namespace std;

void backtrack(vector<int>& nums, int start, vector<vector<int>>& result) {
    if (start == (int)nums.size()) {
        result.push_back(nums);           // a full permutation
        return;
    }
    for (int i = start; i < (int)nums.size(); i++) {
        swap(nums[start], nums[i]);              // choose: fix nums[i] at position start
        backtrack(nums, start + 1, result);       // explore
        swap(nums[start], nums[i]);              // un-choose: restore original order
    }
}

vector<vector<int>> permute(vector<int>& nums) {
    vector<vector<int>> result;
    backtrack(nums, 0, result);
    return result;
}

There are n! permutations of n distinct elements, each costing O(n) to copy, so this is O(n · n!) time. When the input can contain duplicates, sort nums first and add a used[] boolean array with a skip-if-already-used-at-this-level check — swapping alone doesn't correctly dedupe permutations with repeated values.

4. N-Queens

N-Queens asks you to place n queens on an n × n board so that no two attack each other — meaning no shared row, column, or diagonal. Placing one queen per row by construction handles the row constraint automatically, so backtracking only needs to track used columns and the two diagonal directions, which you can identify with row - col (one diagonal family) and row + col (the other):

n_queens.cpp
#include <vector>
#include <unordered_set>
using namespace std;

void solve(int row, int n, vector<int>& cols,
           unordered_set<int>& usedCols,
           unordered_set<int>& usedDiag1,   // row - col
           unordered_set<int>& usedDiag2,   // row + col
           vector<vector<int>>& solutions) {
    if (row == n) {
        solutions.push_back(cols);        // cols[r] = column of the queen in row r
        return;
    }
    for (int col = 0; col < n; col++) {
        int d1 = row - col, d2 = row + col;
        if (usedCols.count(col) || usedDiag1.count(d1) || usedDiag2.count(d2))
            continue;                     // pruned: this square is under attack

        cols[row] = col;
        usedCols.insert(col); usedDiag1.insert(d1); usedDiag2.insert(d2);

        solve(row + 1, n, cols, usedCols, usedDiag1, usedDiag2, solutions);

        usedCols.erase(col); usedDiag1.erase(d1); usedDiag2.erase(d2);  // backtrack
    }
}

int countNQueens(int n) {
    vector<vector<int>> solutions;
    vector<int> cols(n, -1);
    unordered_set<int> usedCols, usedDiag1, usedDiag2;
    solve(0, n, cols, usedCols, usedDiag1, usedDiag2, solutions);
    return (int)solutions.size();
}

Without any pruning, trying every column in every row is O(nⁿ). The column/diagonal checks prune enormous portions of the tree in practice — for n = 8 the classic problem, the search explores a tiny fraction of the 64-square placements — though the formal worst-case bound stays exponential.

5. Pruning Search Spaces

Pruning means detecting, as early as possible, that a partial choice can never lead to a valid or optimal solution, and abandoning that branch before recursing into it. N-Queens already pruned via the column/diagonal sets above; a second common pruning trick is sorting the input so that once a candidate is too large, you know every later candidate is too, and can break out of the loop instead of merely continue-ing past one bad option:

combination_sum.cpp
#include <vector>
#include <algorithm>
using namespace std;

void backtrack(vector<int>& candidates, int target, int start,
               vector<int>& current, vector<vector<int>>& result) {
    if (target == 0) {
        result.push_back(current);
        return;
    }
    for (int i = start; i < (int)candidates.size(); i++) {
        if (candidates[i] > target) break;   // pruned: sorted, so all later ones are too big too

        current.push_back(candidates[i]);
        // pass i (not i + 1): each candidate can be reused
        backtrack(candidates, target - candidates[i], i, current, result);
        current.pop_back();
    }
}

vector<vector<int>> combinationSum(vector<int> candidates, int target) {
    sort(candidates.begin(), candidates.end());   // sorting is what enables the break-prune
    vector<vector<int>> result;
    vector<int> current;
    backtrack(candidates, target, 0, current, result);
    return result;
}

A third pruning strategy — the most powerful one — is noticing when the same subproblem gets explored more than once with no new information, the way fib(5) got recomputed repeatedly in Section 1. Caching those results (memoization) turns exponential backtracking into polynomial dynamic programming, which is exactly the transition Week 13 makes explicit.

Sort first, then look for a break

Whenever a backtracking problem lets you choose the iteration order, sorting the candidates first is often the cheapest pruning win available — it turns an "is this one bad?" check into an "everything from here on is bad, stop looking" check, which is strictly more powerful per operation.

6. Hands-on Exercise

Hands-on

Generate all valid combinations of balanced parentheses

Combine this week's backtracking pattern with the balanced-parentheses idea from Week 7 to generate, rather than just check, valid sequences.

Requirements:

  1. Write vector<string> generateParenthesis(int n) that backtracks over string-building choices at each position.
  2. Track an openCount and closeCount. Only choose to append '(' if openCount < n; only choose to append ')' if closeCount < openCount — this is your pruning condition.
  3. Base case: when the current string's length reaches 2 * n, push a copy into the result vector.
  4. Test with n = 3; confirm you get exactly 5 valid combinations, and print them.
  5. Add int countValidParentheses(int n) that counts valid sequences without storing the strings themselves (increment a counter at the base case instead), and explain in a comment why this version uses less memory.
  6. State, in a comment, the time complexity of generateParenthesis in terms of the nth Catalan number, and why that's far smaller than the naive 2ⁿ you'd get without the pruning condition.
Hint

Build the string incrementally with a mutable string current and use current.push_back(...) / current.pop_back() for the choose/un-choose pair, exactly like the subset and permutation patterns above — parentheses generation is a backtracking problem wearing a string-building disguise.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does a recursive function without a reachable base case cause a stack overflow, and how does that relate to space complexity?

Each unfinished recursive call keeps its own stack frame (local variables, return address) alive on the call stack until it returns, and a call that never hits a base case never returns — so frames accumulate without bound until the program runs out of stack memory. This is exactly why recursion depth is counted as space complexity: an algorithm that recurses to depth n uses O(n) stack space even if it allocates no other memory.

Q2

Why must a "choice" made before a recursive call be undone after that call returns in backtracking?

Backtracking reuses one shared mutable container (like current in the subsets example) across every branch of the search tree instead of allocating a fresh copy per branch, which is what keeps it memory-efficient. If a choice like current.push_back(x) isn't undone with current.pop_back() before trying the sibling branch, that sibling starts from a corrupted state that still contains a choice from a path it never actually took, producing wrong or duplicated results.

Q3

In the N-Queens solution, what do row - col and row + col represent, and why are two separate sets needed?

Every square on one of the board's "↖-to-↘" diagonals shares the same value of row - col, and every square on one of the "↗-to-↙" diagonals shares the same value of row + col — these are the two independent diagonal directions a queen attacks along. They need separate sets because a square can be safe on one diagonal family but attacked on the other, so both checks are required together with the column check to correctly rule out every attacked square.

Q4

Why does sorting the candidates array before backtracking enable a break-based prune instead of just a continue-based one?

Once the array is sorted ascending, finding one candidate larger than the remaining target guarantees every candidate after it in the loop is also too large, since they're all ≥ the current one — so break safely abandons the rest of the loop in one step. On unsorted input that guarantee doesn't hold: a later candidate could still be small enough to work, so you'd only be able to continue past the one bad value and would still have to check every remaining candidate individually.