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:
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:
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.
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:
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:
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:
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.
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.
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:
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:
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.
// Used-set: O(n) space, hash lookups per candidatefor (let i = 0; i < nums.length; i++) { if (used.has(nums[i])) continue // membership check used.add(nums[i]) path.push(nums[i]) permute(nums, used, path, result) path.pop() used.delete(nums[i])}// Swap: O(1) space, array writes per candidatefor (let i = depth; i < arr.length; i++) { swap(arr, depth, i) // choose: fix arr[depth] permute(arr, depth + 1, result) // recurse on suffix swap(arr, depth, i) // unchoose: restore}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:
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.
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:
Next: the bug that trips everyone. Two characters, silently wrong output, and it passes small test cases.
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.
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:
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:
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.
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.
// The complete dedup recipe:nums.sort((a, b) => a - b) // Rule 1: make duplicates adjacentfunction backtrack(nums, idx, path, result) { result.push([...path]) for (let i = idx; i < nums.length; i++) { // Rule 2: scope-relative skip if (i > idx && nums[i] === nums[i - 1]) continue path.push(nums[i]) backtrack(nums, i + 1, path, result) path.pop() }}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).
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:
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.
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:
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.
function backtrack(nums: number[], start: number, path: number[], result: number[][]) { // [A] Collection point result.push([...path]) // [C] K-budget prune (combinations only) if (path.length === k) return for (let i = start; i < nums.length; i++) { // [B] Duplicate skip (combos/subsets with dups) if (i > start && nums[i] === nums[i - 1]) continue path.push(nums[i]) // [D] Recursive argument backtrack(nums, i + 1, path, result) path.pop() }}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:
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:
i = idx. No guards.[B]. Everything else same.[C]. i = start. Collect at depth k.[B] to Combinations.recurse(i) instead of recurse(i + 1) for reuse.i = 0 + used-set, or swap approach. Collect at depth n.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:
recurse(i). No → recurse(i + 1).Answer the four questions yourself and watch the template resolve:
And here's the full configuration space — every problem mapped to its active slots. Tap a row to focus it:
Four questions, four answers, four slots filled. The template assembles itself.
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
if (n - i < k - path.length) returnKills branches that can never reach target lengthswap(arr, depth, i)O(1) per decision — no used[] set neededif (i > start && nums[i] === nums[i-1])i > start, not i > 0 — skip siblings, not first-candidatesYou 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.
result.push([...path])i = start | i = 0 | i = depthi > start && nums[i] === nums[i-1]if (n - i < k - path.length)