算法基础05 动态规划

文章出处:告别动态规划,连刷 40 道题,我总结了这些套路,看不懂你打我(万字长文)

动态规划的三大步骤

动态规划就是利用历史记录,来避免重复计算。而这些历史记录需要一些变量来保存,一般使用一维数组或二维数组来保存。

(1)第一步:定义数组元素的含义

(2)第二步:找出数组元素之间的关系式,当我们计算dp[n]时,是可以利用dp[n-1]dp[n-2]dp[1]来退出dp[n]的,也就是可以利用历史数据来推出新的元素值,所以需要找出数组元素之间的关系式,例如dp[n] = dp[n-1] + dp[n-2]

这一步也是最难的一步

(3)第三步:找出初始值,虽然知道了数组元素之间的关系式,例如dp[n] = dp[n-1] + dp[n-2],但是需要知道初始值,即直接获得dp[2]dp[1]的值,这就是所谓的初始值

有了初始值,有了数组元素之间的关系水,就可以得到dp[n]的值了,dp[n]的含义是我们自己定义的,想求什么,就定义它是什么

实例

例1:简单的一维DP

问题描述:一只青蛙可以跳上1级台阶,也可以跳上2级台阶,求该青蛙跳上一个n级的台阶总共有多少种跳法

(1)定义数组元素含义

首先定义dp[n]的含义:跳上一个n级的台阶总共有dp[n]种跳法,这样计算出dp[n]后就可以得到答案

(2)找出数组元素之间的关系式

动态规划就是把一个规模较大的问题,分成几个规模较小的问题,然后由小的问题推导出大的问题。也就是说,dp[n]的规模是n,比它规模小的是n-1n-2…,也就是说dp[n]一定会和dp[n-1]dp[n-2]…存在某种关系的,我们要找出它们之间的关系

这是最核心也最难的一步

对于这道题,青蛙可以选择跳一级,也可以跳两级,所以青蛙到达第n级的台阶有两种方式:

  1. n-1级跳上来
  2. n-2级跳上来

所以dp[n] = dp[n-1] + dp[n-2]就是数组元素之间的关系式

(3)找出初始条件

dp[1]dp[0]都需要直接给出初始值,显然dp[1]等于1(因为1级的台阶只有1种跳法),dp[0]等于0(0级的台阶自然没有跳法)

(4)初始条件的严谨性

注意,如果按照之间的关系式,dp[2]的结果是1,显然这是不对的,dp[2]的结果应该是2,所以dp[2]也应该作为一个初始值存在

也就是说,在寻找初始值的时候,一定要注意不要找漏了,这个只能通过不断做题来积累经验

(5)写代码:这样就可以写出代码:

function fn(n) {
  if (n <= 2) {
    return n;
  }
  let dp = [0, 1, 2];

  for (let i = 3; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }

  return dp[n];
}

例2:不同路径

问题描述:一个机器人位于一个m x n的网格的左上角,机器人每次只能向下或向右移动一步,机器人试图达到网格的右下角,总共有多少条不同的路径

(1)定义数组元素含义

我们的目的是从左上角到右下角一共有多少条路径,那我们就定义dp[i][j]的含义是,当机器人从左上角走到[i, j]这个位置时,一共有dp[i][j]条路径

那么dp[m-1][n-1]就是我们要的答案(因为网格相当于一个二维数组,数组下标是从0开始计算的,所以右下角的位置是[m-1, n-1]

(2)找出数组元素之间的关系式

机器人如何才能到达[i, j]这个位置?由于机器人可以向下走或者向右走,所以有两种方式到达:

  1. [i - 1, j]这个位置一步到达
  2. [i, j - 1]这个位置一步到达

所以关系是是dp[i][j] = dp[i-1][j] + dp[i][j-1]

(3)找出初始值

dp[i][j]中,如果i或者[j]有一个为0,就不能使用关系式了,因为数组的下标会成为负数了。所以初始值计算出所有dp[0][0...n-1]dp[0...m-1][0]的值,这个值都是1,因为相当于上图中的第一行和第一列:

  • dp[0][0...n-1],第一行,机器人只能是从左侧过来(向右走一步),所以只有1条路径过来
  • dp[0...m-1][0],第一列,机器人只能是从上侧过来(向下走一步),所以只有1条路径过来

(4)写代码

function uniquePath(m, n) {
  if (m <= 0 || n <= 0) {
    return 0;
  }
  const dp = [];
  for (let i = 0; i < m; i++) {
    dp[i] = [];
    for (let j = 0; j < n; j++) {
      if (i === 0 || j === 0) {
        dp[i][j] =1;
      } else {
        dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
      }
    }
  }
  return dp[m - 1][n - 1];
}

例3:最小路径和

问题描述:给定一个包含非负整数的 m x n 网格,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。

说明:每次只能向下或者向右移动一步。

输入:
arr = [
  [1,3,1],
  [1,5,1],
  [4,2,1]
]

输出: 7
解释: 因为路径 1→3→1→1→1 的总和最小。

(1)定义数组元素含义

定义dp[i][j]含的含义为,当机器人从左上角走到[i][j]这个位置时,最小的路径和是dp[i][j],那么dp[m-1][n-1]就是我们要的答案

(2)找出数组元素之间的关系式

机器人达到[i, j]这个位置,有两种选择,向下走或者向右走,所以有两种方式到达

  • [i - 1, j]这个位置一步走达
  • [i, j - 1]这个位置一步走达

这次计算的是路径和最小的,所以需要从上面两种方式中选择一种,是的dp[i][j]是最小的,所以得到关系式:

dp[i][j] = min(dp[i - 1, j], dp[i, j - 1]) + grid[i][j]; // grid[i][j]表示当前网格的值

(3)找出初始值

  • ij都为0时,dp[i][j]就是grid[0][0]当前值
  • i0时,dp[0][j]只能是向右走过来的,所以dp[0][j]就是dp[i][j - 1] + grid[0][j]
  • 同样当j0时,dp[i][0]只能是向下走过来的,所以dp[i][0]就是dp[i - 1][0] + grid[i][0]

(4)写代码

var minPathSum = function (grid) {
  const m = grid.length,
    n = grid[0].length;

  const dp = [];

  for (let i = 0; i < m; i++) {
    dp[i] = [];

    for (let j = 0; j < n; j++) {
      if (i === 0 && j === 0) {
        dp[0][0] = grid[0][0];
      } else if (i === 0) {
        dp[0][j] = dp[0][j - 1] + grid[0][j];
      } else if (j === 0) {
        dp[i][0] = dp[i - 1][0] + grid[i][0];
      } else {
        dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];
      }
    }
  }

  return dp[m - 1][n - 1];
};

例4:编辑距离

给定两个单词 word1 和 word2,计算出将 word1 转换成 word2 所使用的最少操作数 。

你可以对一个单词进行如下三种操作:

插入一个字符 删除一个字符 替换一个字符

示例:
输入: word1 = "horse", word2 = "ros"
输出: 3
解释: 
horse -> rorse (将 'h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')

90%的字符串问题都可以使用动态规划解决,都是利用二维数组解决

(1)定义数组元素含义

目的是求将word1转换word2使用的最小操作数,那么定义dp[i][j]的含义为:当字符串word1长度为i,字符串word2长度为j时,将word1转换为word2所用的最小操作次数

我在尝试的时候犯了一个错误,以为最终求的是dp[i - 1][j - 1],但是因为i``j代表的是就是字符串长度,而不是数组下标,所以最终求的就是dp[i][j]

(2)找出数组元素之间的关系式

word可以进行替换一个字符、插入一个字符、删除一个字符这三种操作,分这两种情况进行讨论

  • word1[i]word2[j]相等,这时候不需要任何操作,所以有dp[i][j] = dp[i - 1][j - 1]

  • word1[i]word2[j]不相等,这时候需要使用上述三个操作进行处理

    • word1[i]替换为word2[j],这个时候只需要word1word2都发生了变化,所以有dp[i][j] = dp[i - 1][j - 1] + 1
    • word1后插入一个与word2[j]相等的字符,相当于word2发生了变化,所以有dp[i][j] = dp[i - 1][j] + 1
    • word1删除一个字符,相当于word1发生了变化,所以有dp[i][j] = dp[i - 1][j] + 1
    • 应该上面三个当中之一的一种操作,是的dp[i][j]的值最小,所以得到关系式dp[i] [j] = min(dp[i-1] [j-1],dp[i] [j-1],dp[[i-1] [j]]) + 1

(3)找出初始值

dp[i][j]当中ij0,关系式是不成立的,所以需要计算出所有初始值dp[0][0...n]dp[0...n][0]的值,这种情况下某一个字符串长度为0,转换为另外一个字符串,只能一直进行插入或者删除操作

(4)写代码

var minDistance = function (word1, word2) {
  const length1 = word1.length;
  const length2 = word2.length;

  // 某一个字符串长度为 0 时的情况
  if (length1 * length2 === 0) {
    return length1 + length2;
  }

  const dp = [];

  for (let i = 0; i <= length1; i++) {
    dp[i] = [];

    for (let j = 0; j <= length2; j++) {
      if (i === 0 && j === 0) {
        dp[0][0] = 0;
      } else if (i === 0) {
        dp[0][j] = dp[0][j - 1] + 1;
      } else if (j === 0) {
        dp[i][0] = dp[i - 1][0] + 1;
      } else {
        if (word1[i - 1] === word2[j - 1]) {
          dp[i][j] = dp[i - 1][j - 1];
        } else {
          dp[i][j] = Math.min(dp[i - 1][j - 1], dp[i][j - 1], dp[i - 1][j]) + 1;
        }
      }
    }
  }
  return dp[length1][length2];
};

优化

优化的核心:画图

目标:将${O(m * n)}$的空间复杂度优化为${O(n)}$

例2不同路径数的优化

这道题目的转移公式是:

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

当前的空间复杂度是${O(m * n)}$,实际上在计算dp[i][j]时,在i维度上(即按行计算时),只需要保存i - 1这一行数据即可,而[0, i - 2]这些数据实际上是没有意义的,也就不需要保存

所以可以将二维数组转换为一维数组,然后通过迭代,不断更新这个一维数组的值即可

var uniquePaths = function (m, n) {
  if (m <= 0 || n <= 0) {
    return 0;
  }

  // 初始化第一行的值
  let dp = new Array(n).fill(1);

  // 从第二行开始遍历
  for (let i = 1; i < m; i++) {
    // 初始化这一行第一个单元格的值
    dp[0] = 1;
    
    // 从第二列开始遍历
    for (let j = 1; j < n; j++) {
      
      // 等式左边的`dp[j]`是第`i`行的单元格的值,而等式右边的值则是上一行即`i - 1`行保存的单元格的值
      dp[j] = dp[j - 1] + dp[j];
    }
  }
  
  // 返回最后一个单元格的值
  return dp[n - 1];
};

例4编辑距离优化

与上一个相比,需要声明一个临时变量predp[i - 1][j - 1]保存起来,同时要注意的就是初始的边界条件,不是1,而是随着迭代而变化的,另外pre的边界条件,也需要一个单独的变量temp来保存(我之前就是因为对pre的初始值考虑的不正确,怎么都做不出来)

var minDistance = function (word1, word2) {
  const m = word1.length,
    n = word2.length;

  // 如果有一个字符串为空字符串,返回值就是为为空字符串的长度
  if (m * m === 0) {
    return m + n;
  }

  const dp = [];
  for (let j = 0; j <= n; j++) {
    dp[j] = j;
  }

  for (let i = 1; i <= m; i++) {
    // temp 的作用就是给 pre 赋初值
    let temp = dp[0];
    dp[0] = i;

    for (let j = 1; j <= n; j++) {
      let pre = temp;
      temp = dp[j];

      if (word1[i - 1] === word2[j - 1]) {
        dp[j] = pre;
      } else {
        dp[j] = Math.min(dp[j - 1], dp[j], pre) + 1;
      }
    }
  }

  return dp[n];
};
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值