一文带你掌握动态规划(二)

上一篇文章中,我们讲解了动态规划的原理,算法步骤以及两种经典的动态规划题目,那么本篇中接着上集继续讲解动态规划的几种常见题型。

如果没看过上一篇文章的同学,建议先从上一篇开始阅读一文带你掌握动态规划(一)

1. 简单多状态dp

在多状态dp问题中,dp表通常不止一个,根据题目要求,我们可以将状态表示进一步划分为更细小的子状态,通过多个不同状态来解决问题。

1.1 打家劫舍

题目链接:面试题 17.16. 按摩师 - 力扣(LeetCode)

算法原理:

状态表示

我们先用以i位置为结尾来表示dp[i]。dp[i]表示到达i位置时的最长预约时长。但是到达i位置其实是有两种状态的,一是我选择i位置的值,二是我不选择i位置的值。那么我们将dp[i]继续细分为。

  1. f[i]:到达 i 位置时,选择nums[i],此时的最长预约时长
  2. g[i]:到达 i 位置时,不选nums[i],此时的最长预约时长

状态转移方程

我们先来分析f[i],如果到达 i 位置时,选择 nums[i],那么 nums[i-1]一定不能选择。此时我们需要在[0, i - 1]区间中找到最长预约时长,这个时长就是g[i-1],因为g[i-1]表示到达i-1时,不选择nums[i-1]的最长预约时长。

所以 f[i] = g[i-1] + nums[i]

我们再来看g[i],到达 i 位置时,不选择num[i],那么g[i]应该找到上一个选择了nums的值,而nums[i-1]可能选择也可能不会选择。那我们还要继续划分。

  • 1.选择nums[i-1],g[i] = f[i-1];
  • 2.没有选择nums[i-1],g[i] = g[i-1]

由于这两种情况都有可能,而我们需要找到较大的那种,所以g[i] = max(f[i-1], g[i-1])

总结

  • 1.状态表示:
    • f[i]:到达 i 位置时,选择nums[i],此时的最长预约时长
    • g[i]:到达 i 位置时,不选nums[i],此时的最长预约时长
  • 2.推导状态转移方程:
    • f[i] = g[i-1] + nums[i]
    • g[i] = max(f[i-1], g[i-1])
  • 3.初始化:f[0] = nums[0], g[0]  = 0
  • 4.填表:从左向右,两个表一起填
  • 5.返回值:max(f[n-1], g[n-1])。

题解:

class Solution 
{
public:
    int massage(vector<int>& nums) 
    {
        if (nums.size() == 0)
            return 0;

        int n = nums.size();
        vector<int> f(n, 0); //f[i]:到达i位置,选择nums[i]的最大预约时长
        vector<int> g(n, 0); //g[i]:到达i位置,不选择nums[i]的最大预约时长
        f[0] = nums[0];
        g[0] = 0;

        //填表
        for (size_t i = 1; i < n; i++)
        {
            f[i] = g[i-1] + nums[i];
            g[i] = max(f[i-1], g[i-1]);
        }

        //返回结果
        return max(f[n-1], g[n-1]);
    }
};

1.2 打家劫舍II

题目链接:LCR 090. 打家劫舍 II - 力扣(LeetCode)

算法原理:

我们先来分析一下,这题和上一题有什么不同。其实就是首位不能相连的区别,也就是说如果选了第一个位置,就不能选最后一个位置;如果不选第一个位置,可能会选择第二个位置。

1.选择第一个位置:不能选第二个位置和第三个位置,剩下的[2, n-2]位置其实可以随便选,和上一道题的思路是一样的。

2.不选择第一个位置:剩下的[1, n-1]位置都可以随便选,和上一题是一样的。

那么这道题就是分类讨论之后,执行两次上一题打家劫舍一样的过程。

 状态表示

我们先用以i位置为结尾来表示dp[i]。dp[i]表示到达i位置时的最长预约时长。但是到达i位置其实是有两种状态的,一是我选择i位置的值,二是我不选择i位置的值。那么我们将dp[i]继续细分为。

  1. f[i]:到达 i 位置时,选择nums[i],此时的最长预约时长
  2. g[i]:到达 i 位置时,不选nums[i],此时的最长预约时长

状态转移方程

我们先来分析f[i],如果到达 i 位置时,选择 nums[i],那么 nums[i-1]一定不能选择。此时我们需要在[0, i - 1]区间中找到最长预约时长,这个时长就是g[i-1],因为g[i-1]表示到达i-1时,不选择nums[i-1]的最长预约时长。

所以 f[i] = g[i-1] + nums[i]

我们再来看g[i],到达 i 位置时,不选择num[i],那么g[i]应该找到上一个选择了nums的值,而nums[i-1]可能选择也可能不会选择。那我们还要继续划分。

  • 1.选择nums[i-1],g[i] = f[i-1];
  • 2.没有选择nums[i-1],g[i] = g[i-1]

由于这两种情况都有可能,而我们需要找到较大的那种,所以g[i] = max(f[i-1], g[i-1])

总结

  • 1.状态表示:
    • f[i]:到达 i 位置时,选择nums[i],此时的最大金额
    • g[i]:到达 i 位置时,不选nums[i],此时的最大金额
  • 2.推导状态转移方程:
    • f[i] = g[i-1] + nums[i]
    • g[i] = max(f[i-1], g[i-1])
  • 3.初始化:f[0] = nums[0], g[0]  = 0
  • 4.填表:从左向右,两个表一起填
  • 5.返回值:max(f[n-1], g[n-1])。

题解:

class Solution 
{
public:
    int steal(vector<int>& nums, int left, int right)
    {
        if (left > right)
            return 0;

        vector<int> f(nums.size(), 0); //到达i位置,偷,最大金额
        vector<int> g(nums.size(), 0); //到达i位置,不偷,最大金额
        f[left] = nums[left];
        g[left] = 0;

        for (size_t i = left + 1; i <= right; i++)
        {
            f[i] = g[i-1] + nums[i];
            g[i] = max(f[i-1], g[i-1]);
        }
        return max(f[right], g[right]);
    }

    int rob(vector<int>& nums) 
    {
        int n = nums.size();
        //第一种是偷第一个位置,那么第二个位置和最后一个位置都不能偷了,剩下位置进行一次打家劫舍1
        //第二种是不偷第一个位置,对剩下位置进行一次打家劫舍1
        //取两者最大值
        return max(nums[0] + steal(nums, 2, n - 2), steal(nums, 1, n - 1));
    }
};

1.3 删除并获得点数

题目链接:740. 删除并获得点数 - 力扣(LeetCode)

算法原理:

 我们先来分析一下这道题,当我们选择了nums[i]时,我们可以获得所有值为nums[i]的点数,然后要删除nums[i+1]和nums[i-1],也就是说不能选择相邻的数。那么我们会发现这题和打家劫舍1那道题其实是一样的,当选择完一个数之后不能选择相邻的数。

比如选择了1,就不能选2,选择了3,就不能选4。

但是和那道题不同的地方在于,这道题的数据可能不是相连的,如下面数据,我们选择了2之后,不能选择3但是可以选择4。

所以我们可以先对这些数据进行处理,再进行一次打家劫舍操作。我们创建一个数组arr,arr[i]表示等于 i 的数的总和。

那么这个时候,我们对arr做一次打家劫舍操作就可以了。

 状态表示

我们先用以i位置为结尾来表示dp[i]。dp[i]表示到达i位置时的最长预约时长。但是到达i位置其实是有两种状态的,一是我选择i位置的值,二是我不选择i位置的值。那么我们将dp[i]继续细分为。

  1. f[i]:到达 i 位置时,选择nums[i],此时的最长预约时长
  2. g[i]:到达 i 位置时,不选nums[i],此时的最长预约时长

状态转移方程

我们先来分析f[i],如果到达 i 位置时,选择 nums[i],那么 nums[i-1]一定不能选择。此时我们需要在[0, i - 1]区间中找到最长预约时长,这个时长就是g[i-1],因为g[i-1]表示到达i-1时,不选择nums[i-1]的最长预约时长。

所以 f[i] = g[i-1] + nums[i]

我们再来看g[i],到达 i 位置时,不选择num[i],那么g[i]应该找到上一个选择了nums的值,而nums[i-1]可能选择也可能不会选择。那我们还要继续划分。

  • 1.选择nums[i-1],g[i] = f[i-1];
  • 2.没有选择nums[i-1],g[i] = g[i-1]

由于这两种情况都有可能,而我们需要找到较大的那种,所以g[i] = max(f[i-1], g[i-1])

总结

  • 0.预处理:创建一个arr表,将arr[i]初始化为所有值为1的数之和。
  • 1.状态表示:
    • f[i]:到达 i 位置时,选择arr[i],此时的最大点数
    • g[i]:到达 i 位置时,不选arr[i],此时的最大点数
  • 2.推导状态转移方程:
    • f[i] = g[i-1] + arr[i]
    • g[i] = max(f[i-1], g[i-1])
  • 3.初始化:f[0] = arr[0], g[0]  = 0
  • 4.填表:从左向右,两个表一起填
  • 5.返回值:max(f[n], g[n])。

题解:

class Solution 
{
public:
    int deleteAndEarn(vector<int>& nums) 
    {
        //预处理
        int arr[10001] = { 0 };
        int start = INT_MAX; 
        int end = 0;
        for (auto e : nums)
        {
            arr[e] += e;
            end = std::max(end, e);
            start = std::min(start, e);
        }

        //打家劫舍
        vector<int> f(end+1, 0); //到达i位置,选择arr[i]的最大点数
        vector<int> g(end+1, 0); //到达i位置,不选择arr[i]的最大点数
        f[start] = arr[start];
        g[start] = 0;

        for (size_t i = start + 1; i <= end; i++)
        {
            f[i] = g[i-1] + arr[i];
            g[i] = max(f[i-1], g[i-1]);
        }

        return max(f[end], g[end]);
    }
};

1.4 粉刷房子

题目链接:LCR 091. 粉刷房子 - 力扣(LeetCode)

算法原理:

状态表示

题目要求粉刷完所有房子的最少花费成本,那么我们根据以往的经验(dp[i]表示以 i 位置结尾....),那么我们可以尝试一下让dp[i]表示为到达 i 位置时的最少花费成本。

但是到达 i 位置其实是有三种情况的,第一种是 i 位置填红色,第二种是 i 位置填蓝色,第三种是 i 位置填绿色。为了细分出这三种情况,我们创建一个二维的dp表

  • dp[i][0]表示:粉刷到 i 位置时,将 i 位置粉刷为红色,此时的最小花费。
  • dp[i][1]表示:粉刷到 i 位置时,将 i 位置粉刷为蓝色,此时的最小花费。
  • dp[i][2]表示:粉刷到 i 位置时,将 i 位置粉刷为绿色,此时的最小花费。

状态转移方程

这三个状态转移方程的逻辑其实是一样的,所以这里只推导一个。

如果我们粉刷到 i 位置时,将 i 位置粉刷为红色,要想此时的花费最少,那么只需要让[0, i - 1]区间中的花费最少即可,而dp[i-1]又恰好表示这个值。

i - 1位置只有两种情况,要么涂蓝色,要么涂绿色。如果这个位置涂蓝色,那么最少花费就是dp[i-1][1],如果涂绿色,那么最少花费就是dp[i-1][2]。

这两种情况都有可能,我们要求最少的那个花费,所以还要求min

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

总结

  • 1.状态表示:
    • dp[i][0]表示:粉刷到 i 位置时,将 i 位置粉刷为红色,此时的最小花费。
    • dp[i][1]表示:粉刷到 i 位置时,将 i 位置粉刷为蓝色,此时的最小花费。
    • dp[i][2]表示:粉刷到 i 位置时,将 i 位置粉刷为绿色,此时的最小花费。
  • 2.推导状态转移方程:
    • dp[i][0] = min(dp[i-1][1], dp[i-1][2]) + cost[i][0]
    • dp[i][1] = min(dp[i-1][0], dp[i-1][2]) + cost[i][1]
    • dp[i][2] = min(dp[i-1][0], dp[i-1][1]) + cost[i][2]
  • 3.初始化:
    • dp[0][0] = cost[0][0]
    • dp[0][1] = cost[0][1]
    • dp[0][2] = cost[0][2]
  • 4.填表:从左到右,从上到下,三个表同时填
  • 5.返回值:max(dp[n-1][0], dp[n-1][1], dp[n-1][2])。

题解:

class Solution 
{
public:
    int minCost(vector<vector<int>>& costs) 
    {
        int n = costs.size();
        // dp[i][0]表示:粉刷到 i 位置时,将 i 位置粉刷为红色,此时的最小花费。
        // dp[i][1]表示:粉刷到 i 位置时,将 i 位置粉刷为蓝色,此时的最小花费。
        // dp[i][2]表示:粉刷到 i 位置时,将 i 位置粉刷为绿色,此时的最小花费。
        vector<vector<int>> dp(n, vector<int>(3, 0));
        dp[0][0] = costs[0][0];
        dp[0][1] = costs[0][1];
        dp[0][2] = costs[0][2];

        for (size_t i = 1; i < n; i++)
        {
            dp[i][0] = min(dp[i-1][1], dp[i-1][2]) + costs[i][0]; 
            dp[i][1] = min(dp[i-1][0], dp[i-1][2]) + costs[i][1];
            dp[i][2] = min(dp[i-1][0], dp[i-1][1]) + costs[i][2];
        }

        return min(dp[n-1][0], min(dp[n-1][1], dp[n-1][2]));
    }
};

1.5 买卖股票的最佳时机含冷冻期

题目链接:309. 买卖股票的最佳时机含冷冻期 - 力扣(LeetCode)

算法原理:

状态表示

状态表示可以结合经验+题目要求来定义。那么我们可以大胆的假设,状态表示为第 i 天结束时的最大利润,但是第 i 天结束其实是有好几种情况的。

  • 1. 第 i 天结束后,手上有股票
  • 2. 第 i 天结束后处于可交易状态(手上没有股票,并且不处于冷冻期)
  • 3. 第 i 天卖出了股票,那么第三天结束时就处于冷冻期

我们可以创建一个二维的dp表。

  • dp[i][0]表示:第 i 天结束后,手上持有股票,目前的最大利润
  • dp[i][1]表示:第 i 天结束后,处于可交易状态时,目前的最大利润
  • dp[i][2]表示:第 i 天结束后,处于冷冻期,目前的最大利润。

状态转移方程

由于这道题稍微有点复杂,在求状态转移方程之前,我们可以先推导一下三个值之间的关系。

箭头表示的是从前一天到今天。如果前一天手上有股票(买入状态),今天还是可以有股票状态的;前一天是可交易状态,那么今天如果买股票了也会进入买入状态,所以可交易状态也可以到买入状态。

但是前一天是卖出了股票,今天就不能买入了,因为前一天将股票卖出了,今天就是冷冻期,冷冻期是不可以卖股票的。

再来分析一下冷冻期。前一天处于买入状态,那么今天是可以将股票卖出的。但是前一天处于冷冻期状态,今天就不能处于冷冻期状态了;同理可交易状态也不能到冷冻期状态

最后一个是可交易状态。如果前一天处于可交易状态,那么今天也可以处于可交易状态;如果前一天卖出了股票,那么今天就是冷冻期,今天结束之后冷冻期结束,那么也可以是可交易状态的;但是前一天是买入状态,那么今天不能是可交易状态的。

根据上图,我们就可以很轻松推导状态转移方程了

  • dp[i][0] = max(dp[i-1][0], dp[i-1][1] - price[i])
  • dp[i][1] = max(dp[i-1][1], dp[i-1][2])
  • dp[i][2] = dp[i-1][0] + price[i]

初始化

  • dp[0][0] = -price[i](第一天买入了,要花的金额为-price[i])
  • dp[0][1] = 0(相当于第一天没有买股票)
  • dp[0][2] = 0 (第一天结束要处于冷冻状态,只有第一天买了股票再卖出)

总结

  • 1.状态表示:
    • dp[i][0]表示:第 i 天结束后,手上持有股票,目前的最大利润
    • dp[i][1]表示:第 i 天结束后,处于可交易状态时,目前的最大利润
    • dp[i][2]表示:第 i 天结束后,处于冷冻期,目前的最大利润
  • 2.推导状态转移方程:
    • dp[i][0] = max(dp[i-1][0], dp[i-1][1] - price[i])
    • dp[i][1] = max(dp[i-1][1], dp[i-1][2])
    • dp[i][2] = dp[i-1][0] + price[i]
  • 3.初始化:
    • dp[0][0] = -price[i]
    • dp[0][1] = 0
    • dp[0][2] = 0 
  • 4.填表:从左到右,从上到下,三个表同时填
  • 5.返回值:max(dp[n-1][1], dp[n-1][2]) (第一个表必然不可能为最终结果)。

题解:

class Solution 
{
public:
    int maxProfit(vector<int>& prices) 
    {
        int n = prices.size();
        // dp[i][0]表示:第 i 天结束后,手上持有股票,目前的最大利润
        // dp[i][1]表示:第 i 天结束后,处于可交易状态时,目前的最大利润
        // dp[i][2]表示:第 i 天结束后,处于冷冻期,目前的最大利润
        vector<vector<int>> dp(n, vector<int>(3, 0));
        dp[0][0] = -prices[0];

        for (size_t i = 1; i < n; 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][2]);
            dp[i][2] = dp[i-1][0] + prices[i];
        }

        return max(dp[n-1][1], dp[n-1][2]);
    }
};

1.6 买卖股票的最佳时机含手续费

题目链接:714. 买卖股票的最佳时机含手续费 - 力扣(LeetCode)

算法原理:

状态表示

根据题目要求+经验,我们可以将dp[i]表示为第 i 天结束后的最大利润。但是第 i 天结束时会有两种状态:1.第 i 天结束后,手上有股票 2.第 i 天结束后,手上没有股票,我们创建两个dp表f 和 g

  • f[i]表示:第 i 天结束后,手上有股票,此时的最大利润
  • g[i]表示:第 i 天结束后,手上没有股票,此时的最大利润

状态转移方程

和上一题一样,我们来分析两个关系之间的关系。箭头表示从前一天到今天。

先来看手上有股票。如果前一天手上有股票,那么今天手上可以继续有股票,如果前一天手上无股票,那么今天可以花费price[i]买股票,今天结束也就有股票了。

手上无股票也是一个道理,昨天没有股票,今天可以继续没有股票;如果昨天有股票,今天结束后没有股票,说明今天将股票卖掉了,所以也是可以的。

那么我们就可以根据上图直接来写状态转移方程。

  • f[i] = max(f[i-1], g[i-1] - price[i])
  • g[i] = max(f[i-1] + price[i] - fee, g[i-1])

总结

  • 1.状态表示:
    • f[i]表示:第 i 天结束后,手上有股票,此时的最大利润
    • g[i]表示:第 i 天结束后,手上没有股票,此时的最大利润
  • 2.推导状态转移方程:
    • f[i] = max(f[i-1], g[i-1] - price[i])
    • g[i] = max(f[i-1] + price[i] - fee, g[i-1])
  • 3.初始化:
    • f[0] = -price[i]
    • g[0] = 0
  • 4.填表:从左到右,两个表同时填
  • 5.返回值:return g[n-1]

题解:

class Solution 
{
public:
    int maxProfit(vector<int>& prices, int fee) 
    {
        int n = prices.size();
        //f[i]表示:第 i 天结束后,手上有股票,此时的最大利润
        //g[i]表示:第 i 天结束后,手上没有股票,此时的最大利润
        vector<int> f(n, 0);
        vector<int> g(n, 0);
        f[0] = -prices[0];
        
        for (size_t i = 1; i < n; i++)
        {
            f[i] = max(f[i-1], g[i-1] - prices[i]);
            g[i] = max(f[i-1] + prices[i] - fee, g[i-1]);
        }

        return g[n-1];
    }
};

1.7 买卖股票的最佳时机III

题目链接:123. 买卖股票的最佳时机 III - 力扣(LeetCode)

算法原理:

状态表示

这题的状态表示还是和前面一样,用经验+题目要求的方式。我们可以将dp[i]表示为:第 i 天结束后,此时的最大利润。但是第 i 天结束时有两种状态,一种是手上有股票,一种是手上没有股票,而这两种状状态还可以继续细分,题目要求最多交易两次,那么就可以在手上有无股票的情况下加入次数。

  1. f[i][j]表示:在 i 天结束后,完成了 j 次交易,此时手上有股票的最大利润。
  2. g[i][j]表示:在 i 天结束后,完成了 j 次交易,此时手上没有有股票的最大利润。

状态转移方程

因为这题有多个状态,那么我们可以画一个图来表示状态之间的关系,便于我们推导状态转移方程。(圆圈内的表示状态,箭头表示从前一天的状态到今天的状态)

首先我们来分析一下手上有股票,如果昨天手上有股票,那么今天可以继续有股票;如果昨天手上没有股票,今天结束时没有股票,那么说明今天花费了prices[i]买了股票,所以两个箭头都是成立的。

再来分析一下手上没有股票的情况,如果昨天没买股票,那么今天可以继续不买;如果昨天手上有股票,今天结束后手上就没有股票了,说明今天把股票卖了。注意:卖股票会将交易次数变多。

有了上面的关系,我们可以开始推导状态转移方程了。

  • f[i][j] = max(f[i-1][j], g[i-1][j] - price[i]])  
  • g[i][j] = max(g[i-1][j], f[i-1][j-1] + price[i])

注意,昨天手上有股票到今天结束无股票的代码应该写出 f[i-1][j-1] + price[i],因为当股票卖出时,相当于完成了一笔交易,也就是说今天的交易次数是比昨天多一次的,今天的交易次数是j,那么昨天就是j - 1。

但是如果按照上面那种写法,g[i][j]可能会出现越界的,因为j可能为0,而后面会出现j - 1。那么为了解决这个问题,我们可以修改一下状态转移方程。

g[i][j] = g[i-1][j]

if (j > 0)
    g[i][j] = max(g[i][j], f[i-1][j-1] + price[i])

初始化

这题的初始化稍微有一点麻烦,先回顾一下状态表示。

  1. f[i][j]表示:在 i 天结束后,完成了 j 次交易,此时手上有股票的最大利润。
  2. g[i][j]表示:在 i 天结束后,完成了 j 次交易,此时手上没有有股票的最大利润。

  • f[0][0]初始化为-price[0]应该比较好理解,如果要第一天结束手上要有股票,那就要花price[i]的钱买股票。
  • g[0][0]初始化为0也好理解,第一天结束手上没股票,只要第一天不买就可以了
  • 其他位置初始化为-∞,因为这题的买卖次数是有限制的,如果要让利润最大,那么必然不可能在同一天进行买卖,为了不影响后续求值,我们将他设置为-∞。

在正常情况下,我们通常会将-∞设置为INT_MIN,但是这题有g[i-1][j] - price[i]],如果g[i-1][j]为-∞,那么可能会报错,为了解决这个问题,我们将-∞设置为-0x3f3f3f3f(这个数就是INT_MIN的一半)

总结

  • 1.状态表示:
    • f[i][j]表示:在 i 天结束后,完成了 j 次交易,此时手上有股票的最大利润。
    • g[i][j]表示:在 i 天结束后,完成了 j 次交易,此时手上没有有股票的最大利润。
  • 2.推导状态转移方程:
    • f[i][j] = max(f[i-1][j], g[i-1][j] - price[i]])  
    • g[i][j] = g[i-1][j]

      if (j > 0)
          g[i][j] = max(g[i][j], f[i-1][j-1] + price[i])

  • 3.初始化:
    • f[0][0] = -price[i]
    • g[0][0] = 0
    • f[0][1] = f[0][2] = g[0][1] = g[0][2] = -0x3f3f3f3f
  • 4.填表:从上到下填写每一行,每一行从左向右,两个表同时填
  • 5.返回值:g表的最后一行的最大值(f表不可能为结果)

题解:

class Solution 
{
public:
    int maxProfit(vector<int>& prices) 
    {
        int n = prices.size();
        //创建dp表
        //f[i][j]表示:在 i 天结束后,完成了 j 次交易,此时手上有股票的最大利润。
        //g[i][j]表示:在 i 天结束后,完成了 j 次交易,此时手上没有有股票的最大利润。
        vector<vector<int>> f(n, vector<int>(3, -0x3f3f3f3f));
        vector<vector<int>> g(n, vector<int>(3, -0x3f3f3f3f));
        f[0][0] = -prices[0];
        g[0][0] = 0;


        for (size_t i = 1; i < n; i++)
        {
            for (size_t j = 0; j < 3; j++)
            {
                f[i][j] = max(f[i-1][j], g[i-1][j] - prices[i]);
                g[i][j] = g[i-1][j];
                if (j > 0)
                    g[i][j] = max(g[i][j], f[i][j-1] + prices[i]);
            }
        }

        return max(g[n-1][0], max(g[n-1][1], g[n-1][2]));
    }
};

关于效率:

这道题虽然我们的效率看起来并不高,其实算法的逻辑是没有问题的,原因在于较快的题解会将二维的dp表优化成一维,甚至只用几个变量来表示,但是我们上面的那个思路其实对下一道题也是可以解出来的,并且代码也是几乎一模一样的。

本文主要讲解的是动态规划的算法原理,感兴趣的同学可以自己去尝试用两个一维的数组来优化。

1.8 买股票的最佳时机IV

题目链接:188. 买卖股票的最佳时机 IV - 力扣(LeetCode)

注意:交易次数是小于等于k即可,并不是一定要达到k次交易

算法原理:

这题的状态表示还是和前面一样,用经验+题目要求的方式。我们可以将dp[i]表示为:第 i 天结束后,此时的最大利润。但是第 i 天结束时有两种状态,一种是手上有股票,一种是手上没有股票,而这两种状状态还可以继续细分,题目要求最多交易k次,那么就可以在手上有无股票的情况下加入次数。

  1. f[i][j]表示:在 i 天结束后,完成了 j 次交易,此时手上有股票的最大利润。
  2. g[i][j]表示:在 i 天结束后,完成了 j 次交易,此时手上没有有股票的最大利润。

状态转移方程

因为这题有多个状态,那么我们可以画一个图来表示状态之间的关系,便于我们推导状态转移方程。(圆圈内的表示状态,箭头表示从前一天的状态到今天的状态)

首先我们来分析一下手上有股票,如果昨天手上有股票,那么今天可以继续有股票;如果昨天手上没有股票,今天结束时没有股票,那么说明今天花费了prices[i]买了股票,所以两个箭头都是成立的。

再来分析一下手上没有股票的情况,如果昨天没买股票,那么今天可以继续不买;如果昨天手上有股票,今天结束后手上就没有股票了,说明今天把股票卖了。注意:卖股票会将交易次数变多。

有了上面的关系,我们可以开始推导状态转移方程了。

  • f[i][j] = max(f[i-1][j], g[i-1][j] - price[i]])  
  • g[i][j] = max(g[i-1][j], f[i-1][j-1] + price[i])

注意,昨天手上有股票到今天结束无股票的代码应该写出 f[i-1][j-1] + price[i],因为当股票卖出时,相当于完成了一笔交易,也就是说今天的交易次数是比昨天多一次的,今天的交易次数是j,那么昨天就是j - 1。

但是如果按照上面那种写法,g[i][j]可能会出现越界的,因为j可能为0,而后面会出现j - 1。那么为了解决这个问题,我们可以修改一下状态转移方程。

g[i][j] = g[i-1][j]

if (j > 0)
    g[i][j] = max(g[i][j], f[i-1][j-1] + price[i])

初始化

这题的初始化稍微有一点麻烦,先回顾一下状态表示。

  1. f[i][j]表示:在 i 天结束后,完成了 j 次交易,此时手上有股票的最大利润。
  2. g[i][j]表示:在 i 天结束后,完成了 j 次交易,此时手上没有有股票的最大利润。

  • f[0][0]初始化为-price[0]应该比较好理解,如果要第一天结束手上要有股票,那就要花price[i]的钱买股票。
  • g[0][0]初始化为0也好理解,第一天结束手上没股票,只要第一天不买就可以了
  • 其他位置初始化为-∞,因为这题的买卖次数是有限制的,如果要让利润最大,那么必然不可能在同一天进行买卖,为了不影响后续求值,我们将他设置为-∞。

在正常情况下,我们通常会将-∞设置为INT_MIN,但是这题有g[i-1][j] - price[i]],如果g[i-1][j]为-∞,那么可能会报错,为了解决这个问题,我们将-∞设置为-0x3f3f3f3f(这个数就是INT_MIN的一半)

第二点就是,如果prices数组中只有20个数,也就是说最多完成20/2 = 10次交易,但是k为30,那么显然不合理,所以可以将k初始化为min(k, n / 2)。当k减少时,时间可以得到有效的优化。

总结

  • 1.状态表示:
    • f[i][j]表示:在 i 天结束后,完成了 j 次交易,此时手上有股票的最大利润。
    • g[i][j]表示:在 i 天结束后,完成了 j 次交易,此时手上没有有股票的最大利润。
  • 2.推导状态转移方程:
    • f[i][j] = max(f[i-1][j], g[i-1][j] - price[i]])  
    • g[i][j] = g[i-1][j]

      if (j > 0)
          g[i][j] = max(g[i][j], f[i-1][j-1] + price[i])

  • 3.初始化:
    • f[0][0] = -price[i]
    • g[0][0] = 0
    • f[0][1] = f[0][2] =.... f[0][k]....= g[0][1] = g[0][2]= ....g[0][k].... = -0x3f3f3f3f
  • 4.填表:从上到下填写每一行,每一行从左向右,两个表同时填
  • 5.返回值:g表的最后一行的最大值(f表不可能为结果)
class Solution 
{
public:
    int maxProfit(int k, vector<int>& prices) 
    {
        int n = prices.size();
        //f[i][j]表示:在 i 天结束后,完成了 j 次交易,此时手上有股票的最大利润。
        //g[i][j]表示:在 i 天结束后,完成了 j 次交易,此时手上没有有股票的最大利润。
        vector<vector<int>> f(n, vector<int>(k + 1, -0x3f3f3f3f));
        vector<vector<int>> g(n, vector<int>(k + 1, -0x3f3f3f3f));
        f[0][0] = -prices[0];
        g[0][0] = 0;
        k = min(k, n / 2);

        //填表
        for (size_t i = 1; i < n; i++)
        {
            for (size_t j = 0; j < k + 1; j++)
            {
                f[i][j] = max(f[i-1][j], g[i-1][j] - prices[i]);
                g[i][j] = g[i-1][j];
                if (j > 0)
                    g[i][j] = max(g[i][j], f[i-1][j-1] + prices[i]);
            }
        }

        //返回值,找到g表最后一行的最大值
        int ret = 0;
        for (size_t i = 0; i < k + 1; i++)
            ret = max(ret, g[n-1][i]);

        return ret;
    }
};

2. 子数组

子数组一般指的是数组中的一个连续的部分,也就是说从数组中任意位置开始,连续截取n个数所形成的一个数组,子数组的个数是平方级别的,也很好计算。

  • 比如以a结尾,他的子序列只有一个[a]
  • 以b结尾,他的子序列有两个[b], [a,b]
  • 以c结尾,他的子序列有三个[c], [b, c], [a,b,c]
  • ......

可以很容易推算出子数组的个数就是一个等差数列。

在子数组问题中,状态表示通常是通过经验+题目要求就能得到,也就是数状态表示基本上为:dp[i]表示:以i位置结尾怎么怎么样。

2.1 最大子数组和

题目链接:53. 最大子数组和 - 力扣(LeetCode)

这题的暴力解法是:算出nums的所有子数组和,然后返回其中最大的,但是这种方法的时间复杂度是O(N^2),如果使用动态规划是可以优化到O(N)的。

算法原理:

状态表示

那么这道题的状态表示还是根据经验+题目要求,dp[i]表示:到达 i 位置的最大和。但是到达 i 位置是存在很多个子数组的,那么我们可以描述的更加详细。

dp[i]表示:以 i 位置为结尾的所有子数组中的最大和。

状态转移方程

以 i 位置为结尾的所有子数组有两种情况,第一种是子数组中只有自己,第二种是子数组还包含前面的元素。

  • 1. 长度为1,dp[i] = nums[i] 
  • 2. 长度为2,dp[i] = dp[i-1] + nums[i] 

第二种情况应该找到以 i 位置为结尾长度大于1的数组和最大的那一个,而dp[i-1]表示到达 i-1 位置时的最大子数组和,那么dp[i]就是在dp[i-1]的基础上再加上nums[i]。

而最终的dp[i]应该为两者之间的最大值。

dp[i] = max(nums[i], dp[i-1] + nums[i])。

总结

  • 1.状态表示:dp[i]表示:以 i 位置为结尾的所有子数组中的最大和。
  • 2.推导状态转移方程:dp[i] = max(nums[i], dp[i-1] + nums[i])。
  • 3.初始化:为了让dp[i-1]不发生越界,所以我们在数组开头额外添加一位,并将这个位置初始化为0(额外添加一位,注意下标映射关系)
  • 4.填表:从左向右填表
  • 5.返回值:返回整个表中最大的那个。

题解:

class Solution 
{
public:
    int maxSubArray(vector<int>& nums) 
    {
        int n = nums.size();
        //dp[i]表示以 i 位置为结尾时,所有子数字和的最大值
        vector<int> dp(n+1, 0);

        int ret = INT_MIN;
        for (size_t i = 1; i <= n; i++)
        {
            dp[i] = max(nums[i-1], dp[i-1] + nums[i-1]);
            ret = max(dp[i], ret);
        }

        return ret;
    }
};

2.2  环形子数组之和

题目链接:918. 环形子数组的最大和 - 力扣(LeetCode)

这题就是在上面那题的基础上加上首位可以相连的条件。

算法原理:

在求状态表示和状态转移方程之前,我们可以先来分析一下。这题的情况分为上面两种,一种是最大子数组和是中间的 一部分,一种是首位相连的。

如果直接求首位相连的情况比较困难,此事我们可以注意一下中间空白的部分。如果整个数组之和是一个定值,那么想要子数组和最大,只需要将空白部分最小即可。

所以下面这种情况我们可以先求空白部分最小,然后用sum减去这部分就可以了。

状态表示

根据经验(以 i 位置为结尾.....) + 题目要求。我们可以将dp[i]设置为以 i 位置为结尾的所有子数组中的最大和,但是经过上面的分析,我们还要求最小和,所以我们需要两个dp表。

  • 1. f[i]表示:以 i 位置为结尾的所有子数组中的最大和
  • 2. g[i]表示:以 i 位置为结尾的所有子数组中的最小和

状态转移方程

我们需要将子数组分成两类,一类是子数组中只包含本身(长度为1),一类是自己加上前面的元素(长度大于1)。

  • 1. 长度为1,f[i] = nums[i]
  • 2. 长度>1,f[i] = f[i-1] + nums[i]

第二种情况应该找到以 i 位置为结尾长度大于1的数组和最大的那一个,而f[i-1]表示到达 i-1 位置时的最大子数组和,那么f[i]就是在f[i-1]的基础上再加上nums[i]。

我们要的是两者之间的最大值,所以要f[i] = max(nums[i], f[i-1] + nums[i])

而g[i]也是一个道理,所以子数组可以分成两类,一类长度等于1,一类长度大于1。

  • 1. 长度为1,g[i] = nums[i]
  • 2. 长度>1,g[i] = g[i-1] + nums[i]

再求出两者之间的最小值 g[i] = min(nums[i], g[i-1] + nums[i])

总结

  • 1.状态表示:        
    • f[i]表示:以 i 位置为结尾的所有子数组中的最大和
    • g[i]表示:以 i 位置为结尾的所有子数组中的最小和
  • 2.推导状态转移方程:
    • f[i] = max(nums[i], f[i-1] + nums[i])
    • g[i] = min(nums[i], g[i-1] + nums[i])
  • 3.初始化:为了让f[i-1]和g[i-1]不发生越界,所以我们在数组开头额外添加一位,并将这个位置初始化为0(额外添加一位,注意下标映射关系)
  • 4.填表:从左向右填表,两个表一起填
  • 5.返回值:设f表最大值为fmax,g表的最小值为gmin,那么返回max(fmax, sum - gmin)。但是有一种特殊情况,如果数组中全是负数的情况,此时sum等于gmin,而此时的fmax为负数,但最终返回0,这种情况是不符合题意的,需要特殊处理。

题解:

class Solution 
{
public:
    int maxSubarraySumCircular(vector<int>& nums) 
    {
        int n = nums.size();
        vector<int> f(n + 1, 0);
        vector<int> g(n + 1, 0);
        //f[i]表示:以 i 位置为结尾的所有子数组中的最大和
        //g[i]表示:以 i 位置为结尾的所有子数组中的最小和

        int fmax = INT_MIN;
        int gmin = INT_MAX;
        int sum = 0;
        for (size_t i = 1; i < n + 1; i++)
        {
            f[i] = max(nums[i-1], f[i-1] + nums[i-1]);
            g[i] = min(nums[i-1], g[i-1] + nums[i-1]);
            fmax = max(f[i], fmax);
            gmin = min(g[i], gmin);
            sum += nums[i-1];
        }
        
        //返回结果
        if (sum == gmin)
            return fmax;
        else
            return max(fmax, sum - gmin);
    }
};

2.3 乘积最大子数组

题目链接:152. 乘积最大子数组 - 力扣(LeetCode)

算法原理:

状态表示

根据经验(以 i 位置为结尾.....) + 题目要求。我们可以将dp[i]设置为以 i 位置为结尾的所有子数组中的最大积。

我们需要将子数组分成两类,一类是子数组中只包含本身(长度为1),一类是自己加上前面的元素(长度大于1),第二种情况应该找到以 i 位置为结尾长度大于1的数组和最大的那一个,而dp[i-1]表示到达 i-1 位置时的最大子数组和,那么dp[i]就是在dp[i-1]的基础上再加上nums[i]。

所以我们能推导出状态转移方程为:dp[i] = max(nums[i], dp[i-1] * nums[i])。但是这里有个问题,如果nums[i] < 0,dp[i-1]表示i-1位置的最大积,最大积乘一个负数,那这里必然不是最大,所以说如果nums[i] < 0,dp[i-1]应该乘i-1位置的最小积。我们添加一个g[i]表示最小积。

  • 1. f[i]表示:以 i 位置为结尾的所有子数组中的最大积
  • 2. g[i]表示:以 i 位置为结尾的所有子数组中的最小积

状态转移方程

以i位置为结尾的所有子数组有两种情况。第一种是子数组只有自己一个数(长度为1),第二种是长度大于1的。

我们再求出上面所有情况的最大值,我们这里就不判断正负了,将两个值都求一下,便于写代码。

f[i] = max(nums[i], f[i-1]*nums[i], g[i-1]*nums[i])

g[i]和f[i]的情况是一样的。

g[i] = min(nums[i], g[i-1]*nums[i], f[i-1]*nums[i])

总结

  • 1.状态表示:        
    • f[i]表示:以 i 位置为结尾的所有子数组中的最大积
    • g[i]表示:以 i 位置为结尾的所有子数组中的最小积
  • 2.推导状态转移方程:
    • f[i] = max(nums[i], f[i-1]*nums[i], g[i-1]*nums[i])
    • g[i] = min(nums[i], g[i-1]*nums[i], f[i-1]*nums[i])
  • 3.初始化:为了让f[i-1]和g[i-1]不发生越界,所以我们在数组开头额外添加一位,并将这个位置初始化为1(额外添加一位,注意下标映射关系)
  • 4.填表:从左向右填表,两个表一起填
  • 5.返回值:返回f表的最大值。

题解:

class Solution 
{
public:
    int maxProduct(vector<int>& nums) 
    {
        int n = nums.size();
        //f[i]表示:以 i 位置为结尾的所有子数组中的最大积
        //g[i]表示:以 i 位置为结尾的所有子数组中的最小积

        vector<int> f(n+1, 1);
        vector<int> g(n+1, 1);

        int ret = INT_MIN;
        for (size_t i = 1; i < n + 1; i++)
        {
            int x = nums[i-1];
            int y = f[i-1] * nums[i-1];
            int z = g[i-1] * nums[i-1];
            f[i] = max(x, max(y, z));
            g[i] = min(x, min(y, z));
            ret = max(ret, f[i]);
        }
        return ret;
    }
};

2.4 乘积为正数的最长子数组长度

题目链接:1567. 乘积为正数的最长子数组长度 - 力扣(LeetCode)

算法原理:

状态表示

根据经验(以 i 位置为结尾.....) + 题目要求。我们可以将dp[i]设置为以 i 位置为结尾的所有子数组积为正数的最大数组长度。

根据我们的猜测就可以来推导状态转移方程了,但是我们想一下,如果只有一个dp表是无法满足题意的,如果nums[i]为负数时,dp[i]就只能设置为0,那么相当于是连续的数组断开了,只能重新开始计数。所以我们设置两个dp表。

  • f[i]表示:以 i 位置为结尾的所有子数组积为正数的最大数组长度。
  • g[i]表示:以 i 位置为结尾的所有子数组积为负数的最大数组长度。

状态转移方程

我们需要将子数组分成两类,一类是子数组中只包含本身(长度为1),一类是自己加上前面的元素(长度大于1)

  • 长度等于1,如果nums[i]>0,说明当前数是正数,f[i] = 1;如果nums[i]<0,说明当前数是负数,负数无法进入f表中,f[i] = 0
  • 长度大于1,如果nums[i]>0,说明可以和前面的i-1连在一起,f[i] = f[i-1]+1;如果nums[i]<0,那么需要和前面积为负数的连在一起,f[i] = g[i-1]==0 ? 0 : g[i-1] + 1(不能直接写成f[i] = g[i-1] + 1,因为如果当g[i-1]为0时,也就是说前一个数不为负数,那么就无法和前面数连接了。

我们可以进一步对上面的状态转移方程进行合并,首先长度为1的num[i]>0的情况和长度大于1的nums[i]>0的情况可以合并,因为后者的值是肯定大于等于1的,而我们最终是要求两者的最大值了,所以我们直接省去这一步。

同理,长度为1的num[i]<0的情况和nums[i]<0的情况也是可以合并的。


 

if (nums[i] > 0)
    f[i] = f[i-1] + 1
else if (nums[i] < 0)
    f[i] = g[i-1] == 0 ? 0 : g[i-1] + 1
else 
    f[i] = 0

我们现在得到了f[i]的状态转移方程,现在要推导一下g[i]的状态转移方程,和上面的f[i]是一样的。我们需要将子数组分成两类,一类是子数组中只包含本身(长度为1),一类是自己加上前面的元素(长度大于1)

  • 长度等于1,如果nums[i]>0,说明当前数是正数,正数不能进入g表,g[i] = 0;如果nums[i]<0,说明当前数是负数,g[i] = 1
  • 长度大于1,如果nums[i]>0,如果想让子数组乘积为0,那么i - 1的位置就要小于0,所以g[i] = g[i-1] == 0 ? 0 : g[i-1] + 1(如果前一个位置不是小于0的,那么i位置的子数组乘积也不能小于0,所以要判断g[i-1]为0时,g[i]也为0)。如果nums[i]<0,想要让乘积为0,那么就要去找乘积>0的子数组,所以g[i] = f[i-1] + 1。

同样,上面的状态转移方程也可以合并。


 

if (nums[i] > 0)
    g[i] = g[i-1] == 0 ? 0 : g[i-1] + 1
else if (nums[i] < 0)
    g[i] = f[i-1] + 1
else 
    g[i] = 0

总结

  • 1.状态表示:        
    • f[i]表示:以 i 位置为结尾的所有子数组积为正数的最大数组长度。
    • g[i]表示:以 i 位置为结尾的所有子数组积为负数的最大数组长度。
  • 2.推导状态转移方程:
  • 3.初始化:为了让f[i-1]和g[i-1]不发生越界,所以我们在数组开头额外添加一位,并将这个位置初始化为0(额外添加一位,注意下标映射关系)
  • 4.填表:从左向右填表,两个表一起填
  • 5.返回值:返回f表的最大值。
class Solution 
{
public:
    int getMaxLen(vector<int>& nums) 
    {
        int n = nums.size(); 
        //f[i]表示:以 i 位置为结尾的所有子数组积为正数的最大数组长度。
        //g[i]表示:以 i 位置为结尾的所有子数组积为负数的最大数组长度。
        vector<int> f(n+1, 0);
        vector<int> g(n+1, 0);

        int ret = INT_MIN;
        for (size_t i = 1; i < n + 1; i++)
        {
            if (nums[i-1] > 0)
            {
                f[i] = f[i-1] + 1;
                g[i] = g[i-1] == 0 ? 0 : g[i-1] + 1;
            }
            else if (nums[i-1] < 0)
            {
                f[i] = g[i-1] == 0 ? 0 : g[i-1] + 1;
                g[i] = f[i-1] + 1;
            }
            else 
            {
                f[i] = 0;
                g[i] = 0;
            }
            ret = max(ret, f[i]);
        }

        return ret;
    }
};

2.5 等差数列划分

题目链接:413. 等差数列划分 - 力扣(LeetCode)

算法原理:

状态表示

根据经验+题目要求,我们可以将dp[i]表示为:以 i 位置为结尾的所有子数组中是等差数列的个数。

状态转移方程

首先我们要明白等差数列的一个性质,如果[a,b,c,d]构成等成数列,如果新来了一个e,能让[c,d,e]构成等差数列,那么能推导出[a,b,c,d,e]也是一个等差数列

如果想让i位置构成等差数列,那么至少要和i-1,i-2位置进行比较,如下图,只有a,b,c能构成等差数列,i位置才有可能有更多的等差数列。

如果a,b,c能构成等差数列的话,那么我们就要进一步判断,以i-1位置为结尾的数能构成多少个等差数列了,a,b,c能构成等差数列,那么以b为结尾的所有等差数列中额外添加一个c还是能构成等差数列的。

在i-1位置时,存在一个子数组是a,b,此时这个子数组是不能构成等差数列的,到达i位置会给这个数组添加一个c,就构成等差数列了所以,i位置结尾的等差数列个数应该比i-1位置多一个。

  • c-b == b-a:dp[i] = dp[i-1] + 1
  • c-b != b-a:dp[i] = 0

总结

  • 1.状态表示:dp[i]表示为:以 i 位置为结尾的所有子数组中是等差数列的个数。
  • 2.推导状态转移方程:
    • c-b == b-a:dp[i] = dp[i-1] + 1
    • c-b != b-a:dp[i] = 0
  • 3.初始化:将dp[0]和dp[1]初始化为0
  • 4.填表:从左向右填表
  • 5.返回值:返回表中所有元素之和。

题解:

class Solution 
{
public:
    int numberOfArithmeticSlices(vector<int>& nums) 
    {
        int n = nums.size();
        //dp[i]表示为:以 i 位置为结尾的所有子数组中是等差数列的个数。
        vector<int> dp(n, 0);

        int ret = 0;
        for (size_t i = 2; i < n; i++)
        {
            if (nums[i] - nums[i-1] == nums[i-1] - nums[i-2])
                dp[i] = dp[i-1] + 1;
            else 
                dp[i] = 0;
            
            ret += dp[i];
        }
        return ret;
    }
};

2.6 最长湍流子数组

题目链接:978. 最长湍流子数组 - 力扣(LeetCode)

湍流子数组指的是相邻位置一上一下,如下图所示,题目要求找到最长的那个。

算法原理:

状态表示

根据经验+题目要求,我们将dp[i]表示为:以i位置为结尾的所有子数组中,最长的湍流子数组的长度

有了dp,我们可以来分析状态转移方程了,到达i位置时,我们需要通过联合i-1位置来判断变化趋势,一共有三种情况

  • 1.dp[i-1] > dp[i] 下降趋势
  • 2.dp[i-1] < dp[i] 上升趋势
  • 3.dp[i-1] = dp[i] 不变

但是有一个问题,题目要求一上一下,我们并不知道i-2到i-1的变化趋势,也就无法判断是否是湍流子数组。我们也可以进一步判断i-2到i-1的变化趋势,这种方式要增加比较多的判断,判断逻辑也较简单,大家可以先尝试一下这种做法。(这种做法的代码也会在后面展示)

比起前面一种,我们可以尝试更改状态表示,将到达i位置的情况进行细分,分为上升状态和下降状态。a

  • f[i]表示:以i位置为结尾的所有子数组中,最后是“上升”状态下的最长湍流子数组的长度。
  • g[i]表示:以i位置为结尾的所有子数组中,最后是“下降”状态下的最长湍流子数组的长度。

状态转移方程

我们假设nums[i-1]为a,nums[i]为b,那么到达i位置有三种情况,a>b, a<b, a=b,根据这三种情况,我们来分析状态转移方程。

在初始化时,我们可以将所有位置初始化为1,那么可以大大减少判断逻辑。

总结

  • 1.状态表示:
    • f[i]表示:以i位置为结尾的所有子数组中,最后是“上升”状态下的最长湍流子数组的长度。
    • g[i]表示:以i位置为结尾的所有子数组中,最后是“下降”状态下的最长湍流子数组的长度。
  • 2.推导状态转移方程:
    • if (a < b) f[i] = g[i-1] + 1
    • if (a > b) g[i] = f[i-1] + 1
  • 3.初始化:将f表和g表中所有元素初始化为1
  • 4.填表:从左向右,两个表一起填
  • 5.返回值:返回两个表中的最大值。

题解:

(方法一)dp[i]表示为:以i位置为结尾的所有子数组中,最长的湍流子数组的长度。

class Solution 
{
public:
    int maxTurbulenceSize(vector<int>& arr) 
    {
        int n = arr.size();
        if (n == 1)
            return 1;

        //dp[i]表示为:以i位置为结尾的所有子数组中,最长的湍流子数组的长度。
        vector<int> dp(n, 0);
        dp[0] = 1;
        dp[1] = arr[0] == arr[1] ? 1 : 2;

        int ret = dp[1];
        for (size_t i = 2; i < n; i++)
        {
            if (arr[i-1] > arr[i])
            {
                //当前是下降趋势,判断i-2到i-1位置
                if (arr[i-2] < arr[i-1])
                    dp[i] = dp[i-1] + 1; //上升则满足湍流子数组
                else 
                    dp[i] = 2; //不满足湍流子数组,长度为2(i-1和i两个位置的子数组)
            }
            else if (arr[i-1] < arr[i])
            {
                //当前位置为上升趋势
                if (arr[i-2] > arr[i-1])
                    dp[i] = dp[i-1] + 1; //下降满足湍流子数组
                else 
                    dp[i] = 2; //不满足湍流子数组
            }
            else 
            {
                dp[i] = 1;
            }

            ret = max(ret, dp[i]);
        }

        return ret;
    }
};
  • (方法二)
  • f[i]表示:以i位置为结尾的所有子数组中,最后是“上升”状态下的最长湍流子数组的长度。
  • g[i]表示:以i位置为结尾的所有子数组中,最后是“下降”状态下的最长湍流子数组的长度。
class Solution 
{
public:
    int maxTurbulenceSize(vector<int>& arr) 
    {
        int n = arr.size();
        //f[i]表示:以i位置为结尾的所有子数组中,最后是“上升”状态下的最长湍流子数组的长度。
        //g[i]表示:以i位置为结尾的所有子数组中,最后是“下降”状态下的最长湍流子数组的长度。
        vector<int> f(n, 1);
        vector<int> g(n, 1);

        int ret = 1;
        for (size_t i = 1; i < n; i++)
        {
            if (arr[i-1] < arr[i]) f[i] = g[i-1] + 1;
            if (arr[i-1] > arr[i]) g[i] = f[i-1] + 1;

            ret = max(ret, f[i]);
            ret = max(ret, g[i]);
        }

        return ret;
    }
};

2.7 单词拆分

题目链接:139. 单词拆分 - 力扣(LeetCode)

算法原理:

状态表示

根据经验+题目要求,我们可以让dp[i]表示为:[0, i]区间内的字符,能否被字典中的单词拼接而成,为true表示可以,为false表示不可以。

也就是说以i结尾的所有子字符串中,要找到一个满足这个子字符串(最后一个单词)要在字典中出现,并且最后一个单词前面的位置也要保证可以被字典中的单词拼接。

状态转移方程

我们设 j 为最后一个单词的起始下标,相当于是 i 是这个单词最后一个位置的下标,首先我们要保证[0, j-1]的位置可以被字典中的单词组成,而这个由dp[j-1]就可以保证,只要dp[j-1]为true,就可以确保前面部分没有问题。

再来看[j, i]位置,将这个位置从原字符串中切割下来,然后在字典中查找。

如果上面两个都成立,则说明 i 位置结束时,[0, i]区间内是可以满足被字典中的单词组成。

总结

  • 1.状态表示:dp[i]表示为:[0, i]区间内的字符,能否被字典中的单词拼接而成
  • 2.推导状态转移方程:
  • 3.初始化:额外添加一个辅助位,防止数组越界,将dp[i]为0(为了让s和dp的下标统一,所以将s中也添加一个辅助位)
  • 4.填表:从左向右填表
  • 5.返回值:返回dp[n]。

题解:

class Solution 
{
public:
    bool wordBreak(string s, vector<string>& wordDict) 
    {
        //将wordDict中的单词假如哈希表,加快查找速度
        unordered_set<string> hashMap(wordDict.begin(), wordDict.end());

        //初始化
        int n = s.size();
        s = " " + s;
        //dp[i]表示:以i位置为结尾时,[0,i]能否被单词拼接
        vector<bool> dp(n + 1, false);
        dp[0] = true;

        for (int i = 1; i < n + 1; i++)
        {
            for (int j = i; j >= 1; j--)
            {
                //如果[0, j-1]位置不能被拼接,直接结束
                if (dp[j-1] != true)
                    continue;
                
                //判断[j, i]能否构成一个单词
                string str = s.substr(j, i-j+1);
                if (hashMap.count(str) != 0)
                {
                    dp[i] = true;
                    break;
                }
            }
        }

        return dp[n];
    }
};

2.8 环绕字符串中唯一的子字符串

题目链接:467. 环绕字符串中唯一的子字符串 - 力扣(LeetCode)

算法原理:

状态表示

根据经验+题目要求,我们可以将dp[i]表示为:以 i 位置为结尾的所有子串中在base中出现的个数。

状态转移方程

以 i 位置为结尾的子串有两种情况,第一种是长度为1(只包含自己),那么此时的dp[i]就是1,第二种是长度大于1的。如果是第二种长度大于1的,那么s[i]需要和s[i-1]进行比较,这时又会有两种情况,1.s[i]是s[i-1]在字母表中的下一个数,2.s[i-1]是z,s[i]是a,这两种情况都是满足题意的,所以只需要满足其中之一,dp[i] = dp[i-1]。

最终dp[i] = 1 + dp[i-1]。

总结

  • 1.状态表示:dp[i]表示为:以 i 位置为结尾的所有子串中在base中出现的个数。
  • 2.推导状态转移方程:
  • 3.初始化:dp[i]至少为1,所以将dp表初始化为1
  • 4.填表:从左向右填表
  • 5.返回值:不能返回整个dp表之和,因为子串中可能会重复,我们只需要返回相同字符结尾中dp值大的那个(创建一个大小为26的字母数组,记录以相应字符为结尾时,最大的dp值,最终返回这个数组之和)。

题解:

class Solution 
{
public:
    int findSubstringInWraproundString(string s) 
    {
        int n = s.size();
        //dp[i]:以i位置为结尾时,所有子串中在base里出现的个数
        vector<int> dp(n, 1);
        vector<int> arr(26, 0);
        arr[s[0] - 'a'] = 1; 

        for (size_t i = 1; i < n; i++)
        {
            //判断当前字符能否和前一个字符相连
            if (s[i] == s[i-1] + 1 || (s[i] == 'a' && s[i-1] == 'z'))
                dp[i] += dp[i-1];
            
            int pos = s[i] - 'a';
            arr[pos] = max(arr[pos], dp[i]);
        }

        int ret = 0;
        for (size_t i = 0; i < 26; i++)
            ret += arr[i];
        return ret;
    }
};

3. 子序列

我们在学习子序列问题之前,需要了解什么是子序列,什么是子数组。

子序列是从左往右,任意挑选k个数组成的序列,而子数组是从左往右,连续挑选k个数组成的序列。这两个序列都要满足序列中的每一个数在原序列中相对顺序是不变的,并且序列的大小为1 <= k <= n(n为原序列大小)

所以子序列和子数组之间的关系是:子数组是子序列的子集。

原序列中每个数都有选和不选两种情况,所以子序列的个数是指数级别的,而子数组的个数类似于一个等差数列,所以是平方级别的,这两个的个数可以说不是一个级别的,所以子序列问题的难度也是比较大的。

在子序列问题中,状态表示通常都是通过经验+题目要求得到的,也就是说dp[i]表示为:以i位置为结尾怎么怎么样或者以i位置为起点怎么怎么样。

3.1 最长递增子序列

题目链接:300. 最长递增子序列 - 力扣(LeetCode)

这题是非常经典的一道题,并且在子序列问题中也比较简单,大家一定要弄懂。

算法原理:

状态表示

根据经验+状态表示,dp[i]表示为:以 i 位置为结尾,所有子序列中最长递增子序列的长度

状态转移方程

以 i 位置结尾的子序列,我们可以将它分成以下几种情况,nums是原序列(题目给定的)。

  • 1. 单独的一个nums[i]。
  • 2. nums[i]的前面是nums[i-1]
  • 3. nums[i]的前面是nums[i-2]
  • 4. nums[i]的前面是nums[i-3]
  • 5. ......

如果子序列的长度为1,那么dp[i] = 1

如果子序列的长度大于1,那么要保证 i 位置的前一个元素 j 位置元素一定要小于 i 位置元素,只有这样才能满足题目中的递增要求,如果能满足,那我们就要找到以 j 位置为结尾的最长递增子序列,因为以 j 结尾的子序列加上 i 也一定还是个递增子序列,这个值就保存在dp[j]中。

由于 0 <= j <= i -1,所以可能会有很多个 j 满足nums[j] < nums[i],所以我们要在所有的递增子序列中找到一个最大的,所以dp[i] = max(dp[j] + 1)

总结

  • 1.状态表示:dp[i]表示为:以 i 位置为结尾,所有子序列中最长递增子序列的长度。
  • 2.推导状态转移方程:dp[i] = max(dp[j] + 1)
  • 3.初始化:dp[i]至少为1,所以将dp表初始化为1
  • 4.填表:从左向右填表
  • 5.返回值:返回dp表中的最大值。

题解:

class Solution 
{
public:
    int lengthOfLIS(vector<int>& nums) 
    {
        int n = nums.size();
        //dp[i]表示:以i位置为结尾的最长递增子序列的长度
        vector<int> dp(n, 1);

        int ret = 1;
        for (int i = 0; i < n; i++)
        {
            for (int j = i - 1; j >= 0; j--)
            {
                if (nums[j] < nums[i])
                    dp[i] = max(dp[i], dp[j] + 1);
            }
            ret = max(ret, dp[i]);
        }  
        return ret;
    }
};

3.2 摆动序列

题目链接:376. 摆动序列 - 力扣(LeetCode)

这题的题目意思就是:如果一个子序列满足:序列中的所有元素是一上一下(变化趋势),那么就称这个序列为摆动序列,我们需要找到最长的摆动序列。

算法原理:

状态表示

根据经验+题目要求,dp[i]可以表示为:以 i 位置为结尾的所有子序列中,最长摆动序列的长度。但是到达 i 位置是有两种情况的,第一种是以下降状态到达i,第二种是以上升状态到达i,所以我们创建两个dp表

  • f[i]:以 i 位置元素为结尾的所有的子序列中,最后一个位置呈现“上升”趋势的最长摆动序列的长度
  • g[i]:以 i 位置元素为结尾的所有的子序列中,最后一个位置呈现“下降”趋势的最长摆动序列的长度

状态转移方程

以 i 位置结尾的子序列,我们可以将它分成以下几种情况,nums是原序列(题目给定的)。

  • 1. 单独的一个nums[i]。
  • 2. nums[i]的前面是nums[i-1]
  • 3. nums[i]的前面是nums[i-2]
  • 4. nums[i]的前面是nums[i-3]
  • 5. ......

这么多情况可以分成两大类:1.单独一个nums[i] 2.和前面的子序列拼接,我们先来分析f表,即到达i位置程序上升。

  • 第一种情况(子序列长度为1):f[i] = 1
  • 第二种情况(子序列长度大于1):设j为[0, i-1]之间一个数,如果找到nums[j] < nums[i],那么就可以满足从j到i是上升的,但是还要保证到达j位置时是下降的,即g[j]。所以f[i] = g[j] + 1,但是j有很多种情况,我们要找到所有情况下的最大值,f[i] = max(g[j] + 1)。

我们再来分析以下g表,即到达i位置时,呈现下降趋势。

  • 第一种情况(子序列长度为1):g[i] = 1
  • 第二种情况(子序列长度大于1):设j为[0, i-1]之间一个数,如果找到nums[j] > nums[i],那么就可以满足从j到i是下降的,但是还要保证到达j位置时是上升的,即f[j]。所以g[i] = f[j] + 1,但是j有很多种情况,我们要找到所有情况下的最大值,g[i] = max(f[j] + 1)。

总结

  • 1.状态表示
    • ​​​f[i]:以 i 位置元素为结尾的所有的子序列中,最后一个位置呈现“上升”趋势的最长摆动序列的长度
    • g[i]:以 i 位置元素为结尾的所有的子序列中,最后一个位置呈现“下降”趋势的最长摆动序列的长度
  • 2.推导状态转移方程:
    • f[i] = max(g[j] + 1)
    • g[i] = max(f[j] + 1)
  • 3.初始化:dp[i]至少为1,所以将dp表初始化为1
  • 4.填表:从左向右,两个表一起填
  • 5.返回值:返回f表和g表中的最大值。

题解:

class Solution 
{
public:
    int wiggleMaxLength(vector<int>& nums) 
    {
        //​//​​​​​​​​​f[i]:以 i 位置元素为结尾的所有的子序列中,最后一个位置呈现“上升”趋势的最长摆动序列的长度
        g[i]:以 i 位置元素为结尾的所有的子序列中,最后一个位置呈现“下降”趋势的最长摆动序列的长度

        int n = nums.size();
        vector<int> f(n, 1);
        vector<int> g(n, 1);

        int ret = 1;
        for (int i = 0; i < n; i++)
        {
            for (int j = i - 1; j >= 0; j--)
            {
                //上升
                if (nums[j] < nums[i])
                    f[i] = max(f[i], g[j] + 1);
                else if (nums[j] > nums[i])
                    g[i] = max(g[i], f[j] + 1);
            }
            ret = max(ret, f[i]);
            ret = max(ret, g[i]);
        }

        return ret;
    }
};

关于效率:

这两题的效率都不高,因为这两题的最优解是贪心,而不是动态规划,动态规划在求解子序列问题时具有通性,可以将这两题看作子序列的一个模板,而贪心的策略是每道题可能都不一样的。

3.3 最长递增子序列的个数

题目链接:673. 最长递增子序列的个数 - 力扣(LeetCode)

这题就是在最长递增子序列的一个变型,其实本质上是一样的,所以在看这题之前,先去看一下最长递增子序列。

算法原理:

在解决这道题之前,我们先学习一个策略:如何使用一次遍历,找到最大值出现的次数,数组如下所示,

我们使用maxval来记录最大值,使用count来记录最大值出现的次数从左往右遍历的过程中,会出现三种情况,记当前值为x

  • 1. maxval == x,将count+1
  • 2. maxval < x,maxval = x,count = 1
  • 3. maxval > x,什么都不用做

通过以上的方法,只需要一次遍历就可以找出最大值,以及最大值出现的次数,

状态表示

还是根据经验 + 题目要求,我们将dp[i]表示为:以 i 位置为结尾的所有子序列中,最长递增子序列的个数。

但是只有一个状态表示肯定是不行的,现在我连最长递增子序列的长度都不知道,怎么求他的个数,所以我们使用两个dp表。

  • len[i]表示:以 i 位置为结尾的所有子序列中,最长递增子序列的长度。
  • count[i]表示:以 i 位置为结尾的所有子序列中,最长递增子序列的个数。

状态转移方程

以 i 位置结尾的子序列,我们可以将它分成以下几种情况,nums是原序列(题目给定的)

  • 1. 单独的一个nums[i]。
  • 2. nums[i]的前面是nums[i-1]
  • 3. nums[i]的前面是nums[i-2]
  • 4. nums[i]的前面是nums[i-3]
  • 5. ......

我们先来求len表

  • 如果子序列的长度为1,那么len[i] = 1
  • 如果子序列的长度大于1,那么要保证 i 位置的前一个元素 j 位置元素一定要小于 i 位置元素,只有这样才能满足题目中的递增要求,如果能满足,那我们就要找到以 j 位置为结尾的最长递增子序列,因为以 j 结尾的子序列加上 i 也一定还是个递增子序列,这个值就保存在len[j]中。

由于 0 <= j <= i -1,所以可能会有很多个 j 满足nums[j] < nums[i],所以我们要在所有的递增子序列中找到一个最大的,所以len[i] = max(len[j] + 1)。

接下来就要分析一下count表了,在前面我们分析过,可以使用一次遍历,既找到最大值又找到最大值出现的次数的,所以我们可以将两个表结合在一起填。

和前面一样,所有的子序列可以分成两大类,第一类是子序列长度为1,第二类是子序列长度大于1

1.子序列长度为1,count[i] = 1

2.子序列长度大于1,也就是说在[0, i-1]区间内,能找到一个j,使得nums[j] < nums[i],也就是说nums[i]会拼到以j为结尾的子序列的后面,而这又可以分成三种情况,len[j]+1可以理解为,将i拼接到j最长递增子序列后面的长度,len[i]是不跟着j位置后面的长度。

  • len[j] + 1 == len[i],i位置是可以跟着j位置后面的,并且i位置也有自己的最长递增子序列,所以count[i] += count[j]。
  • len[j] + 1 < len[i],i拼接到j位置后面的最长递增子序列长度,没有以i位置为结尾的最长递增子序列长度大,那么count[i]最大还是本身,不用管.
  • len[j] + 1 > len[i],也就是说跟着j后面的这种情况才是最大的,没有自己单独的最长递增子序列,那么我们需要更新最大值以及计数,len[i] = len[j] + 1,count[i]=count[j]。

总结

  • 1.状态表示
    • len[i]表示:以 i 位置为结尾的所有子序列中,最长递增子序列的长度。
    • count[i]表示:以 i 位置为结尾的所有子序列中,最长递增子序列的个数。
  • 2.推导状态转移方程:
  • 3.初始化:len[i]和count[i]至少为1,所以将dp表初始化为1
  • 4.填表:从左向右,两个表一起填
  • 5.返回值:返回count表中的最大值。
class Solution 
{
public:
    int findNumberOfLIS(vector<int>& nums) 
    {
        int n = nums.size();
        //len[i]表示:以 i 位置为结尾的所有子序列中,最长递增子序列的长度。
        //count[i]表示:以 i 位置为结尾的所有子序列中,最长递增子序列的个数。
        vector<int> len(n, 1);
        vector<int> count(n, 1);        

        int maxlen = 0; //最长递增子序列
        int maxcount = 1; //最长递增子序列的个数
        for (int i = 0; i < n; i++)
        {
            for (int j = i - 1; j >= 0; --j)
            {
                if (nums[j] < nums[i])
                {
                    //如果拼接到j后面的长度和不拼接相同 -> 可以拼到j后面
                    if (len[j] + 1 == len[i])
                        count[i] += count[j];
                    //拼到j后面不不拼的长度大,那么选择拼接到j后面 -> 更新长度以及计数
                    else if (len[j] + 1 > len[i])
                    {
                        len[i] = len[j] + 1;
                        count[i] = count[j];
                    }
                }
            }
            
            if (len[i] > maxlen)
            {
                maxlen = len[i];
                maxcount = count[i];
            }
            else if (len[i] == maxlen)
            {
                maxcount += count[i];
            }
        }
        
        return maxcount;
    }
};

3.4 最长数对链

题目链接:646. 最长数对链 - 力扣(LeetCode)

算法原理:

这道题和前面不同的点在于,这题的填表顺序是不确定的,按照前面的例子中,我填写i位置,i位置的上一个位置就是在i位置的左边的。这道题不一样,i位置为结尾,他的上一个位置可能在他前面也可能在后面。

所以我们可以先对原始数组排序,只需要根据第一个值排序即可,排完序后,i位置的数对链是肯定连不到i后置后面的数对链的。

如果想让[a,b]连接到[c,d]后面,必须要满足d < a。但是排完序后,a < b, c < d, a <= c,即a <= c < d,所以排完序后,i只能和i位置之前的数对进行相连。

在预处理后,这题其实和最长递增子序列是一模一样的。

状态表示

dp[i]表示:以i位置元素为结尾的所有数对链中,最长的数对链的长度。

状态转移方程

以 i 位置结尾的数对链,我们可以将它分成以下几种情况

  • pairs[i]单独形成数对链
  • pairs[i]的前面是pairs[i-1]
  • pairs[i]的前面是pairs[i-2]
  • pairs[i]的前面是pairs[i-3]
  • ......

上面这么多种情况可以分成两大类,1.长度为1,2.长度大于1

  • 1.长度为1 dp[i] = i
  • 2. 长度大于1,并且在[0, i-1]中找到一个j满足p[j][1] < p[i][0],则dp[i] = dp[j] + 1,由于j有很多种可能,我们要求这么多情况下的最大值,即max(dp[j] + 1)。

总结

  • 1.状态表示:dp[i]表示:以i位置元素为结尾的所有数对链中,最长的数对链的长度。
  • 2.推导状态转移方程:dp[i] = max(dp[j] + 1)
  • 3.初始化:dp[i]至少为1,所以将dp表初始化为1
  • 4.填表:从左向右填表
  • 5.返回值:返回dp表中的最大值。

题解:

class Solution 
{
public:
    int findLongestChain(vector<vector<int>>& pairs) 
    {
        int n = pairs.size();
        //dp[i]表示:以i位置元素为结尾的所有数对链中,最长的数对链的长度。
        vector<int> dp(n, 1);
        sort(pairs.begin(), pairs.end());

        int ret = 1;
        for (int i = 1; i < n; i++)
        {
            for (int j = i - 1; j >= 0; j--)
            {
                if (pairs[j][1] < pairs[i][0])
                {
                    dp[i] = max(dp[i], dp[j] + 1);
                }
            }
            ret = max(ret, dp[i]);
        }

        return ret;
    }
};

3.5 最长定长子序列

题目链接:1218. 最长定差子序列 - 力扣(LeetCode)

算法原理:

状态表示

根据经验+题目要求,dp[i]表示为:以i位置的元素为结尾所有的子序列中,最长的定差子序列的长度。

状态转移方程

以 i 位置结尾的所有定差子序列,我们可以将它分成以下两种情况。

  • 1.长度为1,单独自己形成定差子序列
  • 2.长度大于1,与前面元素结合,形成定差子序列,也就是说在[0, i-1]中找到一个arr[j]满足arr[i] - arr[j] = difference(满足定差条件)

如果说j3,j2,j1都满足到达a是定长的,那么dp[j1] >= dp[j2] >= dp[j3],因为arr[j3]=arr[j2]=arr[j1]的,所以如果要有前驱,那么他们都会有,反而在后面的,可能会出现其他没有的前驱,所以我们只需要找到最大的那个b,也就是j1位置即可。

那么我们现在的思路大概就是,从i位置向前找,如果能找到一个j满足定差的要求,就让dp[i] = dp[j] + 1,这种方式也无可厚非,但是这题的子序列性质是定差的,也就是说我到达i位置,我知道我上一个位置的值一定是arr[i] - difference,所以我们可以提前将b和dp[j]保存起来放到一个哈希表里面,这样就不用遍历了。

我们甚至可以将a和dp[i]也放到哈希表中,最终我们就不要dp表了,直接在哈希表中做动态规划。

总结

  • 1.状态表示:dp[i]表示为:以i位置的元素为结尾所有的子序列中,最长的定差子序列的长度。
  • 2.推导状态转移方程:
    • ​​​​​​​​​​​​​​
  • 3.初始化:hash[arr[0]] = 1(第一个元素的最长定差子序列长度为1)
  • 4.填表:从左向右填表
  • 5.返回值:返回hash表中的最大值。
class Solution 
{
public:
    int longestSubsequence(vector<int>& arr, int difference) 
    {
        unordered_map<int, int> hash;
        hash[arr[0]] = 1;
        int n = arr.size();
        int ret = 1;

        for (size_t i = 1; i < n; i++)
        {
            //在hash表中找值为arr[i] - difference的那个数
            int target = arr[i] - difference;
            if (hash.count(target) != 0)
                hash[arr[i]] = hash[target] + 1;
            else 
                hash[arr[i]] = 1;
            
            ret = max(ret, hash[arr[i]]);
        } 
        return ret;
    }
};

3.6 最长的斐波那契子序列的长度

题目链接:LCR 093. 最长的斐波那契子序列的长度 - 力扣(LeetCode)

算法原理:

状态表示

根据经验+状态表示,dp[i]表示:以i位置为结尾的所有子序列中,最长的斐波那契子序列的长度。

如果只有一个dp表显然是不行的,dp[i]只能保存长度,而我们不知道上一个斐波那契数是什么,所以上面的状态表示是错误的。

只有一个dp表搞不出来斐波那契子序列,但是如果有两个呢,在斐波那契数中,只要我们有其中两个相邻的值,那么我们就能把这个斐波那契数列推到出来,这个也很好得到,比如我们以及a, b,那么a的上一个数就是 b - a,b - a的上一个数是2a - b,也就是说如果有二维的dp表就可以帮我们解决上面的问题。

dp[i][j]表示:以 i 位置以及 j 位置的元素为结尾的所有的子序列中,最长的斐波那契子序列的长度。有了 i 和 j 两个位置,就能很容易得出上一个斐波那契数是什么。

状态转移方程

假设i位置对应b,j位置对应c,我们要找一个k满足a = c - b。

那么a有几种情况:

  • 1.a存在(数组中能找到a),并且a < b,找到以[k][i]为结尾的最长斐波那契子序列,即dp[k][i]
  • 2.a存在,但b < a < c (无法构成斐波那契数,b,c必须为后面两个数)
  • 3.a不存在(无法构成斐波那契子序列),让dp[i][j] = 2(题目要求最少是3,让dp[i][j]为2是为了方便后面填表)

这里有一个优化的策略,如果i和j确定了,那么我们要找的值也就确定了,但是我们需要遍历一遍数组才能找到那个值,所以我们可以提前将他保存起来:将数组中所有元素以及其下标放到哈希表中(这题是严格递增,不会存在相同元素)

总结

  • 1.状态表示:dp[i][j]表示:以 i 位置以及 j 位置的元素为结尾的所有的子序列中,最长的斐波那契子序列的长度
  • 2.推导状态转移方程:
    • ​​​​​​​
  • 3.初始化:表中所有元素都初始化为2,因为要满足i < j,所以只会使用到二维dp表的上半部分
  • 4.填表:在填dp[i][j]时要有dp[k][i],所以从上往下填表
  • 5.返回值:返回dp表中的最大值,如果最终ret小于3,说明表中不存在最长斐波那契子序列返回0。

题解:

class Solution 
{
public:
    int lenLongestFibSubseq(vector<int>& arr) 
    {
        int n = arr.size();
        //dp[i][j]表示:以i位置以及j位置的元素为结尾的所有的子序列中,最长的斐波那契子序列的长度
        vector<vector<int>> dp(n, vector<int>(n, 2));
        unordered_map<int, int> hash;
        for (size_t i = 0; i < n; i++)
            hash[arr[i]] = i;

        int ret = 2;
        for (size_t i = 1; i < n; i++)
        {
            for (size_t j = i + 1; j < n; j++)
            {
                //查找arr[i]和arr[j]前面的那个数
                int target = arr[j] - arr[i];
                if (target < arr[i] && hash.count(target) != 0)
                {
                    dp[i][j] = dp[hash[target]][i] + 1;
                    ret = max(ret, dp[i][j]);
                }
            }
        }

        return ret < 3 ? 0 : ret;
    }
};

3.7 最长等差数列

题目链接:1027. 最长等差数列 - 力扣(LeetCode)

算法原理:

状态表示

根据经验+题目要求,这里的经验依然是以某一个位置为结尾。

dp[i]表示为:以 i 位置元素为结尾的子序列中,最长的等差数列的长度。

只有一个dp表显然是不够的,如果只有上面一个dp表保存最长等差数列的长度,那么到达i位置时,我们是无法找到一个j保证 i 位置元素拼接到 j 后面是一个等差数列。

这题和上面一题有点像,如果我们知道最后两个元素,那我们就能知道这个等差数列是什么样子,我们能通过后面两个元素找到前面有个元素,比如最后两个元素为a,b,那么就能得到公差d为b-a,那么a前一个元素就是a-d=2a-b,能确定前一个元素才能在dp表中找最长的等差数列长度。

dp[i][j]表示为:以 i 位置元素以及 j 位置元素为结尾的子序列中,最长的等差数列的长度。(i < j)

状态转移方程

假设下标 i 位置对应的值为b,下标 j 位置对应的值为c,那么我们就能计算出a,a=2b - c。

a其实有三种情况的:

  • 1.a < b(正确的情况),但是可能会存在很多个a满足条件,我们只需要找到最后面的那个a即可,因为如果要有前驱,那么他们都会有,反而在后面的,可能会出现其他没有的前驱,所以我们只需要找到最后面的那个a,而以[k][i]位置为结尾的再加上j就是[i][j]位置最长的等差数列,dp[i][j] = dp[k][i] + 1。
  • 2.b < a(因为我们规定了最后两个元素是b和c,所以认为这种情况是不正确的),在计算长度时,我们不要a,让b,c单独构成等差数列,dp[i][j] = 2
  • 3.a在nums中不存在 ,b和c两个数构成等差数列,dp[i][j] = 2。

优化,在[i][j]位置找2a-b时,需要遍历一遍数组,那么我们可以提前将2a-b以及下标保存起来,但是这道题和最长定长子序列不一样,我们不能提前把所有的值以及其下标保存到数组当中,因为如果存在相同的值,就会将旧值覆盖掉。

比如上面的数组,在进行填写哈希表后

3对应的下标就是5了,那么如果 i 在6的位置,j在9的位置,他们要找 3 只会找到下标为5的,但是我们默认是想让 i,j为最后两个元素,所以最终找不到在i前面的3,导致出错。

所以我们可以边填表边将前面的数以及下标关系假如哈希表中。比如固定一个i,让j向后找,遍历之后,将hash[nums[i]] = i的映射关系保存起来,再让i+1遍历下一个位置。

总结

  • 1.状态表示:dp[i][j]表示为:以 i 位置元素以及 j 位置元素为结尾的子序列中,最长的等差数列的长度。(i < j)
  • 2.推导状态转移方程:
  • 3.初始化:表中所有元素都初始化为2,因为要满足i < j,所以只会使用到二维dp表的上半部分
  • 4.填表:在填dp[i][j]时要有dp[k][i],所以从上往下填表
  • 5.返回值:返回dp表中的最大值。

题解:

class Solution 
{
public:
    int longestArithSeqLength(vector<int>& nums) 
    {
        int n = nums.size();
        //dp[i][j]表示为:以i位置元素以及j位置元素为结尾的子序列中,最长的等差数列的长度。(i < j)
        vector<vector<int>> dp(n, vector<int>(n, 2));
        unordered_map<int, int> hash;
        hash[nums[0]] = 0;

        int ret = 2;
        for (size_t i = 1; i < n; i++)
        {
            for (size_t j = i + 1; j < n; j++)
            {
                //d=nums[j]-nums[i]
                //能否找到一个值为nums[i]-d=2*nums[i]-nums[j]的值
                int target = 2 * nums[i] - nums[j];
                if (hash.count(target) != 0)
                {
                    dp[i][j] = dp[hash[target]][i] + 1;
                    ret = max(ret, dp[i][j]); 
                }
            }
            hash[nums[i]] = i;
        }

        return ret;
    }
};

3.8 等差数列划分II - 子序列

题目链接:446. 等差数列划分 II - 子序列 - 力扣(LeetCode)

题目让我们找到数组中的所有子序列中,等差子序列的个数。

算法原理:

状态表示

根据经验+题目要求,我们将dp[i]表示为:以i位置的元素为结尾的所有子序列中,等差子序列的个数。

根据前几题的经验,我们很容易知道,只有一个dp表是无法写出这道题的,因为此时我们只有等差子序列的个数,并不知到 i 位置结点的等差序列的前一个元素是什么,我们无法确定一个具体的子序列。

在等差数列中,我们只需要知道相邻的两个元素,那我们就能还原出整个等差数列,这里也是一样的道理,比如最后两个是a,b。那么公差d = b - a,a前面那个数就是a-d=2a-b,前面的所有数都可以通过这种方式求出来。

所以我们让dp[i][j]表示为:以 i 位置和 j 位置的元素为结尾的所有子序列中,等差子序列的个数。(i < j)。

状态转移方程

假设下标 i 位置对应的值为b,下标 j 位置对应的值为c,那么我们就能计算出a,a=2b - c。

a其实有三种情况的:

  • 1.a < b(正确的情况)也就是说能找到一个k满足j可以拼接到k,i 的后面,所以只要以[k][j]为结尾的位置的等差数列我们都要算上,dp[i][j] = dp[k][i] + 1(k,i,j三个可以形成一个等差数列,所以要+1)
  • 2.b < a(因为我们规定了最后两个元素是b和c,所以认为这种情况是不正确的),在计算长度时,我们不要a,让b,c单独构成等差数列,dp[i][j] = 2
  • 3.a在nums中不存在 ,b和c两个数构成等差数列,dp[i][j] = 2。

优化,在[i][j]位置找2a-b时,需要遍历一遍数组,那么我们可以提前将2a-b以及下标保存起来,我们在dp之前可以使用一个hash表将所有值以及其下标映射关系保存起来,这样只用O(1)的时间复杂度就能找到前驱节点,因为可能会出现重复值,所以键值对中的value可以使用vector来表示。

总结

  • 1.状态表示:dp[i][j]表示为:以 i 位置和 j 位置的元素为结尾的所有子序列中,等差子序列的个数。(i < j)。
  • 2.推导状态转移方程:
  • 3.初始化:表中所有元素都初始化为0
  • 4.填表: 在填dp[i][j]时要有dp[k][i],所以从上往下填表
  • 5.返回值:返回dp表中所有元素的和。

题解:

class Solution 
{
public:
    int numberOfArithmeticSlices(vector<int>& nums) 
    {
        int n = nums.size();
        //dp[i][j]表示为:以 i 位置和 j 位置的元素为结尾的所有子序列中,等差子序列的个数。(i < j)。
        vector<vector<int>> dp(n, vector<int>(n, 0));
        unordered_map<long long, vector<int>> hash;
        for (size_t i = 0; i < n; i++)
            hash[nums[i]].push_back(i);
        
        int ret = 0;
        for (size_t i = 1; i < n; i++)
        {
            for (size_t j = i + 1; j < n; j++)
            {
                //能否找到一个值为nums[i]-d=2*nums[i]-nums[j]的值
                long long target = 2 * (long long)nums[i] - nums[j];
                if (hash.count(target) != 0)
                {
                    for (auto k : hash[target])
                        if (k < i)
                            dp[i][j] += (dp[k][i] + 1);
                }
                ret += dp[i][j];
            }
        }

        return ret;
    }
};

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值