前端算法小白学动态规划-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

相关推荐
undefined&&懒洋洋9 分钟前
Web和UE5像素流送、通信教程
前端·ue5
秋夜Autumn22 分钟前
贪心算法相关知识
算法·贪心算法
小懒编程日记34 分钟前
【数据结构与算法】B树
java·数据结构·b树·算法
心怀花木42 分钟前
【算法】双指针
c++·算法
闫铁娃43 分钟前
二分解题的奇技淫巧都有哪些,你还不会吗?
c语言·数据结构·c++·算法·leetcode
Y_3_71 小时前
【回溯数独】有效的数独(medium)& 解数独(hard)
java·数据结构·windows·算法·dfs·回溯
大前端爱好者2 小时前
React 19 新特性详解
前端
shan_shmily2 小时前
算法知识点————贪心
算法
寂柒2 小时前
C++——模拟实现stack和queue
开发语言·c++·算法·list
随云6322 小时前
WebGL编程指南之着色器语言GLSL ES(入门GLSL ES这篇就够了)
前端·webgl