淘先锋技术网

首页 1 2 3 4 5 6 7

目录

第八章:动态规划

关于动态规划逆应该了解这些

1. 什么是动态规划

动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的,

例如:有N件物品和一个最多能背重量为W 的背包。第i件物品的重量是weight[i],得到的价值是value[i]每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。

动态规划中dp[j]是由dp[j-weight[i]]推导出来的,然后取max(dp[j], dp[j - weight[i]] + value[i])。

但如果是贪心呢,每次拿物品选一个最大的或者最小的就完事了,和上一个状态没有关系。

所以贪心解决不了动态规划的问题。

2. 动态规划的解题步骤

对于动态规划问题,我将拆解为如下五步曲,这五步都搞清楚了,才能说把动态规划真的掌握了!

  • 确定dp数组(dp table)以及下标的含义
  • 确定递推公式
  • dp数组如何初始化
  • 确定遍历顺序
  • 举例推导dp数组

**因为一些情况是递推公式决定了dp数组要如何初始化!**所以要先求递推公式然后进行dp数组初始化

3. 动态规划应该如何debug

找问题的最好方式就是把dp数组打印出来,看看究竟是不是按照自己思路推导的!

做动规的题目,写代码之前一定要把状态转移在dp数组的上具体情况模拟一遍,心中有数,确定最后推出的是想要的结果

然后再写代码,如果代码没通过就打印dp数组,看看是不是和自己预先推导的哪里不一样。

如果打印出来和自己预先模拟推导是一样的,那么就是自己的递归公式、初始化或者遍历顺序有问题了。

先思考问题:

  • 这道题目我举例推导状态转移公式了么?
  • 我打印dp数组的日志了么?
  • 打印出来了dp数组和我想的一样么?

leetcode509:斐波那契数

斐波那契数,通常用 F(n) 表示,形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是:
F(0) = 0,F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1
给你n ,请计算 F(n) 。

示例 1:
输入:2
输出:1
解释:F(2) = F(1) + F(0) = 1 + 0 = 1

示例 2:输入:3
输出:2
解释:F(3) = F(2) + F(1) = 1 + 1 = 2

示例 3:
输入:4
输出:3
解释:F(4) = F(3) + F(2) = 2 + 1 = 3
提示:

0 <= n <= 30

【思路】

动规五步曲:

  1. 确定dp数组以及下标的含义

    dp[i]的定义为:第i个数的斐波那契数值是dp[i]

  2. 确定递推公式

    状态转移方程:dp[i] = dp[i-1] + dp[i-2];

  3. dp数组如果初始化

    dp[0] = 0;
    dp[1] = 1;
    
  4. 确定遍历顺序

    从递归公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,dp[i]是依赖 dp[i - 1] 和 dp[i - 2],那么遍历的顺序一定是从前到后遍历的

  5. 举例推到dp数组

    按照这个递推公式dp[i] = dp[i - 1] + dp[i - 2],我们来推导一下,当N为10的时候,dp数组应该是如下的数列:

    0 1 1 2 3 5 8 13 21 34 55

    如果代码写出来,发现结果不对,就把dp数组打印出来看看和我们推导的数列是不是一致的。

时间复杂度:O(n)
空间复杂度:O(n)
class Solution{
publicint fib(int N){
   	if(N <= 1) return N;
    vector<int> dp(N + 1);
    dp[0] = 0;
    dp[1] = 1;
    for(int i = 2; i <= N; i++){
        dp[i] = dp[i-1] + dp[i - 2];
    }
    return dp[N];
}
}

leetcode70:爬楼梯

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

注意:给定 n 是一个正整数。

示例 1:
输入:2
输出:2
解释:有两种方法可以爬到楼顶。

1 阶 + 1 阶
2 阶
示例 2:
输入:3
输出:3
解释:有三种方法可以爬到楼顶。

1 阶 + 1 阶 + 1 阶
1 阶 + 2 阶
2 阶 + 1 阶

【思路】

本题大家如果没有接触过的话,会感觉比较难,多举几个例子,就可以发现其规律。

爬到第一层楼梯有一种方法,爬到二层楼梯有两种方法。

那么第一层楼梯再跨两步就到第三层 ,第二层楼梯再跨一步就到第三层。

所以到第三层楼梯的状态可以由第二层楼梯 和 到第一层楼梯状态推导出来,那么就可以想到动态规划了。

我们来分析一下,动规五部曲:

  1. 确定一个一维数组用来记录不同楼层状态

    dp[i]: 爬到第i阶楼梯,有dp[i]种方法

  2. 确定递归公式

    因为每次可以跳1个或2个台阶所以有下面的操作

    首先是dp[i - 1],上i-1层楼梯,有dp[i - 1]种方法,那么再一步跳一个台阶不就是dp[i]了么。

    还有就是dp[i - 2],上i-2层楼梯,有dp[i - 2]种方法,那么再一步跳两个台阶不就是dp[i]了么。

    那么dp[i]就是 dp[i - 1]与dp[i - 2]之和!

    所以dp[i] = dp[i - 1] + dp[i - 2] 。

    推导dp[i]的时候,一定要时刻想着dp[i]的定义,否则容易跑偏。

    这体现出确定dp数组以及下标的含义的重要性!

  3. 确定dp数组如何初始化

    从dp数组定义的角度上来说,dp[0] = 0 也能说得通。

    需要注意的是:题目中说了n是一个正整数,题目根本就没说n有为0的情况。

    所以本题其实就不应该讨论dp[0]的初始化!

    我相信dp[1] = 1,dp[2] = 2,这个初始化大家应该都没有争议的。

  4. 确定遍历顺序

    从递推公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,遍历顺序一定是从前向后遍历的

  5. 举例推到dp数组

    举例当n为5的时候,dp table(dp数组)应该是这样的

图片

//时间复杂度:O(n)
//空间复杂度:O(n)
// 版本一
class Solution {
public:
    int climbStairs(int n) {
        if (n <= 1) return n; // 因为下面直接对dp[2]操作了,防止空指针
        vector<int> dp(n + 1);
        dp[1] = 1;
        dp[2] = 2;
        for (int i = 3; i <= n; i++) { // 注意i是从3开始的
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[n];
    }
};

优化内存空间的方法

//时间复杂度:O(n)
//空间复杂度:O(1)
// 版本二
class Solution {
public:
    int climbStairs(int n) {
        if (n <= 1) return n;
        int dp[3];
        dp[1] = 1;
        dp[2] = 2;
        for (int i = 3; i <= n; i++) {
            int sum = dp[1] + dp[2];
            dp[1] = dp[2];
            dp[2] = sum;
        }
        return dp[2];
    }
};

【修改题目】

每次可以爬1 、 2或者m 个台阶。问有多少种不同的方法可以爬到楼顶呢?

1阶,2阶,m阶就是物品,楼顶就是背包。

每一阶可以重复使用,例如跳了1阶,还可以继续跳1阶。

问跳到楼顶有几种方法其实就是问装满背包有几种方法。

动态规划五步曲:

  1. 确定dp数组及下标含义

    dp[i]:爬到有i个台阶的楼顶,有dp[i]种方法

  2. 确定递推公式

    求装满背包有几种方法,递推公式一般都是dp[i] += dp[i - nums[j]];

    本题呢,dp[i]有几种来源dp[i - 1],dp[i - 2],dp[i - 3] 等等,即:dp[i - j]

    那么递推公式为:dp[i] += dp[i - j]

  3. dp数组如何初始化

    从递归公式出发dp[i] += dp[i-j],那么dp[0]一定为1,dp[0]是递归中一切数值的基础所在,如果dp[0]是0的话,其他数值都是0了。

    为什么这样设置是因为dp[i] 是由 dp[i-j]后面的推出的与本身没有关系,所以让dp[0]=1其余的为0即可

  4. 确定遍历顺序

    这是背包里求排列问题,即:1、2步和2、1步都是三个台阶,但是这两种方法不同,所以需将target放在外循环,将nums放在内循环

    每一步可以走多次,这是完全背包,内循环需要从前向后遍历

  5. 举例来推导dp数组

[C++代码]

//因为物品可以重复挑选所以式完全背包问题,所以有以下面两个求法,求组合与求排列

class Solution{
public:
    int climbStairs(int n){
        vector<int> dp(n + 1, 0);
        dp[0] = 1;
        for(int i = 1; i <= n; i++){//遍历容量
            for(int j = 1; j <= m; j){//遍历物品,可以用1步也可以用m步
                //dp[j] 由 dp[i-1],dp[i-2],....,dp[i-j]这些组成
            	dp[j] += dp[i - j]; 
            }
        }
        return dp[n];
    }
};

如果求组合数就是外层for循环遍历物品,内层for遍历背包

如果求排列数就是外层for遍历背包,内层for循环遍历物品

背包就是容量

leetcode746:使用最小花费爬楼梯

数组的每个下标作为一个阶梯,第 i 个阶梯对应着一个非负数的体力花费值 cost[i](下标从 0 开始)。

每当你爬上一个阶梯你都要花费对应的体力值,一旦支付了相应的体力值,你就可以选择向上爬一个阶梯或者爬两个阶梯。

请你找出达到楼层顶部的最低花费。在开始时,你可以选择从下标为 0 或 1 的元素作为初始阶梯。

示例 1:

输入:cost = [10, 15, 20] 输出:15 解释:最低花费是从 cost[1] 开始,然后走两步即可到阶梯顶,一共花费 15 。

示例 2:

输入:cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1] 输出:6 解释:最低花费方式是从 cost[0] 开始,逐个经过那些 1 ,跳过 cost[3] ,一共花费 6 。

提示:

cost 的长度范围是 [2, 1000]。
cost[i] 将会是一个整型数据,范围为 [0, 999] 。

[思考]

注意题目描述:每当你爬上一个阶梯你都要花费对应的体力值,一旦支付了相应的体力值,你就可以选择向上爬一个阶梯或者爬两个阶梯

动态五步曲:

  1. 确定dp数组以及下标含义:

    dp[i]的定义:第i个台阶所花费的最少体力为dp[i]

  2. 确定递推公式

    可以有两个途径得到dp[i],一个是dp[i-1],一个是dp[i-2]

    dp[i] = min(dp[i-1], dp[i-2])+cost[i]

    注意这里为什么是加cost[i],而不是cost[i-1],cost[i-2]之类的,因为题目中说了:每当你爬上一个阶梯你都要花费对应的体力值

  3. dp数组如果初始化

    vector<int> dp(cost.size());
    dp[0] = cost[0];
    dp[1] = cost[1];
    
  4. 确定遍历顺序

    因为是模拟台阶,而且dp[i]由dp[i-1]与dp[i-2]推出,所以是从前到后遍历cost数组就可以了。

  5. 例举推到dp数组

    拿示例2:cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1] ,来模拟一下dp数组的状态变化,如下:

    图片

时间复杂度:O(n)
空间复杂度:O(1)
// 版本一
class Solution {
public:
    int minCostClimbingStairs(vector<int>& cost) {
        vector<int> dp(cost.size());
        dp[0] = cost[0];
        dp[1] = cost[1];
        for (int i = 2; i < cost.size(); i++) {
            dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i];
        }
        // 注意最后一步可以理解为不用花费,所以取倒数第一步,第二步的最少值
        return min(dp[cost.size() - 1], dp[cost.size() - 2]);//取倒数最少的
    }
};

剑指Offer46:把数字翻译成字符串【动态规划】

给定一个数字,我们按照如下规则把它翻译为字符串:0 翻译成 “a” ,1 翻译成 “b”,……,11 翻译成 “l”,……,25 翻译成 “z”。一个数字可能有多个翻译。请编程实现一个函数,用来计算一个数字有多少种不同的翻译方法。

 

示例 1:

输入: 12258
输出: 5
解释: 12258有5种不同的翻译,分别是"bccfi", "bwfi", "bczi", "mcfi"和"mzi"
 

提示:

0 <= num < 2^31


【解题思路:】

根据如下图的思路,总结出”递推公式“(即转移方程)。因此,该题可以使用动态规划解决:

Picture1.png

动态规划解析:

记数字num第i位数字位xi,数字num的位数为n;

例如:num = 12258的n=5,x1=1。

  1. 确定dp数组的下标以及含义

    dp[i]代表以xi为结尾的数字翻译成字符串的方法最多有dp[i]个。

  2. 确定状态转移方程:

    若xi和xi-1组成的两位数字可以被翻译,则dp[i] = dp[i-1] + dp[i-2];否则dp[i] = dp[i-1]。

    可以被翻译的两位数区间:当xi-1=0,组成的两位数是无法被翻译的(例如00,01,02,…),因此区间位[10,25].

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-am5rmXKb-1626103523348)(C:\Users\mubao\AppData\Roaming\Typora\typora-user-images\image-20210524160738149.png)]

  3. 初始化状态

    dp[0] = dp[1] = 1,即“无数组”和“第1位数字”的翻译方法数量均为1;

  4. 确定遍历顺序

    因为dp[i]是由dp[i-1]与dp[i-2]确定,所以遍历顺序是从左向右。

**问题:**无数字情况dp[0]=1从何而来?

**答:**当num第1,2位的组成的数字属于[10,25]时,显然应有2中翻译方法,即dp[2]=dp[1]+dp[0]=2,而显然dp[1]=1,因此推出dp[0]=1.

  1. 为了方便获取数字的各位xi,**考虑先将数字num转化为字符串s,(这种思想太重要了)**通过遍历s实现动态规划。

  2. 通过字符串切片s[i-2:i]获取数字组合10*xi-1+xi,通过对比字符串ASCII判断字符串对应区间。

  3. **空间使用优化:**由于dp[i]只与dp[i-1]有关,因此可使用两个变量ab分别记录dp[i],dp[i-1],两个变量交替前进即可,此方法可省去dp列表使用的O(N)的额外空间。

  4. 复杂度分析:

    • 时间复杂度:O(N),N为字符串s的长度(即数字num的位数log(num)),其确定了循环次数。
    • 空间复杂度:O(N),字符串s使用O(N)大小的额外空间

    img

    时间复杂度:O(N)
    空间复杂度:O(N)因为创建了一个string s,字符串的空间是O(N)
    class Solution {
    public:
        int translateNum(int num) {
            string s = to_string(num);
            int a = 1, b = 1, len = s.size();
            for(int i = 2; i <= len; i++) {
                string tmp = s.substr(i - 2, 2);
                int c = tmp.compare("10") >= 0 && tmp.compare("25") <= 0 ? a + b : a;
                b = a;
                a = c;
            }
            return a;
        }
    };
    //优化空间:
    时间复杂度O(N):N为字符串s的长度,即数字num的位数log(num),其决定了循环次数
    空间复杂度O(1):几个变量使用常数大小的空间
    class Solution {
        public int translateNum(int num) {
            int a = 1, b = 1, x, y = num % 10;
            while(num > 9) {
                num /= 10;
                x = num % 10;
                int tmp = 10 * x + y;
                int c = (tmp >= 10 && tmp <= 25) ? a + b : a;
                b = a;
                a = c;
                y = x;
            }
            return a;
        }
    }
    
    //自己写的代码
    class Solution {
    public:
        int translateNum(int num) {
            string s = to_string(num);
            int len = s.size();
            vector<int> dp(len+1, 0);
            dp[1] = 1;
            dp[0] = 1;
            for(int i = 2; i <= len; i++)
            {
                //此时i-1对应s中的i,i-2对应s中的i-1这一点注意
                if((s[i-2] == '1' && (s[i-1] >= '0'  && s[i-1] <= '9')) || (s[i-2] == '2' && (s[i-1] >= '0' && s[i-1] <= '5')))
                {
                    dp[i] =  dp[i-2] + dp[i-1];
                }
                else
                {
                    dp[i] = dp[i-1];
                }
            }
            return dp[len];
        }
    };
    

    因为只用到了两个变量,所以没有必要创建一个N长度的数组,只需要创建两个变量a和b储存值就好了

leetcode91:解码方法

一条包含字母 A-Z 的消息通过以下映射进行了 编码 :

‘A’ -> 1
‘B’ -> 2

‘Z’ -> 26
要 解码 已编码的消息,所有数字必须基于上述映射的方法,反向映射回字母(可能有多种方法)。例如,“111” 可以将 “1” 中的每个 “1” 映射为 “A” ,从而得到 “AAA” ,或者可以将 “11” 和 “1”(分别为 “K” 和 “A” )映射为 “KA” 。注意,“06” 不能映射为 “F” ,因为 “6” 和 “06” 不同。

给你一个只含数字的 非空 字符串 num ,请计算并返回 解码 方法的 总数

题目数据保证答案肯定是一个 32 位 的整数。

示例 1:

输入:s = “12”
输出:2
解释:它可以解码为 “AB”(1 2)或者 “L”(12)。
示例 2:

输入:s = “226”
输出:3
解释:它可以解码为 “BZ” (2 26), “VF” (22 6), 或者 “BBF” (2 2 6) 。
示例 3:

输入:s = “0”
输出:0
解释:没有字符映射到以 0 开头的数字。含有 0 的有效映射是 ‘J’ -> “10” 和 ‘T’-> “20” 。由于没有字符,因此没有有效的方法对此进行解码,因为所有数字都需要映射。
示例 4:

输入:s = “06”
输出:0
解释:“06” 不能映射到 “F” ,因为字符串开头的 0 无法指向一个有效的字符。

提示:

1 <= s.length <= 100
s 只包含数字,并且可能包含前导零。

【思路】

本题主要的地方就是推导其动态方程,单独讨论“0”开头的情况

  1. 首先当第一个字就是0时,我们直接返回0
  2. 创建一个vector存储状态,size()+1可以使“10”这种情况不需要单独讨论
  3. 初始化所有值为1,这样dp[0]与dp[1]就直接初始化了,所以我们可以从第二个字母开始
- 当当前数字为0前面不为1或2时直接return 0。这个比较好理解。
- 没有违规时前面肯定为1或者2,这个时候我们需要借用前面的数字来形成10和20,所以说前面那个数字所形成的
  状态无效,所以我们得取1或2前面的那个数字的状态,所以为:dp[i+1] = dp[i-1];

- 当前面数字为1时或者为2且当前数字小于7时,那么我们可以开始变换。
  这里的动态方程为:dp[i+1] = dp[i] + dp[i-1];
    - 示例 “1 2 1 2 3”, 它所对应的dp为:
    - dp “1 1 2 3 5 8” 你会发现其实每个数字所对应的状态为前两位的和.
- 当上述情况都不存在时证明此数字不会存在变换,所以直接取前面的状态。dp[i+1] = dp[i];
    - 示例 “1 2 1 7 9”, 它所对应的dp为:
    - dp “1 1 2 3 5 5” 注意9的状态没有发生变换,1 2 1 7为决定状态的数字

class Solution {
public:
    int numDecodings(string s) {
        if(s[0] == '0') return 0;
        //dp[i]对应的是从0到i-1的解码方法数
        vector<int> dp(s.size()+1, 1);
        
        for(int i = 1; i < s.size(); ++i)
        {
            //情况一:结尾为0,1234560
            if(s[i] == '0')
            {
                //结尾两位数代表大于26
                //因为英文字母就从1-26,大于26都不存在映射条件
                if(s[i-1] != '1' && s[i-1] != '2')
                    return 0;
                //结尾两数代表小于26
                //12110-》10只有一种映射所以不看只看121即dp[i+1] = dp[i-1]
                dp[i+1] = dp[i-1];
                //如果用break,输入数据"1201234"对用的输出为1预期结果为3输出错误-》但是为什么只能用continue而不能用break呢
                //这是因为break是跳出循环就不进行循环了,而continue需要跳出当前循环,要进行下一次循环
                continue;
            }
            //情况二结尾两位数的代表的编码方法不唯一 
			//12111->11有多种解码的方法所以dp[i+1] = dp[i] + dp[i-1]
            if(s[i-1] == '1' || (s[i-1] == '2' && s[i] < '7'))
                dp[i+1] = dp[i] + dp[i-1];
            //情况三:结尾两位数代表的解码方法唯一
            //12128//因为28只有一种解码方法所以28不增加解码方法,即dp[i+1] = dp[i];
            else
                dp[i+1] = dp[i];
        }
        return dp[s.size()];
    }
};

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Vchmnptv-1626103523351)(C:\Users\mubao\AppData\Roaming\Typora\typora-user-images\image-20210421102122772.png)]

class Solution {
public:
    bool check(string s){
        if(s[0] == '0') 
            return false;
        //将字符串转为数值用stoi,将数值转成字符串用to_string
        int n = stoi(s);
        return (n <= 26 && n >= 1);
    }
    int numDecodings(string s) {
        int n = s.length(), i;
        if(s[0] == '0') return 0;
        vector<int> dp(n+1, 1);
        for(i = 2; i <= n; i++){
            //flag1是两位数,flag2是一位数
            bool flag1 = check(s.substr(i-2, 2));
            bool flag2 = check(s.substr(i-1, 1));
            
            if(flag1 && flag2) 
                dp[i] = dp[i-2] + dp[i-1];
            else if(flag1) 
                dp[i] = dp[i-2];
            else if(flag2) 
                dp[i] = dp[i-1];
            else return 0;
        }
        return dp[n];
    }
};


leetcode343:整数拆分(割绳子问题)

[题目]
给你一根长度为 n 的绳子,请把绳子剪成整数长度的 m 段(m、n都是整数,n>1并且m>1),每段绳子的长度记为 k[0],k[1]...k[m-1] 。请问 k[0]*k[1]*...*k[m-1] 可能的最大乘积是多少?例如,当绳子的长度是8时,我们把它剪成长度分别为2、3、3的三段,此时得到的最大乘积是18。

示例 1:

输入: 2
输出: 1
解释: 2 = 1 + 1, 1 × 1 = 1
示例 2:

输入: 10
输出: 36
解释: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36
提示:

2 <= n <= 58

给定一个正整数 n,将其拆分为至少两个正整数的和,并使这些整数的乘积最大化。返回你可以获得的最大乘积。

示例 1: 输入: 2
输出: 1
解释: 2 = 1 + 1, 1 × 1 = 1。

示例 2:
输入: 10
输出: 36
解释: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36。
说明: 你可以假设 n 不小于 2 且不大于 58。

【思路】

  1. 确定dp数组以及下标的意义

    dp[i]: 拆分数子 i,可以得到的最大乘积为dp[i]

  2. 确定递推公式

    对于正整数n,当n大于等于2时,可以拆分成至少两个正整数和。令k时拆分出的第一个正整数,则剩下的部分是n-k,n-k可以不继续拆分,或者可以继续拆分成至少两个正整数的和。

    当i大于等于2时,假设对正整数i拆分出的的第一个正整数是j(1<= j < i),则有以下两种方案:

    • 将 i 拆分成 j 和 i - j 的和,且 i - j 不再拆分成多个正整数,此时乘积是 j * (i - j);
    • 将 i 拆分成 j 和 i - j 的和, 且 i - j 继续拆分成多个正整数,此时的乘积是 j * dp[i - j].

    因此当 j 固定时,有dp[i] = max( j * (i - j), j * dp[i - j] )由于 j 的取值范围是1 到 i - 1, 需要遍历所有的 j 得到dp[i]的最大值,因此可以得到转移方程如下:

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zojeq5lI-1626103523352)(C:\Users\mubao\AppData\Roaming\Typora\typora-user-images\image-20210414160748516.png)]

    递推公式:dp[i] = max(dp[i], max((i-j) * j, dp[i-j] * j));

  3. dp的初始化

    有的题解里会给出dp[0] = 1,dp[1] = 1的初始化,但解释比较牵强,主要还是因为这么初始化可以把题目过了。

    严格从dp[i]的定义来说,dp[0] dp[1] 就不应该初始化,也就是没有意义的数值。

    拆分0和拆分1的最大乘积是多少?

    这是无解的。

    这里我只初始化dp[2] = 1,从dp[i]的定义来说,拆分数字2,得到的最大乘积是1,这个没有任何异议!

  4. 确定遍历顺序

    确定遍历顺序,先来看看递归公式:dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));

    dp[i] 是依靠 dp[i - j]的状态,所以遍历i一定是从前向后遍历,先有dp[i - j]再有dp[i]

    枚举j的时候,是从1开始的。i是从3开始,这样dp[i - j]就是dp[2]正好可以通过我们初始化的数值求出来。

    j是从1开始遍历,拆分j的情况,在遍历j的过程中其实都计算过了。

    for(int i = 3; i <= n; i++)
        //i-j > 1,因为dp[1]无解,没有办法拆分
    	for(int j = 1; j < i - 1; j++){
            //为什么会有dp[i],是因为j是不断的变换,所以要保存i=6的dp值
    		dp[i] = max(dp[i], max((i-j) * j, dp[i-j] * j));
    	}
    }
    
  5. 举例推导dp数组

图片

//当n不小于2不大于58时可以使用动态规划
class Solution {
public:
    int integerBreak(int n) {
        vector<int> dp(n + 1);
        dp[2] = 1;
        for (int i = 3; i <= n ; i++) {
            for (int j = 1; j < i - 1; j++) {
                dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));
            }
        }
        return dp[n];
    }
};

//如果n大于120时,此时使用动态规划的递推公式dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j))会报错,因为越界
class Solution {
public:
    int cuttingRope(int n) {
        if(n < 2)
        {
            return 0;
        }
        else if(n == 2)
        {
            return 1;
        }
        else if(n == 3)
        {
            return 2;
        }

        long result = 1;
        //当n>=5时,3(n-3) >= 2(n-2)
        while( n > 4)
        {
            result = result * 3 % 1000000007;
            n = n -3;
        }
        result = result *  n % 1000000007;
        return result;
    }
};

leetcode96:不同二叉搜索树

给定一个整数 n,求以 1 ... n 为节点组成的二叉搜索树有多少种?

图片

【思路】

图片

n为1的时候有一棵树,n为2有两棵树,这个是很直观的。

图片

当3为头结点的时候,其左子树有两个节点,看这两个节点的布局,是不是和n为2的时候两棵树的布局也是一样的啊!

当2位头结点的时候,其左右子树都只有一个节点,布局是不是和n为1的时候只有一棵树的布局也是一样的啊!

发现到这里,其实我们就找到的重叠子问题了,其实也就是发现可以通过dp[1] 和 dp[2] 来推导出来dp[3]的某种方式。

dp[3],就是 元素1为头结点搜索树的数量 + 元素2为头结点搜索树的数量 + 元素3为头结点搜索树的数量

元素1为头结点搜索树的数量 = 右子树有2个元素的搜索树数量 * 左子树有0个元素的搜索树数量

元素2为头结点搜索树的数量 = 右子树有1个元素的搜索树数量 * 左子树有1个元素的搜索树数量

元素3为头结点搜索树的数量 = 右子树有0个元素的搜索树数量 * 左子树有2个元素的搜索树数量

有2个元素的搜索树数量就是dp[2]。

有1个元素的搜索树数量就是dp[1]。

有0个元素的搜索树数量就是dp[0]。

所以dp[3] = dp[2] * dp[0] + dp[1] * dp[1] + dp[0] * dp[2]

图片

动态五步曲

  1. 确定dp数组以及下标的意义

    dp[i]: 1到i为节点组成的二叉搜索树的个数为dp[i]

  2. 确定递推公式

    *dp[i] += dp[以j为头结点左子树节点数量]dp[以j为头结点右子树节点的数量]

    j相当于头结点的元素,从1遍历到i为止

    dp[i] += dp[j-1] * dp[i-j];

    j-1为j为头结点左子树节点的数量,i-j为以j为头结点右子树节点的数量。

  3. dp数组如何初始化

    初始化,只需要初始化dp[0]就可以了,推导的基础,都是dp[0]。

    ,空节点也是一颗二叉树,也是一颗二叉搜索树,这是可以说得通的。

    从递归公式上来讲,dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量] 中以j为头结点左子树节点数量为0,也需要dp[以j为头结点左子树节点数量] = 1, 否则乘法的结果就都变成0了。

    dp[0] = 1

  4. 确定遍历顺序

    从递推公式可以看出,节点数为i的状态时依靠i之前节点数的状态,那么遍历i里面每一个数作为头结点的状态用j来遍历。

    for(int i = 1; i <= n; i++){
    	fro(int j = 1; j <= i; j++)
            //因为头结点是[1.....i]所有当以中间值j为头结点时,左子树的个数时j-1,右子树的个数时[i-j]
    		dp[i] += dp[j - 1] * dp[i - j]
    }
    
  5. 举例推导dp 数组

    图片

我这里列到了n为5的情况,是为了方便大家 debug代码的时候,把dp数组打出来,看看哪里有问题

class Solution {
public:
    int numTrees(int n) {
        //dp[i]:从0到i不同头结点元素组成二叉搜索树的个数为dp[i]
        vector<int>dp(n+1,0);
        dp[0] = 1;
        //第一个for循环遍历头结点个数
        for(int i = 1; i <= n; i++){
            //遍历不同头结点的个数
            for(int j = 1; j <= i; j++){
                dp[i] += dp[j-1] * dp[i-j];
               // cout<<"dp["<<i<<"] = "<<dp[i]<<endl;
            }
        }
        return dp[n];

    }
};

剑指Offer60:n个骰子的点数【困难难理解】

【题目】

把n个骰子扔在地上,所有骰子朝上一面的点数之和为s。输入n,打印出s的所有可能的值出现的概率。
你需要用一个浮点数数组返回答案,其中第 i 个元素代表这 n 个骰子所能掷出的点数集合中第 i 小的那个的概率。
示例 1:
输入: 1
输出: [0.16667,0.16667,0.16667,0.16667,0.16667,0.16667]
示例 2:
输入: 2
输出: [0.02778,0.05556,0.08333,0.11111,0.13889,0.16667,0.13889,0.11111,0.08333,0.05556,0.02778]
 
1 <= n <= 11

【方法一:暴力法】超时

给定n个骰子,可得:

  • 每个骰子摇到1至6的概率相等,都为1/6.
  • 将每个骰子的点数看作独立情况,共右6^n种点数组合。例如n=2时点数的组合为:(1,1),(1,2),…,(2,1),…,(6,1),…,(6,6)
  • n个骰子点数和的范围为[n, 6n],数量为6n-n+1=5n+1

暴力统计:每个点数组合都对应一个点数和,考虑遍历所有点数组合,统计每个点数和的出现次数,最后除以点数组合的总数(除以6^n),即可得到每个点数和出现的概率

如下图时,输入n=2时,点数组合,点数和,个点数概率的计算过程

Picture1.png

暴力法需要遍历所有点数组合,因此时间复杂度为O(6^n),观察本题输入取值范围1 <= n <= 11,可知此复杂度时无法接受的。

方法二:动态规划

输入n个骰子的解(即概率列表)为f(n),其中点数和x的概率为f(n,x)

假设已知n-1个骰子的解f(n-1),此时添加一枚骰子,求n个骰子的点数和为x的概率f(n,x)

当添加骰子的点数为1时,前n-1个骰子的点数和应为x-1,方可组成点数和x;同理,当此骰子为2时,前n-1个骰子应为x-2;以此类推,直至此骰子点数为6。将这6种情况的概率相加,即可得到概率f(n,x).递推公式如下所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NGoey2Q2-1626103523357)(C:\Users\mubao\AppData\Roaming\Typora\typora-user-images\image-20210531164358320.png)]

根据以上分析,可知通过子问题的解f(n-1)可递推计算出f(n),而输入一个骰子的解f(1)已知,因此可通过解f(1)依此递推出任意解f(n)。

Picture2.png

观察可发现,以上递推公式虽然可行,但是f(n-1, x- i)中的x-i会有越界的问题。例如**,希望递推计算f(2,2),由于骰子的点数范围为[1,6],因此直营求和f(1,1),即f(1,0),f(1,-1),f(1,-2)…等均无意义。**此越界问题导致代码编写困难

解决办法:将递推公式“逆向”改为“正向”递推

Picture3.png

具体来看,由于新增骰子点数只可能为1-6,因此概率f(n-1, x)仅与f(n,x+1),f(n, x+2),…,f(n,x+6)相关

因而,遍历f(n-1)中各点数和的概率,并将其相加至f(n)中所有相关项,即可完成f(n-1)至f(n)的递推。

将f(i)记为动态规划列表dp[i],则i=1,....n的状态转移过程如下图所示
时间复杂度(O^2):状态转移循环n-1轮;每轮中当i=1,2,。。。n时,对应循环数量分别为6*6,11*6,....,[5(n-1)+1]*6;因此总体复杂度为O(n-1) * (6+[5*(n-1)+1])/2 * 6,即等价于O(N*N)
空间复杂度O(n):状态转移过程中,辅助数组tmp最大长度 为6(n-1)-[(n-1)-1]=5n-4,因此使用O(5n-4)=O(n)大小的额外空间

代码

通常做法是声明一个二维数组dp,dp[i] [j]代表前i各骰子的点数和j的概率,并执行状态转移,而由于dp[i] 仅由dp[i-1]递推得出,为了降低空间复杂度,只建立两个一维数组dp,tmp交替前进即可

class Solution {
public:
    vector<double> dicesProbability(int n) {
        vector<double> dp(6, 1.0 / 6.0);
        //遍历骰子个数,因为一个骰子就已经赋值完了,所以从第2个骰子开始赋值
        for (int i = 2; i <= n; i++) {
            vector<double> tmp(5 * i + 1, 0);
            for (int j = 0; j < dp.size(); j++) {
                for (int k = 0; k < 6; k++) {
                    tmp[j + k] += dp[j] / 6.0;
                }
            }
            dp = tmp;
        }
        return dp;
    }

2021/4/10:丑数(求质数)

给你一个整数 n ,请你判断 n 是否为 丑数 。如果是,返回 true ;否则,返回 false

丑数 就是只包含质因数 2、3 和/或 5 的正整数。

示例 1:

输入:n = 6
输出:true
解释:6 = 2 × 3
示例 2:

输入:n = 8
输出:true
解释:8 = 2 × 2 × 2
示例 3:

输入:n = 14
输出:false
解释:14 不是丑数,因为它包含了另外一个质因数 7 。
示例 4:

输入:n = 1
输出:true
解释:1 通常被视为丑数。

提示:

-2^31 <= n <= 2^31 - 1

class Solution {
public:
    bool isUgly(int num) {
        if (num == 0) return false; // 特别判断
        while (true) {
            if (num == 1) return true; // 当num等于1时,代表这个数的质因数只包含2,3,5。那么为true
            if (num % 2 == 0) num /= 2; // 如果num中含有2的质因数,那么把它抽出来
            else if (num % 3 == 0) num /= 3; // 如果num中含有3的质因数,那么把它抽出来
            else if (num % 5 == 0) num /= 5; // 如果num中含有5的质因数,那么把它抽出来
            else return false; // 不是丑数,返回false
        }
    }
};

【方法二:动态规划】

丑数的递推性质:丑数只包含因子2,3,5,因此有“丑数 = 某较小丑数 * 某因子”

设一直长度为n的丑数序列x1,x2…,xn,求第n+1个丑数xn+1。根据递推性质,丑数xn+1只可能是以下三种情况(索引a,b,c为位置数):

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ExNhBZzd-1626103523367)(C:\Users\mubao\AppData\Roaming\Typora\typora-user-images\image-20210524223307068.png)]

因此,可设置指针a,b,c指向首个丑数(即1),循环根据递归公式得到下一个丑数,并每轮将对应指针执行加1即可

  1. 确定dp数组下标及含义

    dp[i]:代表第i+1个丑数

  2. 确定转移方程

    • 当索引a,b,c满足以下条件是,dp[i]为三种情况的最小值;

    • 每轮计算dp[i]后,需要更新索引a,b,c的值,时期始终满足方程条件。实现方法:独立判断dp[i]和dp[a] * 2,dp[b] * 3,dp[c] * 5的大小关系,若相等则将对应索引a,b,c加1;

      [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jRVSDRXH-1626103523368)(C:\Users\mubao\AppData\Roaming\Typora\typora-user-images\image-20210524223716291.png)]

  3. 确定初始状态

    dp[0] = 1,即第一个丑数是1

  4. 确定遍历顺序

    从小到大遍历

img

复杂度分析:

  • **时间复杂度O(N)😗*其中N=n,动态规划需要遍历计算dp列表
  • **空间复杂度O(N)😗*长度为N的dp列表使用O(N)的额外空间
class Solution {
public:
    int nthUglyNumber(int n) {
        int index_two = 0, index_three = 0, index_five = 0;
        vector<int> dp(n,1);
        for(int i = 1; i < n; i++)
        {
            dp[i] = min(dp[index_two] * 2, min(dp[index_three] * 3, dp[index_five] * 5));
            //注意下面不能用ifelseifelse
            //如果有else会导致重复的数会进来 1 2 3 4 5 6 6 7 8 9 10 10
            if(dp[i] == dp[index_two] * 2) index_two++;
            if(dp[i] == dp[index_three] * 3) index_three++;
            if(dp[i] == dp[index_five] * 5) index_five++;
        }
        return dp[n-1];
    }
};

leetcode264:丑数II

编写一个程序,找出第 n 个丑数。

丑数就是质因数只包含 2, 3, 5 的正整数。

示例:

输入: n = 10
输出: 12
解释: 1, 2, 3, 4, 5, 6, 8, 9, 10, 12 是前 10 个丑数。
说明:

1 是丑数。
n 不超过1690。

当n等于1370时,就超时了。

class Solution {
    bool isUglyNumber(int n){
        if(n <= 0)
            return false;
        while(n)
        {
            if(n == 1) return true;
            else if(n % 2 == 0) n /= 2;
            else if(n % 3 == 0) n /= 3;
            else if(n % 5 == 0) n /= 5;
            else
                return false;
        }
        return false;
    }
public:
    int nthUglyNumber(int n) {
        int number = 0;
        while(n)
        {
            number++;
            if(isUglyNumber(number))
            {
                n--;
            }
        }
        return number;
    }
};

【正解】

class Solution {
public:
    int nthUglyNumber(int n) {
       vector<int> nums;    //用于存放丑数
       nums.push_back(1);
       int i2 = 0, i3 = 0, i5 = 0;
       //这里的主逻辑就是丑数,从小到大不断增加,知道找到第n个丑数
       for(int i = 1; i < n; i++)   //因为i=0时,nums[i]就是1
       {
           int uglyNumber = min({nums[i2] * 2, nums[i3] * 3, nums[i5] * 5});//选取最小丑数
           nums.push_back(uglyNumber);
           if(nums[i2] * 2 == nums[i]) i2++;//上面uglyNumber,对应的下标要相应增加
           if(nums[i3] * 3 == nums[i]) i3++;
           if(nums[i5] * 5 == nums[i]) i5++;
       }
       return nums[n-1];
    }
};

【二维dp数组的应用+双循环】

leetcode62:不同路径

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。

问总共有多少条不同的路径?
输入:m = 3, n = 7
输出:28

示例 2:
输入:m = 3, n = 2
输出:3
解释:
从左上角开始,总共有 3 条路径可以到达右下角。

向右 -> 向右 -> 向下
向右 -> 向下 -> 向右
向下 -> 向右 -> 向右
示例 3:
输入:m = 7, n = 3
输出:28

示例 4:
输入:m = 3, n = 3
输出:6
提示:

1 <= m, n <= 100
题目数据保证答案小于等于 2 * 10^9

图片

【方法一:深度优先遍历】

这道题目,刚一看最直观的想法就是用图论里的深搜,来枚举出来有多少种路径。

注意题目中说机器人每次只能向下或者向右移动一步,那么其实机器人走过的路径可以抽象为一颗二叉树,而叶子节点就是终点!

如图举例:

图片

class Solution {
private:
    int dfs(int i, int j, int m, int n) {
        if (i > m || j > n) return 0; // 越界了
        if (i == m && j == n) return 1; // 找到一种方法,相当于找到了叶子节点
        return dfs(i + 1, j, m, n) + dfs(i, j + 1, m, n);
    }
public:
    int uniquePaths(int m, int n) {
        return dfs(1, 1, m, n);
    }
};

来分析一下时间复杂度,这个深搜的算法,其实就是要遍历整个二叉树。

这颗树的深度其实就是m+n-1(深度按从1开始计算)。

那二叉树的节点个数就是 2^(m + n - 1) - 1。可以理解深搜的算法就是遍历了整个满二叉树(其实没有遍历整个满二叉树,只是近似而已)

所以上面深搜代码的时间复杂度为O(2^(m + n - 1) - 1),可以看出,这是指数级别的时间复杂度,是非常大的。

【方法二:动态规划】

已知:

机器人从(0,0)位置出发,到(m-1,n-1)终点

按照动规五步曲来说

  1. 确定dp数组以及下标的含义

    dp[i] [j]:表示从(0,0)出发,到(i,j)有dp[i] [j]条不同的路径

  2. 确定递推公式

    想要求dp[i] [j],只能有两个方向来推导出来,即dp[i-1] [j]和dp[i] [j-1].

    那么显然,dp[i] [j] = dp[i-1] [j] + dp[i] [j-1],因为dp[i] [j]只有这两个方向过来

  3. dp数组的初始化

    首先dp[i] [0]一定都是1,因为从(0, 0)的位置到(i, 0)的路径只有一条,那么dp[0] [j]也同理。

    for(int i = 0; i < m; i++)dp[i][0] = 1;
    for(int j = 0; j < n; j++)dp[0][j] = 1
    
  4. 确定遍历顺序

    这里要看一下递归公式dp[i] [j] = dp[i - 1] [j] + dp[i] [j - 1],dp[i] [j]都是从其上方和左方推导而来,那么从左到右一层一层遍历就可以了。

    这样就可以保证推导dp[i] [j]的时候,dp[i - 1] [j] 和 dp[i] [j - 1]一定是有数值的。

  5. 例举推到dp数组

    图片

//时间复杂度:O(m*n)
//空间复杂度:O(m*n)
class Solution {
public:
    int uniquePaths(int m, int n) {
        vector<vector<int>> dp(m, vector<int>(n, 0));
        for (int i = 0; i < m; i++) dp[i][0] = 1;
        for (int j = 0; j < n; j++) dp[0][j] = 1;
        for (int i = 1; i < m; i++) {
            for (int j = 1; j < n; j++) {
                dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
            }
        }
        return dp[m - 1][n - 1];
    }
};

优化空间

//时间复杂度:O(m*n)
//空间复杂度:O(n)
Solution {
public:
    int uniquePaths(int m, int n) {
        vector<int> dp(n);
        for (int i = 0; i < n; i++) dp[i] = 1;
        for (int j = 1; j < m; j++) {
            for (int i = 1; i < n; i++) {
                dp[i] += dp[i - 1];
            }
        }
        return dp[n - 1];
    }
};

leetcode63:不同路径II

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。

现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?

图片

网格种的障碍物和空位置分别用1和0表示

图片

输入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]]
输出:2
解释:
3x3 网格的正中间有一个障碍物。
从左上角到右下角一共有 2 条不同的路径:

  1. 向右 -> 向右 -> 向下 -> 向下
  2. 向下 -> 向下 -> 向右 -> 向右
  • m == obstacleGrid.length
  • n == obstacleGrid[i].length
  • 1 <= m, n <= 100
  • obstacleGrid[i][j] 为 0 或 1

动态规划五步曲:

  1. 确定dp数组以及下标含义

    dp[i] [j]: 表示从(0,0)出发,到(i,j)有dp[i] [j]条不同的路径

  2. 确定递推公式

    递推公式与62题一样,dp[i] [j] = dp[i - 1] [j] + dp[i] [j - 1],但是注意的是,以为有障碍(i , j)如果就是障碍的话就应该保持状态(初始状态为0)

    if (obstacleGrid[i][j] == 0) { // 当(i, j)没有障碍的时候,再推导dp[i][j]
        dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
    }
    
  3. dp数组如何初始化

    vector<vector<int>> dp(m, vector<int>(n,0));
    for(int i = 0; i < m && obstacleGrid[i][0]; i++)dp[i][0] = 1;
    for(int j = 0; j < n && obstacleGrid[0][j]; j++)dp[0][j] = 1;
    
    

    但如果(i, 0) 这条边有了障碍之后,障碍之后(包括障碍)都是走不到的位置了,所以障碍之后的dp[i] [0]应该还是初始值0。

    图片

  4. 确定遍历顺序

    从递归公式dp[i] [j] = dp[i-1] [j] + dp[i] [j-1]中可以看出,一定是从左到右一层一层的遍历,这样保证推导dp[i] [j]的时候, dp[i-1] [j] 和 dp[i] [j-1]一定是有数值

    for(int i = 1; i < m; i++){
        for(int j = 1; j < n; j++){
            //如果碰到障碍保持原状
            if(obstacleGrid[i][j] == 1)
                continue;
            dp[i][j] = dp[i-1][j] + dp[i][j-1];
        }
    }
    
  5. 举例推导dp数组

    图片

图片

//时间复杂度:O(n*m)n,m分别为obstacleGrid的长度与宽度
//空间复杂度:O(n*m)
class Solution {
public:
    int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
        //m为网格的行数,n为网格的列数
        int m = obstacleGrid.size();
        int n = obstacleGrid[0].size();
        vector<vector<int>> dp(m, vector<int>(n, 0));
        //初始化的时候要注意,将由障碍物的地方绕过去
        for (int i = 0; i < m && obstacleGrid[i][0] == 0; i++) dp[i][0] = 1;
        for (int j = 0; j < n && obstacleGrid[0][j] == 0; j++) dp[0][j] = 1;
        //相比较上一题:
        for (int i = 1; i < m; i++) {
            for (int j = 1; j < n; j++) {
                //如果没有障碍
                if (obstacleGrid[i][j] == 0) 
                	dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
            }
        }
        return dp[m - 1][n - 1];
    }
};

leetcode64:最小路径和

给定一个包含非负整数的 m x n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。

说明:每次只能向下或者向右移动一步。

输入:grid = [[1,3,1],[1,5,1],[4,2,1]]
输出:7
解释:因为路径 1→3→1→1→1 的总和最小。

输入:grid = [[1,2,3],[4,5,6]]
输出:12

m == grid.length
n == grid[i].length
1 <= m, n <= 200
0 <= grid[i] [j] <= 100

路径问题动规五部曲分析如下:

  1. 确定dp数组(dp table)以及下标的含义

    dp[i] [j]:从[0,0]出发到[i] [j]的最小路径的和

  2. 确定递推公式

    由于每一步只能向下或向右移动**,因此想要走到位置(i, j),上一步就只能在位置(i-1,j)或者位置(i, j-1)中的最小值位置**。

    dp[i] [j] = min(dp[i-1] [j], dp[i] [j-1]) + grid[i] [j];

    应当注意的=是只有一列或一行的情况,

    情况一:只有一列

    dp[0] [j] = dp[0] [j-1] + grid[0] [j];

    情况二:只有一行,这一种情况要单独考虑

    dp[0] [j] = dp[0] [j-1] + grid[0] [j];

  3. dp数组如何初始化

    从递推公式dp[i] [j] = min(dp[i-1] [j], dp[i] [j-1]) + grid[i] [j];可以看出,递推公式的基础就是dp(0,0)

    dp[0] [0] = grid[0] [0];

     //初始化数组
            int row = grid.size();
            int col = grid[0].size();
            //dp[i][j]:从[0,0]出发到[i][j]的最小路径的和
            vector<vector<int>> dp(row, vector<int>(col, 0));
            dp[0][0] = grid[0][0];
    
    
  4. 确定遍历顺序

    由递推公式dp[i] [j] = min(dp[i-1] [j], dp[i] [j-1]) + grid[i] [j];可知遍历的顺序是从前向后遍历的所以有

            //先遍历行,再遍历列
            for(int i = 1; i < row; i++){
                //只有一行或一列的情况要单独考虑
                dp[i][0] = dp[i-1][0] + grid[i][0];
                for(int j = 1; j < col; j++){
                    dp[0][j] = dp[0][j-1] + grid[0][j];
                    dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j];
                    //cout<<dp[i][j]<<" ";
                }
    
  5. 例举dp矩阵

    要深知dp矩阵的每一行每一列代表的含义是什么

    //异常处理只有一行时,怎么讲它放入下面循环中呢?
            if(row == 1)
            {
                for(int j = 1; j < col; j++)
                    dp[0][j] = dp[0][j-1] + grid[0][j];
            }
    

    上面的代码是最容易遗漏的,因为只有一行数据时时不能进入下面的循环

class Solution {
public:
    int minPathSum(vector<vector<int>>& grid) {
        //初始化数组
        int row = grid.size();
        int col = grid[0].size();
        //dp[i][j]:从[0,0]出发到[i][j]的最小路径的和
        vector<vector<int>> dp(row, vector<int>(col, 0));
        dp[0][0] = grid[0][0];

        //同一处理初始化
		if(row > 0)//如果有行
        {
             for(int i = 1; i < row; i++)
            {
                dp[i][0] = dp[i-1][0] + grid[i][0];
            }
		}
       
        if(col > 0)//如果有列
        {
            for(int j = 1; j < col; j++)
            {
                dp[0][j] = dp[0][j-1] + grid[0][j];
            }
        }
       
		 //下面的代码是行数大于1列数大于1是才走
        //先遍历行,再遍历列
        for(int i = 1; i < row; i++){
            for(int j = 1; j < col; j++){
                dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j];
                //cout<<dp[i][j]<<" ";
            }
        }
        return dp[row-1][col-1];
    }
};

leetcode120:三角形最小路径和(防止越界)

给定一个三角形 triangle ,找出自顶向下的最小路径和。

每一步只能移动到下一行中相邻的结点上。相邻的结点 在这里指的是 下标 与 上一层结点下标 相同或者等于 上一层结点下标 + 1 的两个结点。也就是说,如果正位于当前行的下标 i ,那么下一步可以移动到下一行的下标 i 或 i + 1

示例 1:

输入:triangle = [[2],[3,4],[6,5,7],[4,1,8,3]]
输出:11
解释:如下面简图所示:
2
3 4
6 5 7
4 1 8 3
自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)。
示例 2:

输入:triangle = [[-10]]
输出:-10

提示:

1 <= triangle.length <= 200
triangle[0].length == 1
triangle[i].length == triangle[i - 1].length + 1
-10^4 <= triangle[i] [j] <= 10^4
进阶:
你可以只使用 O(n) 的额外空间(n 为三角形的总行数)来解决这个问题吗?

路径问题动规五部曲分析如下:

  1. 确定dp数组(dp table)以及下标的含义

    dp[i] [j]:表示从三角形走到位置(i,j)的最小路径和。这里的位置(i,j)指的是三角形中第i行第j列的位置(从零开始)

  2. 确定递推公式

    由于每一步只能移动到下一行相邻节点上**,因此想要走到位置(i, j)上一步就只能再位置(i-1,j-1)或者位置(i-1, j)**。

    dp(i, j) = min(dp(i-1, j-1), dp(i-1, j))+c(i, j);

    注意当j=0或者当j=i时,上述的方程有一些项是没有意义的。当j=0时,f(i-1, j-1)没有意义,当j=i时,f(i-1, j)没有意义

    dp(i, 0) = dp(i-1, 0) + c(i , 0); dp(i,i) = dp(i-1, i-1) + c(i, i);

    最后答案时dp(n-1, 0)到dp(n-1, n-1)中的最小值,其中n时三角形的行数

  3. dp数组如何初始化

    从递推公式**dp(i, j) = min(dp(i-1, j-1), dp(i-1, j))+c(i, j);可以看出,递推公式的基础就是dp(0,0)

    dp[0] [0] = triangle[0] [0]

vector<vector<int>> dp(triangle.size(),vector<int>(triangle[trangle.size()].size());
dp[0][0] = triangle[0][0];
  1. 确定遍历顺序

    dp[i] [j] 是根据dp[i - 1] [j-1] 和 dp[i - 1] [j] 推导出来的,那么一定是从前到后遍历!

  for (int i = 1; i < len; i++) {
            //情况一:
            dp[i][0] = dp[i-1][0] + triangle[i][0];
            //情况三:
            for(int j = 1; j < i; j++){
                dp[i][j] = min(dp[i-1][j-1], dp[i-1][j]) + triangle[i][j];
            }
            //情况二:
            //为什么放在这个位置,因为第一层是dp[1][1];第2层是dp[2][2].....
            dp[i][i] = dp[i-1][i-1] + triangle[i][i];
        }
  1. 举例推导dp数组

图片

[错误的代码]

class Solution {
public:
    int minimumTotal(vector<vector<int>>& triangle) {
        
        //初始化dp数组
        //dp[i]:代表从到第i层最小路径的和
        int total_leve = triangle.size();
        vector<int> dp(total_leve, INT_MAX);
        dp[0] = triangle[0][0];
        int result = INT_MAX;

        for(int i = 1; i < total_leve; i++){
            for(int j = 0; j <= i; j++){
               // result =  min(result, dp[i-1]+triangle[i][j]);
                dp[i] =  min(dp[i], dp[i-1]+triangle[i][j]);
            //    cout<<dp[i]<<"   ";
            }
        }
        return dp[total_leve-1];
    }
};

【参考代码】

min_element和max_element的用法;范围是左闭右开
#include<algorithm>
作用:查数组中的最大值,返回容器中最小值和最大值的指针。当想获取值的时候要解引用
class Solution {
public:
    int minimumTotal(vector<vector<int>>& triangle) {
        int row = triangle.size();
        int col = triangle[row-1].size();
        vector<vector<int>> dp(row,vector<int>(col, 0) );
        if(row == 0 || col == 0)
            return -1;
        //初始化数组
        dp[0][0] = triangle[0][0];
        //初始化数组边界
        if(row > 0)//如果存在行
       { for(int i = 1; i < row; i++)
        {
            dp[i][0] = dp[i-1][0] + triangle[i][0];
        }
        }
        if(col >0)//如果存在列
        {
            for(int j = 1; j < col; j++)
            {
                dp[j][j] = dp [j-1][j-1] + triangle[j][j];
            }
        }
        
        
        for(int i = 1; i < row; i++)
        {
            for(int j = 1; j < i; j++)
            {
                dp[i][j] = min(dp[i-1][j], dp[i-1][j-1]) + triangle[i][j];
            }
        }
        return *min_element(dp[row-1].begin(), dp[row-1].end());
    }
};

leetcode1269:停在原地的方案数(困难——该题与120题还是有一定的区别,注意)

【题目】

有一个长度为 arrLen 的数组,开始有一个指针在索引 0 处。

每一步操作中,你可以将指针向左或向右移动 1 步,或者停在原地(指针不能被移动到数组范围外)。

给你两个整数 steps 和 arrLen ,请你计算并返回:在恰好执行 steps 次操作以后,指针仍然指向索引 0 处的方案数。

由于答案可能会很大,请返回方案数 模 10^9 + 7 后的结果。

 

示例 1:

输入:steps = 3, arrLen = 2
输出:4
解释:3 步后,总共有 4 种不同的方法可以停在索引 0 处。
向右,向左,不动
不动,向右,向左
向右,不动,向左
不动,不动,不动
示例  2:

输入:steps = 2, arrLen = 4
输出:2
解释:2 步后,总共有 2 种不同的方法可以停在索引 0 处。
向右,向左
不动,不动
示例 3:

输入:steps = 4, arrLen = 2
输出:8
 

提示:

1 <= steps <= 500

【分析】

用dp[i] [j]表示在 i 步操作后,指针位于下标 j 的方案数。其中,i的取值范围是0 <= i <= steps, j的取值范围是0 <= j <= arrLen - 1;

由于一共执行steps步操作,因此指针所在下标一定不会超过steps,可以将j的取值范围进一步缩小到 0 <= j <= min(arrLen - 1, teps)

当没有进行任何操作时,指针一定位于下标0,因此动态规划的初始条件为dp[0] [0] = 1当1 <= j <= min(arrLen - 1, steps)时有dp[0 ] [j] = 0;

每一步操作中,指针都可以向左或者向右移动1步或者停在原地。因此,当1<=i<=steps时,状态dp[i] [j]可以从dp[i-1] [j-1] 、dp[i-1] [j]、 dp[i-1] [ j+1]

初始化,注意边界,当j = 0时,dp[i-1] [j-1] = 0;当 j = min(arrLen - 1, steps)时,dp[i-1] [j+1] = 0。最左边界不能向左移动,最右边界不能向右移动。

注意:题目要求需要对每个状态计算值对10^9+7取模,最终结果得到dp[steps] [0]的值即为答案

class Solution {
    const int MOD = 1000000007;
public:
    int numWays(int steps, int arrLen) {
        if(arrLen == 1)
            return 1;
        //指针移动的最远距离,要么是数组的边界要么是移动的步数,取最小
        int MaxColumn = min(steps, arrLen - 1);
        //dp[i][j]:表示第i步操作后,指针位于下标j的方案数。
        vector<vector<int>> dp(steps + 1, vector<int>(MaxColumn + 1, 0));
        dp[0][0] = 1;
        for(int i = 1; i <= steps; i++)
        {
            for(int j = 0; j <= MaxColumn; j++)
            {
                /*
                下面的写法同样会产生数组越界j = 0时怎么会有[j-1]存在
                //最左边边界不能向左移
                if(j == 0)
                    dp[i-1][j-1] = 0;
                //最右边界不能向右移
                if(j == MaxColumn)
                    dp[i-1][j+1] = 0
                */
                
                //题目要求说要防止加法越界,对计算值进行取模处理,所以每次进行加法操作时都需要对加法值进行取模
                //(为什么该题不能像120题那样割裂开是因为,dp[i][j]不仅与上一值dp[i-1][j]有关还与dp[i-1][j+1]有关,如果割裂开dp[i-1][j+1]一直为0)
                if(j-1 < 0)
                {
                    //最左边界情况
                    dp[i][j] = (dp[i-1][j]  + dp[i-1][j+1] )% MOD;
                }
                else if(j+1 > MaxColumn)
                {
                    //最右边界情况
                    dp[i][j] = (dp[i-1][j]   + dp[i-1][j-1])% MOD ;
                }
                else
                {
                    //处于最左与最右之间
                    dp[i][j] = (((dp[i-1][j]  + dp[i-1][j-1])%MOD  + dp[i-1][j+1]) % MOD);
                }
            }
        }
        //返回第steps步下标为0的dp值
        return dp[steps][0];
    }
};

【三维dp问题】

leetcode879:盈利计划

【题目】

集团里有 n 名员工,他们可以完成各种各样的工作创造利润。
第 i 种工作会产生 profit[i] 的利润,它要求 group[i] 名成员共同参与。如果成员参与了其中一项工作,就不能参与另一项工作。
工作的任何至少产生 minProfit 利润的子集称为 盈利计划 。并且工作的成员总数最多为 n 。
有多少种计划可以选择?因为答案很大,所以 返回结果模 10^9 + 7 的值。
示例 1:

输入:n = 5, minProfit = 3, group = [2,2], profit = [2,3]
输出:2
解释:至少产生 3 的利润,该集团可以完成工作 0 和工作 1 ,或仅完成工作 1 。
总的来说,有两种计划。
示例 2:

输入:n = 10, minProfit = 5, group = [2,3,5], profit = [6,7,8]
输出:7
解释:至少产生 5 的利润,只要完成其中一种工作就行,所以该集团可以完成任何工作。
有 7 种可能的计划:(0),(1),(2),(0,1),(0,2),(1,2),以及 (0,1,2) 。

提示:
1 <= n <= 100
0 <= minProfit <= 100
1 <= group.length <= 100
1 <= group[i] <= 100
profit.length == group.length
0 <= profit[i] <= 100

有两个变量的约束,所以应该使用三维DP解决

解题思路

本题与经典背包问题十分相似,两者不同点在于经典背包问题只有一种容量限制,而本题却有两种限制:集团员工人数上限n,以及工作产生的利润下限minProfit

通过经典背包问题的练习,我们已经知道经典背包可以使用二维动态规划求解:两个维度分别代表物品和容量的限制标准。对于本题上述两种限制,我们可以想到使用三维动态规划求解。本题的三维分别为:当前可选择的工作,已选择的小组员工人数,以及目前状态的工作获利下限。

确定三维数组dp的动态规划状态,其中dp[i] [j] [k] 表示在前i个工作中选择了j个员工,并且满足工作利润至少为k的情况下的盈利总数目。假设group数组长度为len,那么在不考虑取模的运算情况下, 最终答案为:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7eFkTJ7j-1626103523376)(C:\Users\mubao\AppData\Roaming\Typora\typora-user-images\image-20210609092103766.png)]

所以我们可以新建一个三维数组dp[len+1] [n+1] [minProfit+1],初始化dp[0] [0] [0]=1。接下来分析状态转移方程,对于每个工作i,我们根据当前工作人数上限j,有能够开展当前工作和无法开展当前工作两种情况:

  • 如果无法开展当前工作i,那么显然:dp[i] [j] [k] = dp[i-1] [j] [k]

  • 如果能够开展当前工作i,设当前小组人数为group[i],工作获利为profit[i],那么不考虑取模运算的情况下有:

    dp[i] [j] [k] = dp[i-1] [j] [k] + dp[i-1] [j-group[i]] [max(0, k-profit[i])]

由于我们定义的第三维是工作利润至少为k而不是工作利润恰好为k,因此上述状态转移方程中右侧的第三维是max(0, k-profit[i])而不是k-profit[i],因为k-profit[i]可能是负数

class Solution {
public:
    int profitableSchemes(int n, int minProfit, vector<int>& group, vector<int>& profit) {
        int len = group.size(), MOD = (int)1e9 + 7;
        vector<vector<vector<int>>> dp(len + 1, vector<vector<int>>(n + 1, vector<int>(minProfit + 1)));
        dp[0][0][0] = 1;
        for (int i = 1; i <= len; i++) {
            int members = group[i - 1], earn = profit[i - 1];
            for (int j = 0; j <= n; j++) {
                for (int k = 0; k <= minProfit; k++) {
                    if (j < members) {
                        dp[i][j][k] = dp[i - 1][j][k];
                    } else {
                        dp[i][j][k] = (dp[i - 1][j][k] + dp[i - 1][j - members][max(0, k - earn)]) % MOD;
                    }
                }
            }
        }
        int sum = 0;
        for (int j = 0; j <= n; j++) {
            sum = (sum + dp[len][j][minProfit]) % MOD;
        }
        return sum;
    }
};

可以发现dp[i] [j] [k] 仅与 dp[i-1] […] […]有关,所以本题可以用二维动态规划解决。当采用二维动态规划的解法时,对于最小工作利润为0的情况,无论当前在工作的员工有多少人,我们总能提供一种方案,所以初始化dp[i] [0] = 1。此外,降维后dp数组的遍历顺序改为逆序,因为这样才能保证求状态dp[j ] [k]时,用到的时dp[j-members] [max(0, k - earn)]是上一时刻值,而正序遍历则会改写该值。

class Solution {
public:
    int profitableSchemes(int n, int minProfit, vector<int>& group, vector<int>& profit) {
        vector<vector<int>> dp(n+1, vector<int>(minProfit+1));
        //最小利润为0的情况无论当前工作为多少人,我们总能提供一种方案,所以初始化dp[i][0]=1
        for(int i = 0; i <= n; i++)
        {
            dp[i][0] = 1;
        }
        int len = group.size(), MOD = 1e9 + 7;
        for(int i = 1; i <= len; i++)
        {
            int members = group[i-1], earn = profit[i-1];//因为i 属于 1~10包括10而group下标是0~9group[i-1]就是当下的员工数,profit[i-1]就是当下的利润
            for(int j = n; j >= members; j--)
            {
                for(int k = minProfit; k >= 0; k--)
                {
                    dp[j][k] = (dp[j][k] + dp[j-members][max(0, k - earn)]) % MOD;
                }
            }
        }
        return dp[n][minProfit];
    }
};

思考:为什么三维DP数组需要累加和而降维成二维就不需要进行累加呢?

复杂度分析:

  • 时间复杂度:O(len * n * minProfit),其中len为数组group的长度
  • 空间复杂度:O(n * minProfit).使用空间优化的实现,需要创建n+1行minProfit+1列的二维数组dp

leetcode1473:粉刷房子III

【题目】

此方案包含 target = 3 个街区,分别是 [{1}, {2,2}, {1,1}]。
涂色的总花费为 (1 + 1 + 1 + 1 + 5) = 9。
示例 2:

输入:houses = [0,2,1,2,0], cost = [[1,10],[10,1],[10,1],[1,10],[5,1]], m = 5, n = 2, target = 3
输出:11
解释:有的房子已经被涂色了,在此基础上涂色方案为 [2,2,1,2,2]
此方案包含 target = 3 个街区,分别是 [{2,2}, {1}, {2,2}]。
给第一个和最后一个房子涂色的花费为 (10 + 1) = 11。
示例 3:

输入:houses = [0,0,0,0,0], cost = [[1,10],[10,1],[1,10],[10,1],[1,10]], m = 5, n = 2, target = 5
输出:5
示例 4:

输入:houses = [3,1,2,3], cost = [[1,1,1],[1,1,1],[1,1,1],[1,1,1]], m = 4, n = 3, target = 3
输出:-1
解释:房子已经被涂色并组成了 4 个街区,分别是 [{3},{1},{2},{3}] ,无法形成 target = 3 个街区。

提示:

m == houses.length == cost.length
n == cost[i].length
1 <= m <= 100
1 <= n <= 20
1 <= target <= m
0 <= houses[i] <= n
1 <= cost[i] [j] <= 10^4

【思路】

为什么要用三维的数组来存储已有的状态?

因为我们有三个数据需要存储:

  1. 当前是第几个房子
  2. 当前房子的颜色
  3. 当前房子组成的街区数

dp数组和转移方程是什么?

dp[i] [j] [k]:表示前i个房子组成j个街区,并且第i个房子的颜色为k,此时的最小花费数据范围:房子i属于[0 , m-1],街区j属于[0, target],颜色k 属于[0, n];

状态方程:因为这个城市有的已经有颜色了,所以需要依据这个进行分类讨论

  • 情况一:当前房子有颜色

    如果第i-1个房子与第i个房子颜色不同,则会形成新的街区

    如果第i-1个房子与第i个房子颜色相同,则街区数量不变

    如果此时房子已经有颜色,就不需要重新粉刷,没有cost的费用

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ctT8yPIF-1626103523376)(C:\Users\mubao\AppData\Roaming\Typora\typora-user-images\image-20210504091526259.png)]

  • 情况二:当前房子无颜色

    此时就要对当前房子进行涂色了,涂成不同的颜色就会有不同的费用,但转移方程还是类似的。注意到这里cur_color是一个范围值,因此需要对其进行循环遍历

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6JUg294L-1626103523377)(C:\Users\mubao\AppData\Roaming\Typora\typora-user-images\image-20210504091747870.png)]

最终结果怎么取:

如何初始化:

因为取的是min,所以我们把dp的所有数全部初始化为最大数,**这个数可以进行运算但是不能溢出,**当最后取结果时还是这个大数,则说明没有方案能让这些房子形成target个街区。

class Solution {
public:
    int minCost(vector<int>& houses, vector<vector<int>>& cost, int m, int n, int target) {
        // dp[i][j][k]: 前i个房子组成j个街区,且第i个房子的颜色为k,此时的最小花费
        // 房子∈[0, m-1],街区∈[0, target],颜色∈[0, n]
        int dp[m][target+1][n+1];
        memset(dp, 0x3f3f3f3f, sizeof(dp));

        // 初始化
        if(houses[0] == 0) {
            for(int k = 1; k <= n; k++)
                dp[0][1][k] = cost[0][k-1];
        }
        else
            dp[0][1][houses[0]] = 0;
        
        // 转移
        for(int i = 1; i < m; i++) {
            // 房子本身无颜色
            if(houses[i] == 0) {
                for(int cur_color = 1; cur_color <= n; cur_color++) {
                    for(int prv_color = 1; prv_color <= n; prv_color++) {
                        for(int j = 1; j <= target; j++) {
                            if(cur_color == prv_color)
                                dp[i][j][cur_color] = min(dp[i][j][cur_color], dp[i-1][j][cur_color] + cost[i][cur_color-1]);
                            else
                                dp[i][j][cur_color] = min(dp[i][j][cur_color], dp[i-1][j-1][prv_color] + cost[i][cur_color-1]);
                        }
                    }
                }
            }
            // 房子本身有颜色
            else {
                int cur_color = houses[i];
                for(int prv_color = 1; prv_color <= n; prv_color++) {
                    for(int j = 1; j <= target; j++) {
                        if(cur_color == prv_color)
                            dp[i][j][cur_color] = min(dp[i][j][cur_color], dp[i-1][j][cur_color]);
                        else
                            dp[i][j][cur_color] = min(dp[i][j][cur_color], dp[i-1][j-1][prv_color]);
                    }
                }
            }
        }
        int ans = *min_element(dp[m-1][target], dp[m-1][target]+n+1);//min_element的作用范围是左闭右开,下标对用的值是0~n但是min_element后面参数到的位置是n+1
        return ans == 0x3f3f3f3f ? -1 : ans;
    }
};

01背包问题

如果这几种背包,分不清,我这里画了一个图,如下:

图片

01背包概念

有N件物品和一个最多能背重量为W 的背包。第 i 件物品的重量是 weight[i] ,得到的价值是 value[i] 。每件物品只能用一次求解将哪些物品装入背包里物品价值总和最大。

图片

每一件物品其实只有**两个状态,**取或者不取,所以可以使用回溯法搜索出所有的情况,那么时间复杂度就是O(2^n),这里的n表示物品数量。

举一个例子:

背包最大重量为4。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OEPCqFa6-1626103523379)(C:\Users\mubao\AppData\Roaming\Typora\typora-user-images\image-20210309100638107.png)]

二维dp数组01背包

动态规划五步曲:

  1. 确定dp数组以及下标的含义

    对于背包问题,有一种写法,是使用二维数组,即dp[i] [j]表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。

要时刻记着这个dp数组的含义,下面的一些步骤都围绕这dp数组的含义进行的这里i代表什么,j代表什么。

  1. 确定递推公式

    dp[i] [j]的含义:从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。

    那么可以有两个方向推出来dp[i] [j],

    • 由dp[i - 1] [j]推出,即背包容量为j,里面不放物品 i 的最大价值,此时dp[i] [j]就是dp[i - 1] [j]
    • 由dp[i - 1] [j - weight[i]]推出,dp[i - 1] [j - weight[i]] 为背包容量为j - weight[i]的时候不放物品i的最大价值,那么dp[i - 1] [j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值

    所以递归公式:

    dp[i] [j] = max(dp[i - 1] [j], dp[i - 1] [j - weight[i]] + value[i]);

    解读上面的公式:
    dp[i][j]代表从物品0-i选取物品放进容量为j背包中价值总和最大是dp[i][j]
    可能是前0-i个物品选取放进容量为j的背包中的价值就是最大的,根本不需要放物品i。所以对于这种情况dp[i][j] = dp[i-1][j]背包的容量是j
    还有一种可能就是放第i个物品。此时需要背包容量为j-weight[i]的背包放如0-i-1个物品的价值要是最大,当放入物品i的时候达到最大的价值
    dp[i][j] = dp[i-1][j-weight[i]] + value[i];
    综上所述:dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i]);
    
  2. dp数组如何初始化

    关于初始化,一定要和dp数组的定义吻合,否则到递推公式的时候就会越来越乱

    首先从 dp[i] [j] 的定义出发,如果背包容量 j 为 0 的话,即 dp[i] [0],无论是选取哪些物品,背包价值总和一定为0。如图:

    图片

    状态转移方程 dp[i] [j] = max(dp[i - 1] [j], dp[i - 1] [j - weight[i]] + value[i]); 可以看出i 是由 i-1 推导出来,那么i为0的时候就一定要初始化。

dp[0] [j],即:i为0,存放编号0的物品的时候,各个容量的背包所能存放的最大价值。

// 倒叙遍历
for (int j = bagWeight; j >= weight[0]; j--) {
    dp[0][j] = dp[0][j - weight[0]] + value[0]; // 初始化i为0时候的情况
}

//你还不如这样赋值
for(int j = wiehgt[0]; j <= bagWeight; j++)//注意这里不能用j=0开始,因为如果j=0,根本不能容纳物品0的容量
{
    dp[0][j] = value[0];
}

[重要]大家应该发现,这个初始化为什么是倒叙的遍历的?正序遍历就不行么?

正序遍历还真就不行,dp[0] [j]表示容量为j的背包存放物品0时候的最大价值,物品0的价值就是15,因为题目中说了**每个物品只有一个!**所以dp[0] [j]如果不是初始值的话,就应该都是物品0的价值,也就是15。

// 正序遍历
for (int j = weight[0]; j <= bagWeight; j++) {
    dp[0][j] = dp[0][j - weight[0]] + value[0];
}

例如dp[0] [1] 是15,到了dp[0] [2] = dp[0] [2 - 1] + 15; 也就是dp[0] [2] = 30 了,那么就是物品0被重复放入了。

所以一定要倒叙遍历,保证物品0只被放入一次!这一点对01背包很重要,后面在讲解滚动数组的时候,还会用到倒叙遍历来保证物品使用一次

此时dp数组初始化情况如图所示:

图片

总之就是倒序遍历。

**dp[0] [j] 和 dp[i] [0] 都已经初始化了,**那么其他下标应该初始化多少呢?

dp[i] [j]在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,因为0就是最小的了,不会影响取最大价值的结果。

**如果题目给的价值有负数,那么非0下标就要初始化为负无穷了。**例如:一个物品的价值是-2,但对应的位置依然初始化为0,那么取最大值的时候,就会取0而不是-2了,所以要初始化为负无穷。

这样才能让dp数组在递归公式的过程中取最大的价值,而不是被初始值覆盖了

易错点:

  • 初始化数组时,物品容量是从【0,bagWeight】,物品是从【0,物品数量-1】,而不是vector<vector< int >> dp(weight.size(), vector< int >(bagWeight, 0))
//初始化dp数组
vector<vector<int>> dp(weight.size() , vector<int>(bagWeight + 1, 0));
//因为创建数组初始化为0所以才能用倒叙
for(int j = baWeight; j >= weight[0]; j--){
    dp[0][j] = dp[0][j - weight[0]] + value[0];
}
  1. 确定遍历顺序

    两个遍历维度:物品与背包重量

    图片

那么先遍历物品还是先遍历背包重量呢?

其实都可以!但是先遍历物品要更好理解一些。

//weight数组的大小就是物品个数
for(int i = 1; i < weight.size(); i++){//遍历物品
	for(int j = 0; j <= bagWeight; j++){//遍历背包容量
        if(j < weight[i]) //如果背包的容量小于物品的重量
            dp[i][j] = dp[i-1][j];//这是为了展现dp数组里元素的变化
        else
            dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]]+value[i]);
    }
}
  1. 举例推导dp数组

    图片

void test_2_wei_bag_problem1(){
    vector<int> weight = {1, 3, 4};
    vector<int> value = {15, 20, 30};
    int bagWeight = 4;//背包的容量为4
    
    //二维数组
    //为什么将二维数组初始化为0,因为当一个背包什么都不放的时候最大价值为0。
    vector<vector<int>> dp(weight.size() + 1, vector<int>(bagWeight + 1, 0));
    
    //初始化
    for(int j = bagWeight; j >= weight[0]; j--){
        dp[0][j] = dp[0][j - weight[0]] + value[0];
    }
    
    //weight数组的大小就是物品个数
    //i是从1开始遍历的,而不是零这就是为什么建立dp数组的时候行数是weight.size+1,列数是bagWeight.size+1
    for(int i = 1; i < weight.size(); i++){//遍历物品
        for(int j = 0; j <= bagWeight; j++){//遍历背包容量
            if(j < weight[i])
                dp[i][j] = dp[i-1][j];
            else
                dp[i][j] = max(dp[i-1][j], dp[i-1][j - weight[i]] + value[i]);
        }
    }
    cout << dp[weight.size()-1][bagWeight]<<endl;
}

int main(){
	test_2_wei_bag_problem1();
}

01背包问题(滚动数组)

今天我们就来说一说滚动数组,其实在前面的题目中我们已经用到过滚动数组了,就是把二维dp降为一维dp,一些录友当时还表示比较困惑。

那么我们通过01背包,来彻底讲一讲滚动数组!

接下来还是用如下这个例子来进行讲解

背包最大重量为4。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Z6Raju2l-1626103523382)(C:\Users\mubao\AppData\Roaming\Typora\typora-user-images\image-20210309110503153.png)]

在使用二维数组的时候,递推公式:dp[i] [j] = max(dp[i - 1] [j], dp[i - 1] [j - weight[i]] + value[i]);

其实可以发现如果把dp[i - 1]那一层拷贝到dp[i]上,表达式完全可以是:dp[i] [j] = max(dp[i] [j], dp[i] [j - weight[i]] + value[i]);

于其把dp[i - 1]这一层拷贝到dp[i]上,不如只用一个一维数组了,只用dp[j](一维数组,也可以理解是一个滚动数组)。

这就是滚动数组的由来,需要满足的条件是上一层可以重复利用,直接拷贝到当前层。

读到这里估计大家都忘了 dp[i] [j]里的 i 和 j 表达的是什么了,i 是物品,j 是背包容量

dp[i] [j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少

一定要时刻记住这里i和j的含义,要不然很容易看懵了。

动态规划五步曲分析:

  1. 确定dp数组的定义

    在一维dp数组中,dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j].

  2. 一维dp数组的递推公式

    dp[j]为容量为j的背包所背的最大价值,那么如何推导dp[j]呢?

    dp[j]可以通过dp[j - weight[j]]推导出来,dp[j - weight[i]]表示容量为j - weight[i]的背包所背的最大价值。

    dp[j - weight[i]] + value[i] 表示 容量为 j - 物品i重量 的背包 加上 物品i的价值。(也就是容量为j的背包,放入物品i了之后的价值即:dp[j])

    此时dp[j]有两个选择,一个是取自己dp[j],一个是取dp[j - weight[i]] + value[i],指定是取最大的,毕竟是求最大价值,

  3. 一维dp数组如何初始化

    关于初始化,一定要和dp数组的定义吻合,否则到递推公式的时候就会越来越乱

    dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j],那么dp[0]就应该是0,因为背包容量为0所背的物品的最大价值就是0。

    那么dp数组除了下标0的位置,初始为0,其他下标应该初始化多少呢?

    dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

    dp数组在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,如果题目给的价值有负数,那么非0下标就要初始化为负无穷。

    这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了

  4. 一维dp数组的遍历顺序

    for(int i = 0; i < weight.size(); i++){//遍历物品
        //为什么j>=weight[i]是因为你需要让背包剩余的容量大于物品的容量,j - weight[i]要大于零
        //倒序就是保证物品只放一次
    	for(int j = bagWeight; j >= weight[i]; j--){//遍历背包容量
            dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
        }
    }
    

    二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小。

    为什么呢?

    举一个例子:物品0的重量weight[0] = 1,价值value[0] = 15

    如果正序遍历

    dp[1] = dp[1 - weight[0]] + value[0] = 15

    dp[2] = dp[2 - weight[0]] + value[0] = 30

    此时dp[2]就已经是30了,意味着物品0,被放入了两次,所以不能正序遍历。

    为什么倒叙遍历,就可以保证物品只放入一次呢?

    倒叙就是先算dp[2]

    dp[2] = dp[2 - weight[0]] + value[0] = 15 (dp数组已经都初始化为0)

    dp[1] = dp[1 - weight[0]] + value[0] = 15

    所以从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了。

    那么问题又来了,为什么二维dp数组历的时候不用倒叙呢?

    因为对于二维dp,dp[i] [j]都是通过上一层即dp[i - 1] [j]计算而来,本层的dp[i] [j]并不会被覆盖!

    (如何这里读不懂,大家就要动手试一试了,空想还是不靠谱的,实践出真知!)

    再来看看两个嵌套for循环的顺序,代码中是先遍历物品嵌套遍历背包容量,那可不可以先遍历背包容量嵌套遍历物品呢?

    不可以!

    因为一维dp的写法,背包容量一定是要倒序遍历(原因上面已经讲了),如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品。

    (这里如果读不懂,就在回想一下dp[j]的定义,或者就把两个for循环顺序颠倒一下试试!)

    所以一维dp数组的背包在遍历顺序上和二维其实是有很大差异的,这一点大家一定要注意。

void test_1_wei_bakg_problem(){
    vector<int> weight = {1, 3, 4};
    vector<int> value = {15, 20, 30};
    int bagWeight = 4;
    
    //初始化
    vector<int> dp(bagWeight + 1, 0);
    for(int i = 0; i < weight.size(); i++){
        //为什么j>=weight[i]是因为你需要让背包剩余的容量大于物品的容量,j - weight[i]要大于零
    	//倒序就是保证物品只放一次
        for(int j = bagWeight; j >= weight[i]; j--){
            dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
        }
    }
    cout << dp[bagWeight] <<endl;
}
int main(){
    test_1_wei_bag_problem();
}

leetcode416:分割等和子集

为什么分割等和子集可以理解为动态规划………………………………

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

注意:

每个数组中的元素不会超过 100
数组的大小不会超过 200
示例 1:

输入: [1, 5, 11, 5]

输出: true

解释: 数组可以分割成 [1, 5, 5] 和 [11].

示例 2:

输入: [1, 2, 3, 5]

输出: false

解释: 数组不能分割成两个元素和相等的子集.

【分析】

这道题目初步看,是如下两题几乎是一样的,大家可以用回溯法,解决如下两题

  • 698.划分为k个相等的子集
  • 473.火柴拼正方形

这道题目是要找是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

那么只要找到集合里能够出现 sum / 2 的子集总和,就算是可以分割成两个相同元素和子集了。

本题是可以用回溯暴力搜索出所有答案的,但最后超时了,也不想再优化了,放弃回溯,直接上01背包吧。

背包问题,大家都知道,有N件物品和一个最多能被重量为W 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。【每件物品只能用一次】,求解将哪些物品装入背包里物品价值总和最大。

要注意题目描述中商品是不是可以重复放入

即一个商品如果可以重复多次放入是完全背包,而只能放入一次是01背包,写法还是不一样的。

要明确本题中我们要使用的是01背包,因为元素我们只能用一次

只有确定了如下四点,才能把01背包问题套到本题上来。

  • 背包的体积为sum / 2
  • 背包要放入的商品(集合里的元素)重量为元素的数值,价值也为元素的数值
  • 背包如何正好装满,说明找到了总和为 sum / 2 的子集
  • 背包中每一个元素是不可重复放入

以上分析完,我们就可以套用01背包,来解决这个问题了。

动规五步曲:

  1. 确定dp数组以及下标的含义

    01背包中,dp[i]表示:容量为i的背包,所背的物品价值可以最大为dp[i]。

    dp[i]表示背包总容量是i,最大可以凑成i的子集总和为dp[i]

  2. 确定递推公式

    01背包中递推公式为:dp[j] = max(dp[j], dp[i - weight[i]] + value[i]);

    套到本题:相当于背包里放入数值,那么物品为i的重量是nums[i],其价值也为nums[i](为什么)

    所以本题的递推公式:dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);

  3. dp数组如何初始化

    从dp[j]的定义来看,首先dp[0]一定是0。

    如果如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,如果题目给的价值有负数,那么非0下标就要初始化为负无穷。

    这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了

    本题题目中 只包含正整数的非空数组,所以非0下标的元素初始化为0就可以了。

    // 题目中说:每个数组中的元素不会超过 100,数组的大小不会超过 200
    // 那么背包内总和不会大于20000,所以定义一个20000大的数组。
    vector<int> dp(20001, 0);
    
  4. 确定遍历顺序

    如果使用一维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环 倒序遍历。

    // 开始 01背包 
    for(int i = 0; i < nums.size(); i++) {
        for(int j = target; j >= nums[i]; j--) { // 每一个元素一定是不可重复放入,所以从大到小遍历
            //dp[j]:j凑成最大子集的和为dp[j]
            dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
        }
    }
    
  5. 举例推导dp数组

    dp[i]的数值一定是小于等于i的。

    如果dp[i] == i 说明,集合中的子集总和正好可以凑成总和i,理解这一点很重要。

    用例1,输入[1,5,11,5] 为例,如图:

    图片

综上所述C++代码:

//使用滚动数组的方法也是最简单最理解的方法
//时间复杂度:O(n)
//空间复杂度:O(n)
class Solution {
public:
    bool canPartition(vector<int>& nums) {
        int sum = 0;

        // dp[i]中的i表示背包内总和
        // 题目中说:每个数组中的元素不会超过 100,数组的大小不会超过 200
        // 那么背包内总和不会大于20000,所以定义一个20000大的数组。
        //当背包的容量很大时候建议考虑滚动数组求解,滚动数组相对于二维数组来说,就是如果是01背包则对容量遍历时应该采用的倒序遍历
        vector<int> dp(20001, 0); 
        for (int i = 0; i < nums.size(); i++) {
            sum += nums[i];
        }
        if (sum % 2 == 1) 
            return false;
        int target = sum / 2;

        // 开始 01背包 
        for(int i = 0; i < nums.size(); i++) {//没有等号
            //对于滚动数组来说,用一维数组来代替本该的二维数组,因为是01背包,每一个元素不可以重复放入,所以应该是从大到小遍历的
            for(int j = target; j >= nums[i]; j--) { // 每一个元素一定是不可重复放入,所以从大到小遍历
                dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
            }
        }
        // 集合中的元素正好可以凑成总和target 
        if (dp[target] == target) return true;
        return false;      
    }
};

//方法二:使用二维dp数组,套用01背包解决:解决问题的关键就是找到物品和背包容量
class Solution {
public:
    bool canPartition(vector<int>& nums) {
        int len = nums.size();
        int sum = 0;
        for(int i = 0; i < len; i++)
        {
            sum += nums[i];
        }
        if(sum % 2)
            return false;
        int bagWeight = sum / 2;
        vector<vector<int>> dp(len, vector<int>(bagWeight + 1, 0));
        //动条数组的初始化
        for(int i = nums[0]; i <= bagWeight; i++)
        {
            dp[0][i] = nums[0];
        }
        for(int i = 1; i < len; i++)
        {
            for(int j = 0; j <= bagWeight; j++)
            {
                if(nums[i] > j)
                    dp[i][j] = dp[i-1][j];
                else
                    dp[i][j] = max(dp[i-1][j], dp[i-1][j-nums[i]] + nums[i]);
            }
        }
        return dp[len - 1][bagWeight] == bagWeight;
    }
};

01背包相对于本题,主要要理解,题目中物品是nums[i],重量是nums[i]i,价值也是nums[i],背包体积是sum/2。

leetcode1049:最后一块石头的重量II

有一堆石头,每块石头的重量都是正整数

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

如果 x == y,那么两块石头都会被完全粉碎;
如果 x != y,那么重量为 x 的石头将会完全粉碎,而重量为 y 的石头新重量为 y-x。
最后,最多只会剩下一块石头。返回此石头最小的可能重量。如果没有石头剩下,就返回 0。

示例:

输入:[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],这就是最优值。

提示:

1 <= stones.length <= 30
1 <= stones[i] <= 1000

[分析]

本题其实就是尽量让石头分成重量相同的两堆,相撞之后剩下的石头最小,这样就化解成01背包问题了

本题物品的重量为store[i],物品的价值也为store[i]。

对应着01背包里的物品重量weight[i]和 物品价值value[i]。

动态规划五步曲:

  1. 确定dp数组以及下标的含义

    dp[j]表示:容量(其实就是重量)为j的背包,最多可以背dp[j]这么重的石头

  2. 确定递推公式:

    dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);

  3. dp数组的初始化

    既然dp[j] 中的j表示容量,那么最大容量是多少呢,就是所有石头的重量和。

    因为提示中给出1<=stones.length <=30, 1<=sones[i]<=1000,所以最大重量就是30*1000.

    而我们要求的target其实只是最大重量的一半,所以dp数组开到15000大小就可以了。

    当然也可以把石头遍历一遍,计算出石头总重量 然后除2,得到dp数组的大小。

    我这里就直接用15000了。

    接下来就是如何初始化dp[j]呢,因为重量都不会是负数,所以dp[j]都初始化为0就可以了,这样在递归公式dp[j] = max(dp[j], dp[j - stones[i]] + stones[i])中dp[j]才不会初始值所覆盖。

    vector<int> dp(15001, 0);
    
  4. 确定遍历顺序

    如果使用一维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒叙遍历!

【c++代码如下】

for(int i = 0; i < stones.size(); i++){//遍历物品
    for(int j = target; j >= stones[i]; j--){//遍历背包容量
        dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
    }
}
  1. 举例推导dp数组

    举例,输入:[2,4,1,1],此时target = (2 + 4 + 1 + 1)/2 = 4 ,dp数组状态图如下:

    图片

最后dp[target]里是容量为target的背包所能背的最大重量。

那么分成两堆石头,一堆石头的总重量是dp[target],另一堆就是sum - dp[target]。

在计算target的时候,target = sum / 2 因为是向下取整,所以sum - dp[target] 一定是大于等于dp[target]的

那么相撞之后剩下的最小石头重量就是 (sum - dp[target]) - dp[target]。

【c++代码】

class Solution {
public:
    int lastStoneWeightII(vector<int>& stones) {
        int sum = 0;
        for(int i = 0; i < stones.size(); i++){
            sum += stones[i];
        }
        int target = sum / 2;//当sum为奇数的时候向下兼容就是说sum/2 >= target
        vector<int> dp(target+1, 0);
        for(int i = 0; i < stones.size(); i++){//遍历物品
            for(int j = target; j >= stones[i]; j--){//遍历背包
                dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]);
            }
        }
        return (sum - dp[target]) - dp[target];
    }
};

本题其实和416. 分割等和子集几乎是一样的,只是最后对dp[target]的处理方式不同。

416. 分割等和子集相当于是求背包是否正好装满,而本题是求背包最多能装多少。

leetcode494:目标和(与leetcode416对比学习_回溯)

给定一个非负正整数数组,a1,a2,a3…a4,和一个目标数S。现在你有两个符号+和-。对于数组中的任意一个整数,你都可以+或-中选择一个符号添加在前面。
返回可以使最终数组和为目标数S的所有添加符号的方法数。

输入:nums: [1, 1, 1, 1, 1], S: 3
输出:5
解释:

-1+1+1+1+1 = 3
+1-1+1+1+1 = 3
+1+1-1+1+1 = 3
+1+1+1-1+1 = 3
+1+1+1+1-1 = 3

一共有5种方法让最终目标和为3。

提示:

数组非空,且长度不会超过 20 。
初始的数组的和不会超过 1000 。
保证返回的最终结果能被 32 位整数存下。

【思路】

这道题目咋眼一看和动态规划背包啥的也没啥关系。

本题要如何使表达式结果为target

既然为target,那么就一定有 left组合 - right组合 = target

left + right等于sum,而sum是固定的。

公式来了, left - (sum - left) = target -> left = (target + sum)/2 。

target是固定的,sum是固定的,left就可以求出来。

此时问题就是在集合nums中找出和为left的组合。

【回溯算法】

class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking(vector<int>& candidates, int target, int sum, int startIndex) {
        if (sum == target) {
            result.push_back(path);
        }
        // 如果 sum + candidates[i] > target 就终止遍历
        for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) {
            sum += candidates[i];
            path.push_back(candidates[i]);
            backtracking(candidates, target, sum, i + 1);
            sum -= candidates[i];
            path.pop_back();

        }
    }
public:
    int findTargetSumWays(vector<int>& nums, int S) {
        int sum = 0;
        for (int i = 0; i < nums.size(); i++) sum += nums[i];
        if (S > sum) return 0; // 此时没有方案
        if ((S + sum) % 2) return 0; // 此时没有方案,两个int相加的时候要各位小心数值溢出的问题
        int bagSize = (S + sum) / 2; // 转变为组合总和问题,bagsize就是要求的和

        // 以下为回溯法代码
        result.clear();
        path.clear();
        sort(nums.begin(), nums.end()); // 需要排序
        backtracking(nums, bagSize, 0, 0);
        return result.size();
    }
};

【动态规划】

如何转化为01背包问题呢。

假设加法的总和为x,那么减法对应的总和就是sum - x。

所以我们要求的是 x - (sum - x) = S

x = (S + sum) / 2

此时问题就转化为,装满容量为x背包,有几种方法

大家看到(S + sum) / 2 应该担心计算的过程中向下取整有没有影响

这么担心就对了,例如sum 是5,S是2的话其实就是无解的,所以:

因为每个物品(题目中的1)只用一次!

这次和之前遇到的背包问题不一样了,之前都是求容量为j的背包,最多能装多少。

  1. 确定dp数组以及下标的含义

    dp[j]:填满(包括j)这么大容积的包,有dp[i]种方法

    其实也可以使用二维dp数组来求解本题,dp[i] [j]:使用 下标为[0, i]的nums[i]能够凑满j(包括j)这么大容量的包,有dp[i] [j]种方法。

  2. 确定递推公式

    有哪些来源可以推出dp[j]

    不考虑nums[i]的情况下,填满容量为j - nums[i]的背包,有dp[j - nums[i]]中方法

    那么只要搞到**nums[i]**的话,凑成dp[j]就有dp[j - nums[i]] 种方法。

    举一个例子,nums[i] = 2:dp[3],填满背包容量为3的话,有dp[3]种方法。

    那么只需要搞到一个2(nums[i]),有dp[3]方法可以凑齐容量为3的背包,相应的就有多少种方法可以凑齐容量为5的背包。

    那么需要把 这些方法累加起来就可以了,dp[i] += dp[j - nums[j]]

    所以求组合类问题的公式,都是类似这种???

    //dp[j] += dp[j - nums[i]];
    dp[j] = dp[j] + dp[j - nums[i]];
    

    这个公式在后面在讲解背包解决排列组合问题的时候还会用到!

  3. dp数组如何初始化

    输入:nums:[1,1,1,1,1],S:3

    bagSize = (S + sum)/2 = (3+5)/2 = 4;

//时间复杂度:O(n*m)n为正整数,m为背包容量
//空间复杂度:O(m)m为背包容量
class Solution{
public:
    int findTargetSumWays(vector<int>& nums, int S){
        int sum = 0;
        for(int i = 0; i < nums.size(); i++)
            sum += nums[i];
        if(S > sum) return 0;//此时没有方案
        if((S + sum) % 2 == 1 )return 0;
        int bagSize = (S + sum) / 2;
        vector<int> dp(bagSize + 1, 0);
        dp[0] = 1;
        for(int i = 0; i < nums.size(); i++){
            for(int j = bagSize; j >= nums[i]; j--){
                /*
               	遍历nums数组,对于每个元素(选或者不选)
               		若当前遍历的元素nums[i]不选,则此时满足背包容量j的方法数不变,不进行任何操作;
               		若当前便利的元素nums[i]不选择,则此时背包容量减少了nums[i](j-nums[i]),此时满足背包容量j的方法数,应该在dp[j]的基础上加上闭包容量j-nums[i]时的方法数,
               	为什么下面的时加法:是因为本题要求得到的时方法数,并不是最大的方法数,所以需要求和。
                */
                dp[j] = dp[j] + dp[j - nums[i]];
            }
        }
        return dp[bagSize];
    }
}

此时 大家应该不仅想起,我们之前讲过的回溯算法:39. 组合总和是不是应该也可以用dp来做啊?

是的,如果仅仅是求个数的话,就可以用dp,但回溯算法:39. 组合总和要求的是把所有组合列出来,还是要使用回溯法爆搜的。

也就是说dp求不出具体解,只能求出大概解。

本地还是有点难度,大家也可以记住,求装满背包有几种方法的情况下,递推公式一般为

后面我们在讲解完全背包的时候,还会用到这个递推公式!

二维背包问题

leetcode474:一和零

给你一个二进制字符串数组 strs 和两个整数 m 和 n 。

请你找出并返回 strs 的最大子集的大小,该子集中 最多 有 m 个 0 和 n 个 1

如果 x 的所有元素也是 y 的元素,集合 x 是集合 y 的 子集 。

示例 1:

输入:strs = [“10”, “0001”, “111001”, “1”, “0”], m = 5, n = 3
输出:4

解释:最多有 5 个 0 和 3 个 1 的最大子集是 {“10”,“0001”,“1”,“0”} ,因此答案是 4 。
其他满足题意但较小的子集包括 {“0001”,“1”} 和 {“10”,“1”,“0”} 。{“111001”} 不满足题意,因为它含 4 个 1 ,大于 n 的值 3 。

示例 2:
输入:strs = [“10”, “0”, “1”], m = 1, n = 1
输出:2
解释:最大的子集是 {“0”, “1”} ,所以答案是 2 。

提示:

1 <= strs.length <= 600
1 <= strs[i].length <= 100
strs[i] 仅由 ‘0’ 和 ‘1’ 组成
1 <= m, n <= 100

【三维】

【三维】

和01背包很像:1个变量需要2维,2个变量则需要3维

定义三维数组dp,**其中dp[i] [j] [k]表示前i个字符串中,使用j个0和k个1的情况下最多可以得到的字符串数量。**假设数组str长度维l,则最终答案维dp[l] [m] [n]。

当没有任何字符串可以使用时,可以得到的字符串数量只能是0,因此动态规划的边界条件是:当i = 0时,对任意0 <= i <=j时,对于strs中的第i个字符串(计数从1开始),首先遍历该字符串得到其中的0和1的数量,分别记为zeros和ones,然后对于0 <= j <= m和0 <= k <= n,计算dp[i] [j] [k]的值。

当0和1的容量分别时j和k时,考虑以下两种情况:

  • 如果j < zeros或者k <ones,则不能选第i个字符串,此时有dp[i] [j] [k] = dp[i-1] [j] [k];
  • 如果j >= zeros 且 k >= ones,则如果不选第i个字符串,有dp[i] [j] [k] = dp[i-1] [j] [k],如果选第i个字符串,有dp[i] [j] [k] = dp[i-1] [j-zeros] [k-ones] + 1,dp[i] [j] [k] 的值应取上面两项中最大值。

时间复杂度为:O(lmn)

class Solution {
public:
    int findMaxForm(vector<string>& strs, int m, int n) 
    {
        int Len = strs.size();
        int dp[Len+1][m+1][n+1];  //为什么定义数组时要用+1的操作  
        memset(dp, 0, sizeof(dp));

        for (int k = 1; k < Len + 1; k ++)
        {
            int cnt0 = 0,    cnt1 = 0;
            for (char & c : strs[k-1])
                cnt0 += (c=='0'), cnt1 += (c=='1');

            for (int i = 0; i < m + 1; i ++)
            {
                for (int j = 0; j < n + 1; j ++)
                {
                    //不选择第k件物品
                    int a = dp[k-1][i][j];
                    //选择第k件物品(前提是有足够的m与n的额度)
                    int b = (i >= zeroNum && j >= oneNum)?dp[k-1][i-zeroNum][j-oneNum]+1:0;
                    dp[k][i][j] = max(a, b);
          /*          
                    dp[k][i][j] = dp[k-1][i][j];
                    if ((i - cnt0 >= 0) && (j - cnt1 >= 0))
                        dp[k][i][j] = max(dp[k][i][j], dp[k-1][i-cnt0][j-cnt1] + 1);*/
                }
            }
        }

        return  dp[Len][m][n];
    }
};

二维dp

可以把外循环去掉,但是要注意循环的顺序和01背包一样,逆序以确保优化时取得值为旧值

【思路】

注意完全背包与01背包的区别

图片

多重背包是每个物品,数量不同的情况。

本题中strs 数组里的元素就是物品,每个物品都是一个!

而m 和 n相当于是一个背包,两个维度的背包

理解成多重背包的同学主要是把m和n混淆为物品了,感觉这是不同数量的物品,所以以为是多重背包。

只不过这个背包由两个维度,一个是m一个是n,而不同长度的字符串就是不同大小的待装物品,且只能装一次

动态规划五步曲:

  1. 确定dp数组以及下标的含义

    dp[i] [j]: 最多由 i 个0和 j 个1的strs的最大子集的大小为dp[i] [j],就是横坐标为0的个数,纵坐标为1的个数

  2. 确定递推公式

    dp[i] [j]:可以由前一个strs里的字符串导出来,strs里的字符串由zeorNum个0,oneNum个1。

    dp[i] [j] 就可以是 dp[i - zeroNum] [j - oneNum] + 1。

    然后我们在遍历的过程中,取dp[i] [j]的最大值。

    [难懂]所以递推公式:dp[i] [j] = max(dp[i] [j], dp[i - zeroNum] [j - oneNum] + 1);

    此时大家可以回想一下01背包的递推公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

    对比一下就会发现,字符串的zeroNum和oneNum相当于物品的重量(weight[i]),字符串本身的个数相当于物品的价值(value[i])。

    这就是一个典型的01背包! 只不过物品的重量有了两个维度而已。

  3. dp数组的初始化

    01背包的dp数组初始化为0就可以。

    因为物品价值不会是负数,初始为0,保证递推的时候dp[i] [j]不会被初始值覆盖。

  4. 确定遍历顺序

    01背包一定是外层for循环遍历物品内层for循环遍历背包容量从后向前遍历

    物品就是strs里的字符串,背包容量就是题目描述中的m和n。

    for(string str : strs){//遍历物品
    	int oneNum = 0, zeroNum = 0;
        for(char  c : str){
    		if(c == '0')
                zeroNum++;
            else
                oneNum++;
        }
        
        for(int i = m; i >= zeroNum; i--){//遍历背包容量且从后向前遍历
            for(int j = n; j >= oneNum; j--){
                //zeronum是当c等于str中的一个时0个数,onenum时此时1的个数,加1是加它自身的个数
                dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
            }
        }
    }
    
  5. 举例推导dp数组

    以输入:[“10”,“0001”,“111001”,“1”,“0”],m = 3,n = 3为例

    最后dp数组的状态如下所示:

    图片

class Solution {
public:
    int findMaxForm(vector<string>& strs, int m, int n) {
        vector<vector<int>> dp =  vector<vector<int>>(m + 1, vector<int>(n + 1, 0));
       
        for(string str : strs){//遍历物品
         	int zeroNum = 0, oneNum = 0;//必须每次遍历都要重置zeroNum于oneNum
            for(char c : str){
                if(c == '0')
                    zeroNum++;
                else
                    oneNum++;
            }
            //因为是二维数组所以需要两个for循环遍历
            for(int i = m; i >= zeroNum; i--){//遍历容量
                for(int j = n; j >= oneNum; j--){//遍历容量
                    dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
                }
            }
        }
        return dp[m][n];
    }
};

完全背包问题

有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大

[重点]就是完全背包和01背包问题唯一不同的地方就是,每种物品有无限件。

在下面的讲解中,我依然举这个例子:

背包最大重量为4。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Kg08Z2Wt-1626103523385)(C:\Users\mubao\AppData\Roaming\Typora\typora-user-images\image-20210310215738576.png)]

问背包能背的物品最大价值是多少?

01背包和完全背包唯一不同就是体现在遍历顺序上,所以本文就不去做动规五部曲了,我们直接针对遍历顺序经行分析!

首先在回顾一下01背包的核心代码

for(int i = 0; i < weight.size(); i++){//遍历物品
    //从大到小遍历,为了保证每个物品仅被添加一次
	for(int j = bagWeight; j >= weight[i]; j--){//遍历背包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
}

我们知道01背包内嵌的循环是从大到小遍历,为了保证每个物品仅被添加一次。

而完全背包的物品是可以添加多次的,所以要从小到大去遍历,即:

// 先遍历物品,再遍历背包
for(int i = 0; i < weight.size(); i++) { // 遍历物品
    for(int j = weight[i]; j < bagWeight ; j++) { // 遍历背包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
}

dp状态图

图片

其实还有一个很重要的问题,为什么遍历物品在外层循环,遍历背包容量在内层循环?

就知道了,01背包中二维dp数组的两个for遍历的先后循序是可以颠倒了,一位dp数组的两个for循环先后循序一定是先遍历物品,再遍历背包容量。

在完全背包中,对于一维dp数组来说,其实两个for循环嵌套顺序同样无所谓!

因为dp[j] 是根据 下标j之前所对应的dp[j]计算出来的。只要保证下标j之前的dp[j]都是经过计算的就可以了。

遍历物品在外层循环,遍历背包容量在内层循环,状态如图:

图片

遍历背包容量在外层循环,遍历物品在内层循环,状态如图:

图片

// 先遍历背包,再遍历物品
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
    for(int i = 0; i < weight.size(); i++) { // 遍历物品
        if (j - weight[i] >= 0) dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
    cout << endl;
}
// 先遍历物品,在遍历背包
void test_CompletePack() {
    vector<int> weight = {1, 3, 4};
    vector<int> value = {15, 20, 30};
    int bagWeight = 4;
    vector<int> dp(bagWeight + 1, 0);
    for(int i = 0; i < weight.size(); i++) { // 遍历物品
        for(int j = weight[i]; j <= bagWeight; j++) { // 遍历背包容量--》限制条件背包的容量
            dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
        }
    }
    cout << dp[bagWeight] << endl;
}
int main() {
    test_CompletePack();
}

leetcode518:零钱兑换II

给定不同面额的硬币一个总金额。写出函数来计算可以凑成总金额的硬币组合数。假设每一种面额的硬币有无限个。【有无限个】–》完全背包问题

示例 1:

输入: amount = 5, coins = [1, 2, 5]
输出: 4
解释: 有四种方式可以凑成总金额:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1

示例 2:
输入: amount = 3, coins = [2]
输出: 0
解释: 只用面额2的硬币不能凑成总金额3。

示例 3:
输入: amount = 10, coins = [10]
输出: 1
注意,你可以假设:

0 <= amount (总金额) <= 5000
1 <= coin (硬币面额) <= 5000
硬币种类不超过 500 种
结果符合 32 位符号整数

本题和纯完全背包不一样纯完全背包是能否凑成总金额,而本题是要求凑成总金额的个数!

注意题目描述中是凑成总金额的硬币组合数,为什么强调是组合数呢?

例如示例一:

5 = 2 + 2 + 1

5 = 2 + 1 + 2

这是一种组合,都是 2 2 1。

如果问的是排列数,那么上面就是两种排列了。

组合不强调元素之间的顺序,排列强调元素之间的顺序。其实这一点我们在讲解回溯算法专题的时候就讲过了哈。

那我为什么要介绍这些呢,因为这和下文讲解遍历顺序息息相关!

动态规划五步曲:

  1. 确定dp数组以及下标的含义

    dp[j]:凑成总金额 j 的货币组合数为dp[j];

  2. 确定递推公式

    dp[j] (考虑coins[i]的组合总和) 就是所有的dp[j - coins[i]](不考虑coins[i])相加

    所以递推公式为:dp[j] += dp(j - coins[i]);

    求装满背包有几种方法,一般公式都是:dp[j] += dp[j - nums[i]];

  3. dp数组如何初始话

    首先dp[0]一定要为1,dp[0] = 1是递归的基础。

    dp[i]的含义上来讲就是,凑成总金额0的货币组合数为1

    下标非0的dp[j]初始化为0,这样累计加dp[j - coins[i]]的时候才不会影响真正的dp[j]

  4. 确定遍历顺序

    动态规划实现组合数于排列数

    外层for循环遍历物品(钱币),内层for遍历背包(金钱总额)的情况。

    for (int i = 0; i < coins.size(); i++) { // 遍历物品
        for (int j = coins[i]; j <= amount; j++) { // 遍历背包容量
            dp[j] += dp[j - coins[i]];
        }
    }
    

    那么就是先把1加入计算,然后再把5加入计算,得到的方法数量只有{1, 5}这种情况。而不会出现{5, 1}的情况。

    所以这种遍历顺序中dp[j]里计算的是组合数!

    外层for循环遍历背包(金钱总额),内层for遍历物品(钱币)的情况。

    for (int j = 0; j <= amount; j++) { // 遍历背包容量
        for (int i = 0; i < coins.size(); i++) { // 遍历物品
            if (j - coins[i] >= 0) dp[j] += dp[j - coins[i]];
        }
    }
    

    背包容量的每一个值,都是经过 1 和 5 的计算,包含了{1, 5} 和 {5, 1}两种情况。

    此时dp[j]里算出来的就是排列数!

  5. 举例推导dp数组

    输入: amount = 5, coins = [1, 2, 5] ,dp状态图如下:

    图片

class Solution {
public:
    int change(int amount, vector<int>& coins) {
        vector<int> dp(amount + 1, 0);
        dp[0] = 1;
        for (int i = 0; i < coins.size(); i++) { // 遍历物品
            for (int j = coins[i]; j <= amount; j++) { // 遍历背包
                dp[j] += dp[j - coins[i]];
            }
        }
        return dp[amount];
    }
};

如果求组合数就是外层for循环遍历物品,内层for遍历背包

如果求排列数就是外层for遍历背包,内层for循环遍历物品

leetcode322:零钱兑换

给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。

你可以认为每种硬币的数量是无限的。

示例 1:输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1

示例 2:
输入:coins = [2], amount = 3
输出:-1

示例 3:
输入:coins = [1], amount = 0
输出:0

示例 4:
输入:coins = [1], amount = 1
输出:1

示例 5:输入:coins = [1], amount = 2
输出:2
  	提示:

	1 <= coins.length <= 12
	1 <= coins[i] <= 2^31 - 1
    0 <= amount <= 10^4

【思路】

动条规划五步曲:

  1. 确定dp数组以及下标的意义

    dp[j]: 凑足总额为 j 所需钱币的最少个数dp[j]

  2. 确定递推公式

    得到dp[j] (考虑coins[i]),只有一个来源,dp[j - coins[i]],那么只需要加上一个钱币coins[i]即dp[j - coins[i]] + 1就是dp[j](考虑coins[i])

    所以dp[j] 要取所有 dp[j - coins[i]] + 1 中最小的。

    递推公式:dp[j] = min(dp[j - coins[i]] + 1, dp[j]);

  3. dp数组该如何初始化

    首先凑足总金额为0所需钱币的个数一定是0,那么dp[0] = 0;,dp[j]必须初始化为一个最大的数,否则就会在**min(dp[j - coins[i]] + 1, dp[j])**比较的过程中被初始值覆盖。所以下标非0的元素都是应该是最大值。

    vector<int>  dp(amount + 1, INT_MAX);
    dp[0] = 0;
    
  4. 确定遍历顺序

    钱币有顺序和没有顺序都可以,都不影响钱币的最小个数。

    所以本题并不强调集合是组合还是排列。

    如果求组合数就是外层for循环遍历物品,内层for遍历背包

    如果求排列数就是外层for遍历背包,内层for循环遍历物品

本题钱币数量可以无限使用,那么是完全背包。所以遍历的内循环是正序

综上所述,遍历顺序为:coins(物品)放在外循环,target(背包)在内循环。且内循环正序。

//分析题目:coins具有物品与价值两个属性,总金额:acount一个属性就是容量
class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        vector<int> dp(amount + 1, INT_MAX);
        dp[0] = 0;

        for(int i = 0; i < coins.size(); i++){
            for(int j = coins[i]; j <= amount; j++){
                if(dp[j - coins[i]] != INT_MAX)//因为下一公式使用的dp[j - coins[i]]
                    dp[j] = min(dp[j], dp[j - coins[i]] + 1);
            }
        }
        if(dp[amount] == INT_MAX)
            return -1;
        return dp[amount];
    }
};

leetcode279:完全平方数

给定正整数 n,找到若干个完全平方数(比如 1, 4, 9, 16, ...)使得它们的和等于 n。你需要让组成和的完全平方数的个数最少。

给你一个整数 n ,返回和为 n 的完全平方数的 最少数量 。

完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。

示例 1:
输入:n = 12
输出:3
解释:12 = 4 + 4 + 4

示例 2:
输入:n = 13
输出:2
解释:13 = 4 + 9
  提示:

1 <= n <= 10^4

【思路】

完全平方数就是物品(可以无限件使用),凑个正整数n就是背包,问凑满这个背包最少有多少物品?

动态规划五步曲:

  1. 确定dp数组以及下标的含义

    dp[i]:和为i的完全平方数的最少数量为dp[i]

  2. 确定递推公式

    dp[j]可以由dp[j - i * i]推出,dp[j - i * i] + 1便可以凑成dp[j].

    此时我们要选择最小的dp[j],所以递推公式:dp[j] = min(dp[j - i * i] + 1, dp[j]);

  3. dp数组如何初始化

    dp[0]表示和为0的完全平方数的最小数量,那么dp[0]一定是0.

    有同学问题,**那0 * 0 也算是一种啊,**为啥dp[0] 就是 0呢?

    看题目描述,找到若干个完全平方数(比如 1, 4, 9, 16, …),题目描述中可没说要从0开始,dp[0]=0完全是为了递推公式。

    从递归公式dp[j] = min(dp[j - i * i] + 1, dp[j]);中可以看出每次dp[j]都要选最小的,所以非0下标的dp[i]一定要初始为最大值,这样dp[j]在递推的时候才不会被初始值覆盖

  4. 确定遍历顺序

    我这里先给出外层遍历背包,里层遍历物品的代码:

    vector<int> dp(n + 1, INT_MAX);
    dp[0] = 0;
    for (int i = 0; i <= n; i++) { // 遍历背包
        for (int j = 1; j * j <= i; j++) { // 遍历物品
            dp[i] = min(dp[i - j * j] + 1, dp[i]);//为什么+1是因为i-j*j由dp[i - j*j]个完全平方数,再加上j*j所以就有dp[i - j * j] + 1种方法
        }
    }
    
    

所以c++代码为:

// 版本二
class Solution {
public:
    int numSquares(int n) {
        vector<int> dp(n + 1, INT_MAX);
        dp[0] = 0;
        for (int i = 1; i * i <= n; i++) { // 遍历物品
            for (int j = 1; j <= n; j++) { // 遍历背包
                if (j - i * i  >= 0 && dp[j - i * i] != INT_MAX) {
                    dp[j] = min(dp[j - i * i ] + 1, dp[j]);
                }
            }
        }
        return dp[n];
    }
};

leetcode139:单词拆分

给定一个非空字符串 s 和一个包含非空单词的列表 wordDict,判定 s 是否可以被空格拆分为一个或多个在字典中出现的单词。

说明:

拆分时可以重复使用字典中的单词。
你可以假设字典中没有重复的单词。
示例 1:

输入: s = "leetcode", wordDict = ["leet", "code"]
输出: true
解释: 返回 true 因为 "leetcode" 可以被拆分成 "leet code"。
示例 2:

输入: s = "applepenapple", wordDict = ["apple", "pen"]
输出: true
解释: 返回 true 因为 "applepenapple" 可以被拆分成 "apple pen apple"。
     注意你可以重复使用字典中的单词。
示例 3:

输入: s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]
输出: false


【分析】

看到这道题目的时候,大家应该回想起我们之前讲解回溯法专题的时候,讲过的一道题目回溯算法:分割回文串,就是枚举字符串的所有分割情况。

回溯算法:分割回文串:是枚举分割后的所有子串,判断是否回文。

本道是枚举分割所有字符串,判断是否在字典里出现过。

那么这里我也给出回溯法C++代码:

//时间复杂度:O(2^n),因为每一个单词都有两个状态,切割和不切割
//空间复杂度:O(n),算法递归系统调用栈的空间
class Solution {
private:
    bool backtracking (const string& s, const unordered_set<string>& wordSet, int startIndex) {
        if (startIndex >= s.size()) {
            return true;
        }
        for (int i = startIndex; i < s.size(); i++) {
            string word = s.substr(startIndex, i - startIndex + 1);
            if (wordSet.find(word) != wordSet.end() && backtracking(s, wordSet, i + 1)) {
                return true;
            }
        }
        return false;
    }
public:
    bool wordBreak(string s, vector<string>& wordDict) {
        //只有用wordset才可以用find函数
        unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
        return backtracking(s, wordSet, 0);
    }
};

递归的过程中有很多重复计算,可以使用数组保存一下递归过程中计算的结果

这个叫做记忆化递归,这种方法我们之前已经提过很多次了。

使用memory数组保存每次计算的以startIndex起始的计算结果,如果memory[startIndex]里已经被赋值了,直接用memory[startIndex]的结果。

//时间复杂度:O(2^N)
class Solution {
private:
    bool backtracking (const string& s,
            const unordered_set<string>& wordSet,
            vector<int>& memory,
            int startIndex) {
        if (startIndex >= s.size()) {
            return true;
        }
        // 如果memory[startIndex]不是初始值了,直接使用memory[startIndex]的结果
        if (memory[startIndex] != -1) return memory[startIndex];
        for (int i = startIndex; i < s.size(); i++) {
            string word = s.substr(startIndex, i - startIndex + 1);
            if (wordSet.find(word) != wordSet.end() && backtracking(s, wordSet, memory, i + 1)) {
                memory[startIndex] = 1; // 记录以startIndex开始的子串是可以被拆分的
                return true;
            }
        }
        memory[startIndex] = 0; // 记录以startIndex开始的子串是不可以被拆分的
        return false;
    }
public:
    bool wordBreak(string s, vector<string>& wordDict) {
        unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
        vector<int> memory(s.size(), -1); // -1 表示初始化状态
        return backtracking(s, wordSet, memory, 0);
    }
};

[背包问题]

单词就是物品字符串s就是背包,单词能否组成字符串s,就是问物品能不能把背包装满。

拆分时可以重复使用字典中的单词,说明就是一个完全背包!

动规五步曲:

  1. 确定dp数组的含义与下标

    dp[i] : 字符串长度为i的话,dp[i]为true,表示可以拆分为一个或多个在字典中出现的单词

  2. 确定递推公式

    如果确定dp[j] 是true,且 [j, i] 这个区间的子串出现在字典里,那么dp[i]一定是true。(j < i )。

    所以递推公式是 if([j, i] 这个区间的子串出现在字典里 && dp[j]是true) 那么 dp[i] = true

  3. dp数组如何初始化

    从递归公式中可以看出,dp[i] 的状态依靠 dp[j]是否为true,那么dp[0]就是递归的根基,dp[0]一定要为true,否则递归下去后面都都是false了。

    dp[0]表示如果字符串为空的话,说明出现在字典里。

    但题目中说了“给定一个非空字符串 s” 所以测试数据中不会出现i为0的情况,那么dp[0]初始为true完全就是为了推导公式。

    下标非0的dp[i]初始化为false,只要没有被覆盖说明都是不可拆分为一个或多个在字典中出现的单词。

  4. 确定遍历顺序

    题目中说是拆分为一个或多个在字典中出现的单词,所以这是完全背包。

    还要讨论两层for循环的前后循序。

    如果求组合数就是外层for循环遍历物品,内层for遍历背包

    如果求排列数就是外层for遍历背包,内层for循环遍历物品

    但本题还有特殊性,因为是要求子串,最好是遍历背包放在外循环,将遍历物品放在内循环。

    如果要是**外层for循环遍历物品,内层for遍历背包,就需要把所有的子串都预先放在一个容器里**。(如果不理解的话,可以自己尝试这么写一写就理解了)

    所以最终我选择的遍历顺序为:遍历背包放在外循环,将遍历物品放在内循环。内循环从前到后

  5. 以输入: s = “leetcode”, wordDict = [“leet”, “code”]为例,dp状态如图:

    图片

dp[s.size()]就是最终结果。

//时间复杂度:O(n^3)因为substr返回子串的副本是O(n)的复杂度(这里的n是substring的长度)
//空间复杂度:O(n)
class Solution {
public:
	bool wordBreak(string s, vector<string>& wordDict) {
		unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
		vector<bool> dp(s.size()+1, false);
		dp[0] = true;
        //注意的是如果先遍历物品在遍历背包
		for (int i = 1; i <= s.size(); i++){//遍历背包
			for (int j = 0; j < i; j++){//遍历物品
				string word = s.substr(j, i - j);//substr(起始位置,终止位置)
				if (wordSet.find(word) != wordSet.end() && dp[j]){
					dp[i] = true;
				}
			}
		}
		return dp[s.size()];
	}
};

//回溯可以找到具体解,而背包问题只能找到最优解,但是得不到具体解

多重背包问题

有N种物品和一个容量为V 的背包。第i种物品最多有Mi件可用,每件耗费的空间是Ci ,价值是Wi 。求解将哪些物品装入背包可使这些物品的耗费的空间 总和不超过背包容量,且价值总和最大。

多重背包和01背包是非常像的, 为什么和01背包像呢?

每件物品最多有Mi件可用,把Mi件摊开,其实就是一个01背包问题了。

背包最大重量为10。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aMGd0zqp-1626103523388)(C:\Users\mubao\AppData\Roaming\Typora\typora-user-images\image-20210312164027244.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0aZPxnYS-1626103523388)(C:\Users\mubao\AppData\Roaming\Typora\typora-user-images\image-20210312164049054.png)]

//时间复杂度:O(m * n * k)
void test_multi_pack(){
	vector<int> weight = {1, 3, 4};
	vector<int> value = {15, 20, 30};
	vector<int> nums = {2, 3, 2};
    int bagweight = 10;
    //多重背包相比较01背包来说就是讲重复的物品给摊开,以下是核心代码
    //-------------------------------------------------------
    for(int i = 0; i < nums.size(); i++){
        while(nums[i] > 1){//nums[i]保留到1,把其它物品都摊开
            wight.push_back(weight[i]);
            value.push_back(value[i]);
            nums[i]--;
        }
    }
    //-------------------------------------------------------
    vector<int> dp(bagweight + 1, 0);
    for(int i = 0; i < weight.size(); i++){//遍历物品
        for(int j = bagweight; j >= weight[i]; j--){//遍历背包容量
            dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
        }
        for(int j = 0; j <= bagweight; j++){
            cout << dp[j] << " ";
        }
        cout<<endl;
    }
    cout << dp[bagweight] << endl;
}

打家劫舍系列

leetcode198:打家劫舍(数组)

劫舍系列简单来说就是 数组上连续元素二选一,成环之后连续元素二选一,在树上连续元素二选一,所能得到的最大价值

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额

示例 1:输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。

示例 2:
输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
偷窃到的最高金额 = 2 + 9 + 1 = 12 。

提示:

0 <= nums.length <= 100
0 <= nums[i] <= 400

打家劫舍是dp解决的经典问题,动规五部曲分析如下:

  1. 确定dp数组(dp table)以及下标的含义

    dp[i]:考虑下标i(包括i)以内的房屋,最多可以偷窃的金额为dp[i]

  2. 确定递推公式

决定dp[i]的因素就是第i房间偷还是不偷。

如果偷第i房间,那么dp[i] = dp[i - 2] + nums[i] ,即:第i-1房一定是不考虑的,找出 下标i-2(包括i-2)以内的房屋,最多可以偷窃的金额为dp[i-2] 加上第i房间偷到的钱。

如果不偷第i房间,那么dp[i] = dp[i - 1],即考虑i-1房,(注意这里是考虑,并不是一定要偷i-1房,这是很多同学容易混淆的点

然后dp[i]取最大值,即dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);

  1. dp数组如何初始化

从递推公式dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);可以看出,递推公式的基础就是dp[0] 和 dp[1]

从dp[i]的定义上来讲,dp[0] 一定是 nums[0],dp[1]就是nums[0]和nums[1]的最大值即:dp[1] = max(nums[0], nums[1]);

vector<int> dp(nums.size());
dp[0] = nums[0];
dp[1] = max(nums[0], nums[1]);
  1. 确定遍历顺序

    dp[i] 是根据dp[i - 2] 和 dp[i - 1] 推导出来的,那么一定是从前到后遍历!

for (int i = 2; i < nums.size(); i++) {
    dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
}
  1. 举例推导dp数组

图片

class Solution {
public:
    int rob(vector<int>& nums) {
        if (nums.size() == 0) return 0;
        if (nums.size() == 1) return nums[0];
        vector<int> dp(nums.size());
        dp[0] = nums[0];
        dp[1] = max(nums[0], nums[1]);
        for (int i = 2; i < nums.size(); i++) {
            dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
        }
        return dp[nums.size() - 1];
    }
};

leetcode740:删除并获得点数(打家劫舍的变种问题,这种问题的逻辑方式是一样的都是相邻元素不能取)

动态规划可以不看数据量,本题nums.size()可以达到10^4,但是同样也可以用动态规划

给你一个整数数组 nums ,你可以对它进行一些操作。

每次操作中,选择任意一个 nums[i] ,删除它并获得 nums[i] 的点数。之后,你必须删除每个等于 nums[i] - 1 或 nums[i] + 1 的元素。

开始你拥有 0 个点数。返回你能通过这些操作获得的最大点数。

示例 1:

输入:nums = [3,4,2]
输出:6
解释:
删除 4 获得 4 个点数,因此 3 也被删除。
之后,删除 2 获得 2 个点数。总共获得 6 个点数。
示例 2:

输入:nums = [2,2,3,3,3,4]
输出:9
解释:
删除 3 获得 3 个点数,接着要删除两个 2 和 4 。
之后,再次删除 3 获得 3 个点数,再次删除 3 获得 3 个点数。
总共获得 9 个点数。

提示:

1 <= nums.length <= 2 * 10^4
1 <= nums[i] <= 10^4

//时间复杂度:O(N+M),其中N是数组nums的长度,M是数组nums中元素的最大值
//空间复杂度:O(M)
class Solution
{
  int rob(vector<int> nums)
  {
      int len = nums.size();
      if(len == 0)	return 0;
      if(len == 1)	return nums[0];
      vector<int> dp(len);
      dp[0] = nums[0];
      dp[1] = max(nums[0], nums[1]);
      
      for(int i = 2; i < len; i++)
      {
          //当前的数字偷不偷,偷是dp[i-2]+nums[i]不偷是dp[i-1]
          dp[i] = max(dp[i-2] + nums[i], dp[i-1]);
	  }
      return dp[len - 1];
  }
public:
    int deleteAndEarn(vector<int>& nums)
    {
        int maxVal = 0;
        for(int val : nums)
        {
            maxVal = max(maxVal, val);
        }
        vector<int> sum(maxVal + 1);
        //将相同元素求和放入sum数组中,这样形成的sum数组就可以用打家劫舍方法求解了
        for(int val : nums)
        {
            sum[val] += val;
        }
        return rob(sum);
	}
};

leetcode213:打家劫舍II(环形)

你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。

给定一个代表**【输入】每个房屋存放金额的非负整数数组**,计算你 在不触动警报装置的情况下 ,能够**【输出】偷窃到的最高金额**。

示例 1:

输入:nums = [2,3,2]
输出:3
解释:你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2), 因为他们是相邻的。

示例 2:
输入:nums = [1,2,3,1]
输出:4
解释:你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。偷窃到的最高金额 = 1 + 3 = 4 。

示例 3:
输入:nums = [0]
输出:0
提示:

1 <= nums.length <= 100
0 <= nums[i] <= 1000

对于一个数组,成环的话主要有如下三种情况:

  • 情况一:考虑不包含首尾元素

图片

  • 情况二:考虑包含首元素,不包含尾元素

图片

  • 情况三:考虑包含尾元素,不包含首元素

图片

// 注意注释中的情况二情况三,以及把198.打家劫舍的代码抽离出来了
class Solution {
public:
    int rob(vector<int>& nums) {
        if (nums.size() == 0) return 0;
        if (nums.size() == 1) return nums[0];
        int result1 = robRange(nums, 0, nums.size() - 2); // 情况二
        int result2 = robRange(nums, 1, nums.size() - 1); // 情况三
        return max(result1, result2);
    }
    // 198.打家劫舍的逻辑
    int robRange(vector<int>& nums, int start, int end) {
        if (end == start) return nums[start];
        vector<int> dp(nums.size());
        dp[start] = nums[start];
        dp[start + 1] = max(nums[start], nums[start + 1]);
        for (int i = start + 2; i <= end; i++) {
            dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
        }
        return dp[end];
    }
};

leetcode337:打家劫舍III(树形递归)

在上次打劫完一条街道之后和一圈房屋后,小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为“根”。除了“根”之外,每栋房子有且只有一个“父“房子与之相连。一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。如果两个直接相连的房子在同一天晚上被打劫,房屋将自动报警。

计算在不触动警报的情况下,小偷一晚能够盗取的最高金额。

图片

【思路】

要对树的遍历掌握透彻,前中后序遍历(深度优先搜索)还是层序遍历(广度优先搜索)

本题一定是要后序遍历,因为通过递归函数的返回值来做下一步计算

如果抢了当前节点,两个孩子就不是动,如果没抢当前节点,就可以考虑抢左右孩子(注意这里说的是“考虑”

时间复杂度:O(n^2):
空间复杂度:O(n)
class Solution(){
public:
    int rob(TreeNode* root){
        if(root == NULL)return 0;
        if(root->left == NULL && root->right == NULL)return root->val;
        //偷父节点
        int val = root->val;
        if(root->left) val1 += rob(root->left->left) + rob(root->left->right);// 跳过root->left,相当于不考虑左孩子了
        if(root->right) val2 += rob(root->right->left) + rob(root->right->right);// 跳过root->right,相当于不考虑右孩子了
        //不偷父节点
        int val2 = rob(root->left) + rob(root->right);//考虑root的左右孩子
        return max(val1, val2);
    }
}

我们计算了root的四个孙子(左右孩子的孩子)为头结点的子树的情况,又计算了root的左右孩子为头结点的子树的情况,计算左右孩子的时候其实又把孙子计算了一遍。

【记忆化递推】

所以可以使用一个map把计算过的结果保存一下,这样如果计算过孙子了,那么计算孩子的时候可以复用孙子节点的结果。

时间复杂度:O(n)
空间复杂度:O(n)算上递推系统栈上的空间
class Solution {
public:
    unordered_map<TreeNode* , int> umap; // 记录计算过的结果
    int rob(TreeNode* root) {
        if (root == NULL) return 0;
        if (root->left == NULL && root->right == NULL) return root->val;
        if (umap[root]) return umap[root]; // 如果umap里已经有记录则直接返回
        // 偷父节点
        int val1 = root->val;
        if (root->left) val1 += rob(root->left->left) + rob(root->left->right); // 跳过root->left
        if (root->right) val1 += rob(root->right->left) + rob(root->right->right); // 跳过root->right
        // 不偷父节点
        int val2 = rob(root->left) + rob(root->right); // 考虑root的左右孩子
        
        umap[root] = max(val1, val2); // umap记录一下结果
        return max(val1, val2);
    }
};

动态规划其实就是使用状态转移容器来记录状态的变化,这里可以使用一个长度为2的数组,记录当前节点偷与不偷所得到的的最大金钱。

这道题目算是树形dp的入门题目,因为是在树上进行状态转移,我们在讲解二叉树的时候说过递归三部曲,那么下面我以递归三部曲为框架,其中融合动规五部曲的内容来进行讲解

  1. 确定递归函数的参数与返回值

这里我们要求一个节点 偷与不偷的两个状态所得到的金钱,那么返回值就是一个长度为2的数组。

参数为当前节点,代码如下

其实这里的返回数组就是dp数组。

所以dp数组(dp table)以及下标的含义:下标为0记录不偷该节点所得到的的最大金钱,下标为1记录偷该节点所得到的的最大金钱。

所以本题dp数组就是一个长度为2的数组!

那么有同学可能疑惑,长度为2的数组怎么标记树中每个节点的状态呢?

别忘了在递归的过程中,系统栈会保存每一层递归的参数

  1. 确定终止条件

    在遍历过程中如果遇到空节点,无论偷与不偷都是零,所以就返回

    相当于dp数组的初始化

  2. 确定遍历顺序

    首先明确的是使用后序遍历。因为通过递归函数的返回值来做下一步计算

    通过递归左节点,得到左节点偷与不偷的金钱。

    通过递归右节点,得到右节点偷与不偷的金钱。

    // 下标0:不偷,下标1:偷
    vector<int> left = robTree(cur->left); // 左
    vector<int> right = robTree(cur->right); // 右 
    // 中
    
  3. 确定单层递归的逻辑

    如果是偷当前节点,那么左右孩子就不能偷,val1 = cur->val + left[0] + right[0];

    如果不偷当前节点,那么左右孩子就可以偷,至于到底偷不偷一定是选一个最大的,所以:val2 = max(left[0], left[1]) + max(right[0], right[1]);

    最后当前节点的状态就是{val2, val1}; 即:{不偷当前节点得到的最大金钱,偷当前节点得到的最大金钱}

vector<int> left = robTree(cur->left); // 左
vector<int> right = robTree(cur->right); // 右 

// 偷cur
int val1 = cur->val + left[0] + right[0];
// 不偷cur
int val2 = max(left[0], left[1]) + max(right[0], right[1]);
return {val2, val1};
  1. 举例推导dp数组

    图片

最后头结点就是 取下标0 和 下标1的最大值就是偷得的最大金钱

class Solution{
public:
    int rob(TreeNode* root) {
        vector<int> result = robTree(root);
        return max(result[0], result[1]);
    }
    // 长度为2的数组,0:不偷,1:偷
    vector<int> robTree(TreeNode* cur) {
        if (cur == NULL) return vector<int>{0, 0};
        vector<int> left = robTree(cur->left);
        vector<int> right = robTree(cur->right);
        // 偷cur
        int val1 = cur->val + left[0] + right[0];
        // 不偷cur 
        int val2 = max(left[0], left[1]) + max(right[0], right[1]);
        return {val2, val1};
    }
};

买卖股票最佳时机系列

leetcode121:买卖股票的最佳时机-》只能有一次交易

给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。

你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。

返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0 。

示例 1:
输入:[7,1,5,3,6,4]
输出:5
解释:在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。

示例 2:
输入:prices = [7,6,4,3,1]
输出:0
解释:在这种情况下, 没有交易完成, 所以最大利润为 0。

提示:

1 <= prices.length <= 10^5
0 <= prices[i] <= 10^4

【思路】

本道题最直观的方法,就是暴力,找最优间距了。

//暴力法
//时间复杂度:O(n^2)
//空间复杂度:O(1)
class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int result = 0;
        for (int i = 0; i < prices.size(); i++) {
            for (int j = i + 1; j < prices.size(); j++){
                result = max(result, prices[j] - prices[i]);
            }
        }
        return result;
    }
};

方法二:贪心法

因为股票就买卖一次,那么贪心的想法很自然就是取最左最小值,取最右最大值,那么得到的差值就是最大利润。

//时间复杂度:O(n)
//空间复杂度:O(1)
//时间复杂度:O(n)
//空间复杂度:O(1)
class Solution{
public:
    int maxProfit(vector<int>& prices){
        int low = INT_MAX;
        int result = 0;
        for(int i = 0; i < prices.size(); i++){
            low = min(low, prices[i]);//取最左最小价格
            result = max(result, prices[i] - low);//直接取最大区间利润
        }
        return result;
    }
};

方法三:动态规划

  1. 确定dp数组以及下标的含义

    dp[i] [0]表示第i天持有股票所得现金 ,其实一开始现金是0,那么加入第i天买入股票现金就是 -prices[i], 这是一个负数。dp[i] [1] 表示第i天不持有股票所得现金

    注意这里说的是“持有”,“持有”不代表就是当天“买入”!也有可能是昨天就买入了,今天保持持有的状态

  2. 确定递推公式

    如果第i天持有股票即dp[i] [0], 那么可以由两个状态推出来

    • **第i-1天就持有股票,那么就保持现状,**所得现金就是昨天持有股票的所得现金 即:dp[i - 1] [0]
    • **第i天买入股票,**所得现金就是买入今天的股票后所得现金即:-prices[i]

    那么dp[i] [0]应该选所得现金最大的,所以dp[i] [0] = max(dp[i - 1] [0], -prices[i]);

    如果第i天不持有股票即dp[i] [1], 也可以由两个状态推出来

    • 第i-1天就不持有股票,那么就保持现状,所得现金就是昨天不持有股票的所得现金 即:dp[i - 1] [1]
    • 第i天卖出股票,所得现金就是按照今天股票佳价格卖出后所得现金即:prices[i] + dp[i - 1] [0]

    同样dp[i] [1]取最大的,dp[i] [1] = max(dp[i - 1] [1], prices[i] + dp[i - 1] [0]);

    这样递归公式我们就分析完了

  3. dp数组初始化

    由递推公式 dp[i] [0] = max(dp[i - 1] [0], -prices[i]); 和 dp[i] [1] = max(dp[i - 1] [1], prices[i] + dp[i - 1] [0]);可以看出

    其基础都是要从dp[0] [0]和dp[0] [1]推导出来。

    那么dp[0] [0]表示第0天持有股票,此时的持有股票就一定是买入股票了,因为不可能有前一天推出来,所以dp[0] [0] -= prices[0];

    dp[0] [1]表示第0天不持有股票,不持有股票那么现金就是0,所以dp[0] [1] = 0;

  4. 确定遍历顺序

    从递推公式可以看出dp[i]都是由dp[i-1]推导出来的,那么一定是从前向后遍历。

  5. 举例推导dp数组

    输入:[7,1,5,3,6,4],dp数组状态如下:

图片

因为本题中不持有股票状态所得金钱一定比持有股票状态得到的多!

//时间复杂度:O(n)
//空间复杂度:O(1)
class Solution {
public:
    int maxProfit(vector<int>& prices) {
        //为什么要先知道prices有多长?
        int len = prices.size();
        //初始化数组
        vector<vector<int>> dp(len, vector<int>(2));
        //第0天持有股票-》我买股票了
        dp[0][0] = -prices[0];
        //第0填不持有股票-》我什么股票都不买
        dp[0][1] = 0;
        //为什么len前没有等号,是因为如果有等号,则数组越界,因为数组下标是从0开始到len-1
        for(int i = 1; i < len; i++){
            //第i天持有股票:有两种可能:要么前一天就有,要么当前先买
            dp[i][0] = max(dp[i-1][0], -prices[i] );
            第i天不持有股票:有两种可能:要么前一天不持有股票,要么第i天就把股票全卖了
            dp[i][1] = max(dp[i-1][1], dp[i-1][0] + prices[i]);
        }
        return dp[len-1][1];
    }
};

leetcode122:买卖股票的最佳时机II-》多次交易

给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。

注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

示例 1:
输入: [7,1,5,3,6,4]
输出: 7
解释: 在第 2 天(股票价格 = 1)的时候买入,在第 3 天(股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4。随后,在第 4 天(股票价格 = 3)的时候买入,在第 5 天(股票价格 = 6)的时候卖出, 这笔交易所能获得利润 = 6-3 = 3 。

示例 2:
输入: [1,2,3,4,5]
输出: 4
解释: 在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。注意你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。

示例 3:
输入: [7,6,4,3,1]
输出: 0
解释: 在这种情况下, 没有交易完成, 所以最大利润为 0。

提示:

1 <= prices.length <= 3 * 10 ^ 4
0 <= prices[i] <= 10 ^ 4

【思路】

本题和121. 买卖股票的最佳时机的唯一区别本题股票可以买卖多次了(注意只有一只股票,所以再次购买前要出售掉之前的股票

这里重申一下dp数组的含义:

  • dp[i] [0] 表示第i天持有股票所得现金
  • dp[i] [1] 表示第i天不持有股票所得最多现金

如果第i天持有股票即dp[i] [0], 那么可以由两个状态推出来

  • 第i-1天就持有股票,那么就保持现状,所得现金就是昨天持有股票的所得现金 即**:dp[i - 1] [0]**
  • 第i天买入股票,所得现金就是昨天不持有股票的所得现金减去 今天的股票价格 即:dp[i - 1] [1] - prices[i]

121题,因为股票全程只能买卖一次,所以如果买入股票,那么第i天持有股票即dp[i] [0]一定就是 -prices[i]

而本题,因为一只股票可以买卖多次,所以当第i天买入股票的时候,所持有的现金可能有之前买卖过的利润。

那么第i天持有股票即dp[i] [0],如果是第i天买入股票,所得现金就是昨天不持有股票的所得现金 减去 今天的股票价格 即:dp[i - 1] [1] - prices[i]。

如果**第i天不持有股票即dp[i] [1]**的情况, 依然可以由两个状态推出来

  • 第i-1天就不持有股票,那么就保持现状,所得现金就是昨天不持有股票的所得现金 即:dp[i - 1] [1]
  • 第i天卖出股票,所得现金就是按照今天股票佳价格卖出后所得现金即:prices[i] + dp[i - 1] [0]
//时间复杂度:O(n)
//空间复杂度:O(n)
class Solution {
public:
    int maxProfit(vector<int>& prices) {
        //初始化数组
        int row = prices.size() ;
        vector<vector<int>> dp(row, vector<int>(2));
        //注意dp数组的含义:dp[0][0]第一天持有股票的现金数
        dp[0][0] = -prices[0];
        //dp[0][1]第一天不持有股票的现金数,没有买所以现金不变
        dp[0][1] = 0;
        //
        for(int i = 1; i < row; i++){
            //当天持有股票的现金,情况一:昨天就持有;情况二:昨天没有当天现买股票
            dp[i][0] = max(dp[i-1][0], dp[i-1][1] - prices[i]);
            //当天不持有股票的现金,情况一:昨天不持有;情况二:昨天持有当天现卖股票
            dp[i][1] = max(dp[i-1][1], dp[i-1][0] + prices[i]);
        }
        return dp[row-1][1];
    }
};

这正是因为本题的股票可以买卖多次! 所以买入股票的时候,可能会有之前买卖的利润即:dp[i - 1] [1],所以dp[i - 1] [1] - prices[i]。

想到到这一点,对这两道题理解的比较深刻了。

//空间复杂度:O(1)
//时间复杂度:O(n)
// 版本二
class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int len = prices.size();
        vector<vector<int>> dp(2, vector<int>(2)); // 注意这里只开辟了一个2 * 2大小的二维数组
        dp[0][0] -= prices[0];
        dp[0][1] = 0;
        for (int i = 1; i < len; i++) {
            dp[i % 2][0] = max(dp[(i - 1) % 2][0], dp[(i - 1) % 2][1] - prices[i]);
            dp[i % 2][1] = max(dp[(i - 1) % 2][1], prices[i] + dp[(i - 1) % 2][0]);
        }
        return dp[(len - 1) % 2][1];
    }
};

leetcode123:买卖股票的最佳时机III-》只能交易两次

给定一个数组,它的第 i 个元素是一支给定的股票在第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你最多可以完成 两笔 交易。

注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

示例 1:
输入:prices = [3,3,5,0,0,3,1,4]
输出:6
解释:在第 4 天(股票价格 = 0)的时候买入,在第 6 天(股票价格 = 3)的时候卖出,这笔交易所能获得利润 = 3-0 = 3 。随后,在第 7 天(股票价格 = 1)的时候买入,在第 8 天 (股票价格 = 4)的时候卖出,这笔交易所能获得利润 = 4-1 = 3。

示例 2:
输入:prices = [1,2,3,4,5]
输出:4
解释:在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4。注意你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。

示例 3:
输入:prices = [7,6,4,3,1]
输出:0
解释:在这个情况下, 没有交易完成, 所以最大利润为0。

示例 4:
输入:prices = [1]
输出:0

提示:

1 <= prices.length <= 10^5
0 <= prices[i] <= 10^5

【思路】

至多买卖两次,这意味着可以买卖一次,可以买卖两次,也可以不买卖。

动态规划五步曲:

  1. 确定dp数组以及下标的含义

    一天一共就有五个状态, 0. 没有操作

    • 第一次买入
    • 第一次卖出
    • 第二次买入
    • 第二次卖出

    dp[i] [j]中 i 表示第i天,j为 [0 - 4] 五个状态****,dp[i] [j]表示第i天状态j所剩最大现金

  2. 确定递推公式

    dp[i] [1]: 表示的是第i天,买入股票的状态,并不是说一定要第i天买入股票,这是很多同学容易陷入的误区

    情况一:第i天就已经购买股票了,dp[i] [1] = dp[i-1] [0] - prices[i]

    情况二:第i天没有操作,而是沿用前一天买入的状态,即:dp[i] [1] = dp[i - 1] [1]

    dp[i] [1] = max(dp[i-1] [0] - prices[i], dp[i - 1] [1]);

    情况一:第i天卖出股票了,那么dp[i][2] = dp[i - 1] [1] + prices[i]

    情况二:第i天没有操作,沿用前一天卖出股票的状态,即:dp[i] [2] = dp[i - 1] [2]

    dp[i] [2] = max(dp[i-1] [1] - prices[i], dp[i - 1] [2]);

    dp[i] [3] = max( dp[i - 1] [2] - prices[i],dp[i - 1] [3] ); --》第二次买入

    dp[i] [4] = max( dp[i - 1] [3] + prices[i],dp[i - 1] [4] ); --》第二次卖出

  3. dp数组初始化

    第0天没有操作,这个最容易想到,就是0,即:dp[0] [0] = 0;

    第0天做第一次买入的操作,dp[0] [1] = -prices[0];

    首先卖出的操作一定是收获利润,整个股票买卖最差情况也就是没有盈利即全程无操作现金为0,所以dp[0] [2] = 0;不用管第几次,现在手头上没有现金,只要买入,现金就做相应的减少。dp[0] [3] = -prices[0];同理第二次卖出初始化dp[0] [4] = 0;

  4. 确定遍历顺序

    从递归公式其实已经可以看出,一定是从前向后遍历,因为dp[i],依靠dp[i - 1]的数值。

  5. 推导dp数组

    输入[1,2,3,4,5]为例

    图片

现在最大的时候一定是卖出的状态,而两次卖出的状态现金最大一定是最后一次卖出。

所以最终最大利润是dp[4] [4]

提示:

1 <= prices.length <= 10^5
0 <= prices[i] <= 10^5

如果不给提示需要考虑异常处理的情况。

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        //初始化
        int len = prices.size();
        vector<vector<int>> dp(len, vector<int>(5,0));
        dp[0][1] = -prices[0];
        dp[0][3] = -prices[0];

        for(int i = 1; i < len; i++){
            //状态0:无操作,与前一天的现金数相同
            dp[i][0] = dp[i-1][0];
        //    cout<<dp[i][0]<<" ";
            //状态1:第一次持有股票现金:情况一:前一天就持有股票;情况二:当天先买股票
            dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i]);
         //   cout<<dp[i][1]<<" ";
            //状态2:第一次不持有股票现金:情况一:前一天就不持有股票;情况二:当前把股票全部卖掉
            dp[i][2] = max(dp[i-1][2], dp[i-1][1] + prices[i]);
          //  cout<<dp[i][2]<<" ";
            //状态3:第二次持有股票现金:情况一:前一天持有股票现金;情况二:第一次把所有股票卖掉后当天购入的股票
            dp[i][3] = max(dp[i-1][3], dp[i-1][2] - prices[i]);
          //  cout<<dp[i][3]<<" ";
            //状态4:第二次不持有股票现金:情况一:前一天不持有股票的现金;情况二:当天卖掉所有股票获得的现金
            dp[i][4] = max(dp[i-1][4], dp[i-1][3] + prices[i]);
          //  cout<<dp[i][4]<<endl;
        }
        return dp[len-1][4];
    }
};

leetcode188:买卖股票的最佳时机IV-》只能交易k次

给定一个整数数组 prices ,它的第 i 个元素 prices[i] 是一支给定的股票在第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你最多可以完成 k 笔交易。

注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

示例 1:
输入:k = 2, prices = [2,4,1]
输出:2
解释:在第 1 天 (股票价格 = 2) 的时候买入,在第 2 天 (股票价格 = 4) 的时候卖出,这笔交易所能获得利润 = 4-2 = 2。

示例 2:
输入:k = 2, prices = [3,2,6,5,0,3]
输出:7
解释:在第 2 天 (股票价格 = 2) 的时候买入,在第 3 天 (股票价格 = 6) 的时候卖出, 这笔交易所能获得利润 = 6-2 = 4。随后,在第 5 天 (股票价格 = 0) 的时候买入,在第 6 天 (股票价格 = 3) 的时候卖出, 这笔交易所能获得利润 = 3-0 = 3 。

提示:

0 <= k <= 100
0 <= prices.length <= 1000
0 <= prices[i] <= 1000

【思路】

动规五部曲:

  1. 确定dp数组以及下标的含义

    定义了一个二维dp数组,本题其实依然可以用一个二维dp数组。

    使用二维数组 dp[i][j] :第i天的状态为j,所剩下的最大现金是dp[i][j]

    j的状态表示为:

    • 0 表示不操作
    • 1 第一次买入
    • 2 第一次卖出
    • 3 第二次买入
    • 4 第二次卖出

    大家应该发现规律了吧 ,除了0以外,偶数就是卖出,奇数就是买入

    题目要求是至多有K笔交易,那么j的范围就定义为 2 * k + 1 就可以了。

  2. 确定递推公式

    dp[i] [1],表示的是第i天,买入股票的状态,并不是说一定要第i天买入股票

    达到dp[i] [1]状态,有两个具体操作:

    • 操作一:第i天买入股票了,那么dp[i] [1] = dp[i - 1] [0] - prices[i]
    • 操作二:第i天没有操作,而是沿用前一天买入的状态,即:dp[i] [1] = dp[i - 1] [1]

    选最大的,所以 dp[i] [1] = max(dp[i - 1] [0] - prices[i], dp[i - 1] [0]);

    同理dp[i] [2]也有两个操作:

    • 操作一:第i天卖出股票了,那么dp[i] [2] = dp[i - 1] [1] + prices[i]
    • 操作二:第i天没有操作,沿用前一天卖出股票的状态,即:dp[i] [2] = dp[i - 1] [2]

    所以dp[i] [2] = max(dp[i - 1] [i] + prices[i], dp[i] [2])

    同理可以类比剩下的状态,代码如下:

    for (int j = 0; j < 2 * k - 1; j += 2) { 
        dp[i][j + 1] = max(dp[i - 1][j + 1], dp[i - 1][j] - prices[i]);
        dp[i][j + 2] = max(dp[i - 1][j + 2], dp[i - 1][j + 1] + prices[i]);
    }
    
  3. dp数组的初始化

    第0天没有操作,这个最容易想到,就是0,即:dp[0] [0] = 0;

    第0天做第一次买入的操作,dp[0] [1] = -prices[0];

    第0天做第一次卖出的操作,这个初始值应该是多少呢?

    首先卖出的操作一定是收获利润,整个股票买卖最差情况也就是没有盈利即全程无操作现金为0,

    从递推公式中可以看出每次是取最大值,那么既然是收获利润如果比0还小了就没有必要收获这个利润了。

    所以dp[0] [2] = 0;

    第0天第二次买入操作,初始值应该是多少呢?

    不用管第几次,现在手头上没有现金,只要买入,现金就做相应的减少。

    第二次买入操作,初始化为:dp[0] [3] = -prices[0];

    所以同理可以推出dp[0] [j]当j为奇数的时候都初始化为 -prices[0]

    for (int j = 1; j < 2 * k; j += 2) {
        dp[0][j] = -prices[0];
    }
    

    在初始化的地方同样要类比j为偶数是买、奇数是卖的状态

  4. 确定遍历顺序

    从递归公式其实已经可以看出,一定是从前向后遍历,因为dp[i],依靠dp[i - 1]的数值。

  5. 举例推导dp数组

    以输入[1,2,3,4,5],k=2为例。

    图片

最后一次卖出,一定是利润最大的,dp[prices.size() - 1] [2 * k]即红色部分就是最后求解。

class Solution {
public:
    int maxProfit(int k, vector<int>& prices) {
		//异常处理-》提示说明prices可能为空
        if (prices.size() == 0) return 0;
        //初始化赋值
        vector<vector<int>> dp(prices.size(), vector<int>(2 * k + 1, 0));
        for (int j = 1; j < 2 * k; j += 2) {
            dp[0][j] = -prices[0];
        }
        
        for (int i = 1;i < prices.size(); i++) {
            for (int j = 0; j < 2 * k - 1; j += 2) { 
                //奇数买股票,偶数卖股票
                dp[i][j + 1] = max(dp[i - 1][j + 1], dp[i - 1][j] - prices[i]);
                dp[i][j + 2] = max(dp[i - 1][j + 2], dp[i - 1][j + 1] + prices[i]);
            }
        }
        return dp[prices.size() - 1][2 * k];
    }
};

leetcode309:最佳买卖股票时机-》含冷冻期

给定一个整数数组,其中第 i 个元素代表了第 i 天的股票价格 。

设计一个算法计算出最大利润。在满足以下约束条件下,你可以尽可能地完成更多的交易(多次买卖一支股票):

你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
卖出股票后,你无法在第二天买入股票 (即冷冻期为 1 天)。
示例:
输入: [1,2,3,0,2]
输出: 3
解释: 对应的交易状态为: [买入, 卖出, 冷冻期, 买入, 卖出]

【思路】

那么本题则需要第三个状态:不持有股票(冷冻期)的最多现金

动规五部曲,分析如下:

  1. 确定dp数组以及下标的含义

dp[i] [j],第i天状态为j,所剩的最多现金为dp[i] [j]。

j的状态为:

  • 0:持有股票后的最多现金
  • 1:不持有股票(能购买)的最多现金
  • 2:不持有股票(冷冻期)的最多现金
  1. 确定递推公式

达到**持有股票dp[1] [0]**状态,有两个具体操作:

  • 操作一:前一天就是持有股票状态,dp[i] [0] = dp[i - 1] [0]
  • 操作二:今天买入了,dp[i] [0] = dp[i - 1] [1] - prices[i]

dp[i] [0] = max(dp[i - 1] [0], dp[i - 1] [1] - prices[i]);

达到不持有股票(能购买)的最多现金dp[i] [1] 状态,有两个操作:

  • 操作一**:前一天卖出了股票在冷冻期,即:dp[i] [1] = dp[i - 1] [2]**
  • 操作二:前一天就是不持有股票(能购买)的状态,即:dp[i] [1] = dp[i - 1] [1]

dp[i] [1] = max(dp[i - 1] [1], dp[i - 1] [2]);

达到不持有股票(冷冻期)的最多现金dp[i] [2]状态,只有一个操作,即:前一天卖出股票

dp[i] [2] = dp[i - 1] [0] + prices[i];

把这三种情况分析清楚了,这道题目就理解的差不多了

  1. dp数组如何初始化

持有股票,dp[0] [0] = -prices[0],买入股票所省现金为负数。

不持有股票(能购买)的最多现金dp[0] [1] = 0,还没有买卖,现金为0

不持有股票(冷冻期)的最多现金dp[0] [2],这个状态其实是依赖前一个状态的买卖,但没有之前了。

那么来看看递推公式对dp[0] [2]的要求。

从递推公式**dp[i] [2] = dp[i - 1] [0] + prices[i];**可以看出,dp[0] [2]初始为任何数值都可以,反正dp[1] [2] 要从新计算,不依赖dp[0] [2]。

从递归公式dp[i] [1] = max(dp[i - 1] [1], dp[i - 1] [2]); 可以看出,dp[0] [2]只要别大于0就行,因为dp[0] [1]为0,而dp[1] [1]取 dp[0] [1]和dp[0] [2]最大值,dp[1] [1]本应该是0的(第二天不持有股票(能购买)的最多现金),如果dp[0] [2]初始为大于0的数值,就影响了dp[1] [1]。

所以理论上来说,dp[0] [2]只要初始为小于等于0的任何一个数值都可以!

那么我们就统一都初始为0了。

代码如下:

vector<vector<int>> dp(n, vector<int>(3, 0));

初始化其实很有讲究,很多同学可能是稀里糊涂的全都初始化0,反正就可以通过,但没有想清楚,为什么都初始化为0

  1. 确定遍历顺序

    从递归公式上可以看出,dp[i] 依赖于 dp[i-1],所以是从前向后遍历。

  2. 举例推导dp数组

图片

最后两个状态 不持有股票(能购买) 和 不持有股票(冷冻期)都有可能最后结果,取最大的。


//时间复杂度:O(n)
//空间复杂度:O(1)
class Solution {
public:
    int maxProfit(vector<int>& prices) {
        //求出prices的size大小
        int n = prices.size();

        //异常处理,如果prices为空矩阵,则异常退出。
        if(n == 0)
            return 0;
        
        //初始化数组
        vector<vector<int>> dp(n, vector<int>(3, 0));
        //第一天持有股票的现金
        dp[0][0] = -prices[0];
        
        for(int i = 1; i < n; i++){
            //第i天持有股票的现金数:第一种情况:第i-1天持有股票的现金数,第二种情况:第i-1天不持有股票第i天购买股票
            dp[i][0] = max(dp[i-1][0], dp[i-1][1] - prices[i]);

            //第i天卖股票处于冷冻期的现金
            //dp[i][2] = dp[i-1][0] + prices[i];---》这是错误的,处理顺序不能放在前面因为第一次处理时

            //第i天不持有股票的现金数,第一种情况:第i天不持有股票的现金数,第二种情况:第i-2天的滞留处于冷冻期的现金数
            dp[i][1] = max(dp[i-1][1], dp[i-1][2]);//只有先[1]再[2]得到的才是前一天的处于冷冻期现金的金额,如果顺序改变则会出错

            //第i天卖股票处于冷冻期的现金
            dp[i][2] = dp[i-1][0] + prices[i];
        }
        //第n天的不需要冷冻期的现金与第n天需要处理冷冻期的现金之间选择最多的现金
        return max(dp[n-1][1], dp[n-1][2]);
    }
};

leetcode714:买卖股票的最佳时机-》多次交易且含手续费

给定一个整数数组 prices,其中第 i 个元素代表了第 i 天的股票价格 ;非负整数 fee 代表了交易股票的手续费用。

你可以无限次地完成交易,但是你每笔交易都需要付手续费。如果你已经购买了一个股票,在卖出它之前你就不能再继续购买股票了。

返回获得利润的最大值。

注意:这里的一笔交易指买入持有并卖出股票的整个过程,每笔交易你只需要为支付一次手续费。

示例 1: 输入: prices = [1, 3, 2, 8, 4, 9], fee = 2 输出: 8

解释: 能够达到的最大利润:
在此处买入 prices[0] = 1
在此处卖出 prices[3] = 8
在此处买入 prices[4] = 4
在此处卖出 prices[5] = 9
总利润: ((8 - 1) - 2) + ((9 - 4) - 2) = 8.

注意:

0 < prices.length <= 50000.
0 < prices[i] < 50000.
0 <= fee < 50000.

dp[i] [0] 表示第i天持有股票所省最多现金。dp[i][1] 表示第i天不持有股票所得最多现金

如果第i天持有股票即dp[i][0], 那么可以由两个状态推出来

  • 第i-1天就持有股票,那么就保持现状,所得现金就是昨天持有股票的所得现金 即:dp[i - 1] [0]
  • 第i天买入股票,所得现金就是昨天不持有股票的所得现金减去 今天的股票价格 即:dp[i - 1] [1] - prices[i]

所以:dp[i] [0] = max(dp[i - 1] [0], dp[i - 1] [1] - prices[i]);

在来看看如果第i天不持有股票即dp[i][1]的情况, 依然可以由两个状态推出来

  • 第i-1天就不持有股票,那么就保持现状,所得现金就是昨天不持有股票的所得现金 即:dp[i - 1] [1]
  • 第i天卖出股票,所得现金就是按照今天股票价格卖出后所得现金,注意这里需要有手续费了即:dp[i - 1] [0] + prices[i] - fee

所以:dp[i] [1] = max(dp[i - 1] [1], dp[i - 1] [0] + prices[i] - fee);

class Solution {
public:
    int maxProfit(vector<int>& prices, int fee) {
        //初始化数组
        int len = prices.size();
        vector<vector<int>> dp(len, vector<int>(2,0));
        //持有股票的现金
        dp[0][0] = -prices[0];
        
        for(int i = 1; i < len; i++){
            //第i天持有股票的现金:第一种情况昨天就持有股票,第二种情况:昨天不持有股票,今天新购买
            dp[i][0] = max(dp[i-1][0], dp[i-1][1] - prices[i]);
            
            //第i天不持有股票的现金:第一种情况昨天就不持有股票;第二种情况:昨天持有股票今天给卖了所以不持有股票
            dp[i][1] = max(dp[i-1][1], dp[i-1][0] + prices[i] - fee);
        }
        return max(dp[len-1][0], dp[len-1][1]);
    }
};

最长递增(上升)子序列

leetcode300:最长上升子序列-》判断后加入递推公式

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

示例 1:
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。

示例 2:
输入:nums = [0,1,0,3,2,3]
输出:4

示例 3:
输入:nums = [7,7,7,7,7,7,7]
输出:1
  提示:

1 <= nums.length <= 2500
-10^4 <= nums[i] <= 10^4

[思路]

dp[i]是可以根据dp[j] (j < i)推导出来的,那么依然用动规五部曲来分析详细一波:

  1. dp[i]的定义

    dp[i]表示i之前包括i的最长上升子序列

  2. 状态转移方程

    位置i的最长升序子序列等于j从0到i-1各个位置的最长升序子序列 + 1 的最大值。

    所以:if (nums[i] > nums[j]) dp[i] = max(dp[i], dp[j] + 1);

    注意这里不是要dp[i] 与 dp[j] + 1进行比较,而是我们要取dp[j] + 1的最大值

  3. dp[i]的初始化

    每一个i,对应的dp[i](即最长上升子序列)起始大小至少都是是1.

  4. 确定遍历顺序

    dp[i] 是有0到i-1各个位置的最长升序子序列 推导而来**,那么遍历i一定是从前向后遍历。**

    j其实就是0到i-1,遍历i的循环里外层,遍历j则在内层,代码如下:

    for (int i = 1; i < nums.size(); i++) {
        for (int j = 0; j < i; j++) {
            if (nums[i] > nums[j]) 
                //dp[i]表示位置i之前包括i的最长上升子序列个数
                //为什么dp[i]可能等于dp[i]是因为同一个i值可能对应的j不同所以值不同,dp[i]选取最大的值。
                dp[i] = max(dp[i], dp[j] + 1);
        }
        if (dp[i] > result) 
            result = dp[i]; // 取长的子序列
    }
    
  5. 举例推导dp数组

    输入:[0,1,0,3,2],dp数组的变化如下:

    图片

class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        //初始化
        int len = nums.size();
        //将数组初始化为1因为对于自身来说每个值对应的上升子序列都是1
        vector<int> dp(len, 1);
        /*
        为什么要引入result,这是根据dp的定义而定的,dp[i]是只从0到i的最长子序列的个数,这个数组并不是递增排列的,对于数据[1,3,6,7,9,4,10,5,6]
        来说当i等于7时最长子序列时是与nums[i]对比所以是比5小的最长子序列是4个。所以dp[7]是4,如果不加result的话返回的就是错误的,因为dp[6]=6;
        */
        //用于记录最长的子序列
        int result = 0;
        //第一层循环遍历数组
        for(int i = 1; i < len; i++){
            //第二层循环寻找比nums[i]小的值
            for(int j = 0; j < i; j++)
                if(nums[i] > nums[j])
                    //nums[i] > nums[j]所以dp可能等于dp[j] + 1这个1是第numa[i]自身
                    dp[i] = max(dp[i], dp[j] + 1);
                    //cout<< "dp[i]="<<dp[i]<<" ";
            }
           if(dp[i] > result) 
                result = dp[i];//取最长的子序列
        }
        return result;
    }
};

leetcode674:最长连续递增序列->连续

给定一个未经排序的整数数组,找到最长且 连续递增的子序列,并返回该序列的长度。

连续递增的子序列 可以由两个下标 l 和 r(l < r)确定,如果对于每个 l <= i < r,都有 nums[i] < nums[i + 1] ,那么子序列 [nums[l], nums[l + 1], ..., nums[r - 1], nums[r]] 就是连续递增子序列。

示例 1:
输入:nums = [1,3,5,4,7]
输出:3
解释:最长连续递增序列是 [1,3,5], 长度为3。
尽管 [1,3,5,7] 也是升序的子序列, 但它不是连续的,因为 5 和 7 在原数组里被 4 隔开。

示例 2:
输入:nums = [2,2,2,2,2]
输出:1
解释:最长连续递增序列是 [2], 长度为1。
  提示:

0 <= nums.length <= 10^4
-10^9 <= nums[i] <= 10^9

【思路】

本题要求的是最长连续递增序列

动规五部曲分析如下:

  1. 确定dp数组(dp table)以及下标的含义

    dp[i]:以下标i为结尾的数组的连续递增的子序列长度为dp[i]

    注意这里的定义,一定是以下标i为结尾,并不是说一定以下标0为起始位置。

  2. 确定递推公式

    如果 nums[i + 1] > nums[i],那么以 i+1 为结尾的数组的连续递增的子序列长度 一定等于 以i为结尾的数组的连续递增的子序列长度 + 1 。

    即:dp[i + 1] = dp[i] + 1;

    注意这里就体现出和动态规划:300.最长递增子序列的区别!

    因为本题要求连续递增子序列,所以就必要比较nums[i + 1]与nums[i],而不用去比较nums[j]与nums[i] (j是在0到i之间遍历)

    既然不用j了,那么也不用两层for循环,本题一层for循环就行,比较nums[i + 1] 和 nums[i]。

    这里大家要好好体会一下!

  3. dp数组如何初始化

    以下标i为结尾的数组的连续递增的子序列长度最少也应该是1,即就是nums[i]这一个元素。

    所以dp[i]应该初始1;

  4. 确定遍历顺序

    从递推公式上可以看出, dp[i + 1]依赖dp[i],所以一定是从前向后遍历

  5. 举例推导dp数组

    已输入nums = [1,3,5,4,7]为例,dp数组状态如下:

    图片

//时间复杂度:O(n)
//空间复杂度:O(n)
class Solution {
public:
    int findLengthOfLCIS(vector<int>& nums) {
        /*
        异常判断,如果nums是空怎么办,不进入循环直接return 0
        */
        //初始化
        int len = nums.size();
        vector<int> dp(len, 1);
        //异常处理因为for循环内只能处理len》2的数组
        if(len == 1) return 1;
        //用于记录最长连续上升子序列
        int result = 0;
        for(int i = 1; i < len ; i++){
            //因为是判断连续的上升子序列,所以只需要与前一个值对比就可以了。
            if(nums[i] > nums[i-1]){
                dp[i] = dp[i-1] + 1;
              //  cout<<"dp[i] = "<< dp[i]<<" ";
            }
            result = dp[i] > result? dp[i]:result;
        }
        return result;
    }
};

leetcode368:最大整除子集

给你一个由 无重复 正整数组成的集合 nums ,请你找出并返回其中最大的整除子集 answer ,子集中每一元素对 (answer[i], answer[j]) 都应当满足:
answer[i] % answer[j] == 0 ,或
answer[j] % answer[i] == 0
如果存在多个有效解子集,返回其中任何一个均可。

示例 1:

输入:nums = [1,2,3]
输出:[1,2]
解释:[1,3] 也会被视为正确答案。
示例 2:

输入:nums = [1,2,4,8]
输出:[1,2,4,8]

提示:

1 <= nums.length <= 1000
1 <= nums[i] <= 2 * 109
nums 中的所有整数 互不相同

【思路】

**状态定义:**dp[i]表示在输入数组nums升序排列的前提下,以nums[i]为最大整数的【整除子集】的大小。nums[i]必须被选择。

**状态转移方程:**枚举j=0…i-1的所有整数nums[j],如果nums[j]能整除nums[i],说明nums[i]可以扩充以nums[j]为最大整数的整除子集里成为一个更大的整除子集。

**初始化:**由于nums[i]必须被选择,因此对于任意i=0…n-1,初始的时候dp[i]=1,这里n是输入数组的长度。

**输出:**我们需要枚举所有的dp[i],选出最大整除子集的大小maxSize,以及该最大子集中的最大整数maxVal。

  1. 倒序遍历dp数组,知道找到dp[i] = maxsize为止,把此时对应的nums[i]加入结果集,此时maxVal=nums[i];
  2. 然后将maxSize的值减1,继续倒序遍历找到dp[i] = maxSize,且nums[i]能整除maxval的i为止,将此时的nums[i]加入结果集,maxVal更新为此时的nums[i]
  3. 重复以上操作,知道maxSize的值变为0,此时结果集为一个目标子集。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FoGr7SDx-1626103523396)(C:\Users\mubao\AppData\Roaming\Typora\typora-user-images\image-20210423155007379.png)]

class Solution {
public:
    vector<int> largestDivisibleSubset(vector<int>& nums) {
        int len = nums.size();
        sort(nums.begin(), nums.end());
        //第一步:动态规划找出最大子集的个数,最大子集中的最大整数
        //dp[i]:表示在输入数组nums升序排列的前提下,以nums[i]为最大整数的整除子集的大小 ,nums[i]必须被选择
        vector<int> dp(len, 1);//为什么初始化为1,是因为每个数字对于自身都是一个最大整数子集
        int maxSize = 1;
        int maxVal = nums[0];
        for(int i = 1; i < len; i++)
        {
            //j只能是i前面的,
            for(int j = 0; j < i; j++)
            {
                if(nums[i] % nums[j] == 0)
                {
                    dp[i] = max(dp[i], dp[j] + 1);
                }
            }
            if(dp[i] > maxSize)
            {
                maxSize = dp[i];
                maxVal = nums[i];
            }
        }
        //第二步:倒推获得最大子集
        vector<int> res;
        if(maxSize == 1)
        {
            res.push_back(nums[0]);
            return res;
        }
        for(int i = len - 1; i >= 0 && maxSize > 0; i--)
        {
            //[2 4 7 8 9 12 16 20]下面代码就把12给排除了
            if(dp[i] == maxSize && maxVal % nums[i] == 0)
            {
                res.push_back(nums[i]);
                maxVal = nums[i];
                maxSize--;
            }
        }
        return res;
    }
};

leetcode718:最长重复子数组

给两个整数数组 A 和 B ,返回两个数组中公共的、长度最长的子数组的长度。

示例:

输入:
A: [1,2,3,2,1]
B: [3,2,1,4,7]
输出:3
解释:
长度最长的公共子数组是 [3, 2, 1] 。
  提示:

1 <= len(A), len(B) <= 1000
0 <= A[i], B[i] < 100

【思路】

注意题目中说的子数组,其实就是连续子序列。这种问题动规最拿手,动规五部曲分析如下:

  1. 确定dp数组(dp table)以及下标的含义

    dp[i] [j] :以下标i - 1为结尾的A,和以下标j - 1为结尾的B,最长重复子数组长度为dp[i] [j]

    此时细心的同学应该发现,那dp[0] [0]是什么含义呢?总不能是以下标-1为结尾的A数组吧。

    其实dp[i] [j]的定义也就决定着,我们在遍历dp[i] [j]的时候i 和 j都要从1开始。

    那有同学问了,我就定义dp[i] [j]为 以下标i为结尾的A,和以下标j 为结尾的B,最长重复子数组长度。不行么?

    行倒是行!但实现起来就麻烦了一些,大家看下面的dp数组状态图就明白了。

  2. 确定递推公式

    根据dp[i] [j]的定义,dp[i] [j]的状态只能由dp[i - 1] [j - 1]推导出来。

    即当A[i - 1] 和B[j - 1]相等的时候,dp[i] [j] = dp[i - 1] [j - 1] + 1;

    根据递推公式可以看出,遍历i 和 j 要从1开始!

  3. dp数组如何初始化

    根据dp[i] [j]的定义,dp[i] [0] 和dp[0] [j]其实都是没有意义的!

    但dp[i] [0] 和dp[0] [j]要初始值,因为 为了方便递归公式dp[i] [j] = dp[i - 1] [j - 1] + 1;

    所以dp[i] [0] 和dp[0] [j]初始化为0。

    举个例子A[0]如果和B[0]相同的话,**dp[1] [1] = dp[0] [0] + 1,只有dp[0] [0]初始为0,**正好符合递推公式逐步累加起来。

  4. 确定遍历顺序

    外层for循环遍历A,内层for循环遍历B。

    那又有同学问了,外层for循环遍历B,内层for循环遍历A。不行么?

    也行,一样的,我这里就用外层for循环遍历A,内层for循环遍历B了。

    同时题目要求长度最长的子数组的长度。所以在遍历的时候顺便把dp[i] [j]的最大值记录下来。

  5. 举例推导dp数组

    拿示例1中,A: [1,2,3,2,1],B: [3,2,1,4,7]为例,画一个dp数组的状态变化,如下:

    图片

//时间复杂度O(n*m)n为A长度,m为B长度
//空间复杂度:O(n*m)
class Solution {
public:
    int findLength(vector<int>& A, vector<int>& B) {
        //初始化,将没有意义的数值初始化为0
        //dp数组的含义:以i-1为结尾的A矩阵与以j-1为结尾的B矩阵的共同子序列的长度
        vector<vector<int>> dp(A.size()+1, vector<int>(B.size()+1, 0));
        //用于记录最长的子序列
        int result = 0;

        //外循环遍历A内循环遍历B
        //为什么时等号是因为下标时从1~A.size,如过没有等号则需要是0~A.size()-1;
        for(int i = 1; i <= A.size(); i++){
            for(int j = 1; j <= B.size(); j++){
                //出现共同子序列时
                //为什么是-1是因为下标是从1开始取,而A与B矩阵应该是从0开始取
                if(A[i-1] == B[j-1])
                    //前一个加上自身
                    dp[i][j] = dp[i-1][j-1] + 1;
                //cout<<"dp["<<i<<"]["<<j<<"]"<<dp[i][j]<<" ";
                result = dp[i][j] > result? dp[i][j]:result;
            }
        }
        return result;
    }
};

leetcode14:最长公共前缀(非dp)

【题目】

编写一个函数来查找字符串数组中的最长公共前缀。

如果不存在公共前缀,返回空字符串 ""。

 

示例 1:

输入:strs = ["flower","flow","flight"]
输出:"fl"
示例 2:

输入:strs = ["dog","racecar","car"]
输出:""
解释:输入不存在公共前缀。
 

提示:

0 <= strs.length <= 200
0 <= strs[i].length <= 200
strs[i] 仅由小写英文字母组成

【方法一:纵向扫描】

依次遍历每个字符串,更新最长公共前缀。另一种方法是纵向扫描。纵向扫描时,从前往后遍历所有字符串的每一列,比较相同列上的字符是否相同,如果相同则继续对下一列进行比较,如果不相同则当前列不再属于公共前缀,当前列之前的部分为最长公共前缀。

fig2

时间复杂度:O(mn),其中m是字符串数组中的字符串的平均长度,n是字符串的数量。最坏的情况下,字符串数组中的灭个字符串的每个字符都会被比较一次。
空间复杂度:O(1),使用的额外空间复杂度为常数


class Solution {
public:
    string longestCommonPrefix(vector<string>& strs) {
        //如果输入的是“”空
        if(strs.size() == 0)
        {
            return "";
        }
        //len为第一个字符的长度,count是strs中字符的个数
        int len = strs[0].size();
        int count = strs.size();
        for(int i = 0; i < len; i++)
        {
            char c = strs[0][i];
            for(int j = 1; j < count; j++)
            {
                if(i == strs[j].size() || strs[j][i] != c)
                {
                    return strs[0].substr(0, i);
                }
            }
        }
        //如果strs是最短的,直接返会strs[0]就可以了
        return strs[0];
    }
};

【方法二:分治】

注意到LCP的计算满足结合率,有以下结论:

LCP(S1…Sn) = LCP(LCP(s1…Sk)), LCP(Sk+1…Sn)

其中LCP(S1…Sn)是字符串S1…Sn的最长公共前缀,1 <k<n。

基于上述结论,可以使用分治法得到字符串数组中的最长公共前缀。对于问题LCP(Si…Sj),可以分解成两个子问题LCP(Si…Smid)与LCP(Smid+1…Sj),其中mid = (i+j)/2。对于两个子问题分别求解,然后对两个子问题的解计算最长公共前缀,即为原问题的解。

fig3

时间夫再度:O(mn),其中m是字符串数组中的字符串的平均长度,n是字符串的数量。时间复杂度的递推式式T(n)=2*T(n/2)+O(m),通过计算可得T(n) = O(mn)。
空间复杂度:O(mlogn),其中m式字符串数组中的字符串的平均长度,n是字符串的数量。空间复杂度主要取决于递归调用的层数,层数最大为logn,每层需要m的空间存储返回结果。

class Solution {
    string longestCommonPrefix(const vector<string>& strs, int start, int end)
    {
        if(start == end)
        {
            return strs[start];
        }
        else
        {
            int mid = (start + end) / 2;
            string lcpLeft = longestCommonPrefix(strs, start, mid);
            string lcpRight = longestCommonPrefix(strs,mid+1,end);
            return commonPrefix(lcpLeft, lcpRight);
        }
    }

    string commonPrefix(const string& lcpLeft, const string& lcpRight)
    {
        int minLength = min(lcpLeft.size(), lcpRight.size());
        for(int i = 0; i < minLength; i++)
        {
            if(lcpLeft[i] != lcpRight[i])
            {
                return lcpLeft.substr(0, i);
            }
        }
        return lcpLeft.substr(0, minLength);
    }
public:
    string longestCommonPrefix(vector<string>& strs) {
        if(strs.size() == 0)
            return "";
        return longestCommonPrefix(strs, 0 , strs.size()-1);       
    }
};

最长公共子序列系列

leetcode1143:最长公共子序列

给定两个字符串 text1 和 text2,返回这两个字符串的《最长公共子序列的长度》。

一个字符串的子序列 是指这样一个新的字符串:它是由原字符串在《不改变字符的相对顺序的情况》下删除某些字符(也可以不删除任何字符)后组成的新字符串。

例如,"ace" 是 "abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。两个字符串的「公共子序列」是这两个字符串所共同拥有的子序列。

若这两个字符串没有公共子序列,则返回 0。

示例 1:

输入:text1 = "abcde", text2 = "ace"
输出:3
解释:最长公共子序列是 "ace",它的长度为 3。

示例 2:
输入:text1 = "abc", text2 = "abc"
输出:3
解释:最长公共子序列是 "abc",它的长度为 3。

示例 3:
输入:text1 = "abc", text2 = "def"
输出:0
解释:两个字符串没有公共子序列,返回 0。
提示:

1 <= text1.length <= 1000
1 <= text2.length <= 1000 输入的字符串只含有小写英文字符。

【思路】

  1. 确定dp数组(dp table)以及下标的含义

    dp[i] [j]:长度为[0, i - 1]的字符串text1与长度为[0, j - 1]的字符串text2的最长公共子序列为dp[i] [j]

    有同学会问:为什么要定义长度为[0, i - 1]的字符串text1,定义为长度为[0, i]的字符串text1不香么?

    这样定义是为了后面代码实现方便,如果非要定义为为长度为[0, i]的字符串text1也可以,大家可以试一试!

  2. 确定递推公式

    主要就是两大情况:text1[i - 1] 与 text2[j - 1]相同,text1[i - 1] 与 text2[j - 1]不相同

    如果text1[i - 1] 与 text2[j - 1]相同,那么找到了一个公共元素,所以dp[i] [j] = dp[i - 1] [j - 1] + 1;

    如果text1[i - 1] 与 text2[j - 1]不相同,那就看看text1[0, i - 2]与text2[0, j - 1]的最长公共子序列 和 text1[0, i - 1]与text2[0, j - 2]的最长公共子序列,取最大的。

    即:dp[i] [j] = max(dp[i - 1] [j], dp[i] [j - 1]);

if (text1[i - 1] == text2[j - 1]) {
    dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
    dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
  1. dp数组如何初始化

    先看看dp[i] [0]应该是多少呢?

    test1[0, i-1]和空串的最长公共子序列自然是0,所以dp[i] [0] = 0;

    理dp[0] [j]也是0。

​ 其他下标都是随着递推公式逐步覆盖,初始为多少都可以,那么就统一初始为0。

​ 代码:

vector<vector<int>> dp(text1.size() + 1, vector<int>(text2.size() + 1, 0));
  1. 确定遍历顺序

从递推公式,可以看出,有三个方向可以推出dp[i] [j],如图:

图片

那么为了在递推的过程中,这三个方向都是经过计算的数值,所以要从前向后,从上到下来遍历这个矩阵。

个矩阵。

  1. 举例推导dp数组

    以输入:text1 = “abcde”, text2 = “ace” 为例,dp状态如图:

图片

class Solution {
public:
    int longestCommonSubsequence(string text1, string text2) {
        //初始化数组
        //dp[i][j]:长度为[0,i-1]的字符串text1与长度为[0,j-1]的字符串text2的最长公共子序列个数
        vector<vector<int>> dp(text1.size()+1, vector<int>(text2.size()+1,0));
        //第一层循环遍历text1字符串,第二层循环遍历text2字符串
        for(int i = 1; i <= text1.size(); i++){
            for(int j = 1; j <= text2.size(); j++){
                //如果相等个数加1否则选取上面与左面最大的值
                if(text1[i-1] == text2[j-1])
                    dp[i][j] = dp[i-1][j-1] + 1;
                else
                    //dp[i-1][j]指的是text1[0,i-2]与text2[0,j-1]的最长子序列个数,dp[i][j-1]是text1[0,i-1]与text2[0,j-1]的最长子序列个数
                    dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
            }
        }
        return dp[text1.size()][text2.size()];
    }
};

leetcode1035:不相交的线

我们在两条独立的水平线上按给定的顺序写下 A 和 B 中的整数。

现在,我们可以绘制一些连接两个数字 A[i] 和 B[j] 的直线,只要 A[i] == B[j],且我们绘制的直线不与任何其他连线(非水平线)相交。

以这种方法绘制线条,并返回我们可以绘制的最大连线数。

图片

【思路】

如何将一个未知的题目与已知的求解方法结合一起,进行学习。

绘制一些连接两个数字 A[i] 和 B[j] 的直线,只要 A[i] == B[j],且直线不能相交!

直线不能相交,这就是说明在字符串A中 找到一个与字符串B相同的子序列,且这个子序列不能改变相对顺序,只要相对顺序不改变,链接相同数字的直线就不会相交。

图片

其实也就是说A和B的最长公共子序列是[1,4],长度为2。这个公共子序列指的是相对顺序不变(即数字4在字符串A中数字1的后面,那么数字4也应该在字符串B数字1的后面)

class Solution {
public:
    int maxUncrossedLines(vector<int>& A, vector<int>& B) {
        //初始化数组
        //col->dp矩阵的列,row-》dp矩阵的行
        int col = A.size();
        int row = B.size();
        vector<vector<int>> dp(row + 1, vector<int>(col + 1, 0));

        //为什么i要等于row这是因为dp每行每列所代表的含义决定的row从1-row代表不同的数,而不是从0开始算起,col同理
        //注意row与B对应,col与A对应
        for(int i = 1; i <= row; i++){
            for(int j = 1; j <= col; j++){
                //如果相等
                if(B[i-1] == A[j-1])
                    dp[i][j] = dp[i-1][j-1] + 1;
                //如果不相等,1 2与1 3-》1 2 与 1 和 1 与 1 3最大的个数
                else
                    dp[i][j] = max(dp[i][j-1], dp[i-1][j]);
                
              //  cout<<dp[i][j]<<"   ";
            }
        }
        return dp[row][col];
    }
};

leetcode53:最大子序和【微软】

给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

示例:
输入: [-2,1,-3,4,-1,2,1,-5,4]
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。

动态规划五步曲:

  1. 确定dp数组的下标及含义

    dp[i]:包括下标i之前的最大连续子序列和为dp[i].

  2. 确定递推公式

    dp[i]只有两个方向可以推导出来

    • dp[i-1]+nums[i],即nums[i]加入当前连续子序列和
    • nums[i],即:从头开始计算当前连续子序列和

    一定取最大的,所以dp[i]=max(dp[i-1] + nums[i], nums[i]);

  3. dp数组如何初始化

    从地推公式可以看出dp[i]是依赖于dp[i-1]的状态,dp[0]就是递推公式的基础。

    dp[0] = nums[0];

  4. 确定遍历顺序

    递推公式中dp[i]依赖于dp[i-1]的状态,需要从前向后遍历

  5. 举例推导dp数组

    输入nums = [-2,1,-3,4,-1,2,1,-5,4],对应的dp状态如下:

    图片

最后的结果

可不是dp[nums.size()-1],而是dp[6].

那么我们要找的最大的连续子序列,就应该找每一个i为终点的连续最大子序列。

所以在递推公式的时候,可以直接选出最大的dp[i]。

//空间复杂度:O(n)
//时间复杂度:O(n)
class Solution{
public:
    //确定输入与输出->输入的是一个数组,输出的是返回的最大连续子序列的和
    int maxSubArray(vector<int>& nums){
        //异常处理,因为题目要求是不可能输入空数组所以不需要写下面的已成处理
        if(nums.size() == 0)
            return 0;
        //初始化数组
        vector<int> dp(nums.size());
        dp[0] = nums[0];
        int result = dp[0];
        //循环遍历数组
        for(int i = 1; i < nums.size(); i++){
            //状态转移公式
            dp[i] = max(dp[i-1] + nums[i], nums[i]);
            result = result > dp[i]?result:dp[i];
        }
        return  result;
    }
};

【优化】

可以优化空间:如果nums数组是可以被更改的话,可以将nums数组作为dp数组

/*
时间复杂度:O(N)
空间复杂度:O(1)
*/
class Solution {
public:
    int maxSubArray(vector<int>& nums) {
       int res = nums[0];
       for(int i = 1 ; i <nums.size(); i++)
       {
           nums[i] = max(nums[i] + nums[i-1], nums[i]);
           res = max(nums[i], res);
       }
       return res;
    }
};

leetcode??:奇怪的打印机

【题目】

有台奇怪的打印机有以下两个特殊要求:

打印机每次只能打印由 同一个字符 组成的序列。
每次可以在任意起始和结束位置打印新字符,并且会覆盖掉原来已有的字符。
给你一个字符串 s ,你的任务是计算这个打印机打印它需要的最少打印次数。

示例 1:

输入:s = “aaabbb”
输出:2
解释:首先打印 “aaa” 然后打印 “bbb”。
示例 2:

输入:s = “aba”
输出:2
解释:首先打印 “aaa” 然后在第二个位置打印 “b” 覆盖掉原来的字符 ‘a’。

提示:

1 <= s.length <= 100
s 由小写英文字母组成

【思路】

  1. 确定dp数组及下标问题

    dp[i] [j]表示打印完成字符串区间[i, j]的最少操作数

  2. 确定递推公式

    • s[i] = s[j],即区间两端的字符相同,那么当我们打印左侧字符s[i]时,可以顺便打印右侧字符s[j],这样我们即可忽略右侧字符对该区间的影响,只需要考虑如何尽快打印完区间[i, j-1]即可,此时有dp[i] [j] = dp[i] [j-1]

      【我们无需关心区间[i, j-1]的具体打印方案,因为我们总可以第一步完成s[i]的打印,此时可顺便完成s[j]的打印,不会影响打印完成区间[i, j-1]的最少操作数。】

    • s[i] != s[j],即区间两端的字符不同,那么我们需要分别完成该区间的左右两部分的打印。我们记两部分分别为区间[i, k]和区间[k+1, j](其中i <= k < j),此时dp[i] [j] = min(k=i ~ j-1)dp[i] [k] + dp[k+1] [j]。

    边界条件f[i] [i] = 1,对于长度为1的区间,需要打印1次。最后的答案为f[0] [n-1]。

  3. dp数组如何初始化

    dp[i] [i]:打印自己就是需要一次,剩下的其余值可以设为INT_MAX,因为打印的是最小值,为了部影响后面的值将其设为最大值。

  4. 确定遍历顺序

    由递归公式可以得到dp[i] [j]取决于dp[i-1] [?]的值,所以i的遍历顺序时从大到小,j的遍历顺序时从小到大

class Solution 
{
public:
    int strangePrinter(string s) 
    {
        int strSize = s.size();
        if(strSize == 0)
            return 0;
        //确定dp数组
        vector<vector<int>> dp(strSize, vector<int>(strSize, INT_MAX));
        //只打印一个元素,只需要一次即可,dp数组的初始化
        for(int i = 0; i < strSize; i++)
        {
            dp[i][i] = 1;
        }

        for(int i = strSize - 1; i >= 0; i--)
        {
            for(int j = i + 1; j < strSize; j++)
            {
                //两层for循环穷举区间[i,j]
                dp[i][j] = 1 + dp[i+1][j];//i单独打印,s[i+1, j]段另外打印
                //尝试将s[i, j]分成s[i, k]和s[k+1, j]
                for(int k = i + 1; k < j; k++)
                {
                    if(s[i] == s[k])
                    {
                        //dp[i+1][k]代表将i放到[i+1, k]一起打印,dp[k+1][j]代表[k+1, j]另外打印
                        dp[i][j] = min(dp[i][j], dp[i+1][k]+dp[k+1][j]);
                    }
                }
                if(s[i] == s[j])
                {
                    //dp[i+1][j]代表将i放到[j+1, i]一起打印
                    dp[i][j] = min(dp[i][j], dp[i+1][j]);
                }
            }
        }
        return dp[0][strSize - 1];
    }
};

字符串问题

剑指Offer48:最长不含重复字符的子字符串(leetcode3)

【题目】

请从字符串中找出一个最长的不包含重复字符的子字符串,计算该最长子字符串的长度。

示例 1:

输入: “abcabcbb”
输出: 3
解释: 因为无重复字符的最长子串是 “abc”,所以其长度为 3。
示例 2:

输入: “bbbbb”
输出: 1
解释: 因为无重复字符的最长子串是 “b”,所以其长度为 1。
示例 3:

输入: “pwwkew”
输出: 3
解释: 因为无重复字符的最长子串是 “wke”,所以其长度为 3。
请注意,你的答案必须是 子串 的长度,“pwke” 是一个子序列,不是子串。

提示:

s.length <= 40000

【解题思路:】

长度为N的字符串共有(1+N)N/2个子字符串(复杂度为O(N*N)),判断长度为N的字符串是否有重复字符的复杂度为O(N),因此本题使用暴力法解决的复杂度为O(N^3).

动态规划解析:

  • **状态定义:**设动态规划数组dp[j]代表以字符s[j]为结尾的“最长不重复子字符串”的长度。

  • 转移方程:固定右边界j,设字符s[j]左边距离最近的相同字符为s[j],即s[i] = s[j]

    1. 当i < 0,即s[j]左边无相同字符,则dp[j] = dp[j-1] + 1;
    2. 当dp[j-1] < j - i,说明字符s[i]再子字符串dp[j-1]区间之外,则dp[j] = dp[j-1]+1
    3. 当dp[j-1]>=j-i,说明字符s[i]在子字符串dp[j-1]区间之中,则dp[j]的左边界由s[i]决定,即dp[j] = j - i;
    当i < 0时,由于dp[j-1] <= j 恒成立,因而dp[j-1] < j-i恒成立,因此分支1和2可被合并
    
  • **返回值:**max(dp),即全局的“最长不重复子字符串”的长度。

Picture1.png

空间复杂度降低:
	1.由于返回值是取dp列表最大值,因此可借助变量tmp存储dp[j],变量res每轮更新最大值即可。
	2.由此可节省dp数组,使用O(N)大小的额外空间
观察转移方程,可知本质问题为:每轮遍历字符s[j]时如何计算索引i?

方法一:动条规划+哈希表

  • **哈希表统计:**遍历字符串s,使用哈希表(记为dic)统计各字符最后一次出现的索引
  • **左边界i获取方式:**遍历到s[j]时,可通过访问哈希表dic[s[j]]获取最近的相同字符的索引i。

复杂度分析:

  • **时间复杂度O(N):**其中N为字符串长度,动态规划需遍历计算dp列表
  • **空间复杂度O(1)😗*字符的ASCII码范围为0~127,哈希表dic最多使用O(128)=O(1)大小的额外空间

img

class Solution {
public:
    int lengthOfLongestSubstring(string s) {
        unordered_map<char, int> dic;
        int res = 0, tmp = 0, len = s.size(), i;
        for(int j = 0; j < len; j++)
        {
            if(!dic.count(s[j]))
                i = -1;
            else
                i = dic[s[j]];
            dic[s[j]] = j;
            tmp = tmp < j - i? tmp+1 : j-i;//dp[j-1]->dp[j]-->dp[i]==>用一个变量tmp表示
            res = max(res, tmp);//max(dp[j-1], dp[j]);
        }
        return res;
    }
};

方法二:动态规划+线性遍历

  • **左边界i获取方式:**遍历到s[j]时,初始化索引i = j-1,向左遍历搜索一个满足s[i] = s[j]的字符即可

复杂度分析:

  • **时间复杂度O(N*N)😗*其中N为字符串长度,动态规划需要遍历计算dp列表,占用O(N);每轮计算dp[j]时搜索i需要遍历j个字符,占用O(N)。
  • **空间复杂度O(1)😗*几个变量使用常数大小的额外空间。
class Solution {
public:
    int lengthOfLongestSubstring(string s) {
        int res = 0, tmp = 0, len = s.size();
        for(int j = 0; j < len; j++)
        {
            int i = j-1;
            while(i >= 0 && s[i] != s[j])i--;//线性查找
            tmp = tmp < j - i? tmp+1 : j - i;
            res = max(res, tmp);
        }
        return res;
    }
};

方法三:双指针+哈希表

  • **哈希表dic统计:**指针j遍历字符s,哈希表统计字符s[j]最后一次出现的索引
  • **更新左指针i:**根据上轮做指针i和dic[s[j]],每轮更新左边界i,保证区间[i+1,j]内无重复字符且最大。i = max(dic[s[j]], i)
  • **更新结果res:**取上轮res和本轮双指针区间[i+1, j]的宽度(即j - i)中的最大值。res = max(res, j - i)

复杂度分析:

  • **时间复杂度O(N)😗*其中N为字符串长度,动条规划需要遍历计算dp数组
  • **空间复杂度:O(1)😗*字符的ASCII码范围为0~127,哈希表dic最多使用O(128)=O(1)大小的额外空间

img

class Solution {
public:
    int lengthOfLongestSubstring(string s) {
        unordered_map<char, int> dic;
        int i = -1, res = 0, len = s.size();
        for(int j = 0; j < len; j++)
        {
            if(dic.count(s[j]))
            {
                i = max(i, dic[s[j]]);//更新左指针
            }
            dic[s[j]] = j;//哈希表记录
            res = max(res, j - i);//更新结果
        }
        return res;
    }
};

leetcode115:不同的子序列

给定一个字符串 s 和一个字符串 t计算在 s 的子序列中 t 出现的个数

字符串的一个 子序列 是指,通过删除一些(也可以不删除)字符且不干扰剩余字符相对位置所组成的新字符串。(例如,“ACE” 是 “ABCDE” 的一个子序列,而 “AEC” 不是)

题目数据保证答案符合 328位带符号整数范围。

图片

提示:

0 <= s.length, t.length <= 1000 s 和 t 由英文字母组成

给定两个单词 word1 和 word2,找到使得 word1 和 word2 相同所需的最小步数,每步可以删除任意一个字符串中的一个字符

示例:

输入: “sea”, “eat”
输出: 2

解释: 第一步将"sea"变为"ea",第二步将"eat"变为"ea"

【思路】

如果不是子序列,而是要求连续序列的,那就可以考虑**KMP算法【该算法的使用场景】**了

因为该题目相对于72.编辑距离,还是简单不少,因为该题只涉及删除操作,不用考虑增加等之类。

动态规划五步曲:

  1. 确定dp数组意义及下标

    dp[i] [j]:以i-1为结尾的s子序列中出现以j-1为结尾的t 的个数为dp[i] [j]

  2. 确定递推公式

    • s[i-1] 与t[j-1]相等—》dp[i] [j] = dp[i-1] [j-1] + dp[i-1] [j];(怎么没有懂)

    • s[i-1]与t[j-1]不相等-》dp[i] [j] = dp[i - 1] [j];

      当s[i - 1] 与 t[j - 1]相等时,dp[i] [j]可以有两部分组成。

      一部分是用s[i - 1]来匹配,那么个数为dp[i - 1] [j - 1] (以i-2为结尾的s子序列中出现j-2为结尾的t的个数)。

      一部分是不用s[i - 1]来匹配,个数为dp[i - 1] [j] (以i-2为结尾的s子序列中出现j-1为结尾的t的个数)。

      例如:s:bagg 和 t:bag ,s[3] 和 t[2]是相同的,但是字符串s也可以不用s[3]来匹配,即用s[0]s[1]s[2]组成的bag。

      当然也可以用s[3]来匹配,即:s[0]s[1]s[3]组成的bag。

      当s[i - 1] 与 t[j - 1]不相等时,dp[i][j]只有一部分组成,不用s[i - 1]来匹配,即:dp[i - 1] [j]

  3. dp数组如何初始化

    dp[i] [0] 表示:以i-1为结尾的s可以随便删除元素,出现空字符串的个数。

    那么dp[i] [0]一定都是1,因为也就是把以i-1为结尾的s,删除所有元素,出现空字符串的个数就是1。

    再来看dp[0] [j],dp[0] [j]:空字符串s可以随便删除元素,出现以j-1为结尾的字符串t的个数。

    么dp[0] [j]一定都是0,s如论如何也变成不了t。

    最后就要看一个特殊位置了,即:dp[0] [0] 应该是多少。

    dp[0] [0]应该是1,空字符串s,可以删除0个元素,变成空字符串t。

    vector<vector<long long>> dp(s.size() + 1, vector<long long>(t.size() + 1);//为什么时long long类型
    for(int i = 0; i <= s.size(); i++ ) dp[i][0] = 1;
    for (int j = 1; j <= t.size(); j++) dp[0][j] = 0;
    
  4. 确定遍历顺序

    从递推公式dp[i] [j] = dp[i - 1] [j - 1] + dp[i - 1] [j]; 和 dp[i] [j] = dp[i - 1] [ j]; 中可以看出dp[i] [j]都是根据左上方和正上方推出来的

    所以遍历的时候一定是从上到下,从左到右,这样保证dp[i] [j]可以根据之前计算出来的数值进行计算。

    for (int i = 1; i <= s.size(); i++) {//从上到下
        for (int j = 1; j <= t.size(); j++) {//从左到右
            if (s[i - 1] == t[j - 1]) {//情况一:
                dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
            } else {//情况二:
                dp[i][j] = dp[i - 1][j];
            }
        }
    }
    
  5. 举例推导dp数组

    图片

class Solution {
public:
    int numDistinct(string s, string t) {
        //long long类型的数据类型还是不下????--->那我就用long double
        //将下面的long long 都换成 long double
        vector<vector<long long>> dp(s.size() + 1, vector<long long>(t.size() + 1));
        for (int i = 0; i <= s.size(); i++) dp[i][0] = 1;
        for (int j = 1; j <= t.size(); j++) dp[0][j] = 0;
        for (int i = 1; i <= s.size(); i++) {
            for (int j = 1; j <= t.size(); j++) {
                if (s[i - 1] == t[j - 1]) {
                    dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
                } else {
                    dp[i][j] = dp[i - 1][j];
                }
            }
        }
        return dp[s.size()][t.size()];
    }
};

leetcode538:两个字符串的删除操作

给定两个单词 word1 和 word2,找到使得 word1 和 word2 相同所需的最小步数,每步可以删除任意一个字符串中的一个字符。

示例:

输入: “sea”, “eat”
输出: 2 解释: 第一步将"sea"变为"ea",第二步将"eat"变为"ea"

思考为什么要给下面的两个提示?

  1. 给定单词的长度不超过500。

  2. 给定单词中的字符只含有小写字母。

【思路】

  1. 确定dp数组(dp table)以及下标的含义

dp[i] [j]:以i-1为结尾的字符串word1,和以j-1位结尾的字符串word2,想要达到相等,所需要删除元素的最少次数

  1. 确定递推公式
  • 当word1[i - 1] 与 word2[j - 1]相同的时候
  • 当word1[i - 1] 与 word2[j - 1]不相同的时候

当word1[i - 1] 与 word2[j - 1]相同的时候,dp[i] [j] = dp[i - 1] [j - 1];

当word1[i - 1] 与 word2[j - 1]不相同的时候,有三种情况:

情况一:删word1[i - 1],最少操作次数为dp[i - 1] [j] + 1

情况二:删word2[j - 1],最少操作次数为dp[i] [j - 1] + 1

情况三:同时删word1[i - 1]和word2[j - 1],操作的最少次数为dp[i - 1] [j - 1] + 2

那最后当然是取最小值,所以当word1[i - 1] 与 word2[j - 1]不相同的时候,递推公式:dp[i] [j] = min({dp[i - 1] [j - 1] + 2, dp[i - 1] [ j] + 1, dp[i] [j - 1] + 1});

  1. dp数组如何初始化

从递推公式中,可以看出来,dp[i] [0] 和 dp[0] [j]是一定要初始化的。

dp[i] [0]:word2为空字符串,以i-1为结尾的字符串word2要删除多少个元素,才能和word1相同呢,很明显dp[i] [0] = i。dp[0] [j]的话同理

vector<vector<int>> dp(word1.size() + 1, vector<int>(word2.size() + 1));
for(int i = 0; i <= word1.size(); i++) dp[i][0] = i;//要深刻理解dp数组的含义
for(int j = 0; j <= word2.size(); j++) dp[0][j] = j;
  1. 确定遍历顺序

    从递推公式上来看,遍历的顺序时从上到下,从左到右

  2. 举例推导dp数组

    图片

    class Solution {
    public:
        int minDistance(string word1, string word2) {
            //动态规划五步曲
            int len1 = word1.size();
            int len2 = word2.size();
            vector<vector<int>> dp(len1 + 1 , vector<int>(len2 + 1, 0));
            for(int i = 0 ; i < len1 + 1; i++){
                dp[i][0] = i;
            }
            for(int j = 0 ; j < len2 + 1; j++){
                dp[0][j] = j;
            }
    
            for(int i = 1; i <= len1 ; i++)
            {
                for(int j = 1; j <= len2; j++)
                {
                    //情况一:
                    if(word1[i-1] == word2[j-1])
                    {
                        dp[i][j] = dp[i-1][j-1];
                    }
                    //情况二:
                    else
                    {
                        //三种转变的情况
                        dp[i][j] = min({dp[i-1][j]+1, dp[i][j-1]+1, dp[i-1][j-1]+2});
                        //cout<<dp[i][j]<<"   ";
                    }
                }
                //cout<<endl;
            }
    
            return dp[len1][len2];
        }
    };
    

leetcode72:编辑距离

给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。

你可以对一个单词进行如下三种操作

  • 插入一个字符
  • 删除一个字符
  • 替换一个字符

示例 1:输入:word1 = “horse”, word2 = “ros”
输出:3
解释:
horse -> rorse (将 ‘h’ 替换为 ‘r’)
rorse -> rose (删除 ‘r’)
rose -> ros (删除 ‘e’)

示例 2:
输入:word1 = “intention”, word2 = “execution”
输出:5
解释:
intention -> inention (删除 ‘t’)
inention -> enention (将 ‘i’ 替换为 ‘e’)
enention -> exention (将 ‘n’ 替换为 ‘x’)
exention -> exection (将 ‘n’ 替换为 ‘c’)
exection -> execution (插入 ‘u’)

提示:

  • 0 <= word1.length, word2.length <= 500
  • word1 和 word2 由小写英文字母组成

【思路】

  1. 确定dp数组(dp table)以及下标的含义

dp[i] [j] 表示以下标i-1为结尾的字符串word1,和以下标j-1为结尾的字符串word2,最近编辑距离为dp[i] [j]

  1. 确定递推公式

在确定递推公式的时候,首先要考虑清楚编辑的几种操作,整理如下:

  • 情况一:if (word1[i - 1] == word2[j - 1])

    • 不操作
  • 情况二:if (word1[i - 1] != word2[j - 1])

也就是如上四种情况。

if (word1[i - 1] == word2[j - 1]) 那么说明不用任何编辑,dp[i] [j] 就应该是 dp[i - 1] [j - 1],即dp[i] [j] = dp[i - 1] [j - 1];

此时可能有同学有点不明白,为啥要即dp[i] [j] = dp[i - 1] [j - 1]呢?

在整个动规的过程中,最为关键就是正确理解dp[i] [j]的定义!

if (word1[i - 1] != word2[j - 1]),此时就需要编辑了,如何编辑呢?

操作一:word1增加一个元素,使其word1[i - 1]与word2[j - 1]相同,那么就是以下标i-2为结尾的word1j-1为结尾的word2的最近编辑距离 加上一个增加元素的操作。

即 dp[i] [j] = dp[i - 1] [j] + 1;

**操作二:**word2添加一个元素,使其word1[i - 1]与word2[j - 1]相同,那么就是以下标i-1为结尾的word1 与 j-2为结尾的word2的最近编辑距离 加上一个增加元素的操作。

即 dp[i] [j] = dp[i] [j - 1] + 1;

这里有同学发现了,怎么都是添加元素,删除元素去哪了。

word2添加一个元素,相当于word1删除一个元素,例如 word1 = “ad” ,word2 = “a”,word2添加一个元素d,也就是相当于word1删除一个元素d,操作数是一样!

操作三:替换元素,word1替换word1[i - 1],使其与word2[j - 1]相同,此时不用增加元素,那么以下标i-2为结尾的word1 与 j-2为结尾的word2的最近编辑距离 加上一个替换元素的操作。

即 dp[i] [j] = dp[i - 1] [j - 1] + 1;

综上,当 if (word1[i - 1] != word2[j - 1]) 时取最小的,即:dp[i] [j] = min({dp[i - 1] [j - 1], dp[i - 1] [j], dp[i][j - 1]}) + 1;

if (word1[i - 1] == word2[j - 1]) {
    dp[i][j] = dp[i - 1][j - 1];
}
else {
    dp[i][j] = min({dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]}) + 1;
}
  1. dp数组如何初始化

dp[i] [j] 表示以下标i-1为结尾的字符串word1,和以下标j-1为结尾的字符串word2,最近编辑距离为dp[i] [j]

dp[i] [0] :以下标i-1为结尾的字符串word1,和空字符串word2,最近编辑距离为dp[i] [0]。

那么dp[i] [0]就应该是i,对空字符串做添加元素的操作就可以了,即:dp[i] [0] = i;

同理dp[0] [j] = j;

所以C++代码如下:

for (int i = 0; i <= word1.size(); i++) dp[i][0] = i;
for (int j = 0; j <= word2.size(); j++) dp[0][j] = j;
  1. 确定遍历顺序

从如下四个递推公式:

  • dp[i] [j] = dp[i - 1] [j - 1]
  • dp[i] [j] = dp[i - 1] [j - 1] + 1
  • dp[i] [j] = dp[i] [j - 1] + 1
  • dp[i] [j] = dp[i - 1] [j] + 1

可以看出dp[i] [j]是依赖左方,上方和左上方元素的,如图:

图片

for (int i = 1; i <= word1.size(); i++) {
    for (int j = 1; j <= word2.size(); j++) {
        if (word1[i - 1] == word2[j - 1]) {
            dp[i][j] = dp[i - 1][j - 1];
        }
        else {
            dp[i][j] = min({dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]}) + 1;
        }
    }
}
  1. 举例推导dp数组

    图片

    class Solution {
    public:
        int minDistance(string word1, string word2) {
            //动态规划五步曲
            //初始化数组
            int len1 = word1.size();
            int len2 = word2.size();
            vector<vector<int>> dp(len1 + 1,vector<int>(len2 + 1));
            for(int i = 0; i <= len1; i++) dp[i][0] = i;
            for(int j = 0; j <= len2; j++) dp[0][j] = j;
    
            for(int i = 1; i <= len1; i++)
            {
                for(int j = 1; j <= len2; j++)
                {
                    if(word1[i-1] == word2[j-1])
                    {
                        dp[i][j] = dp[i-1][j-1];
                    }
                    else
                    {
                        //情况一:word1删除 dp[i-1][j]+1
                        //情况二:word2删除 dp[i][j-1]+1;
                        //情况三:word1或word2替换 dp[i-1][j-1]+1
                        dp[i][j] = min({dp[i-1][j], dp[i][j-1], dp[i-1][j-1]}) + 1;
                    }
                }
            }
            return dp[len1][len2];
        }
    };
    

leetcode647:回文子串

给定一个字符串,你的任务是计算这个字符串中有多少个回文子串。

具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被视作不同的子串。

示例 1:

输入:“abc”
输出:3
解释:三个回文子串: “a”, “b”, “c”

示例 2:

输入:“aaa”
输出:6
解释:6个回文子串: “a”, “a”, “a”, “aa”, “aa”, “aaa”

提示:输入的字符串长度不会超过 1000。

【思路】

暴力法

两层for循环,遍历区间起始位置和终止位置,然后判断该区间是不是回文串

#include <iostream>
#include <vector>
using namespace std;
class Solution{
private:
    //功能是判断从下标i到下标j这个字符串是否是回文字符
    //返回值1代表是回文字符串,0代表不是回文字符串
    int isPara(string s, int i, int j)
    {
        while(i < j)
        {
            if(s[i] != s[j])
                return 0;
            i++;
            j--;
        }
        return 1;
    }
public:
    int length_str(string s)
    {
        int result = 0;
        int len = s.size();
        //dp[i][j]表示从i字符到j字符
        vector<vector<int>> dp(len, vector<int>(len, 0));
        for(int i = 0; i < len; i++)
        {
            for(int j = i ; j < len; j++)
            {
                if(isPara(s,i,j))
                {
                    result++;
                }
            }
        }
        return result;
    }
};

int main()
{
    string s;
    cin>>s;
    int result = Solution().length_str(s);
    cout<<result<<endl;
    return 0;
}

动态规划

  1. 确定dp数组以及下标的含义

    bool类型的dp[i] [j]:区间范围[i, j]左闭右闭的子串是否是回文子串,如果dp[i] [j]为true,否则为false

  2. 确定递推公式

    整体上是两种,就是s[i]与s[j]相等,s[i]与s[j]不相等这两种。

    s[i]与s[j]不相等,那没啥好说的了,dp[i] [j]一定是false

    s[i]与s[j]相等时,这就复杂一些了,有如下三种情况

    • 情况一:下标i 与 j相同,同一个字符例如a,当然是回文子串

    • 情况二:下标i 与 j相差为1,例如aa,也是文子串

    • 情况三:下标:i 与 j相差大于1的时候,例如cabac,此时s[i]与s[j]已经相同了,我们看i到j区间是不是回文子串就看aba是不是回文就可以了,那么aba的区间就是 i+1 与 j-1区间,这个区间是不是回文就看dp[i + 1] [j - 1]是否为true

      if(s[i] == s[j])
      {
          if(j - i <= 1)//情况一与情况二
          {
      		result++;
              dp[i][j] = true;
          }
          else if(dp[i+1][j-1])//情况三
          {
              result++;
              dp[i][j] = true;
          }
      }
      

      result就是统计回文子串的数量

  3. dp数组如何初始化

    dp[i] [j]可以初始化为false

  4. 确定遍历顺序

    从递推公式dp[i+1] [j-1]可以看出,遍历顺序是从下到上,从左到右遍历

    //i保证从下到上遍历,右dp的定义[i,j]可以得出j一定是大于i的
    for (int i = s.size() - 1; i >= 0; i--) {  // 注意遍历顺序
        for (int j = i; j < s.size(); j++) {
            *****
        }
    }
    
  5. 举例推导dp数组

    比如输入“aaa",dp[i ] [j]装填如下:

    图片

    //时间复杂度O(n^2)
    //空间复杂度O(n^2)
    class Solution {
    public:
        int countSubstrings(string s) {
            vector<vector<bool>> dp(s.size(), vector<bool>(s.size(), false));
            int result = 0;
            for (int i = s.size() - 1; i >= 0; i--) {  // 注意遍历顺序
                for (int j = i; j < s.size(); j++) {
                    if (s[i] == s[j]) {
                        if (j - i <= 1) { // 情况一 和 情况二
                            result++;
                            dp[i][j] = true;
                        } else if (dp[i + 1][j - 1]) { // 情况三
                            result++;
                            dp[i][j] = true;
                        }
                    }
                }
            }
            return result;
        }
    };
    

双指针法(中心扩散法)

首先确定回文串,就是找中心然后想两边扩散看是不是对称的就可以了。

在遍历中心点的时候,要注意中心点有两种情况

一个元素可以作为中心点,两个元素也可以作为中心点。

那么有人同学问了,三个元素还可以做中心点呢。其实三个元素就可以由一个元素左右添加元素得到,四个元素则可以由两个元素左右添加元素得到。

所以我们在计算的时候,要注意一个元素为中心点和两个元素为中心点的情况。

//时间复杂度:O(n^2)
//空间复杂度:O(1)

class Solution {
public:
    int countSubstrings(string s) {
        int result = 0;
        for (int i = 0; i < s.size(); i++) {
            result += extend(s, i, i, s.size()); // 以i为中心 
            result += extend(s, i, i + 1, s.size()); // 以i和i+1为中心
        }
        return result;
    }
    int extend(const string& s, int i, int j, int n) {
        int res = 0;
        while (i >= 0 && j < n && s[i] == s[j]) {
            i--;
            j++;
            res++;
        }
        return res;
    }
};

leetcode 516:最长回文子序列

给定一个字符串 s ,找到其中最长的回文子序列,并返回该序列的长度。可以假设 s 的最大长度为 1000 。

示例 1:
输入: “bbbab”
输出: 4
一个可能的最长回文子序列为 “bbbb”。

示例 2:
输入:“cbbd”
输出: 2
一个可能的最长回文子序列为 “bb”。

提示:

  • 1 <= s.length <= 1000
  • s 只包含小写英文字母

【思路】

回文子串是要连续的,回文子序列可不是连续的! 回文子串,回文子序列都是动态规划经典题目。

动规五部曲分析如下:

  1. 确定dp数组(dp table)以及下标的含义

    dp[i] [j]:字符串s在[i, j]范围内最长的回文子序列的长度为dp[i] [j]

  2. 确定递推公式

    在判断回文子串的题目中,关键逻辑就是看s[i]与s[j]是否相同。

    如果s[i]与s[j]相同,那么dp[i] [j] = dp[i + 1] [j - 1] + 2;

    图片

如果s[i]与s[j]不相同,说明s[i]和s[j]的同时加入 并不能增加[i,j]区间回文子串的长度,那么分别加入s[i]、s[j]看看哪一个可以组成最长的回文子序列

加入s[j]的回文子序列长度为dp[i + 1] [j]

加入s[i]的回文子序列长度为dp[i] [j - 1]

那么dp[i] [j]一定是取最大的,即:dp[i] [j] = max(dp[i + 1] [j], dp[i] [j - 1]);

图片

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]);
}
  1. dp数组如何初始化

    首先要考虑当i 和j 相同的情况,从递推公式:dp[i] [j] = dp[i + 1] [j - 1] + 2; 可以看出 递推公式是计算不到 i 和j相同时候的情况。

    所以需要手动初始化一下,当i与j相同,那么dp[i] [j]一定是等于1的,即:一个字符的回文子序列长度就是1

    其他情况dp[i] [j]初始为0就行,这样递推公式:dp[i] [j] = max(dp[i + 1] [j], dp[i] [j - 1]); 中dp[i] [j]才不会被初始值覆盖。

    vector<vector<int>> dp(s.size(), vector<int>(s.size(), 0));
    for (int i = 0; i < s.size(); i++) dp[i][i] = 1;
    
  2. 确定遍历顺序

    递推公式:dp[i][j] = dp[i + 1] [j - 1] + 2,dp[i] [j] = max(dp[i + 1] [j], dp[i] [j - 1]) 从矩阵的角度来说,dp[i][j] 下一行的数据。所以遍历i的时候一定要从下到上遍历,这样才能保证,下一行的数据是经过计算的。分别对应着下图中的红色箭头方向,如图:

    图片

for (int i = s.size() - 1; i >= 0; i--) {
    for (int j = i + 1; j < s.size(); 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]);
        }
    }
}
  1. 举例推导dp数组

    输入s="cbbd"为例,dp数组状态如图:

    图片

红色框即:dp[0] [s.size() - 1]; 为最终结果。

class Solution {
public:
    int longestPalindromeSubseq(string s) {
        //初始化dp数组,dp[i][j]:字符串s在[i,j]范围内最长的回文子序列的长度为dp[i][j]
        int len = s.size();
        //输入的字符不能为空
        assert(len > 0);
        //输入必须只能是小写字母
        
        //初始化数组
        vector<vector<int>> dp(len, vector<int>(len, 0));
        for(int i = 0; i < len; i++)
        {
            dp[i][i] = 1;
        }
        //递推公式:
        /*
        if(s[i] == s[j])
            dp[i][j] = s[i+1][j-1] + 2
        else
            dp[i][j] = max(dp[i+1][j], dp[i][j-1])
        */
        for(int i = len - 2 ; i >= 0; i--)
        {
            for(int j = i + 1; j < len ; 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]);
                }
            }
        }
        return dp[0][len-1];
    }
};

leetcode5:最长回文子串

给你一个字符串 s,找到 s 中最长的回文子串。

示例 1:

输入:s = “babad”
输出:“bab”
解释:“aba” 同样是符合题意的答案。
示例 2:

输入:s = “cbbd”
输出:“bb”
示例 3:

输入:s = “a”
输出:“a”
示例 4:

输入:s = “ac”
输出:“a”

提示:

1 <= s.length <= 1000
s 仅由数字和英文字母(大写和/或小写)组成

class Solution {
public:
    string longestPalindrome(string s) {
        int len = s.size();
        int max = INT_MIN;
        //row与col用于记录最长回文字符串的下标范围为[row,col]
        int col, row;
        //dp[i][j]表示字符串[i~j]范围内是否时回文子串
        vector<vector<bool>> dp(len, vector<bool>(len, false));
        for(int i = len - 1; i >= 0; i--)
        {
            for(int j = i; j < len; j++)
            {
                if(s[i] == s[j])
                {
                    if(j - i <= 1)
                    {
                        dp[i][j] = true;
                        //如果[i][j]是字符串,计算以下距离最大时的下标。
                        if(max < (j - i))
                        {
                            max = j - i;
                            col = j;
                            row = i;
                        }
                    }
                    else if(dp[i + 1][j - 1])
                    {
                        dp[i][j] = true;
                        if(max < (j - i))
                        {
                            max = j - i;
                            col = j;
                            row = i;
                        }
                    }
                }
            }
        } 
        //用于返回
        string result;
        //substr的用法:substr(begin, count);begin是起始位置,count是从begin开始算共有几个字符
        result = s.substr(row, col - row + 1);
        return result;
    }
};

leetcode132:分割回文串II(动态规划)

给你一个字符串 s,请你将 s 分割成一些子串,使每个子串都是回文。

返回符合要求的 最少分割次数 。

示例 1:

输入:s = “aab”
输出:1
解释:只需一次分割就可将 s 分割成 [“aa”,“b”] 这样两个回文子串。
示例 2:

输入:s = “a”
输出:0
示例 3:

输入:s = “ab”
输出:1

提示:

1 <= s.length <= 2000
s 仅由小写英文字母组成

【思路】

动态规划五部曲:

  1. 确定dp数组以及下标的含义

    dp[i]:范围是[0,i]的回文子串,最少分割次数是dp[i]

  2. 确定递推公式

    如果要对长度为[0, i]的子串进行分割,分割点为j。

    那么如果分割后,区间[j + 1, i]是回文子串,那么dp[i] 就等于 dp[j] + 1。

    为什么只看[j + 1, i]区间,不看[0, j]区间是不是回文子串呢?

    [0, j]区间的最小切割数量,我们已经知道了就是dp[j]。当切割点j在[0, i] 之间时候,dp[i] = dp[j] + 1;

    本题是要找到最少分割次数,所以遍历j的时候要取最小的dp[i]。

    所以最后递推公式为:dp[i] = min(dp[i], dp[j] + 1);

    注意这里不是要 dp[j] + 1 和 dp[i]去比较,而是要在遍历j的过程中取最小的dp[i]!

    可以有dp[j] + 1推出,当[j + 1, i] 为回文子串

  3. dp数组如何初始化

    首先来看一下dp[0]应该是多少。

    dp[i]:范围是[0, i]的回文子串,最少分割次数是dp[i]。

    那么dp[0]一定是0,长度为1的字符串最小分割次数就是0。

    在递推公式dp[i] = min(dp[i], dp[j] + 1) 中我们可以看出每次要取最小的dp[i]。

    那么非零下标的dp[i]就应该初始化为一个最大数,这样递推公式在计算结果的时候才不会被初始值覆盖!

    vector<int> dp(s.size(), INT_MAX);
    dp[0] = 0;
    
  4. 确定遍历顺序

    由递推公式:dp[i] = min(dp[i], dp[j] + 1);

    是在[0,i]之间,所以遍历i的for循环一定在外层,这里遍历j的for循环在内层才能通过 计算过的dp[j]数值推导出dp[i]。

    for(int i = 1; i <  s.size(); i++)
    {
    	if(isPalindromic[0][i])//判断是否是回文子串
    	{
    		dp[i] = 0;
    		continue;
    	}
        for(int j = 0; j < i; j++)
        {
            if(isPalindromic[j+1][i])
            {
                dp[i] = min(dp[i], dp[j] + 1);
            }
        }
    }
    

    isPalindromic[i] [j]是一个二维数组,记录的是字符串[i, j]是不是回文子串

    先用一个二维数组来保存整个字符串的回文情况。

    vector<vector<bool>> isPalindromic(s.size(), vector<bool>(s.size(), false));
    for(int i = s.size() - 1; i >= 0; i--)
    {
        for(int j = i; j < s.size(); j++)
        {
            if(s[i] == s[j] && (j - i <= 1 || isPalindromic[i + 1][j - 1]))
            {
                isPalindromic[i][j] = true;
            }
        }
    }
    
  5. 举例推导dp数组

    以输入:”aabc"为例

图片

class Solution {
public:
    int minCut(string s) {
        //isPalindromic二维数组,记录的是[i, j]是不是回文子串,如果是回文串则置1否则置0
        vector<vector<bool>> isPalindromic(s.size(), vector<bool>(s.size(), false));
        for (int i = s.size() - 1; i >= 0; i--) {
            for (int j = i; j < s.size(); j++) {
                if (s[i] == s[j] && (j - i <= 1 || isPalindromic[i + 1][j - 1])) {
                    isPalindromic[i][j] = true;
                    
                /*
                //上面的代码参考leetcode647
                上面的代码也可以转成下面的代码
                if(s[i] == s[j])
                {
                    if(j - i <= 1)
                        isPalindromic[i][j] = true;
                    else if(dp[i+1][j-1])
                        isPalindromic[i][j] = true;
                }
                */    
                }
            }
        }
        
        // 初始化
        vector<int> dp(s.size(), INT_MAX);
		dp[0] = 0;
        
        for (int i = 1; i < s.size(); i++) {
            if (isPalindromic[0][i]) {
                dp[i] = 0;
                continue;
            }
            for (int j = 0; j < i; j++) {
                if (isPalindromic[j + 1][i]) {
                    dp[i] = min(dp[i], dp[j] + 1);
                }
            }
        }
        return dp[s.size() - 1];

    }
};

leetcode10:正则表达式匹配

给你一个字符串 s 和一个字符规律 p,请你来实现一个支持 ‘.’ 和 ‘*’ 的正则表达式匹配。

‘.’ 匹配任意单个字符
‘*’ 匹配零个或多个前面的那一个元素
所谓匹配,是要涵盖 整个 字符串 s的,而不是部分字符串。

示例 1:

输入:s = “aa” p = “a”
输出:false
解释:“a” 无法匹配 “aa” 整个字符串。
示例 2:

输入:s = “aa” p = “a*”
输出:true
解释:因为 ‘*’ 代表可以匹配零个或多个前面的那一个元素, 在这里前面的元素就是 ‘a’。因此,字符串 “aa” 可被视为 ‘a’ 重复了一次。
示例 3:

输入:s = “ab” p = “."
输出:true
解释:".
” 表示可匹配零个或多个(’*’)任意字符(’.’)。
示例 4:

输入:s = “aab” p = “cab”
输出:true
解释:因为 ‘*’ 表示零个或多个,这里 ‘c’ 为 0 个, ‘a’ 被重复一次。因此可以匹配字符串 “aab”。
示例 5:

输入:s = “mississippi” p = “misisp*.”
输出:false

提示:

0 <= s.length <= 20
0 <= p.length <= 30
s 可能为空,且只包含从 a-z 的小写字母。
p 可能为空,且只包含从 a-z 的小写字母,以及字符 . 和 *。
保证每次出现字符 * 时,前面都匹配到有效的字符

【解题思路】

设s的长度为n,p的长度为m;将s的第i个字符记为si,p的第j个字符记为pj,将s的前i个字符组成的子字符串记为s[:i],同理将p的前j个字符组成的子字符串记为p[:j]。因此本题可转化为求s[:n]是否能与p[:m]匹配

总体思路是从s[:1]和p[:1]是否能匹配开始判断,每轮添加一个字符并判断是否能匹配,直至添加完整个字符串s和p。展开来看,假设s[:i]与p[:j]可以匹配,那么下一状态有两种:

  1. 添加一个字符si+1后是否能匹配?
  2. 添加字符pj+1后是否能匹配?

Picture1.png

因此,本题的状态共右m*n种,应定义状态矩阵dp,dp[i] [j]代表s[:i]与p[:j]是否可以匹配做好状态定义,接下来是根据普通字符.,*三种字符的功能定义,分析出动条规划的转移方程。

动态规划解析:

  • **状态定义:**设动态规划矩阵dp,dp[i] [j]代表字符串s的前i个字符和p的前j个字符能否匹配 。

  • **状态方程:**需要注意,由于dp[0] [0]代表的是空字符串状态,因此dp[i] [j]对应的添加字符是s[i-1] 和 p[j-1]。

    • p[j-1] = '*'时,dp[i] [j]在当下任一情况为true时等于true:

      1. **dp[i] [j-2]😗*将字符组合p[j - 2]*看作出现0次时,能否匹配;
      2. **dp[i-1] [j] 且 s[i-1] = p[j-2]😗*即让字符p[j-2]多出现1次时,能否匹配;
      3. **dp[i-1] [j]且p[j-2] = ‘.’😗*即让字符'.'多出现1次时,能否匹配;
    • p[j-1] != '*'时,dp[i] [j]在当下任一情况为ture时等于true:

      1. **dp[i-1] [j-1]且s[i-1] = p[j-1]😗*即让字符p[j-1]多出现一次时,能否匹配;
      2. **dp[i-1] [j-1]且p[j-1] = ‘.’😗*即将字符.看作字符s[i-1]时,能否匹配;
  • **初始化:**需要先初始化dp矩阵首行,以避免状态转移时索引越界。

    • dp[0] [0] = true:代表两个空字符串能够匹配;
    • dp[0] [j] = dp[0] [j-2]且p[j-1] = '*':首行s为空字符串,因此当p的偶数位为*时才能够匹配(即让p的奇数位出现0次,保持p是空字符串)。因此,循环遍历字符串p,步长为2(即只看偶数位)。
  • 返回值:dp矩阵右下角字符,代表字符串sp能否匹配。

img

复杂度分析:

  • **时间复杂度O(MN)😗*其中M,N分别为sp的长度,状态转移需遍历整个dp矩阵
  • **空间复杂度O(MN)😗*状态矩阵dp使用O(MN)的额外空间。
class Solution {
public:
    bool isMatch(string s, string p) {
        int m = s.size() + 1, n = p.size() + 1;
        //dp[i][j]代表字符串s的前i个字符和p的前j个字符能否能够匹配
        vector<vector<bool>> dp(m, vector<bool>(n, false));
        dp[0][0] = true;
        //初始化首行
        for(int j = 2; j < n; j += 2)
        {
            dp[0][j] = dp[0][j-2] && p[j-1] == '*';
        }
        //状态转移
        for(int i = 1; i < m; i++)
        {
            for(int j = 1; j < n; j++)
            {
                if(p[j-1] == '*')
                {
                    if(dp[i][j-2])//如果p[j-2]出现零次:s = "a"p="aa*"//其中a*出现1次
                        dp[i][j] = true;
                    else if(dp[i-1][j] && s[i-1] == p[j-2])//即让字符 p[j-2]多出现1次时能够匹配;s = "aa"p="aa*"//其中a*出现1次
                        dp[i][j] = true;
                    else if(dp[i-1][j] && p[j-2] == '.')//s = "aa"p="a.*",其中a*出现1次,因为.可以代表任意字符,所以上一个时这个的特例
                        dp[i][j] = true;
                }
                else
                {
                    //dp[i-1][j-1],前一个相等看当下s[i]与p[j]是否相等
                    if(dp[i-1][j-1] && s[i-1] == p[j-1])
                        dp[i][j] = true;
                    else if(dp[i-1][j-1] && p[j-1] == '.')//上一个是这个的特例,因为p[j-1]==.时说明p[j-1]可以作为任意字符
                        dp[i][j] = true;
                }
            }
        }
        return dp[m-1][n-1];
    }
};

leetcode:数位成本和为目标值的最大数字

**利用动态规划找到最优解,就是不断的回退找到最优解对应的组合。**解决方法就是创建一个新的数组,用来存储是那个值走到当下值的。

【题目】

给你一个整数数组 cost 和一个整数 target 。请你返回满足如下规则可以得到的 最大 整数:

给当前结果添加一个数位(i + 1)的成本为 cost[i] (cost 数组下标从 0 开始)。
总成本必须恰好等于 target 。
添加的数位中没有数字 0 。
由于答案可能会很大,请你以字符串形式返回。
示例 1:
输入:cost = [4,3,2,5,6,7,2,5,5], target = 9
输出:"7772"
解释:添加数位 '7' 的成本为 2 ,添加数位 '2' 的成本为 3 。所以 "7772" 的代价为 2*3+ 3*1 = 9 。 "977" 也是满足要求的数字,但 "7772" 是较大的数字。
 数字     成本
  1  ->   4
  2  ->   3
  3  ->   2
  4  ->   5
  5  ->   6
  6  ->   7
  7  ->   2
  8  ->   5
  9  ->   5
示例 2:
输入:cost = [7,6,5,5,5,6,8,7,8], target = 12
输出:"85"
解释:添加数位 '8' 的成本是 7 ,添加数位 '5' 的成本是 5 。"85" 的成本为 7 + 5 = 12 。
示例 3:
输入:cost = [2,4,6,2,4,6,4,4,4], target = 5
输出:"0"
解释:总成本是 target 的条件下,无法生成任何整数。
示例 4:
输入:cost = [6,10,15,40,40,40,40,40,40], target = 47
输出:"32211" 
提示:
cost.length == 9
1 <= cost[i] <= 5000
1 <= target <= 5000

【解题思路】

若两个整数位数不同,位数更多的整数必然大于位数小的整数,因此我们需要计算可以得到的整数最大位数。

该问题可以看作是恰好装满背包容量为target,物品重量为cost[i],价值为1的完全背包问题。

定义二维数组dp,其中dp[i+1] [j]表示使用前i个数位且花费总成本恰好为j时的最大位数,若花费总成本无法为j,则规定为负无穷。特别地,dp[0] []为不选任何数位的状态,因此除了dp[0] [0] = 0,其余dp[0] [j]全为负无穷。

对于第i个数位,考虑花费总成本恰好为j时的状态转移:

  • 若j < cost[i],则无法选第i个数位,此时有dp[i+1] [j] = dp[i] [j] ;
  • 若j >= cost[i],存在选或不选两种决策,不选时有dp[i+1] [j] = dp[i] [j],选时由于第i个数位可以重复选择,可以从使用前i个数位且花费总成本恰好位j - cost[i]的状态转移过来,即dp[i+1] [j] = dp[i+1] [j-cost[i]] + 1;

因此状态转移方程为:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kBEdMPEE-1626103523411)(C:\Users\mubao\AppData\Roaming\Typora\typora-user-images\image-20210612085542575.png)]

dp[9] [target] 即为可以得到的整数的最大位数,若其小于0则说明我们无法得到满足要求的整数,返回”0“。否则,我们需要生成一个整数,其位数为dp[9] [target]且数值最大。

为了生成该整数,我们可以用一个额外的二维数组from,在状态转移时记录转移来源。这样我们可以从最终状态dp[9] [target]顺着from不断倒退,直至达到起始状态dp[0] [0].在倒退状态时,若转移来源是dp[i+1] [j-cost[i]]则说明我们选取了第i个数位。

根据转移方程:

  • 若j < cost[i],有from[i+1] [j] = j;
  • 若j >= cost[i],当dp[i] [j] > dp[i+1] [j-cost[i]] + 1时有from[i+1] [j] = j,否则有from[i+1] [j] = j - cost[i]。

注意我们并没有记录来源时i还是i+1,这是因为若from[i+1] [j]的值为j,则必定从i转移过来,否则必定从i+1转移过来。

此外,由于我们是从最大的数位向最小的数位倒退,为使生成的整数尽可能地大,对于当前数位应尽可能多的选取,所以当dp[i] [j]与dp[i+1] [j-cost[i]] + 1相等时,我们选择从后者转移过来。

这样我们就得到了每个数位的选择次数,为使生成整数尽可能地大,我们应按照从大到小的顺序填入各个数位。由于该顺序与倒退状态顺序一致,我们可以在倒退状态时,将当前数位直接加入生成的整数末尾。

代码实现时,负无穷可以用一个非常小的负数表示,保证转移时对于值为负无穷的状态,其+1后仍为负数。

class Solution {
public:
    string largestNumber(vector<int>& cost, int target) {
        vector<vector<int>> dp(10, vector<int>(target+1, INT_MIN));
        vector<vector<int>> from(10, vector<int>(target+1));
        dp[0][0] = 0;
        for(int i = 0; i < 9; i ++)
        {
            int c = cost[i] ;
            for(int j = 0; j <= target; j++)
            {
                if(j < c)
                {
                    dp[i+1][j] = dp[i][j];
                    from[i+1][j] = j;//从i转移过来的
                }
                else{
                    if(dp[i][j] > dp[i+1][j-c]+1)
                    {
                        dp[i+1][j] = dp[i][j];
                        from[i+1][j] = j;
                    }
                    else
                    {
                        dp[i+1][j] = dp[i+1][j-c] + 1;
                        from[i+1][j] = j - c;//从i+1转移过来的
                    }
                }
            }
        }
        if(dp[9][target] < 0)
        {
            return "0";
        }
        string ans;
        int i = 9, j = target;
        while(i > 0)
        {
            if(j == from[i][j])
            {
                i--;
            }
            else
            {
                ans += '0' + i;//'0'+i是转成字符
                j = from[i][j];
            }
        }
        return ans;
    }
};

动态规划与博弈论

leetcode877:石子游戏

亚历克斯和李用几堆石子在做游戏。偶数堆石子排成一行,每堆都有正整数颗石子 piles[i] 。

游戏以谁手中的石子最多来决出胜负。石子的总数是奇数,所以没有平局。

亚历克斯和李轮流进行,亚历克斯先开始。 每回合,玩家从行的开始或结束处取走整堆石头。 这种情况一直持续到没有更多的石子堆为止,此时手中石子最多的玩家获胜。

假设亚历克斯和李都发挥出最佳水平,当亚历克斯赢得比赛时返回 true ,当李赢得比赛时返回 false 。

示例:

输入:[5,3,4,5]
输出:true
解释:
亚历克斯先开始,只能拿前 5 颗或后 5 颗石子 。
假设他取了前 5 颗,这一行就变成了 [3,4,5] 。
如果李拿走前 3 颗,那么剩下的是 [4,5],亚历克斯拿走后 5 颗赢得 10 分。
如果李拿走后 5 颗,那么剩下的是 [3,4],亚历克斯拿走后 4 颗赢得 9 分。
这表明,取前 5 颗石子对亚历克斯来说是一个胜利的举动,所以我们返回 true 。

提示:

2 <= piles.length <= 500
piles.length 是偶数。
1 <= piles[i] <= 500
sum(piles) 是奇数。

【解题思路】

定义dp[l] [r] 为考虑区间[l, r],在双方都做好选择的情况下,先手与后手的最大得分差值为dp[l] [r]

那么dp[1] [n]为所有石子,先手与后手的得分差值:

  • f[1] [n] > 0,则先手必胜, 返回True
  • f[1] [n] < 0,则先手必败,返回Flase

不失一般性的考虑f[l] [r]如何转移。根据题意,只能从两端取石子(令piles下标从1开始),共两种情况:

  • 从左端取石子,价值为piles[l-1];取完石子后,原来的后手变为先手,从[l+1, r]区间做最优决策,所得到价值为f[l+1] [r]。因此本次先手从左端取石子的话,双方的差值为: piles[l-1] - f[l+1] [r]
  • 从右端取石子,价值为piles[r - l];取完石子后,原来的后手变为先手从[l, r- 1]区间做最优决策,所得价值为f[l] [r-1]。因此本次先手从右端点取石子的话,双方差值为:piles[r-1] - f[l] [r-1]双方都想赢,都会做出最优决策(即使自己与对方差分最大)。因此f[l] [r]为上述两种情况中的最大值。根据状态转移方程,我们发现最大区间的状态值依赖于小区间的状态值,典型的区间DP问题。
  • 按照从小到大【枚举区间长度】和【区间左端点】的常规做法进行求解。
class Solution {
public:
    bool stoneGame(vector<int>& piles) {
        int len = piles.size();
        vector<vector<int>> dp(len, vector<int>(len, 0));//dp[i][j]:表示从下标i到下标j区间内的先手与后手最大差值为dp[i][j]
        //隐含条件i < j
        //初始化数组
        for(int i = 0; i < len; i++)
        {
            dp[i][i] = piles[i];
        }
        //dp[i][j] = max(piles[i]-dp[i+1][j], piles[j]-dp[i][j-1])
        for(int i = len - 1;  i >= 0; i--)
        {
            for(int j = i + 1; j < len ; j++)
            {
                dp[i][j] = max(piles[i] - dp[i+1][j], piles[j] - dp[i][j-1]);
            }
        }
        //最终结果使从下标0到len-1
        return dp[0][len-1] > 0;
    }
};