40.动态规划(8) | 单词拆分(h)、多重背包

        今天是完全背包的又一个应用和多重背包的基础,到这里背包问题就结束了。第1题复习了回溯法,新接触到记忆化递归,更重要地,还学习了bool形式dp数组的多重背包问题,其中最重要的是遍历顺序,还需要多熟悉。第2题学习了多重背包的模板方法,也就是将多重背包转换为01背包,比较容易。


第1题(LeetCode 139. 单词拆分

        比较难,自己没想出来,直接看了题解。有回溯和DP两种解法,前者时间复杂度O(2^n),后者O(n^3)。明显DP解法更优。

        用回溯方法的话,需要设置indBegin作为递归参数,在递归函数中将其作为当前待尝试单词的开始。在循环中从indBegin开始遍历当前待尝试单词结尾下标,根据两下标得到当前待尝试单词。然后从wordDict中查找该单词(为了提高查找效率,需要将wordDict转换为集合来查找),如果找到则进行下一层递归,直至indBegin达到目标字符串s的末尾就返回true;如果没找到,则进行下一个尝试。按这样的方法,直接写出下面的代码虽然正确,但会超时:

class Solution {
public:
    bool back(string& s, unordered_set<string>& wordSet, int indBegin) {
        if (indBegin == s.size()) {
            return true;
        }
        for (int i = indBegin; i < s.size(); i++) {
            string word = s.substr(indBegin, i - indBegin + 1);
            if (wordSet.find(word) != wordSet.end() && back(s, wordSet, i + 1)) {
                return true;
            }
        }
        return false;
    }
    bool wordBreak(string s, vector<string>& wordDict) {
        unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
        return back(s, wordSet, 0);
    }
};

        优化的方法就是进行剪枝,具体的方法叫做记忆化递归。在原本的回溯过程中,已经得到了“从某个下标开始尝试”不可行的结果,但仍会进行重复尝试。所以记忆化递归的做法是设置一个长度与目标字符串s相等的bool数组memory,用于记录从某一下标出发开始尝试是否可行。在一开始初始化memory所有值为true。如果在某一下标处进行了所有循环但仍未返回true,就说明从该下标出发不可行,就将该下标的memory设置为false。在之后的递归中循环开始前,如果当前起始下标对应的memory值为false,那么当前递归就也直接返回false,从而避免了无效的尝试。

class Solution {
public:
    vector<bool> memory;
    bool back(string& s, unordered_set<string>& wordSet, int indBegin) {
        if (indBegin == s.size()) {
            return true;
        }
        if (memory[indBegin] == false) {
            return false;
        }
        for (int i = indBegin; i < s.size(); i++) {
            string word = s.substr(indBegin, i - indBegin + 1);
            if (wordSet.find(word) != wordSet.end() && back(s, wordSet, i + 1)) {
                return true;
            }
        }
        memory[indBegin] = false;
        return false;
    }
    bool wordBreak(string s, vector<string>& wordDict) {
        unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
        memory.resize(s.size(), true);
        return back(s, wordSet, 0);
    }
};

代码中需要注意,递归的循环中,i一定是从indBegin开始的。自己起初是设置为从indBegin + 1开始的,那么下一层递归的indBegin参数就不再应该是(i + 1)了,而应该是i,否则会错误地略过当前层的第(indBegin + 1)个元素。并且递归函数开头if (indBegin == s.size())中的"=="也要改完">="。所以还是将i设置为从indBegin开始好很多,会省很多麻烦。

        背包解法方面,需要将dp[i]定义为bool类型,定义为目标字符串s的前i部分能否由单词表中单词组成。如果dp[j](j < i)为true,且目标字符串s的[j, i]部分能在单词表wordDict中找到的话,dp[i]就也设置为true。所以该问题的背包容量递增的过程目标字符串s从0向后扩展的过程,当前的目标字符串,也就是当前背包容量,对应的物品是“当前目标字符串的所有后缀”,而所有物品就是目标字符串s的所有子串。

        初始化方面,dp[0]应设置为true,代表空字符串是可以构成的,否则dp的其他值都不会是true。而遍历顺序上,因为是完全背包问题,所以内外层循环方向都是正向。而内外层顺序虽然理论上可以交换,但如果按照习惯使用外层循环遍历物品,内层循环遍历背包容量的方式,那么外层循环的物品就是前面提到的目标字符串s的所有子串。这就需要提前用一个容器保存目标字符串s的所有子串,在实现上会比较麻烦。而如果使用外层循环遍历背包容量,内层循环遍历物品的方式,则会在内层循环中遍历“当前目标字符串的所有后缀”,实现上容易很多。

class Solution {
public:
    bool wordBreak(string s, vector<string>& wordDict) {
        unordered_set wordSet(wordDict.begin(), wordDict.end());
        int n = s.size();
        vector<bool> dp(n + 1, false);
        dp[0] = true;
        for (int end = 1; end <= n; ++end) {
            for (int begin = 0; begin < end; ++begin) {
                string sub = s.substr(begin, end - begin);
                int idx = begin - 1 + 1;
                if (dp[idx] && wordSet.find(sub) != wordSet.end()) {
                    dp[end] = true;
                    break;
                }
            }
        }
        return dp[n];
    }
};

        这里时间复杂度O(n^3)是因为substr()的时间复杂度是O(n)。

        二刷:忘记方法。另外,内部循环中,if()中dp的下标idx,因为dp[i]对应的是s[i - 1],所以s[last]对应dp[last + 1],last即begin - 1,所以idx = last + 1 = begin - 1 + 1 = begin。


第2题(多重背包问题)

        在LeetCode上也没有标准模板题,所以自行写数据测试。多重背包问题是指物品中的每个物品是有限个的。该问题有多种解法,其中比较容易理解和实现的是转化为01背包问题,也就是把每个数量为n的物品拆分为n个数量为1的物品,然后用01背包的解法解决。最直接的实现方法是将原来的物品数组拆分为更长的新数组,包括weight和value。

#include<iostream>
#include<vector>

using namespace std;

int main() {
	vector<int> nums = {2, 3, 2};
	vector<int> weight = {1, 3, 4};
	vector<int> value = {15, 20, 30};
	int space = 10;
	for (int i = 0; i < nums.size(); ++i) {
		while (nums[i] > 1) {
			weight.push_back(weight[i]);
			value.push_back(value[i]);
			nums[i]--;
		}
	}
	vector<int> dp(space + 1, 0);
	for (int i = 0; i < weight.size(); ++i) {
		for (int j = space; j >= weight[i]; --j) {
			dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
		}
		for (int j = 0; j <= space; ++j) {
			cout << dp[j] << " ";
		}
		cout << endl;
	}
	cout << dp[space] << endl;
	return 0;
}

需要注意第12行的循环条件处,当物品数量为1时就应该停止循环,而不是0时,否则会错误地多加1件物品。
        实际上,不需要用额外空间来拆分原来的物品数组也可以实现。做法是在更新dp矩阵时再多嵌套一层循环,循环次数就是当前物品数。

#include<iostream>
#include<vector>

using namespace std;

int main() {
	vector<int> nums = {2, 3, 2};
	vector<int> weight = {1, 3, 4};
	vector<int> value = {15, 20, 30};
	int space = 10;
	vector<int> dp(space + 1, 0);
	// 物品和背包容量之间增加一层循环
	for (int i = 0; i < weight.size(); ++i) {
		for (int k = 0; k < nums[i]; ++k) {
			for (int j = space; j >= weight[i]; --j) {
				dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
			}
			for (int j = 0; j <= space; ++j) {
				cout << dp[j] << " ";
			}
			cout << endl;
		}
	}
	cout << dp[space] << endl;
	return 0;
}

        这层循环也可嵌套在最内层,此时最内层循环就需要将该物品数量从1遍历到最大数量。循环变量为k的话,放入当前物品的选项需要从dp[j - weight[i]] + value[i]改为dp[j - k * weight[i]] + k * value[i]),代表分别放入k件当前物品。所以可以将第12~23行代码替换为:

// 背包容量内增加一层循环
for (int i = 0; i < weight.size(); ++i) {
	for (int j = space; j >= weight[i]; --j) {
		for (int k = 1; k <= nums[i] && j >= k * weight[i]; ++k) {
			dp[j] = max(dp[j], dp[j - k * weight[i]] + k * value[i]);
		}
	}
	for (int j = 0; j <= space; ++j) {
		cout << dp[j] << " ";
	}
	cout << endl;
}
  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值