2. HashMap源码:红黑树源码解析

红黑树

存在方法不全,日后慢慢添加。。。

主要特性

  1. 平衡二叉树:左右子树的高度差不超过1
  2. 根节点是黑色
  3. 红色节点的子节点一定是黑色,也就是红色节点不能在纵向连续出现
  4. 一个黑色节点可以存在两个红色节点,也就是红色节点可以在横向连续出现
  5. 每个节点到根节点的路径上,黑色节点的数目是相同的。

红黑树的左旋右旋

这里单独把红黑树中左旋右旋的方法拿出来进行理解。

这里引用了博客

  • 左旋:以某个结点作为支点(旋转结点),其右子结点变为旋转结点的父结点,右子结点的左子结点变为旋转结点的右子结点,左子结点保持不变。

img

// 节点p左旋转,注意这里没有改变颜色
    static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root,
                                          TreeNode<K,V> p) {
        TreeNode<K,V> r, pp, rl;
        // 当前节点(待旋转节点)以及右孩子不为空时
        if (p != null && (r = p.right) != null) {
            // 如果右孩子的左孩子不为空,就变为当前节点的右孩子
            if ((rl = p.right = r.left) != null)
                // 右孩子的左孩子 的 父节点 从右孩子变为 当前节点
                rl.parent = p;
            /* 接下来是用当前节点的原始右孩子来顶替当前节点*/
            // 先赋值 右孩子的父节点就是 当前节点的父节点,如果当前节点的父节点是null,那当前节点就是根节点。
            if ((pp = r.parent = p.parent) == null)
                // 那根节点就是右孩子 变色成黑色
                (root = r).red = false;
            // 如果存在当前节点的父节点,且当前节点是左孩子
            else if (pp.left == p)
                // 当前节点的右孩子来顶替当前节点
                pp.left = r;
            else
                pp.right = r;
            // 顶替完之后把当前节点作为其左孩子,梳理关系
            r.left = p;
            p.parent = r;
        }
        return root;
    }

注意这里的逻辑:首先是把当前节点的右孩子的左孩子作为当前节点的右孩子,此时原始的右孩子脱离树,接着当前节点的原始右孩子相当于顶替了当前节点的位置,在把当前节点作为其左孩子,层数没增加

  • 右旋:以某个结点作为支点(旋转结点),其左子结点变为旋转结点的父结点,左子结点的右子结点变为旋转结点的左子结点,右子结点保持不变。如图4。

img

static <K,V> TreeNode<K,V> rotateRight(TreeNode<K,V> root,
                                       TreeNode<K,V> p) {
    TreeNode<K,V> l, pp, lr;
    //p不为null且p的左子树不为null
    if (p != null && (l = p.left) != null) {
        //将l(p的左子树)的右子树变成p的左子树
        if ((lr = p.left = l.right) != null)
            //修改父节点引用,lr是l(p的左子树)的右子树
            lr.parent = p;
        //将l(p的右子树)的父节点变成p的父节点(右旋过程,将左子树变成自己的父节点)
        if ((pp = l.parent = p.parent) == null)
            //将l变成根节点(子树的根节点),并变成黑色(符合性质)
            (root = l).red = false;
        //如果存在父节点且p是该节点的右子树
        else if (pp.right == p)
            pp.right = l;
        //如果存在父节点且p是该节点的左子树
        else
            pp.left = l;
        //将l(p的右子树)变成p(右旋中,将右子树变成自己的父节点)
        l.right = p;
        p.parent = l;
    }
    return root;
}

左旋只影响旋转结点和其右子树的结构,把右子树的结点往左子树挪了。
右旋只影响旋转结点和其左子树的结构,把左子树的结点往右子树挪了。

红黑树节点的方法

!putTreeVal:插入红黑树节点

参考博客:12

主要思路就是先找到红黑树的根节点,然后从根节点开始遍历,如果当前节点的hash值和待插入的hash不一样,判断二者大小决定插入的方向;如果当前节点的hash和key值都和待插入的相同,就直接返回当前节点;如果当前节点的hash值相同但是key值不同,就进入判断,如果同时满足待插入的key类型未实现compare接口或者两个key值的类型不匹配,就在当前节点的左右子树进行查找,如果存在key值相同的节点就直接返回。

如果不存在和待插入相等的节点,就找到子节点中的空节点,插入后调整节点之间的前后和父子关系,最后要调整平衡红黑树,并且将红黑树的根节点放在table数组的头节点处。

注意:如果当前当前节点的key和待插入的key同类型可比较且不相等呢?不就进不到最后的分支得不到dir也就无法插入新节点?(hash相同key也可以不同,这里dir是在if分支的条件中被赋值:dir = compareComparables(kc, k, pk)) == 0,因此可以继续插入或者继续推动循环遍历)

final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
                                       int h, K k, V v) {
            Class<?> kc = null; // 传入key的类型
            boolean searched = false; // 标识符
            TreeNode<K,V> root = (parent != null) ? root() : this; // 查找根节点,没有parent本身就是根节点,有的话调用root()方法查找
    // 从根节点开始查找,循环没有结束条件,内部自行return结束;循环递进的条件是语句p = (dir <= 0) ? p.left : p.right)
            for (TreeNode<K,V> p = root;;) {
                int dir, ph; K pk; // 当前节点的hash值和key值,以及待插入的方向
                if ((ph = p.hash) > h) // 比较当前节点的hash和传入待插入的hash
                    dir = -1; //在左子树插入新节点
                else if (ph < h)
                    dir = 1;
                //当当前节点和待插入节点的hash值和key值都相同,直接返回当前节点,注意这里的p不是新new出来的,是已经存在了的节点的引用。
                // 另外 == 就是句柄地址相同,equals是key的内容相同。
                else if ((pk = p.key) == k || (k != null && k.equals(pk))) 
                    return p;
                /* !!注意这里,进入下面if分支的条件是:
                1. 当前节点和待插入节点的hash值相同,key值不相同
                2. 待插入的key值所属类型(也就是Class对象)没有实现Comparable接口,不能比较;或者是 当前节点和待插入的key值分属不同的类型
                3. 如果二者可以比较,dir = compareComparables(kc, k, pk)) == 0和equals==false就冲突了,看上述的  注意 。
                */
                else if ((kc == null &&
                          (kc = comparableClassFor(k)) == null) ||
                         (dir = compareComparables(kc, k, pk)) == 0) {
                    // 标识符,只起作用一次,就是在根节点遍历的时候(因为根节点遍历了之后没必要重复遍历子树)
                    if (!searched) {
                        TreeNode<K,V> q, ch;
                        searched = true;
                        // 先遍历左边在遍历右边,存在和待插入相同的节点就直接返回引用q。 这里存在一个find函数。
                        if (((ch = p.left) != null &&          
                             (q = ch.find(h, k, kc)) != null) ||
                            ((ch = p.right) != null &&
                             (q = ch.find(h, k, kc)) != null))
                            return q;
                    }
                    // 如果遍历过程没找到,证明一定要插入新节点,判断插入方向,其实就是比较当前节点和待插入节点的hash值。
                    // (System.identityHashCode(a) <= System.identityHashCode(b) ?-1 : 1)               
                    dir = tieBreakOrder(k, pk);
                }
				
                /*到这里肯定已知要插入的方向dir,
                处理节点关系*/
                //这是保存当前节点
                TreeNode<K,V> xp = p;
                // 当前节点向下移动,推动循环。插入位置是左右孩子的空节点处
                if ((p = (dir <= 0) ? p.left : p.right) == null) {
                    Node<K,V> xpn = xp.next;
                    TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn); // 待插入的新节点
                    if (dir <= 0)
                        xp.left = x; // 插入方向
                    else
                        xp.right = x;
                    /*节点关系变化: 前后:之前是xp的next是xpn,现在要在其中插入x。也就是xp的next是x,xpn的prev是x。
                    父母:x变成xp的孩子,x的prev和parent都是xp。
                    */
                    xp.next = x;
                    x.parent = x.prev = xp;
                    if (xpn != null)
                        ((TreeNode<K,V>)xpn).prev = x;
                    // 插入完成之后,需要重新移动root节点 到table数组的i位置(桶位)的第一个节点上 并且需重新平衡红黑树。
                    // 这个i位置是根据桶定位来的
                    moveRootToFront(tab, balanceInsertion(root, x));
                    return null;
                }
            }
        }

compareComparables和comparableClassFor:比较

可以参考博客

comparableClassFor:如果一个对象没有实现了comparable接口,就返回null。

compareComparables:

源码:

static int compareComparables(Class<?> kc, Object k, Object x) {
            return (x == null || x.getClass() != kc ? 0 :
                    ((Comparable) k).compareTo(x));
        }

如果x是空,或者两个参数不是同类,就返回0;否则调用compareTo方法,如果两个对象长度和内容一致,就返回0.

!find:查找函数

作用是在putTreeVal函数中,查找和待插入节点key和hash以及key值类型(传入类型)都相同的节点,找到后返回该节点的引用。

总体思路就是先根据当前节点和待查找节点的hash值来判断接下来的查找反向是左还是右,如果hash值相同key相同证明查找到,返回该节点的引用值。如果hash相同key不同,且key可以比较,就用CompareTo返回的结果来决定接下来的查找方向;如果二者key不可比较(例如所属类型不同),就先从右子树查找再从左子树查找,如果都没有,证明红黑树中没有待查找节点,返回null。

    final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
        TreeNode<K,V> p = this;    //当前节点
        do {
            int ph, dir; K pk;
            TreeNode<K,V> pl = p.left, pr = p.right, q; // 当前节点的左右孩子
            // 根据hash值判断查找方向
            if ((ph = p.hash) > h) // hash值小的从左子树迭代查找
                p = pl;
            else if (ph < h) // hash值大的从右子树迭代查找
                p = pr;
            // hash值相等,且键地址相同或equals为true(内容相同)时,查找成功
            else if ((pk = p.key) == k || (k != null && k.equals(pk)))
                return p;
            // hash值相等,键不相同,同时当前节点没有左子树,就从右子树查找
            else if (pl == null)
                p = pr;
            // hash值相等,键不相同,同时当前节点没有右子树,就从左子树查找
            else if (pr == null)
                p = pl;
            // 两个节点的hash值相等,键不相同,左右孩子不为空,Key可比较且不为空
            else if ((kc != null ||
                      (kc = comparableClassFor(k)) != null) &&
                     (dir = compareComparables(kc, k, pk)) != 0)
                p = (dir < 0) ? pl : pr; //根据compareTo方法的结果来决定接下来的查找方向
            // Key不可比较(两个节点的类型不同),先查找右子树
            else if ((q = pr.find(h, k, kc)) != null)
                return q;
            // 右子树查找不到时
            else
                p = pl;
        } while (p != null);
        return null;
    }

getTreeNode:从根节点查找树节点

该函数作用就是从根节点开始查找红黑树节点

final TreeNode<K,V> getTreeNode(int h, Object k) {
    // 当前节点存在父节点就调用root()方法查找根节点;如果没有,证明当前节点就是根节点
    // 找到根节点后调用find方法
        return ((parent != null) ? root() : this).find(h, k, null);
    }

tieBreakOrder

强行比较两个对象的大小,该方法返回值-1或者1.两个对象不空时比较类名,如果一方/都为空,或者类名相同,就比较内存地址。

        static int tieBreakOrder(Object a, Object b) {
            int d;
            if (a == null || b == null ||
                    // 比较两个类的名字是否相等
                    (d = a.getClass().getName().
                            compareTo(b.getClass().getName())) == 0)
                // System.identityHashCode(a):返回对象的默认hashCode,空对象的默认hashCode=0
                d = (System.identityHashCode(a) <= System.identityHashCode(b) ?
                        -1 : 1);
            return d;
        }

!balanceInsertion:插入平衡函数

这个函数很重要,是调整平衡二叉树的作用,最后返回一个平衡的红黑树。主要思路是:

  1. 首先新插入一个节点(逻辑在putTreeVal中,暂时认为是从空树开始建立,所以就认为是大的插入到右子树,小的插入到左子树)

  2. 注意新插入节点x默认设置颜色是红色

  3. 如果x是根节点,修改x颜色为黑色,直接返回树。

  4. 如果x的父节点xp是黑色或者其是根节点,直接返回树。

  5. 如果父节点xp是红色

    (1) 如果父节点是父父节点xpp的左节点

    ​ 如果父节点的兄弟xppr也是红色,那直接修改xp和xppr为黑色,xpp修改为红色,并令xpp作为新的插入节点x进入下次循环,根据循环开始中根节点的判断,xpp(现在是新x)颜色被修改回黑色。

    ​ 如果xppr是黑色:

    ​ 如果x是xp的右孩子,就以xp为旋转节点左旋(此时相当于xp变为新x,x变为了新xp)

    ​ 然后把新xp改为黑色,新xpp改为红色,在以xpp为旋转节点右旋。此时新xp(就是最早的x)变为根节点,黑色,xpp和新x(原来的xp)变为子节点(红色)。

    (2) 夫节点是父父节点xpp的右节点(和(1)逻辑差不多)

    ​ 如果父节点的兄弟xppl也是红色,那直接修改xp和xppl为黑色,xpp修改为红色,并令xpp作为新的插入节点x进入下次循环,根据循环开始中根节点的判断,xpp(现在是新x)颜色被修改回黑色。

    ​ 如果xppl是黑色:

    ​ 如果x是xp的左孩子,就以xp为旋转节点右旋(此时相当于xp变为新x,x变为了新xp)

    ​ 然后把新xp改为黑色,新xpp改为红色,在以xpp为旋转节点左旋。此时新xp(就是最早的x)变为根节点,黑色,xpp和新x(原来的xp)变为子节点(红色)。

        // 插入新节点并平衡
        static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,
                                                    TreeNode<K,V> x) {
            x.red = true; // 新插入节点x默认颜色是红色
            // 注意循环不断向上判断各个节点
            for (TreeNode<K,V> xp, xpp, xppl, xppr;;) {
                // x为根节点 变为黑色,直接返回树
                if ((xp = x.parent) == null) {
                    x.red = false;
                    return x;
                }
                // x的父节点xp是黑色或者是根节点,直接返回树
                else if (!xp.red || (xpp = xp.parent) == null)
                    return root;
                /*    父节点都为红  */
                // xp是父父节点xpp的左孩子
                if (xp == (xppl = xpp.left)) {
                    // xp和兄弟节点xppr都为红
                    if ((xppr = xpp.right) != null && xppr.red) {
                        // xp xppr变黑色,xpp变红色
                        xppr.red = false;
                        xp.red = false;
                        xpp.red = true;
                        x = xpp; // xpp变红色后继续进入循环,此时x是xpp,是根节点,变回黑色,返回树
                    }
                    // xp是红,xppr是黑时
                    else {
                        // 当前节点为xp的右孩子时,左旋xp,此时xp和x位置变换
                        if (x == xp.right) {
                            root = rotateLeft(root, x = xp);
                            xpp = (xp = x.parent) == null ? null : xp.parent;
                        }
                        
                        if (xp != null) {
                            xp.red = false; // xp改为黑色
                            if (xpp != null) {
                                xpp.red = true; // xpp改为红色
                                root = rotateRight(root, xpp); // 右旋xpp,此时根节点是x(也就是当前时刻的xp黑色),再次循环后符合根节点判断,直接返回树。
                            }
                        }
                    }
                }
                // xp在xpp的右孩子时
                else {
                    // xp和兄弟节点xppl都为红
                    if (xppl != null && xppl.red) {
                        // xp xppl变黑色,xpp变红色
                        xppl.red = false;
                        xp.red = false;
                        xpp.red = true;
                        x = xpp; // xpp变红色后继续进入循环,此时x是xpp,是根节点,变回黑色,返回树
                    }
                    // xp是红,xppl是黑时
                    else {
                       // 当前节点为xp的左孩子时,右旋xp,此时xp和x位置变换
                        if (x == xp.left) {
                            root = rotateRight(root, x = xp); 
                            xpp = (xp = x.parent) == null ? null : xp.parent;
                        }
                        if (xp != null) {
                            xp.red = false;
                            if (xpp != null) {
                                xpp.red = true; // xpp改成红色
                                root = rotateLeft(root, xpp); // 左旋xpp,此时根节点是x(也就是当前时刻的xp黑色),再次循环后符合根节点判断,直接返回树。
                            }
                        }
                    }
                }
            }
        }
    }
    

moveRootToFront:根节点变为桶数组索引处的头节点

确保树的根节点是桶中的索引位置处的第一个节点,其实就是确保根节点的prev前向节点是null。

注意这里最后要整理节点顺序是因为: 红黑树的根节点不一定就是双向链表的头节点。

    static <K,V> void moveRootToFront(Node<K,V>[] tab, TreeNode<K,V> root) {
        /*
        根据根节点的hash值来计算其应该所处的table数组的位置(桶位)
        */
        int n;
        if (root != null && tab != null && (n = tab.length) > 0) {
            int index = (n - 1) & root.hash; // 找到桶位
            TreeNode<K,V> first = (TreeNode<K,V>)tab[index];
            if (root != first) { // 当根节点不是桶中第一个元素时
                Node<K,V> rn;
                tab[index] = root; // 根节点放在桶的第一位
                
               /* 处理节点顺序
               原先是 rp root rn;start
               现在是rp rn; root start*/
                TreeNode<K,V> rp = root.prev; // 根的前一个节点
                // 将根节点从双表中抽出,原来的位置前后链接
                if ((rn = root.next) != null)
                    ((TreeNode<K,V>)rn).prev = rp;
                if (rp != null)
                    rp.next = rn;
                // 根节点放在双链表的首位
                if (first != null)
                    first.prev = root;
                root.next = first;
                root.prev = null;
            }
            //递归做一个恒定校验
            assert checkInvariants(root);//assert后面的表达式为false时会抛出错误
        }
    }

这里是防御性编程,校验更改后的结构是否满足红黑树和双链表的特性, checkInvariants在递归检查整棵树是否符合红黑树的性质,若检查不符会返回false导致moveRootToFront抛出错误. 因为HashMap并没有做并发安全处理,可能在并发场景中意外破坏了结构

!treeify:链表红黑树化函数

作用是把单向链表数组变为红黑树,其实就是新建一个红黑树,用传参单向链表来赋值。

其实主要思路和putTreeVal方法的逻辑差不多,只不过这里不需要查找当前红黑树内是否存在和待插入节点相同的节点。因为红黑树化是从0开始构建一个红黑树,所以主要工作就是判定插入方向,插入新节点,梳理节点关系,平衡红黑树,把根节点移动到双向链表的头节点。

final void treeify(Node<K,V>[] tab) {
    TreeNode<K,V> root = null;
    //循环,此时已经被替换为TreeNode的双向链表,第一次循环x的值是双向链表的头节点,这里定义了两个treeNode,分别是this和next。用x=next来实现循环推进
    for (TreeNode<K,V> x = this, next; x != null; x = next) {
        next = (TreeNode<K,V>)x.next; // 链表中的下一节点
        //将x节点的左右节点设置为null
        x.left = x.right = null;
        //如果没有根节点,就把当前节点作为根节点
        if (root == null) {
            x.parent = null; // 修改parent为null
            x.red = false;   //变黑色
            root = x;
        }
        //当前红黑树存在根节点,插入新节点的逻辑和putTreeVal方法一样
        else {
            // 待插入节点的key和hash值,以及key的Class对象
            K k = x.key;
            int h = x.hash;
            Class<?> kc = null;
            //从根节点遍历,插入节点,内部退出循环
            for (TreeNode<K,V> p = root;;) {
                //定义dir(方向),ph(当前节点的hash),当前节点的key
                int dir, ph;
                K pk = p.key;
                //根据节点的hash值来判断待插入方向
                if ((ph = p.hash) > h)
                    //左侧
                    dir = -1;
                else if (ph < h)
                    //右侧
                    dir = 1;
                 
                //节点hash相等,如果当前节点和待插入节点的类型不匹配
                else if ((kc == null &&
                          (kc = comparableClassFor(k)) == null) ||
                         (dir = compareComparables(kc, k, pk)) == 0)
                    //比较得到方向     
                    dir = tieBreakOrder(k, pk);

                //保存当前节点
                TreeNode<K,V> xp = p;
                // 当前节点如果左右孩子是空节点,就可以插入节点,否则继续循环
                if ((p = (dir <= 0) ? p.left : p.right) == null) {
                    // 修改节点关系 ,此时p变为p的左/右孩子,所以用xp来梳理关系
                    x.parent = xp;
                    if (dir <= 0)
                        xp.left = x;
                    else
                        xp.right = x;
                    //平衡红黑树
                    root = balanceInsertion(root, x);
                    break;
                }
            }
        }
    }
    //确保哈希桶指定位置存储的节点是红黑树的根节点
    moveRootToFront(tab, root);
}

!untreeify:红黑树退化为链表

主要作用就是当当前hashmap中不断删除键值对,红黑树的大小小于阈值,因此需要退化成为单向链表。

final Node<K,V> untreeify(HashMap<K,V> map) {
    	//定义两个普通节点 hd指向头节点 tl不断更新到尾结点
        Node<K,V> hd = null, tl = null;
    	// q是当前hashmap红黑树中的头结点,不断向后更新
        for (Node<K,V> q = this; q != null; q = q.next) {
            // 这个函数就是新建一个节点,键值都是q的键值,next都是null
            Node<K,V> p = map.replacementNode(q, null);
            if (tl == null)
                hd = p; // 指向链表头结点,只在第一次循环实现。
            else
                tl.next = p;// 链表的next通过tl来实现。
            tl = p; // tl位置不断更新,变为tl.next。
        }
    // 循环结束后tl指向尾结点,hd指向头结点。
        return hd;
    }

!split:扩容时拆分红黑树

扩容时候用,相当于把一颗红黑树拆成两棵。类似于单链表的拆分。可以对比着resize中单链表拆分的流程来看。

注意这里的拆分其实也是拆分成两个链表,只是在最后根据链表的长度来决定是否红黑树化,红黑树化之后才是真正的拆分成了红黑树。

final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
    // 当前(旧数组)的红黑树节点
    TreeNode<K,V> b = this;
    //拆分成两棵树(实际上这里还是双向链表,最后红黑树化才是树)
    TreeNode<K,V> loHead = null, loTail = null;
    TreeNode<K,V> hiHead = null, hiTail = null;
    int lc = 0, hc = 0;
    //遍历节点
    for (TreeNode<K,V> e = b, next; e != null; e = next) {
        next = (TreeNode<K,V>)e.next;
        //将e的next引用置空,目的是从e断开
        e.next = null;
        //这里判断高低树的操作和单链表中相同,都是hash和旧数组长度相与。
        if ((e.hash & bit) == 0) {
            if ((e.prev = loTail) == null)
                loHead = e; // 低树的头节点
            else
                loTail.next = e; //next一直指向不断更新的e
            loTail = e; //随着e遍历红黑树,形成一条链。这一段其实就是引用的节点不断变化,同时改变引用节点的next,从而构建一个新链表。
            ++lc;  //计算低树的节点数目
        }
        //不为0放到高树,和上面的代码逻辑一样。
        else {
            if ((e.prev = hiTail) == null)
                hiHead = e;
            else
                hiTail.next = e;
            hiTail = e;
            ++hc;
        }
    }
    //判断低树是否为null
    if (loHead != null) {
        //判断是否需要链表化
        if (lc <= UNTREEIFY_THRESHOLD)
            tab[index] = loHead.untreeify(map);
        else {
            tab[index] = loHead;
             //如果高位首节点不为空,说明原来的红黑树已经被拆分成两个链表了,操作完成可以进行红黑树化
            if (hiHead != null) 
                loHead.treeify(tab); // 低链表红黑树化
        }
    }
    //判断upper树是否为null 和上述逻辑一样
    if (hiHead != null) {
    //判断是否需要链表化
        if (hc <= UNTREEIFY_THRESHOLD)
            //下标是旧数组下标+旧数组长度
            tab[index + bit] = hiHead.untreeify(map);
        else {
            tab[index + bit] = hiHead;
            if (loHead != null)
                hiHead.treeify(tab);
        }
    }
}
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值