zoukankan      html  css  js  c++  java
  • Knapsack Problem

    0-1背包

    • 描述:N件物品,第i件的重量是w[i],价值v[i]。有一个容量为W的背包,求将哪些物品放入背包可使总价值最大。每件物品可以用0或1次
    • 分析:根据题意,可以写出表达式:

    [max(Sigma v_ix_i), s.t. Sigma w_ix_i<=W, x_iin{0, 1} ]

    最直接的思路就是:对于每件物品,都有yes/no两种选择,尝试所有的组合,记录每个组合的价值,选出满足重量条件的最大价值。时间复杂度(O(2^n)),空间复杂度(O(n))

    // backtracking
    class knapsack01 {
    public:
    	int knapsack(int W, vector<int>& w, vector<int>& v, string& ans) {
    		string cur(w.size(), '0');
    
    		dfs(0, 0, 0, W, w, v, cur, ans);
    		return maxV;
    	}
    private:
    	void dfs(int s, int curW, int curV, int W, vector<int>& w, vector<int>& v, string& cur, string& ans) {
    		// 到达叶子结点,得到一个解,所以在这里更改最终结果
    		if (s >= w.size()) {
    			if (maxV < curV) {
    				ans.assign(cur);
    				maxV = curV;
    			}
    			return;
    		}
    
    		// as for goods s, two choices
    		for (int i = 0; i < 2; ++i) {
    			cur[s] = i + '0';
    
    			if (curW + i * w[s] <= W) {
    				curW += i * w[s];
    				curV += i * v[s];
    				dfs(s + 1, curW, curV, W, w, v, cur, ans);
    				curW -= i * w[s];
    				curV -= i * v[s];
    			}
    		}
    	}
    
    	int maxV = 0;
    };
    

    上面的程序可以通过剪枝进行优化,下来换一种思路:
    dp[i][j]表示前i件物品重量恰好为j时具有的最大价值,问题转化为求dp[N][0...W]的最大值,边界条件dp[0...N][0]=0
    假设3件物品,(w={1,1,2})(v={1,2,4})(W=2),先用递归形式分析,每件物品只有yes/no两种状态:
    在这里插入图片描述
    可以看到,求解过程中有很多重叠子问题,故可以采用记忆化递归求解,时间复杂度即为子问题数量(O(NW)),空间复杂度(O(NW))
    记忆化递归可以写成自底向上的动态规划,状态转移方程:

    [dp[i][j]=max{dp[i-1][j], v[i]+dp[i-1][j-w[i]]} ]

    // dp->space complexity O(NW)
    class knapsack01 {
    public:
    	int knapsack(int W, vector<int>& w, vector<int>& v) {
    		const int N = w.size();
    		vector<vector<int>> dp(N + 1, vector<int>(W + 1, 0));
    		for(int i = 1;i <= N;++i)
    			for (int j = w[i - 1]; j <= W; ++j) {
    				dp[i][j] = max(dp[i - 1][j], v[i - 1] + dp[i - 1][j - w[i - 1]]);
    			}
    
    		return *max_element(dp[N].begin(), dp[N].end());
    	}
    };
    

    前i件物品只依赖于前i-1件物品,(dp)数组的更新方向为:
    在这里插入图片描述
    所以可以使用滚动数组降低空间复杂度为(O(W))

    // dp->space complexity O(W)
    // method 1: use temp array
    class knapsack01 {
    public:
    	int knapsack(int W, vector<int>& w, vector<int>& v) {
    		const int N = w.size();
    		vector<int> dp(W + 1, 0);
    		for (int i = 1; i <= N; ++i) {
    			vector<int> temp(W + 1, 0);
    			for (int j = w[i - 1]; j <= W; ++j) {
    				temp[j] = max(temp[j], v[i - 1] + dp[j - w[i - 1]]);
    			}
    			dp.swap(temp);
    		}
    		
    		return *max_element(dp.begin(), dp.end());
    	}
    };
    
    // method 2: use scrolling array
    class knapsack01 {
    public:
    	int knapsack(int W, vector<int>& w, vector<int>& v) {
    		const int N = w.size();
    		vector<int> dp(W + 1, 0);
    		for (int i = 1; i <= N; ++i) {
    			// iterate j reversely, avoid dp override
    			for (int j = W; j >= w[i - 1]; --j) {
    				dp[j] = max(dp[j], v[i - 1] + dp[j - w[i - 1]]);
    			}
    		}
    		
    		return *max_element(dp.begin(), dp.end());
    	}
    };
    

    有一道比较类似的题目Target Sum,分析下此题顺便再练习下DP的套路。
    题意是这样:给定一些非负数字,可以给每个数字添上+-号,使得添加后的所有数字之和等于S。数组大小不超过20,S大小不超过1000。
    我第一次做感觉这是个纯暴力DFS,枚举所有可能,复杂度(O(2^n))
    这道题和0/1背包不同之处在于:数组里的所有数字都必须用到。

    接着我们试着做一些优化:
    换种方式看问题:在这堆数字中选一些作为正数集合(P),剩下作为负数集合(N),那么有(sum(P)-sum(N)=S)(sum(P)+sum(N)+sum(P)-sum(N)=sum(nums)+S=2*sum(P)),故(sum(P)=(sum(nums)+S)/2),同时注意到(sum(nums)+S)是偶数。
    所以问题转化为在数组中寻找一些数作为正数,使得这些数的和为((sum(nums)+S)/2),求这样的组合有多少种。这就转化为了0/1背包问题。

    我们用(dp(i,j))表示从前i个数中选出和为j的方案数目,有状态转移方程(dp(i,j)=dp(i-1,j)+dp(i-1,j-nums[i])),如果纯粹暴力递归,有很多重叠子问题(类似背包问题的那个图)。Base Case就是,(dp(0,0)=1),即选出和为0的方案,就是每个都不选一种;否则当i = 0 || j < 0时,(dp(i,j)=0)

    所以先用记忆化搜索:

    class Solution {
    public:
        int findTargetSumWays(vector<int>& nums, int S) {
            int sum = 0;
            for (int num : nums)
                sum += num;
            vector<vector<int>> memo(21, vector<int>(1001, -1));
            return sum < S || (sum + S) & 1 ? 0 : cnt(nums, memo, nums.size(), (sum + S) >> 1);
        }
    private:
        int cnt(vector<int>& nums, vector<vector<int>>&memo, int idx, int sum) {
            if (!idx && !sum)
                return 1;
            if (!idx || sum < 0)
                return 0;
            
            if (memo[idx][sum] > 0)
                return memo[idx][sum];
            memo[idx][sum] = cnt(nums, memo, idx - 1, sum) + cnt(nums, memo, idx - 1, sum - nums[idx - 1]);
            return memo[idx][sum];
        }
    };
    

    改一下Bottom-Up的形式,更新方向从左到右、从上到下,注意这里j不能从nums[i - 1]开始,否则j的前半部分无法更新到正确的值,后面如果用到就是错误的值。

    class Solution {
    public:
        int findTargetSumWays(vector<int>& nums, int S) {
            int sum = 0;
            for (int num : nums)
                sum += num;
            if (sum < S || (sum + S) & 1)
                return 0;
            
            vector<vector<int>> dp(21, vector<int>(1001, 0));
            dp[0][0] = 1;
            for (int i = 1; i <= nums.size(); ++i) {
            	// 不能for (int j = nums[i - 1]; j <= (sum + S) >> 1; ++j)
                for (int j = 0; j <= (sum + S) >> 1; ++j) {
                    dp[i][j] = dp[i - 1][j];
                    if (j >= nums[i - 1]) {
                        dp[i][j] += dp[i - 1][j - nums[i - 1]];
                    }
                }
            }
            return dp[nums.size()][(sum + S) >> 1];
        }
    };
    

    最后来优化空间:(dp(i,j))取决于(dp(i-1,j))(dp(i-1,j-num[i-1])),所以只要用一个一维数组(dp(j))记录上一行的所有值即可,这里必须反向更新,因为如果正向,上一行的j-num[i-1]位置已经被新值覆盖,计算结果出错。如果反向:上一行需要的2个位置都没有被覆盖:(dp(j)=dp(j)+dp(j-nums[i-1]))
    初始时候(0,0)位置为1,即(dp(0)=1),以后(dp(0))会不断更新。

    class Solution {
    public:
        int findTargetSumWays(vector<int>& nums, int S) {
            int sum = 0;
            for (int num : nums) {
                sum += num;
            }
            return sum < S || (S + sum) & 1 ? 0 : numSubsetSum(nums, (S + sum) >> 1);
        }
    private:
        int numSubsetSum(vector<int>& nums, int target) {
            vector<int> dp(target + 1, 0);
            dp[0] = 1;
            for (int i = 0; i < nums.size(); ++i) {
                /* 等价写法
            	for (int j = target; j >= 0; --j){
    					if (j - nums[i] >= 0)
    						dp[j] += dp[j - nums[i]];
    			} */
    			// 只有j>=nums[i]才更新,否则沿用上一行的值
                for (int j = target; j >= nums[i]; --j) {
                    dp[j] += dp[j - nums[i]];    
                }
            }
            return dp[target];
        }
    };
    

    完全背包

    • 每件物品可以使用任意多次
    • 一个Naive的思路: 虽然题目描述每件物品可以使用任意多次,但实际上由于W的限制,每件物品最多使用(lfloor W/w[i] floor)次。这样我们可以将每件物品拆为(lfloor W/w[i] floor)件,问题就转化为了0-1背包。子问题仍然有NW个,但是求解每个子问题需要(O(W/w[i])),总的时间复杂度(O(Sigma (W/w[i])*W)),也即(O(W*拆后物品件数))
    • 更tricky的做法:W无法改变,只能改变拆后物品件数。这里可以使用二进制的思想:假设我们某件物品可以使用(10=8+2)次,原本需要复制出10件,现在只要复制出2件,价值和重量是原来的8倍和2倍,这样就降低了复杂度。
    • 完全背包有(O(NW))的算法。

    多重背包

    • 每件物品最多可以使用(num[i])次。
    • 同样,Naive的思路就是将每件物品都复制(num[i])次,问题转化为0-1背包,复杂度(O(Sigma nums[i]*W))
    • (num[i])用二进制表示,价值和重量变为原来的相应倍,降低复杂度。

    Future

    后续还有混合背包、二维费用的背包等,详情可以学习背包九讲

  • 相关阅读:
    Redis 存储字符串和对象
    深入理解Java中的final关键字
    Redis缓存Object,List对象
    JVM最多能创建多少个线程: unable to create new native thread
    Java中如何使用Redis做缓存
    Fiddler抓包11-HTTPS证书Actions无法导出问题
    Fiddler抓包10-会话框添加查看get与post请求类型
    python接口自动化10-token登录
    python接口自动化9-https请求(SSL)
    python爬虫beautifulsoup4系列4-子节点
  • 原文地址:https://www.cnblogs.com/EIMadrigal/p/12345051.html
Copyright © 2011-2022 走看看