高级搜索树之B树

1、前言

微软创始人比尔盖茨在 1981 年提出了一个观点:640K ought to be enough for anybody 。他想说明的是,在计算机世界中,实际上只需要 640K 的内容容量就足够存放一切信息了。

也许现在你会觉得这是不具有长远眼光的一句话,但是当你完成 B 树的学习之后,你可能会认为这句话是正确的!

1.1、越来越小的内存

由于科学技术和工业的发展,内存的绝对大小当然是不断增大的,而我们这里说的“小”是相对于实际应用需求而言的。

目前,问题规模的膨胀却远远快于存储能力的增长!

以数据库为例,在20世纪80年代初,典型数据库的规模为10~100 MB,而三十年后的今天,典型数据库的规模已需要以 TB 为单位来计量。计算机存储能力提高速度相对滞后,是长期存在的现象,而且随着时间的推移,这一矛盾将日益凸显。

那么此时就会有人问,为什么不直接增大内存容量呢?这对于现在的技术应该很容易实现。但是在同样的成本下,存储器的容量越大(小)则访问速度越慢(快),因此一味地提高存储器容量,亦非解决这一矛盾的良策。

实践证明,分级存储才是行之有效的方法。在由内存与外存(磁盘)组成的二级存储系统中,数据全集往往存放于外存中,计算过程中则可将内存作为外存的高速缓存,存放最常用数据项的复本。借助高效的调度算法,如此便可将内存的“高速度”与外存的“大容量”结合起来。

两个相邻存储级别之间的数据传输,统称I/O操作!

1.2、两个重要事实

事实一:不同容量的存储器,访问速度差异悬殊

以内存与磁盘为例,其单次访问延迟大致分别在纳秒(ns)和毫秒(ms)级别,相差5至6个数量级。也就是说,对内存而言的一秒,相当于磁盘的一星期。

因此,为了避免一次外存访问,我们宁愿访问内存上千次。

多数的存储系统,都是分级组织,最常用的数据尽可能放在更高层,更小的存储器中,实在找不到,才向更底层,更大的存储器中索取。

注:算法的 I/O 复杂度正比于不同存储级别之间的传输次数,因此算法的实际运行速度也往往主要取决于此。

事实二:从磁盘中读写 1B,与读写 1KB 几乎一样快

因此一般可以采用批量式访问,以页或块为单位;其效果是明显的,单位字节的 1KB 访问时间大大缩短。

2、B树结构

2.1、基本介绍

当数据规模大到内存已不足以容纳时,常规平衡二叉搜索树的效率将大打折扣。其原因在于,查找过程对外存的访问次数过多。例如,若将10^9个记录在外存中组织为AVL树,则每次查找大致需做30次外存访问。

因此我们需要将通常的二叉搜索树,改造为多路搜索树。在中序遍历的意义下,这也是一种等价变换。

经过适当的合并,可以得到超级节点:

每2代合并:4路

每3代合并:8路

...

没d代合并:m = 2^d 路,m - 1 个关键码

(这里提到的路,即分支数,m 路即有 m 条分支)

在多级存储中使用 B树,可针对外部查找,大大减少 I/O 次数!

难道使用 AVL 还不够吗?假如有 n = 1G 个记录,那么每次查找就需要 log(2, 10^9) = 30 次 I/O 操作,每次只读取一个关键码,得不偿失!

而 B 树却能充分利用外存对批量访问的高效支持,正如上面提到的,对于硬盘而言,访问 1B 和访问 1KB 几乎没有差别。此时 B 树每下降一层,都会以超级节点为单位读取一组关键码。

上面提到的一组到底有多大,需要视磁盘的数据块大小决定,目前多数数据库系统采用的是 m = 200 - 300 。

如果使用 B 树访问 1G 个记录,且此时 m = 256 时,则每次查找只需要 log(256, 10^9) = 4 次 I/O 操作。

或许你会觉得 4 次和 30 次 I/O 操作的差距并不大,但实际上这是针对计算机中内外存的跨级处理,打个形象的比喻,如果对于人而言,就好比 4 年 和 30 年的差距,你可以选择花 4 年时间去读大学,但是如果要你花 30 年读大学,那么绝大部分人都可能会放弃!

2.2、具体结构

所谓 m 阶 B 树,即 m 路平衡搜索树(其中 m >= 2 )。

外部节点的深度统一,所有叶节点的的深度统一。树高 h = 外部节点的深度。

m 路分支的一颗平衡搜索树,对应的每一个超级节点又是怎样一个情况呢?

(n 是节点数,m 是分支数,n + 1 = m,因此分支数正好比节点数多 1 )

需要注意的是,要区分 节点 和 分支 数量之间的关系。

例如对于一个 m = 5 的 5 路平衡搜索树而言:

它的分支数量范围是:3 - 5,故又称为 (3,5)树

它的一个超级节点的内部节点范围是:2 - 4(分支树比节点数多 1)

2.3、紧凑表示

为了使得结构展示更加简洁,因此统一采用了紧凑的表示方法,大致是将外部引用的节点隐藏,直接用两个数据节点的间隙代替,同时还可以省去外部节点的展示:

3、接口实现

我们首先需要实现超级节点的 BTNode ,需要主要的是,在实现中,我们使用两个向量来分别保存关键码和对应的孩子向量:

其中 O 代表关键码,X 代表孩子向量,你可以想象将 X 的这一行向左移动半格,形成一个错位效果,那么我们可以得到下面这种简单表示:

3.1、超级节点 BTNode

template <typename T> struct BTNode;template <typename T> using BTNodePosi = BTNode<T>*; //B-树节点位置template <typename T> struct BTNode {    BTNodePosi<T> parent; // 父节点    Vector<T> key; // 关键码向量    Vector< BYNodePosi<T> > child; // 孩子向量,总比关键码多一个    // 构造函数1:创建一个空节点    BTNode() {        parent = NULL;        child.insert( 0, NULL );    }    // 构造函数2    BTNode(T e, BTNodePosi<T> lc, BTNodePosi<T> rc) {        parent = NULL; // 作为根节点时        key.insert( 0, e ); // 只有一个关键码        child.insert( 0, lc ); if ( lc ) lc -> parent = this; // 左孩子        child.insert( 1, rc ); if ( rc ) rc -> parent = this; // 右孩子    }}

3.2、BTree 模板类

template <typename T> class BTree { //B-树模板类protected:   int _size; // 存放的关键码总数   int _order; // B-树的阶次,至少为3——创建时指定,一般不能修改   BTNodePosi<T> _root; // 根节点   BTNodePosi<T> _hot; // BTree::search()最后访问的非空(除非树空)的节点位置   void solveOverflow ( BTNodePosi<T> ); //因插入而上溢之后的分裂处理   void solveUnderflow ( BTNodePosi<T> ); //因删除而下溢之后的合并处理public:   BTNodePosi<T> search ( const T& e ); //查找   bool insert ( const T& e ); //插入   bool remove ( const T& e ); //删除}; //BTree

需要指明的是,这并不是全部的接口,我们只关注其中最重要的几个(搜索,插入与删除)。

3.3、search 实现

要实现 search ,那么首先要明确要以怎样的查找思路进行:

1)将根节点作为当前节点(根节点常驻 RAM);

2)只要当前节点非外部节点,则在当前节点中顺序查找(此时在 RAM 内部,速度超快);

3)若找到关键码,则返回查找成功;否则沿着引用转至对应的子树,所没有对应引用,返回查找失败;

4)转至对应的子树时,是将其子树根节点读入内存(这就是 I/O 操作,此操作为主要耗时的部分);

5)更新当前节点;

6)如第三点,沿着子树到达最底部,则返回查找失败

现在我们就要根据上面的思路实现 search 算法:

template <typename T> BTNodePosi(T) BTree::search ( const T & e ) {    BTNodePosi(T) v = _root; _hot = NULL; // 从根节点开始遍历,此时的 _hot 为 NULL    while ( v ) { // 逐层查找        Rank r = v -> key.search( e ); // 在当前节点中,顺序查找不大于e的最大关键码        if ( r >= 0 && e == v -> key[ r ] ) { // 找到的情况            return v;        }        // 未找到的情况,沿着引用转至对应的下层引用,并载入其根( I/O )        _hot = v; v = v -> child[ r + 1 ];    } // v 为 NULL,意味着抵达外部节点,退出循环        return NULL;}

需要单独指出的是,关于搜索树的特性,即目标值大小的比较实际上集成在了向量的搜索中,要明确一下这一点!

关于复杂度的分析:

首先我们在查找思路中明确,根节点是常驻内存中的。那么首先可以忽略内存中的查找时间(因为内存搜索效率高,且 m 也一般介于 200 至 300)。

那么剩下的运行时间就主要取决于 I/O 操作,而从算法中可以看出,在每一层只有转变引用时才会用到一次 I/O 操作,因此运行时间就是取决于终止节点的深度,即运行时间为 O(h)。

此时你应该已经意识到,树的高度是影响复杂的重要因素,因此我们需要再来讨论一下树高的情况!

1、最大树高:

含 N 个关键码的 m 阶 B树,最大高度是多少呢?

为了尽可能高,则分支树统一取 m 除以的上整,因此各层节点数量为:

同时考虑外部节点所在层的情况,那么经过推导可以得到最大树高为:

或许这个你仍没有一个直观的感觉,我们以 BBST 做对比的话,若 m 取 256 ,则 B 树的树高仅有 BBST 的 1/7 。

2、最小树高:

为了尽可能的低,那么每层超级节点的分支数就应该取到最大,则取 m,那么各层的节点数为:

同时考虑外部节点所在层的情况,那么经过推导可以得到最小树高为:

同样以 BBST 做对比的话,若 m 取 256 ,则 B 树的树高仅有 BBST 的 1/8 。

那么如果用 B 树来表示我们已知的某些数量时,树高的表现会足够优秀吗?

下面给出一组数据:

可以看到,在表现足够大数据量的情况下,B 树仍然有足够优异的表现!

通过选取合适的节点规模(m),足够弥补存储层级之间巨大的速度差异。

3.4、insert 实现

首先来看一下具体的 insert 实现:

template <typename T>bool BTree::insert ( const T & e ) {    BTNodePosi(T) v = search( e ); // 查找目标节点是否存在,若不存在返回 NULL    if ( v ) {        return  false; // 确定目标节点在树中不存在    }    // 注意:经过上面search方法,_hot 已经为失败情况下最后一个节点,    // key和child都已经为最后一层引用    Rank r = _hot -> key.search( e ); // 查找 e 应该插入的位置    _hot -> key.insert( r + 1, e ); // 将 e 插入对应位置    _hot -> child.insert( r + 2, NULL ); // 创建一个空子树指针     _size++; // 更新全树规模    solveOverflow ( _hot ); // 如有必要,需做分裂        return true;}

整个插入的算法实现思路并不复杂,根据代码中注释能帮助更好理解。

上面代码中,最重要的一步是 solveOverflow 方法的执行!下面会详细介绍。

1、上溢:

首先明确上溢的定义:在插入的情况下,因为新插入的关键码导致某个超级节点内的关键码数量超过了 m - 1,此时 B 树的结构被破坏,这就是上溢。

如下图,加入这是一颗 m = 6 的六阶的 B树,那么其关键码数量范围应该为 2 - 5 ,而由于 37 的插入,导致关键码超过了规定范围,则此时即为发生上溢。

2、解决上溢:分裂

发生上溢后,由于 B 树结构被破坏,因此我们需要想办法进行恢复。其关键就在于分裂操作!

仍然利用上面的图,在插入 37 后,该超级节点中的关键码就已经超出了最小值,因此我们可以在该超级节点中进行一次分裂操作。具体的操作步骤是这样的:

1)取中位数:m 除以 2 ,取下整(注意,这里和前面计算关键码数量范围时不同,那里是取上整);

2)那么 6 除以 2 取下整结果为 3;

3)注意是以 0 为下标的起始位置,因此下标为 3 的值为 37,此时就以 37 为界限将该超级节点分为三个部分:[17, 20, 31], [37], [41, 56]

4)此时将 37 上升一层,然后以[17, 20, 31] 和 [41, 56] 分别作为 37 这个关键码的左右孩子,就像这样:

注意:经过分裂后的左右孩子一定满足 m 阶 B 树的条件!

然后,接收下层关键码的父级超级节点如果已经饱和,则再接受之后也会发生上溢,因此需要再次进行分裂,大可套用前法,继续分裂,就像这样:

上溢可能会持续发生,在最坏的情况下会传递到根节点,那么可以令被提升的关键码自成节点,作为新的树根,如上图右侧两步。同时这也是 B 树增高的唯一可能。

注意,新的树根是可以仅有两个分支的,这也是 B 树的结构条件之一!

而且可以很容易的清楚,分裂操作的时间不会超过树的高度,即不超过 O(h)。

需要特别指明的是,虽然上溢至根节点而产生新的树根的情况存在,但是出现这样情况的概率极低!

下面来看几个具体实例:(3-5树,则关键码范围为:2-4)

3、solveOverFlow 实现

template <typename T>void BTree::solveOverFlow ( BTNodePosi(T) v ) {    // 递归基条件:如果超级节点中关键码数量小于等于 _order 阶次,则停止递归    if ( v -> child.size() <= _order ) return;    /**     * 1、在 C++ 中,整数 / 直接相当于取下整,此为轴点     * 2、此时是上溢状态,则该超级节点内的关键码个数正好比约束范围多一     * 即:此时应有_order = key.size() = child.size() - 1     */    Rank s = _order / 2;    // 注意:新节点已有一个空孩子;此节点用于存放轴点右侧的节点    BTNodePosi(T) u = new BTNode<T>();        // 分裂出右侧节点 u    for ( Rank j = 0; j < _order - s - 1; j ++ ) {        u -> child.insert( j, v -> child.remove( s + 1 ) );        u -> key.insert( j, v -> key.remove( s + 1 ) );    }    // 移动 v 最靠右的孩子,这是因为在上面循环中并不能完全把child移动完    u -> child[ _order - s -1 ] = v -> child.remove( s + 1 );     // 若 u 的孩子们非空,则统一令其以 u 为 父节点    if ( u -> child[0] ) {        for (Rank j = 0; j < _order - s; j++) {            u -> child[j] -> parent = u;        }    }    BTNodePosi(T) p = v -> parent; // 记录 v 的当前父节点 p    if ( !p ) {        _root = p = new BTNode<T>();        p -> child[0] = v;        v -> parent = p;    }    Rank r = 1 + p -> key.search( v -> key[0] );  // p中指向 v 的指针的秩    // 插入 s ,即轴点的关键码上升    p -> key.insert( r, v -> key.remove( s ) );    // 插入 u,要注意原来的 v 的指向未变    p -> child.insert( r + 1, u );    u -> parent = p;    solveOverflow ( p ); //上升一层,如有必要则继续分裂——至多递归O(logn)层}

结合前面的思路梳理和代码中的注释,可以更好的理解代码的实现!

3.5、remove 实现

首先来看一下具体的 remove 实现:

template <typename T>bool BTree::remove ( const T & e ) {    // 搜索目标关键码是否存在    BTNodePosi(T) v = search( e );    if ( !v ) { // 若目标不存在,直接返回 false        return false;    }    Rank r = v -> key.search( e ); // 确定 e 在 v 中的关键码向量的位置    // v 非叶节点的情况:找到 e 的直接后继(必属于某叶节点),然后交换其位置    if ( v -> child[0] ) {        BTNodePosi(T) u = v -> child[ r + 1 ]; // 一定在 r + 1 的右侧        while ( u -> child[0] ) {            u = u -> child[0]; // 一直向左        }        // 找到后,交换其位置,这里的关键在于 = 实现了相互赋值!!!        v -> key[r] = u -> key[0];        v = u; // 这里是整个超级节点的相互赋值        r = 0;    } // 至此,v必然位于最底层,且其中第r个关键码就是待删除者    v -> key.remove( r );    v -> child.remove( r + 1 );    _size--;    solveUnderflow ( v ); //如有必要,需做旋转或合并    return true;}

与 insert 类似,在 remove 的过程中,某些超级节点内的关键码数量会低于 B 树的最低限制(下溢)此时 B 树结构被破坏,我们也需要进行一些操作使 B 树恢复其合法结构!

1、下溢

首先明确下溢的定义:在删除的情况下,因为删除的关键码导致某个超级节点内的关键码数量少于 (m / 2)取上整再减去 1,此时 B 树的结构被破坏,这就是下溢。

2、解决下溢

首先明确一点,某节点 v 发生下溢时,必恰好包含:

接下来,我们需要观察其左(L)、右兄弟(R)中所含关键码的数量,可分为三种情况!

(1)若 L 存在,且至少包含 (m/2)取上整 个关键码

可以将 y 从 P 中移至 V 中(作为 V 中最小的关键码)

然后将关键码 x 从 L 中一直 P 中(取代原来的关键码 y)

结果是这样的:

实际上这是一种“借”的思想,V 中节点不够,然后又发现其兄弟中拥有足够的节点,则可以向兄弟“借”节点。

从思路上我们是直接向兄弟“借”,但是实际上这样直接借可能会破坏顺序性,因此通过“旋转”的方式相借。具体的原理是:

y 的左右子树中,V 中所有节点一定大于 y,L 中所有节点的值一定小于 y 。因此 y 一定能在 V 中作为最小值存在,而 L 中的最大值 x 也一定可以替代 y 的位置。

至此,局部乃至整数都重新满足 B 树的合法结构,即下溢修复完毕!

(2)若 R 存在,且至少包含 (m/2)取上整 个关键码

此一种情况与(1)中完全对称。

前两种都是“旋转”,是因为有兄弟节点可以借出来关键码,那么如果没有关键码可以借呢?接下来让我们一起来看看第三种情况。

(3)L 和 R 或者不存在,或者所含的关键码均不足(m/2)取上整 个

注意:L 和 R 仍必有其一,且恰含 (m/2)取上整 - 1 个关键码

实际上,此时的 L 和 R 不可能同时不存在,因为 m 至少为 3,要满足B树结构则必然有足够的分支,也就有足够的兄弟。

从 P 中抽出介于 L 和 V 之间的关键码 y,通过 y 做粘接,以 L 和 V 合成一个节点,同时合并此前 y 的孩子引用。

结果:

这一操作称为“合并”,如此操作后,原高度处的下溢得以修复,但是却可能导致更高层的下溢,不过不需要担心,只需要套用这三种方法进行旋转或者合并即可!

而下溢虽然可能持续发生,但最坏的情况也不过到达根,最多不会超过 O(h) 次。同时根的直接孩子发生下溢,若需要合并时,树的高度会减小 1,这也是 B 树唯一可能高度减小的情况。

同 insert 中高度增加的概率类似,这种高度减小的情况出现的概率极低!

来看看一些实例:

最后,来看看 solveUnderflow 的具体实现:

template <typename T>void BTree::solveUnderFlow( BTNodePosi(T) v ) {    /**     * 1、(_order + 1) / 2 即表示 _order 除以 2 取上整     * 2、递归基,v 未发生下溢时的情况     */     if ( (_order + 1) / 2 <= v -> child.size() ) {        return;    }    // 对树根处的特别处理    BTNodePosi(T) p = v -> parent;    if ( !p ) { //递归基:已到达根节点        // 若作为树根的 v 已不含关键码,却有(唯一的)非空孩子        if ( !v -> key.size() && v -> child[0] ) {            _root = v -> child[0]; _root -> parent  = NULL;            v -> child[0] = NULL; release( v ); // 销毁这个无用节点        } // 整树高度降低一层        return;    }    // 确定 v 是 p 的第 r 个孩子,因为此时 v 可能不含关键码,故不能通过关键码查找    Rank r = 0;    while ( p -> child[r] != v ) {        r++;    }    // 确定了 v 的位置,下面就需要分情况进行讨论了    // 情况1:向左兄弟借关键码    if ( 0 < r ) { // 若v不是p的第一个孩子,则左兄弟必存在        BTNodePosi(T) ls = p -> child[ r -1 ];        // 判断左孩子是否有能力借出关键码        if ( (_order + 1) / 2 < ls -> child.size() ) {            // p 借出一个关键码给v(作为最小关键码)            v -> key.insert( 0, p -> key[ r - 1 ] );             // ls 中的最大关键码转入 p            p -> key[r-1] = ls -> key.remove( ls -> key.size() - 1 );            // 同时ba把 ls 的最右侧孩子过继给 v            v -> child.insert( 0, ls -> child.remove( ls -> child.size() -1 ) );            if ( v -> child[0] ) {                v -> child[0] -> parent = v            }            // 至此,通过右旋已完成当前层(以及所有层)的下溢处理            return;        }    } // 至此,左兄弟要么为空,要么不足以借出关键码     // 情况2:向右兄弟借关键码,情况与 1 完全对称    if ( p -> child.size() - 1 > r ) { // 若v不是p的最后一个孩子,则右兄弟必存在       BTNodePosi<T> rs = p->child[ r + 1 ];       // 判断右兄弟是否能借出关键码       if ( ( __order + 1 ) / 2 < rs->child.size() ) {            // p 借出一个关键码给 v(作为最大关键码)            v -> key.insert ( v -> key.size(), p->key[r] );             // rs 的最小关键码转入 p            p -> key[r] = rs -> key.remove ( 0 );             //同时rs的最左侧孩子过继给v            v->child.insert ( v -> child.size(), rs -> child.remove ( 0 ) );            if ( v -> child[ v -> child.size() - 1 ] ) { //作为v的最右侧孩子               v -> child[ v -> child.size() - 1 ] -> parent = v;            }            return; //至此,通过左旋已完成当前层(以及所有层)的下溢处理       }    } //至此,右兄弟要么为空,要么不足以借出关键码    // 情况3:左、右兄弟要么为空(但不可能同时),要么都不足以借出关键码    // 与左兄弟合并    if ( 0 < r ) {         BTNodePosi<T> ls = p -> child[r - 1]; // 左兄弟必存在        // 将上层 p 中的对应位置的节点插入 ls 中        ls -> key.insert( ls -> key.size(), p -> key.remove ( r - 1 ) );        p -> child.remove ( r );        // p的第 r - 1 个关键码转入 ls 后,则 v 不再是 p 的第 r 个孩子        ls -> child.insert ( ls -> child.size(), v -> child.remove ( 0 ) );        // v 的最左侧孩子过继给 ls 做最右侧孩子        if ( ls -> child[ ls -> child.size() - 1 ] ) {            ls -> child[ ls -> child.size() - 1 ] -> parent = ls;        }                // v 剩余的关键码和孩子,依次转入ls        while ( !v -> key.empty() ) {             ls -> key.insert ( ls -> key.size(), v -> key.remove ( 0 ) );            ls -> child.insert ( ls -> child.size(), v -> child.remove ( 0 ) );            if ( ls -> child[ ls -> child.size() - 1 ] ) {                ls -> child[ ls -> child.size() - 1 ] -> parent = ls;            }        }        release ( v ); // 释放v    } else { // 与右兄弟合并        BTNodePosi<T> rs = p -> child[r + 1]; //右兄弟必存在        rs -> key.insert ( 0, p -> key.remove ( r ) );        p->child.remove ( r );                // p 的第 r 个关键码转入 rs,v 不再是 p 的第 r 个孩子        rs -> child.insert ( 0, v -> child.remove ( v -> child.size() - 1 ) );        //v的最右侧孩子过继给rs做最左侧孩子        if ( rs->child[0] ) {              rs->child[0]->parent = rs;        }        // v 剩余的关键码和孩子,依次转入 rs        while ( !v -> key.empty() ) {             rs -> key.insert ( 0, v -> key.remove ( v -> key.size() - 1 ) );            rs -> child.insert ( 0, v -> child.remove ( v -> child.size() - 1 ) );            if ( rs -> child[0] ) {                rs -> child[0] -> parent = rs;            }        }        release ( v ); //释放v    }    solveUnderFlow ( p ); // 上升一层,如有必要则继续分裂——至多递归O(logn)层    return;}

复杂度:

与插入操作类似,在存在 N 个关键码的 m 阶 B 树中每次关键码的删除,都可以在 O(logmN) 时间内完成。同样,因为某一关键码的删除而导致 logmN 次合并操作的情况概率极低,单次删除操作的平均只需要长舒茨节点的合并。

至此,关于 B 树的介绍就告一段落了!

最后,欢迎大家关注我的微信公众号:火锅只爱鸳鸯锅

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值