Pebellslearn by building
0%
RoadmapMatrixCatalogSandboxSign in
Greedy, Backtracking & Dynamic Programming  ›  Lesson

Backtracking & Exact-Length Mixes

Backtracking 40 minExplore choices one at a time, and back out of dead ends
You're building a piece ofTunebox — festival planner
This piece — exact_mixes(): Every combination of tracks that fills a slot exactly.
Scenario Tunebox Radio has a fixed gap before the news and can't run dead air. The mix finder lists every combination of tracks that fills it to the minute, so the DJ can pick one.
Your task
Build exact_mixes(tracks, slot). Each track is [name, minutes]. Return every combination of tracks (each used at most once) whose minutes add up to exactly slot, as lists of names. Each mix keeps its tracks in input order, and mixes are ordered by the positions of their tracks (first positions, then second, …), which is the order a choose/explore/un-choose search finds them in. Example: exact_mixes([["Aurora", 4], ["Blink", 3], ["Cobalt", 2], ["Drift", 5], ["Echo", 1]], 7) → [["Aurora", "Blink"], ["Aurora", "Cobalt", "Echo"], ["Cobalt", "Drift"]].

Backtracking & Exact-Length Mixes

Tunebox Radio has a 7-minute gap before the news, and dead air isn't an option. The DJ doesn't want a mix that fills it exactly. They want every one, to pick from. No formula lists them. You have to try choices, but try them systematically, and drop a branch the moment it can't work. That's backtracking.

Choose, explore, un-choose

Every backtracking function has the same three beats at its heart. Make a choice. Recurse to explore everything that follows from it. Then undo the choice, so the next option starts from a clean slate.

python

path is one list shared by every call. start makes sure we only ever add tracks after the last one chosen. That's why ["Blink", "Aurora"] never shows up as a second copy of ["Aurora", "Blink"].

Step through a two-track version and watch path grow and shrink:

step through it
1out, path = [], []
2def explore(start):
3 out.append(path[:])
4 for i in range(start, 2):
5 path.append("AB"[i])
6 explore(i + 1)
7 path.pop()
8explore(0)
9print(out)

❓ Cross-question: "Why path[:] and not just path?" path is one list that keeps changing. Appending path itself stores a reference to it, so every entry in out is the same list, and it's empty by the time the search ends. path[:] stores a snapshot.

broken — fix it

This should print all four subsets of ["a", "b"]. It prints four empty lists. Make each recorded subset keep its contents.

The recursion tree and its cost

Draw each call as a node and each loop step as an edge to a child. For three tracks you get:

                []
         /      |      \
       [A]     [B]     [C]
      /   \     |
   [A,B] [A,C] [B,C]
     |
  [A,B,C]

Every subset is exactly one node. Each track is in or out, so n tracks give 2ⁿ nodes, and each copy costs up to n: O(n · 2ⁿ). One more track doubles the work, so backtracking is for small n or heavily pruned trees.

What does this print?
def count_nodes(n, start=0):
    return 1 + sum(count_nodes(n, i + 1) for i in range(start, n))

print(count_nodes(5))

Permutations: order matters

A running order uses every track, and position matters. You can't use a start index here, because a later track can come first. Keep a used flag for each track instead:

python

The tree now has n! leaves: 6 for three tracks and 3,628,800 for ten. Un-choose has to undo everything choose did:

What does this print?
def orders(tracks):
    out, path, used = [], [], [False] * len(tracks)
    def explore():
        if len(path) == len(tracks):
            out.append(path[:])
            return
        for i, track in enumerate(tracks):
            if not used[i]:
                used[i] = True
                path.append(track)
                explore()
                path.pop()            # forgot: used[i] = False
    explore()
    return out

print(len(orders(["a", "b", "c"])))

Combination sums: the mix finder

Now the task's shape. Pick tracks, each at most once, whose lengths add up to exactly the slot. It's the subsets template with a running total, recording only when the total hits the slot:

python

Look at the order. The search always tries earlier tracks first, so mixes come out sorted by the positions of their tracks: (0, 1), then (0, 2, 4), then (2, 3). That's exactly the order the task asks for.

Pruning: cut dead branches early

That continue is pruning. It never steps into a branch that can't lead to an answer. Count the calls with and without it:

python

If the lengths are sorted, you can prune harder. Once one track overflows, every later one does too, so break instead of continue. On unsorted input that same break is a bug:

What does this print?
def count_mixes(lengths, slot, start=0, total=0):
    if total == slot:
        return 1
    found = 0
    for i in range(start, len(lengths)):
        if total + lengths[i] > slot:
            break                     # "nothing after this can fit either"
        found += count_mixes(lengths, slot, i + 1, total + lengths[i])
    return found

print(count_mixes([5, 9, 2], 7))

The task keeps tracks in input order, so it uses continue. It's the right call whenever sorting would change the answer's order.

Problem Nodes in the tree Time Extra space
Subsets 2ⁿ O(n · 2ⁿ) O(n) depth, plus output
Permutations about e · n! O(n · n!) O(n)
Combination sum, each track once at most 2ⁿ O(n · 2ⁿ) worst case O(n)
...with pruning far fewer in practice same worst case O(n)

❓ Cross-question: "Why not itertools.combinations?" For a dozen tracks, it's fine. But it builds all 2ⁿ candidates and filters afterwards, so it can't prune. It also yields answers grouped by size, not in position order.

Coming from Java/JS: path[:] is new ArrayList<>(path) / [...path]. The nested explore closes over out and path like a JS closure. In Java you'd pass them as parameters.


Idioms & real-world patterns

yield answers instead of collecting them

A generator hands out each answer as it's found, so the caller can stop early. Passing an immutable tuple (path + (x,)) gives every call its own path, so there's nothing to un-choose, at the cost of a copy per step:

python

⚡ Advanced: duplicate lengths without duplicate mixes

With lengths [1, 1, 2] and slot 3, the plain search finds [1, 2] twice, once for each 1. Sort the lengths, then skip a value that equals the one just before it at the same level of the tree:

python

🎯 Your turn

Write exact_mixes(tracks, slot). Each track is [name, minutes], with minutes a positive whole number. Return every combination of tracks, each track used at most once, whose minutes add up to exactly slot. Each mix is a list of names.

  • Each mix keeps its tracks in input order.
  • Mixes are ordered by the positions of their tracks: compare first positions, then second, and so on. That's the order the choose/explore/un-choose search finds them in.

exact_mixes([["Aurora", 4], ["Blink", 3], ["Cobalt", 2], ["Drift", 5], ["Echo", 1]], 7)[["Aurora", "Blink"], ["Aurora", "Cobalt", "Echo"], ["Cobalt", "Drift"]]

Hint: explore(start, total) with path holding chosen positions. When total == slot, record their names. continue past any track that would overflow.

Then press ▶ Run, tap the Live App try chips to call it with different inputs, and hit ✓ Check. Green means this piece of the app is built. ✅

Practice — level up

0/5 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upEvery subset

Write all_subsets(tracks) returning every subset of tracks (including the empty one), each keeping input order. Use the choose/explore/un-choose template: record path[:] on entry to each call, then try tracks from start onward. That produces this order: all_subsets(["a", "b", "c"])[[], ["a"], ["a", "b"], ["a", "b", "c"], ["a", "c"], ["b"], ["b", "c"], ["c"]].

subsetsbacktracking
DrillEvery running order

Write playlist_orders(tracks) returning every ordering of all the (distinct) tracks. Use a used flag per track and try tracks in input order at every position, so playlist_orders(["a", "b", "c"])[["a", "b", "c"], ["a", "c", "b"], ["b", "a", "c"], ["b", "c", "a"], ["c", "a", "b"], ["c", "b", "a"]]. An empty list has exactly one order: [[]].

permutationsbacktracking
BuildMixes with repeats

Write mixes_with_repeats(lengths, slot). lengths are distinct positive jingle lengths, and each may be used any number of times. Return every combination that adds up to exactly slot (at least 1). Write each combination in non-decreasing order, and list the combinations in ascending order. mixes_with_repeats([2, 3, 5], 8)[[2, 2, 2, 2], [2, 3, 3], [3, 5]].

combinationspruningbacktracking
BossHarmonic running orders

Write harmonic_orders(tracks). Each track is [name, key], where key is a spot 1–12 on the Camelot wheel, and names are distinct. In a running order, neighbouring tracks must be harmonically close: their keys are equal, or one step apart around the wheel (12 and 1 are neighbours). Return every running order that uses all the tracks, as lists of names, sorted A→Z (compare first names, then second, …).

permutationspruningbacktracking
CapstoneEqual vinyl sides

Write split_sides(lengths, k). A vinyl box set has k sides (k ≥ 1), and every track in lengths (positive minutes) goes on exactly one side. Return True if the tracks can be split so every side has exactly the same total length, otherwise False.

backtrackingpruningrecursion
exact_mixes.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
exact_mixes(tracks, slot) → listEvery combination of track names that fills the slot exactly.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.