数据结构与算法-国王与金矿
题目:
有一个国家发现了 \(N\) 座金矿,每座金矿的黄金储量不同,需要参与挖掘的工人数也不同。参与挖矿工人的总数是 \(W\) 人。每座金矿要么全挖,要么不挖,不能派出一半人挖取一半金矿。要求用程序求解出,要想得到尽可能多的黄金,应该选择挖取哪几座金矿?
每座金矿的产量和需要的矿工人数都不相同:
一个输入例子:
w = 10
500金/5人 400金/5人 200金/3人 300金/4人 350金/3人
解答
排列组合
每一座金矿都有挖与不挖两种选择,如果有 \(N\) 座金矿,排列组合起来就有 \(2^N\) 种选择。对所有可能性做遍历,排除那些使用工人数超过 \(10\) 的选择,在剩下的选择里找出获得金币数最多的选择。
代码比较简单就不展示了,时间复杂度也很明显,就是 \(O(2^N)\) , 这在大部分题目中都会超时。
动态规划
如果仔细思考,可以发现这道题和 01背包问题类似,都可以使用动态规划的方法解答。
动态规划有三个核心元素:最优子结构、边界、状态转移方程式。
下面,我们把金矿数量设为 \(N\) ,工人数设为 \(W\),金矿的黄金量设为数组 G[]
,金矿的用工量设为数组 P[]
。对于上面的输入例子:
vector<int> G = {500, 400, 200, 300, 350};
vector<int> P = {5, 5, 3, 4, 3};
那么 5 座金矿和 4 座金矿的最优选择之间存在这样的关系:\(F(5,10) = MAX( F(4,10), F(4,10-P[4])+G[4] )\) 注意:数组下标从0开始。
最后我们还需要确定一下,这个问题的边界是什么?
边界两种情况:
(1)只有 1 座金矿,也就是 \(N=1\) 的时候。这时候没得选,只能挖这座唯一的金矿,得到的黄金数量就是 G[0]
。
(2)如果给定的工人数量不够挖取第 1 座金矿,也就是 \(W < P[0]\) 的时候,那么得到的黄金数就是 0 了。
数据初始化和状态转移
我们先画出如下表格,表格的第一列代表给定前 1-5 座金矿的情况,也就是 \(N\) 的取值。表格的第一行代表给定的工人数,也就是 \(W\) 的取值。表格中其余的空白格,代表给定 \(N\) 和 \(W\) 值对应的黄金获得数,也就是 \(F(N,W)\)。下面让我们来逐行填写表格中的空白。
- 1金矿时,是400金,需要 5 工人。所以前4个格子都是0,因为人数不够。后面格子都是400,因为只有这一座金矿可挖。
- 第2座金矿有 500 金,需要 5 工人。第二行前 4 个格子是 \(W < 5\), 所以 \(F(N,W)= F(N - 1,W)= 0\)。
- 第 2 行后 6 个格子计算,因为 \(W \ge 5\) ,所以根据 \(F(N,W)= MAX(F(N-1,W),F(N-1,W-5)+500)\),第 5-9 个格子的值是 500 。( 需要注意的是第 2 行第 10 个格子,也就是 \(N=2,W=10\) 的时候,\(F(N-1,W)=400,F(N-1,W-5)=400,MAX(400,400+500) = 900\)。)
- 第 3 座金矿有 200 金,需要 3 工人。第 3 行计算方法和前面一样。
- 第4座金矿有300金,需要4工人,计算方法同上。
- 第5座金矿有350金,需要3工人,计算方法同上。
将上面的逻辑转化为代码:
class Solution {
public:
int maxGold(const vector<int> &gold, const vector<int> &man, int w) {
int N = gold.size();
// 下标从 1 开始
vector<vector<int> > dp = vector<vector<int> >(N+1, vector<int>(w+1, 0));
// 初始化第一行
for(int i = 1; i <= w; i++) {
if(i < man[0]) {
dp[1][i] = 0;
} else {
dp[1][i] = gold[0];
}
}
for(int i= 2; i<= N; i++) {
for(int j = 1; j <= w; j++) {
if(j < man[i-1]) {
dp[i][j] = dp[i-1][j];
} else {
dp[i][j] = max(dp[i-1][j], dp[i-1][j-man[i-1]] + gold[i-1]);
}
}
}
return dp[N][w];
}
};
上面的代码有优化的空间,我们可以发现,计算当前行的结果只与前一行相关,不需要保留所有的计算结果。这样可以降低空间复杂度。
class Solution {
public:
int maxGold(const vector<int> &gold, const vector<int> &man, int w) {
int N = gold.size();
vector<int> dp = vector<int>(w+1, 0);
for(int i= 1; i<= N; i++) {
for(int j = w; j >= man[i-1]; j--) {
dp[j] = max(dp[j], dp[j-man[i-1]] + gold[i-1]);
}
}
return dp[w];
}
}
我们换个问法,我们把金矿问题转换为 01背包问题:要求“恰好装满背包”时的最优解,有的题目则并没有要求必须把背包装满。一种区别,这两种问法的实现方法是在初始化的时候有所不同。如果要求恰好装满背包,那么在初始化时除了 \(f[0]\) 为 \(0\) 其它 \(f[1..V]\) 均设为 \(−\infty\),这样就可以保证最终得到的 \(f[N]\) 是一种恰好装满背包的最优解。
如果并没有要求必须把背包装满,而是只希望价格尽量大,初始化时应该将 \(f[0..V]\) 全部设为 \(0\)。
为什么呢?可以这样理解:初始化的 \(f\) 数组事实上就是在没有任何物品可以放入背包时的合法状态。如果要求背包恰好装满,那么此时只有容量为 \(0\) 的背包可能被价值为 \(0\) 的 nothing “恰好装满”,其它容量的背包均没有合法的解,属于未定义的状态,它们的值就都应该是 \(−\infty\) 了。如果背包并非必须被装满,那么任何容量的背包都有一个合法解“什么都不装”,这个解的价值为 \(0\),所以初始时状态的值也就全部为 \(0\) 了。
class Solution {
public:
int maxGold(const vector<int> &gold, const vector<int> &man, int w) {
int N = gold.size();
vector<int> dp = vector<int>(w+1, INT_MIN);
dp[0] =0;
for(int i= 1; i<= N; i++) {
for(int j = w; j >= man[i-1]; j--) {
dp[j] = max(dp[j], dp[j-man[i-1]] + gold[i-1]);
if(dp[j] < 0) dp[j] = INT_MIN;
}
}
return dp[w];
}
}
递归法
把状态转移方程式翻译成递归程序,递归的结束的条件就是方程式当中的边界。因为每个状态有两个最优子结构,所以递归的执行流程类似于一颗高度为 \(N\) 的二叉树。
class Solution {
public:
int getMostGold(const vector<int> &gold, const vector<int> &man, int w, int n) {
if(n <=1 && w < man[0]) {
return 0;
}
if(n == 1 && w >= man[0]) {
return gold[0];
}
if(n > 1 && w < man[n-1]) {
return getMostGold(gold, man, w, n-1);
}
return max(getMostGold(gold, man, w, n-1), getMostGold(gold, man, w - man[n-1], n-1) + gold[n-1]);
}
}
记忆化搜索法
在简单递归的基础上增加一个 map
备忘录,用来存储中间结果。map
的 Key 是一个包含金矿数 N 和工人数 W 的对象,Value 是最优选择获得的黄金数。
方法的时间复杂度和空间复杂度相同,都等同于备忘录中不同 Key 的数量。
auto pair_hash = [fn=hash<int>()](const pair<int, int> &o) {
return ((fn(o.first) << 16) ^ fn(o.second));
};
unordered_map<pair<int, int>, int, decltype(pair_hash)> mem(0, pair_hash);
class Solution{
int getMostGoldWithMap(const vector<int> &gold, const vector<int> &man, int w, int n) {
if(n <=1 && w < man[0]) {
return 0;
}
if(n == 1 && w >= man[0]) {
return gold[0];
}
if(n > 1 && w < man[n-1]) {
pair<int, int> p(n-1, w);
if(mem.find(p) != mem.end()) {
return mem[p];
}
int v = getMostGoldWithMap(gold, man, w, n-1);
mem[p] = v;
return v;
}
pair<int, int> p1(n-1, w);
pair<int, int> p2(n-1, w - man[n-1]);
int a, b;
if(mem.find(p1) != mem.end()) {
a = mem[p1];
} else {
a = getMostGoldWithMap(gold, man, w, n-1);
mem[p1] = a;
}
if(mem.find(p2) != mem.end()) {
b = mem[p2];
} else {
b = getMostGoldWithMap(gold, man, w-man[n-1], n-1);
mem[p2] = b;
b += gold[n-1];
}
return max(a, b);
}
};