Pipette doses
List every way to hit an exact volume from reusable pipette sizes, using a start index so a recipe and its reorderings are generated once.
The rack holds a few tip sizes and the vial needs an exact volume. Listing the recipes by hand misses some and writes others twice.
The problem
A lab rack holds pipette tips in a few fixed volumes, whole microlitres, all different. The technician draws one tip at a time and empties it into the vial; a volume can be drawn as often as wanted, with no cap on the draws.
List every way to reach target microlitres exactly. Two recipes are the same
when they use the same volumes the same number of times — everything lands in
one vial, so 3 + 5 and 5 + 3 are one recipe. Write each with its volumes in
non-decreasing order.
Input. tips — distinct positive integers, the volumes on the rack.
target — the volume needed.
Output. Every recipe, a list of volumes summing to target, in any order.
Example.
tips = [2, 3, 5], target = 8
-> [[2, 2, 2, 2], [2, 3, 3], [3, 5]]
Four 2s, or a 2 and two 3s, or a 3 and a 5. 5 + 3 is not a fourth, and
2 + 2 + 5 overshoots.
A second example, where the answer is empty:
tips = [4, 6], target = 3 -> []
tips = [2, 4, 8], target = 8 -> [[2,2,2,2], [2,2,4], [4,4], [8]]
Nothing built from 4s and 6s lands on 3, so the answer is the empty list, not a list holding an empty recipe. A single 8 is a recipe of one draw.
Constraints.
1 <= len(tips) <= 20, all distinct2 <= tips[i] <= 401 <= target <= 40
Hints
Hint 1
Fix an order on the rack and never go back to a tip you have passed. What does
that rule do to 3 + 5 and 5 + 3?
Hint 2
Two things shrink as the recursion descends: the volume still owed, and the stretch of the rack still allowed.
Hint 3
Drawing the same tip twice is legal, so the branch that takes tip i recurses
with i, not i + 1. Only moving on advances the index.
Approach
Brute force
Enumerate every sequence of draws whose running total lands on the target, sort
each, and drop the duplicates. On the rack [2, 3, 5] with a target of 40 there
are 560,287 sequences and 34 distinct recipes: sixteen thousand sequences per
line of answer.
The insight
Never step backwards along the rack: after drawing the tip at index i, the
next draw may reuse i or move on, but never return to an earlier index — so
every branch is non-decreasing and each recipe is produced once.
The precondition is that every volume is positive: the owed volume strictly falls with each draw, so the recursion terminates, and the non-decreasing rule orders the recipes rather than filtering them afterwards. Sorting the rack buys a second thing — once a tip exceeds what is owed, so does every tip after it, so the loop can stop rather than skip.
Algorithm
- Sort the rack.
draw(i, owed)— record every recipe fromtips[i:]summing toowed, using the shared buffer.- If
owed == 0, append a copy ofrecipeand return. - For
jfromito the end: iftips[j] > owed, break — the rack is sorted, so nothing later fits. - Otherwise append
tips[j], calldraw(j, owed - tips[j]), then pop.
Complexity
Time O(n^(t/m)) where t is the target and m the smallest tip: no recipe holds more than t/m draws, so the tree is that deep and each node branches at most n ways. Space O(t/m) for the deepest chain of draws.
Solution
"""Pipette doses — every multiset hitting a target, via a non-decreasing rack walk."""
def solve(tips, target):
rack = sorted(tips)
recipes = []
recipe = []
def draw(i, owed):
# invariant: `recipe` is non-decreasing, its volumes plus `owed` equal
# the target, and every remaining draw comes from rack[i:].
if owed == 0:
recipes.append(list(recipe))
return
for j in range(i, len(rack)):
if rack[j] > owed:
break # sorted rack: nothing after this one fits
recipe.append(rack[j])
draw(j, owed - rack[j]) # j, not j + 1: the same tip may repeat
recipe.pop()
draw(0, target)
return recipesThe cases that ran
TESTS = [
(([2, 3, 5], 8), [[2, 2, 2, 2], [2, 3, 3], [3, 5]]),
(([4, 6], 3), []), # unreachable: empty list, not [[]]
(([2, 4, 8], 8), [[2, 2, 2, 2], [2, 2, 4], [4, 4], [8]]),
(([7], 7), [[7]]), # a single draw is a recipe
(([7], 8), []),
(([5, 2, 3], 6), [[2, 2, 2], [3, 3]]), # unsorted rack, same answer
]Pitfalls
- Recursing with
j + 1on the take branch. Reuse is forbidden, so repeated volumes disappear and the example prints only[3, 5]. - Recursing from index 0 instead of
j. Stepping backwards is allowed again, so[2, 3, 3]arrives as[3, 2, 3]and[3, 3, 2]too, inflating the answer by every recipe's arrangements. - Breaking on an unsorted rack. On
[5, 2, 3]the loop stops at the 5 as soon as 5 exceeds the owed volume, never reaching the 2 or the 3, and recipes vanish silently.
Variants
- Tasting flight — the same walk down a list, but each item is taken once and no total has to be hit.
- Pruning — the lesson behind the sorted break.