接上篇前端算法小白学动态规划-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
最优子结构
人数够挖金矿的情况
两种最优子结构:
- 不挖当前金矿,转化问题,金矿数减一,收益为:F(n-1,w)
- 挖当前金矿,当前金矿收益就是 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] )
思路总结
- 找到函数的自变量和因变量(这样说不知道准不准确),自变量是工人和金矿数量,因变量是最优选择对应的值,得出 F(n, w),金矿价值和金矿需要的人数可以看做是固定的条件,在函数运算前是可以确定下来的。
- 分解子问题,找到动态规划的三要素。
- 写算法,先处理边界值。
- 然后是特定条件下的子结构,最后是普通结构。同时需考虑参数的限制条件。
- 小数据量,断点执行看过程,验证算法流程和计算结果。
- 分析算法复杂度,从空间和时间两个角度进行优化。
解法
解法一
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