DSA Minimum Cost to Cut a Stick
Minimum Cost to Cut a Stick asks for the cheapest order of cuts that splits one stick at given positions. Each cut charges the current piece length, so early long cuts cost the most.
The problem
Minimum Cost to Cut a Stick asks for the cheapest order in which to make a set of cuts in one stick. The stick runs from position 0 to position n. Every cut position must be cut exactly once.
One cut charges the full length of the piece it splits. A cut made early, while its piece is still long, charges more than the same cut made late. The order is the whole problem.
The cases used in this article
cases = [
(7, [1, 3, 5], 14),
(7, [1, 3, 4, 5], 16),
(9, [2, 5, 7], 18),
(6, [], 0),
]
for n, cuts, expected in cases:
print("stick", n, "cuts", cuts, "cheapest total:", expected)Read one row at a time. The first row needs three cuts and pays 14 at best. The last row needs no cuts at all, so it pays nothing.
Intuition: cut from left to right
The first idea cuts in position order, from the left end to the right end. The order is easy to name. It is also easy to beat.
Cutting left to right makes the first cut pay for the whole stick. Each later cut then pays for a piece that is still long, because the cuts to its right have not happened yet.
Left to right against a better order on one stick
def cost_of_order(n, order):
pieces = [(0, n)]
total = 0
for cut in order:
for i, (lo, hi) in enumerate(pieces):
if lo < cut < hi:
total += hi - lo
pieces[i:i + 1] = [(lo, cut), (cut, hi)]
break
return total
print("order 1, 3, 5 costs:", cost_of_order(7, [1, 3, 5]))
print("order 3, 1, 5 costs:", cost_of_order(7, [3, 1, 5]))
print("order 5, 3, 1 costs:", cost_of_order(7, [5, 3, 1]))The middle order wins on this stick. Cutting at 3 first splits the stick into two short pieces. Both later cuts then charge short lengths.
Brute force: try every first cut, recursively
The brute force picks the first cut of a piece in every possible way. Each choice splits the piece in two. The two halves are then solved the same way, with no memory of earlier work.
A counter records the calls. The same small piece is solved again and again, once for every path that reaches it.
The recursive brute force, counted
calls = 0
def brute(points, i, j):
global calls
calls += 1
if j - i < 2:
return 0
best = None
for k in range(i + 1, j):
cand = brute(points, i, k) + brute(points, k, j) + (points[j] - points[i])
if best is None or cand < best:
best = cand
return best
points = sorted([0, 7] + [1, 3, 5])
print("cheapest:", brute(points, 0, len(points) - 1), "calls:", calls)
calls = 0
points = sorted([0, 7] + [1, 3, 4, 5])
print("cheapest:", brute(points, 0, len(points) - 1), "calls:", calls)Both answers are correct. The call count is the problem. Four cuts already need dozens of calls, and most of them repeat work on pieces already solved.
The optimal solution: fill a table over ranges
The state is a range between two boundary points, and the table entry for a range holds the cheapest cost of cutting inside it once every first cut for that range has been tried. That one state is the whole method.
The boundary points are the stick ends plus every cut, sorted. A range with no cut inside it costs zero. Longer ranges are filled after shorter ones, so both halves of any split are ready when needed.
The range table on every case
def cheapest_cuts(n, cuts):
points = sorted([0, n] + cuts)
m = len(points)
table = [[0] * m for _ in range(m)]
choice = [[None] * m for _ in range(m)]
for length in range(2, m):
for i in range(m - length):
j = i + length
best = None
for k in range(i + 1, j):
cand = table[i][k] + table[k][j] + (points[j] - points[i])
if best is None or cand < best:
best = cand
choice[i][j] = k
table[i][j] = best
def plan(i, j):
k = choice[i][j]
if k is None:
return []
return [points[k]] + plan(i, k) + plan(k, j)
return table[0][m - 1], plan(0, m - 1)
cases = [
(7, [1, 3, 5], 14),
(7, [1, 3, 4, 5], 16),
(9, [2, 5, 7], 18),
(6, [], 0),
]
for n, cuts, expected in cases:
total, order = cheapest_cuts(n, cuts)
print("stick", n, "cuts", sorted(cuts), "-> total", total, "order", order, "match:", total == expected)Every case from the first section matches. The table also records one first cut per range, so a cheapest order is read back out, not just a cheapest total.
Walkthrough: stick 7 with cuts at 1, 3 and 5
The walk fills the table for points 0, 1, 3, 5 and 7. Ranges of one step cost zero. Longer ranges combine two shorter ones plus their own length.
The table, range by range
points = [0, 1, 3, 5, 7]
m = len(points)
table = [[0] * m for _ in range(m)]
choice = [[None] * m for _ in range(m)]
for length in range(2, m):
for i in range(m - length):
j = i + length
best = None
for k in range(i + 1, j):
cand = table[i][k] + table[k][j] + (points[j] - points[i])
if best is None or cand < best:
best = cand
choice[i][j] = k
table[i][j] = best
print("range", points[i], "to", points[j], "first cut", points[choice[i][j]], "cost", best)
print("full stick cost:", table[0][m - 1])The full range tries first cuts at 1, 3 and 5. Cutting at 3 first charges 7, then the left piece costs 3 and the right piece costs 4. The total 14 is read off the last row of the walk.
Complexity
For m boundary points the table holds one entry per pair of points, and each entry tries every cut inside its range, so the time is O(m^3). The table and the choice table each hold one entry per pair, so the space is O(m^2).
Counting candidate evaluations as ranges grow
for cuts_count in [3, 4, 5, 6]:
m = cuts_count + 2
tried = 0
for length in range(2, m):
for i in range(m - length):
j = i + length
tried += j - i - 1
print("cuts", cuts_count, "ranges", m * (m - 1) // 2, "first-cut tries", tried)The try count grows much faster than the cut count. Six cuts need 56 first-cut tries in the table, against a far larger repeated count in the recursion.
Edge cases and pitfalls
No cuts means no cost, and one cut always charges the whole stick. Unsorted input is sorted before anything else runs, so the caller may pass cuts in any order.
The classic mistake charges the cut position instead of the piece length. A cut at 3 in a piece from 0 to 7 charges 7, not 3. The code below shows both numbers side by side.
Edge cases, run
def cheapest_total(n, cuts):
points = sorted([0, n] + cuts)
m = len(points)
table = [[0] * m for _ in range(m)]
for length in range(2, m):
for i in range(m - length):
j = i + length
table[i][j] = min(
table[i][k] + table[k][j] + (points[j] - points[i])
for k in range(i + 1, j)
)
return table[0][m - 1]
print("no cuts:", cheapest_total(6, []))
print("one cut:", cheapest_total(6, [2]))
print("unsorted cuts:", cheapest_total(7, [5, 1, 3]))
print("neighbouring cuts:", cheapest_total(4, [1, 2, 3]))
print("wrong charge for cut at 3 in 0..7 would be:", 3)
print("right charge for cut at 3 in 0..7 is:", 7 - 0)Neighbouring cuts still work. Each tiny piece charges its own small length, and no range is ever skipped.
Exercise
A piece runs from position 2 to position 9. What does the first cut inside it charge?