- Home
- /
- Tutorials
- /
- DSA Tutorial
- /
- Backtracking Explained
Backtracking
Backtracking Explained
Backtracking is brute force with an eraser. You try a choice, follow it, and if it leads nowhere you undo it and try the next - which is why the undo step is the part that matters.
The template
Every backtracking solution is this shape:
Choose, explore, unchoose
javascript
// A runnable instance of the template: every 2-element combination.
function backtrack(current, options, results, start) {
if (current.length === 2) { // a complete solution
results.push([...current]) // copy — current keeps changing
return
}
for (let i = start; i < options.length; i++) {
current.push(options[i]) // choose
backtrack(current, options, results, i + 1) // explore
current.pop() // unchoose
}
}
const results = []
backtrack([], ["a", "b", "c"], results, 0)
console.log(results) // [["a","b"], ["a","c"], ["b","c"]]Two details cause most bugs: copying when you record a solution, and undoing after exploring. Miss the copy and every result is the same array. Miss the undo and choices leak into sibling branches.
Subsets
Every subset
javascript
function subsets(nums) {
const results = []
function build(start, current) {
// Every state is itself a valid subset.
results.push([...current])
for (let i = start; i < nums.length; i++) {
current.push(nums[i])
build(i + 1, current) // i + 1, so no element repeats
current.pop()
}
}
build(0, [])
return results
}
console.log(subsets([1, 2, 3]))
// [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]2^n subsets, so O(2^n × n) including the copies. That is inherent - there are that many answers.
Permutations
Every ordering
javascript
function permutations(nums) {
const results = []
const used = new Array(nums.length).fill(false)
function build(current) {
if (current.length === nums.length) {
results.push([...current])
return
}
for (let i = 0; i < nums.length; i++) {
if (used[i]) continue
used[i] = true
current.push(nums[i])
build(current)
current.pop() // undo both
used[i] = false
}
}
build([])
return results
}
console.log(permutations([1, 2, 3]).length) // 6Note that used[i] = false is undone as well as the push. Every piece of state you change on the way down must be restored on the way up.
Pruning is the whole optimisation
Backtracking is exponential. The only way to make it fast is to abandon branches early - stop as soon as a partial solution cannot possibly work, rather than completing it and rejecting it.
Combinations summing to a target
javascript
function combinationSum(candidates, target) {
const sorted = [...candidates].sort((a, b) => a - b)
const results = []
function build(start, current, remaining) {
if (remaining === 0) {
results.push([...current])
return
}
for (let i = start; i < sorted.length; i++) {
// Sorted, so every later candidate is bigger too — stop entirely.
if (sorted[i] > remaining) break
current.push(sorted[i])
build(i, current, remaining - sorted[i]) // i, so reuse is allowed
current.pop()
}
}
build(0, [], target)
return results
}
console.log(combinationSum([2, 3, 6, 7], 7)) // [[2,2,3], [7]]The break is the pruning. Sorting first is what makes it valid - without a sorted list you could only continue, and you would explore far more dead ends.
The shape every backtracking problem takes
Choose, recurse, un-choose. The un-choose step is what makes it backtracking rather than plain recursion, and forgetting it is the bug you will hit first - the path keeps growing because nothing ever removes what you added.
The template
javascript
function solve(input) {
const results = []
const path = []
function backtrack(start) {
if (isComplete(path)) {
results.push([...path]) // copy — path keeps mutating
return
}
for (const choice of choicesFrom(start)) {
if (!isValid(choice, path)) continue
path.push(choice) // choose
backtrack(start + 1) // explore
path.pop() // un-choose
}
}
backtrack(0)
return results
}
// The template above is a shape to copy, not something runnable on its own —
// isComplete, choicesFrom and isValid are yours to supply per problem.
console.log("template: choose, recurse, un-choose")Note [...path] rather than path. Pushing the array itself stores a reference to something that is about to be mutated, so every result ends up identical - usually empty. This is the second bug everyone hits.
Pruning is the whole game
Unpruned backtracking explores every combination, which is exponential and hopeless past small n. The isValid check is what makes it tractable: rejecting a partial path early removes an entire subtree of work, not one candidate.
