数据结构和算法躬行记(8)——动态规划

简介:  动态规划(Dynamic Programming,DP)是指在给定的约束条件下求最优值的算法,在解决问题的过程,需要经历多个决策阶段,每个决策阶段都对应着一组状态。

  动态规划(Dynamic Programming,DP)是指在给定的约束条件下求最优值的算法,在解决问题的过程,需要经历多个决策阶段,每个决策阶段都对应着一组状态。

  适用于动态规划解决的问题包含三个特征:

  (1)最优子结构:通过子问题的最优解,可推导出问题的最优解,即后面阶段的状态可以通过前面阶段的状态推导出来。

  (2)无后效性:某阶段状态一旦确定,就不受之后阶段的决策影响。即子问题的解一旦确定,就不再改变。

  (3)子问题重叠:不同的决策序列,到达某个相同的阶段时,可能会产生重复的状态。即有些子问题会被重复计算多次。

  动态规划对每个子问题只计算一次,然后将其计算结果保存到一张表中记忆化存储,以便下次需要同一个子问题解时直接查表,从而获得较高的效率,降低了时间复杂度。


一、0-1背包


  在之前《回溯》一文中也提到了0-1背包问题,而此处会在背包重量限制的前提下,计算装入物品的最大总价值。

  假设背包容量为4斤,吉他(1斤,价值1500)、音响(4斤,价值3000)和笔记本(3斤,价值2000),求背包的最大价值(题目来源于《图解算法 9.1节》)。

  先画状态转移表(如下所示),一般是二维的,在画之前需要回答三个问题:

  (1)单元格中的值是什么?当前背包中的最大总价值。

  (2)如何划分子问题?考虑容量为1、2、3和4的背包,并且将物品依次放入。

  (3)网格的坐标轴是什么?横坐标是背包重量,纵坐标是物品。

  接下来将计算每个单元格的值。

  (1)第一步是将吉他放入背包的四个重量中,而重量1、2和3其实就是在解决各个子问题。

  (2)第二步是依次处理音响,判断是否需要放入,经过比对发现只有最大容量才能放入,更新最大价值为3000。

  (3)第三步是依次处理笔记本,在背包容量为3斤时更新最大价值为2000,而在4斤时,可同时放入吉他和笔记本,更新最大价值为3500。


74.png


  根据状态表得出状态转移方程,先计算当前商品价值和剩余空间价值,得到的结果与上一个单元格比对,将较大值填充到当前单元格中。

dp[i][j] = max(goods[i].value + dp[i-1][j-goods[i].weight], dp[i-1][j])

  具体的代码实现如下所示,本文的代码仅做参考,没有经过严格的测试用例论证。


function knapsack(goods, w) {
  let max = Number.MIN_VALUE,
    dp = [new Array(w)],
    length = goods.length;
  for (let j = 1; j <= w; j++) {
    dp[0][j] = goods[0].value;
  }
  for (let i = 1; i < length; i++) {
    dp[i] = [];
    for (let j = 1; j <= w; j++) {
      let rest = j - goods[i].weight;        //计算减去当前商品重量后的背包容量
      if (rest > 0) {                        //套用状态转移方程
        dp[i][j] = Math.max(goods[i].value + dp[i - 1][rest], dp[i - 1][j]);
      } else {
        dp[i][j] = dp[i - 1][j];             //沿用上一个单元格的价值
      }
      if (max < dp[i][j]) max = dp[i][j];    //计算最大值
    }
  }
  return max;
}


二、子串和子序列


1)最长公共子串

  最长公共子串是指在不改变字符相对顺序的情况下提取两段字符串中共有的子串,例如fosh和fish,最长公共子串是sh,长度为2(题目来源于《图解算法 9.3节》)。例题:300. 最长上升子序列

  在画状态表之前先回答三个问题:

  (1)单元格中的值是什么?公共子串的长度。

  (2)如何划分子问题?将两段字符串分割成字符,从前往后依次比对。

  (3)网格的坐标轴是什么?横坐标是第一段字符串,纵坐标是第二段字符串。

75.png


  根据状态表得出状态转移方程,当两个字符相同时,左上角的单元格加一,否则单元格为0。

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

  具体的代码实现如下所示


function longestCommonSubstr(str1, str2) {
  let len1 = str1.length,
    len2 = str2.length,
    max = Number.MIN_VALUE,
    dp = [];
  for (let i = 0; i < len1; i++) {
    dp[i] = [];
    for (let j = 0; j < len2; j++) {
      if (str1[i] != str2[j]) {            //两个字符不同
        dp[i][j] = 0;
        continue;
      }
      //应对 i-1 或 j-1 小于0的情况
      dp[i][j] = i < 1 || j < 1 ? 1 : dp[i - 1][j - 1] + 1;
      if (max < dp[i][j]) {
        max = dp[i][j];
      }
    }
  }
  return max;
}

2)最长公共子序列

  还有一个类似的题目是求最长公共子序列,其实就是提取共有的子序列,例如fosh和fish,最长公共子序列是fsh,例题:1143. 最长公共子序列。状态转移表如下所示。


76.png


  根据状态表得出状态转移方程,当两个字符相同时,仍然是左上角的单元格加一,否则比较左和上两个单元格的值,取较大值。

dp[i][j] = (dp[i-1][j-1] + 1) | max(dp[i-1][j], dp[i][j-1])

  具体的代码实现如下所示


function longestCommonSubsequence(str1, str2) {
  let len1 = str1.length,
    len2 = str2.length,
    max = Number.MIN_VALUE,
    dp = [];
  for (let i = 0; i < len1; i++) {
    dp[i] = [];
    for (let j = 0; j < len2; j++) {
      if (str1[i] != str2[j]) {
        //两个字符不同
        dp[i][j] = Math.max(
            i < 1 ? 0 : dp[i - 1][j], 
            j < 1 ? 0 : dp[i][j - 1]
        );
        max = Math.max(max, dp[i][j]);
        continue;
      }
      //应对 i-1 或 j-1 小于0的情况
      dp[i][j] = i < 1 || j < 1 ? 1 : dp[i - 1][j - 1] + 1;
      max = Math.max(max, dp[i][j]);
    }
  }
  return max;
}


3)最长上升子序列

  求出一个无序的整数数组中最长上升子序列的长度。例题:300. 最长上升子序列

  网上的解法都是用一维状态转移方程,此处仍然采用二维的解法(可观察各个步骤),其中dp[i][j]是指以第 i 个元素结尾的前 j 个元素中的最长上升子序列。

  状态表如下所示,每次只计算dp[i][i]的值,其余值沿用上一行的结果。

77.png


  根据状态表得出状态转移方程,当第 i 个元素比第 j 个元素大时,在该值的基础上加一,否则默认赋为一。

dp[i][i] = 1 | max(dp[i][j]) + 1

  具体的代码实现如下所示


function lengthOfLIS(nums) {
  let len = nums.length,
    max = 1,
    dp = [];
  dp[0] = [1];            //初始化dp[0][0]的值
  for (let i = 1; i < len; i++) {
    dp[i] = [];
    dp[i][i] = 1;        //初始化dp[i][i]的值
    for (let j = 0; j < i; j++) {     //让第i个元素与前j个元素逐一比较
      dp[i][j] = dp[i-1][j];          //默认沿用上一行的值
      if (nums[i] > nums[j]) {        //当第i个元素比第j个元素大时,取其中的较大值
        dp[i][i] = Math.max(dp[i][i], dp[i][j] + 1);
      }
    }
    max = Math.max(max, dp[i][i]);
  }
  return max;
}


三、钱币找零


  在《贪心算法》一节中曾提到过钱币找零的问题,此处会加大难度。

  假设有几种不同面值的纸币 v1,v2,……,vn(单位是元),如果要支付 w 元,那么最少需要多少张纸币,例如有 3 种不同的纸币,1元、2元、5元,要支付 9元,最少需要 3张纸币。例题:322. 零钱兑换

  在画状态表之前先回答三个问题:

  (1)单元格中的值是什么?最少纸币数。

  (2)如何划分子问题?考虑要支付1、2...9等金额时,各自需要的最少纸币数。

  (3)网格的坐标轴是什么?横坐标是支付金额,纵坐标是可用的纸币。


78.png

  根据状态表得出状态转移方程,计算金额减去当前面值的剩余金额所用的最少纸币数,将其加一和上一行的最少纸币数做比较,取小值。

dp[i][j] = min(dp[i-1][j], dp[i][j - coins[i]] + 1)

  具体的代码实现如下所示,虽然代码比较长,但好多都是在判断边界条件,以及做各类情况的处理,核心其实还是围绕着状态转移方程。


function coinChange(coins, amount) {
  let len = coins.length,
    min = Number.MAX_VALUE,
    dp = [new Array(amount)];
  for (let j = 1; j <= amount; j++) {    //初始化第一行
    //纸币面值比金额大,或金额无法整除纸币
    if(coins[0] > j || (j % coins[0]) > 0) {
      //对于无法支付金额的情况,赋值为0
      dp[0][j] = 0;
      continue;
    }
    dp[0][j] = j / coins[0];            //得到纸币数量
  }
  for (let i = 1; i < len; i++) {
    dp[i] = [];
    for (let j = 1; j <= amount; j++) {
      let rest = j - coins[i];
      if(rest == 0) {        //可用当前纸币支付金额
        dp[i][j] = 1;
        continue;
      }
      if(rest < 0) {        //当前纸币比支付金额大
        dp[i][j] = dp[i-1][j];
        continue;
      }
      if(dp[i-1][j] > 0 && dp[i][rest] > 0) {    //都可以支付金额
        dp[i][j] = Math.min(dp[i-1][j], dp[i][rest] + 1);
      }else if(dp[i-1][j] > 0) {                 //只有上一行可以支付金额
        dp[i][j] = dp[i-1][j];
      }else if(dp[i][rest] > 0) {                //只能借助剩余金额的纸币数支付
        dp[i][j] = dp[i][rest] + 1;
      }else {                                    //无法支付
        dp[i][j] = 0;
      }
    }
  }
  //取金额的最小值
  for(let i = 0; i < len; i++) {
    if(dp[i][amount] == 0) {        //过滤掉无法支付的数据
      continue;
    }
    if(dp[i][amount] > 0) {
      min = Math.min(min, dp[i][amount]);
    }
  }
  return min == Number.MAX_VALUE ? -1 : min;
}




相关文章
|
1月前
|
存储 人工智能 算法
数据结构与算法细节篇之最短路径问题:Dijkstra和Floyd算法详细描述,java语言实现。
这篇文章详细介绍了Dijkstra和Floyd算法,这两种算法分别用于解决单源和多源最短路径问题,并且提供了Java语言的实现代码。
70 3
数据结构与算法细节篇之最短路径问题:Dijkstra和Floyd算法详细描述,java语言实现。
|
14天前
|
算法 Python
在Python编程中,分治法、贪心算法和动态规划是三种重要的算法。分治法通过将大问题分解为小问题,递归解决后合并结果
在Python编程中,分治法、贪心算法和动态规划是三种重要的算法。分治法通过将大问题分解为小问题,递归解决后合并结果;贪心算法在每一步选择局部最优解,追求全局最优;动态规划通过保存子问题的解,避免重复计算,确保全局最优。这三种算法各具特色,适用于不同类型的问题,合理选择能显著提升编程效率。
31 2
|
1月前
|
算法
动态规划算法学习三:0-1背包问题
这篇文章是关于0-1背包问题的动态规划算法详解,包括问题描述、解决步骤、最优子结构性质、状态表示和递推方程、算法设计与分析、计算最优值、算法实现以及对算法缺点的思考。
69 2
动态规划算法学习三:0-1背包问题
|
1月前
|
存储 算法 Java
Set接口及其主要实现类(如HashSet、TreeSet)如何通过特定数据结构和算法确保元素唯一性
Java Set因其“无重复”特性在集合框架中独树一帜。本文解析了Set接口及其主要实现类(如HashSet、TreeSet)如何通过特定数据结构和算法确保元素唯一性,并提供了最佳实践建议,包括选择合适的Set实现类和正确实现自定义对象的hashCode()与equals()方法。
33 4
|
1月前
|
算法
动态规划算法学习四:最大上升子序列问题(LIS:Longest Increasing Subsequence)
这篇文章介绍了动态规划算法中解决最大上升子序列问题(LIS)的方法,包括问题的描述、动态规划的步骤、状态表示、递推方程、计算最优值以及优化方法,如非动态规划的二分法。
66 0
动态规划算法学习四:最大上升子序列问题(LIS:Longest Increasing Subsequence)
|
1月前
|
算法
动态规划算法学习二:最长公共子序列
这篇文章介绍了如何使用动态规划算法解决最长公共子序列(LCS)问题,包括问题描述、最优子结构性质、状态表示、状态递归方程、计算最优值的方法,以及具体的代码实现。
129 0
动态规划算法学习二:最长公共子序列
|
1月前
|
搜索推荐 算法
数据结构与算法学习十四:常用排序算法总结和对比
关于常用排序算法的总结和对比,包括稳定性、内排序、外排序、时间复杂度和空间复杂度等术语的解释。
20 0
数据结构与算法学习十四:常用排序算法总结和对比
|
1月前
|
存储 算法
动态规划算法学习一:DP的重要知识点、矩阵连乘算法
这篇文章是关于动态规划算法中矩阵连乘问题的详解,包括问题描述、最优子结构、重叠子问题、递归方法、备忘录方法和动态规划算法设计的步骤。
105 0
|
1月前
|
机器学习/深度学习 搜索推荐 算法
探索数据结构:初入算法之经典排序算法
探索数据结构:初入算法之经典排序算法
|
1月前
|
算法 Java 索引
数据结构与算法学习十五:常用查找算法介绍,线性排序、二分查找(折半查找)算法、差值查找算法、斐波那契(黄金分割法)查找算法
四种常用的查找算法:顺序查找、二分查找(折半查找)、插值查找和斐波那契查找,并提供了Java语言的实现代码和测试结果。
21 0
下一篇
无影云桌面