The K-Budget Wall

You know the dial. i = idx for subsets, i = start for combinations, i = 0 for permutations. Three settings on the same hardware. But knowing which setting to use and understanding why each setting works are two very different things. Turning a dial is easy. Knowing what the dial is mechanically doing — which gears it engages, which paths it opens and closes — that's the deeper skill.

Let's start with combinations, because they have a property that subsets and permutations don't: a budget. When you're generating combinations of size k, you're not exploring the full tree. You're exploring a tree with a ceiling — a horizontal line drawn at depth k that says “everything below here is irrelevant.” Every node at depth k is a valid combination. Every node deeper than k is wasted work. And every node at depth d < k with fewer than k - d elements remaining is a dead end.

Here's that ceiling in action — nodes at depth k are valid combinations, anything deeper is pure waste:

depth 2[][1][2][3][1,2][1,3][2,3][1,2,3]wasted work

That last part is the interesting bit. Think about it concretely. You're building combinations of 3 elements from [1, 2, 3, 4, 5]. You've picked element 4 as your first choice. Now you need 2 more elements, but the only thing left after 4 is [5] — one element. You can't reach 3. The branch is dead before you've explored it.

A naive combination generator would still recurse into that branch, try to pick from an array that's too short, hit the base case, and backtrack. It does the right thing eventually — no wrong output — but it wastes time exploring subtrees that cannot possibly produce a valid combination. And with larger inputs, the waste adds up fast. For n = 20, k = 10, the number of prunable branches is enormous.

Here's a useful way to think about it. Imagine you're packing a suitcase for a trip and the airline says “exactly 10 items.” You're at a store with 20 items on the shelf. You start from the left, picking items into your suitcase. By the time you've reached the 15th item on the shelf, you've only picked 4. You need 6 more items, but there are only 5 left on the shelf. No combination of remaining items can get you to 10. A smart shopper would stop right there — abandon this particular path and try a different combination of earlier items. A brute-force shopper would keep walking to the end of the shelf, confirming one by one that it's impossible, then backtrack. Same conclusion, different amounts of wasted effort.

Try it yourself. Pick items or skip past them. Watch the fuel gauge drain:

Pack exactly 4 items from a shelf of 7
🧢
👕
📖
🎧
🕶️
🎒
🧳0/4 packed · 7 left on shelf · need 4

The suitcase metaphor also captures something subtle: the further right you start on the shelf, the tighter the constraint becomes. If you start at item 1, you have 19 more items to choose from — plenty of budget. If you start at item 16, you only have 4 items left, and if you need 10, you're dead on arrival. The constraint gets tighter as you move forward, which means the pruning opportunities get richer toward the end of the array. This is why pruning matters most for the later starting positions — exactly where naive approaches waste the most time.

The optimization is a single line of code. One inequality that checks "are there enough remaining elements to reach k?" before the loop body executes. If the answer is no, return immediately. Don't push. Don't recurse. Don't pop. Just bail.

But I'm not going to tell you the condition. You're going to discover it yourself, by hitting the wall.

Below is a simplified combination builder. You'll pick elements from a pool, one at a time, trying to build combinations of length 3. The first couple of rounds will succeed — there are plenty of elements. But the third round starts from a position where the pool is almost exhausted. You'll feel the wall before you see the code.

Budget0 / 3
[]
Available (5 remaining, need 3 more)

That one-line check — if (nums.length - i < k - path.length) return — eliminates entire branches before a single recursive call. The condition reads: "If the number of elements from i to the end is less than the number of slots I still need to fill, this branch is impossible. Don't bother."

Watch the two quantities — capacity and demand — change as you go deeper. The moment demand overtakes capacity, the branch is dead:

n=5, k=3 · depth=0 · picked 0 elements
avail: 5
need: 3

Let's unpack the arithmetic with real numbers. Say nums = [1,2,3,4,5], k = 3, and your current path is [4]. That means path.length = 1, so k - path.length = 2 — you need 2 more elements. The loop variable i is at index 4 (pointing to element 5), so nums.length - i = 5 - 4 = 1 — one element left. Is 1 < 2? Yes. Prune. No recursive call needed.

Now rewind to a healthier branch. Path is [1], i is at index 1 (element 2). You need 3 - 1 = 2 more elements. Elements remaining: 5 - 1 = 4. Is 4 < 2? No. Continue exploring. This branch has room.

Step through each index position to see the condition evaluate in real time:

path = [4] · need 2 more · scanning from i=0
1
0
2
1
3
2
4
3
5
4
capacity: 5 - 0 = 5 >= demand: 2

The condition nums.length - i < k - path.length is doing one job: it's comparing capacity (how many elements remain in the array from position i onward) against demand (how many more elements the path needs). When demand exceeds capacity, the branch is provably empty. There's nothing to compute, nothing to explore, nothing to collect. It's a mathematical impossibility dressed up as a one-line if statement.

Here's what that pruning looks like on a larger tree. Toggle the button to see which branches survive and which ones die:

[][1][2][3][4][1,2][1,3][1,4][2,3][2,4][3,4]

The visual is striking: a single node[4] — loses its entire subtree. With a larger input like n = 20, k = 10, the pruned branches vastly outnumber the valid ones. The tree goes from a sprawling mess to a targeted exploration.

Here's the thing that makes this optimization especially satisfying: it's free. No extra data structures, no preprocessing, no space cost. It's a subtraction and a comparison. And it compounds — every pruned branch has its own subtree of recursive calls that now never happen. At n = 20, k = 10, the pruning version runs roughly 3x faster than the unpruned version. Same output, same correctness, one-third the work. All from a single if statement.

The savings grow dramatically with input size:

The deeper principle is this: combinations have a structural invariant that you can exploit. At any node in the recursion tree, you know two things: how many elements you still need (k - path.length) and how many elements are available (nums.length - i). When the second number is smaller than the first, the invariant is violated and the subtree is guaranteed to be empty. Detecting this violation early is what turns a correct-but-wasteful generator into an efficient one.

You'll see this pattern called “bound-based pruning” in algorithm textbooks, and it shows up everywhere in competitive programming. LC 77 (Combinations) is the canonical example, but the same principle applies to LC 216 (Combination Sum III), LC 40 (Combination Sum II), and any problem that asks “find all subsets/combinations of exactly size k.” The numbers change, the pruning condition doesn't.

The k-budget wall isn't a bug. It's an optimization opportunity that's always been there, hiding in the relationship between two numbers. Once you see it, you'll add this check to every combination function you write. It's the kind of optimization that costs nothing and saves everything.

In competitive programming, this optimization is so standard that experienced contestants add it without thinking — like putting a base case at the top of a recursive function. It's not a “trick.” It's hygiene. But in interviews, adding the pruning condition after writing the basic solution is a strong signal. It shows you understand the structure of the search space well enough to see where work is guaranteed to be wasted. That's a different skill from writing correct code — it's writing efficient correct code, and it's what separates “acceptable” solutions from “strong hire” solutions.

And this generalizes beyond combinations. Whenever a backtracking problem has a target size, a budget, or a quota — “pick exactly k items,” “find paths of length n,” “select teams of size m” — the same principle applies. Count what you need. Count what's available. If available is less than needed, prune. One line. Guaranteed savings.

Before we move to permutations, take a moment to appreciate what just happened. You felt the impossibility before you wrote the check. You hit the wall, understood why the branch was doomed, predicted the algorithm's correct behavior, and then encoded that understanding into a single inequality. That's the ideal path from intuition to implementation: feel it, name it, code it.

The Swap Trick

Combinations prune the tree from below — the k-budget kills branches that can't reach the target depth. Permutations have the opposite geometry: the tree is maximally wide at every level, and the challenge isn't reaching a target depth but preventing elements from appearing twice in the same path.

In Screen 1, you saw the standard approach: i = 0 with a used set. The loop starts at 0 because any unused element can go in any position. The used set prevents picking the same element twice. It works. It produces all n! arrangements. But there's a cost: every recursive call checks a hash set or boolean array. For small n that's negligible. For n = 10 — where there are 3.6 million permutations — every lookup adds up.

What if you could generate permutations without any auxiliary data structure at all? No used array, no hash set, no membership checks. Just the array itself, rearranged in place. Is that even possible?

The interaction below starts with a question about why i = 0 alone isn't enough, then shows you an alternative approach. Don't read ahead — the mechanism will make more sense when you see it happen.

Screen 1 showed that permutations use i = 0 — the loop reconsiders every element. But i = 0 alone isn't enough.

With i = 0 and NO used[] guard, what happens at the second recursion level?

Let's zoom out on what you just saw. The swap approach partitions the array into two zones: a fixed prefix (positions 0 to depth - 1, which are already placed) and an available suffix (positions depth to n - 1, which are still candidates). To “choose” an element for position depth, you swap it into that position from somewhere in the suffix. To “unchoose,” you swap it back.

Each level of recursion grows the fixed prefix by one element. Step through the depth slider below to watch the partition evolve:

ABCDAvailabledepth: 0
depth 0

At depth = 0, every element is available — nothing is fixed yet. At depth = 1, the first position is locked in and the remaining three elements can go in positions 1 through 3. At depth = 2, two positions are decided and only two candidates remain. By depth = 4, the entire array is one complete permutation.

This is the mechanism that makes swaps work: the boundary between FIXED and AVAILABLE is the depth parameter itself. Every element to the left of depth has been placed. Every element to the right is still a candidate. When you swap(arr, depth, i), you're pulling element i from the available zone into the first open position of the fixed zone. When you swap back, you return it to the pool. The boundary slides right on recurse, left on backtrack. No bookkeeping needed — the array encodes everything.

See it in miniature — one swap-and-restore cycle:

swap(arr, 0, 2) — A at depth, C is candidate
A
B
C
D

The beauty is that the partition is implicit. You never need to scan the array to find available elements — they're always sitting in positions depth through n - 1. You never need a used check — everything before depth is off-limits by construction. The array IS the state. Fixed and available are just two slices of the same data.

Two approaches to permutations, both producing exactly n! outputs:

Used-set approach: for (let i = 0; i < n; i++) with if (used.has(nums[i])) continue. The loop considers every element but filters through a set. The guard is O(1) amortized per check, but you need O(n) extra space for the set, and the constant factors of hash operations add up over millions of recursive calls.

Swap approach: for (let i = depth; i < n; i++) with swap(arr, depth, i). The loop only considers elements in the available suffix — everything before depth is already fixed. No used check needed. No extra space. Each “choose” is a single swap (two writes). Each “unchoose” is another swap (two writes). The cost per decision is O(1) with tiny constants.

1
// Used-set: O(n) space, hash lookups per candidate
2
for (let i = 0; i < nums.length; i++) {
3
  if (used.has(nums[i])) continue  // membership check
4
  used.add(nums[i])
5
  path.push(nums[i])
6
  permute(nums, used, path, result)
7
  path.pop()
8
  used.delete(nums[i])
9
}
10
11
// Swap: O(1) space, array writes per candidate
12
for (let i = depth; i < arr.length; i++) {
13
  swap(arr, depth, i)           // choose: fix arr[depth]
14
  permute(arr, depth + 1, result) // recurse on suffix
15
  swap(arr, depth, i)           // unchoose: restore
16
}

Both are correct. Both produce n! outputs. The swap approach is cleaner when you can modify the array in place (most interview problems). The used-set approach is safer when the array is read-only or when you need to handle duplicates (swap-based dedup is trickier).

The key insight isn't that one approach is better — it's that the permutation problem has a structural property (a fixed/available partition) that the swap approach exploits. Understanding this property means you can choose the right tool for the problem: used-set for read-only or duplicate-heavy inputs, swap for in-place generation.

There's an analogy that might help cement this. Think of a row of people standing in a line. The used-set approach is like asking each person “have you already been called?” — you maintain a clipboard with names checked off. The swap approach is like saying “everyone who's been called, step to the left.” There's no clipboard — the line itself encodes who's been called and who hasn't. Left side = placed. Right side = available. When you want to place someone new, you walk to the right side and swap them to the boundary. The boundary moves one step right. No bookkeeping, no scanning — the physical arrangement IS the state.

Step through the two approaches side by side — same decision, different bookkeeping:

Used-set (clipboard)
A
B
C
check set: O(1) each
Swap (line)
A
B
C
no lookup needed

The line analogy reveals something else: why the swap approach handles the depth = depth identity swap. When i === depth, the element already sitting at position depth is “swapped with itself” — it stays put. This isn't a special case. It's the natural behavior: the first person on the right side of the line is already at the boundary, so “swapping them in” means doing nothing. The identity swap costs two writes (both writing the same value), but it keeps the loop structure uniform. No if statement needed.

depth=1, i=1 — swap(arr, 1, 1)
A
B
C
D

This is worth pausing on because it's a common source of confusion. Students often ask "why does the loop start at depth instead of depth + 1?" The answer: because the element at position depth is a valid candidate too. It hasn't been placed yet — it's in the available zone. Skipping it would miss permutations where that element happens to already be in the right spot.

This is why swap-based permutation is a favorite in systems programming and competitive programming: it has the best constant factors and zero auxiliary space. But it has a downside — handling duplicates with swaps is significantly harder than with a used-set, because you need to check for duplicate values in the available suffix before swapping. That check can degrade to O(n) per level if you're not careful, which erases the constant-factor advantage.

In interviews, the swap approach comes up most often on LC 46 (Permutations) and LC 31 (Next Permutation, which uses a related swap-based idea). For LC 47 (Permutations II — with duplicates), most interviewers expect the used-set approach because swap-based dedup requires either sorting the suffix at each level or maintaining a local hash set, both of which complicate the code without clear benefit.

There's a phrase that captures this distinction nicely: swap to place, set to filter. Swapping places an element into the right position directly. A used set filters candidates from a pool. Both solve the same problem — preventing repeats — but they do it at different levels of abstraction. Swapping works at the array level. The set works at the value level. Knowing both means you can pick the one that fits the constraints of the problem you're solving.

And all three loop variants — i = 0, i = start, i = depth — are just different windows into the same array. Tap each to see which indices the loop considers:

At recursion level start=2, which indices does the loop visit?
1
i=0
2
i=1
3
i=2
4
i=3
5
i=4
for (i = 0; i < n; i++)
Permutations: consider every element

Next: the bug that trips everyone. Two characters, silently wrong output, and it passes small test cases.

The Scope Check

There's one bug in backtracking that I've seen more than any other. More than wrong loop inits, more than missing base cases, more than off-by-one errors in the recursive argument. It's subtle enough that it passes most test cases. Run a subsetsWithDup on [1, 2, 3] — perfect output. Run it on [1, 1, 2] and the result count is close enough that you might not even notice. But the output is silently wrong.

The bug hides in plain sight because it looks almost right. The line if (i > 0 && nums[i] === nums[i - 1]) continue reads naturally: “if this element is a duplicate, skip it.” That sounds correct. The problem is that “duplicate” means two different things depending on context, and the code conflates them. There are sibling duplicates (two candidates at the same recursion level with the same value) and ancestor duplicates (a candidate with the same value as something chosen at a higher level). The skip should only apply to siblings. The bug applies it to both.

Below is a buggy subsetsWithDup implementation. Your job: trace through it on [1, 1, 2], watch valid subsets disappear, find the exact buggy line, and predict what the fix changes.

Subsets II with duplicate handling
Trace on sorted [1, 1, 2]
idx
1
1
2
Call subsetsWithDup([1,1,2], idx=0). Collect [].

Now you've seen the bug destroy output. Let's crystallize what happened.

The condition i > 0 && nums[i] === nums[i - 1] says “if this element has the same value as the previous one, skip it.” But i > 0 doesn't check “am I the first candidate at this recursion level” — it checks “am I the first element in the array.” Those are completely different questions. At idx = 1, the first candidate is nums[1], not nums[0]. The skip should only trigger when there's a sibling with the same value that already explored this subtree — and that means i > idx (or i > start), not i > 0.

Side by side, the difference is stark:

The i > 0 row is missing [1,1] and [1,1,2]. Those are perfectly valid subsets — the input has two 1s, so picking both is legal. But the buggy skip condition saw the second 1 at position 1, noticed it matched position 0, and skipped it. It didn't matter that we were at recursion level idx = 1, where position 1 IS the first candidate. The i > 0 check doesn't know about recursion levels. It only knows about array indices.

The i > start row gets it right. At recursion level idx = 1, the check i > 1 fails because i IS 1. The second 1 is not skipped — it's the first candidate at this level, and it deserves a chance to generate its subtree. At recursion level idx = 0, if we reach i = 1 and nums[1] === nums[0], then i > 0 is true and the skip fires — correctly, because a sibling (index 0) with the same value already explored this subtree.

That's what I call the scope check: i > idx checks whether the duplicate skip should apply at the current scope (recursion level) or whether it's falsely triggering across scopes. i > 0 has no concept of scope — it's a global position check. i > idx is scope-relative.

Two rules. Always together. Never skip one.

Rule 1: Sort the input. The nums[i] === nums[i - 1] check only detects duplicates that are adjacent. If the input is [2, 1, 2], the two 2s are separated by 1 — the skip condition never fires, and both 2s produce identical subtrees. Sorting brings duplicates together: [1, 2, 2]. Now the skip condition works.

Toggle the sort to see the difference:

[2, 1, 2] — duplicates separated by 1
2
[0]
1
[1]
2
[2]
nums[2] === nums[0] but i-1 is index 1 → skip MISSES the duplicate

Rule 2: Use i > start, not i > 0. The anchor must be the starting index of the current recursion level, not the global position zero. This ensures the skip only applies to siblings — elements competing for the same slot at the same depth — and never to the first candidate at a new level.

Watch the same candidate get different verdicts depending on the anchor:

Recursion level idx=1 · nums = [1, 1, 2] · nums[1]===nums[0]
i=1val=1SKIP
i=2val=2KEEP
i > 0 → 1 > 0 → true → SKIPS first candidate (wrong!)

These two rules interact. Sorting is a prerequisite for the skip condition to work at all — it makes duplicates adjacent. The scope-relative anchor (i > start) is a prerequisite for the skip condition to be correct — it prevents false positives at level boundaries. Remove either rule and the output breaks silently.

Let's trace through the fixed version step by step to see how the two rules cooperate. Input: [1, 1, 2] (already sorted). Call backtrack(nums, 0, [], result).

At idx = 0: collect []. Loop i = 0, 1, 2.

  • i = 0: i > 0? No. Pick 1. Recurse with idx = 1.
    • At idx = 1: collect [1]. Loop i = 1, 2.
      • i = 1: i > 1? No. Pick 1. Recurse with idx = 2. Collect [1,1].
        • i = 2: Pick 2. Collect [1,1,2].
      • i = 2: Pick 2. Collect [1,2].
  • i = 1: i > 0? Yes. nums[1] === nums[0]? Yes (1 === 1). Skip.
  • i = 2: Pick 2. Recurse. Collect [2].

Result: [[], [1], [1,1], [1,1,2], [1,2], [2]] — all 6 subsets, no duplicates, no omissions. The skip at i = 1, idx = 0 is correct: index 0 already explored the 1-subtree at this level. The non-skip at i = 1, idx = 1 is also correct: index 1 is the first candidate at recursion level 1.

1
// The complete dedup recipe:
2
nums.sort((a, b) => a - b)  // Rule 1: make duplicates adjacent
3
4
function backtrack(nums, idx, path, result) {
5
  result.push([...path])
6
  for (let i = idx; i < nums.length; i++) {
7
    // Rule 2: scope-relative skip
8
    if (i > idx && nums[i] === nums[i - 1]) continue
9
    path.push(nums[i])
10
    backtrack(nums, i + 1, path, result)
11
    path.pop()
12
  }
13
}

Here's a way to remember it that's helped me in interviews. The skip condition asks two questions: (1) "Are we looking at a duplicate?" — that's nums[i] === nums[i - 1]. (2) "Is this the first chance to pick this value at this level?" — that's i > start. Both must be true to skip. If it's the first chance (i === start), we pick the duplicate even though it equals the previous element, because no sibling has explored this subtree yet.

The i > 0 version only asks question (1). It never asks question (2). That's why it's wrong: it assumes every duplicate should be skipped, regardless of context. But context — which recursion level you're in, whether a sibling has already explored this value — is everything.

Here's another way to visualize it. Imagine a meeting room with a round table. Each recursion level is a different table. At each table, several candidates sit down (the elements from i = start to i = nums.length - 1). The duplicate skip says: “if you have the same name as the person who sat down before you at THIS table, don't sit — your predecessor already covered your case.” That's i > start. The buggy version says: “if you have the same name as anyone who ever sat down at ANY table, don't sit.” That's i > 0. The bug conflates siblings (same table) with ancestors (different tables).

nums = [1, 1, 2] — who gets skipped at each table?
Table 0 (idx=0)
1i=0
1i=1
2i=2
Table 1 (idx=1)
1i=1
2i=2
i > start: skip only if a SIBLING with same value already sat down

This distinction — siblings vs. ancestors — is the key to every duplicate handling strategy in backtracking. Siblings are elements at the same recursion level competing for the same slot. Ancestors are elements at earlier levels that have already been chosen. Duplicate skipping should only apply to siblings, because two siblings with the same value produce identical subtrees. An ancestor with the same value as a sibling is a different story entirely — it was chosen at a different level, and the subtree below it is structurally different.

Toggle between the two groups on the actual recursion tree:

subsetsWithDup([1,1,2]) — focus: second [1] at level 1
[][1][1]focus[2][1,1][1,2]
Siblings: same level, competing for same slot — skip duplicates here

This applies identically to Subsets II (LC 90), Combinations II (LC 40), Permutations II (LC 47), and any other “with duplicates” variant. The underlying mechanism is always the same: sort, then scope-check. The specific variable name changes (idx, start, begin), but the pattern is invariant. Once you internalize “sort + scope-relative skip,” you can handle every duplicate-elimination problem on LeetCode without memorizing individual solutions.

And there's a meta-lesson here about debugging. The i > 0 bug is particularly dangerous because it produces almost correct output. On distinct inputs like [1, 2, 3], it's perfectly correct — there are no duplicates to skip. On inputs with duplicates far apart like [1, 2, 3, 1] (unsorted), the sort makes it [1, 1, 2, 3], and the bug's effect is subtle: one missing subset out of many. This is the class of bug that passes 90% of test cases and fails on the 10% you didn't think to write. The antidote is to always test with small duplicate-heavy inputs like [1, 1, 2] where missing subsets are obvious.

Four Modifications, One Template

Step back. Over the last three sections you've gone deep into the internal mechanics of each variant: the k-budget prune that kills dead branches, the swap trick that eliminates the used-set, and the scope check that prevents false duplicate skips. Each of these is a modification to the same backtracking template — and each addresses a different aspect of the template's behavior.

But here's what's easy to miss when you're deep in the weeds of one variant at a time: these modifications are independent. The k-budget prune doesn't interfere with the duplicate skip. The swap trick doesn't conflict with the collection point. You can mix and match them depending on the problem's constraints.

Think of the template as a base recipe — flour, water, yeast, salt — and the four modifications as ingredient swaps. You can add chocolate chips without changing the rising time. You can substitute whole wheat flour without affecting the topping. Each modification targets one axis of the recipe, and changing one axis doesn't disturb the others. That's what makes the template composable: the modifications are orthogonal.

Before we look at how these modifications compose, notice something about subset sizes. How many subsets of size 0 exist? Size 1? Size k? The distribution forms a symmetric curve — and the total always sums to 2^n:

How many subsets of each size?total: 32

C(5,0) + C(5,1) + ... + C(5,5) = 2^5 = 32

Combination Sum with duplicates? You need the k-budget prune AND the scope-relative duplicate skip. Permutations of distinct elements? You need the swap trick (or used-set) but no dedup and no k-budget. Subsets II? You need the scope check but not the budget prune or the swap. Each problem selects its modifications from the same menu.

The template below shows all four modification points. Think of each bracketed section as a slot that can be activated or left empty depending on the problem. No problem needs all four — but every problem with duplicates needs [B], every problem with a target size needs [C], and every problem statement tells you which slots to fill.

This is what makes the backtracking template so powerful in interviews. You don't need to memorize seven different algorithms. You need to memorize one template and four modification slots. When an interviewer says “generate all combinations of size k with duplicates,” you don't think “oh, that's Combinations II, let me recall the specific code.” You think: “that's the standard template with the k-budget prune active and the scope-relative duplicate skip active.” Two slots filled, two left empty. The template does the rest.

And here's the interview-day payoff: if you can articulate which slots are active and why, you've demonstrated deeper understanding than someone who memorized the solution. Interviewers can tell the difference between “I recognize this problem type and I'll write the code I memorized” and “I understand the structural properties that determine which modifications are needed.” The second answer earns the hire signal.

Explore each modification below. Tap the labels to highlight the corresponding line in the code and read what it controls. You need to visit all four before continuing.

Four modifications turn the same template into different algorithms. Tap each to see what it controls.
Explore all four modifications
1
function backtrack(nums: number[], start: number,
2
                   path: number[], result: number[][]) {
3
  // [A] Collection point
4
  result.push([...path])
5
6
  // [C] K-budget prune (combinations only)
7
  if (path.length === k) return
8
9
  for (let i = start; i < nums.length; i++) {
10
    // [B] Duplicate skip (combos/subsets with dups)
11
    if (i > start && nums[i] === nums[i - 1]) continue
12
13
    path.push(nums[i])
14
    // [D] Recursive argument
15
    backtrack(nums, i + 1, path, result)
16
    path.pop()
17
  }
18
}

0/4 sections explored

Four slots in one template. Four modifications that you can combine freely. The beauty of the backtracking template isn't that it solves one type of problem — it's that it's configurable. The loop init is the primary dial (subsets vs. combinations vs. permutations). The four modifications are secondary dials that handle edge cases and optimizations. Together, they cover every “generate all X” problem you'll encounter on LeetCode.

To crystallize this, try flipping the switches below. Four independent toggles, each controlling one aspect of the template. Watch how the combination of active switches determines which algorithm you get:

Produces
Subsets (LC 78)
No modifications active — base template

Start with everything off — that's plain Subsets (LC 78). Turn on just [B] — now it's Subsets II. Turn on [A] and [C] — Combinations. Add [D] for element reuse — Combination Sum. Every toggle changes one axis of behavior without affecting the others.

This is the mental model that scales. Instead of memorizing seven separate solutions, you're learning four binary switches. The number of combinations is 2^4 = 16, but only about 7 of those correspond to real LeetCode problems. The rest are either redundant (k-budget with no collection point makes no sense) or exotic (element reuse with no target length is unusual). But the framework handles them all.

Let's name the full configuration space:

  • Subsets (distinct): Collection at every node. i = idx. No guards.
  • Subsets II (with dups): Sort + scope check [B]. Everything else same.
  • Combinations: K-budget prune [C]. i = start. Collect at depth k.
  • Combinations II: Add scope check [B] to Combinations.
  • Combination Sum: recurse(i) instead of recurse(i + 1) for reuse.
  • Permutations: i = 0 + used-set, or swap approach. Collect at depth n.
  • Permutations II: Sort + scope check for swap approach. Or used-set with count map.

Seven problems. Four modification points. One template. That's the deep structure behind the backtracking pattern.

Here's a practical tip for interview day: when you encounter a new “generate all X” problem, don't try to recognize which of the seven problems it matches. Instead, ask four questions:

  1. When do I collect? Every node (subsets), at target depth (combinations), at full length (permutations)?
  2. Can an element repeat in a single path? Yes → recurse(i). No → recurse(i + 1).
  3. Does the input have duplicates? Yes → sort + scope check.
  4. Is there a target size? Yes → k-budget prune.

Answer the four questions yourself and watch the template resolve:

[A]1. When do I collect?
[D]2. Can elements repeat?
[B]3. Input has duplicates?
[C]4. Target size?

And here's the full configuration space — every problem mapped to its active slots. Tap a row to focus it:

Problem[A][B][C][D]

Four questions, four answers, four slots filled. The template assembles itself.

Three Flavors, One Recipe

You've moved past the dial. The dial — i = idx, i = start, i = 0 — was the topic of Screen 1. It told you WHICH variant to use. This screen went deeper: HOW each variant works internally, and what happens when the internal mechanics are misconfigured.

You felt the k-budget wall: the moment where a combination branch can't reach the target length and exploring further is pure waste. You wrote the one-line condition that detects this impossibility — nums.length - i < k - path.length — and saw it prune entire subtrees.

You watched the swap trick: elements trading positions in the array itself, creating a fixed prefix and an available suffix. No used-set, no membership checks. The array IS the state — swap(arr, depth, i) is the choose step, and swap(arr, depth, i) again is the unchoose. Two writes per decision.

And you traced through the most common backtracking bug: i > 0 instead of i > start. You watched [1, 1] vanish from the output of a subsets function — a valid subset, silently dropped because the skip condition checked global position instead of recursion-level scope. Two characters. Missing output. Passes small tests.

These aren't three separate techniques. They're three modifications to the same template, and they compose freely. The k-budget prune works with or without the duplicate skip. The swap trick works independently of the collection point. Mix and match based on what the problem demands.

Below is your scorecard: what you predicted, what you built, what you traced. Every number is real — pulled from the interactions you just completed.

Three Flavors, One Recipe

K-Budget Prune
if (n - i < k - path.length) returnKills branches that can never reach target length
Swap Permute
swap(arr, depth, i)O(1) per decision — no used[] set needed
Scope Check
if (i > start && nums[i] === nums[i-1])i > start, not i > 0 — skip siblings, not first-candidates
K-Budget Pruning0/0 predictions, 0/0 blanks
Swap Permutations0/0 predictions, 0/0 blanks
Duplicate Bugmissed the bug0/0 predictions
Code Bridgeincomplete

You watched the algorithm explore a branch that could never reach k=3. That wasted work is exactly what n - i < k - path.length eliminates. One line, entire subtrees gone.

The swap approach replaces the used[] set with a structural partition: everything before depth is fixed, everything after is available. Same n! output, cleaner implementation.

The duplicate skip needs TWO things working together: sort() to make duplicates adjacent, then i > start (not `i > 0`) to skip only among siblings. Miss either one and the output breaks silently.

The Four Modifications
Collection pointresult.push([...path])
Every node (subsets) vs depth-k only (combos) vs path-full (perms)
Loop initi = start | i = 0 | i = depth
Forward-only, full access, or swap-partition
Dedup guardi > start && nums[i] === nums[i-1]
Scope-relative, requires sorted input
Early terminationif (n - i < k - path.length)
Prune impossible branches before exploring them