Hard Problemsminimizationpartitiongrid

The painter's partition problem

Given n boards of different lengths and k painters who all paint at the same speed, where each painter must paint one contiguous, unsplit block of boards, assign the boards, in order, to minimize the time the slowest painter spends.

Note. This problem is more commonly solved with binary search on the answer; the DP formulation shown here is the derivable, table-based alternative.

Do this lesson first: coin change

Example input

boards = [10, 10, 5, 4, 2, 3, 2, 9, 10, 6], k = 3 painters

Expected output

25

Break it down

Answer each question out loud before you open it. Getting it wrong here is the useful part. A revealed answer you never guessed at teaches you nothing.

Fill the table

The table pauses before each cell you have to supply. Type the value the recurrence gives, and the write animation confirms it.

Step not started

Press start. The animation stops at every cell YOUR recurrence must fill.

Row 0 is one painter, and one painter has no decision to make: p = 1 means every board from 1 to i is theirs, so dp[0][i - 1] is the plain total of the first i boards. That is why the whole of row 0 is a base rather than a computed cell, and why it reads as a running total, 10, 20, 25, 29 and on up to 61 at ten boards. It is also the only row that needs no comparison, which makes it the right place for a sweep to start. THE DOTS ARE NOT ZEROS, and it is worth being exact about what that does and does not buy you here. A grid has to be a rectangle, so the table is declared 3 rows by however many boards, but a cell with i < p asks three painters to share two boards while every painter must paint at least one contiguous block. That has no answer rather than a cheap one, so those positions render as a dot because they DO NOT EXIST. Nothing is written to them and no cell depends on one: the split range, j from p - 1 up to i - 1, is exactly the range that stays clear of them. Now the part that is easy to get wrong in the other direction, and this page is the exception rather than the rule. On a min-over-SUM table, a zero sitting where a dot belongs is a free move and the min takes it every time: min-sum-in-a-triangle says exactly that about its own dots, and it is right to, because a descent that could stand on the empty half of that table would come back under the truth. It does not transfer here. There is exactly one place the mistake could even bite, since row 0 has no dots at all: widen the range and row 2's leftmost candidate becomes the dot at dp[1][0], which the shipped range never reads. So fill every dot with a zero AND widen the split range to allow every j, and all seven answers come out unchanged, 10, 11, 14, 15, 19, 20 and 25, with not one live cell moving either. The max is what protects this table: a zero prior gets paired with the LARGEST remaining block, so max(0, that block) is just the block. At dp[1][0] that block is boards 2 through 10, a SUFFIX and not one of row 0's prefix totals, and it comes to 51 against a true 25 at ten boards, so the min never takes it. So the min over maxes makes the ragged half harmless, which is a property worth noticing rather than a hazard to fear, and the reason to keep the dots is that a position which does not exist is not a value, not that a zero would hand you the wrong number. The fill order needs one property and has it. Every candidate a cell reads lies in the row ABOVE and at a column strictly to the LEFT of this one, never in this row and never further right, so a row-major sweep left to right within each row has all of them final before the cell asks for them.

row p - 1: how many painters share the job, so the bottom row is the whole crew
0
1
2
3
4
5
6
7
8
9
0
10
20
25
29
31
34
36
45
55
61
1
·
10
15
19
20
20
20
25
29
31
2
·
·
10
10
11
14
15
19
20
25
column i - 1: how many boards are in the job, so the cell is the slowest painter's time over the first i boards
painters-partition.ts
  1. 1function painterPartition(boards, k) {
  2. 2 const n = boards.length;
  3. 3 const pre = [0];
  4. 4 for (const b of boards) pre.push(pre[pre.length - 1] + b);
  5. 5 const dp = Array.from({ length: k }, () => new Array(n + 1).fill(Infinity));
  6. 6 for (let i = 1; i <= n; i++) dp[0][i] = pre[i];
  7. 7 for (let p = 1; p < k; p++) {
  8. 8 for (let i = p + 1; i <= n; i++) {
  9. 9 for (let j = p; j < i; j++) {
  10. 10 const slowest = Math.max(dp[p - 1][j], pre[i] - pre[j]);
  11. 11 if (slowest < dp[p][i]) dp[p][i] = slowest;
  12. 12 }
  13. 13 }
  14. 14 }
  15. 15 return dp[k - 1][n];
  16. 16}
Base caseComputedBeing readAnswer

The code, the trap, the variations

painters-partition.ts
  1. 1function painterPartition(boards, k) {
  2. 2 const n = boards.length;
  3. 3 const pre = [0];
  4. 4 for (const b of boards) pre.push(pre[pre.length - 1] + b);
  5. 5 const dp = Array.from({ length: k }, () => new Array(n + 1).fill(Infinity));
  6. 6 for (let i = 1; i <= n; i++) dp[0][i] = pre[i];
  7. 7 for (let p = 1; p < k; p++) {
  8. 8 for (let i = p + 1; i <= n; i++) {
  9. 9 for (let j = p; j < i; j++) {
  10. 10 const slowest = Math.max(dp[p - 1][j], pre[i] - pre[j]);
  11. 11 if (slowest < dp[p][i]) dp[p][i] = slowest;
  12. 12 }
  13. 13 }
  14. 14 }
  15. 15 return dp[k - 1][n];
  16. 16}

Where people go wrong

Splitting by COUNT instead of by LOAD. Three painters, so give each a third of the boards: it is the first thing everyone tries, and on ONE natural way of pinning down what a third of the boards means it loses at every one of the seven slider sizes. Cutting into three blocks as equal in count as possible, with any remainder going to the earliest blocks, loses by 10, 9, 6, 10, 6, 5 and 4 from 4 boards up to 10. At 7 boards that rule puts boards 1, 2 and 3 together, 10 and 10 and 5, for 25, against a true optimum of 15. Be careful how much you conclude from that, because the remainder convention is doing some of the work. Send the remainder to the LATEST blocks instead and the same rule ties the optimum at 4 boards; take the best of EVERY cut whose three counts differ by at most one and it ties the optimum at 4 boards and at 10, while still losing by 4, 6, 5, 1 and 5 at the other five sizes. So counting boards is not a good rule with a bad tie-break. It is a rule with no reason to be right that sometimes gets lucky, and the reason is right there in the input: a board's count is always 1, while its length here runs from 2 to 10. The second trap is assuming the heaviest block sits somewhere predictable, usually that the last painter is left the smallest one. It does not. Following the cuts this table picks, the heaviest block is the third at 5, 6 and 10 boards, the second at 7 and 8, the first at 9, and tied across the first two at 4. At the slider's top the last painter carries 25 against 20 and 16. Worse for anyone hoping to name THE optimal cut: at 10 boards four different cuts all score 25, and two of them do leave the last painter the smallest block, so which cut you get is a property of your tie-break and not of the input. The third trap is reading the answer off the wrong axis. Row 2 is three painters and the last column is all the boards, and the answer is the cell where both are true. Further left in row 2 is three painters on a SHORTER job, which answers a different question and is never a LARGER number: strictly smaller from 5 boards up, and at 4 boards the single cell to the left of the answer holds 10 exactly as the answer does. Higher up in the last column is FEWER painters on this job, which is strictly larger at every slider size.

  • Let k be an input instead of fixing it at 3 painters.

    The table gains rows and changes nothing else: k rows, the same prefix-sum row 0, the same min over maxes, the same dots wherever i < p, and the answer at dp[k - 1][n - 1]. Time goes to O(k n^2). This page fixes k at 3 only so the grid stays three rows tall on screen.

  • Minimize the TOTAL time all the painters spend rather than the slowest painter's time.

    The problem disappears. Every board is painted exactly once by exactly one painter, so the total is the sum of all the board lengths, 61 on this page's ten boards, whatever the cuts are, and every legal assignment ties. There is nothing to optimize and no table to build. Make the substitution once anyway: minimizing the MAXIMUM is the whole reason this is a problem, and swapping it for minimizing the sum does not give you an easier version, it gives you a non-problem.

  • Drop the contiguity requirement, letting a painter take any set of boards.

    The recurrence stops working, and it is worth knowing why. "The last painter's block begins after board j" is no longer a decision that leaves a shorter prefix behind, because any board can now go to any painter, so there is no last decision that shrinks the input. What is left is makespan minimization on 3 identical machines, which is NP-hard. Integer board lengths still admit a pseudo-polynomial table, but it is keyed on the loads assigned so far rather than on a prefix length, and the neat 3 by n grid is gone. Contiguity here is the counterpart of adjacency in min-sum-in-a-triangle, and the two do opposite jobs rather than the same one. Drop adjacency there and a small table still WORKS, dp[r][c] = tri[r][c] + the min of the whole row above; what dies is its NECESSITY, since the answer collapses to the sum of the row minima. Drop contiguity here and no small table is possible at all, only the load-keyed pseudo-polynomial one just described. Adjacency is a necessity condition, contiguity a possibility condition, and that difference is worth more than the parallel.