Problem overview
The method minPathSum receives a grid of non-negative numbers, an Array[Array[Int]]. You start in the top-left cell, finish in the bottom-right cell, and may only move down or right. The method returns the smallest possible sum of the numbers on such a path, including both end cells.
For the grid [[1, 3, 1], [1, 5, 1], [4, 2, 1]] the answer is 7, from the path 1, 3, 1, 1, 1 (right, right, down, down). The statement asks for O(n * m) time and O(n * m) extra space.
Approach: a recursive minimum with a 2D cache
Define minSum(grid, cache, i, j) as the smallest path sum from cell (i, j) to the bottom-right corner. A path from (i, j) starts by stepping down to (i + 1, j) or right to (i, j + 1), so
minSum(i, j) = grid(i)(j) + min(minSum(i + 1, j), minSum(i, j + 1))
Computed naively, this recomputes many cells, because different routes reach the same cell. The grid has only n * m cells, so a table of the same size remembers each answer once. After that, each cell costs constant work, and the whole computation is O(n * m).
Two special cases anchor the recursion. A step that leaves the grid is not a valid path, so it must never be the minimum: it returns Int.MaxValue. The bottom-right cell is a path on its own, so it returns its own value.
Step by step
- Create the cache with
Array.ofDim[Int](grid.length, grid(0).length), a 2D array of the same size as the grid. Every entry starts as 0, meaning "not computed yet". - Return
minSum(grid, cache, 0, 0)as the last expression ofminPathSum, since every path starts at the top-left cell. - Declare
minSumaboveminPathSum, with the grid and the cache asArray[Array[Int]], the indicesiandjasInt, and anIntresult. - Out of the grid. If
i >= grid.lengthorj >= grid(0).length, returnInt.MaxValue, so the minimum never picks that move. - The goal cell. If
iandjare both on the last row and column, returngrid(i)(j). This stops the recursion. - Cache check. If
cache(i)(j) > 0, the cell was computed before, so return it. A cell still holding 0 is not filled yet. - Compute and store:
cache(i)(j)becomesgrid(i)(j)plus the smaller of the two recursive results, taken withmath.min. - Return
cache(i)(j)as the last expression.
The solution in Scala
class Solution {
def minSum(grid: Array[Array[Int]], cache: Array[Array[Int]], i: Int, j: Int): Int = {
if (i >= grid.length || j >= grid(0).length) {
return Int.MaxValue
}
if (i == grid.length - 1 && j == grid(0).length - 1) {
return grid(i)(j)
}
if (cache(i)(j) > 0) {
return cache(i)(j)
}
cache(i)(j) = grid(i)(j) + math.min(minSum(grid, cache, i + 1, j), minSum(grid, cache, i, j + 1))
cache(i)(j)
}
def minPathSum(grid: Array[Array[Int]]): Int = {
val cache = Array.ofDim[Int](grid.length, grid(0).length)
minSum(grid, cache, 0, 0)
}
}
The Scala details worth a closer look:
- Arrays of arrays. An
Array[Array[Int]]is indexed with two sets of parentheses:grid(i)(j).grid.lengthis the number of rows andgrid(0).lengththe number of columns. Array.ofDim[Int](rows, cols)allocates the rows and columns in one call. The elements start as 0, the default forInt. That default is what the "not computed" test relies on.- Updating an element.
cache(i)(j) = valueis the same ascache(i).update(j, value). Int.MaxValueis safe here only because it is never added to. Adding 1 to it would wrap toInt.MinValue, sinceIntis 32 bits and overflow wraps silently. The only cell that has both moves off the grid is the bottom-right cell, and the goal check returns before the sum is built. Every other cell has at least one valid move, so the minimum is always a real sum.- The last expression is the result. The final
cache(i)(j)needs noreturn, while the early exits usereturn. math.minis the standard two-argument minimum forIntvalues.
Complexity
Time: O(n * m). There are n * m cells, and each is computed once. Every later call for the same cell returns from the cache, and each cell is requested by at most two neighbours, so the total number of calls is bounded by a small multiple of n * m.
Space: O(n * m). The cache has one entry per cell. The recursion also goes up to n + m - 1 calls deep, which is smaller than the table.
Edge cases and common mistakes
- A single cell.
[[5]]is both the start and the goal, so the answer is 5. - A single row or column.
[[1, 2, 3]]gives 6, and[[1], [2], [3]]gives 6, because there is only one path. - The example.
[[1, 3, 1], [1, 5, 1], [4, 2, 1]]gives 7. - Zeros in the grid. The cache uses 0 to mean "not computed". A cell whose true answer is 0 (only possible when every cell on its best path is 0) is never recognised as cached, so it is recomputed every time it is reached. The result stays correct, but the speed does not: on a 14 by 14 grid of zeros the recursion makes about 59 million calls, against 391 for a grid of ones. A separate marker such as -1 would avoid this.
- Returning 0 instead of
Int.MaxValueoff the grid. Out-of-grid moves would look like free paths, and the minimum would always choose them. - Forgetting the goal check. The bottom-right cell would take the minimum of two out-of-grid moves and return a huge value.
- Forgetting to store in the cache. The result stays correct but takes exponential time.
Testing
[[1, 3, 1], [1, 5, 1], [4, 2, 1]]gives7.[[5]]gives5.[[1, 2, 3]]gives6.[[1], [2], [3]]gives6.[[0, 0], [0, 0]]gives0.
