数据结构 - 背包问题

01背包

有一个容量为 N 的背包,用这个背包装下物品的价值最大,这些物品有两个属性:体积 w 和价值 v。

定义一个二维数组 dp 存储最大价值,其中 dp[i][j] 表示前 i 件物品体积不超过 j 的情况下能达到的最大价值。设第 i 件物品体积为 w,价值为 v,根据第 i 件物品是否添加到背包中,可以分两种情况讨论:

  • 第 i 件物品没添加到背包,总体积不超过 j 的前 i 件物品的最大价值就是总体积不超过 j 的前 i-1 件物品的最大价值,dp[i][j] = dp[i-1][j]。
  • 第 i 件物品添加到背包中,dp[i][j] = dp[i-1][j-w] + v。
    第 i 件物品可添加也可以不添加,取决于哪种情况下最大价值更大。

因此,0-1 背包的状态转移方程为: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j − w[i]] + v[i])

// W 为背包总体积
// N 为物品数量
// weights 数组存储 N 个物品的重量
// values 数组存储 N 个物品的价值
public int knapsack(int W, int N, int[] weights, int[] values) {
    int[][] dp = new int[N + 1][W + 1];
    for (int i = 1; i <= N; i++) {
        int w = weights[i - 1], v = values[i - 1];
        for (int j = 1; j <= W; j++) {
            if (j >= w) {
                dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - w] + v);
            } else {
                dp[i][j] = dp[i - 1][j];
            }
        }
    }
    return dp[N][W];
}

空间优化
观察状态转移方程可以知道,前 i 件物品的状态仅与前 i-1 件物品的状态有关,因此可以将 dp 定义为一维数组,其中 dp[j] 既可以表示 dp[i-1][j], 也可以表示 dp[i][j]。

此时,dp[j] = max(dp[j], dp[j−w[i]]+v[i])

注意:
必须保证,在计算dp[ j ]的时候,dp[ j - w[i]]依然等于dp[i -1][ j - w[i] ]。

如果 j 从小到大计算,dp[ j - w[i]] 已经在 dp[j]之前被计算过了。用到的dp[ j - w[i]]是第i次循环计算出来的值, 相当于在执行:dp[ i ][ j ] = max{ dp[i - 1][ j ], dp[ i ][ j - w[i] ] }; (注意是dp[ i ][ j - w[i]] )

因为 dp[j-w] 表示 dp[i-1][j-w],因此不能先求 dp[i][j-w],防止将 dp[i-1][j-w] 覆盖。即要先计算 dp[i][j] 再计算 dp[i][j-w],程序实现时需要按倒序来循环求解。

public int knapsack(int W, int N, int[] weights, int[] values) {
    int[] dp = new int[W + 1];
    for (int i = 1; i <= N; i++) {
        int w = weights[i - 1], v = values[i - 1];
        for (int j = W; j >= 1; j--) {
            if (j >= w) {
                dp[j] = Math.max(dp[j], dp[j - w] + v);
            }
         }
    }
    return dp[W];
}

完全背包

完全背包与01背包不同就是每种物品可以有无限多个:一共有N种物品,每种物品有无限多个,第 i(i从1开始)种物品的重量为w[ i ],价值为v[ i ]。在总重量不超过背包承载上限W的情况下,能够装入背包的最大价值是多少?

分析
目标和变量和01背包没有区别,所以可定义与01背包问题几乎完全相同的状态:dp[i][j]表示将前i种物品装进限重为j的背包可以获得的最大价值, 0<=i<=N, 0<=j<=W

初始状态也是一样的,将dp[0][0…W]初始化为0,表示将前0种物品(即没有物品)装入书包的最大价值为0。那么当 i > 0 时dp[i][j]也有两种情况:

  • 不装入第 i 种物品,即dp[i−1][j],同01背包;
  • 装入第 i 种物品,此时和01背包不太一样,因为每种物品有无限个,所以此时不应该转移到dp[ i−1 ][j − w[i]]而应该转移到dp[ i ][j − w[i]],即装入第i种商品后还可以再继续装入第 i 种商品。

所以状态转移方程为: dp[i][j] = max(dp[i−1][j], dp[i][j−w[i]]+v[i]) // j >= w[i]

这个状态转移方程与01背包问题唯一不同就是max第二项不是dp[i-1]而是dp[i]。

可进行空间优化,优化后不同点在于这里的 j 只能正向枚举,而01背包只能逆向枚举,因为这里的max第二项是dp[ i ],而01背包是dp[i-1],即这里就是需要覆盖, 而01背包需要避免覆盖。所以伪代码如下:

// 完全背包问题思路一伪代码(空间优化版)
dp[0,...,W] = 0
for i = 1,...,N    
	for j = w[i],...,W 
		// 必须正向枚举!!!        
		dp[j] = max(dp[j], dp[j−w[i]]+v[i])

由上述伪代码看出,01背包和完全背包问题此解法的空间优化版解法唯一不同就是前者的 j 只能逆向枚举而后者的 j 只能正向枚举,这是由二者的状态转移方程决定的。此解法时间复杂度为O(NW), 空间复杂度为O(W)。

注意:
求解顺序的完全背包问题时,对物品的迭代应该放在最里层,对背包的迭代放在外层,只有这样才能让物品按一定顺序放入背包中。

例子

1. 分割等和子集 (leetcode 416)
可以看成一个背包大小为 sum/2 的 0-1 背包问题。

public boolean canPartition(int[] nums) {
       int sum = 0;
       for(int num: nums) {
           sum += num;
       }

       if (sum % 2 == 1) {
           return false;
       }

       int W = sum / 2;
       boolean[] dp = new boolean[W + 1];
       dp[0] = true;

       for(int num: nums) {
           for(int i = W; i >= num; i--) {
               dp[i] |= dp[i - num];
           }
       }

       return dp[W];
}

2. 目标和 (leetcode 494)

该问题可以转换为 Subset Sum 问题,从而使用 0-1 背包的方法来求解。
可以将这组数看成两部分,P 和 N,其中 P 使用正号,N 使用负号,有以下推导:

sum( P) + sum(N) = sum(nums)
sum( P) - sum(N) = target
2 * sum(N) = sum(nums) - target

因此只要找到一个子集,并且和等于 (sum(nums) - target)/2,就证明存在解。

public int findTargetSumWays(int[] nums, int target) {
        int sum = 0;
        for(int num: nums) {
            sum += num;
        }

        if (sum < target) {
            return 0;
        }

        if ((sum - target) % 2 != 0) {
            return 0;
        }

        int W = (sum - target) / 2;
        int[] dp = new int[W + 1];
        dp[0] = 1;

        for(int num: nums) {
            for(int i = W; i >= num; i--) {
                dp[i] += dp[i - num];
            }
        }
        return dp[W];
}

3. 一和零 (leetcode 474)

这是一个多维费用的 0-1 背包问题,有两个背包大小,0 的数量和 1 的数量。

public int findMaxForm(String[] strs, int m, int n) {
        if (strs == null || strs.length == 0) {
            return 0;
        }

        int[][] dp = new int[m + 1][n + 1];

        for(String str: strs) {
            int zero = 0, one = 0;
            
            for(char ch: str.toCharArray()) {
                if (ch == '0') {
                    zero++;
                } else if (ch == '1') {
                    one++;
                }
            }

            for(int i = m; i >= zero; i--) {
                for(int j = n; j >= one; j--) {
                    dp[i][j] = Math.max(dp[i][j], dp[i - zero][j - one] + 1);
                }
            }
        }
        return dp[m][n];
}

4. 零钱兑换 (leetcode 322)
因为硬币可以重复使用,因此这是一个完全背包问题。完全背包只需要将 0-1 背包的逆序遍历 dp 数组改为正序遍历即可。

public int coinChange(int[] coins, int amount) {
        int[] dp = new int[amount + 1];
        Arrays.fill(dp, amount + 1);
        dp[0] = 0;

        for(int coin: coins) {
            for(int i = coin; i <= amount; i++) {
                dp[i] = Math.min(dp[i], dp[i - coin] + 1);
            }
        }

        return dp[amount] == (amount + 1) ? -1 : dp[amount];
    }

5. 零钱兑换|| (leetcode 518)

完全背包问题,使用 dp 记录可达成目标的组合数目。

public int change(int amount, int[] coins) {
        if (amount == 0) {
            return 1;
        }

        int[] dp = new int[amount + 1];
        dp[0] = 1;
        
        for(int coin: coins) {
            for(int j = coin; j <= amount; j++) {
                dp[j] += dp[j - coin];
            }
        }

        return dp[amount] == amount + 1 ? 0 : dp[amount];
}
  • 46
    点赞
  • 50
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值