题目描述:

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。

问总共有多少条不同的路径?

image
输入:m = 3, n = 7
输出:28
示例 2:

输入:m = 2, n = 3
输出:3
解释: 从左上角开始,总共有 3 条路径可以到达右下角。

向右 -> 向右 -> 向下
向右 -> 向下 -> 向右
向下 -> 向右 -> 向右
示例 3:

输入:m = 7, n = 3
输出:28
示例 4:

输入:m = 3, n = 3
输出:6
提示:

1 <= m, n <= 100
题目数据保证答案小于等于 2 * 10^9

思路分析:

动态规划入门

动态规划(Dynamic Programming,DP)的核心思想是将问题拆解为子问题,通过记录这些子问题的答案来避免重复计算,从而提高计算效率。具体来说,当我们解决一个大问题时,先解决它的子问题,并将这些子问题的最优解存储在一个表格(通常是数组或矩阵)中。在之后的计算过程中,可以直接查找表格中存储的解,而不必重新计算。

动态规划问题通常需要满足两个条件:

  1. 最优子结构(Optimal Substructure):问题的最优解可以通过子问题的最优解推导出来。也就是说,解决一个大问题时,可以通过合并多个子问题的解来得到最终的解。
  2. 重叠子问题(Overlapping Subproblems):问题可以被分解成多个相同的子问题,这些子问题在计算过程中会重复出现,因此适合用记忆化搜索或表格存储来避免重复计算。
  3. 无后效性:一旦某一个子问题的求解结果确定以后,就不会再被修改。

解决动态规划问题有两种常见的思考方式:

  1. 自顶向下(Top-down):这种方法通常采用递归的方式解决问题,并结合记忆化技术(Memoization)来记录子问题的解。通过递归函数的调用,我们可以从最终问题逐步向子问题推导,并在每次调用时检查是否已经计算过该子问题,若已计算,则直接返回结果,避免重复计算。

  2. 自底向上(Bottom-up):自底向上的方法先从最小的子问题开始逐步解决,直到最终问题。这通常通过填充表格的方式实现。自底向上的动态规划在实现上通常更高效,因为它不涉及递归的开销,并且可以更清晰地展现问题的结构。

解决动态规划的5个步骤

在解决动态规划(DP)问题时,通常需要以下几个步骤来构建和推导出解决方案:

1. 确定 dp 数组(DP table)以及下标的含义

dp 数组是用来存储子问题的解的数组。它的定义通常依赖于问题的状态。例如,如果问题涉及到某个选择的状态或某个过程的步骤,dp[i] 就表示某个特定状态下的问题解。

  • 状态dp[i] 表示问题在某一特定条件下的最优解(可能是最大值、最小值、次数等)。
  • 下标的含义:根据问题的特性,i 表示当前的状态或问题的规模。比如在背包问题中,i 可能表示背包容量;在最短路径问题中,i 可能表示某个节点。

2. 确定递推公式

递推公式描述了如何根据已解决的子问题来计算更大的问题的解。递推公式是动态规划问题的核心,它直接决定了算法的时间复杂度。

  • 递推公式通常是通过分析问题的结构得出的。
  • 它表示如何将子问题的解合并来得到更大问题的解。
  • 对于某些问题,可能有多种递推公式(不同的状态转移方式),需要选择最合适的一种。

3. dp 数组如何初始化

初始化 dp 数组是动态规划的一个重要步骤。初始化通常是根据问题的边界条件来确定的。

  • 边界条件:通常是 dp[0]dp[1] 等特殊情况。例如,在背包问题中,当背包容量为 0 时,最大价值为 0;在最短路径问题中,起始节点的距离通常初始化为 0,其他节点初始化为无穷大。
  • 基础状态:在一些问题中,可能需要根据初始条件来填充 dp 数组的某些部分,例如初始状态的解。

4. 确定遍历顺序

遍历顺序决定了 dp 数组的填充顺序,也就是决定了子问题的解的计算顺序。通常有两种常见的遍历方式:

  • 从小到大(自底向上):从最小的子问题开始计算,逐步扩展到更大的问题。常用于问题的规模逐渐增大的情况。
  • 从大到小(自顶向下):从较大的子问题开始计算,逐步将其拆解为更小的子问题。这个方法在递归的情况下尤为常见。

5. 举例推导 dp 数组

让我们通过一个经典的动态规划问题来说明这些步骤:0/1 背包问题

问题描述:

给定一个背包容量 Wn 个物品,每个物品有一个重量和价值。问:如何选择物品放入背包,使得背包内的物品总价值最大,且总重量不超过背包容量。

步骤解析:

  1. 确定 dp 数组的含义

    • 定义 dp[i][w] 为前 i 个物品中,背包容量为 w 时的最大价值。
    • i 表示当前考虑的物品的编号(1 到 n)。
    • w 表示当前背包的容量(0 到 W)。
  2. 确定递推公式
    对于每个物品 i 和背包容量 w,有两种选择:

    • 不放入物品 i,则 dp[i][w] = dp[i-1][w]
    • 放入物品 i,则 dp[i][w] = dp[i-1][w-weight[i]] + value[i](前提是当前背包容量 w 足够容纳物品 i)。

    递推公式为:
    image

  3. 初始化 dp 数组

    • dp[0][w] = 0:表示没有物品时,背包的最大价值为 0。
    • dp[i][0] = 0:表示背包容量为 0 时,不管有多少物品,最大价值都为 0。
  4. 确定遍历顺序

    • 从小到大遍历:首先遍历物品 i1n,然后对每个物品遍历背包容量 w1W
  5. 举例推导 dp 数组
    假设有 3 个物品,背包容量为 4。物品的重量和价值分别为:

    • 物品 1: 重量 2, 价值 3
    • 物品 2: 重量 1, 价值 2
    • 物品 3: 重量 3, 价值 4

    初始化 dp 数组:
    image

    填充 dp 数组:

    • 考虑物品 1,容量从 1 到 4:

      • dp[1][1] = 0(不能放入物品 1)
      • dp[1][2] = 3(可以放入物品 1)
      • dp[1][3] = 3(可以放入物品 1)
      • dp[1][4] = 3(可以放入物品 1)
    • 考虑物品 2,容量从 1 到 4:

      • dp[2][1] = 2(可以放入物品 2)
      • dp[2][2] = 3(可以放入物品 1 或物品 2)
      • dp[2][3] = 3(可以放入物品 1 或物品 2)
      • dp[2][4] = 5(可以放入物品 1 和物品 2)
    • 考虑物品 3,容量从 1 到 4:

      • dp[3][1] = 2
      • dp[3][2] = 3
      • dp[3][3] = 4(可以放入物品 3)
      • dp[3][4] = 5(可以放入物品 2 和物品 3)

    最终 dp[3][4] = 5,表示最大价值是 5。

总结:

  • dp 数组:用于存储子问题的解。
  • 递推公式:描述如何通过子问题的解来推导出更大问题的解。
  • 初始化:根据边界条件初始化 dp 数组。
  • 遍历顺序:确定从哪个子问题开始计算,以确保可以使用已经计算出的结果。

通过这种方式,我们可以逐步推导出问题的最终解。

本题解法

本题都是从左上角出发,要求达到右下角。每次只能向右或者向下,就是这两种选择。那么以此为例:image
想要到达三角形处,有两种选择:
1.从1处向右一格
2.从2处向下一格
所以:\(dp[i][j] = dp[i - 1][j] + dp[i][j - 1]\).那我们要求的是到指定点的路径条数,\(dg[i][j]\)很自然就是到达指定点的路径条数。

  1. 初始化分析
    在动态规划问题中,初始值非常重要。初始值通常是边界条件,它决定了在计算其他值时的起始状态。
    假设我们从坐标 (0, 0) 开始:
    \(dp[0][0] = 1\):这是起点,表示从起点到起点有 1 条路径(即起点本身)。
    如果没有其他选择(例如,格子外部或障碍物),则第一行和第一列的值都是 1,因为它们只有一种路径:一直从左或从上到达。
    \(dp[0][j] = 1\)(对于所有 j),表示从 (0, 0) 到达第一行的任何位置都有 1 条路径(只能从左走)。
    \(dp[i][0] = 1\)(对于所有 i),表示从 (0, 0) 到达第一列的任何位置也有 1 条路径(只能从上走)。

Note: 注意go语言初始化二维数组的方法。

点击查看代码
func uniquePaths(m int, n int) int {
    dp:= make([][]int,m)
    for i := 0; i < m; i++ {
        dp[i] = make([]int, n)
    }
    dp[0][0] = 1
    for i := 0; i < m; i++ {
        dp[i][0] = 1
    }
    for i := 0; i < n; i++ {
        dp[0][i] = 1
    }
    for i := 1; i < m; i++ {
        for j := 1; j < n; j++ {
            dp[i][j] = dp[i-1][j]+dp[i][j-1]
        }
    }
    return dp[m-1][n-1]
}