A robot starts in the top-left cell of an m by n grid and may only move right or down. How many routes reach the bottom-right cell? This is the unique paths problem. It is a common first two-dimensional dynamic programming exercise, and it also underlies sequence alignment, lattice-path probability and the binomial coefficients themselves.

The easy answer fits in four lines. The useful knowledge is everything around it: why the recurrence is correct, how to cut memory to one row, the closed form and how it overflows, obstacles, grids too large to tabulate, answers modulo a prime, and the variants that look similar but need a different recurrence. Every number on this page was computed, not copied.

The recurrence

Name cells (r, c) from (0, 0) at the top left. The last move into (r, c) came either from above, (r - 1, c), or from the left, (r, c - 1). These two sets of routes are disjoint, because they differ in their final step. Together they cover every route. So by the addition rule:

paths(0, c) = 1                 # first row: only rightward moves
paths(r, 0) = 1                 # first column: only downward moves
paths(r, c) = paths(r - 1, c) + paths(r, c - 1)

This is optimal substructure in its plainest form. The count for a cell depends only on two smaller subproblems, and those subproblems overlap heavily. Plain recursion therefore takes exponential time. The table fills in O(mn) time, as the general dynamic programming guide explains for the whole family.

Path counts: 3 x 7 open grid (left) and 4 x 5 grid with two blocked cells (right)1111111123456713610152128each cell = cell above + cell to the left111111X123112X312447blocked cells hold 0; answer 7 of 35
Left: filling the 3 x 7 table row by row gives 28. Right: obstacles are cells whose count is forced to zero, so the 4 x 5 grid drops from 35 routes to 7.

One row is enough

Row r needs only row r - 1. One array of length n is enough, updated left to right. Before the update dp[c] holds the value from above, and dp[c - 1] already holds the new value from the left.

def unique_paths(m: int, n: int) -> int:
    if m <= 0 or n <= 0:
        return 0
    if n > m:                        # keep the array on the shorter side
        m, n = n, m
    dp = [1] * n
    for _ in range(1, m):
        for c in range(1, n):
            dp[c] += dp[c - 1]
    return dp[-1]

assert unique_paths(3, 7) == 28
assert unique_paths(1, 1) == 1

Time is O(mn) and memory O(min(m, n)). Swapping so that the array runs along the short side matters for 10 by 1,000,000 grids, where it is the difference between ten numbers and a million.

The closed form and its overflow trap

Every route has exactly m - 1 down moves and n - 1 right moves, in some order. A route is a choice of which of the m + n - 2 steps are downs, so the count is C(m + n - 2, m - 1). For 3 by 7 that is C(8, 2) = 28, matching the table. The combinatorics article covers the counting rules behind this. The closed form costs O(min(m, n)) arithmetic operations:

def unique_paths_closed(m: int, n: int) -> int:
    k = min(m, n) - 1
    total = m + n - 2
    r = 1
    for i in range(1, k + 1):
        r = r * (total - k + i) // i   # exact: r is C(total - k + i, i) after each step
    return r

The division is always exact, because after step i the running value is itself a binomial coefficient. Python integers never overflow, but fixed-width ports do, and at different sizes:

Square grid sideAnswerWhat breaks
18C(34, 17) = 2,333,606,220exceeds signed 32-bit
32C(62, 31) = 465,428,353,255,261,088answer fits 64-bit, but r * (total - k + i) overflows first
34C(66, 33), about 7.2 x 10^18still fits signed 64-bit (DP is safe)
35C(68, 34), about 2.8 x 10^19exceeds signed and unsigned 64-bit

The row at side 32 is the subtle one. The DP only ever adds, so its values never exceed the answer. The multiply-then-divide loop builds an intermediate value larger than the answer. A C or Java port of the closed form therefore fails on inputs where the DP would have succeeded. Fixes include dividing by the gcd before multiplying, using 128-bit intermediates, or using the DP.

Obstacles

With blocked cells, the recurrence still holds. Blocked cells contribute zero, and the first row and column are no longer all ones, because a block cuts off everything after it.

def unique_paths_obstacles(grid: list[list[int]]) -> int:
    """grid[r][c] == 1 means blocked."""
    if not grid or not grid[0]:
        return 0
    n = len(grid[0])
    dp = [0] * n
    dp[0] = 1 if grid[0][0] == 0 else 0          # a blocked start means zero paths
    for row in grid:
        for c in range(n):
            if row[c] == 1:
                dp[c] = 0
            elif c > 0:
                dp[c] += dp[c - 1]
    return dp[-1]

g = [[0, 0, 0, 0, 0],
     [0, 1, 0, 0, 0],
     [0, 0, 0, 1, 0],
     [0, 0, 0, 0, 0]]
assert unique_paths_obstacles(g) == 7

Trace it on the 4 by 5 grid in the diagram. Row 0 is all ones. In row 1 the block at column 1 zeroes that cell, so the row reads 1, 0, 1, 2, 3. Row 2 reads 1, 1, 2, 0, 3, and row 3 ends at 7. Without obstacles the answer would be C(7, 3) = 35.

Huge grids with few obstacles

If the grid is 100,000 by 100,000 with a few hundred blocked cells, the table is impossible but the closed form still works between points. Sort the blocked cells by row, then column. Add the target as a final point. For each point, compute f(i), the number of routes that reach it without touching an earlier blocked cell. That is all routes to it, minus routes that first hit some earlier blocked cell j and then continue to i:

def paths_avoiding(points, target, MOD=10**9 + 7):
    pts = sorted(points) + [target]
    fact, inv = build_factorials(target[0] + target[1], MOD)
    def C(a, b):
        return 0 if b < 0 or b > a else fact[a] * inv[b] % MOD * inv[a - b] % MOD
    def ways(p, q):                    # monotone routes from p to q
        dr, dc = q[0] - p[0], q[1] - p[1]
        return C(dr + dc, dr) if dr >= 0 and dc >= 0 else 0
    f = []
    for i, q in enumerate(pts):
        v = ways((0, 0), q)
        for j in range(i):
            v -= f[j] * ways(pts[j], q)
        f.append(v % MOD)
    return f[-1]

On the 4 by 5 example: f(1, 1) = 2 and f(2, 3) = 10 - 2 x 3 = 4. The target gets 35 - 2 x 10 - 4 x 2 = 7, matching the DP. The cost is O(k squared) for k obstacles plus factorial tables, regardless of grid size. Answers at this scale are astronomically large, so they are usually requested modulo a prime p. Then build_factorials precomputes factorials and their modular inverses via Fermat. When p is smaller than m + n, factorials hit zero, so switch to Lucas' theorem.

Sampling a route uniformly

Counts do more than answer how many. If you know the number of routes from every cell to the goal, you can draw a route uniformly at random. At each cell, move down with probability equal to the routes through the cell below divided by the routes from here, otherwise move right. Each step preserves uniformity, so every complete route has probability one over the total. This is how you generate unbiased test cases, estimate properties of a typical route by Monte Carlo, or build procedural maps with guaranteed reachability.

import random

def sample_path(grid, rng=random):
    """Uniformly random right/down route avoiding blocked cells, or None."""
    m, n = len(grid), len(grid[0])
    ways = [[0] * (n + 1) for _ in range(m + 1)]   # routes from (r, c) to the goal
    for r in range(m - 1, -1, -1):
        for c in range(n - 1, -1, -1):
            if grid[r][c]:
                ways[r][c] = 0
            elif (r, c) == (m - 1, n - 1):
                ways[r][c] = 1
            else:
                ways[r][c] = ways[r + 1][c] + ways[r][c + 1]
    if ways[0][0] == 0:
        return None
    r = c = 0
    moves = []
    while (r, c) != (m - 1, n - 1):
        if rng.randrange(ways[r][c]) < ways[r + 1][c]:
            moves.append("D"); r += 1
        else:
            moves.append("R"); c += 1
    return "".join(moves)

The table is filled backwards, from the goal, with a padding row and column of zeros so that edge cells need no special case. Drawing 70,000 samples on the 4 by 5 grid in one run gave each of its 7 routes between 9,891 and 10,167 times, against an expected 10,000. Using randrange on exact integers avoids the bias that floating-point ratios introduce once counts exceed 2 to the 53. The same suffix table also ranks and unranks routes in lexicographic order, which lets you shard an enumeration across workers by index range.

Variants

VariantChangeNote
Minimum-cost pathmin(cost above, cost left) + cellsame table, different semiring; see path sum problems
Diagonal moves allowedadd paths(r - 1, c - 1)Delannoy numbers: D(2, 2) = 13 on a 3 x 3 grid
Paths through cell Xforward count to X times backward count from Xtwo tables, one product
Must stay on or below the diagonalzero cells above itCatalan numbers
Exactly k turnsadd a direction and turn-count dimensionstate grows to O(mnk)
Moves in all four directionsno longer a DAGcounting simple paths in general graphs is #P-complete; use BFS for shortest paths

Operational guidance

  • Decide the number type first. Exact big integers, 64-bit with a proven size bound, or modulo a prime. Write the choice into the function contract.
  • Cross-check implementations. Test the DP against the closed form on every grid up to 20 by 20, and the inclusion-exclusion version against the obstacle DP on random small grids.
  • Validate inputs. Zero or negative sizes, a blocked start or end, and ragged rows all need explicit handling.
  • Avoid deep recursion. Memoised top-down code recurses about m + n deep. That is fine at 50 by 50 and hits Python's default limit of 1,000 frames once m + n nears 1,000.
  • Count probabilities in log space. If you need the fraction of routes with a property, divide counts in floating point only after taking logs, or use exact fractions.

Failure modes

  • Initialising the first row and column to 1 with obstacles present. Cells after a block in row 0 or column 0 must be 0.
  • Off-by-one in the binomial. The answer is C(m + n - 2, m - 1), not C(m + n, m).
  • Integer overflow in the closed form. It happens before the DP would overflow, as the side-32 row shows.
  • Modulo applied inconsistently. Subtraction in inclusion-exclusion can go negative. Python's % always returns a non-negative result, so one reduction at the end works. In C or Java the remainder of a negative number is negative, so add MOD before reducing.
  • Iterating the 1-D array right to left. Then dp[c - 1] still holds the old row, and the answer is wrong without any error.

Trade-offs

MethodTimeMemoryHandles obstacles
2-D tableO(mn)O(mn)yes; keeps every count for path reconstruction
Rolling rowO(mn)O(min(m, n))yes
Closed formO(min(m, n))O(1)no
Inclusion-exclusionO(k squared + m + n)O(k + m + n)yes, sparse obstacles on huge grids

What to do next

  1. Implement unique_paths and the closed form, and assert they agree for every m, n up to 20.
  2. Port the closed form to a language with 64-bit integers and confirm it fails at side 32. Then fix it.
  3. Run the obstacle DP and the inclusion-exclusion version on the 4 by 5 grid and get 7 from both.
  4. Write build_factorials with Fermat inverses and count routes on a 100,000 by 100,000 grid with 200 random blocked cells modulo 1,000,000,007.
  5. Add diagonal moves and check D(2, 2) = 13. Then try the on-or-below-diagonal variant and compare it with the Catalan numbers.
Key takeaway: The number of right-and-down routes through an m by n grid is C(m + n - 2, m - 1). The DP that adds the counts above and to the left computes it in one row of memory, and it extends naturally to obstacles. Choose the number type before coding: the multiply-then-divide closed form overflows 64-bit integers at side 32, even though the answer fits. For huge sparse grids, use inclusion-exclusion over sorted obstacles modulo a prime.