Skip to content
echogeco
Dynamic programming · Knapsack

Sluice doses

Meet an exact irrigation ration with the fewest gate openings, by filling one table entry per volume instead of trusting the largest gate first.

One-dimensional table over the volume still owedmediumProblem · 4 min · 212 of 290
On this page

An irrigation canal feeds each field through a bank of sluice gates, and every gate releases a fixed dose. The warden wants the ration met exactly, with as few openings as possible.

The problem

The canal bank has a row of sluice gates. Each gate is calibrated: opening it runs for a set dwell and releases exactly that many litres, given in gates. A gate may be opened as often as the warden likes, and the gates may be used in any combination — the doses simply add up.

A field is due a ration of exactly ration litres. Over-watering damages the crop, so the doses must total the ration precisely; there is no partial opening and no way to shut a gate early.

Report the fewest openings that hit the ration exactly, or -1 if no combination of doses reaches it.

Input. gates — a list of integers, the litres each gate releases per opening. ration — an integer, the litres owed.

Output. The fewest openings totalling exactly ration, or -1.

Example.

gates = [9, 6, 1], ration = 12   ->  2

Two openings of the 6-litre gate. Reaching for the biggest gate first gives 9 + 1 + 1 + 1, four openings — the large dose leaves a remainder that only the small gate can clear.

A second example, where the ration cannot be met:

gates = [5, 9], ration = 13   ->  -1

Five and nine build 5, 9, 10, 14, 15, 18 and so on, but never 13. The warden reports -1 rather than over-watering by a litre.

Constraints.

  • 1 <= len(gates) <= 12
  • 1 <= gates[i] <= 5000
  • 0 <= ration <= 10^4

Hints

Hint 1

Think about the last gate the warden opens. Whatever it was, the openings before it had to total the rest of the ration exactly.

Hint 2

That makes the answer for a volume depend on the answers for smaller volumes — one per gate. Which gates got you to the smaller volume never matters.

Hint 3

Fill a table from 0 litres upward and every entry you need is already finished. Mark a volume unreachable rather than pretending it costs nothing.

Approach

Brute force

Recurse: subtract one gate's dose and solve the rest, trying every gate at every step. With 12 gates and a 1-litre gate available, the tree is 12 to the power of 10000 paths, and the same remaining volume is re-solved down countless branches.

Greedy is the other tempting shortcut — take the biggest gate that fits, repeat — and it is simply wrong. The first example is the counterexample: greedy pays four openings where two will do.

The insight

The fewest openings for a volume depends only on the volume, never on which gates produced it, so one entry per litre from 0 up to the ration is the whole solution.

That independence is the precondition. Take an optimal plan for v and remove one opening of gate g from it: what remains is a plan for v - g, and it must itself be optimal, because a cheaper plan for v - g plus that one opening would beat the plan for v. So best[v] is 1 + min(best[v - g]) over the gates that fit, and reading only entries below v means a single upward pass finishes the table.

Unreachable volumes need a value that cannot be extended into a false answer: mark them infinite, so best[v - g] + 1 stays infinite and never wins a minimum.

Algorithm

  1. Make best of length ration + 1, all infinite except best[0] = 0.
  2. For each volume v from 1 to ration:
  3. For each gate dose g that is at most v, consider best[v - g] + 1.
  4. Keep the smallest as best[v].
  5. Return best[ration], or -1 if it is still infinite.

Complexity

Time O(ration * len(gates)) — 120000 comparisons at the top of the range. Space O(ration) for the table; the gates themselves add nothing.

Solution

Python 3 · standard library15 lines · 7 of 7 tests passing
"""Sluice doses — fewest gate openings for an exact ration, filled volume by volume."""

UNREACHABLE = float("inf")


def solve(gates, ration):
    # Invariant: best[v] is the fewest openings totalling exactly v litres, and
    # it depends on v alone — never on which gates produced the earlier litres.
    best = [UNREACHABLE] * (ration + 1)
    best[0] = 0                       # nothing owed costs no openings
    for volume in range(1, ration + 1):
        for dose in gates:
            if dose <= volume and best[volume - dose] + 1 < best[volume]:
                best[volume] = best[volume - dose] + 1
    return -1 if best[ration] == UNREACHABLE else best[ration]
The cases that ran
TESTS = [
    (([9, 6, 1], 12), 2),         # greedy largest-first would pay 4
    (([5, 9], 13), -1),
    (([7, 3], 24), 4),
    (([4], 0), 0),                # nothing owed, no openings
    (([5000], 5000), 1),          # the largest dose, met in one opening
    (([2, 4, 6], 7), -1),         # every dose is even, the ration is not
    (([1], 10000), 10000),        # top of the range, one litre at a time
]

Pitfalls

  • Taking the largest gate that fits, repeatedly. On [9, 6, 1] with a 12-litre ration that costs 4 openings instead of 2. Greedy is only safe for dose sets with a special structure, and nothing here promises one.
  • Filling the table with 0 instead of infinity. Every volume then reports 0 openings, and the unreachable 13 in the second example comes back as 0 rather than -1.
  • Forgetting the zero ration. best[0] = 0 is both the base case and the answer when nothing is owed. Seeding it as infinite makes every entry unreachable; returning 1 for it invents an opening nobody made.
  • Returning the sentinel. A leftover float("inf") reported as the answer is not -1, and a caller comparing it against an integer count will take it for a very expensive but valid plan.

Variants

  • Terrace climbs — the same one-entry-per- position table, counting the arrangements instead of minimising a count.
  • The knapsack family — where reusing a gate any number of times sits against the version that allows each item once.