LeetCode刷题笔记(6):动态规划

动态规划是解决带重叠子问题的最优化问题的一种有效解法。动态规划自底向下进行,即先解决子问题,再解决父问题。这与带状态记录(memoization)的分治算法相反,其是自上向下搜索到子问题,用状态记录避免子问题被重复求解。

动态规划的关键是建立状态转移方程,因此选择合适的状态量十分重要。状态转移方程的建立可以这样思考:第 i 个状态是由之前的某些状态得到的,而我们要从中选出符合条件的最优转移过程。

一维问题

一般设dp[i]表示第i项满足XX,考察i与之前若干项关系。

1、爬楼梯(70)

本题在建立状态转移方程时,要注意到每次只能跨1级或2级台阶。这说明第 i 级台阶上一级状态只能是第i-1或第i-2级台阶。设dp[ i ]表示走到第 i 阶台阶的方法数,则dp[i] = dp[i-1] + dp[i-2]。即斐波那契数列。由于每个状态只取决于前两个状态,因此我们没必要一直保存所有过程状态,可以用滚动数组的思想,只保留当前状态的前两个状态。

class Solution {
public:
    int climbStairs(int n) {
      if(n<=2) return n;
      int pre1 = 1, pre2 = 2, cur;
      for(int i=2;i<n;i++){
        cur = pre1 + pre2;
        pre1 = pre2;
        pre2 = cur;
      }
      return cur;
    }
};

2、打家劫舍(198)

假设dp[i]表示抢劫到第i个房子时的最大收益,我们想要建立dp[i] 与子问题状态的关系。对第 i 间房子,如果我们抢劫,则第i-1间房子就不能抢,所以dp[i] = c[i] + dp[i-2];如果不抢,则dp[i] = dp[i-1]。综上得到状态转移方程,dp[i] = max(c[i] + dp[i-2], dp[i-1])。初始条件:dp[0] = 0,dp[1] = c[0]。

class Solution {
public:
    int rob(vector<int>& nums) {
      //初始条件dp[0] = 0, dp[1] = nums[0]
      if(nums.size()==1) return nums[0];
      int p1 = 0, p2 = nums[0],cur;
      //求dp[n]
      for(int i=1; i<nums.size(); i++){
        cur = max(nums[i]+p1,p2);
        p1 = p2;
        p2 = cur;
      }
      return cur;
    }
};

3、等差数列划分(413)

首先把数组相邻元素作差,这样可以通过相邻元素是否相等判断是否是等差数列。

设dp[n]表示长度为n的等差数列包含的等差子数组个数,则dp[n] = 1 + 2 *dp[n-1] - dp[n-2]。

如下例:[1,2,3,4,5] 个数等于[1,2,3,4,5]本身这一个加上[2,3,4,5]和[1,2,3,4]的个数,但[2,3,4,5]和[1,2,3,4]又都包含了[3,4,5],会重复计算一遍,所以应当扣除。起始条件:以作差数组长度为标准,dp[1] = 0, dp[2] = 1。

class Solution {
public:
    int numberOfArithmeticSlices(vector<int>& nums) {
      int n = nums.size();
      if(n<3) return 0;
      vector<int> difference(n-1);
      for(int i=0;i<n-1;i++){
        difference[i] = nums[i+1] - nums[i];        
      }

      vector<int> len(n,0);//统计各种长度的连续子数组个数
      int max_len = 0;
      for(int i=1;i<n-1;i++){
        int cnt = 1;//连续相同个数
        while(i<n-1 && difference[i]==difference[i-1]){
          ++cnt;
          ++i;
        }
        ++len[cnt];
        max_len = max(max_len,cnt);
      }

      int p1 = 0,p2 = 1,sum = len[2],cur;
      for(int i=3;i<=max_len;i++){
        cur = 1 + 2*p2 - p1;
        sum += cur*len[i];//长度为i的等差数组包含等差子数组个数cur * 长度为i等差子数组数量
        p1 = p2;
        p2 = cur;
      }
      return sum;
    }
};

参考解答,有更简单的dp方法。

举例

[1, 2, 3, 4, 5, 19, 20, 30, 40]。答案为7。

首先容易观察到:

  1. 长度为3的等差数列,可以贡献1种答案。例如 [1,2,3] 。

  2. 长度为4的等差数列,可以贡献3种答案。例如[1,2,3,4],有长度为3的子数列[1,2,3][2,3,4]两种。以及长度为4的数列[1,2,3,4]一种。一共是1+2=3种。

  3. 长度为5的等差数列,可以贡献6种答案。例如[1,2,3,4,5],有长度为3的子数列[1,2,3][2,3,4][3,4,5]三种,以及长度为4的子数列[1,2,3,4][2,3,4,5]两种,以及长度为5的数列[1,2,3,4,5]一种。一共是1+2+3=6种。

假设我们已经找到了一个长度为3的等差数列。它可以给答案带来一种贡献。

如果遍历到下一个数时,发现这个数可以拼接到前面长度为3的等差数列的末尾,形成一个长度为4的等差数列,那么把长度为3的等差数列的答案贡献数加一,就是由于这次拼接带来的新的贡献数。当前长度为4的等差数列,这次拼接新的贡献量为1+1=2。

同理,下一次遍历又发现一个数可以在已发现的长度为4的等差数列的基础上,拼接成长度为5的等差数列,那么新的贡献量就是2+1=3.

如果下一个数无法与前面的数列行成新的等差数列,那么贡献量清零。

回到例子

[1, 2, 3, 4, 5, 19, 20, 30, 40],

我们从前往后遍历:

行成[1,2,3]时,当前新贡献量为1,答案加1。
行成[1,2,3,4]时,当前新贡献量为2,答案再加2。
行成[1,2,3,4,5]时,当前新贡献量为3,答案再加3。
遇到了19,贡献量清零。
最后遇到[20, 30, 40],当前新贡献量为1,答案再加1。
结束,返回答案:7。

即每当在等差数列后面又发现新数字可以加入当前等差数列,则可以给长度为3、4、5、、、len的等差数列数量分别加一。所以dp[n] = dp[n-1] + n-2。dp[1] = dp[2] = 0,dp[3] =1.

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

        int d = nums[0] - nums[1], t = 0;
        int ans = 0;
        // 因为等差数列的长度至少为 3,所以可以从 i=2 开始枚举
        for (int i = 2; i < n; ++i) {
            if (nums[i - 1] - nums[i] == d) {
                ++t;
            }
            else {
                d = nums[i - 1] - nums[i];
                t = 0;
            }
            ans += t;
        }
        return ans;
    }
};

二维问题

和一维类似,设dp[i][j]表示(i, j)满足XX,考察(i, j)与相邻位置的关系。

4、最小路径和(64)

设dp[ i ][ j ]表示到(i, j)路径上最小数字和,由于每次只能向下或向右移动一格,所以(i, j)只能由(i-1, j)或(i, j-1)抵达。故dp[ i ][ j ] = min(dp[ i-1 ][ j ],  dp[ i ][ j-1 ]) + grid[ i ][ j ](i>0, j>0;第0行结点只能由其左边结点抵达,所以dp[0][ j ] = dp[ 0 ][ j -1] + grid[ i ][ j ];第1行结点只能由上边结点抵达,所以dp[ i ][0] = dp[ i-1 ][0] + grid[ i ][ j ]。

class Solution {
public:
    int minPathSum(vector<vector<int>>& grid) {
      int m = grid.size(), n =grid[0].size();
      vector<vector<int>> dp(m,vector<int>(n));
      //初始化边界,第0行和第0列
      int sum = 0;
      for(int i=0;i<n;i++){
        sum += grid[0][i];
        dp[0][i] = sum;
      }
      sum = 0;
      for(int i=0;i<m;i++){
        sum += grid[i][0];
        dp[i][0] = sum;
      }

      for(int i=1;i<m;i++){
        for(int j=1;j<n;j++){
          dp[i][j] = min(dp[i-1][j],dp[i][j-1]) + grid[i][j];
        }
      }
      return dp[m-1][n-1];
    }
};

5、01 矩阵(542)

方法一:BFS搜索。要确定每个结点到0结点的最近距离,我们可以先将为0的结点入队,然后取出这些结点做BFS搜索,向外搜索的层数就是这些1结点到0结点的距离。

class Solution {
public:
    vector<vector<int>> updateMatrix(vector<vector<int>>& mat) {
      int m = mat.size(), n = mat[0].size();
      vector<vector<int>> dis(m,vector<int>(n));
      //vector<vector<bool>> inq(m,vector<bool>(n,false));不需要辅助数组,可以直接用mat标识
      queue<pair<int,int>> q;
      for(int i=0; i<m; i++){//把为0的结点入队
        for(int j=0; j<n; j++){
          if(mat[i][j]==0){
            dis[i][j] = 0;
            //inq[i][j] = true;
            q.push({i,j});
          }
        }
      }

      int level = 1;
      vector<int> direction{-1,0,1,0,-1};
      while(!q.empty()){
        int size = q.size();
        for(int i=0; i<size; i++){
          auto [r,c] = q.front();
          q.pop();
          for(int j=0;j<4;j++){//探寻上下左右4个结点
            int x = r + direction[j], y = c + direction[j+1];
            if(x>=0 && x<m && y>=0 && y<n && mat[x][y]==1){//=1说明未入队
              dis[x][y] = level;
              mat[x][y] = 0;
              q.push({x,y});
            }
          }
        }
        ++level;
      }
      return dis;
    }
};

方法二:动态规划

当一个点为0时,其到0点最近距离为0;当为1时,它到最近0点的距离取决于上下左右4个结点。

因此可以得到状态转移方程为:

我们可以从左上角开始递推,这样就得到了(i, j)到其左上方最近0点的距离,从右下角递推,这样就得到了(i, j)到其右下方最近0点的距离。那么对于右上方和左下方的最近0点是否已经被考虑了呢?

我们可以这样证明:以右上最近0点为例

给出一个性质: 假如距离(i,j)最近的0点在(i-a,j+b) a>0,b>0,则距离(i,j+b)最近的0点在(i-a,j+b)。 用反证法证明: 如果距离(i,j+b)最近的0点(x,y)不在(i-a,j+b),则(i,j+b)和(x,y)距离d<a,这时点(i,j)和(x,y)的距离d'<=b+d<a+b,与假设矛盾。

利用这个性质,如果距离(i,j)最近的点在(i-a,j+b) a>0,b>0,在第一个dp(左上开始)时(i,j)没有取得最优值,但在第二个dp(右下开始)时由于(i,j+b)的最优值已经取得(因为这个最优值在他正上方),所以(i,j)也能取得最优值。

因此我们可以这样实现本题:先把dis数组初始化为一个较大值。遍历mat数组,把为0的结点的dis位置标为0。之后分别从左上、右下开始遍历,更新。

class Solution {
public:
    vector<vector<int>> updateMatrix(vector<vector<int>>& mat) {
      int m = mat.size(), n = mat[0].size();
      vector<vector<int>> dis(m,vector<int>(n,INT_MAX/2));
      for(int i=0; i<m; i++){
        for(int j=0; j<n; j++){
          if(mat[i][j]==0){
            dis[i][j] = 0;
          }
        }
      }

      //从左上角开始递推
      for(int i=0;i<m;i++){
        for(int j=0;j<n;j++){
          //这样的写法就不需要单独先对第一行和第一列的边界进行初始赋值
          if(i>=1){
            dis[i][j] = min(dis[i][j],dis[i-1][j] + 1);
          }
          if(j>=1){
            dis[i][j] = min(dis[i][j],dis[i][j-1] + 1);
          }
        }
      }
      //从右下角开始递推
      for(int i=m-1;i>=0;i--){
        for(int j=n-1;j>=0;j--){
          //这样的写法就不需要单独先对第一行和第一列的边界进行初始赋值
          if(i<m-1){
            dis[i][j] = min(dis[i][j],dis[i+1][j] + 1);
          }
          if(j<n-1){
            dis[i][j] = min(dis[i][j],dis[i][j+1] + 1);
          }
        }
      }
      return dis;
    }
};

6、最大正方形(221)

对于在矩形内搜索正方形或长方形的题型,常见做法是定义一个二维数组dp,dp[ i ][ j ]表示满足题目条件的,以(i, j)为右下角的正方形或长方形的属性。

因此,本题可以定义dp[ i ][ j ]表示以(i, j)为右下角的最大正方形边长值。显然dp[ i ][ j ]与dp[ i-1 ][ j ]、dp[ i ][ j-1 ]、dp[ i-1 ][ j-1 ]有关。注意到有以下性质:dp[ i ][ j ] = k的充要条件是 dp[ i-1 ][ j ]、dp[ i ][ j-1 ]、dp[ i-1 ][ j-1 ]的值均不小于k-1。可以参考下图示例理解。

class Solution {
public:
    int maximalSquare(vector<vector<char>>& matrix) {
      int m = matrix.size(), n = matrix[0].size();
      vector<vector<int>> dp(m+1,vector<int>(n+1,0));
      int max_area = 0;
      for(int i=1; i<=m; i++){
        for(int j=1; j<=n; j++){
          if(matrix[i-1][j-1]=='1'){
            dp[i][j] = min(dp[i-1][j], min(dp[i][j-1], dp[i-1][j-1])) + 1;
            max_area = max(max_area,dp[i][j]);
          }
        }
      }
      return max_area * max_area;
    }
};

分割类型题

分割类型的动态规划问题的状态转移方程,通常并不依赖于相邻位置,而是以依赖于满足分割的条件的位置。

7、完全平方数(279)

本题的分割位置取决于各个完全平方数1、4、9......。设dp[ i ] 表示数字 i 最少可以用几个完全平方数相加构成,则dp[ i ] = min{ dp[ i-1 ],dp[ i -4], ... , dp[ i -j*j ] }(j*j <= i)。

class Solution {
public:
    int numSquares(int n) {
      vector<int> dp(n+1,INT_MAX);
      dp[0] = 0;
      for(int i=1; i<=n; i++){
        for(int j=1; j*j<=i; j++){
          dp[i] = min(dp[i], dp[i-j*j] + 1);
        }
      }
      return dp[n];
    }
};

8、解码方法(91)

由于1-26的数字对应26个字母,所以对应方式只有1位数或者2位数,因此要考察 dp[ i ] 和 dp[ i-1 ]、dp[ i-2 ]的关系。设dp[ i ]表示从0到 i 位的数字有多少种解码方式。

当s[ i ] != ‘0’时,s[ i ]可以单独解码成字母,则dp[ i ] += dp[i-1]

当s[ i -1] != '0'时,如果s[ i -1]和s[ i ]构成的数字又小于27,则s[ i -1]和s[ i ]可以一起解码成字母,dp[ i ] += dp[i-2]。

初始条件:dp[ 0 ] = 1 ( if s[0] != ‘0’);        dp[1] = 0/1/2;依情况推断。

class Solution {
public:
    bool isValid(char c1, char c2){
      int num = (c1 - '0')*10 + (c2-'0');
      if(1<=num && num<=26) return true;
      return false;
    }

    int numDecodings(string s) {
        if(s[0]=='0') return 0;        
        int n = s.length();
        if(n==1) return 1;

        vector<int> dp(n,0);
        dp[0] = 1;        
        if(s[1]!='0') ++dp[1];
        if(isValid(s[0],s[1])) ++dp[1];

        for(int i=2; i<n; i++){
          if(s[i] != '0'){
            dp[i] += dp[i-1];
          }
          if(s[i-1]!='0' && isValid(s[i-1],s[i])){
            dp[i] += dp[i-2];        
          }
        }
        return dp[n-1];
    }
};

9、单词拆分(139)

设dp[ i ]表示[0, i]范围的子串可否进行单词拆分。则本题的分割位置是在 i 之前的可以拆分的位置上(设为idx,用一个数组存储),我们只需把这些[idx+1, i]范围的子串是否是单词列表中的单词即可,如果是则可以拆分,否则不能。

注意:由于单词列表最大长度不超过20,可以利用信息减少需要搜索的分割位置数量。

class Solution {
public:
    bool wordBreak(string s, vector<string>& wordDict) {
      //把单词列表用集合存储,便于查找
      unordered_set<string> wordSet;
      for(string str: wordDict){       
          wordSet.insert(str);
      }

      int n = s.length();
      vector<bool> dp(n,false);
      vector<int> idx_of_True;//存储为ture的索引
      idx_of_True.push_back(-1);
      for(int i=0; i<n; i++){
        int len = idx_of_True.size()-1;
        for(int j=len;j>=0;j--){//截取从上一个为true的位置idx到i的单词查找
          int idx = idx_of_True[j];
          if(i-idx>20) break;//由于单词列表最长不超过20,超过20直接退出循环
          string word = s.substr(idx+1,i - idx);
          if(wordSet.count(word)){//子串是set中单词,把索引加入idx_of_True
            dp[i] = true;
            idx_of_True.push_back(i);
            break;
          }
        }
      }

      return dp[n-1];
    }
};

子序列问题

对子序列问题,常见的思路是定义dp[ i ]表示已 i 结尾的子序列满足性质,处理完全部位置后,选出其中最大值。

10、最长递增子序列(300)

本题定义dp[ i ]表示以nums[ i ]结尾的最长子序列长度。则dp[ i ] = max(dp[ j ]) + 1, if nums[ j ] < nums[ i ] 且0<= j <= i-1. 时间复杂度为O(n^2)。

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

方法二:使用二分查找

在寻找最长子序列时,我们应该尽量让当前子序列的尾部元素尽量小,这样才更可能在后面加上新元素,生成更长子序列。我们定义dp[ k ] 表示nums数组中长度为k+1的递增子序列的尾部值,并使其值尽量小。之后开始遍历,如果nums[ i ]大于dp的全部元素,则说明更长的子序列长度出现了,nums[ i ]加入到dp数组中;否则,我们在dp中找到第一个大于等于nums[ i ]的位置pos,修改其值,这一步使长度为pos+1的子序列获得更小的尾元素,那么之后我们就更可能在此基础上增加新元素,构成更长的递增子序列。

参看下图链接理解。

这样构造的dp数组一定是递增的,可以用二分查找寻找合适位置。时间复杂度降为O(nlgn)。

class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
      int n = nums.size();
      vector<int> dp;//dp[i]存储nums数组中长度为i+1的递增子序列的尾部值,并使其值尽量小
      dp.push_back(nums[0]);
      for(int i=1; i<n; i++){
        if(nums[i] > dp.back()){
          //大于当前最大长度len的子序列尾部值,说明nums[i]可以与之构成len+1的递增子序列
          dp.push_back(nums[i]);
        }else{//否则,找到第一个大于等于nums[i]位置,更新对应尾部值
        
          int low = 0, high = dp.size()-1;
          while(low<high){
            int mid = (low+high)/2;
            if(dp[mid]>=nums[i]){
              high = mid;
            }else{
              low = mid+1;
            }
          }
          dp[low] = nums[i];
          //也可以直接调用lower_bound函数
          //auto itr = lower_bound(dp.begin(),dp.end(),nums[i]);
          //*itr = nums[i];
        }
      }
      return dp.size();
    }
};

语法:lower_bound(a[0] , a[n], x)返回有序数组a中第一个大于等于x的下标。

11、最长公共子序列(1143)

经典最长公共子序列问题(LCS),设dp[ i ][ j ]表示第一个字符串以位置 i 结尾的前缀,和第二个字符串以位置 j 结尾的前缀的最长公共子序列长度。则dp[ i ][ j ] = dp[ i-1 ][ j-1 ], if s1[ i ]  = s2[ i ];

dp[ i ][ j ] = max(dp[i-1][j],dp[i][j-1])  if s1[ i ]  != s2[ i ]。

class Solution {
public:
    int longestCommonSubsequence(string text1, string text2) {
      int m = text1.length(), n = text2.length();
      vector<vector<int>> dp(m+1,vector<int>(n+1,0));
      for(int i=1; i<=m; i++){
        for(int j=1; j<=n; j++){
          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]);
          }
        }
      }
      return dp[m][n];
    }
};

尝试进行空间压缩,由于每次只需要相邻2行,可以使用滚动数组,可以参考背包问题的压缩思路。由于dp[ i ][ j ]取决于dp[ i-1 ][ j ]、dp[ i ][ j-1 ]、dp[ i-1 ][ j-1 ],即上部、左部、左上部元素影响。由于依赖于左部元素,因此内循环必须是顺序进行,(逆序的话,没有dp[ i ][ j-1 ]我们无法更新dp[ i ][ j ]),但这样会覆盖dp[ i-1 ][ j-1 ],因此需要事先使用一个变量pre进行保存。

当然,简单点,可以直接用两行数组保存。同时,数组长度可以取两字符串长度最小值。空间复杂度可以降为O(min(M,N))。

class Solution {
public:
    int longestCommonSubsequence(string text1, string text2) {
      int m = text1.length(), n = text2.length();      
      vector<int> dp(n+1,0);
      
      for(int i=1; i<=m; i++){
        int pre = dp[0];//存储dp[i][j]的左上角元素,相当于dp[i-1][j-1]
        for(int j=1; j<=n; j++){
          int next = dp[j];//保留修改前的元素相当于dp[i-1][j],留给下次遍历使用
          if(text1[i-1] == text2[j-1]){
            dp[j] = pre + 1;
          }else{
            dp[j] = max(dp[j],dp[j-1]);
          }
          pre = next;
        }
      }
      return dp[n];
    }
};

背包问题

掌握0-1背包和完全背包两个基本问题,以及空间压缩的写法。背包问题中dp[ i ][ j ]表示前 i 个物品中恰好装入容量为 j 的背包所能获得的最大价值。

12、分割等和子集(416)

本题可以转化为选取数组中的部分元素,使其和恰好等于数组总和sum的一般半(设为target)。用0-1背包问题表述:选取若干物品,能否使其重量恰好等于target。本题中由于只涉及重量,不涉及价值,因此可以只用bool类型,dp[ i ][ j ]表示前 i 个数字中是否恰好和为 j 。再采用空间压缩的写法。

class Solution {
public:
    bool canPartition(vector<int>& nums) {
      int sum = accumulate(nums.begin(),nums.end(),0);
      if(sum%2 != 0) return false;
      int target = sum/2, n = nums.size();
      vector<bool> dp(target+1,false);
      dp[0] = true;//0个数的和恰好为0,故应设为true
      for(int i=1; i<=n; i++){
        for(int j = target; j>=nums[i-1]; j--){
          dp[j] = dp[j] || dp[j-nums[i-1]];
        }
      }
      return dp[target];

    }
};

语法:accumulate函数

在头文件 #include <numeric> 里,主要是用来累加容器里面的值,比如int、string之类,可以少写一个for循环。

比如直接统计 vector<int> v 里面所有元素的和:(第三个参数的0表示sum的初始值为0)

int sum = accumulate(v.begin(), v.end(), 0);

比如直接将 vector<string> v 里面所有元素一个个累加到string str中:(第三个元素表示str的初始值为空字符串)

string str = accumulate(v.begin(), v.end(), "");

13、一和零(474)

本问题为选取适当的字符串,在0和1不超过限值的情况下,使字符串总数尽量多。用背包问题的观点看,即0和1两个条件可以看做背包有两种容量限制,数量看作价值,把每个字符串价值看为1即可。同样的使用空间压缩,0-1背包问题使用逆序遍历,由于有2个容量条件,所以有3层循环。

class Solution {
public:
    int findMaxForm(vector<string>& strs, int m, int n) {
      int slen = strs.size();
      vector<vector<int>> dp(m+1,vector<int>(n+1,0));

      vector<int> num0(slen,0),num1(slen,0);//统计各个字符串的0和1个数
      for(int i=0; i<slen; i++){
        for(char c: strs[i]){
          if(c == '0') ++num0[i];
          else ++num1[i];
        }
      }

      for(int i=1; i<=slen; i++){
        for(int j=m; j>=num0[i-1]; j--){
          for(int k=n; k>=num1[i-1];k--){            
            dp[j][k] = max(dp[j][k], dp[j-num0[i-1]][k-num1[i-1]] + 1);                        
          }
        }
      }
      return dp[m][n];

    }
};

14、零钱兑换(322)

完全背包问题的简单变形,硬币的面额看作是容量,数量看作价值,由于取最少数量,所以max变成min,因此初值要赋予一个较大值(实际上amount+2就足够了)。

class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
      vector<int> dp(amount+1,INT_MAX/2);
      //实际上最大值amount+2就足够了,因为全部用1元硬币也只需要amount个
      dp[0] = 0;
      for(int i=1; i<=coins.size(); i++){
        for(int j=1; j<=amount; j++){
          if(j>=coins[i-1]){
            dp[j] = min(dp[j], dp[j-coins[i-1]]+1);
          }
        }
      }
      if(dp[amount] == INT_MAX/2) return -1;
      return dp[amount];
    }
};

15、编辑距离(72)

要找出从word1到word2的最少修改数,即是在相同的LCS基础上进行增、删、替操作,因此我们可以借鉴LCS的动态规划思路。设dp[ i ][ j ]表示word1的前 i 个字符和word2的前 j 个字符所需的最少修改数,则当word1[i-1] = word2[j-1]时,dp[ i ][ j ] = dp[ i-1 ][ j-1 ](新添后缀字符一致,不需要修改);二者不等时,有如下3种修改方式:

1)对新添的不同第 i-1号字符进行替换操作,替换成word2中字符,则问题转化为在word1的前 i-1 个字符和word2的前 j-1 个字符的最小操作数,故dp[ i ][ j ] = dp[ i-1 ][ j-1 ] + 1;

2)对新添的不同第 i-1号字符进行删除操作,则问题转化为在word1的前 i-1 个字符和word2的前 j 个字符的最小操作数,故dp[ i ][ j ] = dp[ i-1 ][ j ] + 1;

3)在word1的前 i-1 个字符和word2的前 j-1 个字符的基础上,插入第i-1号字符,故dp[ i ][ j ] = dp[ i ][ j-1 ] + 1.

所以 dp[ i ][ j ] = min( dp[ i-1 ][ j ],   dp[ i ][ j-1 ], dp[ i-1 ][ j-1 ] )  + 1,  if word1[i-1] != word2[j-1]

class Solution {
public:
    int minDistance(string word1, string word2) {
      int m = word1.length(), n = word2.length();
      vector<vector<int>> dp(m+1, vector<int>(n+1,0));
      for(int i=0; i<=m; i++){
        for(int j=0; j<=n; j++){
          if(i==0){//长为0的w1字符串插入j次后变为空字符串w2
            dp[i][j] = j;
          }else if(j==0){//长为i的w1字符串删除i次后变为空字符串w2
            dp[i][j] = i;
          }else{
            if(word1[i-1]==word2[j-1]) dp[i][j] = dp[i-1][j-1] + 1;
            else dp[i][j] = min(dp[i-1][j-1], min(dp[i-1][j],dp[i][j-1]))+1;
          }
        }
      }
      return dp[m][n];
    }
};

同样可以进行空间压缩,类似于LCS问题,注意保留左上角元素dp[ i-1][ j-1 ]。

class Solution {
public:
    int minDistance(string word1, string word2) {
      int m = word1.length(), n = word2.length();
      vector<int> dp(n+1,0);
      for(int i=0; i<=m; i++){
        int pre = dp[0];
        for(int j=0; j<=n; j++){
         int next = dp[j];
         if(i==0){
           dp[j] = j;
         }else if(j==0){
           dp[j] = i;
         }else{
           if(word1[i-1] == word2[j-1]) dp[j] = pre;
           else  dp[j] = min(pre,min(dp[j-1],dp[j])) + 1;
         }
         pre = next;
        }
      }
      return dp[n];
    }
};

16、只有两个键的键盘(650)

设dp[ i ]表示得到长为 i 字符串最少需要的操作数。对于质数 i,只能一个个复制得到,因此dp[ i ] = i. 对合数 i , 设 j 是  i 的因数(j>=2),则 i 可以由 j 复制得到,则dp[ i ] = dp[ j ] + dp [i/j]。dp[ j ]表示为了得到长度为 j 的字符串,需要进行的操作数,j 得到 i 过程等价于1得到i/j过程。对于所有因数,显然对最大的因数k,上式是最少的(复制长度越大粘贴次数越少)。

class Solution {
public:
    int minSteps(int n) {
      vector<int> dp(n+1);
      for(int i=2; i<=n; i++){
        dp[i] = i;//质数只能一个个粘贴得到
        for(int j=2; j*j<=i;j++){
          if(i%j==0){//i是最小因数,i/j就是最大因素
            dp[i] = dp[j] + dp[i/j];
            break;
          }
        }
      }
      return dp[n];
    }
};

17、正则表达式匹配(10)

本题的难点就在于如何建立状态转移方程。具体可以参考题解

class Solution {
public:
    
    bool isMatch(string s, string p) {
      int m = s.length(), n = p.length();
      vector<vector<bool>> dp(m+1,vector<bool>(n+1,false));
      dp[0][0] = true;
      
      for(int i=1; i<n+1; i++){//按规定,""可以和"a*"匹配,所以第0行,应单独进行初始化
        if(p[i-1]=='*') dp[0][i] = dp[0][i-2];
      }
      
      for(int i=1; i<m+1; i++){
        for(int j=1; j<n+1;j++){
          if(p[j-1]!='*'){
            if(p[j-1]==s[i-1] || p[j-1] =='.'){
              dp[i][j] = dp[i-1][j-1];
            }
          }else{
            if(p[j-2]==s[i-1] || p[j-1] =='.'){
              dp[i][j] = dp[i][j-2] || dp[i-1][j];
            }else{
              dp[i][j] = dp[i][j-2];
            }
          }
        }
      }
      return dp[m][n];
    }
};

股票交易问题

股票交易类问题可以参考这篇文章。力扣

这里进行一个简单总结。

首先,问题的一般形式归结为:给定每日的股价数组prices,在最多交易k次,且至多同时持有1支股票的情况下,如何交易才能获得最大利润?

解答:股票问题最通用的情况由三个特征决定:当前的天数 i、允许的最大交易次数 k 以及每天结束时持有的股票数。

定义:  T[i][k][0] 表示在第 i 天结束时,最多进行 k 次交易且在进行操作后持有 0 份股票的情况下可以获得的最大收益;
T[i][k][1] 表示在第 i 天结束时,最多进行 k 次交易且在进行操作后持有 1 份股票的情况下可以获得的最大收益。

则状态转移方程为:

T[i][k][0] = max(T[i - 1][k][0], T[i - 1][k][1] + prices[i])
T[i][k][1] = max(T[i - 1][k][1], T[i - 1][k - 1][0] - prices[i])

T[i][k][0]要求第i天结束后持有0支股票,因此在第i天只能休息或卖出,对应max中的两种情况;

T[i][k][1]要求第i天结束后持有1支股票,因此在第i天只能休息或买入,对应max中的两种情况。

基准情况为:

T[-1][k][0] = 0, T[-1][k][1] = -Infinity

T[i][0][0] = 0,  T[i][0][1] = -Infinity

两个0项表示交易还未发生(第0天或者k=0),两个-INF项表示没有股票交易时不能持有股票。

最终的解答是T[n-1][k][0]

下面的题目都是在这一基本问题的基础上进行变化。

18、买卖股票的最佳时机(121)

本题本质上是求 max(prices[ j ] - prices[ i ])( j > i )。直接的思路就是两重循环。


class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int n = (int)prices.size(), ans = 0;
        for (int i = 0; i < n; ++i){
            for (int j = i + 1; j < n; ++j) {
                ans = max(ans, prices[j] - prices[i]);
            }
        }
        return ans;
    }
};

我们发现,在两重循环中,实际上进行大量重复计算。比如我们计算 j 点卖出的最大利润,是从0枚举到 j - 1作差。但其实我们可以发现,通过中间某个i点的结果,再加上 i - j-1间的数据也可以得到 j 点的答案。那最简单的情况就是利用 j 和 j-1的关系。

我们设dp[ i ] 表示在第 i 天卖出所能获得的最大利润。则对第 i天,有两种策略,一是和第 i -1天一样,在之前相同的低点买入;二是在第 i -1天买入。 所以dp[i] = max(dp[i-1]+prices[i] - prices[i-1],prices[i] - prices[i-1])。

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

实际上可以进一步简化,我们在第 i 天卖出要获得最大利润的方法,是在 i 之前的最低点买入,那么我们用一个变量minprice记录之前的最低价。对一段时期,则是从所有 i 天的利润中选出最大值,我们再用一个变量maxprofit记录0-i天之间的最大利润。

class Solution {
public:
    int maxProfit(vector<int>& prices) {
      int n = prices.size();
      
      int maxprofit = 0, minprice = INT_MAX;
      for(int i=0;i<n;i++){
        if(prices[i]<minprice){//更新当前最小买入价格
          minprice = prices[i];
        }else{//更新卖出的最大利润
          maxprofit = max(maxprofit, prices[i] - minprice);
        }
      }
      return maxprofit;
    }
};

我们再用上面说的基本情况进行分析。只能交易一次,k=1,则状态转移方程变为:

T[i][1][0] = max(T[i - 1][1][0], T[i - 1][1][1] + prices[i])
T[i][1][1] = max(T[i - 1][1][1], T[i - 1][0][0] - prices[i]) = max(T[i - 1][1][1], -prices[i])

 利用T[i - 1][0][0] = 0的条件,可简化第二个状态转移方程,并节省交易次数k的维度。

T[i][0] = max(T[i-1][0], T[i-1][1] + prices[i]);

 T[i][1] = max(T[i-1][1],  -prices[i]);

class Solution {
public:
    int maxProfit(vector<int>& prices) {
      int n = prices.size();
      vector<vector<int>> T(n+1,vector<int>(2));
      T[0][0] = 0;
      T[0][1] = -prices[0];
      for(int i=1; i<n; i++){
        T[i][0] = max(T[i-1][0], T[i-1][1] + prices[i]);
        T[i][1] = max(T[i-1][1],  -prices[i]);
      }
      return T[n-1][0];
    }
};

由于第i天的状态只与第i-1天有关,可以用滚动数组方法减少空间复杂度。

class Solution {
public:
    int maxProfit(vector<int>& prices) {
      int n = prices.size();
      int profit0 = 0, profit1 = -prices[0];//分别代替T[i][0]和T[i][1]
      for(int i=1; i<n; i++){
        profit0 = max(profit0, profit1 + prices[i]);
        profit1 = max(profit1,  -prices[i]);
      }
      return profit0;
    }
};

实际上,profit1是在更新当前遇到的股价最低值,而profit0则是在更新从当前天卖出能赚到的利润最大值,这与本问题的第3段代码思想是一致的。

19、买卖股票的最佳时机 II(122)

本题中要求进行尽量多次的交易,则k为正无穷,那么k与k-1的状态是一样的。故有

T[i][k][0] = max(T[i - 1][k][0], T[i - 1][k][1] + prices[i])
T[i][k][1] = max(T[i - 1][k][1], T[i - 1][k - 1][0] - prices[i]) = max(T[i - 1][k][1], T[i - 1][k][0] - prices[i])

 因此,同样可以省略k这一维度

class Solution {
public:
    int maxProfit(vector<int>& prices) {
      int n = prices.size();
      vector<vector<int>> T(n+1,vector<int>(2));
      T[0][0] = 0;
      T[0][1] = -prices[0];
      for(int i=1; i<n; i++){
        T[i][0] = max(T[i-1][0], T[i-1][1] + prices[i]);
        T[i][1] = max(T[i-1][1],  T[i-1][0]-prices[i]);
      }
      return T[n-1][0];
    }
};

同样也可以进行空间压缩

class Solution {
public:
    int maxProfit(vector<int>& prices) {
      int n = prices.size();
      int profit0 = 0, profit1 = -prices[0];//分别代替T[i][0]和T[i][1]
      for(int i=1; i<n; i++){
        int temp = profit0;//保留原来的profit0,用于更新T[i-1][0],
        profit0 = max(profit0, profit1 + prices[i]);
        profit1 = max(profit1,  profit0 - prices[i]);
      }
      return profit0;
    }
};

和股票问题1对比,区别在于profit1在更新过程中加上了已有的收益profit0,相当于在局部用问题1方法取得最大收益,在把这些收益加起来得到全局最大收益。这实际上告诉我们本题使用贪心算法的正确性。因此也可以用贪心算法,把所有的收益段加起来即可。

class Solution {
public:
    int maxProfit(vector<int>& prices) {   
        int ans = 0;
        int n = prices.size();
        for (int i = 1; i < n; ++i) {
            ans += max(0, prices[i] - prices[i - 1]);
        }
        return ans;
    }
};

20、买卖股票的最佳时机 III(123)

本题k=2,故状态转移方程为

T[i][2][0] = max(T[i - 1][2][0], T[i - 1][2][1] + prices[i])
T[i][2][1] = max(T[i - 1][2][1], T[i - 1][1][0] - prices[i])
T[i][1][0] = max(T[i - 1][1][0], T[i - 1][1][1] + prices[i])
T[i][1][1] = max(T[i - 1][1][1], T[i - 1][0][0] - prices[i]) = max(T[i - 1][1][1], -prices[i])

 最后一个式子利用了T[i - 1][0][0] = 0。

class Solution {
public:
    int maxProfit(vector<int>& prices) {
      int n = prices.size();
      vector<vector<vector<int>>> T(n+1,vector<vector<int>>(3,vector<int>(2)));
      T[0][2][0] = 0;
      T[0][2][1] = -prices[0];
      T[0][1][0] = 0;
      T[0][1][1] = -prices[0];
      for(int i=1; i<n; i++){
        T[i][2][0] = max(T[i - 1][2][0], T[i - 1][2][1] + prices[i]);
        T[i][2][1] = max(T[i - 1][2][1], T[i - 1][1][0] - prices[i]);
        T[i][1][0] = max(T[i - 1][1][0], T[i - 1][1][1] + prices[i]);
        T[i][1][1] = max(T[i - 1][1][1], -prices[i]);
      }
      return T[n-1][2][0];
    }
};

同样可以进行空间压缩

class Solution {
public:
    int maxProfit(vector<int>& prices) {
      int n = prices.size();
      int profitTwo0 = 0, profitTwo1 = -prices[0],profitOne0 = 0, profitOne1 = -prices[0];
      //分别代替T[i][2][0]\T[i][2][1]\T[i][1][0]\T[i][1][1]
      for(int i=1; i<n; i++){
        profitTwo0 = max(profitTwo0, profitTwo1 + prices[i]);
        profitTwo1 = max(profitTwo1, profitOne0 - prices[i]);
        profitOne0 = max(profitOne0, profitOne1 + prices[i]);
        profitOne1 = max(profitOne1, - prices[i]);
      }
      return profitTwo0;
    }
};

21、买卖股票的最佳时机 IV(188)

本题对应的正是上面所说的一般情况。

class Solution {
public:
    int maxProfit(int k, vector<int>& prices) {
      int n = prices.size();
      if(n<2) return 0;
      vector<vector<vector<int>>> T(n,vector<vector<int>>(k+1,vector<int>(2)));
      for(int i=0;i<k+1;i++){//初始化基准情况
        T[0][i][0] = 0;
        T[0][i][1] = -prices[0];
      }

      for(int i=1;i<n;i++){
        for(int j=1;j<=k;j++){
          T[i][j][0] = max(T[i - 1][j][0], T[i - 1][j][1] + prices[i]);
          T[i][j][1] = max(T[i - 1][j][1], T[i - 1][j - 1][0] - prices[i]); 
        }
      }
      return T[n-1][k][0];
    }
};

同样,注意到i只与i-1天有关,可以用滚动数组方法减少这一维的空间。

class Solution {
public:
    int maxProfit(int k, vector<int>& prices) {
      int n = prices.size();
      if(n<2) return 0;
      vector<vector<int>> T(k+1,vector<int>(2));
      for(int i=0;i<k+1;i++){//初始化基准情况
        T[i][0] = 0;
        T[i][1] = -prices[0];
      }

      for(int i=1;i<n;i++){
        for(int j=1;j<=k;j++){
          T[j][0] = max(T[j][0], T[j][1] + prices[i]);
          T[j][1] = max(T[j][1], T[j - 1][0] - prices[i]); 
        }
      }
      return T[k][0];
    }
};

当然,n天里最多进行n/2笔交易,当k>n/2时,k不再成为限制交易的条件,相当于正无穷,可以转化为k为正无穷的情况处理。这样可以减少k很大时的执行时间。

22、最佳买卖股票时机含冷冻期(309)

本题是在k为无穷的基础上,增加了一天的冷冻期。我开始的思路是设置一个标置量表示前一天是否卖出,如果卖出,那么T[i][1]只能在当天休息,而不能买入。

class Solution {
public:
    int maxProfit(vector<int>& prices) {
      int n = prices.size();
      vector<vector<int>> T(n,vector<int>(2));
      T[0][0] = 0;
      T[0][1] = -prices[0];
      bool flag = false;//表示前一天是否卖出股票
      for(int i=1;i<n;i++){
        if(flag) {//前一天卖出了股票
          T[i][1] = T[i-1][1];         
        }else{
          T[i][1] = max(T[i-1][1],T[i-1][0]-prices[i]);
        }
        if(T[i-1][0]<T[i-1][1]+prices[i]){//卖出股票
          T[i][0] = T[i-1][1]+prices[i];
          flag = true;
        }else{
          T[i][0] = T[i-1][0];
          flag = false;
        }              
      }
      return T[n-1][0];
    }
};

但测试后,发现结果不对。思考后,发现flag可能在错误的卖出时机被修改,影响答案的正确性。

比如[1,2,3,0,2]这个例子,在prices[0] = 1时买入,在prices[1] = 2这个卖出点就会修改flag,影响后续T[i][1]的修改,而最终的结果中,我们其实应该在prices[2] = 3这个点卖出才有最大收益。

重新思考问题。K为无穷且没有冷冻期时,

T[i][k][0] = max(T[i - 1][k][0], T[i - 1][k][1] + prices[i])
T[i][k][1] = max(T[i - 1][k][1], T[i - 1][k][0] - prices[i])

现在有冷冻期,状态转移方程变为

T[i][k][0] = max(T[i - 1][k][0], T[i - 1][k][1] + prices[i])
T[i][k][1] = max(T[i - 1][k][1], T[i - 2][k][0] - prices[i])

为什么直接把T[i][k][1]中的T[i - 1][k][0] - prices[i]变成T[i - 2][k][0] - prices[i]呢?

假设我们在T[i][k][1]中执行的是买入操作,则由于冷冻期的存在,在第i-1天不能卖出股票,所以T[i-1][k][0] = T[i - 2][k][0]. 因此买入时,T[i][k][1] = T[i - 1][k][0] - prices[i] = T[i - 2][k][0] - prices[i]。

class Solution {
public:
    int maxProfit(vector<int>& prices) {
      int n = prices.size();
      vector<vector<int>> T(n+1,vector<int>(2));
      T[0][0] = 0;
      T[0][1] = -prices[0];
      for(int i=1; i<n; i++){
        T[i][0] = max(T[i-1][0], T[i-1][1] + prices[i]);
        T[i][1] = max(T[i-1][1], (i>=2? T[i-2][0]:0)-prices[i]);
      }
      return T[n-1][0];
    }
};

同样可以压缩空间。

class Solution {
public:
    int maxProfit(vector<int>& prices) {
      int n = prices.size();
      int preProfit0 = 0, profit0 = 0, profit1 = -prices[0];//分别代替T[i-1][0],T[i][0]和T[i][1]
      for(int i=1; i<n; i++){        
        int nextProfit0 = max(profit0, profit1 + prices[i]);
        int nextProfit1 = max(profit1,  preProfit0 - prices[i]);
        preProfit0 = profit0;
        profit0 = nextProfit0;
        profit1 = nextProfit1;
      }
      return profit0;
    }
};

23、买卖股票的最佳时机含手续费(714)

本题属于k为无穷加含手续费的情况,只需要在买入或卖出时的状态转移方程扣除手续费即可。

以卖出为例

T[i][k][0] = max(T[i - 1][k][0], T[i - 1][k][1] + prices[i] - fee)
T[i][k][1] = max(T[i - 1][k][1], T[i - 1][k][0] - prices[i])

class Solution {
public:
    int maxProfit(vector<int>& prices, int fee) {
      int n = prices.size();
      
      vector<vector<int>> T(n,vector<int>(2));
      T[0][0] = 0;
      T[0][1] = -prices[0];
      for(int i=1;i<n;i++){
        T[i][0] = max(T[i-1][0], T[i-1][1] + prices[i] - fee);
        T[i][1] = max(T[i-1][1], T[i-1][0] - prices[i]);
      }
      return T[n-1][0];
    }
};

空间压缩

class Solution {
public:
    int maxProfit(vector<int>& prices, int fee) {
      int n = prices.size();
      int profit0 = 0, profit1 = -prices[0];//分别代替T[i][0]和T[i][1]
      for(int i=1; i<n; i++){        
        profit0 = max(profit0, profit1 + prices[i] - fee);
        profit1 = max(profit1,  profit0 - prices[i]);       
      }
      return profit0;
    }
};

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值