单调栈及动态规划算法记录

单调栈

通常是一维数组,要寻找任一个元素的右边或者左边第一个比自己大或者小的元素的位置,此时我们就要想到可以用单调栈了。时间复杂度为O(n)。

单调栈的本质是空间换时间,因为在遍历的过程中需要用一个栈来记录右边第一个比当前元素高的元素,优点是整个数组只需要遍历一次。

更直白来说,就是用一个栈来记录我们遍历过的元素,因为我们遍历数组的时候,我们不知道之前都遍历了哪些元素,以至于遍历一个元素找不到是不是之前遍历过一个更小的,所以我们需要用一个容器(这里用单调栈)来记录我们遍历过的元素。

  • 在使用单调栈的时候首先要明确如下几点:
  1. 单调栈里存放的元素是什么?

​ 单调栈里只需要存放元素的下标i就可以了,如果需要使用对应的元素,直接T[i]就可以获取。

  1. 单调栈里元素是递增呢? 还是递减呢?
  • 比如要找数组中当前元素后面第一个比他大的元素

那么如果遍历到了一个元素10

而从栈底到栈顶分别存的是11 9 8 三个元素对应的下标,那么 9 8对应的下标就被弹出,他们两个后面第一个比他们大的元素就是10;

接雨水

  1. 暴力算法: 对于i位置的雨水,向左边寻找左边最高的柱子,向右边寻找右边最高的柱子, 由于每次都需要寻找左右两边最高的柱子所以时间复杂度为 O ( n 2 ) O(n^2) O(n2);

  2. 对暴力算法进行双指针优化:把每一个位置的左边的最高的高度记录在一个数组上 m a x L e f t maxLeft maxLeft,同样把每一个位置的右边的最高高度记录在一个数组上 m a x R i g h t maxRight maxRight, 避免重复计算

    那么当前位置i左边的最高高度就是前一个位置的左边最高高度和本高度的最大值。

  3. 使用单调栈(按照行的方向计算雨水)

    image-20231117151901817

从栈头(元素从栈头弹出)到栈底的顺序应该是从小到大的顺序。

因为一旦发现添加的柱子高度大于栈头元素了,此时就出现凹槽了,栈头元素就是凹槽底部的柱子,栈头第二个元素就是凹槽左边的柱子,而添加的元素就是凹槽右边的柱子。

image-20231117152102815

柱状图中的最大矩形

  1. 暴力解法

    遍历每一个位置,以每个位置i为高度计算每个位置的最大矩形大小,记录最大矩形的面积

  2. 使用单调栈,和接雨水有两个不同之处

    单调栈从栈底到栈顶是递增的,新的元素小于栈顶元素,才需要进行面积计算

    对初始序列需要进行处理,在前面加一个0,在后面加一个0,防止初始序列是递增或者递减序列

    • 为什么接雨水不需要第二种处理? 递增递减接不住雨水 ,并且左右边界也接不了雨水,这就是两道题的主要不同之处。

马拉车算法 manacher’s algorithm 之最长回文子串

之前我们使用动态规划解决的leetcode5这道最长回文子串的问题,时间复杂度和空间复杂度均为n的平方

这次我们使用马拉车算法将时空复杂度降低到O(n)

首先为了方便同时处理奇偶数回文串,我们需要在每个字母之间加入特殊符号#, 开头加上哨兵$方便处理

比如abba -> $#a#b#b#a

image-20231106192707294

马拉车算法通过盒子的方法处理减少时间复杂度

首先判断当前遍历的元素在不在正在维护的盒子内部

如果在内部的话找到两个值,一个是盒子中对称位置的元素的回文半径,第二个是当前位置i距离盒子右边界的距离

选择其中小的一个作为当前元素的回文半径,如果对称位置元素的回文半径是比较小的那个,那么不必在继续处理

如果是距离盒子右边边界的距离较小,就要使用暴力更新当前位置的回文半径,

回文半径更新之后 对应的半径如果超出原来盒子右边边界,就要以当前位置为中心,更新左右两边的边界。

回文半径最大的就是最长的回文子串

  • 为什么时间复杂度为O(n)?

核心代码

void manacher(char* s)
{
    int c = 0, r = 0;
    p[0] = 0;
    for( int i = 1; s[i]!='\0' ; ++i ) {
        if( r > i ) p[i] = min( p[ 2 * c - i ], r - i ); //从对称点半径和i和盒子右边的距离之间选一个
        else p[i] = 0;
        while( s[i + 1 + p[i]] == s[i - 1 - p[i]] ) p[i]++;//暴力搜索
        if( i + p[i] > r ) {//更新盒子边界
            r = i + p[i];
            c = i;
        }
    }
}
  • 只需要弄清楚两点
  1. while()循环本身的时间复杂度在没有前提条件的情况下确实是 O ( n ) O(n) O(n)
  2. 但是这里的 r r r(也就是上面答案中的 m a x l e n maxlen maxlen),是不断往后走而不可能往前退的,它自身的值的变化是递增的。那么你可以明白,要进入while循环 i i i的值必然是比 r r r大的,也就是说整个程序结束为止,while循环执行的操作数为 n n n次(线性次),而字符串中的每个字符,最多能被访问到2次。时间复杂度必然为 O ( n ) O(n) O(n)
  3. while循环中走过的地方,最终会更改盒子的边界,而盒子内部的东西不会再进入while循环,字符串中的每个字符最多能被访问到两次。

动态规划

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

  • 动态规划问题步骤
    1. 确定dp table 以及下标的含义
    2. 确定递推公式
    3. dp数组如何初始化
    4. 确定遍历顺序
    5. 举例推导DP数组

爬楼梯

image-20231120190454103

class Solution {
public:
    int climbStairs(int n) {
        int dp[n+1];
        dp[0] = 1;
        dp[1] = 1;
        for(int i = 2; i <= n; ++i){
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[n];
    }
};

初始化的时候dp[0] = 1 该怎么解释呢?

没必要解释,有人问的话就说爬0层楼梯没意义,也可以设置初始值为dp[1] 和 dp[2]

每次可以爬1 or 2 or 3 … or m层楼梯怎么处理呢

很简单 再加一个循环把dp[i-1], dp[i-2], dp[i-3]…dp[i-m] 全部加起来就行。

    int climbStairs(int n, int m) {
        vector<int> dp(n + 1, 0);
        dp[0] = 1;
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= m; j++) { // 把m换成2,就可以AC爬楼梯这道题
                if (i - j >= 0) dp[i] += dp[i - j];
            }
        }
        return dp[n];
    }

使用最小的花费爬楼梯问题

image-20231121153953762

    int minCostClimbingStairs(vector<int>& cost) {
        vector<int> dp = vector<int>(cost.size() + 1, 0);
        if(cost.size() < 2) return 0;
        dp[0] = 0;
        dp[1] = 0;
        for(int i = 2; i <= cost.size(); ++i){
            dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i -2]);
        }
        return dp[cost.size()];
    }
  • 主要问题在题目中,可以从序号0或者序号1开始 ,而dp[i]指的是到达第i阶台阶所花费的最少体力
  • 同时注意审题,当你支付了i位置的体力之后,你可以爬1or2阶

不同路径问题

要点

  1. 注意初始化,0行和0列都需要初始化为1(因为 d p [ i ] [ j ] dp[i][j] dp[i][j]的定义是到达i,j位置有多少种路径

整数拆分问题

dp[i]指的是整数i拆分后相乘的最大乘积

for (int i = 3; i <= n ; i++) {
    for (int j = 1; j <= i / 2; j++) {
        dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));
    }
}

拆分一个数n 使之乘积最大,那么一定是拆分成m个近似相同的子数相乘才是最大的。

例如 6 拆成 3 * 3, 10 拆成 3 * 3 * 4。 100的话 也是拆成m个近似数组的子数 相乘才是最大的。

只不过我们不知道m究竟是多少而已,但可以明确的是m一定大于等于2,既然m大于等于2,也就是 最差也应该是拆成两个相同的 可能是最大值。

那么 j 遍历,只需要遍历到 n/2 就可以,后面就没有必要遍历了,一定不是最大值。

不同的二叉搜索树

image-20231122104807552

二叉搜索树的知识回顾

左节点小于根节点小于右节点

image-20231122105448791

dp[i]指的是i个节点可以组成的二叉搜索树的数量

image-20231122115459737

需要对每个1…n都作为头节点来计算搜索树的数目然后累加

for (int i = 1; i <= n; i++) {
    for (int j = 1; j <= i; j++) {
        dp[i] += dp[j - 1] * dp[i - j];
    }
}

递推公式也非常简单 dp[j - 1] 就是比j小的数可以组成的二叉搜索树的数目,dp[i - j] 就是比j大的数可以组成的二叉搜索树的数目

416分割等和子集

注意这种题型之前是使用回溯算法做的,比如划分为K个相等的子集以及火柴拼正方形

最后一块石头的重量

要点就是分成两堆重量尽量相同的两堆石头,分别是 d p [ t a r g e t ] dp[target] dp[target] s u m − d p [ t a r g e t ] sum - dp[target] sumdp[target]

目标和(需要再刷)

使用背包解决这个问题需要一些巧妙的处理

首先使用背包的思想,要把这个集合分成两堆,一堆是前面加+号,另外一堆前面加-号

集合总和为sum 那么正数的和为X,负数的和为X - sum

t a r g e t = 2 X − s u m target = 2X - sum target=2Xsum

X = ( t a r g e t + s u m ) / 2 X = (target + sum) / 2 X=(target+sum)/2 注意这里如果不能整除的话 说明没办法分成相应的两堆,直接返回0就行

dp[i] 表示填满容量为i的背包 有多少种方法

d p [ j ] + = d p [ j − n u m s [ i ] ] dp[j] += dp[j - nums[i]] dp[j]+=dp[jnums[i]]

一和零,很巧妙的背包问题,值得再看一下

d p [ i ] [ j ] dp[i][j] dp[i][j] :最多有i个0和j个1的strs的最大子集大小

image-20231124144807595

m是装0的背包容量,n是装1的背包容量

完全背包问题

完全背包和01背包不同的地方在于,没中物品都有无数件

主要不同体现在遍历顺序上,01背包的背包容量的遍历顺序是从大容量到小容量的,保证每种物品只被添加1次,而对于完全背包来说遍历顺序从小容量到大容量,因为每种物品都可以添加多次

零钱兑换2(需要再看一下)

image-20231124174036975

dp[i] 就是所有的dp[j - coins[i]]相加

dp[i] += dp[i - coins[i]]

求装满背包有几种方法,公式都是dp[i] += dp[i - conins[i]]

本题目求解的是组合数目,所以对遍历的顺序有着一定的要求

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

假设:coins[0] = 1,coins[1] = 5。

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

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

但是如果交换遍历的顺序

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]里算出来的就是排列数!

本题显然需要使用第一种遍历顺序

组合总和

这道题和零钱兑换的不同之处在于这道题的方法数目是排列(1,1,2)和(1,2,1)不是同一个东西

注意,这道题目的中加数据出现了整数溢出,遇见这种情况的时候可以使用unsigned int 或者 unsigned long long

零钱兑换

image-20231125175127491

这道题想让我们用最小的硬币数量凑齐amount,思考的时候总是想着怎么在程序运行中对遍历过的硬币累加和amout进行比较,这样是不对的

  • dp[i]表示凑成金额i所需要的最少的硬币个数
  • 注意需要求最少的个数,dp[j] = min(dp[j], dp[j - coins[i]] + 1) 所以初始化DP数组的时候要全部改成INT_MAX ,并且显然dp[0]需要设置为0
  • 注意dp[amount] 如果还是初始值得话说明无法使用现有的硬币凑成该值
class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        vector<int> dp(amount+1, INT_MAX);//dp[i]表示凑成金额i所需的最少的硬币的个数
        int sum = 0;
        dp[0] = 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];
    }
};

完全平方数

这道题目就是上面题目的变体

image-20231126103834169

class Solution {
public:
    int numSquares(int n) {
        //dp[i]是为了凑成正整数i所需的最少的完全平方数
        vector<int> dp(n + 1, INT_MAX);
        dp[0] = 0;
        for(int i = 0; i*i <= n; ++i){//遍历物品
            for(int j = i * i; j <= n; ++j){//遍历背包容量
                if(dp[j - i*i] != INT_MAX){
                    dp[j] = min(dp[j], dp[j - i*i] + 1);
                }
            }
        }
        return dp[n];
    }
};

注意遍历物品的时候 i*i <= n 记得等于号,比如n = 1的话才能进行处理

139.单词拆分(回溯算法,DP)

这道题也可以使用回溯算法来做,复习回溯的时候再来看看吧

image-20231126123944317

这道题非常的巧妙,需要注意先遍历背包,再遍历物品的顺序,还要注意遍历的物品到底是什么东西

class Solution {
public:
    bool wordBreak(string s, vector<string>& wordDict) {
        vector<bool> dp(s.size() + 1, false); //dp[i]表示长度为i的字符串,dp[i]为真表示可以拆分成一个或者多个在字典中出现的单词
        dp[0] = true;
        unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
        //这道题目想当于求排列,需要先遍历背包,再遍历物品
        for(int i = 1; i <= s.size(); ++i){//先遍历背包
            for(int j = 0; j < i; ++j){//再遍历物品,说是便利物品,其实并不是遍历字典,而是从0-i之间选一个中间点j
            //将0到i分为两段,一段可以被拆分成一个或多个在字典中出现的单词并且另一段是一个在字典中出现的单词,那么dp[i]就为真
                    string word = s.substr(j, i - j);
                    if(dp[j] && wordSet.find(word) != wordSet.end())
                        dp[i] = true;
            }
        }
        return dp[s.size()];
    }
};
  • 为什么不能先遍历物品呢?

问题就是我先遍历apple的话后面那个Apple就无法便利到了,所以对于每个位置,要把每个word都遍历一遍,所以先遍历背包,再遍历物品

多重背包

多重背包和01背包的区别在于,没中物品的数量是一个定额,不一定是仅仅有一个,也不可能是无限多个(完全背包)

一种解决办法是 由于数量是定额,所以我们把数量展开,相当于有多个相同重量和价值的物品,转换成为01背包问题

还有另一种解决办法是 把每种商品遍历的个数在01背包里面遍历一遍。

#include<iostream>
#include<vector>
using namespace std;
int main() {
    int bagWeight,n;
    cin >> bagWeight >> n;
    vector<int> weight(n, 0);
    vector<int> value(n, 0);
    vector<int> nums(n, 0);
    for (int i = 0; i < n; i++) cin >> weight[i];
    for (int i = 0; i < n; i++) cin >> value[i];
    for (int i = 0; i < n; i++) cin >> nums[i];

    vector<int> dp(bagWeight + 1, 0);

    for(int i = 0; i < n; i++) { // 遍历物品
        for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
            // 以上为01背包,然后加一个遍历个数
            for (int k = 1; k <= nums[i] && (j - k * weight[i]) >= 0; k++) { // 遍历个数
                dp[j] = max(dp[j], dp[j - k * weight[i]] + k * value[i]);
            }
        }
    }

    cout << dp[bagWeight] << endl;
}

背包递推公式

问能否能装满背包(或者最多装多少):dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]); ,对应题目如下:

问装满背包有几种方法:dp[j] += dp[j - nums[i]] ,对应题目如下:

问背包装满最大价值:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); ,对应题目如下:

问装满背包所有物品的最小个数:dp[j] = min(dp[j - coins[i]] + 1, dp[j]); ,对应题目如下:

打家劫舍2

这道题很有意思啊,房屋成环了,意味着不能同时偷头部和尾部两个位置的房子

那处理也很简单,算出两个普通打家劫舍的结果 一个不包含头部元素,一个不包含尾部元素,巧妙的解决这个问题。

class Solution {
    int HouseRobber(vector<int>& nums){
        if(nums.size() == 0) return 0;
        if(nums. size() == 1) return nums[0];    
        vector<int> dp(nums.size() + 1, 0);
        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];
    }
public:
    int rob(vector<int>& nums) {
        if(nums.size() == 0) return 0;
        if(nums. size() == 1) return nums[0];  
        //截取两个数组,其中一个没有尾部元素,另外一个没有头部元素
        vector<int> nums1(nums.begin(), nums.end() - 1);
        vector<int> nums2(nums.begin() + 1, nums.end());
        int result1 = HouseRobber(nums1);
        int result2 = HouseRobber(nums2);
        return max(result1, result2);
    }

};

打家劫舍3

二叉树和动态规划的联合问题 树形DP

首先想到的应该是暴力递归的办法

    int rob(TreeNode* root) {
        if(root == nullptr) return 0;
        if(root->left == nullptr && root->right == nullptr) return root->val;
        //偷父节点
        int temp1 = root->val;
        if(root->left) temp1 += rob(root->left->left) + rob(root->left->right);
        if(root->right) temp1 += rob(root->right->left) + rob(root->right->right);
        //偷子节点
        int temp2 = rob(root->left) + rob(root->right);
        return max(temp1, temp2);
    }

那肯定是超出时间限制,仙人我在算int temp2 = rob(root->left) + rob(root->right);这个的时候,又把rob(root->left->left) + rob(root->left->right);rob(root->right->left) + rob(root->right->right);算了一遍,所以我们使用umap来记录一下

    int rob(TreeNode* root) {
        if(root == nullptr) return 0;
        if(root->left == nullptr && root->right == nullptr) return root->val;
        if(umap[root]) return umap[root];
        //偷父节点
        int temp1 = root->val;
        if(root->left) temp1 += rob(root->left->left) + rob(root->left->right);
        if(root->right) temp1 += rob(root->right->left) + rob(root->right->right);
        //偷子节点
        int temp2 = rob(root->left) + rob(root->right);
        umap[root] = max(temp1, temp2);
        return umap[root];
    }

动态规划的做法

在上面两种方法,其实对一个节点 偷与不偷得到的最大金钱都没有做记录,而是需要实时计算。

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

  1. 确定递归函数的参数和返回值(要求一个节点 偷与不偷的两个状态所得到的金钱,那么返回值就是一个长度为2的数组。)

    vector<int> robTree(TreeNode* cur) {
    

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

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

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

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

  2. 确定终止条件,如果遇到空节点的话,很明显,无论偷还是不偷都是0,所以就返回

  3. 遍历顺序是后序遍历,左右根,因为需要通过递归函数的返回值做下一步计算

  4. 确定单层逻辑
    如果是偷当前节点,那么左右孩子就不能偷,val1 = cur->val + left[0] + right[0]; (如果对下标含义不理解就再回顾一下dp数组的含义
    如果不偷当前节点,那么左右孩子就可以偷,至于到底偷不偷一定是选一个最大的,所以:val2 = max(left[0], left[1]) + max(right[0], right[1]);

  5. 从这张图就可以看出通过后序遍历每个节点都储存着{不偷当前节点得到的金钱,偷当前节点得到的金钱}
    class Solution {
        vector<int> robDP(TreeNode* cur){
            if(cur == nullptr) return {0, 0};
            vector<int> left = robDP(cur->left);
            vector<int> right = robDP(cur->right);
            int val1 = cur->val + left[0] + right[0];//偷了当前节点就不能偷左右节点
            int val2 = max(left[0], left[1]) + max(right[0], right[1]);
            return {val2, val1};
        }
    public:
        int rob(TreeNode* root) {
            vector<int> result;
            result = robDP(root);
            return max(result[0], result[1]);
        }
    };
    

    买卖股票的最佳时机系列算法

    第一道题目就是一天买,一天卖,首先想到暴力解法

    然后就是

    1

    明显我只需要找到左边的一个最小值,再在它的右边找到最大差值就好

    这道题目使用贪心算法还是比较简单的

        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;
        }
    

    动态规划解法

        int maxProfit(vector<int>& prices) {
            //dp[i][0] 表示第i天持有股票所得的最多现金
            //dp[i][1] 表示第i天不持有股票所得的最多现金
            vector<vector<int>> dp(prices.size(), vector(2, 0));
            //注意初始化
            dp[0][0] = -prices[0];
            for(int i = 1; i < prices.size(); ++i){
                dp[i][0] = max(-prices[i], dp[i-1][0]);
                dp[i][1] = max(prices[i] + dp[i-1][0], dp[i-1][1]);
            }
            return dp[prices.size() - 1][1];
        }
    

    2第二道题是可以多次交易一支股票的

    因为可以买卖多次所以 d p [ i ] [ 0 ] dp[i][0] dp[i][0] 就是第i天持有股票手头上的现金

    一共有两种可能

    1. 第i天买的股票 d p [ i − 1 ] [ 1 ] − p r i c e s [ i ] dp[i-1][1] - prices[i] dp[i1][1]prices[i](仅有这一项和上一题不同,因为可以多次买卖,所以买股票的时候和之前卖股票的所得有关)
    2. 之前买的股票 d p [ i ] [ 0 ] dp[i][0] dp[i][0]

    d p [ i ] [ 1 ] dp[i][1] dp[i][1]是第i天手头没有股票的时候手头上的现金

    1. 第i天卖掉了股票 d p [ i − 1 ] [ 0 ] + p r i c e s [ i ] dp[i-1][0] + prices[i] dp[i1][0]+prices[i]
    2. 之前卖掉了股票 d p [ i − 1 ] [ 1 ] dp[i-1][1] dp[i1][1]

    可恶啊,就是这么简单

    第三道题要求我们最多完成两笔交易

    这道题不同就大了

    一天会有四个状态(其实有5个,但是没有操作那个状态没啥用)

    1. 没有操作
    2. 第一次持有股票
    3. 第一次不持有股票
    4. 第二次持有股票
    5. 第二次不持有股票

    经典了,现在要求我们可以完成k笔交易

    很经典,根据上一次的代码其实就可以推出来

    最多两笔交易有用的就是4个状态,那么k笔交易就是2*k个状态

        dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i]);
        dp[i][2] = max(dp[i-1][2], dp[i-1][1] + prices[i]);
        dp[i][3] = max(dp[i-1][3], dp[i-1][2] - prices[i]);
        dp[i][4] = max(dp[i-1][4], dp[i-1][3] + prices[i]);
    

    那么可见代码就非常好写了

    	for(int j = 1; j <= 2*k; j += 2){
    		dp[i][j] = max(dp[i-1][j], dp[i-1][j-1] - prices[i]);
    		dp[i][j+1] = max(dp[i-1][j+1], dp[i-1][j] + prices[i]);
    	}
    

    现在加上了买卖股票的冷冻期

    本质上还是增加了每一天的状态

    前面的题目只有总体来做就是两种状态,持有股票和不持有股票的状态

    本题目有四个状态

    1. 持有股票的状态
    2. 不持有股票的状态(包含两个:1. 保持卖出股票的状态,两天前就卖出了股票,有一天cool down,或者是前一天就是卖出股票的状态 2. 今天卖出股票)
    3. 今天为冷冻期

    因为本题我们有冷冻期,而冷冻期的前一天,只能是 「今天卖出股票」状态,如果是 「不持有股票状态」那么就很模糊,因为不一定是 卖出股票的操作。

    image-20231130210556893
    dp[i][0] = max(dp[i - 1][0], max(dp[i - 1][3], dp[i - 1][1]) - prices[i]);
    dp[i][1] = max(dp[i - 1][1], dp[i - 1][3]);
    dp[i][2] = dp[i - 1][0] + prices[i];
    dp[i][3] = dp[i - 1][2];
    

    第五道题目加入了手续费

    就卖出去的时候减去手续费就行了,没啥特别的

    最长上升子序列问题

    dp[i] 表示包含nums[i]在内的最长上升子序列的长度(选择范围是[0, i])

            for(int i = 1; i < nums.size(); ++i){
                for(int j = 0; j < i; ++j){
                    if(nums[i] > nums[j]){
                        dp[i] = max(dp[i], dp[j] + 1);
                        result = max(dp[i], result);
                    }
                }
            }
    

再次注意这里,由于dp[i]中必须包含nums[i],所以取到最大上升子序列的位置不是固定的

所以代码中使用了一个result来记录最长的子序列长度

最长连续递增子序列

这道题首先的想法就是暴力,也过了,既然是动态规划专题,那么也思考一下动态规划吧

dp[i]表示包含nums[i]在内的最长上升子序列的长度

        for(int i = 1; i < nums.size(); ++i){
            if(nums[i] > nums[i-1]){
                dp[i] = dp[i-1] + 1;
                result = max(result, dp[i]);
            }
        }

很明显,由于要求连续递增,那么当前位置的数字只能和前一个比较。

最长重复子数组(这道题我感觉有点难度)主要是在 d p [ i ] [ j ] dp[i][j] dp[i][j]的定义上(初始化)

返回两个数据中公共的,长度最长的子数组的长度

d p [ i ] [ j ] dp[i][j] dp[i][j]以i为结尾的A和以j为结尾的B,最长重复子数组的长度为 d p [ i ] [ j ] dp[i][j] dp[i][j]

这里需要注意的是最长重复子数组中一定包含nums[i],这一点非常重要!

废话少说,我觉得我的解法比carl简洁多了

    int findLength(vector<int>& nums1, vector<int>& nums2) {
        vector<vector<int>> dp(nums1.size(), vector<int>(nums2.size(), 0));
        int result = 0;//用来记录最大长度
        //init
        for(int i = 0; i < nums1.size(); ++i){
            if(nums2[0] == nums1[i]){
                dp[i][0] = 1;
                result = 1;//初始化的时候如果有相同的话,那么result至少是1
            }
        }
        for(int j = 0; j < nums2.size(); ++j){
            if(nums1[0] == nums2[j]){
                dp[0][j] = 1;
                result = 1;
            }
        }
        for(int i = 1; i < nums1.size(); ++i){
            for(int j = 1; j < nums2.size(); ++j){
                if(nums1[i] == nums2[j]){
                    dp[i][j] = dp[i-1][j-1] + 1;
                    result = max(result, dp[i][j]);//记录长度
                }
            }
        }
        return result;
    }

最长公共子序列

和上一道题目的不同之处在于,上面一道题目要求连续的数组,下面一道题目要求的是公共子序列,代表相对顺序

同样 d p [ i ] [ j ] dp[i][j] dp[i][j]的定义也不相同

这道题的 d p [ i ] [ j ] dp[i][j] dp[i][j] 是[0, i] 的text1和[0, j]的text2的最长公共子序列的长度,不一定非要包含text1[i]和text2[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]);
}

这道题目的初始化需要特别的注意

如果text1[i] == text2[0] 的话那么第一列从i开始往下的所有元素都应该置为1,行也是同样

        for(int i = 0; i < text1.size(); ++i) {
            if(text1[i] == text2[0]){
                while(i < text1.size()){
                    dp[i][0] = 1;
                    i++;
                }
            }
        }
        for(int j = 0; j < text2.size(); ++j) {
            if(text1[0] == text2[j])
                while(j < text2.size()){
                    dp[0][j] = 1;
                    j++;
                }
        }

1035.不相交的线

这道题目其实和上面那道题目一模一样,两个数组连线,不允许相交,问最大连线数目,其实就是求最长公共子序列

image-20231202113732290

53.最大数组和

这道题目一定要注意dp数组的定义必须是包含nums[i]并且把它当做结尾的最大连续子序列

因为题目求解的是一个连续的区间,如果定义dp是[0-i]区间的最大连续子序列的和

递推公式就是 dp[i] = max(dp[i-1], dp[i-1]+nums[i])

这样的话dp[i-1]中的数据可能是[0, i-3]的和 再加上nums[i]这样数据就不连续了

    int maxSubArray(vector<int>& nums) {
        vector<int> dp(nums.size(), 0);
        dp[0] = nums[0];
        int result = nums[0]; 

392.判断子序列

这道题目优先想到的是双指针的方法

    bool isSubsequence(string s, string t) {
        int i = 0, j = 0;
        for(int j = i; j < t.size();){
            if(s[i] == t[j]){
                ++i;
                ++j;
            }
            else
                ++j;
        }
        if(i == s.size())
            return true;
        else 
            return false;

第二种呢就是动态规划的方法

这一块我和代码随想录的思路不同,所以在s和t为空这一块考虑不周,只能在代码前面加几个判断条件

具体不同在,我的 d p [ i ] [ j ] dp[i][j] dp[i][j]表示以i为结尾的字符串s和以j为结尾的字符串t的相同子序列长度

同时对于s[i] != t[j] 的情况代码随想录是这样解释的,由于t比s长,只删除t的元素即 d p [ i ] [ j ] = d p [ i ] [ j − 1 ] dp[i][j] = dp[i][j-1] dp[i][j]=dp[i][j1]

而我仍然沿用了1143.最长公共子序列的解法 d p [ i ] [ j ] = m a x ( d p [ i − 1 ] [ j ] , d p [ i ] [ j − 1 ] ) ; dp[i][j] = max(dp[i-1][j], dp[i][j-1]); dp[i][j]=max(dp[i1][j],dp[i][j1]);

也就是说我认为这两道题目没有任何区别,事实也确实是这样。

bool isSubsequence(string s, string t) {
        if(s.size() == 0) return true;
        if(t.size() == 0 && s.size() == 0) return true;
        if(t.size() == 0) return false;
        vector<vector<int>> dp(s.size(), vector(t.size(), 0));
        for(int i = 0; i < s.size(); ++i){
            if(s[i] == t[0]){
               while(i < s.size()){
                   dp[i][0] = 1;
                   ++i;
               } 
            }
        }
        for(int j = 0; j < t.size(); ++j){
            if(s[0] == t[j]){
               while(j < t.size()){
                   dp[0][j] = 1;
                   ++j;
               } 
            }
        }
        for(int i = 1; i < s.size(); ++i){
            for(int j = 1; j < t.size(); ++j){
                if(s[i] == t[j])
                    dp[i][j] = dp[i-1][j-1] + 1;
                else
                    dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
            }
        }
        return dp[s.size()-1][t.size()-1] == s.size() ? true : false;
    }

115.不同的子序列

设置 d p [ i ] [ j ] dp[i][j] dp[i][j] 是以i为结尾的S子序列中以j为结尾的t子序列的个数

同样没有按照代码随想录的做,出现的问题呢是 d p [ i ] [ j ] dp[i][j] dp[i][j]出现了溢出,不得不使用unsigned long 来表示

同时请注意这里的初始化比较复杂

s中有元素和t[0]一样的话每个都要加一,并且不一样的话,那个位置要继承上面的count 这个很明显

并且else中的if判断是为了防止s只有一个元素导致超出数组范围设置的

d p [ i ] [ j ] = d p [ i − 1 ] [ j − 1 ] + d p [ i − 1 ] [ j ] dp[i][j] = dp[i-1][j-1] + dp[i-1][j] dp[i][j]=dp[i1][j1]+dp[i1][j]

注意这个公式

如果s[i - 1] 和t[i-1] 相同的使用

例如 s: bagg t: bag

其中s[i-1]和t[i-1] 就是两个各自最后一个g

第一种情况使用s[i-1]进行匹配 由于s[i-1] == t[i-1] 所以这个值就等于 d p [ i − 1 ] [ j − 1 ] dp[i-1][j-1] dp[i1][j1] 也就是bag 和 ba 的子序列个数 显然是 1

第二种情况就是不使用s[i-1] 那么就是 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i1][j] 就是使用 bag 和bag 子序列个数是1

所以s中包含两个t

        for(int i = 0; i < s.size(); ++i){
            if(s[i] == t[0]){
                dp[i][0] = count;
                count++;
            }
            else 
                if(i - 1 >=0)
                    dp[i][0] = dp[i-1][0];
        }
        vector<vector<unsigned long>> dp(s.size(), vector<unsigned long>(t.size(), 0));
        unsigned long count = 1;
        for(int i = 0; i < s.size(); ++i){
            if(s[i] == t[0]){
                dp[i][0] = count;
                count++;
            }
            else 
                if(i - 1 >=0)
                    dp[i][0] = dp[i-1][0];
        }
        for(int i = 1; i < s.size(); ++i){
            for(int j = 1; j < t.size(); ++j){
                if(s[i] == t[j]){
                    dp[i][j] = dp[i-1][j-1] + dp[i-1][j];
                }
                else
                    dp[i][j] = dp[i-1][j];
            }
        }
        return int(dp[s.size() - 1][t.size() - 1]);

583.两个字符串的删除操作

d p [ i ] [ j ] dp[i][j] dp[i][j] 以i - 1为结尾的字符串word1,和以j - 1为结尾的字符串word2,想要达到相等所需要删除元素的最少次数

这个没办法了,只能妥协了,不然没法初始化

注意初始化以及底12行的判断条件比较的是word1[i-1]和word2[j-1];

    int minDistance(string word1, string word2) {
        vector<vector<int>> dp(word1.size() + 1, vector<int>(word2.size() + 1, 0));
        int count = 0;
        for(int i = 0; i <= word1.size(); ++i){
                dp[i][0] = i;
        }
        for(int j = 0; j <= word2.size(); ++j){
                dp[0][j] = 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] + 2, min(dp[i-1][j] + 1, dp[i][j-1] + 1));
                }                
            }
        }
        return dp[word1.size()][word2.size()];
    } 

72.编辑距离

还是应该相信老程序员的经验,别犟!:)

d p [ i ] [ j ] dp[i][j] dp[i][j]表示的是以i-1为结尾的word1,和编程以j-1为结尾的word2需要的最少操作数

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];
                else{
                    dp[i][j] = min(dp[i-1][j], min(dp[i][j-1], dp[i-1][j-1])) + 1;
                }

其中需要注意的是给word1删除一个元素其实和给word2增加一个元素是一样的

回文子串(动态规划,双指针,马拉车算法)

动态规划

这道题下意识会使用dp[i] 为下标i结尾的字符串有几个回文串,这样定义的话找不到dp[i]和之前的关系所以没办法做

根据回文串的特殊性质

image-20231205113335928

我们这样定义

我们在判断字符串S是否是回文,那么如果我们知道 s[1],s[2],s[3] 这个子串是回文的,那么只需要比较 s[0]和s[4]这两个元素是否相同,如果相同的话,这个字符串s 就是回文串。

那么此时我们是不是能找到一种递归关系,也就是判断一个子字符串(字符串的下表范围[i,j])是否回文,依赖于,子字符串(下表范围[i + 1, j - 1])) 是否是回文。

所以为了明确这种递归关系,我们的dp数组是要定义成一位二维dp数组。

布尔类型的 d p [ i ] [ j ] dp[i][j] dp[i][j]:表示区间范围[i,j] (注意是左闭右闭)的子串是否是回文子串,如果是 d p [ i ] [ j ] dp[i][j] dp[i][j]为true,否则为false。

if (s[i] == s[j]) {
    if (j - i <= 1) { // 情况一 和 情况二 一种是"a" 另外一种是"aa"
        result++;
        dp[i][j] = true;
    } else if (dp[i + 1][j - 1]) { // 情况三
        result++;
        dp[i][j] = true;
    }
}

使用动态规划解决这道题的空间复杂度还是不小的。

其中尤其要注意遍历顺序以及注意第二个循环的开始应该是i

        for(int i = s.size() - 1; i >= 0; --i){
            for(int j = i; j < s.size(); ++j){//Attention!
                if(s[i] != s[j]){
                    dp[i][j] = false;
                }
                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;
                    }
                }
            }
        }

双指针

双指针方法还是比较巧妙的,实质上就是通过最每个元素作为中心之后,或者两个相同的元素作为中心之后,寻找对应的回文子串的数量,并且把他们相加

class Solution {
    int extend(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;
    }
public:
    int countSubstrings(string s) {
        int result = 0;
        //使用双指针的方法
        //实际上就是计算以每一个元素或者两个相同元素为中心的回文子串的数量相加;
        for(int i = 0 ; i < s.size(); ++i){
            result += extend(s, i, i, s.size());
            result += extend(s, i, i+1, s.size());
        }
        return result;
    }
};

马拉车算法

马拉车算法是求解最长回文子串的不是这道题

最长回文子串

这里首先附上双指针解法,我自己写的,我真厉害,实质上就是通过最每个元素作为中心之后,或者两个相同的元素作为中心之后,看看以这个为中心的回文子串最长有多长。

class Solution {
    vector<int> resultIndex = vector<int>(2, 0);
    int Maxlength = 0;
    void extend(string s, int i, int j, int n){
        while(i >= 0 && j < n && s[i] == s[j]){
            if(j - i + 1 > Maxlength){
                Maxlength = j - i + 1;
                resultIndex[0] = i;
                resultIndex[1] = j;
            }
            --i;
            ++j;
        }
    }
public:
    string longestPalindrome(string s) {
        for(int i = 0; i < s.size(); ++i){
            extend(s, i, i, s.size());
            extend(s, i, i + 1, s.size());
        }
        string result = s.substr(resultIndex[0], resultIndex[1] - resultIndex[0] + 1);
        return result;
    }
};

回顾一下马拉车算法 可以在 O ( n ) O(n) O(n)的时间复杂度下解决这个问题,非常迪奥

class Solution {
    int max = 0; 
    int Index = 0;
    void manacher(string s){
        vector<int> d(s.size(), 0);
        d[1] = 1;
        for(int i = 2, l = 0, r = 1; i < s.size(); ++i){
            if(i <= r){
                if(d[r+l-i] < r-i+1){
                    d[i] = d[r+l-i]; //在盒内部,不需要暴力处理了
                    continue;
                }
                else
                    d[i] = r-i+1; //后续可能还需要暴力处理
            }
            while(s[i - d[i]] == s[i + d[i]]) d[i]++;
            if(i + d[i] - 1 > r){
                l = i - d[i] + 1;
                r = i + d[i] - 1;
            }
            if(d[i] > max){
                max = d[i];
                Index = i;
            }
        }
    }
public:
    string longestPalindrome(string s) {
        string S;
        S += "$#";
        for(int i = 0; i < s.size(); ++i){
            S += s[i];
            S += "#";
        }
        manacher(S);
        string temp = S.substr(Index - max + 1, 2 * max - 1); //取出回文部分
        string result = "";
        for(int i = 0; i < temp.size(); ++i){
            if(temp[i] != '#')//删除辅助字符
                result += temp[i];
        }
        return result;
    }
};

516.最长回文子序列

这道题注意和回文子串进行区分,子序列可以是不连续的

这边先回顾一下最长回文子串的定义:布尔类型的 d p [ i ] [ j ] dp[i][j] dp[i][j]:表示区间范围[i,j] (注意是左闭右闭)的子串是否是回文子串,如果是 d p [ i ] [ j ] dp[i][j] dp[i][j]为true,否则为false。

最长回文子序列的 d p [ i ] [ j ] dp[i][j] dp[i][j]指的是,在[i, j] 范围之内最长的回文子序列长度

如果s[i]与s[j]相同,那么 d p [ i ] [ j ] = d p [ i + 1 ] [ j − 1 ] + 2 dp[i][j] = dp[i + 1][j - 1] + 2 dp[i][j]=dp[i+1][j1]+2

如果s[i]与s[j]不相同,说明s[i]和s[j]的同时加入 并不能增加[i,j]区间回文子序列的长度,那么分别加入s[i]、s[j]看看哪一个可以组成最长的回文子序列。

加入s[j]的回文子序列长度为 d p [ i + 1 ] [ j ] dp[i + 1][j] dp[i+1][j]

加入s[i]的回文子序列长度为 d p [ i ] [ j − 1 ] dp[i][j - 1] dp[i][j1]

那么 d p [ i ] [ j ] dp[i][j] dp[i][j]一定是取最大的,即: d p [ i ] [ j ] = m a x ( d p [ i + 1 ] [ j ] , d p [ i ] [ j − 1 ] ) dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]) dp[i][j]=max(dp[i+1][j],dp[i][j1]);

这里的初始化方式就有所不同了,需要对对角线进行初始化

for (int i = 0; i < s.size(); i++) dp[i][i] = 1;

注意细节

    int longestPalindromeSubseq(string s) {
        vector<vector<int>> dp(s.size(), vector<int>(s.size(), 0));
        for(int i = 0; i < s.size(); ++i) dp[i][i] = 1;
        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]);
            }
        }
        return dp[0][s.size() - 1];
    }

细节包括

  1. j开始遍历的地方是i+1
  2. 注意i是从下到上遍历的

贪心算法

分发饼干

我是用的是遍历饼干,我看之前我的解法都是遍历胃口,不过问题不大,小饼干先满足小胃口,饼干尽量不浪费

        sort(g.begin(), g.end());
        sort(s.begin(), s.end());
        int j = 0;
        int result = 0;
        for(int i = 0; i < s.size(); ++i){//遍历饼干
            if(j >= g.size()){
                break;
            }
            if(g[j] <= s[i]){
                j++;
                result++;
            }
        }

376.摆动序列

题目要求我们从原始序列中删除一些元素来获得一个子序列,让这个子序列满足摆动序列的特点

image-20231208114817924

在计算是否有峰值的时候,大家知道遍历的下标 i ,计算 prediff(nums[i] - nums[i-1]) 和 curdiff(nums[i+1] - nums[i]),如果prediff < 0 && curdiff > 0 或者 prediff > 0 && curdiff < 0 此时就有波动就需要统计。

这是我们思考本题的一个大题思路,但本题要考虑三种情况:

  1. 情况一:上下坡中有平坡
  2. 情况二:数组首尾两端
  3. 情况三:单调坡中有平坡

这道题目还是有点复杂的,需要好好想一下

贪心算法

    int wiggleMaxLength(vector<int>& nums) {
        if(nums.size() <= 1) return nums.size();
        int curDiff = 0; 
        int preDiff = 0;
        int result = 1;
        for(int i  = 0; i < nums.size() - 1; ++i){
            curDiff = nums[i + 1] - nums[i];
            if((preDiff <= 0 && curDiff > 0) || (preDiff >= 0 && curDiff < 0)){
                result++;
                preDiff = curDiff;
            }
        }
        return result;
    }
image-20231208133822841
        if((preDiff <= 0 && curDiff > 0) || (preDiff >= 0 && curDiff < 0)){
            result++;
            preDiff = curDiff;
        }

为什么把preDiff的更新放在里面呢,出现了波峰波谷才更新?

为了避免上面图的情况,如果放在外面的话,在第三个2的位置,prediff = 0 && curdiff >0 ,这里就会对result++,但这是错误的

解决办法就是放在里面,这样的话再第三个2的位置,prediff = 1 就不会导致错误的结果

动态规划方法

image-20231208135358025
    int wiggleMaxLength(vector<int>& nums) {
        vector<vector<int>> dp(nums.size(), vector<int>(2, 1));
        for(int i = 1; i < nums.size(); ++i){
            for(int j = 0; j < i; ++j){
                if(nums[i] > nums[j])
                    dp[i][0] = max(dp[i][0], dp[j][1] + 1);
            }
            for(int j = 0; j < i; ++j){
                if(nums[i] < nums[j])
                    dp[i][1] = max(dp[i][1], dp[j][0] + 1);
            }
        }
        return max(dp[nums.size() - 1][0], dp[nums.size() - 1][1]);
    }

这个动态规划挺难啊,注意初始化全为1,因为最短的摆动序列就是一个数他自己。
esult++;
preDiff = curDiff;
}
}
return result;
}


<img src="https://img-blog.csdnimg.cn/img_convert/a9bf7aebea0a5a87d6bd2bc2a206f8ea.png" alt="image-20231208133822841" style="zoom: 50%;" />

```C
        if((preDiff <= 0 && curDiff > 0) || (preDiff >= 0 && curDiff < 0)){
            result++;
            preDiff = curDiff;
        }

为什么把preDiff的更新放在里面呢,出现了波峰波谷才更新?

为了避免上面图的情况,如果放在外面的话,在第三个2的位置,prediff = 0 && curdiff >0 ,这里就会对result++,但这是错误的

解决办法就是放在里面,这样的话再第三个2的位置,prediff = 1 就不会导致错误的结果

动态规划方法

image-20231208135358025
    int wiggleMaxLength(vector<int>& nums) {
        vector<vector<int>> dp(nums.size(), vector<int>(2, 1));
        for(int i = 1; i < nums.size(); ++i){
            for(int j = 0; j < i; ++j){
                if(nums[i] > nums[j])
                    dp[i][0] = max(dp[i][0], dp[j][1] + 1);
            }
            for(int j = 0; j < i; ++j){
                if(nums[i] < nums[j])
                    dp[i][1] = max(dp[i][1], dp[j][0] + 1);
            }
        }
        return max(dp[nums.size() - 1][0], dp[nums.size() - 1][1]);
    }

这个动态规划挺难啊,注意初始化全为1,因为最短的摆动序列就是一个数他自己。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值