leetcode动态规划学习

0-1背包问题

参考:

【动态规划/背包问题】那就从 0-1 背包问题开始讲起吧 ...

内容是学习 宫水三叶的刷题日记 公众号专题内容时的笔记,为了方便个人复习整理到这里。建议大家关注该公众号,写的很清楚,有更多内容。

经典0-1背包

二维数组(空间复杂度为O(mn))

动态规划数组含义:

即第i行代表第i个物品(0表示第一个物品),第j列代表背包容量为j,背包容量从0开始,所以列数为容量+1。

(此部分图片来源:背包问题——01背包|完全背包-CSDN博客

初始化:背包容量为0时最大价值为0,所以第0列全部为0(int数组初始化为0所以不用处理这个),第一行当容量小于w[0]时值为0,大于等于时值为w[i]

对每一个物品,都只有两种状态,选或者不选:

所以转移方程为:

(把c看成j就好了,懒得自己写公式了)

代码:

class Solution {
    public int maxValue(int N, int C, int[] v, int[] w) {
        int[][] dp = new int[N][C+1];
        // 先处理「考虑第一件物品」的情况
        for (int i = 0; i <= C; i++) {
            dp[0][i] = i >= v[0] ? w[0] : 0;
        }
        // 再处理「考虑其余物品」的情况
        for (int i = 1; i < N; i++) {
            for (int j = 0; j < C + 1; j++) {
                // 不选该物品
                int n = dp[i-1][j]; 
                // 选择该物品,前提「剩余容量」大于等于「物品体积」
                int y = j >= v[i] ? dp[i-1][j-v[i]] + w[i] : 0; 
                dp[i][j] = Math.max(n, y);
            }
        }
        return dp[N-1][C];
    }
}

滚动数组优化(空间复杂度为O(2*n))

根据「转移方程」,我们知道计算第 i行格子只需要第 i-1行中的某些值。

也就是计算「某一行」的时候只需要依赖「前一行」。

因此可以用一个只有两行的数组来存储中间结果,根据当前计算的行号是偶数还是奇数来交替使用第 0 行和第 1 行。

这样的空间优化方法称为「滚动数组」,我在 路径问题 第四讲 也曾与你分享过。

这种空间优化方法十分推荐,因为改动起来没有任何思维难度。

只需要将代表行的维度修改成 2,并将dp数组中所有使用行维度的地方从 i改成 i&1或者 i%2 即可(更建议使用 ,& 运算在不同 CPU 架构的机器上要比 % 运算稳定)。

代码:

class Solution {
    public int maxValue(int N, int C, int[] v, int[] w) {
        int[][] dp = new int[2][C+1];
        // 先处理「考虑第一件物品」的情况
        for (int i = 0; i <= C; i++) {
            dp[0][i] = i >= v[0] ? w[0] : 0;
        }
        // 再处理「考虑其余物品」的情况
        for (int i = 1; i < N; i++) {
            for (int j = 0; j < C + 1; j++) {
                // 不选该物品
                int n = dp[(i-1)&1][j]; 
                // 选择该物品,前提「剩余容量」大于等于「物品体积」
                int y = j >= v[i] ? dp[(i-1)&1][j-v[i]] + w[i] : 0; 
                dp[i&1][j] = Math.max(n, y);
            }
        }
        return dp[(N-1)&1][C];
    }
}

一维数组(空间复杂度为O(n))

再次观察我们的「转移方程」:

(把c看成j就好了,懒得自己写公式了)

不难发现当求解第i行格子的值时,不仅是只依赖第 i-1行,还明确只依赖第 i-1行的第j个格子和第j-v[i]个格子。

所以可以进一步将行这个维度优化掉,直接改为dp[j] = Math.max(dp[j],dp[j-v[i]]+w[i]),但是有一个问题就是我们原来是从左向右去遍历j的,如果从左向右的话那当我们改dp[j]的时候它左边的dp[j-v[i]]应该也已经被改过了,这时候对应的是i而不是i-1,所以我们要更改遍历顺序,j从右向左遍历,因为我们要取下标为j-v[i]的元素,所以循环控制条件为j>=v[i]

代码

class Solution {
    public int maxValue(int N, int C, int[] v, int[] w) {
        int[] dp = new int[C + 1];
        for (int i = 0; i < N; i++) {
            for (int j = C; j >= v[i]; j--) {
                // 不选该物品
                int n = dp[j]; 
                // 选择该物品
                int y = dp[j-v[i]] + w[i]; 
                dp[j] = Math.max(n, y);
            }
        }
        return dp[C];
    }
}

如何将原问题抽象为「01 背包」问题

例题1 分割等和子集

1.给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

示例 1:

输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1, 5, 5] 和 [11] 。

示例 2:

输入:nums = [1,2,3,5]
输出:false
解释:数组不能分割成两个元素和相等的子集。

原问题可以看做,从nums中挑选N个元素使得元素总和等于所有元素和的一半。设所有和为sum,所有元素和一半为target。这个问题就可以看做是,背包容量为target,物品重量和价值都为nums[i]的一个背包问题,将最大能容纳的价值求解出来看是不是等于target,就等价于原数组能不能分割成两个子集。

二维数组:dp = new int[N][target+1],初始化第一行,所有容量小于nums[i]的为0,其余为nums[i],转移方程为dp[i][j] = Math.max(dp[i-1][j],dp[i-1][j-nums[i]]+nums[i])。

优化一维数组:dp[j] = Math.max(dp[j],dp[j-nums[i]]+nums[i])

代码:

class Solution {
    public boolean canPartition(int[] nums) {
        int n = nums.length;

        //「等和子集」的和必然是总和的一半
        int sum = 0;
        for (int i : nums) sum += i;
        int target = sum / 2;

        // 对应了总和为奇数的情况,注定不能被分为两个「等和子集」
        if (target * 2 != sum) return false;

        // 将「物品维度」取消
        int[] f = new int[target + 1];
        for (int i = 0; i < n; i++) {
            int t = nums[i];
            // 将「容量维度」改成从大到小遍历
            for (int j = target; j >= 0; j--) {
                // 不选第 i 件物品
                int no = f[j];
                // 选第 i 件物品
                int yes = j >= t ? f[j-t] + t : 0;
                f[j] = Math.max(no, yes);
            }
        }
        // 如果最大价值等于 target,说明可以拆分成两个「等和子集」
        return f[target] == target;
    }
}

总结:

可以发现,本题的难点在于「对问题的抽象」,主要考察的是如何将原问题转换为一个「01 背包」问题。

事实上,无论是 DP 还是图论,对于特定问题,大多都有相应的模型或算法。

难是难在如何将问题转化为我们的模型。

至于如何培养自己的「问题抽象能力」?

首先通常需要我们积累一定的刷题量,并对「转换问题的关键点」做总结。

例如本题,一个转换「01 背包问题」的关键点是我们需要将「划分等和子集」的问题等效于「在某个数组中选若干个数,使得其总和为某个特定值」的问题。

间接求解到直接求解的转变:

但这道题到这里还有一个”小问题“。就是我们最后是通过「判断」来取得答案的。

通过判断取得的最大价值是否等于target来决定是否能划分出「等和子集」。

虽然说逻辑上完全成立,但总给我们一种「间接求解」的感觉。

造成这种「间接求解」的感觉,主要是因为我们没有对「01 背包」的「状态定义」和「初始化」做任何改动。

但事实上,我们是可以利用「01 背包」的思想进行「直接求解」的。

当我们与某个模型的「状态定义」进行了修改之后,除了考虑调整「转移方程」以外,还需要考虑修改「初始化」状态。

试考虑,我们创建的数组存储的是布尔类型,初始值都是false,这意味着无论我们怎么转移下去,都不可能产生一个true,最终所有的状态都仍然是false。换句话说,我们还需要一个有效值true来帮助整个过程能递推下去。

通常我们使用「首行」来初始化「有效值」。

将「物品编号」从 0 开始调整为从 1 开始。

原本我们的f[0][x]代表只考虑第一件物品、 f[1][x]代表考虑第一件和第二件物品;调整后我们的 f[0][x]代表不考虑任何物品、 f[1][x]代表只考虑第一件物品 ...

这种技巧本质上还是利用了「哨兵」的思想。

二维代码:

class Solution {
    public boolean canPartition(int[] nums) {
        int n = nums.length;

        //「等和子集」的和必然是总和的一半
        int sum = 0;
        for (int i : nums) sum += i;
        int target = sum / 2;

        // 对应了总和为奇数的情况,注定不能被分为两个「等和子集」
        if (target * 2 != sum) return false;

        // f[i][j] 代表考虑前 i 件物品,能否凑出价值「恰好」为 j 的方案
        boolean[][] f = new boolean[n+1][target+1];
        f[0][0] = true;
        for (int i = 1; i <= n; i++) {
            int t = nums[i-1];
            for (int j = 0; j <= target; j++) {
                // 不选该物品
                boolean no = f[i-1][j];
                // 选该物品
                boolean yes = j >= t ? f[i-1][j-t] : false;
                f[i][j] = no | yes;
            }
        }
        return f[n][target];
    }
}

一维代码:

class Solution {
    public boolean canPartition(int[] nums) {
        int n = nums.length;

        //「等和子集」的和必然是总和的一半
        int sum = 0;
        for (int i : nums) sum += i;
        int target = sum / 2;

        // 对应了总和为奇数的情况,注定不能被分为两个「等和子集」
        if (target * 2 != sum) return false;

        // 取消「物品维度」
        boolean[] f = new boolean[target+1];
        f[0] = true;
        for (int i = 1; i <= n; i++) {
            int t = nums[i-1];
            for (int j = target; j >= 0; j--) {
                // 不选该物品
                boolean no = f[j];
                // 选该物品
                boolean yes = j >= t ? f[j-t] : false;
                f[j] = no | yes;
            }
        }
        return f[target];
    }
}

例题2 最后一块石头的重量 II

有一堆石头,用整数数组 stones 表示。其中 stones[i] 表示第 i 块石头的重量。

每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为 x 和 y,且 x <= y。那么粉碎的可能结果如下:

  • 如果 x == y,那么两块石头都会被完全粉碎;
  • 如果 x != y,那么重量为 x 的石头将会完全粉碎,而重量为 y 的石头新重量为 y-x

最后,最多只会剩下一块 石头。返回此石头 最小的可能重量 。如果没有石头剩下,就返回 0

示例 1:

输入:stones = [2,7,4,1,8,1]
输出:1
解释:
组合 2 和 4,得到 2,所以数组转化为 [2,7,1,8,1],
组合 7 和 8,得到 1,所以数组转化为 [2,1,1,1],
组合 2 和 1,得到 1,所以数组转化为 [1,1,1],
组合 1 和 1,得到 0,所以数组转化为 [1],这就是最优值。

示例 2:

输入:stones = [31,26,33,21,40]
输出:5

问题转化为:把一堆石头分成较小的A、较大的B两堆,求两堆石头重量差最小值。

进一步分析:要让差值小,两堆石头的重量都要接近sum/2;我们假设两堆分别为A,B,A<sum/2,B>sum/2,若A更接近sum/2,B也相应更接近sum/2,用动态规划求解A堆在背包数量为sum/2情况下能装的最大的石头价值,设其为max,那么B堆石头的价值就是sum-max,两个石头堆相撞以后剩余的重量就是B-A=sum-2*max

所以先用dp数组求解A堆,最后返回B-A值

代码

class Solution {
    public int lastStoneWeightII(int[] stones) {
        int sum = 0;
        for(int s:stones){
            sum += s;
        }
        int target = sum/2;
        int[] dp = new int[target+1];
        for(int i=0;i<stones.length;i++){
            for(int j=target;j>=stones[i];j--){
                dp[j] = Math.max(dp[j],dp[j-stones[i]]+stones[i]);
            }
        }
        return sum - 2 * dp[target];
    }
}

数组中的最长山脉

描述

把符合下列属性的数组 arr 称为 山脉数组 :

  • arr.length >= 3
  • 存在下标 i0 < i < arr.length - 1),满足
    • arr[0] < arr[1] < ... < arr[i - 1] < arr[i]
    • arr[i] > arr[i + 1] > ... > arr[arr.length - 1]

给出一个整数数组 arr,返回最长山脉子数组的长度。如果不存在山脉子数组,返回 0 。

思路

用left数组动归求从一个点可以向左延伸的长度,right数组动归求该点可以向右延伸的长度。对左右均不为0的位置,认为该点存在山峰,求所有这些山峰点中left+right+1的最大值

代码

lass Solution {
    public int longestMountain(int[] arr) {
        int n = arr.length;
        int[] left = new int[n];
        int[] right = new int[n];
        if(n==0 || n==1) return 0;
        for(int i=1;i<n;i++){
            if(arr[i]>arr[i-1]){
                left[i] = left[i-1]+1;
            }else{
                left[i] = 0;
            }
        }
        for(int i=n-2;i>=0;i--){
             if(arr[i]>arr[i+1]){
                right[i] = right[i+1]+1;
            }else{
                 right[i] = 0;
            }
        }
        int max = 0;
        for(int i=0;i<n;i++){
            if(left[i]!=0 &&right[i]!=0)
            max = Math.max(left[i]+right[i]+1,max);
        }
        return max;
    }
}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值