跳至主要內容

01. 计数类 DP 知识

ITCharge大约 6 分钟

计数类 DP 知识

1. 计数类 DP 简介

1.1 计数问题简介

计数问题:计算满足特定条件下的可行方案数目的问题。

这里的「可行方案数目」指的是某个问题一共有多少种方法。

「计数问题」本身是组合数学中的重要内容,这类问题通常有两种经典的求解方法:

  1. 找到递归关系,然后以动态规划的方式,列出递推式,然后求解出方案数。
  2. 转为数学问题,计算出对应的组合数,如:卡特兰数、快速幂、排列数、组合数等。

在解决具体问题时,还需要根据题目的具体情况进行分析。一般情况下,我们使用第 11 种方法来解决。这是因为:

  1. 采用动态规划的方式能够高效的处理大规模计数问题,并且使用较少时间和空间复杂度。
  2. 即使找到了组合数,还要面临计算组合数的困难(高阶组合数计算困难、计算效率较低)。

所以我们通常使用「动态规划」的方法来解决计数问题。

1.2 计数类 DP 简介

计数类 DP:一类使用动态规划方法来统计可行方案数目的问题。区别于求解最优解,计数类 DP 需要统计所有满足条件的可行解数量,同时需要满足不重复、不遗漏的条件。

计数类 DP 的核心思想就是:通过动态规划的算法思想,去计算出解决这个问题有多少种方法。一般来说,计数类 DP 只关注方案数目,不关注具体方案情况。

比如,从一个矩阵的左上角走到右下角,每次只能向右走或者向下走,一共有多少条不同路径。注意:这里求解的是有多少条,而不是具体路线的走法。

2. 计数类 DP 的应用

2.1 不同路径

2.1.1 题目链接

2.1.2 题目大意

描述:给定两个整数 mmnn,代表大小为 m×nm \times n 的棋盘, 一个机器人位于棋盘左上角的位置,机器人每次只能向右、或者向下移动一步。

要求:计算出机器人从棋盘左上角到达棋盘右下角一共有多少条不同的路径。

说明

  • 1m,n1001 \le m, n \le 100
  • 题目数据保证答案小于等于 2×1092 \times 10^9

示例

  • 示例 1:
输入:m = 3, n = 7
输出:28
  • 示例 2:
输入:m = 3, n = 2
输出:3
解释:
从左上角开始,总共有 3 条路径可以到达右下角。
1. 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右
3. 向下 -> 向右 -> 向下

2.1.3 解题思路

思路 1:动态规划
1. 划分阶段

按照路径的结尾位置(行位置、列位置组成的二维坐标)进行阶段划分。

2. 定义状态

定义状态 dp[i][j]dp[i][j] 为:从左上角到达位置 (i,j)(i, j) 的路径数量。

3. 状态转移方程

因为我们每次只能向右、或者向下移动一步,因此想要走到 (i,j)(i, j),只能从 (i1,j)(i - 1, j) 向下走一步走过来;或者从 (i,j1)(i, j - 1) 向右走一步走过来。所以可以写出状态转移方程为:dp[i][j]=dp[i1][j]+dp[i][j1]dp[i][j] = dp[i - 1][j] + dp[i][j - 1],此时 i>0,j>0i > 0, j > 0

4. 初始条件
  • 从左上角走到 (0,0)(0, 0) 只有一种方法,即 dp[0][0]=1dp[0][0] = 1
  • 第一行元素只有一条路径(即只能通过前一个元素向右走得到),所以 dp[0][j]=1dp[0][j] = 1
  • 同理,第一列元素只有一条路径(即只能通过前一个元素向下走得到),所以 dp[i][0]=1dp[i][0] = 1
5. 最终结果

根据状态定义,最终结果为 dp[m1][n1]dp[m - 1][n - 1],即从左上角到达右下角 (m1,n1)(m - 1, n - 1) 位置的路径数量为 dp[m1][n1]dp[m - 1][n - 1]

思路 1:动态规划代码
class Solution:
    def uniquePaths(self, m: int, n: int) -> int:
        dp = [[0 for _ in range(n)] for _ in range(m)]
        
        for j in range(n):
            dp[0][j] = 1
        for i in range(m):
            dp[i][0] = 1

        for i in range(1, m):
            for j in range(1, n):
                dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
        
        return dp[m - 1][n - 1]
思路 1:复杂度分析
  • 时间复杂度O(m×n)O(m \times n)。初始条件赋值的时间复杂度为 O(m+n)O(m + n),两重循环遍历的时间复杂度为 O(mn)O(m * n),所以总体时间复杂度为 O(m×n)O(m \times n)
  • 空间复杂度O(m×n)O(m \times n)。用到了二维数组保存状态,所以总体空间复杂度为 O(m×n)O(m \times n)。因为 dp[i][j]dp[i][j] 的状态只依赖于上方值 dp[i1][j]dp[i - 1][j] 和左侧值 dp[i][j1]dp[i][j - 1],而我们在进行遍历时的顺序刚好是从上至下、从左到右。所以我们可以使用长度为 mm 的一维数组来保存状态,从而将空间复杂度优化到 O(m)O(m)

2.2 整数拆分

2.2.1 题目链接

2.2.2 题目大意

描述:给定一个正整数 nn,将其拆分为 k(k2)k (k \ge 2) 个正整数的和,并使这些整数的乘积最大化。

要求:返回可以获得的最大乘积。

说明

  • 2n582 \le n \le 58

示例

  • 示例 1:
输入: n = 2
输出: 1
解释: 2 = 1 + 1, 1 × 1 = 1
  • 示例 2:
输入: n = 10
输出: 36
解释: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36

2.2.3 解题思路

思路 1:动态规划
1. 划分阶段

按照正整数进行划分。

2. 定义状态

定义状态 dp[i]dp[i] 表示为:将正整数 ii 拆分为至少 22 个正整数的和之后,这些正整数的最大乘积。

3. 状态转移方程

i2i \ge 2 时,假设正整数 ii 拆分出的第 11 个正整数是 j(1j<i)j(1 \le j < i),则有两种方法:

  1. ii 拆分为 jjiji - j 的和,且 iji - j 不再拆分为多个正整数,此时乘积为:j×(ij)j \times (i - j)
  2. ii 拆分为 jjiji - j 的和,且 iji - j 继续拆分为多个正整数,此时乘积为:j×dp[ij]j \times dp[i - j]

dp[i]dp[i] 取两者中的最大值。即:dp[i]=max(j×(ij),j×dp[ij])dp[i] = max(j \times (i - j), j \times dp[i - j])

由于 1j<i1 \le j < i,需要遍历 jj 得到 dp[i]dp[i] 的最大值,则状态转移方程如下:

dp[i]=max1j<i{max(j×(ij),j×dp[ij])}dp[i] = max_{1 \le j < i}\lbrace max(j \times (i - j), j \times dp[i - j]) \rbrace

4. 初始条件
  • 0011 都不能被拆分,所以 dp[0]=0,dp[1]=0dp[0] = 0, dp[1] = 0
5. 最终结果

根据我们之前定义的状态,dp[i]dp[i] 表示为:将正整数 ii 拆分为至少 22 个正整数的和之后,这些正整数的最大乘积。则最终结果为 dp[n]dp[n]

思路 1:代码
class Solution:
    def integerBreak(self, n: int) -> int:
        dp = [0 for _ in range(n + 1)]
        for i in range(2, n + 1):
            for j in range(i):
                dp[i] = max(dp[i], (i - j) * j, dp[i - j] * j)
        return dp[n]
思路 1:复杂度分析
  • 时间复杂度O(n2)O(n^2)
  • 空间复杂度O(n)O(n)