数据结构与算法-国王与金矿

题目:

有一个国家发现了 \(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)\)。下面让我们来逐行填写表格中的空白。

image

  • 1金矿时,是400金,需要 5 工人。所以前4个格子都是0,因为人数不够。后面格子都是400,因为只有这一座金矿可挖。

image

  • 第2座金矿有 500 金,需要 5 工人。第二行前 4 个格子是 \(W < 5\), 所以 \(F(N,W)= F(N - 1,W)= 0\)

image

  • 第 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\)。)

image

  • 第 3 座金矿有 200 金,需要 3 工人。第 3 行计算方法和前面一样。

image

  • 第4座金矿有300金,需要4工人,计算方法同上。

image

  • 第5座金矿有350金,需要3工人,计算方法同上。

image

将上面的逻辑转化为代码:

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);
    }
};
posted @ 2022-08-08 13:37  Logan_Xu  阅读(170)  评论(0编辑  收藏  举报