前端算法小白学动态规划-2

接上篇前端算法小白学动态规划-1。下面进入金矿问题,借鉴楼梯问题的解决思路。

2. 金矿问题

问题描述

有一位国王拥有 5 座金矿,每座金矿的黄金储量不同,需要参与挖掘的工人人数也不同。如果参与挖矿的工人的总数是 10。每座金矿要么全挖,要么不挖,不能派出一半人挖取一半的金矿。要想得到尽可能多的黄金,应该选择挖取哪几座金矿?

  • 200kg 黄金 / 3 人
  • 300kg 黄金 / 4 人
  • 350kg 黄金 / 3 人
  • 400kg 黄金 / 5 人
  • 500kg 黄金 / 5 人

思路分析

相比于楼梯问题只有两个因素:台阶数量(10 级台阶)和跨越台阶的方式(一次迈 1 级或 2 级),金矿问题涉及到的因素更多:金矿总数(5 座)、挖矿工人总数(10 人)、每座金矿的价值(长度为 5 的数组)、挖每座金矿需要的工人数量(长度为 5 的数组)。

首先提取题干信息,总结出问题:需要求出 10 个工人挖 5 座金矿的最优选择。

按照楼梯问题相似的思路,对于第 5 座金矿,存在挖和不挖两种选择:

  • 如果第 5 座金矿不挖,那么问题转化成 10 名工人挖前 4 个金矿的最优选择。
  • 如果第 5 座金矿挖,那么需要 5 人,剩下就是 5 名工人挖前 4 个金矿的最优选择。

随着金矿选择挖或者不挖,剩余金矿数和剩余工人数在不断减少,和楼梯问题最后计算 F(2)和 F(1)一样,需要解决的问题也就会越来越简单,那么复杂问题在这个过程中就会逐渐转化为简单问题。

用 F(5, 10)表示要计算出 10 个人挖 5 个金矿的最优选择。 那么 F(5, 10) = Max( F(4, 10), 500 + F(4, 5) ) 以此类推: F(4, 10) = Max( F(3, 10), 400 + F(3, 5) ) F(4, 5) = Max( F(3, 5), 400 + F(3, 0) ) ... 直到金矿数为 0,或者工人数为 0,收益显然就是 0。也就找到了问题的边界。

把金矿数量设为 n,工人数量设为 w,金矿的含金量设为数组 g[],金矿所需开采人数设为数组 p[]。 设 F(n, w)为 n 个金矿、w 个工人时的最优收益函数,

问题的边界

问题的边界就是 n = 0 || w = 0,此时 F(n, w) = 0

最优子结构

人数够挖金矿的情况

两种最优子结构:

  1. 不挖当前金矿,转化问题,金矿数减一,收益为:F(n-1,w)
  2. 挖当前金矿,当前金矿收益就是 g[n-1](第 5 座对应数组下标为 4),需要用掉 p[n - 1]个人,剩下 w - p[n - 1]个人,金矿数剩下 n-1,收益为:g[n - 1] + F(n - 1, w - p[n - 1])

那么第 n 座金矿的最佳收益,就是两种情况下最大的那个,Max(F(n - 1, w), g[n - 1] + F(n - 1, w - p[n - 1]))。

F(n, w)的两个参数应该要大于等于 0:

  • n-1 >= 0 => n >= 1
  • w-p[n - 1] >= 0 => w >= p[n - 1]

整合起来:F(n, w) = Max(F(n - 1, w), g[n - 1] + F(n - 1, w - p[n - 1])) (n >= 1 && w >= p[n - 1])

人数不够挖金矿的情况

在过程中,还会碰到一种情况。假如剩下 2 座金矿,剩下 3 人,计算 F(2,3) 情况。

  • 200kg 黄金 / 3 人
  • 300kg 黄金 / 4 人

对于 300kg 的金矿,3 人不够挖,不能选择第二种挖当前金矿,那么只能选择第一种不挖当前金矿,也就是 F(2,3)最优收益直接等于 F(1,3) 这种情况下的条件就是当前人数 3 小于当前金矿所需的人数 4,也就是 w < p[n - 1]

同样类推出一种最优子结构:F(n, w) = F(n - 1, w) (n >= 1 && w < p[n - 1] )

思路总结

  1. 找到函数的自变量和因变量(这样说不知道准不准确),自变量是工人和金矿数量,因变量是最优选择对应的值,得出 F(n, w),金矿价值和金矿需要的人数可以看做是固定的条件,在函数运算前是可以确定下来的。
  2. 分解子问题,找到动态规划的三要素。
  3. 写算法,先处理边界值。
  4. 然后是特定条件下的子结构,最后是普通结构。同时需考虑参数的限制条件。
  5. 小数据量,断点执行看过程,验证算法流程和计算结果。
  6. 分析算法复杂度,从空间和时间两个角度进行优化。

解法

解法一

js 复制代码
const g = [200, 300, 350, 400, 500]; // 金矿价值
const p = [3, 4, 3, 5, 5]; // 金矿需要的人数

const fn1 = (n, w) => {
  // 边界情况
  if (n === 0 || w === 0) {
    return 0;
  }
  // 需要先判断人数够不够
  if (w < p[n - 1]) {
    return fn1(n - 1, w);
  }
  // 走到这里需要满足 n >= 1 && w >= p[n - 1]
  // 前面的判断刚好已经处理了,到这里肯定满足
  return Math.max(fn1(n - 1, w), g[n - 1] + fn1(n - 1, w - p[n - 1]));
};
console.log(fn1(5, 10)); // 900

注:第一次学习时感觉有一个容易让人糊涂的点,就是作为参数的 n-1,和读取数组中相应元素的 n-1。当前处理的是第 n 座金矿,而第 n 座金矿对应数组中的下标是 n-1,取相应的值就是 g[n-1]、p[n-1]。如果按照思考过程来写代码就不会混淆了。如果需要看的更清晰,可以把 g 和 p 数组的增加第一个元素 0,让下标和第几座金矿数量对应,更方便理解。

js 复制代码
// 给g和p函数第一个位置加一个0,这样访问时n与数组下标刚好对应,不需要-1
const g = [0, 200, 300, 350, 400, 500]; // 金矿价值
const p = [0, 3, 4, 3, 5, 5]; // 金矿需要的人数

const fn1 = (n, w) => {
  // 边界情况
  if (n === 0 || w === 0) {
    return 0;
  }
  // 需要先判断人数够不够
  if (w < p[n]) {
    return fn1(n - 1, w);
  }
  // 走到这里需要满足 n >= 1 && w >= p[n - 1]
  // 前面的判断刚好已经处理了,到这里肯定满足
  return Math.max(fn1(n - 1, w), g[n] + fn1(n - 1, w - p[n]));
};
console.log(fn1(5, 10)); // 900

和楼梯问题一样,运算过程如下,是个树状过程,时间复杂度是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( 2 n ) O(2^n) </math>O(2n)。可以看到有值被重复计算了。

scss 复制代码
            F(5, 10)
    F(4, 10)          F(4, 5)
F(3, 10)  F(3, 5)  F(3, 5)   F(3, 0)
            ......

解法二

如何避免重复调用,还是空间换时间的思路。

解法一中分析问题,是从后往前分析,找到基础值,后面需要计算的值是依赖于前面计算的值。那么从基础值开始计算,把中间状态保存下来,就可以避免重复调用了。如下:

从 F(0,0)开始,依次按照解法一种推导出的规律计算值,后面计算的值可以利用前面算出来的值。 F(0,0)=>F(0,1)=> ...=>F(0,10) F(0,1)=>F(1,1)=> ...=>F(1,10) ... F(5,1)=>F(5,1)=> ...=>F(5,10)

可以得到如下表格:

这样用双循环就可以了,把中间值保存二维数组中。空间复杂度 O(nw),时间复杂度 O(nw)。

为了存储临界值的情况,二维数组纵向长度是金矿数加 1,横向长度是工人数加 1 。

js 复制代码
const fn2 = (w, g, p) => {
  // JS无法直接声明二维数组,先创建二维数组
  const resultTable = new Array(g.length + 1);
  for (let i = 0; i < resultTable.length; i++) {
    resultTable[i] = [];
    for (let j = 0; j < w + 1; j++) {
      resultTable[i][j] = 0;
    }
  }
  console.log(resultTable);
  // i 对应 n 金矿数量
  // j 对应 w 工人数量
  // 这样处理逻辑只要替换解法1中对应的变量即可
  for (let i = 1; i < resultTable.length; i++) {
    for (let j = 1; j < resultTable[i].length; j++) {
      if (j < p[i - 1]) {
        resultTable[i][j] = resultTable[i - 1][j];
      } else {
        resultTable[i][j] = Math.max(
          resultTable[i - 1][j],
          g[i - 1] + resultTable[i - 1][j - p[i - 1]]
        );
      }
    }
  }
  console.log(resultTable);
  return resultTable[resultTable.length - 1][w];
};
const w = 10; // 工人数
const g = [200, 300, 350, 400, 500]; // 金矿价值
const p = [3, 4, 3, 5, 5]; // 金矿需要的人数
console.log(fn2(w, g, p)); // 900

解法三

在解法二的过程中可以看到二维数组中,resultTable[i - 1][j]的值,只依赖于 resultTable[i - 1][j]和 resultTable[i - 1]j - p[i - 1]]的值。也就是下一行的数据只依赖上一行的数据,因此空间复杂度可以进一步优化。

具体在表格中看两个典型的例子:

  • 深灰色的 F(2,1) 是人数不够的情况(w < p[n - 1]),直接等于浅灰色的 F(1,1),也就意味着此时可以直接复用原来的数据,不需要再做计算。
  • 深橙色的 F(2,4)是人数够的情况,依赖于浅橙色的 F(1,4)和 F(1,0)。也就是说在计算第 n=2 行数据时,只依赖了 n=1 行的数据。

按照前面递进数据的思想,那么就可以用一维数组来存储一行的数据。

但是由于后面的数据依赖前面的数据,但是如果从左往右递进替换数据,第二行计算出来的去替换第一行。第二行后面的数据由于需要依赖第一行前面的数据,这样就行不通。

那么换个思路,从右往左递进替换数据。

还是双重循环,表格竖向,从上到下,第一重循环金矿数。横向表格,从右往左,第二重循环工人数。

注:如何写双重循环,第一重循环是在第二重循环中固定的量,也就是横向的从右往左时,列数在变化,行数固定,那么行数对应的就是第一重循环的值。

两重循环出来了,并且 F(2,3) = F(1,3) 这种复用数据的场景就不用写了。直接计算另一种情况即可。

i 和 j 应该要大于等于 1,在循环的条件中体现出来。

js 复制代码
const fn3 = (w, g, p) => {
  // 创建一个w+1长度的数组,赋初始值为0
  const resultTable = new Array(w + 1);
  for (let i = 0; i < resultTable.length; i++) {
    resultTable[i] = 0;
  }
  console.log(resultTable);
  for (let i = 1; i <= g.length; i++) {
    // 从后往前
    for (let j = w; j >= 1; j--) {
      if (j >= p[i - 1]) {
        resultTable[j] = Math.max(
          resultTable[j],
          g[i - 1] + resultTable[j - p[i - 1]]
        );
      }
    }
  }
  console.log(resultTable);
  return resultTable[w];
};
const w = 10; // 工人数
const g = [200, 300, 350, 400, 500]; // 金矿价值
const p = [3, 4, 3, 5, 5]; // 金矿需要的人数
console.log(fn3(w, g, p)); // 900

时间复杂度 O(nw),空间复杂度 O(w)。

3.背包问题

问题描述

有一个背包,可以装载重量为 5kg 的物品。有 4 个物品,他们的重量和价值如下。在不得超过背包的承重的情况下,将哪些物品放入背包,可以使得总价值最大?

  • 物品 1,重 1kg,价值 3 元。
  • 物品 2,重 2kg,价值 4 元。
  • 物品 3,重 3kg,价值 5 元。
  • 物品 4,重 4kg,价值 6 元。

4 个物品装入 5kg 重的背包。物品数量 n,背包能够装载的质量 m,物品重量 w[],物品价值 v[]。F(n, c)

解法

背包问题和金矿问题是一样的,直接用解法三的算法,修改传参即可。

  • 背包容纳的重量对应工人数 w
  • 每个物品重量对应金矿所需的工人数 p
  • 物品价值对应金矿价值 g
js 复制代码
const fn3 = (w, g, p) => {
  // 创建一个w+1长度的数组,赋初始值为0
  const resultTable = new Array(w + 1);
  for (let i = 0; i < resultTable.length; i++) {
    resultTable[i] = 0;
  }
  console.log(resultTable);
  for (let i = 1; i <= g.length; i++) {
    // 从后往前
    for (let j = w; j >= 1; j--) {
      if (j >= p[i - 1]) {
        resultTable[j] = Math.max(
          resultTable[j],
          g[i - 1] + resultTable[j - p[i - 1]]
        );
      }
    }
  }
  console.log(resultTable);
  return resultTable[w];
};
const w = 5; // 背包容纳的重量
const g = [3, 4, 5, 6]; // 物品价值
const p = [1, 2, 3, 4]; // 物品重量
console.log(fn3(w, g, p)); // 9

上篇:前端算法小白学动态规划-1

相关推荐
崔庆才丨静觅13 分钟前
5分钟快速搭建 AI 平台并用它赚钱!
前端
智驱力人工智能27 分钟前
小区高空抛物AI实时预警方案 筑牢社区头顶安全的实践 高空抛物检测 高空抛物监控安装教程 高空抛物误报率优化方案 高空抛物监控案例分享
人工智能·深度学习·opencv·算法·安全·yolo·边缘计算
崔庆才丨静觅39 分钟前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment42 分钟前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅1 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊1 小时前
jwt介绍
前端
爱敲代码的小鱼1 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax
孞㐑¥1 小时前
算法——BFS
开发语言·c++·经验分享·笔记·算法
月挽清风1 小时前
代码随想录第十五天
数据结构·算法·leetcode
XX風2 小时前
8.1 PFH&&FPFH
图像处理·算法