I want to show you something that messed with my head for longer than I'd
like to admit. It's a single function — seven lines of code — that can
generate subsets, combinations, or permutations depending on the value of
one expression. Not three functions. One. The same push, the same recursive
call, the same pop. The skeleton is identical across all three algorithms.
When I first learned backtracking, I thought these were three separate techniques with three separate templates. I wrote them out on three separate pages of notes. It wasn't until I put them side by side and stared at the diff that I realized: there is no diff. Or rather, the diff is a single expression. Everything else is shared.
If you've worked through the backtracking module, you already know the template. Here it is one more time, stripped to its bones:
function backtrack(nums, idx, path, result) { result.push([...path]) // collect current path for (let i = ???; i < nums.length; i++) { // <-- THE DIAL path.push(nums[i]) // choose backtrack(nums, i + 1, path, result) // explore path.pop() // unchoose }}Walk through it line by line. result.push([...path]) snapshots whatever
path we've built so far — empty, partial, or full. This is the collection
point: it runs at every node of the recursion tree, capturing the path
before we go deeper. Then the for loop iterates over some range of
candidates, and for each one we do the classic choose-recurse-unchoose
dance: push an element onto the path, recurse to explore everything
reachable from here, then pop it off when we come back.
Every line is doing exactly the same job across all three algorithms. The
push doesn't change. The pop doesn't change. The recursive call doesn't
change (well, almost — we'll get to that). The only thing that differs is
the loop initialization: for (let i = ???. That ??? is doing more work
than any other expression in the function.
Think about what that means. You have a five-line loop body that's
identical across subsets, combinations, and permutations. The entire
behavioral difference — a binary tree vs. a fan-out tree, 2^n outputs vs.
n! outputs, clean results vs. a mess of duplicates — lives in the starting
value of a single loop variable. It's like having three radios that share
the same antenna, the same speaker, the same power supply — and the only
difference is which frequency the tuner is set to. Same hardware. One dial.
Three completely different stations.
Twist the dial yourself — tap a frequency to see what each position means:
Tap each silhouette below to see the shape of the tree each dial position produces. Same template, three radically different structures:
Change ??? from one value to another and the function pivots from
generating every subset of [1, 2, 3] to generating every arrangement of
those same three elements. Same machine, different dial position, completely
different output.
But don't take my word for it. Below is that same function with the loop
initializer blanked out. You have four options: idx, 0, idx + 1, and
start. Three of them produce broken or surprising output for subsets. Only
one is correct. Pick one and watch what happens to the recursion tree. If you
pick wrong, good — the failure is the lesson.
Fill in the loop initializer.
So: i = idx means “start from where I am and move forward.” Each recursive
call only considers elements after the current one — that's what prevents
[2, 1] from appearing alongside [1, 2]. Once you've passed an element,
you never look back. The result is a clean forward-only traversal where every
element gets exactly one chance to be included or excluded.
Compare that with i = 0, which reopens the entire array at every level of
recursion. Elements that are already in the path can be picked again.
Elements that appeared earlier in the array — elements you've already made a
decision about — are suddenly back on the table. You saw the tree: it fills
with [1, 1], [2, 1], and paths that should never exist in a subsets
function.
Here's the code side by side, because the difference really is just two characters:
// Subsets: forward only — each call starts past the current elementfor (let i = idx; i < nums.length; i++)// Broken attempt: everything is back on the table, including repeatsfor (let i = 0; i < nums.length; i++) // no guard!If you picked i = 0 and watched the tree explode with duplicates —
congratulations, you just experienced the single most common backtracking
bug. Starting at 0 without tracking used elements means nums[0] is a
candidate at every single level of recursion. The tree grows exponentially
wider than it should, filled with paths that duplicate each other or
reference the same element twice.
The reason this bug is so common is that i = 0 feels correct. You're
iterating over an array — why wouldn't you start at index 0? In any other
context, starting at 0 is the default. But backtracking isn't “any other
context.” The loop init isn't just a starting index — it's a policy about
which elements the current call is allowed to consider. Starting at idx
says “I've already handled everything before me.” Starting at 0 says “let's
pretend nothing has happened yet.”
Tap through the depth levels to see the difference in real time:
This matters more than it might seem. The loop init doesn't just control
which elements appear — it controls how many elements appear at each
level of the recursion tree. i = idx gives each node roughly n - depth
candidates, and since the recursive call passes i + 1, the tree narrows
as you go deeper. i = 0 gives each node access to all n elements (minus
any that are guarded out), which means the tree stays wide at every level.
The tree shape — and therefore the algorithm's time complexity — is a
direct consequence of that one expression.
But here's the thing worth remembering: i = 0 isn't wrong in an
absolute sense. It's wrong for subsets. For permutations, starting at 0 is
exactly what you want — you just need a used set to prevent
self-repetition. The init value isn't a bug or a fix. It's a dial. Turn
it one way and you get subsets. Turn it another way and you get permutations.
The template doesn't care which algorithm you're running. It just follows the
loop.
Watch the pointer slide as you switch policies — same array, different starting position, completely different candidates:
Subsets — forward only from here
One expression, three algorithms. Let's wire all three.
You've seen what happens when you turn that dial the wrong way. Now let's see what happens when you turn it right — three times, for three different algorithms.
The template stays the same every round: push, recurse, pop. The only
things that change are the loop initialization and, sometimes, a guard
condition or a depth check. But those tiny changes produce radically
different recursion trees. The question is: how different? Does the tree
get wider? Shorter? Does it prune early, or does it fan out until every
possible arrangement has its own leaf?
All three algorithms answer a different version of the same fundamental question: which elements are available at each recursive call? The answer to that question — the “visibility rule” — completely determines the tree's shape, the output's size, and the function's complexity. Think of the recursive call as a flashlight sweeping across the array. The loop init controls where the beam starts. Some configurations point the flashlight forward only — you can never illuminate what's behind you. Others sweep the entire array, relying on a guest list to filter out elements that are already in the path.
Toggle between the two modes to see the flashlight in action:
Elements [0] and [1] are behind the beam — permanently invisible at this depth.
Here are the three visibility rules, without spoiling which tree each one produces:
k items." Same forward-only traversal, but with a ceiling. Once the path
reaches a target length, the branch is done — no point going deeper.used set that prevents picking
the same element twice in one path. Every element that isn't already in
the current path is a candidate — regardless of its position in the array.Here's what each rule looks like on a concrete array — notice how many candidates each one lets through:
Think of it like a valve on a pipe. Same plumbing, same water pressure. But one valve position gives you a steady flow, another restricts the throughput with an early shutoff, and the third opens the floodgates. Same pipe, three throughput profiles. The water (the template) hasn't changed. Only the valve (the loop init) has.
Each of these rules translates directly into a different loop line. Here they are as code, without labels — see if you can already guess which algorithm each one produces:
// Variant Afor (let i = idx; i < nums.length; i++)// Variant Bfor (let i = start; i < nums.length; i++) // + base case: path.length === k// Variant Cfor (let i = 0; i < nums.length; i++) { if (!used.has(nums[i])) ... }Notice the progression. Variant A restricts the starting index — it draws a
boundary and says “nothing behind this line.” Variant B adds a depth
constraint on top of the same restriction — same boundary, plus a ceiling.
Variant C throws out the starting-index restriction entirely and replaces it
with an explicit membership check — no boundary, but a bouncer at the door
who checks your name against a list.
These three approaches aren't arbitrary. They represent three fundamentally different policies for managing element visibility during recursion. And each policy produces a characteristic tree shape that you'll learn to recognize on sight — the way a musician can identify a chord from its voicing without needing to name each note.
Three loop lines. Three variants. Your job below: fill in the blanks for each one, then predict the tree shape before it reveals. Pay attention to the branching factor at each level — it's the visual fingerprint that distinguishes the three algorithms.
Same skeleton. Three different loop configs. Three radically different trees. Now that you've seen all three grow, let's name what happened.
The subsets tree branched binary: at each node, one element was either
included in the path or not. i = idx paired with recurse(i + 1) meant
every element got exactly one chance — include or skip — and the tree grew
to 2^n leaves. Every root-to-leaf path corresponded to one subset. The
tree was symmetric and predictable: two children at every internal node,
the left child including the current element, the right child skipping it.
2^n (every possible include/exclude combination).The combinations tree was narrower. i = start enforced the same
forward-only rule, but the path.length === k check killed branches before
they could grow past depth k. Think of it as the subsets tree with a
horizontal line drawn across it at level k — everything below that line is
pruned away. The tree starts the same way, but branches die young. Fewer
branches survive to become leaves, and only paths of exactly length k
get collected.
C(n, k) (only paths of exactly length k survive).The permutations tree exploded. i = 0 meant every level reconsidered
every element in the array. The used set prevented self-repetition within
a single path, but each node still had n - depth children — at the root
you pick from n elements, then n - 1, then n - 2, all the way down.
The result: n! leaves, one for every possible ordering. If you compared
this tree's width to the subsets tree, the difference was dramatic: the
permutations tree was wider at every level, and the gap grew with depth.
n! (every possible arrangement).The deeper insight isn't about memorizing three init values. It's that the
loop initializer encodes a visibility rule — a contract about which
elements each recursive call is allowed to see. i = idx says “forward
only.” i = start says “forward only, with a ceiling.” i = 0 says
“everything, but check the guest list.” That visibility rule, combined with
any guards or base cases, completely determines the tree shape.
And the tree shape is the complexity. This is why subsets is O(2^n),
combinations is O(C(n, k)), and permutations is O(n!). The branching
factor at each level of the recursion tree IS the time complexity. You don't
need to derive it mathematically — you can see it in the tree. Two
branches per node? 2^n. Decreasing fan-out? n!. To put real numbers on
it: at n = 10, subsets produces 1,024 leaves. Permutations produces
3,628,800. That's three orders of magnitude more work, and the only
difference is idx vs. 0 in the loop init.
Here's what the branching factor looks like at each level for n = 3 — watch
how the trees grow at completely different rates:
Drag the slider to watch the gap widen. By n = 15 it is absurd:
// The loop init determines the branching factor:i = idx → 2 branches/node → 2^n leaves → O(2^n)i = start → pruned at k → C(n,k) leaves → O(C(n,k))i = 0 → n-depth branches → n! leaves → O(n!)Three loop lines. Three visibility rules. Three tree shapes. Three complexities. And it all traces back to one expression.
Wiring the loop correctly when you know which algorithm you want is one
skill. Spotting a mis-wired loop in someone else's code — where the function
name says “permutations” but the output tells a different story — is a
completely different skill. And in practice, it's the one that matters more.
Here's why. When you're writing a backtracking function from scratch, you
have the problem statement in front of you. You can reason from the
constraints to the correct loop config. But when you're debugging someone
else's code — or reviewing your own code three weeks later — all you have is
the function. The problem statement is a distant memory. The function name
says permutations, and your job is to decide whether the implementation
actually produces permutations or something else entirely.
In code reviews, in debugging sessions, in interviews where the interviewer
hands you a broken implementation and asks “what's wrong?” — you need to
read a backtracking function and immediately recognize whether the loop is
configured for the right variant. You can't run the code in your head
line-by-line (that takes too long for any input larger than n = 3). You
need to look at the loop line and know what tree shape it implies, then
check whether that shape matches the function's stated purpose.
Look at the skeleton below. Every line is doing its job — except one. The loop init is where variants live, and it's where bugs hide:
The three bugs below are the three most common backtracking misconfigurations I've seen — in LeetCode solutions, in interview submissions, and in production code. Each one is subtle. Each one produces some output. The function doesn't crash, doesn't loop forever, doesn't throw an error. It just quietly produces the wrong results. The kind of bug that passes small test cases and fails on edge cases or larger inputs.
Here's what makes these bugs especially tricky: they're all one-expression
errors. The rest of the function is correct. The choose-recurse-unchoose
pattern is right. The base case is right. The collection point is in a
reasonable place. It's just the loop configuration that's off — and because
the loop line is so short (maybe 40 characters), it's easy to glance right
past it during a code review.
Three snippets below. Each has a subtle misconfiguration: a loop init that doesn't match the intended algorithm, a missing guard, or a collection point in the wrong place. Read the code carefully, predict what breaks, then see if your diagnosis was right.
// Intended: permutations
for (let i = start; i < nums.length; i++) {
if (!used.has(nums[i])) { ... }
}The loop starts at i = start instead of i = 0. What goes wrong?
Three bugs, three patterns. Let's name each one so you can spot them on sight in the future.
Bug 1: Wrong visibility window. The permutations snippet used
i = start instead of i = 0. That locked out every element before
start — they could never appear later in the path, no matter what.
Entire permutations vanished from the output. The function ran fine.
It produced some correct permutations. It just silently dropped every
arrangement that would have needed an earlier element in a later position.
// BUG: i = start locks out earlier elementsfor (let i = start; i < nums.length; i++)// FIX: permutations need full accessfor (let i = 0; i < nums.length; i++)This is the most dangerous category of backtracking bug because the output looks reasonable. You get valid permutations — just not all of them. If your test input is small enough, you might not even notice the missing ones.
Bug 2: Missing duplicate guard. The combinations snippet processed
[1, 2, 2, 3] without skipping duplicate values at the same recursion
level. With two 2s in the input, there were two ways to build [1, 2] —
one using the first 2 (index 1) and one using the second 2 (index 2).
Same combination, counted twice. The classic fix is the i > start
duplicate skip:
// The one-line guard that prevents same-level duplicates:if (i > start && nums[i] === nums[i - 1]) continueThat single continue statement eliminates an entire class of duplicate
outputs. It says: “if this element has the same value as the previous one at
this recursion level, skip it — the previous sibling already explored this
subtree.” Note the condition carefully: i > start, not i > 0. The
start anchor ensures the guard only applies to siblings at the current
level, not across different levels. This is one of the most important lines
in backtracking, and one of the most frequently forgotten.
Bug 3: Wrong collection point. The subsets snippet collected results
only at leaf nodes (idx === nums.length), which threw away every partial
subset. This one is especially sneaky because it looks like a perfectly
reasonable base case. Moving result.push inside a leaf-only check doesn't
crash or loop — the function runs fine and produces some output. It just
quietly drops everything that isn't a full-length path. For [1, 2, 3],
you'd get [1, 2, 3] but lose [1, 2], [1], [2, 3], and every other
partial subset — roughly 90% of the correct output.
// BUG: collects only at leaves (full-length subsets)if (idx === nums.length) { result.push([...path]) }// FIX: collect at EVERY node — empty, partial, and fullresult.push([...path]) // unconditional, before the loopThe pattern across all three bugs is the same: the function name is a
lie. A function called permutations that uses i = start isn't
generating permutations — it's generating a weird forward-only subset of
them. A function called subsets that only collects at leaves is generating
something closer to powerset-of-full-length — which is just a single
combination. The function name tells you what the author intended. The
loop line tells you what the code actually does. When those two disagree,
trust the loop line.
Here's a diagnostic checklist you can use on any backtracking function:
0, idx, start, or something else?
This tells you the visibility policy.i + 1 (move forward),
i (stay in place for repeats), or something else?result.push unconditional (subsets),
gated by a depth check (combinations), or gated by a completion check
(permutations)?used set? A duplicate skip? A bounds
check?Those four reads — init, recursive arg, collection point, guards — tell you exactly which variant the function implements, regardless of what it's named. Tap each zone below to see exactly where it lives in the code:
Build this habit: every time you read a backtracking function, read the loop line first. Before the base case, before the collection point, before the function name. The loop line is where the variant lives.
So far you've been working with the loop init directly — filling blanks, reading code, diagnosing bugs. But in real life, you don't start with code. You start with a problem described in English, and your first job is to figure out which loop configuration the problem needs before you write a single line.
In an interview, nobody says “implement subsets.” They say “given a list of ingredients, find every possible combination of exactly three.” Or “enumerate every way to assign trucks to loading docks.” Or “generate every playlist a user could build from their library.” The algorithm name is nowhere in the problem statement. You have to extract it from the constraints.
This is the triage skill, and it comes down to three questions. If you can answer these three questions about any problem, you can wire the loop before writing a single line of code:
[A, B] the same as [B, A], or are they
different outputs? If picking cumin then paprika yields the same dish as
paprika then cumin, order doesn't matter — you're looking at a
combinations-type problem. If assigning truck A to dock 1 is different
from assigning truck A to dock 2, order matters — that's permutations
territory.These questions aren't just a checklist to memorize. They're the actual
decisions that determine the loop configuration. “Does order matter?”
directly maps to whether the loop starts at 0 (full access) or at a
forward-only index. “Is there a size constraint?” maps to whether you add a
depth prune. The problem statement is encoding the loop line in natural
language — you just need to decode it.
Try the decision tree yourself — answer the two questions and see which algorithm falls out:
The tricky part is that problem statements rarely use the words “order,” “size,” or “repeat” directly. Instead they say things like “every possible arrangement” (order matters), “choose exactly three” (fixed size), or “each item can be used multiple times” (repeats allowed). The vocabulary varies, but the underlying constraints don't. Once you learn to spot the constraint behind the phrasing, the loop line writes itself.
But I'm not going to hand you a lookup table — that's just memorization, and memorization breaks the first time a problem describes the constraint in unfamiliar words. Instead, three scenarios below describe real product features using plain English. No algorithm names anywhere. Read the constraints, decide which variant fits, then wire the loop to match. The last one has a twist.
A streaming app wants to show every possible playlist a user could build from their library — any length, any combination of songs. No playlist is "better" than another.
Which variant fits?
Now that you've done the triage yourself, here's the decision tree you were implicitly using — made explicit:
Does order matter? If truck A at dock 1 is different from truck A at
dock 2, you need permutations — i = 0 with a used guard. The loop needs
to see the full array at every level, because any unused element could go in
any position. If order doesn't matter, continue to the next question.
Is there a fixed size? If you need exactly k items (3 spices from 8),
that's combinations — i = start with a path.length === k prune. The
forward-only index prevents duplicates; the depth check prevents overshoot.
If any size is valid (every possible playlist from your library), that's
subsets — i = idx with no depth constraint.
// The decision tree as code:if (orderMatters) → i = 0 + used guard // permutationselse if (fixedSize) → i = start + depth-k prune // combinationselse → i = idx // subsetsWhat about the twist? You saw it with the spice problem. Same
ingredients, same rack, same number to pick. But the moment the chef says
“the order I add spices changes the flavor” — one constraint flipped — the
loop init shifts from start to 0, and combinations become permutations.
The items didn't change. The count didn't change. Only the relationship
between items changed: from “which ones” to “in what order.”
That's the real lesson here. It's not about memorizing which init goes with which algorithm name. It's about reading the constraints — order, size, repetition — and letting those constraints tell you exactly which expression to write. The constraints are the loop configuration. You just need to translate from English to code.
And notice how thin the line is between variants. The spice problem showed
you that a single constraint flip — “order doesn't matter” to “order
matters” — changes the entire algorithm. It changes the loop init, the
guard, the tree shape, and the complexity class. The problem looks almost
identical. The code change is two characters. But the output space goes from
C(8, 3) = 56 combinations to P(8, 3) = 336 permutations — six times
larger.
Flip the toggle to feel the difference — one constraint, six times the output:
Pick 3 spices from a rack of 8
i = startbaselineThat's why the triage questions matter: they catch a six-fold
complexity difference that you'd miss if you were just pattern-matching on
the problem title.
One more thing worth noting: this triage applies to almost every problem on LeetCode tagged “backtracking.” Before you write the function, before you think about edge cases, before you worry about optimization — ask the three questions. By the time you've answered them, you already know the loop line. The rest is just filling in the skeleton you've already seen four times today.
You started with a single blanked-out expression and no idea how much weight
it carried. You filled it for subsets. You watched it detonate when you
turned the dial to 0. You rewired it three different ways and predicted
three different tree shapes — binary, pruned, and fan-out. You diagnosed
three broken implementations by reading nothing but the loop line. And you
classified three real-world problems by reasoning about constraints alone,
without any algorithm names in sight.
All of that — every screen, every interaction, every tree that grew on your screen — traced back to one expression in one line of code.
for (let i = ???; i < nums.length; i++)That's the dial. idx for subsets. start for combinations. 0 for
permutations. One template. One expression. Three algorithms.
Next up: you'll use these three variants on real LeetCode problems — Subsets II (with duplicate handling), Combination Sum, and Permutations II. The template is the same. The dial is the same. The only new skill is reading the constraints and turning the dial to match.
One Template, Three Algorithms
You got i = idx right away and still saw what i = 0 does to the tree. Quick instincts paired with the full picture.
0/6 blanks filled. The pattern: i = idx for subsets, i = start plus a depth check for combinations, i = 0 with a used-set for permutations.
You saw i = idx build a binary tree, then watched i = 0 blow it wide open. The loop init is the dial — and now you know what each setting does.