动态规划算法

接着按照Labuladong来刷动态规划相关的算法题

1.通过01背包问题来理清动态规划的解题思路

1)对题目进行分析

  • 题目:选择价值尽可能多的物品放入背包
  • 状态:背包的容量,可选择的物品
  • 选择:是否装进背包
  • dp数组用于描述其状态,dp[i][w]:表示对于前i个物品,当前背包容量为w,该情况下可以装的最大价值
  • 状态转移:第 i 个物品装入背包 dp[i][w]应该等于dp[i-1][w-wt[i-1]] + val[i-1]

                         第 i 个物体没有装入背包dp[i][w]应该等于dp[i-1][w]

2)整体思路

  • 1)首先处理特殊情况,例如背包容量为0,或者物体列表为空,这样可以直接返回
  • 2)初始化dp table
  • 3)对dp表的base case赋值,一般是dp[0][..]和dp[..][0]的情况
  • 4)双层的循环内,表示状态转移的关系
  • 5)返回dp表最后的值
int dp[N+1][W+1]
dp[0][..] = 0
dp[..][0] = 0

for i in [1..N]:
    for w in [1..W]:
        dp[i][w] = max(
            把物品 i 装进背包,
            不把物品 i 装进背包
        )
return dp[N][W]

3)该问题中需要特别注意的细节

int knapsack(int W, int N, vector<int>& wt, vector<int>& val) {
    // vector 全填入 0,base case 已初始化
    vector<vector<int>> dp(N + 1, vector<int>(W + 1, 0));  //为什么大小是n+1,w+1,为什么要加1
    for (int i = 1; i <= N; i++) {
        for (int w = 1; w <= W; w++) { #循环的起始和末端如何界定的?
            if (w - wt[i-1] < 0) {
                // 当前背包容量装不下,只能选择不装入背包
                dp[i][w] = dp[i - 1][w];
            } else {
                // 装入或者不装入背包,择优
                dp[i][w] = max(dp[i - 1][w - wt[i-1]] + val[i-1], 
                               dp[i - 1][w]);
            }
        }
    }

    return dp[N][W];  #最后为什么返回n,w?
}
  • 1)初始化时为什么维数要加1?

        因为从装0件物品,到装n件(一共有n件物品)一共有n+1个数,所以建立dp表时的维度为长度+1

  • 2)双层循环时的起始和结束如何确定?

       根据状态转移方程,如果其中有i-1,那么起始需要从1开始而不是0,也可以从另外一个角度理解,在base case中已经给定了dp[0][..], dp[..][0]的值,所以后面的值是根据base case计算的,所以是从1开始,到n结束。

  • 3)为什么返回数组每维最后的?

      主要看最后得到的结果是什么

2. 背包类题目总结:

1)416.分割等和子集:背包大小为sum/2,看数组里面的数字能否找到和等于sum/2的。dp[i][w] 对于容量为w的背包,前i件物品可以将其装满,则dp[i][w] = true, 否则为false。 状态转移方程仍然是看第i件物品是否要装入背包,注意装入前需要判断能否装的下。

            if (j - nums[i - 1] < 0) {
               // 背包容量不足,不能装入第 i 个物品
                dp[i][j] = dp[i - 1][j]; 
            } else {
                // 装入或不装入背包
                dp[i][j] = dp[i - 1][j] | dp[i - 1][j-nums[i-1]];
            }

2)518.零钱兑换问题2

与背包问题不同的是,这里每种物品有无限多个,但是思路还是一样的,只不过dp表代表的是dp[i][w],前i种硬币凑成w有多少种凑法。然后思考递推关系式,

如果你不把这第i个物品装入背包,也就是说你不使用coins[i]这个面值的硬币,那么凑出面额j的方法数dp[i][j]应该等于dp[i-1][j],继承之前的结果。

如果你把这第i个物品装入了背包,也就是说你使用coins[i]这个面值的硬币,那么dp[i][j]应该等于dp[i][j-coins[i-1]]

3)凑零钱问题:dp[n]表示为当前目标金额为n,需要的硬币枚数。是否选择第i枚硬币,选择或者不选择,取两者中较小的。

3.一维的dp表相关问题

1)518.零钱兑换问题2

2)凑零钱问题(见前)

3)斐波那契数列:只与该数的前面两个数有关

4)最长递增子序列:

dp[i] 表示以 nums[i] 这个数结尾的最长递增子序列的长度。从dp[i]变成dp[i+1]找到之前的比nums[i+1]小的数作为结尾的最长子序列,dp[i] = max(dp[j]+1, dp[i]),需要有两层循环嵌套。base case是dp每个值都为1,因为最少也有自身可以作为子序列。最后找到dp的最大值则是最后的结果。

5)信封嵌套:需要长宽都更大才能完成嵌套,首先将数组先按照宽升序,高降序排序,这里相当于找到高的最长递增子序列,思想和上一题一样。

6)最大子序列和:注意是连续的子序列,所以dp[i]代表以nums[i]结尾的最大子序列和,有两种选择是否和前面的接起来,在其中选择最大的dp[i] = max(nums[i], dp[i-1]+nums[i])。 注意这里的dp表的维数为n。

 

4. 二维dp表(比较复杂需要思考状态转移的题目)

1)最长公共子序列:dp[i][j]表示s1前i个序列和s2前j个序列,最长的公共子序列的长度。需要做的选择是看s1[i]和s[j]是否相等,若相等则公共子序列的长度加一,否则只移动一步(s1或者s2,选择其中子序列最长的)。注意前i个对应在数组里是i-1。

2)最长回文子序列:dp[i][j]是nums从i到j中最长回文子序列的长度,如果nums[i]和nums[j]相等,则回文序列长度加2,如果不相等,则考虑i或者j变化,找到其中最长的回文子序列。dp就是n*n的多维数组,base case是i=j的情况,这种情况只有一个字母所以回文子序列的长度为1。然后是遍历,与之前情况不同,遍历是从大到小反着遍历,不知道为什么要这样遍历,作者说是因为为了保证每次计算dp[i][j],左、下、左下三个方向的位置已经被计算出来,只能斜着遍历或者反着遍历

具体代码如下,注意最后返回的和之前的不一样:

// 反着遍历保证正确的状态转移
    for (int i = n - 1; i >= 0; i--) {
        for (int j = i + 1; j < n; j++) {
            // 状态转移方程
            if (s[i] == s[j])
                dp[i][j] = dp[i + 1][j - 1] + 2;
            else
                dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
        }
    }
    // 整个 s 的最长回文子串长度
    return dp[0][n - 1];

3)编辑距离

这道题最主要的是如何转换成动态规划问题,作者是从后向前遍历两个字符串,如果字符相等不用做什么操作直接向前移动,如果不一样可以做删除,插入,替换,跳过等操作,从这些操作中找到最少的操作。

插入操作:在 s1[i] 插入一个和 s2[j] 一样的字符,那么 s2[j] 就被匹配了,前移 j,继续跟 i 对比,操作数加一。

删除操作:删除s[i],相当于前移i,继续和s[j]做对比

替换操作:将s[i]替换成s[j],然后i,j向前移动

5.动态规划系列问题——股票买卖问题

每天都有三种「选择」:买入、卖出、无操作,我们用 buy, sell, rest 表示这三种选择。问题的「状态」有三个,第一个是天数,第二个是允许交易的最大次数,第三个是当前的持有状态(即之前说的 rest 的状态,我们不妨用 1 表示持有,0 表示没有持有)。例如dp[3][2][1] 的含义就是:今天是第三天,我现在手上持有着股票,至今最多进行 2 次交易。再比如 dp[2][3][0] 的含义:今天是第二天,我现在手上没有持有股票,至今最多进行 3 次交易。

状态转移框架:

dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
              max(   选择 rest  ,             选择 sell      )

解释:今天我没有持有股票,有两种可能:
要么是我昨天就没有持有,然后今天选择 rest,所以我今天还是没有持有;
要么是我昨天持有股票,但是今天我 sell 了,所以我今天没有持有股票了。

dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])
              max(   选择 rest  ,           选择 buy         )

解释:今天我持有着股票,有两种可能:
要么我昨天就持有着股票,然后今天选择 rest,所以我今天还持有着股票;
要么我昨天本没有持有,但今天我选择 buy,所以今天我就持有股票了。

定义 base case:

dp[-1][k][0] = 0
解释:因为 i 是从 0 开始的,所以 i = -1 意味着还没有开始,这时候的利润当然是 0 。
dp[-1][k][1] = -infinity
解释:还没开始的时候,是不可能持有股票的,用负无穷表示这种不可能。
dp[i][0][0] = 0
解释:因为 k 是从 1 开始的,所以 k = 0 意味着根本不允许交易,这时候利润当然是 0 。
dp[i][0][1] = -infinity
解释:不允许交易的情况下,是不可能持有股票的,用负无穷表示这种不可能。

根据题目改变数组的相应的规则即可。

6.动态规划系列问题——打家劫舍问题

1)House Robber I

不能抢相邻的房子,dp[i]代表前i个房子所抢到的价值,所做的选择是是否抢,dp[i] = max(dp[i-1], dp[i-2]+nums[i])。base case是dp[0], dp[1]。也要考虑房子列表为0或者为1的情况。

2)House Robber II

该情况是房子围成了一个圈,讨巧的方法是,首尾不能同时抢,所以考虑第一个到倒数第二个,第二个到最后一个中价值较高的即可

3)House Robber III

Map<TreeNode, Integer> memo = new HashMap<>();
public int rob(TreeNode root) {
    if (root == null) return 0;
    // 利用备忘录消除重叠子问题
    if (memo.containsKey(root)) 
        return memo.get(root);
    // 抢,然后去下下家
    int do_it = root.val
        + (root.left == null ? 
            0 : rob(root.left.left) + rob(root.left.right))
        + (root.right == null ? 
            0 : rob(root.right.left) + rob(root.right.right));
    // 不抢,然后去下家
    int not_do = rob(root.left) + rob(root.right);

    int res = Math.max(do_it, not_do);
    memo.put(root, res);
    return res;
}

思路是一样的,是做抢还是不抢的选择,但是具体的操作比较巧妙,如果抢的话,去下下家,不抢的话可以直接去下家。

4)状态压缩:现在是用一维的dp表,但是其实是不需要的,状态转移只和dp[i]最近的两个状态有关,所以可以进一步优化,将空间复杂度降低到 O(1)。

int robRange(int[] nums, int start, int end) {
    int n = nums.length;
    int dp_i_1 = 0, dp_i_2 = 0;
    int dp_i = 0;
    for (int i = end; i >= start; i--) {
        dp_i = Math.max(dp_i_1, nums[i] + dp_i_2);
        dp_i_2 = dp_i_1;
        dp_i_1 = dp_i;
    }
    return dp_i;
}

5.何时进行状态压缩

如果dp[i][j]都是通过上一行dp[i-1][..]转移过来的,之前的数据都不会再使用了,则可以进行状态压缩,将二维dp数组压缩为一维,节约空间复杂度。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值