处理动规问题,找到状态转移方程是必不可少,而大多数动态规划问题都可以通过填表的形式找到其状态转移方程,其中01背包及其相关问题更为突出!
01背包问题:
有一个背包,容量为4榜,现有如下物品
要求:
1.达到的目标为装入的背包的总价值最大,并且重量不超出
2.装入的物品不能重复
思路:使用动态规划的思想来解决此问题,既然是要使用动态规划,那么首当其冲的一点是找到此问题的状态转移方程。此题由于有价值和容量两个变量,所以我们定义一个二维数组dp来记录当前的最优解(即使得满足要求的最大价值)。为了更容易的找到状态转移方程,我们先手填画表的形式填充dp数组。
通过上述的填表,不难得出右边的填表规律(状态转移方程),有了它从而代码的实现也就不难了。
public static void main(String[] args) {
int[] w = {1, 4, 3};//物品的重量
int[] val = {1500, 3000, 2000};//物品的价值
int m = 4;//背包的容量
int n = val.length;//物品的个数
int[][] dp = new int[n + 1][m + 1];
//为了记录放入商品的情况,我们定义一个二维数组
int[][] path = new int[n + 1][m + 1];
for (int i = 1; i < dp.length; i++) {
for (int j = 1; j < dp[0].length; j++) {
if (w[i - 1] > j) {
dp[i][j] = dp[i - 1][j];
} else {
//dp[i][j] = Math.max(dp[i - 1][j], val[i - 1] + dp[i - 1][j - w[i - 1]]);
//为了记录商品的存放,不能直接使用状态转移方程
if (dp[i - 1][j] < val[i - 1] + dp[i - 1][j - w[i - 1]]) {
dp[i][j] = val[i - 1] + dp[i - 1][j - w[i - 1]];
path[i][j] = 1;
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
}
System.out.println(dp[n][m]);
//逆向打印路径
int i = path.length - 1;
int j = path[0].length - 1;
while (i > 0 && j > 0) {
if (path[i][j] == 1) {
System.out.println("第" + (i) + "个商品放入背包");
j -= w[i - 1];
}
i--;
}
}
下面给出两个01背包问题的扩展问题。
目标和:
给定一个有n个正整数的数组A和一个整数sum,求选择数组A中部分数字和为sum的方案数,当两种选取方案有一个数字的下标不一样,我们就认为是不同的组成方案。
思路: 此题也有两个变量,一个是sum,一个是数组的内容,所以此处也使用二维数组来记录当前的最优解,为了方便寻找状态转移方程,我们也采用填表的方式。
(示例数组{5,5,10,2,3},sum=15)
得到了上述的状态转移方程,代码实现也就不难了。
public long combinationSum(int[] nums, int target) {
long[][] dp = new long[nums.length + 1][target + 1];
dp[0][0] = 1;
for (int i = 1; i <= nums.length; i++) {
for (int j = 0; j <= target; j++) {
if (j >= nums[i - 1]) {
dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i - 1]];
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
return dp[nums.length][target];
}
算法优化: 仔细观察代码不难发现,在状态转移方程式中,对于i变量而言后者dp有且只与前者dp有关联,故而想到能否只用一维dp数组来解决此题(即忽略变量i),显然这种想法是可以实现的。
第一感觉,直接记录下前一次的数组数据值,方便下一轮循环的直接调用。
public static long combinationSum2(int[] nums, int target) {
long[] dp = new long[target + 1];
dp[0] = 1;
int tmp = nums[0];
for (int i = 1; i <= nums.length; i++) {
for (int j = 0; j <= target; j++) {
if (j >= tmp) {
dp[j] += dp[j - tmp];
}
}
System.out.println(Arrays.toString(dp));
if (i != nums.length) {
tmp = nums[i];
}
}
return dp[target];
}
结果如下:
结果不对,我们发现主要错误在于从前往后遍历时会使得部分结果实现累加而产生重复,由此我们便调转一下思路,反向遍历!
public static long combinationSum3(int[] nums, int target) {
long[] dp = new long[target + 1];
dp[0] = 1;
for (int num : nums) {
for (int j = target; j >= num; j--) {
dp[j] += dp[j - num];
}
System.out.println(Arrays.toString(dp));
}
return dp[target];
}
结果如下:
此时结果便于我们最初的预期一模一样了,并且相比于最开始的方法,其空间复杂度由O(n^2)降低到O(n)。
组合总和:
给定一个由正整数组成且不存在重复数字的数组,找出和为给定目标正整数的组合的个数,同一个元素可以取多次。
思路一:本题是上篇有关回溯博客中组合总和的一个扩展,所以此题依然可以用回溯来解决,至于具体的回溯思想可以参考上篇的回溯博客,这里就不过多讲解。
private int count = 0;
public int combinationSum4(int[] nums, int target) {
Arrays.sort(nums);
backtrack(nums, target, 0);
return count;
}
private void backtrack(int[] nums, int target, int sum) {
if (sum == target) {
count++;
return;
}
for (int num : nums) {
if (sum + num > target) {
break;
}
sum += num;
backtrack(nums, target, sum);
sum -= num;
}
}
此方法虽然简单易懂,但是时间复杂度太高,很多有关递归的题目都可以用动态规划来优化算法,回溯就是借助递归来实现,所以此处依然可以使用动态规划来优化算法。
思路二:运用动态规划的思想,由于此题可以重复取得同一元素(即无限背包),所以当前数组dp的取值就不再受限于已用过的背包。在这里我们只需用一维数组dp来记录在无限背包中能得到当前值的方案总数,所以每外层更新一次dp就需要内层循环遍历一次nums,为了使得状态转移方程更凸显,我们再填表加以演示。
(示例数组{1,2,3},sum=4)
找到了状态转移方程,代码实现也就难度不大了。
public static int combinationSum4(int[] nums, int target) {
int[] dp = new int[target + 1];
dp[0] = 1;
for (int i = 1; i < dp.length; i++) {
for (int num : nums) {
if (i - num >= 0) {
dp[i] += dp[i - num];
}
}
}
return dp[target];
}
01背包问题是动态规划里面有些难度的一类问题,主要是因为其状态转移方程因为背包“重量”的原因而使得其前后两者之间的递推关系并不连续。
(连续示例:dp[i] += dp[i-1])(非连续示例:dp[i] += dp[i-nums[i]])
所以此类问题的状态转移方程往往也不容易直接得到,此时填表就是一个不错的手段。
最后补充一句:动态规划的思想虽然可以得到满足条件的组合总数但是无法得到具体的各种方案,所以此时还得需要回溯来得到结果!(不是有了动规,回溯就没用了,两种算法思想各有各的特点,各有各的使用场景)