动态规划

342 阅读6分钟

动态规划

动态规划,英文:Dynamic Programming,简称DP,如果某一问题有很多重叠子问题,使用动态规划是最有效的。

所以动态规划中每一个状态一定是由上一个状态推导出来的这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的

动态规划的解题步骤

  1. 确定dp数组(dp table)以及下标的含义
  2. 确定递推公式
  3. dp 数组如何初始化
  4. 确定遍历顺序
  5. 举例推导 dp 数组

leetCode

1. 斐波那契数列

斐波那契数 (通常用 F(n) 表示)形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和

示例 1:

  • 输入:2
  • 输出:1
  • 解释:F(2) = F(1) + F(0) = 1 + 0 = 1

示例 2:

  • 输入:3
  • 输出:2
  • 解释:F(3) = F(2) + F(1) = 1 + 1 = 2

示例 3:

  • 输入:4
  • 输出:3
  • 解释:F(4) = F(3) + F(2) = 2 + 1 = 3

动态规划 5 部曲

  1. 确定 dp 数组以及下标的含义 dp[i] 的定义为:第 i 个数的斐波那契数值是 dp[i]
  2. 确定递推公式
    状态转移方程 dp[i] = dp[i - 1] + dp[i - 2];
  3. dp 数组如何初始化 题目中直接给我们了(该数列由 0 和 1 开始)
dp[0] = 0;
dp[1] = 1;
  1. 确定遍历顺序
    从递归公式 dp[i] = dp[i - 1] + dp[i - 2] 中可以看出,dp[i]是依赖 dp[i - 1] 和 dp[i - 2],那么遍历的顺序一定是从前到后遍历的

  2. 举例推导dp数组
    按照这个递推公式 dp[i] = dp[i - 1] + dp[i - 2],我们来推导一下,当N为10的时候,dp数组应该是如下的数列:

  0 1 1 2 3 5 8 13 21 34 55

代码如下

var fib = function(n) {
   let dp = [0, 1]
   for(let i = 2; i <= n; i++) {
       dp[i] = dp[i - 1] + dp[i - 2]
   }
   return dp[n]
};

2. 爬楼梯

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

注意:给定 n 是一个正整数。

示例 1:

  • 输入: 2

  • 输出: 2

  • 解释: 有两种方法可以爬到楼顶。

    • 1 阶 + 1 阶
    • 2 阶

示例 2:

  • 输入: 3

  • 输出: 3

  • 解释: 有三种方法可以爬到楼顶。

    • 1 阶 + 1 阶 + 1 阶
    • 1 阶 + 2 阶
    • 2 阶 + 1 阶

爬到第一层楼梯有一种方法,爬到二层楼梯有两种方法。

那么第一层楼梯再跨两步就到第三层 ,第二层楼梯再跨一步就到第三层。

所以到第三层楼梯的状态可以由第二层楼梯 和 到第一层楼梯状态推导出来,那么就可以想到动态规划了。

动态规划五部曲

  1. 确定dp数组以及下标的含义

dp[i]:爬到第 i 层楼梯,有 dp[i] 种方法

  1. 确定递推公式

dp[i] 的定义可以看出,dp[i] 可以有两个方向推出来。

首先是 dp[i - 1],上 i-1 层楼梯,有dp[i - 1]种方法,那么再一步跳一个台阶不就是 dp[i] 了么。

还有就是 dp[i - 2],上 i-2 层楼梯,有 dp[i - 2] 种方法,那么再一步跳两个台阶不就是 dp\[i] 了么。

那么 dp[i] 就是 dp[i - 1]dp[i - 2] 之和!

所以 dp[i] = dp[i - 1] + dp[i - 2]

  1. dp数组如何初始化

不考虑 dp[0] 如何初始化(站在0层没有意义,而且题目中给定 n 是一个正整数),只初始化 dp[1] = 1,dp[2] = 2,然后从 i = 3 开始递推,这样才符合 dp[i] 的定义。

  1. 确定遍历顺序
    从递推公式 dp[i] = dp[i - 1] + dp[i - 2] ;中可以看出,遍历顺序一定是从前向后遍历的

  2. 举例推导dp数组

举例当 n为5 的时候,dp table(dp数组)应该是这样的

20210105202546299.png

代码如下

function climbStairs(n) {
    /**
        dp[i]: i阶楼梯的方法种数
        dp[1]: 1;
        dp[2]: 2;
        ...
        dp[i]: dp[i - 1] + dp[i - 2];
     */
     
    const dp = [];
    dp[1] = 1;
    dp[2] = 2;
    for (let i = 3; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }
    return dp[n];
};

3. 不同路径

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

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

问总共有多少条不同的路径?
示例 1:

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

示例 2:

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

解释: 从左上角开始,总共有 3 条路径可以到达右下角。

  1. 向右 -> 向右 -> 向下
  2. 向右 -> 向下 -> 向右
  3. 向下 -> 向右 -> 向右

示例 3:

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

示例 4:

  • 输入:m = 3, n = 3
  • 输出:6

动态规划五部曲

  1. 确定dp数组(dp table)以及下标的含义
    dp\[i]\[j] :表示从(0 ,0)出发,到 (i, j)dp\[i]\[j] 条不同的路径。

  2. 确定递推公式

想要求 dp\[i]\[j] ,只能有两个方向来推导出来,即 dp\[i - 1]\[j]dp\[i]\[j - 1]

此时再回顾一下 dp\[i - 1]\[j] 表示啥,是从(0, 0)的位置到 (i - 1, j) 有几条路径,dp\[i]\[j - 1] 同理。

那么很自然,dp[i][j] = dp[i - 1][j] + dp[i][j - 1],因为dp[i][j]只有这两个方向过来。

  1. dp数组的初始化
    如何初始化呢,首先 dp\[i]\[0] 一定都是 1 ,因为从 (0, 0) 的位置到 (i, 0) 的路径只有一条,那么 dp\[0]\[j] 也同理。

    所以初始化代码为:

for (let i = 0; i < m; i++) dp[i][0] = 1;
for (let j = 0; j < n; j++) dp[0][j] = 1;
  1. 确定遍历顺序
    这里要看一下递推公式 dp[i][j] = dp[i - 1][j] + dp[i][j - 1]dp[i][j] 都是从其上方和左方推导而来,那么从左到右一层一层遍历就可以了。

  2. 举例推导dp数组

如图所示: 62.不同路径1

function uniquePaths(m: number, n: number): number {
    /**
        dp[i][j]: 到达(i, j)的路径数
        dp[0][*]: 1;
        dp[*][0]: 1;
        ...
        dp[i][j]: dp[i - 1][j] + dp[i][j - 1];
     */
    const dp: number[][] = new Array(m).fill(0).map(_ => []);
    for (let i = 0; i < m; i++) {
        dp[i][0] = 1;
    }
    for (let i = 0; i < n; i++) {
        dp[0][i] = 1;
    }
    for (let i = 1; i < m; i++) {
        for (let j = 1; j < n; j++) {
            dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
        }
    }
    return dp[m - 1][n - 1];
};

4.不同路径||

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

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

现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?

网格中的障碍物和空位置分别用 1 和 0 来表示。

示例 1:

  • 输入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]]

  • 输出:2 解释:

  • 3x3 网格的正中间有一个障碍物。

  • 从左上角到右下角一共有 2 条不同的路径:

    1. 向右 -> 向右 -> 向下 -> 向下
    2. 向下 -> 向下 -> 向右 -> 向右

示例 2:

  • 输入:obstacleGrid = [[0,1],[0,0]]
  • 输出:1

在上文的不同路径中我们已经详细分析了没有障碍的情况,有障碍的话,其实就是标记对应的dp table(dp数组)保持初始值(0)就可以了。

这道题和上一道题很相似,只是多了障碍物这一个条件,根据 dp[i][j] 的定义,dp[i][j] 是表示从(0 ,0)出发,到(i, j) 有dp[i][j]条不同的路径,如果 碰到了障碍物,那么说明这条路走不通,要保持初始状态(0)

所以代码为

if (obstacleGrid[i][j] == 0) { // 当(i, j)没有障碍的时候,再推导dp[i][j]
    dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
  1. dp数组如何初始化

同样的,如果遇到了障碍物 ,则停止赋初始值

for (let i = 0; i < m && obstacleGrid[i][0] == 0; i++) dp[i][0] = 1;
for (let j = 0; j < n && obstacleGrid[0][j] == 0; j++) dp[0][j] = 1

代码如下

function uniquePathsWithObstacles(obstacleGrid: number[][]): number {
    /**
        dp[i][j]: 到达(i, j)的路径数
        dp[0][*]: 用u表示第一个障碍物下标,则u之前为1,u之后(含u)为0
        dp[*][0]: 同上
        ...
        dp[i][j]: obstacleGrid[i][j] === 1 ? 0 : dp[i-1][j] + dp[i][j-1];
     */
    const m: number = obstacleGrid.length;
    const n: number = obstacleGrid[0].length;
    const dp: number[][] = new Array(m).fill(0).map(_ => new Array(n).fill(0));
    for (let i = 0; i < m && obstacleGrid[i][0] === 0; i++) {
        dp[i][0] = 1;
    }
    for (let i = 0; i < n && obstacleGrid[0][i] === 0; i++) {
        dp[0][i] = 1;
    }
    for (let i = 1; i < m; i++) {
        for (let j = 1; j < n; j++) {
            if (obstacleGrid[i][j] === 1) continue;
            dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
        }
    }
    return dp[m - 1][n - 1];
};

5. 整数拆分

给定一个正整数 n,将其拆分为至少两个正整数的和,并使这些整数的乘积最大化。 返回你可以获得的最大乘积。

示例 1:

  • 输入: 2
  • 输出: 1
  • 解释: 2 = 1 + 1, 1 × 1 = 1。

示例 2:

  • 输入: 10
  • 输出: 36
  • 解释: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36。
  • 说明: 你可以假设 n 不小于 2 且不大于 58。

动规五部曲,分析如下:

  1. 确定dp数组(dp table)以及下标的含义
    dp[i]:分拆数字 i,可以得到的最大乘积为 dp\[i]
    2. 确定递推公式
    一个是 j* (i - j) 直接相乘。如果不能分割的话
    另一个是 dp[i-j]* j,

那有同学问了,j 怎么就不拆分呢?

j是从1开始遍历,拆分j的情况,在遍历 j 的过程中其实都计算过了。那么从1遍历j,比较 (i - j) * j 和 dp[i - j] * j 取最大的。

递推公式:dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));

也可以这么理解,j * (i - j) 是单纯的把整数拆分为两个数相乘,而 j * dp\[i - j] 是拆分成两个以及两个以上的个数相乘。

如果定义 dp[i - j] \* dp[j] 也是默认将一个数强制拆成4份以及4份以上 了。

所以递推公式:dp[i] = max({dp[i], (i - j) * j, dp[i - j] * j});

那么在取最大值的时候,为什么还要比较dp[i]呢?

因为在递推公式推导的过程中,每次计算dp[i],取最大的而已。

  1. dp的初始化

不少同学应该疑惑,dp[0] dp[1]应该初始化多少呢?

有的题解里会给出dp[0] = 1,dp[1] = 1的初始化,但解释比较牵强,主要还是因为这么初始化可以把题目过了。

严格从dp[i]的定义来说,dp[0] dp[1] 就不应该初始化,也就是没有意义的数值。

拆分0和拆分1的最大乘积是多少?

这是无解的。

这里我只初始化dp[2] = 1,从dp[i]的定义来说,拆分数字2,得到的最大乘积是1,这个没有任何异议!

  1. 确定遍历顺序

确定遍历顺序,先来看看递归公式:dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));

dp[i] 是依靠 dp[i - j]的状态,所以遍历i一定是从前向后遍历,先有dp[i - j]再有dp[i]。\

点击查看 leetCode 题解

var integerBreak = function(n) {
    let dp = new Array(n + 1).fill(0)
    dp[2] = 1

    for(let i = 3; i <= n; i++) {
        for(let j = 1; j <= i / 2; j++) {
            dp[i] = Math.max(dp[i], dp[i - j] * j, (i - j) * j)
        }
    }
    return dp[n]
};

以上内容来自代码随想录

做一个记录,方便以后查看