Problem
Starting from the top-left corner, what is the number of possible unique paths to reach the bottom-right corner, if you can only move either down or right at any point in time?
Dynamic Programming Solution for Moving Down/Right
The number of unique paths to grid[r][c]
is the number of unique paths
to grid[r-1][c]
plus the number of unique paths to grid[r][c-1]
,
e.g.,
1 | 1 | 1 | 1 |
1 | 2 | 3 | 4 |
1 | 3 | 6 | 10 |
At any given time, we’re interested in two adjacent rows, so our space usage should be at most \(2n\). Furthermore, we do not back-track to the left, so if we update our values left-to-right, we can use \(n\) space because the current cell will have the value from what would have been in the previous row.
int uniquePaths(int m, int n) {
if (m <= 0 || n <= 0) return 0;
std::vector<int> numPathsToPosition(n, 0);
numPathsToPosition[0] = 1;
for (int r = 0; r < m; ++r) {
for (int c = 0; c < n; ++c) {
if (int nextC = c + 1; nextC < n) {
numPathsToPosition[nextC] += numPathsToPosition[c];
}
}
}
return numPathsToPosition[n-1];
}
The above solution is pretty fast for ’s online judge, so the runtime (less than 5ms) is not comparable. The runtime is \(O(m n)\).
The memory usage is \(O(n)\). Per , that’s \(\approx\) 6MB which is better than 63% of submissions. The range is 5,400 KB to 6,500 KB, so all submissions within an order of magnitude of each other.
Combinatorics Solution for Moving Down/Right
models the problem as a combinatorics problem: to get to the bottom-right, we need to choose \(m - 1\) right moves and \(n - 1\) down moves, from a total of \(m + n - 2\) moves.
$$ C_{m-1}^{m+n-2} = C_{n-1}^{m+n-2} = \frac{(m+n-2)!}{(m-1)!(n-1)!} $$
Why is \(C_{m-1}^{m+n-2} = C_{n-1}^{m+n-2}\) true? In general, \(C^{n}_{k} = \frac{n!}{k!(n-k)!}\), and so this follows algebraically. It’s not a special property of the problem.
Assuming \(m > n\) in order to eliminate \((m-1)!\) gives us:
$$ C_{m-1}^{m+n-2} = \frac{(m+n-2) \times (m+n-3) \times … \times (m+n-n) \times (m-1) \times … \times 1 }{ ((m-1) \times (m-2) \times … \times 1) (n-1)!} $$ $$ = \frac{(m+n-2) \times (m+n-3) \times … \times m}{(n-1)!} $$
How is the above guaranteed to be an integer?
By definition, \(C^{n}_{k}\) counts something, and therefore it’s an integer.
$$ C^{n}_{k} = \binom{n}{k} = \frac{n}{k!(n-k)!} $$ $$ = \frac{n (n-1) (n-2) … (n - k + 1)}{k!} $$
… the numerator is a product of \(k\) successive integers, and (whose proof is beyond my mathematical maturity) states that the factorial of \(k\) divides the product of \(k\) successive numbers.
The mathematical aspect is settled, but why does the code before work without running into precision errors? Shouldn’t we first compute the numerator, and then divide out the denominator? Dividing integral types is pretty bad as it truncates the result.
The largest numerator possible is when \(m = 100, n = 100\), which is
\(198 \cdot 197 \cdot … \cdot 100 = \frac{198!}{99!} < 2^{712}\).
The largest ints in are 64
bits wide, and thus too small to hold \(2^{712}\). long double
’s max
is \(1.18973 \cdot 10^{4932}\), which can hold \(2^{712} < 2.155
\cdot 10^{214}\), but with loss of precision. To use a long double
without losing precision, then we’re limited by LDBL_MANT_DIG
, which
is typically 64
, and thus not better than the biggest int
from the
standard library.
Maybe the takeaway is that if you’re worried about large numbers (factorials and such), use a language that natively supports infinite-precision arithmetic, e.g. Python?
int unique_paths(int m, int n) {
long num_combinations = 1;
const int m_prime = max(m, n);
// Because m' > n', when `num <= m'` all of the values in [1, n') will
// have been visited by the loop.
for (int num = m + n - 2, denom = 1; num >= m_prime; --num, ++denom) {
num_combinations *= num;
if (denom < n) num_combinations /= denom;
}
return num_combinations;
}
Re-ordering \(m, n\), such that \(m\) is the max, the loop goes through \(m + n - 2 - m = n - 2\) iterations, and therefore the runtime is \(O(min(m, n))\). The space usage is \(O(1)\). I don’t think we can do better than this.
Curiously, the space usage for the combinatorics approach is 5.9MB. I thought it’d be much lower than the 6MB used in the DP solution. On second thought, the DP solution uses \(O(n)\) space, and the problem is defined such that \(n \le 100\), which is close to \(O(1)\).
Takeaways
I was wrong to earlier assume that because other people’s solutions were within one magnitude of 6MB, the DP solution was the optimal one. Sometimes \(n\) is too small to distinguish between optimal and sub-optimal solutions.
The regularity of the problem (well-defined grid; only moving right/down) should hint at a (probably optimal) math-based answer.
The answer is guaranteed to be \(\le 2 \cdot 10^9 = 2^{lg(2 \cdot 10^9)} < 2^{31}\). The
int
data type is guaranteed to be at least 16 bits, but on 32/64 bit systems, it’s almost guaranteed to be at least 32 bits wide . I can specifystd:uint32_t
from<cstdint>
to be sure that a 32-bit representation is used .std:uint32_t
is unlikely to improve the space usage as I don’t see solutions that use 3MB. It’s more likely that LeetCode’s environment uses 32-bitint
s. Update: no improvement, bothsizeof(int)
andsizeof(std::uint32_t)
evaluate to4
bytes (and therefore 32 bits).