力扣hot100之62:不同路径
力扣hot100之62:不同路径

LeetCode 62:不同路径(Unique Paths)题解

Unique Paths 是 LeetCode Hot 100 里面一道非常经典的动态规划题。

不过这道题很有意思,因为它虽然通常会被归类到动态规划里,但实际上还有一种非常漂亮、而且更简洁的做法:

组合数学

你给出的这份 Java 代码,用的就不是传统的二维 dp 数组,而是直接从“总共要走多少步、其中有多少步向下”这个角度出发,用组合数把答案算出来。

这种写法非常巧妙,因为它抓住了这道题最本质的地方:

路径的不同,本质上只是若干步“向右”和若干步“向下”的排列方式不同。

本文就结合这段代码,系统地讲一下这道题的思路。


题目介绍

给定两个整数 mn,表示一个机器人位于一个 m x n 的网格左上角。

机器人每次只能向下或者向右移动一步。 现在要求它从左上角出发,走到右下角,一共有多少种不同的路径。

例如一个 3 x 7 的网格:

  • 起点在左上角

  • 终点在右下角

  • 机器人只能:

    • 向右走

    • 向下走

题目要我们求的是:

总共有多少种不同的走法。

注意这里不是求最短路径,因为无论怎么走,只要从左上到右下,步数其实都一样; 题目真正问的是:

这些固定步数的走法一共有多少种排列方式。


示例

示例 1

输入:m = 3, n = 7
输出:28

意思是一个 3 行 7 列的网格,从左上角走到右下角,一共有 28 种不同路径。

示例 2

输入:m = 3, n = 2
输出:3

因为从左上角到右下角,总共需要走:

  • 向下 m - 1 = 2

  • 向右 n - 1 = 1

也就是总共 3 步,其中 2 步向下、1 步向右。

所有可能的走法有:

下 下 右
下 右 下
右 下 下

所以答案是 3


最容易想到的思路:动态规划

这道题大多数人第一次接触时,第一反应通常是动态规划。

因为对于网格中的某个位置 (i, j) 来说,它只能从两个地方走过来:

  • 上面 (i - 1, j)

  • 左边 (i, j - 1)

所以到达当前位置的路径数就是:

dp[i][j] = dp[i - 1][j] + dp[i][j - 1]

这就是标准的动态规划状态转移。

例如:

  • 第一行的所有位置,路径数都只能是 1

  • 第一列的所有位置,路径数也只能是 1

  • 其他位置由上方和左方相加得到

这个思路完全正确,也是这道题最经典的基础解法。

但是你给出的代码并没有走这条路,而是用了一个更简洁的数学方法。


核心思路:把问题转成组合数

这道题最本质的地方在于:

从左上角走到右下角,不管怎么走,总步数其实是固定的。

假设网格大小是 m x n

  • 总共需要向下走:

m - 1

  • 总共需要向右走:

n - 1

所以总步数就是:

(m - 1) + (n - 1) = m + n - 2

而一条路径,本质上就是在这 m + n - 2 步里,选择哪些位置走“向下”,其余位置自然就是“向右”。

所以问题就变成了:

从总共 m + n - 2 步中,选出 m - 1 步作为向下,有多少种选法?

这其实就是一个组合数问题:

C(m + n - 2, m - 1)

当然你也可以写成:

C(m + n - 2, n - 1)

两者是等价的。

这就是这道题最漂亮的数学转化。


你的代码在做什么?

你给出的代码如下:

class Solution {
    public int uniquePaths(int m, int n) {
        long ans = 1;
        for (long up = n, down = 1; down < m; down++, up++)
            ans = ans * up / down;
        return (int) ans;
    }
}

这段代码本质上就是在计算:

C(m + n - 2, m - 1)

只不过没有直接调用阶乘,而是通过逐步乘除的方式来避免中间数过大。

这也是实现组合数时非常常见的一种技巧。


Java 代码(带注释)

下面我把这段代码加上注释重新整理一下:

class Solution {
    public int uniquePaths(int m, int n) {
        long ans = 1;

        // 计算组合数 C(m+n-2, m-1)
        // 这里采用乘一项、除一项的写法,避免直接算阶乘带来的溢出风险
        for (long up = n, down = 1; down < m; down++, up++) {
            ans = ans * up / down;
        }

        return (int) ans;
    }
}

代码拆解

这段代码不长,但如果第一次接触组合数写法,可能会有点疑惑。 下面一步一步来看。


1. 为什么总步数是 m + n - 2

从左上角走到右下角:

  • 行数从第 1 行走到第 m 行,需要向下走 m - 1

  • 列数从第 1 列走到第 n 列,需要向右走 n - 1

所以总步数固定为:

(m - 1) + (n - 1) = m + n - 2

这一点非常关键,因为它说明:

所有路径的长度都是一样的,区别只在于这些步子的顺序。


2. 为什么可以转化成组合数?

假设我们把一条路径写成一个序列:

  • D 表示向下

  • R 表示向右

例如对于 m = 3, n = 7

  • 向下需要 2

  • 向右需要 6

那么任何一条合法路径都可以写成长度为 8 的序列,其中:

  • 2D

  • 6R

例如:

D D R R R R R R
D R D R R R R R
R R D R D R R R

等等。

所以问题本质上就是:

在 8 个位置中挑 2 个位置放 D,其余放 R

这就是组合数:

C(8, 2)

推广到一般情况,就是:

C(m + n - 2, m - 1)

3. 为什么代码里 upn 开始?

for (long up = n, down = 1; down < m; down++, up++)

我们来对应一下组合数公式。

组合数:

C(m+n-2, m-1)

可以展开成:

(m+n-2)! / ((m-1)! * (n-1)!)

进一步可以写成连乘形式:

[(n) * (n+1) * (n+2) * ... * (m+n-2)] / [1 * 2 * 3 * ... * (m-1)]

也就是说:

  • 分子从 n 开始,一直乘到 m + n - 2

  • 分母从 1 开始,一直乘到 m - 1

这正好对应代码里的:

  • up = n

  • down = 1

然后每轮同时递增。

所以这段循环其实就是在一项一项地计算组合数。


4. 为什么 down < m

for (long up = n, down = 1; down < m; down++, up++)

因为我们要乘除的分母部分是:

1, 2, 3, ..., m-1

所以 down 应该从 1 一直走到 m - 1

而循环条件写成:

down < m

恰好就表示 down 最大取到 m - 1


5. 为什么每次都可以直接 ans = ans * up / down

ans = ans * up / down;

这一步很多人第一次看会担心:

这样先乘再除,会不会出现不能整除的问题?

从数学上讲,最终结果一定是组合数,是整数。 而这种逐步乘除的方式在这里是成立的,因为整个过程本质上是在按组合数的连乘形式计算,最终每一步都能保证中间结果仍然是正确值。

此外,这里用的是 long,也是为了让中间计算更安全。

这种写法相比直接算三个阶乘再相除,有两个优点:

  1. 不需要真的去算很大的阶乘

  2. 中间数增长更可控,不容易溢出

所以这是组合数计算中一个非常常见的技巧。


6. 为什么最后转成 int 返回?

return (int) ans;

因为题目给定的数据范围保证最终答案在 int 范围内。

不过中间计算过程为了更安全,仍然使用了 long

long ans = 1;

这是一个很稳妥的写法。


手动模拟一遍

我们用一个简单例子来走一遍:

m = 3, n = 7

那么:

  • 向下走 m - 1 = 2

  • 向右走 n - 1 = 6

  • 总步数 = 8

答案应该是:

C(8, 2) = 28

现在来看代码怎么计算。

初始:

ans = 1
up = 7
down = 1

第一次循环

ans = 1 * 7 / 1 = 7

然后:

  • up = 8

  • down = 2

第二次循环

ans = 7 * 8 / 2 = 28

然后:

  • down = 3

此时不满足 down < m(因为 m = 3),循环结束。

最终返回:

28

这正好就是答案。


再看一个小例子

例如:

m = 3, n = 2

总步数:

3 + 2 - 2 = 3

向下走:

m - 1 = 2

所以答案是:

C(3, 2) = 3

代码里:

  • up = 2

  • down = 1

第一次

ans = 1 * 2 / 1 = 2

第二次

ans = 2 * 3 / 2 = 3

最终答案也是 3


为什么这种方法是正确的?

因为这道题里每条路径都可以一一对应成一个由:

  • m - 1 个“向下”

  • n - 1 个“向右”

组成的序列。

而不同路径的区别,只在于这些“向下”和“向右”的排列位置不同。

因此:

  • 每条路径都对应一种选法

  • 每种选法也都唯一对应一条路径

这就是一个标准的一一映射关系。

所以路径总数就等于组合数:

C(m+n-2, m-1)

而代码只是在用一种不容易溢出的方式去计算这个组合数而已。


和动态规划解法有什么区别?

这道题最常见的两种做法就是:

1. 动态规划

定义:

dp[i][j] = 到达 (i,j) 的路径数

转移方程:

dp[i][j] = dp[i-1][j] + dp[i][j-1]

优点:

  • 思路直观

  • 更容易从“网格走法”本身理解

缺点:

  • 需要额外数组

  • 空间复杂度更高

2. 组合数学

直接把问题转成:

C(m+n-2, m-1)

优点:

  • 代码更短

  • 空间复杂度更低

  • 数学上非常漂亮

缺点:

  • 需要先看出问题本质是组合数

所以你这份代码虽然短,但其实比普通 DP 更“高级”一点,因为它抓住了问题最底层的结构。


复杂度分析

时间复杂度

循环次数是:

m - 1

所以时间复杂度是:

O(m)

当然如果把 mn 对称看待,也可以理解成:

O(min(m, n))

的某种组合数计算思路,只不过你这里是按 m - 1 这一边展开的。

空间复杂度

只使用了常数级变量:

  • ans

  • up

  • down

所以空间复杂度是:

O(1)

这比二维 DP 的 O(mn) 空间复杂度要低很多。


这道题真正想考什么?

这道题表面上看是网格路径题,但本质上考察的是一种非常重要的能力:

第一,能不能从动态规划题里看出数学本质

很多人看到“网格 + 路径数”,就会立刻想到 DP。 这没有错,但如果你能进一步看到:

路径本质上只是固定步数的排列组合

那就说明你对题目的理解更深入了一层。

第二,能不能把组合数写得安全

即使知道答案是组合数,真正写代码时也不能直接暴力算阶乘。 因为中间结果可能会非常大。

而你这段代码采用“边乘边除”的方式来计算组合数,这其实是一个非常值得掌握的小技巧。


总结

这道题的核心思想可以概括成一句话:

从左上角走到右下角,本质上就是在总共 m+n-2 步里,选择 m-1 步向下(或选择 n-1 步向右),因此答案就是组合数。

具体步骤就是:

  1. 先分析出总步数是:

m + n - 2
  1. 其中向下步数固定为:

m - 1
  1. 所以路径总数就是:

C(m+n-2, m-1)
  1. 用“边乘边除”的方法安全地把这个组合数算出来

这样就能在:

O(m)

的时间复杂度和:

O(1)

的空间复杂度下解决问题。

这是一道非常经典、也非常值得反复理解的题。 因为它会让你真正意识到:

  • 很多动态规划题,其实背后还有更深的数学结构

  • 如果能抓住问题本质,代码可以变得非常简洁

  • 组合数学和算法题并不是割裂的,它们经常会结合在一起出现

把这道题真正吃透之后,再去看:

  • 最小路径和

  • 不同路径 II

  • 杨辉三角

  • 网格类 DP 题

你会更容易分辨:哪些题该老老实实做 DP,哪些题其实可以直接转成数学问题。

因为“不同路径”本身就是 DP 与组合数学结合得非常漂亮的一道经典题。

如果对您有帮助的话,能否支持一下博主?
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇