Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Bits & Machine Numbers  ›  Lesson

Subsets as Bitmasks

Subsets as Bitmasks 28 minCounting from 0 to 2ⁿ − 1 visits every subset, one machine word each
You're building a piece ofQuarry — the record encoder
This piece — pick_indexes(): Chooses which indexes to keep in memory by trying every subset.
Scenario Quarry has room in memory for only some of its candidate indexes. Each time the workload shifts, the index picker tries every combination that fits and keeps the one that saves the most query time.
Your task
Build pick_indexes(sizes, gains, budget). Try every subset of candidate indexes and return the indices (increasing) of the one with the largest total gain whose total size fits the budget. Ties on gain go to the subset using less memory. Example: pick_indexes([40, 25, 60], [9, 5, 12], 70) → [0, 1].

Subsets as Bitmasks

In short: give each of n items a bit position, and every number from 0 to 2ⁿ−1 is a different subset — bit i is 1 when item i is in it. Counting upward visits every subset exactly once in O(n · 2ⁿ) time, and set algebra becomes bit operations: union is |, intersection is &, difference is a & ~b, and membership is a >> i & 1. It's O(1) extra space per mask, since a whole subset lives in one machine word, so this is a tool for small n, not for n = 40.

Eight rows, mask 0 through mask 7 in three bit columns labelled epoch/shard/id, each row's binary digits matching the subset it names

Quarry's query planner has a handful of candidate indexes it could keep in memory: by id, by shard, by epoch, by type. Each costs memory and each speeds up some queries. Which combination should it keep? With a few candidates, the honest answer is to try every combination and keep the best. That's 2ⁿ subsets for n candidates, and this lesson shows the cheapest way to walk them: count.

In the algorithm-design section you generated subsets with backtracking, a recursive tree of "take it or leave it" choices. Bitmasks flatten that whole tree into a single for loop over integers. There's no recursion and no call stack, and each subset is stored in one machine word.

A subset is a number

Give each of the n items a bit position. A subset is then an n-bit number whose bit i is 1 when item i is in it. Every n-bit number from 0 to 2ⁿ − 1 is a different subset, and every subset is one of those numbers. Counting upward visits them all:

watch it move · array

Counting from 0 to 7 in three bits visits every subset of three indexes exactly once.

mask 0 = 000 selects nothing. That's the empty set, keeping no indexes at all. 3 cells. At index 2.

1 / 8

In code, turning a mask back into items is one comprehension:

python
What does this print?
mask = 0b1101
print([i for i in range(4) if mask >> i & 1])

The cost is the number of masks times the work per mask, O(n · 2ⁿ) to list every subset. That's fine for n = 20, which is about 20 million steps, and impossible for n = 40. Bitmask enumeration is a tool for small n. When n is small, it's often the simplest correct solution there is.

The classic slip is precedence again. This planner should see all 8 subsets:

broken — fix it

It prints 4 ['id', 'shard']. Subtraction binds tighter than <<, so the range stops at 1 << 2 and half the subsets never appear.

Set operations become bit operations

Once subsets are masks, set algebra is single instructions:

Set operation Mask operation
A ∪ B a | b
A ∩ B a & b
A − B a & ~b
is item i in A? a >> i & 1
A ⊆ B a & b == a
|A| a.bit_count()

This is a bitset, and you'll see it throughout the course. The capstone uses one to record which shards a batch of records touches. A bitset of 64 items fits in one word, and a union of two sets costs a single OR.

Gray code: one change per step

Plain counting sometimes flips many bits at once. Going from 3 to 4 flips all three. If each subset needs expensive work to set up, such as loading an index or recomputing a total, you'd rather each step change one item. The Gray code g = i ^ (i >> 1) gives exactly that. It visits every mask, and consecutive masks differ in exactly one bit:

What does this print?
print([i ^ (i >> 1) for i in range(4)])
watch it move · array

In Gray order the same eight subsets appear, but each step adds or removes exactly one index.

Start from the empty set, 000. 3 cells. At index 2.

1 / 8

That property turns a running total into an O(1) update per step. Trace a Gray-order walk that keeps the memory cost of the chosen indexes up to date without re-adding:

step through it
1sizes = [40, 25, 60]
2prev, total = 0, 0
3for i in range(1, 1 << len(sizes)):
4 g = i ^ (i >> 1)
5 changed = g ^ prev
6 bit = changed.bit_length() - 1
7 total += sizes[bit] if g & changed else -sizes[bit]
8 prev = g
9print(total)

Enumerating only the submasks

Sometimes you want the subsets of a particular set m, not of everything. Looping over all 2ⁿ masks and skipping those with bits outside m wastes time. This loop visits only the submasks, in decreasing order:

s = m
while True:
    ...use s...
    if s == 0: break
    s = (s - 1) & m

Subtracting 1 borrows through the low bits, and & m throws away every bit outside m. The result is the next smaller number made only of m's bits.

What does this print?
m = 0b1011
count = 0
s = m
while True:
    count += 1
    if s == 0:
        break
    s = (s - 1) & m
print(count)

Run it yourself. The greyed cells are bits outside m, which the loop never turns on. Change m to see how the number of frames tracks 2 ** m.bit_count():

your code, drawn · array

Your submask loop only ever lights bits inside m, so it takes 2 to the popcount of m frames, not 2 to the width.

Summed over every mask m of n bits, the submask walks cost 3ⁿ in total. Each item is outside m, in m but not s, or in both. That 3ⁿ is the price of the DP-over-subsets problems in the planning section, and of the capstone exercise below.

How large can n get? Quarry allows its planner about ten million steps per decision, and enumerating all subsets of n candidate indexes costs about n·2ⁿ steps:

n n·2ⁿ steps
10 10,240
16 1,048,576
18 4,718,592
19 9,961,472
20 20,971,520

n = 19 just fits. One more candidate more than doubles the cost to 21 million. Exponential cost has a hard wall, and the planner should switch to a greedy or DP method well before 20 candidates.

Interview corner — an even split

Quarry has up to 16 shards, each with a known size in GB. Can they be divided between two machines so both hold exactly the same total? Every division is a mask, where bit i on means shard i goes to machine A. So you check 2ⁿ masks and compare each subset's sum with half the total:

python

It's exponential, and that's fine for 16 shards. A DP on sums, from the dynamic-programming lesson, scales in the total instead. Knowing which regime you're in is what the question is really testing.

Quick practice

What does this print?
a, b = 0b1100, 0b1010
print(a & b, a | b, a & ~b)
broken — fix it

It prints False True — backwards. mask & i treats i as a bitmask, not a bit position. Fix the test.


🎯 Your turn

Write pick_indexes(sizes, gains, budget). Index i costs sizes[i] MB and saves gains[i] ms per query. Try every subset and return the indices of the subset with the largest total gain whose total size is <= budget, in increasing order. If two subsets tie on gain, prefer the one using less memory. The tests never tie beyond that. Keeping nothing ([]) is always allowed.

  • pick_indexes([40, 25, 60], [9, 5, 12], 70) → [0, 1] (65 MB, gain 14)
  • pick_indexes([80], [10], 50) → []

Hint — loop mask over range(1 << n), compute the subset's size and gain, skip it if it's over budget, and keep the best (gain, -size) seen so far.

Then press ▶ Run, try the Live App chips, and hit ✓ Check. ✅

Practice — level up

0/6 solved

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

Warm-upWhich items does a mask select?

Write subset_of(mask, items) returning the items whose bit is on in mask, in order of bit position. Bit i stands for items[i]. Bits past the end of items are ignored.

bitmasksubsets
DrillEvery subset, by counting

Write all_subsets(items) returning every subset of items as a list of lists, in mask order: the subset for mask 0 first, then mask 1, 2, … up to 2**n - 1. Inside each subset, keep the items' original order. Use a loop over masks, not recursion.

subsetsbitmask
BuildSubset sums in Gray order

Write gray_sums(sizes) returning the total size of every subset of sizes, visited in Gray-code order g = i ^ (i >> 1) for i in 0 … 2**n - 1. The first entry is 0, the empty set. Update the total in O(1) per step: work out which single bit changed and add or subtract that one size. Don't re-add the whole subset.

gray-codebitmask
BossFewest indexes that cover a query

A query filters on the columns in needed. Each candidate index covers the columns listed in indexes[i]. Write min_cover(needed, indexes) returning the smallest number of indexes whose columns together include every needed column, or -1 if even all of them can't. If needed is empty, the answer is 0. There are at most 12 indexes. Turn each column list into a bitmask first.

bitmasksubsets
CapstoneFewest load batches

Quarry reloads up to 10 tables after a restart. Table i takes sizes[i] GB, and one load batch can hold at most cap GB. Write min_batches(sizes, cap) returning the fewest batches that load every table, or -1 if some table is bigger than cap. With no tables the answer is 0. Use a DP over masks: best[mask] is the fewest batches for the tables in mask, built from the submasks of mask that fit in one batch.

submaskbit-manipulation
ExplainWhy forty candidates won't finish

A teammate points the index picker at 40 candidate indexes and it never returns. Explain why, estimate how long it would take, and say roughly where the limit of "try every subset" lies. Then suggest what the planner could do instead, and when backtracking with pruning helps.

pick_indexes.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
pick_indexes(sizes, gains, budget) → listIndices of the best subset of indexes that fits in memory.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.