LeetCode动态规划系列教程(上)

大家好!

相信读者你绝对想不到,我们居然出了一个Leetcode的系列。

从去年7月到现在,我已经在北京的互联网公司呆了整整一年的时间。这中间经历过各种各样的酸甜苦辣,自己为了面试刷题的过程(从杉数到滴滴——未入门算法工程师再找实习工作记),也会经常听到北美同学面试的时候所遇到的各种艰难。是的,只要是互联网公司,无论是国内还是国外,总是要考察很多leetcode的东西。而leetcode如何刷,刷多少,刷到什么程度,其实各个公司也各不相同。但是事实上,leetcode的本质考察点是算法与数据结构,而除去基本的算法与数据结构外,leetcode困难的地方在于熟练度+一些技巧。然而技巧毕竟是存量,不是增量,我们刷多了,自然就有经验。所以这一个系列,我们不面向easy的题目,而更多关注hard和medium+的高频题,并通过大量的leetcode原题,来刻画出互联网公司究竟会考察哪些实际算法与数据结构的知识,以达到复习《算法与数据结构》的效果。

不过Leetcode系列毕竟不是传统的数学课,没有什么大纲可参考,因此这一个系列完全是个人经验的总结,因此我们不会覆盖所有考察的方面,也很难保证适用于所有人。但是一定要明白的是,考察leetcode的目的,还是为了考察算法与数据结构,这也是编程和思考能力基础中的基础。至于技巧的部分,只要看得多,自然就会了,但千万不要为了细节而忽视了本质。

那么我们开始吧。

动态规划(上)

为了吸引阅读量,我们总需要一些标题党,动态规划(Dynamic Programming,DP)就是一个很好的标题党。动态规划究竟应该怎么考虑,我学到大学毕业都没学明白。因此第一篇文章,我们就盯着动态规划来看,看看它究竟应该怎么思考。

动态规划的优化原理

动态规划的优化原理一句话就能说清楚:寻找最优子结构,避免重复计算。初学者看这句话可能有点抽象,我们用斐波那契数列(Fibonacci)的例子来解释。

Problem 1:

考虑数列 ,如果数列满足 ,则称其为斐波那契数列。设计算法计算斐波那契数列的通项。

计算数列通项的算法很明显,有了 ,就可以通过公式计算出 。一个常见的解决问题的方案是递归。简单来说假如设 表示数列的第 ,那么只需要写出这样的函数就可以

int f(int n){
  if(n == 0 || n == 1)return 1;
  return f(n - 1) + f(n - 2);
}

这个算法的问题在什么地方呢?这里我们假设要计算 ,那么我们可以通过一棵递归调用树来观察一下。

可以看出,数值 等,其实在调用树中是被多次调用的。对于f(2)这种数值,因为它没有被事先赋值,所以多次调用就涉及到了多次的重复计算。那么效率自然相形见绌。

那么什么是动态规划呢?它严格来说可以理解为是一个运筹学的概念。但记住一句话:寻找最优子结构,在这里,就是确认 与之前的函数的关系,并且观察,是否一个自变量更大的函数值,会被自变量更小的函数值来决定。而避免重复计算的意思,就是在计算的时候,努力避免已经计算好的值被重复再计算一次。因此在这里,我们其实可以考虑按照这个方式来完成代码。

# Assume that you want to compute f(N)

dp[0] = 1;
dp[1] = 1;
for(int i = 2; i <= N; i++){
  dp[i] = dp[i - 1] + dp[i - 2];
}
printf(dp[N]);

这里的核心计算逻辑dp[i] = dp[i - 1] + dp[i - 2]有个专门的说法,叫做状态转移方程。很明显,它就告诉了你,你定义的状态怎么走,才能转移到下一个状态

也就是说,这里的dp[i],其实就对应了上面的 。可以看出,这个过程中,我们使用循环代替了递归,是因为循环走一遍就会结束,因此一个值计算完毕之后,不会“跳回去”再计算一遍

当然这里要注意的是,动态规划的建模需要考虑无后效性。简单来说,需要保证的是,你定义状态的时候,之后的状态只依赖于之前的状态。在这里,因为你可以让你的状态转移方程满足这个条件,所以无后效性是满足的。

所以总结一下动态规划类型的题目,大概可以分为这么几个步骤

  1. 定义状态(函数)和状态变量(自变量)

  2. 寻找状态转移方程

  3. 寻找循环方式

  4. 考虑边界情况,注意无后效性

考虑边界情况的意思是,注意给动态规划设置一个初始的迭代起点。比方说在这个数列中,必须要知道 ,你才能继续计算,对吧

好的,接下来,我们来看一些常见的动态规划的模型题。

Problem 2: Leetcode 62

一个机器人位于一个 m x n 网格的左上角 。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角。

问总共有多少条不同的路径?

这是一个网格类型的问题,所以很明显,影响函数的自变量就是2个:一个行数,一个列数

现在我们假设说,dp[i][j]表示从左上角走到位置 ,对应的方案数。那么这样的话,我们可以考虑的是,这个状态是如何转移的。更具体一点来说就是,从之前的状态,到之后的状态,这中间会经历什么?然后把所有的可能的转移都考虑到,取最优解,就是我们的答案。

对于这个题其实不难想,因为我们有要求说,机器人只有两个方向可以走。所以到达 前,机器人只能从上边,或者左边来。而从上边来的话,对应的是dp[i-1][j]的状态,而从左边来的话,对应的是dp[i][j-1]的状态。而这两个都有可能发生,所以到这个点的总的状态数,我们应该加在一起。因此,状态转移方程可以写为

边界条件非常简单,考虑dp[i][0], dp[0][j]就可以了。这个时候相当于只在边界上走,那么因为只有一种可能(一直往下/一直往右),所以全部赋值为1。有了初始条件之后,就相当于有了初始的推力,那么状态转移方程就像引擎一样,可以一直把所有的结果都计算出来。

好的,我们放出这个题的核心代码。

for (int i = 0; i < m; ++i) {
  f[i][0] = 1;
}
for (int j = 0; j < n; ++j) {
  f[0][j] = 1;
}
for (int i = 1; i < m; ++i) {
  for (int j = 1; j < n; ++j) {
    f[i][j] = f[i - 1][j] + f[i][j - 1];
  }
}

最终只需要输出f[m-1][n-1]即可。

这里 的遍历顺序都是从小到大,这是因为两个变量对应的状态都是由比它更小的数决定的。也就是说,如果情况反过来,那么遍历顺序也有可能是从大到小

这里要注意的是,一般编程来说大家都会以0作为下标的开始。不过我们很多时候在题干意义上定义问题,会习惯以1作为下标开始。这有的时候会带来一些理解上的麻烦,也请大家多多谅解~

虽然这一个题算是dp的一个入门级别的题,但是事实上,它在空间上的优化点还是很明显的。我们可以看出,一个状态最多只会依赖到它”相邻的两个状态“。换句话说它没有办法关注到更远的状态了。我们用图来更形象的说一下。

对于红色的点来说,可以看出它只会受到“上一个”和“前一个”的制约,因此严格来说,如果到计算这一个点的状态值的时候,实际上是不需要考虑第一列的结果的。

因此,这个情况下,其实我们可以把它简化成

确实,不需要额外的状态维度来保存数据这一点,没人否认。但是这个式子又是怎么得到的呢?注意,空间省了,计算步骤是没变的,时间复杂度还是一样的。我们再画一张图看一下。

所以可以看出,需要修改的dp[i],对应的就是新的一行的对应列的元素,而dp[i-1]就是格子的左边。赋值等式右边的dp[i]对应的是旧的一行的对应列的元素。所以这两个式子的等价看似无厘头,其实画个图就很清楚了。

好的,代码怎么改相信读者也都比较清楚了,这里就不多说了。

Problem 3: Leetcode 300

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

比方说如果输入[10,9,2,5,3,7,101,18],那么输出就是4,因为 [2,3,7,101]是答案。

这个其实就是大名鼎鼎的最长上升子序列(Longest Incresing Subsequence,LIS)的问题。它的参数非常好找,就是数组的元素个数。

定义dp[i]表示“前 个元素,且以第 个元素结尾,对应的最长上升子序列长度“。这里有一个技巧就是要强制要求且以第 个元素结尾,为什么?这是因为,我们要保证的是子序列是递增的,所以必须要有相邻元素的比较。那么上一个状态和这一个状态的”相邻元素的比较“从哪里来?其实也只能从最后一个元素来(仔细品一品这句话)。而且因为最长上升子序列一定会以某一个下标的元素结尾,所以不会有遗漏的情况,所以把所有的情况取最值,是一定可以得到最优解的

好的,那么我们看怎么比较?既然要有“相邻元素的比较”,那么对于dp[i]而言,之前任意一个dp[j], j < i是不是都有可能?所以只要我们的nums[j] < nums[i],我们就可以认为,第 个数字的前一个可能是第 个。那么相连起来,序列长度就要加一,所以状态转移方程为

初始的情况其实也不难,一开始其实可以假设没有任何的“拼接”发生。这相当于认为,序列长度为1。

好的,我们放出核心代码。

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

这里的maxans就是答案。

Problem 4: Leetcode 322

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

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

比方说如果我们有coins = [1, 2, 5], amount = 11,那么答案就是3,因为我们有11 = 5 + 5 + 1,所以3枚硬币就够了。

这一个问题实际上就是一个完全背包问题。说它是背包问题,是因为这个题中,每一个硬币都可以选择“要”或者“不要”,并且硬币数量是不受限的(也就是说如果每一种硬币只有1个,那就是一个0-1背包问题了)。那么这样的话,其实我们的递推思路也很简单,就是每一次,去看到底选哪一种硬币就可以了

我们设dp[i]表示“金额为 的情况下,最少需要的硬币数”。枚举每一类硬币,其实就可以找到对应的之前的状态。具体来说,如果我们这里选择面值为1的硬币,那么状态就回退到了dp[i-1]。面值为2的话,状态就回退到了dp[i-2]

好的,所以状态转移方程就是

这里的 就是硬币的种数, 就是对应的硬币的面值。

那么,边界条件是什么呢?这里其实就是dp[0] = 0。但是同时,其他的位置的值,我们要设置为一个很大的数,表示“目前还没有找到方案,所以认为不可能完成”的意思。

好的,我们来看一下核心代码。

vector<int> dp(amount + 1, Max);
dp[0] = 0;

for (int i = 1; i <= amount; ++i) {
  for (int j = 0; j < (int)coins.size(); ++j) {
    if (coins[j] <= i) {
      dp[i] = min(dp[i], dp[i - coins[j]] + 1);
    }
  }
}

这里的Max就是一个很大的值。最后的结果就是dp[amount]。如果这个数还是一个很大的值,那就说明不可能完成。根据题目要求,我们需要返回-1

有的人可能会问0-1背包问题的具体例子,这个对应的是Lintcode 125,或者NOIP的“采药”问题。感兴趣的朋友可以自己去看一看,因为思路比这里的完全背包问题要简单,我们就不多说了。

Problem 5: Leetcode 122

给定一个数组 prices ,其中 prices[i] 是一支给定股票第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。

注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

比方说如果股票每天的价格分别为[7,1,5,3,6,4],那么答案就是7,因为可以第2天买,第3天卖,第4天买,第5天卖。

从这个题开始,我们就开始介绍股票系列的问题。这些问题也是相对比较容易考到的高频问题,并且在思考难度上层层递进,值得我们多说两句。

首先,这个题容易想到的变量是股票的天数,毕竟每一天股票价格不一样。但是就这其实是不够的,因为可以看到,股票的买卖状态也会影响你的操作,所以需要刻画股票的买卖状态。因此这个问题,除了天数以外,还需要增加一个“买卖”的维度,分别对应0和1。

我们定义dp[i][0]为“第 天交易完后手里没有股票的最大利润”,dp[i][1]为“第 天交易完后手里有一支股票的最大利润”。那么这样的话,分开考虑,对于dp[i][0],它的前一天有可能没有股票,也有可能有股票。前一天没有,今天也没有,说明没有操作。前一天有,今天没了,说明卖掉了,会有收益。所以这样的话,状态转移方程就是

对于dp[i][1],分析方式是一致的。我们就直接给出结果了。

具体怎么来的,交给读者思考。

关于边界条件,其实就是在一开始的时候股票是什么样的。这里其实就是dp[0][0] = 0, dp[0][1] = -prices[0]。这是因为一开始,如果手上就有股票,说明买了。如果手上没有股票,那么收益为0。

好的,我们来看一下核心代码。

dp[0][0] = 0, dp[0][1] = -prices[0];
for (int 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][0] - prices[i]);
}

最后只需要输出dp[n-1][0]就可以。这里的下标是从0开始的,所以n-1实际上就是第 天。

Problem 6: Leetcode 309

给定一个整数数组,其中第 i 个元素代表了第 i 天的股票价格 。

设计一个算法计算出最大利润。在满足以下约束条件下,你可以尽可能地完成更多的交易(多次买卖一支股票):

你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。卖出股票后,你无法在第二天买入股票 (即冷冻期为 1 天)。

这个题相比较Problem 5,多了一个限制,就是冷冻期。但是考虑到冷冻期只会有1天,所以无非就是多了一个状态而已。也就是说相比较之前的“买卖”而言,还多了一个状态,这个状态就是“冷冻”。

我们设dp[i][0]表示持股,dp[i][1]表示没有持股,并且在冷冻期,dp[i][2]表示没有持股,并且不在冷冻期(完整的context在上一个题,我就不多写了……)。分这三种情况讨论,可以看出,如果是dp[i][0],那么上一天,要不持股,要不没持股,注意,必须要不在冷冻期内,所以我们可以写出

如果是dp[i][1],那么因为进入了冷冻期,所以只有可能是卖出了,所以这种情况就是

类似的,对于dp[i][2],也可以得到结果为

最后还有个边界条件,这个不难思考,dp[0][0]= -prices[0], dp[0][2] = 0,但是还有一个问题是dp[0][1] = 0这个是为什么?这个其实从题干意义上,不容易判断。但是我们可以从方程上去看,取什么值是合适的

其实可以看到,这个初值可以影响到的就是dp[1][0], dp[1][2],对于dp[1][0],其实就应该是刚买股票,那么对应的结果就应该是-prices[i],所以这个初值是一定的。不放心的话,可以再看dp[1][2],而这个情况相当于没有股票,也没有冷冻期。那么正常情况下思考,它当然应该是和dp[0][2]相同才对(因为状态转移的另外一种情况,实际上是不存在的)。因此,这里应该赋值为0。当然,其实你赋值-1,严格来说也是符合逻辑的,但是结合dp[i][0]那个结果来看,就不一定了。

好的,我们来看一下核心代码。

dp[0][0] = -prices[0];
for (int i = 1; i < n; ++i) {
  dp[i][0] = max(f[i - 1][0], f[i - 1][2] - prices[i]);
  f[i][1] = f[i - 1][0] + prices[i];
  f[i][2] = max(f[i - 1][1], f[i - 1][2]);
}

最终返回的就是dp[n-1][1], dp[n-1][2]中的更大的那个。

Problem 7: Leetcode 123

给定一个数组,它的第 i 个元素是一支给定的股票在第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你最多可以完成 两笔 交易。

注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

这里的问题相比较之前来说,多了一个限制,就是最多两笔交易。但是本质上,这还是一个序列的问题,所以思考方式是一样的:从前往后思考。

这里刻画状态就不需要按照天数来刻画了(虽然也确实是影响因素),但是这样之后,”两笔消费“的这个信息就很难用了。所以要克服这个困局,必须要先考虑,怎么利用“两笔消费”的信息,而不是坚持使用之前的思路。而事实上,信息的更新,是可以通过时间的推进来做的。具体什么意思,大家后面就能看明白了。

我们设变量 对应五种状态,分别对应为

  1. 未进行过任何操作;

  2. 只进行过一次买操作;

  3. 进行了一次买操作和一次卖操作,即完成了一笔交易;

  4. 在完成了一笔交易的前提下,进行了第二次买操作;

  5. 完成了全部两笔交易。

第一个状态其实不用多说的,因为如果一直没有任何操作,那就肯定一直没有收益,所以很明显

剩下的四个状态,随着时间的推进,可能在每一天的结果不一样,所以要考虑状态转移。假如说 对应的是第 天的状态,那么对于第 天,我们分别考虑。

对于 ,有两种可能,有可能保持状态不变,也有可能从0开始,买了一支股票。所以状态转移方程为

对于 ,可以考虑保持状态不变,也可以考虑说,在之前买过一次之后,把它卖出去。所以状态转移方程为

类似的我们有

最后,还有一个边界条件。这里对应的就是 在第1天的情况。很明显,一开始持有股票,和不持有股票,对应的收益肯定会差一个prices[0]。所以事实上,持有的话,那么初始值就是-prices[0]。不持有的话,就是0

好的,我们来看一下核心代码。

int x2 = -prices[0], x3 = 0;
int x4 = -prices[0], x5 = 0;
for (int i = 1; i < n; ++i) {
  x2 = max(x2, -prices[i]);
  x3 = max(x3, x2 + prices[i]);
  x4 = max(x4, x3 - prices[i]);
  x5 = max(x5, x4 + prices[i]);
}

最终只需要给出 的值就可以了。

可能有的人会问为什么不能给 ?其实也是可以的,但是要注意的是,其实这里我们是允许同一天进行买和卖的操作的(虽然没有收益),所以这个算法中, 这个情况的最优解,应该在 中也有,所以其实就不需要再考虑 最后的结果是多少了。

Problem 8: Leetcode 188

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

设计一个算法来计算你所能获取的最大利润。你最多可以完成 k 笔交易。

注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

这个问题虽然看起来是Problem 7的升级版本,但是事实上,我们可以发现,同样的思路也可以运用到Problem 8中来。也就是说,之前我们通过“两次交易”的状态,设置了5个变量。合理推断,如果是 次交易,其实可以设置 个变量。这样虽然可以做,但是很明显的一个问题在于,你不能事先知道 是多少,所以不如把它当成额外的第二维的参数,来思考这个问题。

buy[i][j]表示“ 天,交易了 笔之后,手上有股票,得到的最大收益“。设sell[i][j]表示手上没股票的对应情况。那么这样的话,如何推导,其实我觉得思路一模一样,也没有多提的必要了。所以我们这里就直接放出核心代码了。

buy[0][0] = -prices[0];
sell[0][0] = 0;
for (int i = 1; i <= k; ++i) {
   buy[0][i] = -prices[0];
   sell[0][i] = 0;
}

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

最终只需要返回最后一天,sell数组的交易次数的不同情况即可。

Problem 9: Leetcode 376

如果连续数字之间的差严格地在正数和负数之间交替,则数字序列称为 摆动序列 。第一个差(如果存在的话)可能是正数或负数。仅有一个元素或者含两个不等元素的序列也视作摆动序列。

例如, [1, 7, 4, 9, 2, 5] 是一个 摆动序列 ,因为差值 (6, -3, 5, -7, 3) 是正负交替出现的。

相反,[1, 4, 7, 2, 5] 和 [1, 7, 4, 5, 5] 不是摆动序列,第一个序列是因为它的前两个差值都是正数,第二个序列是因为它的最后一个差值为零。子序列 可以通过从原始序列中删除一些(也可以不删除)元素来获得,剩下的元素保持其原始顺序。

给你一个整数数组 nums ,返回 nums 中作为 摆动序列 的 最长子序列的长度 。

这个问题其实在思路上已经没有什么创新点了。但是考虑到它也很高频,读者如果希望自己思考的话,可以先不要往下看……

按照动态规划的传统思路,如果是这种问题的话,那么自然是考虑,当时间不断推进的时候,状态会如何转移

很明显,如果要求是摆动序列,那么我们观察最后两个元素的时候,只会有两种情况:要不第一个元素比第二个元素大,要不就小。所以我们可以考虑,设up[i]表示“前 个元素中的某一个为结尾的,且最后两个元素是上升趋势的序列长度”,down[i]类似意思。注意这里没有要求序列中以第 个为结尾,是因为题目中没有出现“序列连续”的要求。但是有的题目中,我们考虑状态的时候是有这个要求的。

那么我们先看up[i]可以由什么状态转移过来。如果是up[i],说明我们在考虑最后两个元素是“上升趋势”的情况。那么这样的话,事实上,如果最近的两个数依然是上升趋势(nums[i] > nums[i-1]),那么要不可以不取这个数作为子序列的一部分,要不就取,分别对应up[i-1]down[i-1] + 1。但是如果最近的两个数不是上升趋势,那么就不能够取nums[i]了(想想为什么?),只能够考虑取up[i-1]。这个相当于用第 个数来取代第 个数的一个方案。

如果是down[i],那么趋势就是“下降趋势”了,这个可以做类似的讨论。

好的,那么这个题的状态转移方程就是

至于边界条件,这个我想不是很难,我们直接给出,就是up[0] = down[0] = 1

好的,我们来看看核心代码吧。

up[0] = down[0] = 1;
for (int i = 1; i < n; i++) {
  if (nums[i] > nums[i - 1]) {
    up[i] = max(up[i - 1], down[i - 1] + 1);
    down[i] = down[i - 1];
  } else if (nums[i] < nums[i - 1]) {
    up[i] = up[i - 1];
    down[i] = max(up[i - 1] + 1, down[i - 1]);
  } else {
    up[i] = up[i - 1];
    down[i] = down[i - 1];
  }
}

最终就是看up[n-1], down[n-1]中的较大的那个。

当然了,这个题在空间上其实是可以优化的,因为我们发现,我们每一次都只会依赖前一个状态,不会依赖到两个状态以前的那些状态。因此,我们可以考虑只用两个变量up, down来完成我们的任务。这里代码大家可以自己去改,或者看官网题解,我们就不多说了。

小结

本节我们介绍了动态规划的基本做题方法,并举出了一些很常见的动态规划的基本题。这些题的做法各有不同,但是大的动态规划的解题思路是相同的。也就是说,我们需要合理的去观察各个状态,并尝试理解它们。刷题刷多了就熟了没错,但更重要的是了解做题的大的框架,再去记忆小的做题的tricks。只有两手抓,两手都硬,才能事半功倍。

下一节我们会介绍一些动态规划的更加困难的高频题。算是再让大家理解一些困难题题解的思路吧~

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值