~/dynamic-programming/grid-dp

Grid paths

The cheapest route across a grid when you can only move right or down: each cell takes the better of the cell above and the one to its left.

what

dp[r][c] is the cheapest way to reach cell (r, c). A path enters it from above or from the left, so add the cell's cost to the smaller of those two.

use when

Paths through a grid or table where every move goes forward (right and down), and you want the min, the max or the number of paths.

time

O(rows·cols)

space

O(rows·cols), or O(cols) with one rolling row

You’ll recognise it when

  • The input is a grid (or a table, or a triangle) and you travel from one corner to the other.
  • Every move goes forward: only right or down, sometimes also diagonally down-right.
  • The question asks for the cheapest or most valuable path, or how many paths there are.
  • Some cells may be blocked, and you still count or optimise around them.

If you may also move up or left, the moves can go round in circles and this breaks: that’s a shortest-path problem for Dijkstra or BFS instead.

The idea

You’re crossing a city laid out as a grid, from the top-left corner to the bottom-right, and every block charges a toll. You may only walk east or south. Trying every route is hopeless on a big grid: a 20 × 20 city already has over 35 billion of them.

Now stand on any one block and ask: how did I get here? There are only two ways in, from the block above or from the block to the left. If you already know the cheapest way to reach each of those two, the cheapest way to reach this block is the smaller of them plus this block’s toll. That’s the DP basics recipe on a 2-D table:

Minimum path sum
State dp[r][c] = the cheapest cost of reaching cell (r, c), both ends included
Choice enter from above or from the left
Recurrence dp[r][c] = grid[r][c] + min(dp[r-1][c], dp[r][c-1])
Base case dp[0][0] = grid[0][0]; the top row and first column have only one way in
Order row by row, left to right, so both neighbours are ready

How it works

The cost grid is on the left and dp fills on the right. Scroll through the steps and the graphic follows along; turn on quiz me to call each cell yourself, or edit the grid.

  1. State. Make a table dp the same size as the grid. dp[r][c] will hold the cheapest cost of any path from the top-left corner to cell (r, c).
  2. Base case. Every path starts on the corner and pays for it: dp[0][0] = grid[0][0] = 3.
  3. Top row. Nothing lies above it, so each cell there can only be entered from the left. Treating the missing neighbour as infinity lets the same line of code handle it.
  4. First column. Likewise, nothing lies to the left, so each cell is entered from above: dp[1][0] = 3 + 9 = 12.
  5. Two ways in. Cell (1, 1) can be entered from above, where the cheapest arrival costs dp[0][1], or from the left, dp[1][0]. Which is it?
  6. Keep the cheaper one. From above costs 7, from the left 12, so dp[1][1] = 7 + 2 = 9. The cell’s own cost is paid either way; only the neighbour decides.
  7. Sometimes the left wins. At (1, 2), the left gives 9 and above gives 15, so dp[1][2] = 9 + 3 = 12. Row by row, both neighbours are always filled before you need them.
  8. The corner. From above 16, from the left 21, so the answer is dp[3][3] = 16 + 6 = 22. The 21 is the route a greedy walker takes, always stepping to the cheaper next block (3 → 4 → 2 → 1 → 3 → 8 → 6 = 27): it dodges the 5 early and pays for the 8 later.
  9. Recover the path. Walk back from the corner. Each cell was built from its smaller neighbour, so step to that one, until you reach the start.
  10. Done. The cheapest path is 3 + 4 + 2 + 1 + 5 + 1 + 6 = 22. Every right/down path visits exactly rows + cols − 1 cells.
loading grid-dp…

Why it’s correct: the last move of any path into (r, c) comes from above or from the left. Whatever came before that last move is a path to that neighbour, and it may as well be the cheapest one, which is exactly what dp stores there. So the minimum over both neighbours, plus the cell, covers every path. Filling row by row means those two cells are always final by the time you read them.

def min_path_sum(grid):
"""Cheapest path from the top-left to the bottom-right cell, moving only
right or down. Returns (cost, moves), moves as a string of 'R' and 'D'."""
rows, cols = len(grid), len(grid[0])
INF = float("inf")
# dp[r][c] = cheapest cost of reaching cell (r, c), both ends included
dp = [[0] * cols for _ in range(rows)]
for r in range(rows):
for c in range(cols):
if r == 0 and c == 0:
dp[0][0] = grid[0][0]
continue
up = dp[r - 1][c] if r > 0 else INF
left = dp[r][c - 1] if c > 0 else INF
dp[r][c] = grid[r][c] + min(up, left)
# Walk back from the corner to the neighbour each cell was built from.
moves = []
r, c = rows - 1, cols - 1
while r > 0 or c > 0:
if c == 0 or (r > 0 and dp[r - 1][c] <= dp[r][c - 1]):
moves.append("D")
r -= 1
else:
moves.append("R")
c -= 1
return dp[rows - 1][cols - 1], "".join(reversed(moves))
#include <algorithm>
#include <climits>
#include <string>
#include <utility>
#include <vector>
using namespace std;
// Cheapest path from the top-left to the bottom-right cell, moving only right
// or down. Returns {cost, moves}, moves as a string of 'R' and 'D'.
pair<long long, string> minPathSum(const vector<vector<long long>>& grid) {
int rows = grid.size(), cols = grid[0].size();
const long long INF = LLONG_MAX;
// dp[r][c] = cheapest cost of reaching cell (r, c), both ends included
vector<vector<long long>> dp(rows, vector<long long>(cols, 0));
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
if (r == 0 && c == 0) {
dp[0][0] = grid[0][0];
continue;
}
long long up = r > 0 ? dp[r - 1][c] : INF;
long long left = c > 0 ? dp[r][c - 1] : INF;
dp[r][c] = grid[r][c] + min(up, left);
}
}
// Walk back from the corner to the neighbour each cell was built from.
string moves;
int r = rows - 1, c = cols - 1;
while (r > 0 || c > 0) {
if (c == 0 || (r > 0 && dp[r - 1][c] <= dp[r][c - 1])) {
moves += 'D';
r--;
} else {
moves += 'R';
c--;
}
}
reverse(moves.begin(), moves.end());
return {dp[rows - 1][cols - 1], moves};
}
class GridPaths {
// Cheapest path from the top-left to the bottom-right cell, moving only
// right or down. Returns the cost; the moves ('R' and 'D') go into `moves`.
static long minPathSum(long[][] grid, StringBuilder moves) {
int rows = grid.length, cols = grid[0].length;
final long INF = Long.MAX_VALUE;
// dp[r][c] = cheapest cost of reaching cell (r, c), both ends included
long[][] dp = new long[rows][cols];
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
if (r == 0 && c == 0) {
dp[0][0] = grid[0][0];
continue;
}
long up = r > 0 ? dp[r - 1][c] : INF;
long left = c > 0 ? dp[r][c - 1] : INF;
dp[r][c] = grid[r][c] + Math.min(up, left);
}
}
// Walk back from the corner to the neighbour each cell was built from.
int r = rows - 1, c = cols - 1;
while (r > 0 || c > 0) {
if (c == 0 || (r > 0 && dp[r - 1][c] <= dp[r][c - 1])) {
moves.append('D');
r--;
} else {
moves.append('R');
c--;
}
}
moves.reverse();
return dp[rows - 1][cols - 1];
}
}

Each row only reads the row above and the cell just filled to its left, so one row of cols numbers is enough. Before you overwrite dp[c] it still holds the value from the row above, and dp[c - 1] already holds this row’s value:

def min_path_cost(grid):
cols = len(grid[0])
INF = float("inf")
dp = [INF] * cols
dp[0] = 0 # so the first cell costs just itself
for row in grid:
for c in range(cols):
left = dp[c - 1] if c > 0 else INF
dp[c] = row[c] + min(dp[c], left) # dp[c] is still "above"
return dp[-1]

This gives the cost only; keep the full table if you need the path.

Why it’s O(rows·cols)

There are rows × cols cells, and each is filled with one comparison and one addition, so the fill is O(rows·cols). The walk back takes rows + cols − 2 steps. The table uses O(rows·cols) memory; the rolling row uses O(cols), or O(1) extra if you may overwrite the grid itself.

Compare that with trying every path: a path is rows − 1 downs and cols − 1 rights in some order, so there are C(rows + cols − 2, rows − 1) of them.

grid paths to try cells to fill
4 × 4 20 16
10 × 10 48,620 100
20 × 20 35,345,263,800 400

Common mistakes

Walking greedily

Stepping onto whichever next cell is cheaper looks at one move ahead and can walk into an expensive corner. In the example greedy pays 27; the table finds 22. The table works because it keeps the best cost to every cell, not just the one you’d pick now.

Reading outside the grid on the edges

In the top row there is no dp[r - 1][c]. In Python, dp[-1][c] silently reads the last row; in C++ and Java it’s out of bounds. Treat a missing neighbour as infinity (or fill the first row and column separately).

dp[r][c] = grid[r][c] + min(dp[r - 1][c], dp[r][c - 1]) # ✗ r == 0 reads dp[-1]
up = dp[r - 1][c] if r > 0 else INF # ✓ no way in from above

Using 0 for “unreachable”

When you maximise (collecting gold) or cells can be negative, 0 looks like a real, reachable score and beats genuine negative paths. Use -inf for “can’t get here” when maximising, inf when minimising, and never add a cell’s value to it as if it were real.

Overflow on large grids

A 1,000 × 1,000 grid with costs up to 10⁹ has paths costing up to about 2 × 10¹². That overflows a 32-bit int in C++ and Java: use long long or long. Path counts grow even faster; problems ask for them modulo a prime for that reason.

Variations

  • Counting paths. Replace min with +: ways[r][c] = ways[r-1][c] + ways[r][c-1], starting from ways[0][0] = 1. With no obstacles the answer is C(rows + cols − 2, rows − 1), a handy check: a 3 × 7 grid has C(8, 2) = 28 paths.
  • Obstacles. A blocked cell gets ways = 0 (or inf cost), and nothing flows through it. Don’t pre-fill the whole first row and column with 1: every cell after a block in that row or column is unreachable too.
  • Maximum gold. Collect the most along the way: use max instead of min, and -inf for cells you can’t reach.
  • Dungeon game: fill backwards. When you need the smallest starting health that never drops to zero, the answer at a cell depends on what comes after it. Fill from the bottom-right corner up and left: need[r][c] = max(1, min(need[r+1][c], need[r][c+1]) − grid[r][c]).
  • Diagonal moves. If you may also step down-right, each cell has three ways in: min(dp[r-1][c], dp[r][c-1], dp[r-1][c-1]). The same table, one more neighbour. String DPs like edit distance have exactly this shape.

Check yourself

5 quick questions. Pick an answer to see why it's right or wrong.

  1. 1

    Cheapest path from the top-left to the bottom-right of a grid of non-negative costs, but now you may step up, down, left or right. What’s the approach?

  2. 2

    Counting right/down paths with one rolling row. What does this print?

    rows, cols = 3, 7
    ways = [1] * cols
    for _ in range(rows - 1):
    for c in range(1, cols):
    ways[c] += ways[c - 1]
    print(ways[-1])
  3. 3

    What does this print?

    grid = [[3, 4, 8],
    [9, 2, 3],
    [2, 1, 5]]
    INF = float("inf")
    dp = [INF] * 3
    dp[0] = 0
    for row in grid:
    for c in range(3):
    left = dp[c - 1] if c > 0 else INF
    dp[c] = row[c] + min(dp[c], left)
    print(dp)
  4. 4

    Counting right/down paths around blocked cells. There’s only one real path here (down first). What does this code print?

    grid = [[0, 1, 0],
    [0, 0, 0]] # 1 = blocked
    rows, cols = len(grid), len(grid[0])
    ways = [[0] * cols for _ in range(rows)]
    for c in range(cols):
    ways[0][c] = 1
    for r in range(rows):
    ways[r][0] = 1
    for r in range(1, rows):
    for c in range(1, cols):
    ways[r][c] = 0 if grid[r][c] else ways[r - 1][c] + ways[r][c - 1]
    print(ways[-1][-1])
  5. 5

    You collect the most gold on a right/down path. Cells can hold negative values (traps), and some cells are walls you can’t enter. Why mark walls and unreachable cells with -inf instead of 0?

Practice problems

Further reading

esc