0-1背包问题总结

0-1背包问题

问题描述

N N N 件物品和一个容量为 C C C 的背包。放入第 i i i 件物品耗费的容量是 W i W_i Wi,得到的价值是 V i V_i Vi。求解将哪些物品装入背包可使价值总和最大。

这个是最基础的背包问题,特点是:每种物品仅有一件,可以选择放或不放。

递归算法

定义函数 F ( i , C ) F(i,C) F(i,C) 表示前 i i i 件物品放入容量为 C C C 的背包可以获得的最大价值。于是
F ( i , C ) = m a x { F ( i − 1 , C ) , F ( i − 1 , C − W i ) + V i } F(i,C) = max\{F(i-1,C) , F(i-1,C-W_i)+V_i\} F(i,C)=max{F(i1,C),F(i1,CWi)+Vi}

对于将前 i i i 件物品放入容量为 C C C 的背包中,获得的最大价值的问题。可以只考虑第 i i i 件物品放与不放的策略:

  1. 不放第 i i i 件,那么问题就转化为“将前 i − 1 i-1 i1件物品放入容量为 C C C 的背包中,获得的最大价值”,这时候价值为 F ( i − 1 , C ) F(i-1,C) F(i1,C)
  2. 放第 i i i 件,那么问题就转化为“将前 i − 1 i-1 i1 件物品放入容量为 C − W i C-W_i CWi 的背包中,获得的最大价值”,这时候获得的最大价值就是 F ( i − 1 , C − W i ) + V i F(i-1,C-W_i)+V_i F(i1,CWi)+Vi

于是伪代码为:GitHub代码:递归(recursion)

//重量数组
double[] W;
//价值数组
double[] V;

//递归函数,有时候W和V可能需要传参使用,而不使用成员变量,也就是F(double[] W,double[] V,int i ,double C)
public double F(int i, double C) {
    //边界条件
    if (i < 0 || C < 0) return 0;

    // 不选第i个的价值
    double value = F(i - 1, C);

    //只有背包容量大于或等于第i个物品的重量时,才能考虑是否装第i个
    if (C >= W[i]) {
        //选第i个时的价值F(i - 1, C - W[i]) + V[i];

        //看选与不选第i个哪个获得的价值最大。然后赋值给value,然后返回。
        value = Math.max(value, F(i - 1, C - W[i]) + V[i]);
    }
    return value;
}

带备忘录的自顶向下法(top-down with memoization)

GitHub代码:top_down_with_memoization

对于递归算法,为了减少重复计算的子问题,于是定义memory数组,用于记录已经计算了的子问题

//重量数组
int[] W;
//价值数组
int[] V;

//用于存储已经计算的子问题。首先将其全部赋值为-1,表示还未计算。
//其中memory[i][j]表示递归函数F(i,j)返回的值
int[][] memory = new int[N][C];
//或者在最开始的时候定义一个非常大的数组,比如new int[1024][1024]

//递归函数
F(i,C){
    //边界条件
    if (i < 0 || C < 0) return 0;

    //如果有数据,那么就返回。
    if (memory[i][C] != -1) {
        return memory[i][C];
    }
    
    // 不选第i个的价值
    int value = F(i - 1, C);

    //只有背包容量大于或等于第i个物品的重量时,才能考虑是否装第i个
    if (C >= W[i]) {
        //选第i个时的价值F(i - 1, C - W[i]) + V[i];
        //看选与不选第i个哪个获得的价值最大。然后赋值给value,然后返回。
        value = Math.max(value, F(i - 1, C - W[i]) + V[i]);
    }
    //到了这里说明未被记录
    memory[i][C] = value;
    return value;
}

自底向上法(bottom-up method)

GitHub代码:bottom_up_method

定义子问题: d p [ i ] [ j ] dp[i][j] dp[i][j] 表示前 i i i 件物品放入背包容量为 j j j 的最大价值。于是:

  1. i = 0 i=0 i=0 时,表示没有物品放入背包为 j j j 中的最大价值,为0,即 d p [ 0 ] [ j ] = 0 dp[0][j] = 0 dp[0][j]=0
  2. j = 0 j=0 j=0 时,表示前i件物品放入背包容量为0的最大价值,也是0,即 d p [ i ] [ 0 ] = 0 dp[i][0] = 0 dp[i][0]=0
  3. i ≠ 0 i \ne 0 i=0 并且 j ≠ 0 j \ne 0 j=0 时, d p [ i ] [ j ] = m a x { d p [ i − 1 ] [ j ] , d p [ i − 1 ] [ j − W i ] + V i } dp[i][j] = max\{dp[i-1][j],dp[i-1][j-W_i]+V_i\} dp[i][j]=max{dp[i1][j],dp[i1][jWi]+Vi}

由于我们要计算的是 d p [ N ] [ C ] dp[N][C] dp[N][C],于是我们需要定义的数组大小为 [ N + 1 ] [ C + 1 ] [N+1][C+1] [N+1][C+1]

//其中W表示重量数组,V表示价值数组,N表示物品个数,C表示背包容量
//说明,对于W与V而言,下标是从0开始的,其中第i个物品的重量和价值为W[i-1]和V[i-1]
public int knapsack(int[] W, int[] V, int N, int C) {
    //定义dp数组
    int[][] dp = new int[N + 1][C + 1];

    //初始化,对于当重量为0和物品个数为0时,价值为0
    for (int i = 0; i < N + 1; i++) {
        dp[i][0] = 0;
    }
    for (int j = 0; j < C + 1; j++) {
        dp[0][j] = 0;
    }


    //根据状态转移方程编写迭代
    for (int i = 1; i < N + 1; i++) {
        for (int j = 1; j < C + 1; j++) {
            //放与不放,哪个价值更高,就将其存储到dp中

            //不放第i个物品的价值value
            int value = dp[i - 1][j];

            //如果要放入,那么第i个物品的重量需要比背包容量小
            if (W[i - 1] <= j) {//表示第i个物品放得下
                //放入第i个物品的价值为value2
                // value2 = dp[i-1][j-W[i-1]]+V[i-1];
                // value = Math.max(value,value2)   
                //将上面的写在一起
                value = Math.max(value, dp[i - 1][j - W[i - 1]] + V[i - 1]);
            }
            //将计算出来的最优dp[i][j]进行存储
            dp[i][j] = value;
        }
    }
    //看需要返回什么,如果只是最大值,那就直接返回dp[N][C],如果返回数组也行。
    return dp[N][C];
}

回溯算法(backtracking)

GitHub代码:回溯法(backtracking)

简单概述

回溯法按深度优先策略搜索问题的解空间树。首先从根节点出发搜索解空间树,当算法搜索至解空间树的某一节点时,先利用剪枝函数判断该节点是否可行(即能得到问题的解)。如果不可行,则跳过对该节点为根的子树的搜索,逐层向其祖先节点回溯;否则,进入该子树,继续按深度优先策略搜索。

回溯法的基本行为是搜索,搜索过程使用剪枝函数来为了避免无效的搜索。剪枝函数包括两类:

  1. 使用约束函数,剪去不满足约束条件的路径;
  2. 使用限界函数,剪去不能得到最优解的路径。

对于回溯法,

  1. 问题解的形式:其问题的解一般可以表示为1个n元组 ( x [ 1 ] , x [ 2 ] , . . . , x [ n ] ) (x_{[1]},x_{[2]},...,x_{[n]}) (x[1],x[2],...,x[n]) 的形式。
  2. 显式约束:对分量 x [ i ] x_{[i]} x[i] 的取值范围的限定
  3. 解空间:对问题的一个实例,解向量满足显式约束的所有n元组构成该实例的一个解空间
  4. 隐式约束:为满足问题的解对不同的分量之间施加的约束。

以0-1背包问题为例:

  1. 问题解的形式:对于有 n n n 种可选大的物品的0-1背包问题,其解为长度 n n n 的向量 ( x [ 1 ] , x [ 2 ] , . . . , x [ n ] ) (x_{[1]},x_{[2]},...,x_{[n]}) (x[1],x[2],...,x[n])

  2. 显式约束: x [ i ] ∈ { 0 , 1 } x_{[i]}\in \left\{ 0,1 \right\} x[i]{0,1} ,其中 x [ i ] = 1 x_{[i]}=1 x[i]=1 表示选取第 i i i 个物品, x [ i ] = 0 x_{[i]} = 0 x[i]=0 表示不选第 i i i 个物品。解向量中每一个变量所有可能的0-1赋值,构成了该问题的解空间。比如,对于 n = 4 n=4 n=4 时,其解空间为 ( 0 , 0 , 0 , 0 ) (0,0,0,0) (0,0,0,0), ( 0 , 0 , 0 , 1 ) (0,0,0,1) (0,0,0,1), ( 0 , 0 , 1 , 0 ) (0,0,1,0) (0,0,1,0), ( 0 , 0 , 1 , 1 ) (0,0,1,1) (0,0,1,1), ( 0 , 1 , 0 , 0 ) (0,1,0,0) (0,1,0,0), ( 0 , 1 , 0 , 1 ) (0,1,0,1) (0,1,0,1), ( 0 , 1 , 1 , 0 ) (0,1,1,0) (0,1,1,0), ( 0 , 1 , 1 , 1 ) (0,1,1,1) (0,1,1,1), ( 1 , 0 , 0 , 0 ) (1,0,0,0) (1,0,0,0), ( 1 , 0 , 0 , 1 ) (1,0,0,1) (1,0,0,1), ( 1 , 0 , 1 , 0 ) (1,0,1,0) (1,0,1,0), ( 1 , 0 , 1 , 1 ) (1,0,1,1) (1,0,1,1), ( 1 , 1 , 0 , 0 ) (1,1,0,0) (1,1,0,0), ( 1 , 1 , 0 , 1 ) (1,1,0,1) (1,1,0,1), ( 1 , 1 , 1 , 0 ) (1,1,1,0) (1,1,1,0), ( 1 , 1 , 1 , 1 ) (1,1,1,1) (1,1,1,1)

    对于物品{ A A A B B B C C C D D D},其中 A A A 表示选取物品 A A A A ˉ \bar{A} Aˉ 表示不选取物品 A A A 。那么对于选取物品 A A A B B B C C C D D D 的问题的解向量就是 ( 1 , 1 , 1 , 1 ) (1,1,1,1) (1,1,1,1)
    在这里插入图片描述

  1. 解空间:对问题的一个实例,解向量满足显式约束的所有n元组构成该实例的一个解空间

  2. 隐式约束:装入背包的物品总重量不超过背包的容量,即: Σ n i = 1 x [ i ] × w [ i ] ≤ C \underset{i=1}{\overset{n}{\varSigma}}x_{[i]} \times w_{[i]} \le C i=1Σnx[i]×w[i]C

回溯法的实现

假设问题的解用向量 x = ( x [ 1 ] , x [ 2 ] , . . . , x [ n ] ) x=(x_{[1]},x_{[2]},...,x_{[n]}) x=(x[1],x[2],...,x[n]) 表示,其中 x [ i ] x_{[i]} x[i]属于 X i X_i Xi ,从空向量开始,首先选择 X 1 X_1 X1 的最小值作为 x [ 1 ] x_{[1]} x[1] 的值,如果合法,部分解为 ( x [ 1 ] ) (x_{[1]}) (x[1]) ,继续在 X 2 X_2 X2 中选择最小的值赋值给 x [ 2 ] x_{[2]} x[2] ,否则把 X 1 X_1 X1 的下一个元素赋值给 x [ 1 ] x_{[1]} x[1]

一般地,假设算法已得到部分解 ( x [ 1 ] , x [ 2 ] , . . . , x [ j ] ) (x_{[1]},x_{[2]},...,x_{[j]}) (x[1],x[2],...,x[j]) ,考虑向量 v = ( x [ 1 ] , x [ 2 ] , . . . , x [ j ] , x [ j + 1 ] ) v=(x_{[1]},x_{[2]},...,x_{[j]},x_{[j+1]}) v=(x[1],x[2],...,x[j],x[j+1])

  1. v v v 为问题的可行解,算法记录它作为1个解。如果只需要1个解时,算法终止,否则继续寻找其他解。
  2. v v v 为部分解,算法在 X j + 2 X_{j+2} Xj+2 中选择最小值赋值给 x [ j + 2 ] x_{[j+2]} x[j+2] ,继续步骤1
  3. v v v 既不是部分解,也不是最终解,
    • X j + 1 X_{j+1} Xj+1 还有其它元素可选择,赋值下一个元素给 x [ j + 1 ] x_{[j+1]} x[j+1]
    • 如果 X j + 1 X_{j+1} Xj+1 没有其他元素可选择,算法回溯上一层,即把 X j X_j Xj 的下一个元素赋值给 x [ j ] x_{[j]} x[j] 。若 X j X_{j} Xj 也没有其他元素可选,则算法回溯再上一层,即把 X j − 1 X_{j-1} Xj1 的下一个元素赋值给 x j − 1 x_{j-1} xj1 ,依此类推。
递归实现

x x x :表示解向量,一般是x[]数组形式

n n n :表示解向量的长度

f ( n , t ) f(n,t) f(n,t) g ( n , t ) g(n,t) g(n,t) :表示当前扩展结点处未被搜索过的子树的起始编号终止编号

h ( i ) h(i) h(i) :表示当前扩展结点处 x [ t ] x_{[t]} x[t] 的第 i i i 个可选值

c o n s t r a i n t ( t ) constraint(t) constraint(t) :当前扩展结点处的约束条件

b o u n d ( t ) bound(t) bound(t) :当前扩展结点的限界条件

void backtrackRec(int t) {
    if (t > n) {
        //表示搜索至叶结点,也就是获得了一个可行解,输出x向量或者记录x向量
        output(x);
    } else {
        //对x[t]每一个可能的取值进行搜索,对于0-1背包,其取值可为0或1
        for (int i = f(n, t); i <= g(n, t); i++) {   
            x[t] = h(i);
            //(x[1],x[2],...,x[t])满足约束,限界条件
            if (constraint(t) && bound(t)) {
                backtrackRec(t + 1);//前进
            }
        }
    }
}

在看上面代码的时候可以在脑中想象一颗解空间树,然后对于每一个函数的调用,就类似于一个人站在了某一个结点处,选择该怎么走(而选择的选项就是下一个解向量能取的值),有点类似于递归思想。

迭代实现

s o l u t i o n ( t ) solution(t) solution(t):判断在当前扩展结点处是否已得到问题的可行解。如果返回true,表示扩展结点处 x [ 1 : t ] x[1:t] x[1:t] 是问题的可行解;如果返回false表示在当前扩展结点处 x [ 1 : t ] x[1:t] x[1:t] 只是问题的部分解,还需要向纵深方向继续搜索。

f ( n , t ) f(n,t) f(n,t) g ( n , t ) g(n,t) g(n,t) :表示当前扩展结点处未被搜索过的子树的起始编号终止编号

h ( i ) h(i) h(i) :表示当前扩展结点处 x [ t ] x_{[t]} x[t] 的第 i i i 个可选值

c o n s t r a i n t ( t ) constraint(t) constraint(t) :当前扩展结点处的约束条件,如果返回true,在当前扩展结点处 x [ 1 : t ] x[1:t] x[1:t] 取值满足问题的约束条件,否则,不满足约束条件,可剪去相应的子树。

b o u n d ( t ) bound(t) bound(t) :当前扩展结点的限界条件,如果返回true时,在当前扩展结点处 x [ 1 : t ] x[1:t] x[1:t] 取值未使目标函数越界,还需对其相应的子树进一步搜索。否则,当前扩展结点处 x [ 1 : t ] x[1:t] x[1:t] 取值使目标函数越界,可剪去相应的子树。

void backtrackIter() {
    int t = 1;
    while (t > 0) {
        //X_t还有其他元素
        if (f(n, t) <= g(n, t)) {
            for (int i = f(n, t); i <= g(n, t); i++) {
                x[t] = h(i);
                //(x[1],x[2],...,x[t])满足约束及限界条件
                if (constraint(t) && bound(t)) {
                    if (solution(t)) {
                        //求得一个解,输出x,或者保存x
                        output(x);
                    } else {
                        t++;    //前进
                    }
                } else {
                    t--;    //回溯
                }
            }
        }
    }
}
子集树与排列树
  • 当所给的问题是从 n n n 个元素的集合 S S S 中找出 S S S 满足某种性质的子集时,相应的解空间树称为子集树。一般有 2 n 2^n 2n 个叶结点。其结点总数为 2 n + 1 − 1 2^{n+1}-1 2n+11 。遍历子集树的算法需要 Ω ( 2 n ) \varOmega(2^n) Ω(2n) 计算时间。

  • 当所给的问题是确定 n n n 个元素满足某种性质的排列时,相应的解空间树称为排列树。排列树一般有 n ! n! n! 个叶结点。遍历排列树需要 Ω ( n ! ) \varOmega(n!) Ω(n!) 计算时间。 其算法框架可描述如下:

    void backtrack(int t) {
        if (t > n) {
            output(x)
        } else {
            for (int i = t; i <= n; i++) {
                swap(x[t], x[i]);
                if (constraint(t) && bound(t)) {
                    backtrack(t + 1);
                }
                swap(x[t], x[i]);
            }
        }
    }
    

回溯法的效率分析

算法的效率很大程度上依赖于以下因素:

  1. 产生 x [ k ] x_{[k]} x[k] 的时间
  2. 满足显约束的 x [ k ] x_{[k]} x[k] 值的个数
  3. 计算约束函数 c o n s t r a i n t constraint constraint 的时间
  4. 计算上界函数 b o u n d bound bound 的时间
  5. 满足约束函数和上界函数约束的所有 x [ k ] x_{[k]} x[k] 的个数

一般而言,对于解空间结点数为 2 n 2^n 2n 或者 n ! n! n! ,在最坏情况下,回溯法的时间复杂度一般为 O ( p ( n ) ∗ 2 n ) O(p(n)*2^n) O(p(n)2n) O ( q ( n ) ∗ n ! ) O(q(n)*n!) O(q(n)n!) ,其中 p ( n ) p(n) p(n) q ( n ) q(n) q(n) 均为 n n n 的多项式。

0-1背包问题回溯法代码

//对于放入背包的物品,该类记录单个物品的信息
public class BLBag implements Comparable<BLBag> {
    //物品名字
    private String name;
    
    //物品重量
    private int weight;
    
    //物品价值
    private int value;
    
    //单位重量价值,设置为int类型有问题,比如5/3和4/3int以后就会出现相等。
    private double unitValue;
    
    //重写Comparable接口的方法,用于比较该类与传入的BLbag的单位价值的大小
    //方便后面Array.sort调用的时候使用
    @Override
    public int compareTo(BLBag snapsack) {
        double value = snapsack.unitValue;
        if (unitValue > value)
            return 1;
        if (unitValue < value)
            return -1;
        return 0;
    }
    
    //下面构造方法与get,set方法
    public BLBag(String name, int weight, int value) {
        this.weight = weight;
        this.value = value;
        this.name = name;
        //由于value和weight都是int类型,在算除法之前需要转成double
        this.unitValue = (weight == 0) ? 0 : (double) value / (double) weight;
    }

    public String getname() {
        return name;
    }

    public void setname(int weight) {
        this.name = name;
    }

    public int getWeight() {
        return weight;
    }

    public void setWeight(int weight) {
        this.weight = weight;
    }

    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }

    public double getUnitValue() {
        return unitValue;
    }
}

那么使用回溯法解决背包问题如下:

import java.util.Arrays;
import java.util.Collections;

public class Knapsack{
    // 待选择物品数量
    private int n;
    
    // 待选择的物品
    private BLBag[] bags;
   
    // 背包的总承重
    private int totalWeight;
    
    // 背包的当前承重
    private int currWeight;
      
    // 放入物品后背包的最优价值
    private int bestValue;
    
    // 放入物品和背包的当前价值
    private int currValue;
    
    //构造方法,将物品按照一定规则排序,并且初始化背包容量
    public Knapsack(BLBag[] bags, int totalWeight) {
        this.bags = bags;
        this.totalWeight = totalWeight;
        this.n = bags.length;

        // 物品依据单位重量价值从大到小进行排序
        Arrays.sort(bags, Collections.reverseOrder());
    }
    
    //回溯算法,从子集树第i个开始。。。
    public int backtrack(int i){
        //到达子集树的叶结点,相当于此时没有物品可以放入背包,当前价值为最优价值
        if (i >= n) {
            bestValue = currValue;
            return bestValue;
        }

        // 首要条件:放入当前物品,判断物品放入背包后是否小于背包的总承重
        if (currWeight + bags[i].getWeight() <= totalWeight) {
            // 将物品放入背包中的状态
            currWeight += bags[i].getWeight();
            currValue += bags[i].getValue();

            // 选择下一个物品进行判断
            bestValue = backtrack(i + 1);

            // 将物品从背包中取出的状态
            currWeight -= bags[i].getWeight();
            currValue -= bags[i].getValue();
        }

        // 次要条件:不放入当前物品,放入下一个物品可能会产生更优的价值,则对下一个物品进行判断
        // 当前价值+剩余价值<=最优价值,不需考虑右子树情况,由于最优价值的结果是由小往上逐层返回,
        // 为了防止错误的将单位重量价值大的物品错误的剔除,需要将物品按照单位重量价值从大到小进行排序
        if (currValue + getSurplusValue(i + 1) > bestValue) {
            // 选择下一个物品进行判断
            bestValue = backtrack(i + 1);
        }
        return bestValue;
    }
    
    /**
    两种方法获取上界:
    	1.是直接获取未被选择的所有的价值,作为上界
    	2.根据当前剩余重量,根据贪心思想获取能够获得的最大价值,作为上界
    **/
    
//    // 方法1:获得物品的剩余总价值surplusValue
//    public int getSurplusValue(int i) {
//        int surplusValue = 0;
//        for (int j = i; j < n; j++)
//            surplusValue += bags[i].getValue();
//        return surplusValue;
//    }
    
    //方法二:剩余容量能够获得的最大价值surplusValue。
    public int getSurplusValue(int i) {
        int residualWeight = totalWeight - currWeight;//计算剩余容量 = 总承重 - 当前承重
        int surplusValue = 0;

        //由于物品数组bags已经按照单位重量价值从大到小排好序了。
        //所以按照贪心策略,如果第i个能够装进背包,那就将其价值累加。
        while (i < n && bags[i].getValue() <= residualWeight) {
            //考虑了装第i个的,那么剩余容量相应减少,而获得的最大价值相应增加
            residualWeight -= bags[i].getWeight();
            surplusValue += bags[i].getValue();
            i++;
        }

        //到这里可能还存在剩余背包未装满,但是剩余容量却小于当前的第i个的重量。也按照贪心的思想分块加
        if (i < n) {
            surplusValue += bags[i].getValue() * residualWeight / bags[i].getWeight();
        }

        return surplusValue;
    }
      
}

对于上面的代码,可以写个测试用例:

public void test1(){
    int w = 194;// 背包的容量
    int n = 7;// 物品的个数
    BLBag[] bags = new BLBag[n];
    int[] weight = {72, 33, 37, 94, 39, 99, 5};
    int[] value = {9, 95, 6, 4, 88, 42, 37};
    String pid;
    for (int i = 0; i < n; i++) {
        pid = "p" + i;
        bags[i] = new BLBag(pid, weight[i], value[i]);
    }
    Knapsack knapsack = new Knapsack(bags, w);
    System.out.println("最优解为:" + knapsack.backtrack(0));
}

分支限界法(branch-and-bound)

GitHub代码:分支限界法(branch_and_bound)

分支限界法类似于回溯法,都是在问题的解空间树上搜索问题解的算法,但是又不同于回溯法:

  1. 回溯法的求解目标是找出解空间树中满足约束条件的所有解。一般使用深度优先的方式搜索解空间树。
  2. 分支限界法则是找出满足约束条件的一个解。或者说满足约束条件的解中的一个最优解。一般使用广度优先搜索的方式进行搜索。

对于广度优先搜索,可以借用队列来实现广度优先搜索。

没写完,感觉要表述出来很麻烦。,但是可以参考我的代码,该文章的所有的代码都在: 0-1背包问题求解总结

参考

a.k.a. dd_engitianyicui/pack: 背包问题九讲 (github.com)

算法设计与分析(第四版)

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值