哈希表1.8学习

目标

通过hashMap 常用的put 和get 函数 来切入了解整个hashMap 的工作流程,了解红黑树的插入与删除,熟悉HashMap1.7 与1.8的异同,以及使用HashMap的最佳规范。

预备知识

在这里插入图片描述

红黑树的插入

设待插入节点为 N(New),父节点为P(Parent)叔父节点 U(Uncle) 祖父节点为G(Grandpa)规定待插入的节点为红色

如果父节点p为黑色,直接插入

p为红色,

1 叔父节点U 为null 旋转 + 变色 (旋转祖父节点)

2 叔父节点 U 为 红色,变色 (U P 一起变 祖父 也变)

3 叔父节点U 为 黑色 ,旋转 + 变色 (旋转祖父节点)

红黑树的删除

红黑树的删除思路是找到待删除的节点的后继节点作为其替换节点,之后删除该替换节点。易知节点的后继节点 要么是叶子节点、要么是只有右孩子的节点,并且该右孩子节点必为红色节点。

下面根据替换节点 分类讨论

1、替换节点是红色 直接删除
在这里插入图片描述
2、替换节点是黑色,存在红色右节点 。

做法:红色右节点变色,顶替替换节点。
在这里插入图片描述
3、替换节点是黑色 ,无子节点,是父节点的右子节点,兄弟节点是红色,那么父节点一定为黑色,且红色兄弟节点一定有2个黑色孩子节点,且为最底层节点。

做法:右旋+ 变色
在这里插入图片描述
4、替换节点是黑色,无子节点,是红色父节点的右子节点,兄弟节点是黑色。

做法:(黑色兄弟节点 无孩子或者 有左孩子)旋转 (黑色兄弟节点 有右孩子) 旋转之后 + 插入情况的逻辑
在这里插入图片描述
5、替换节点是黑色,无子节点,是黑色父节点的右子节点,兄弟节点是黑色。黑色兄弟节点右红色左子节点或者右子节点。(重要的兄弟节点有红色左节点 如果兄弟节点只有右红色孩子节点 则需要左旋转变 )

做法:旋转 + 变色 核心思想是整个分支的黑高不能少 所以只要兄弟节点有红色子节点就比较简单,可以通过兄弟节点的变色来增加黑高
在这里插入图片描述
6、替换节点是黑色,无子节点,是黑色父节点的右子节点,兄弟节点是黑色。黑色兄弟节点无红色子节点。

做法:由于替换节点是无子节点、兄弟节点也没有子节点,父节点也是黑色,无论怎么删除替换节点后都会使得路径上的黑色节点数少1(黑高),所以做法是删除替换节点、将兄弟节点变红,(此时分支上的黑色节点数少1 但是局部上是符合红黑树,整体上不符合),并且将200节点作为替换节点继续递归向上调整。将200 节点当做替换节点只是调整,并不涉及到200 节点的删除。
在这里插入图片描述
举例说明
在这里插入图片描述
7、镜像情况

源码讲解

从put 函数开始

在这里插入图片描述

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);//尾插法
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

putTreeVal函数
关于putTreeVal 逻辑上是若是找到待插入的元素的与已经插入的元素的key“相等”的情况,那么应该将此节点返回,若是没有则利用BST插入规则将其插入,从下面代码的17-25行可以看到,若是我们待插入的key 是新key即旧树上没有,那么会导致在红黑树的插入会导致全树搜索。

代码块

    final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
                                       int h, K k, V v) {
            Class<?> kc = null;
            boolean searched = false;//全树搜索的开关 当我们待插入的K,并未实现Comparable 接口,此时会进行全树搜索。 通过设置searched = true 该搜索只会进行一次
            TreeNode<K,V> root = (parent != null) ? root() : this;
            for (TreeNode<K,V> p = root;;) {
                int dir, ph; K pk;
                if ((ph = p.hash) > h)
                    dir = -1;
                else if (ph < h)
                    dir = 1;
                else if ((pk = p.key) == k || (k != null && k.equals(pk)))// 哈希值相同 但是 key 引用不一样或者 不equal 
                    return p;
                else if ((kc == null &&
                          (kc = comparableClassFor(k)) == null) ||
                         (dir = compareComparables(kc, k, pk)) == 0) {
                    if (!searched) {
                        TreeNode<K,V> q, ch;
                        searched = true;
                        if (((ch = p.left) != null &&
                             (q = ch.find(h, k, kc)) != null) ||
                            ((ch = p.right) != null &&
                             (q = ch.find(h, k, kc)) != null))//find函数就是将ch 节点作为起点开始对ch节点为根的树进行全局搜索 直到找到"相等"key值的TreeNode
                            return q;
                    }
                    dir = tieBreakOrder(k, pk);// 当二者哈希值相等且不可比较的时候,该函数会返回k 和pk的比较值 
                }
​
                TreeNode<K,V> xp = p;
                if ((p = (dir <= 0) ? p.left : p.right) == null) {//更新p 值,若为null 则说明该把该值插入到xp节点的子节点位置上了,(BST 插入)并且balanceInsertion后结束循环
                    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 = x;
                    x.parent = x.prev = xp;
                    if (xpn != null)
                        ((TreeNode<K,V>)xpn).prev = x;
                    moveRootToFront(tab, balanceInsertion(root, x));//将新的root 放在双向链表的最前端 我们可以从不同的角度看某个桶位上的数据 可能既是一颗红黑树 又是一条双向链表。
                    return null;
                }
            }
        }

插入该新节点后需要进行一个balanceInsertion操作,balanceInsertion操作就是红黑树的插入的情况,掌握三种基本情况,第一待插入的节点的父节点是黑色,直接插入,第二待插入节点的叔父节点是红色,变色 操作。第三 待插入的节点的叔父节点是黑色或者null 则需要旋转 + 变色操作。见下面代码块

代码块

 static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,
                                                TreeNode<K,V> x) {
        x.red = true;// attention1 新插入的节点是红色 
        for (TreeNode<K,V> xp, xpp, xppl, xppr;;) {
            if ((xp = x.parent) == null) {//如果父节点为null 说明x节点就为 root,将其color 设置为黑色 就可返回
                x.red = false;
                return x;
            }
            else if (!xp.red || (xpp = xp.parent) == null)//如果父节点是黑色 或者父节点红色但是父节点的parent 为null
                return root;
          
          //如果代码执行到这里 说明x color 为 红色 父节点 为红色 有祖父节点并且祖父节点为黑色。
            if (xp == (xppl = xpp.left)) {
                if ((xppr = xpp.right) != null && xppr.red) {
                    xppr.red = false;
                    xp.red = false;
                    xpp.red = true;
                    x = xpp;
                }
                else {
                    if (x == xp.right) {
                        root = rotateLeft(root, x = xp);
                        xpp = (xp = x.parent) == null ? null : xp.parent;
                    }
                    if (xp != null) {
                        xp.red = false;
                        if (xpp != null) {
                            xpp.red = true;
                            root = rotateRight(root, xpp);
                        }
                    }
                }
            }
            else {//如果父节点是祖父节点的右孩子
                if (xppl != null && xppl.red) //如果叔父节点是红色  ,叔父 父亲变黑色 祖父节点变红色 并且将调整节点设置为xpp  
                    xppl.red = false;
                    xp.red = false;
                    xpp.red = true;
                    x = xpp;
                }
                else {//叔父节点为 null 或者 是 叔父节点是黑色  
                    if (x == xp.left) {//左子树 需要右旋 x 和xp 的角色互换
                        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;//祖父节点红色
                            root = rotateLeft(root, xpp);
                        }
                    }
                }
            }
        }
    }

treeifyBin 函数
treeifyBin 是将单链表(在一定条件下)转化为红黑树。首先是将每个节点替换为TreeNode 然后是进行treeify

代码块
Java

  final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;
            do {
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }
​

//注意treeifyBin 是HashMap 的函数 而 treeify 是TreeNode 的函数

treeify 遍历这个单链表,将节点一个个挂在红黑树上,设置每个节点的left right parent prev 等TreeNode 相关的指针,之后会进行balanceInsertion

代码块

Java
final void treeify(Node<K,V>[] tab) {
            TreeNode<K,V> root = null;
            for (TreeNode<K,V> x = this, next; x != null; x = next) {
                next = (TreeNode<K,V>)x.next;
                x.left = x.right = null;
                if (root == null) {
                    x.parent = null;
                    x.red = false;
                    root = x;
                }
                else {
                    K k = x.key;
                    int h = x.hash;
                    Class<?> kc = null;
                    for (TreeNode<K,V> p = root;;) {
                        int dir, ph;
                        K pk = p.key;
                        if ((ph = p.hash) > h)
                            dir = -1;
                        else if (ph < h)
                            dir = 1;
                        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) {
                            x.parent = xp;
                            if (dir <= 0)
                                xp.left = x;
                            else
                                xp.right = x;
                            root = balanceInsertion(root, x);
                            break;
                        }
                    }
                }
            }
            moveRootToFront(tab, root);
        }

resize函数
resize包括初始化逻辑,创建哈希表的时候采用 懒汉方式,即真正使用的时候才创建,而不是在创建底层的存储数组。下面不同构造函数的初始化代码

当我们利用HashMap map = new HashMap(); 之后resize 时候会通过17 - 20 行代码初始化。

当我们利用HashMap map = new HashMap(16,0.75f) 构造函数内部将threshold = 16,对应的初始化代码在15 - 16 行 (newCap 初始化)21 - 25 行是newThr 的初始化

代码块

Java
final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    if (e.next == null)//只有一个元素
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)//红黑树桶位的转移 
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

上述代码6-14 中 是真正的对已经初始化后的hashMap的底层数组的capacity 和threshold 的加倍。

resize 逻辑是按照table容量从0 开始一个个桶开始向新数组迁移,当只有一个元素时,直接移动,当是未树化的元素时,由于旧桶位上的元素只能有两个位置,原位置和原位置+oldCapacity的位置,这个根据新参与运算的位为1 还是0 确定。所以可以将旧数组上的一条链转化为高低链,高位链存放新数组【原位置+oldCapacity】位置的元素,低位链存新数组【原位置】位置的元素,所以对每一个桶遍历桶内的Node节点,分为高低链即可然后放置在新数组的相应位置即可。

关于桶位上是红黑树的迁移逻辑在split 中。split 中也是首先要分成高低链,只不过要计数,每条链的节点个数,如果低位链的节点个数小于UNTREEIFY_THRESHOLD 6,则会进行链化untreeify,其逻辑就是将TreeNode 节点一个个替换为Node 节点,然后将其挂在桶位上。如果低位链的节点个数大于UNTREEIFY_THREASHOLD,那么说明需要进行树化,最好的情况是高位链为空,即所有元素都在低位链上,此时低位链就是原来的红黑树,不需要树化,否则需要按顺序一个个进行红黑树构建,其中涉及到balanceInsertion.

代码块

Java
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
            TreeNode<K,V> b = this;
            // Relink into lo and hi lists, preserving order
            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 = null;
                if ((e.hash & bit) == 0) {
                    if ((e.prev = loTail) == null)
                        loHead = e;
                    else
                        loTail.next = e;
                    loTail = e;
                    ++lc;
                }
                else {
                    if ((e.prev = hiTail) == null)
                        hiHead = e;
                    else
                        hiTail.next = e;
                    hiTail = e;
                    ++hc;
                }
            }
​
            if (loHead != null) {
                if (lc <= UNTREEIFY_THRESHOLD)// 如果低位链的数量小于等于6 需要链化
                    tab[index] = loHead.untreeify(map);
                else {
                    tab[index] = loHead;
                    if (hiHead != null) // (else is already treeified) 如果高位链是空 则不需要树化,此链本来就是红黑树 不为空的时候进行树化
                        loHead.treeify(tab);
                }
            }
            if (hiHead != null) {
                if (hc <= UNTREEIFY_THRESHOLD)
                    tab[index + bit] = hiHead.untreeify(map);
                else {
                    tab[index + bit] = hiHead;
                    if (loHead != null)//如果低位链为空 则说明所有元素都在高位链上,本身就是一个红黑树,不为空的话则需要进行树化。
                        hiHead.treeify(tab);
                }
            }
        }

get函数

在这里插入图片描述

final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            if ((e = first.next) != null) {
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

find函数

find函数会使用二分搜索来进行,在这里我们可以看到,如果我们的Key未实现Comparable 接口,那么最坏情况即树上没有该key对应的节点,此时就会进行全树搜索。

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;
                if ((ph = p.hash) > h)
                    p = pl;
                else if (ph < h)
                    p = pr;
                else if ((pk = p.key) == k || (k != null && k.equals(pk)))
                    return p;
                else if (pl == null)
                    p = pr;
                else if (pr == null)
                    p = pl;
                else if ((kc != null ||
                          (kc = comparableClassFor(k)) != null) &&
                         (dir = compareComparables(kc, k, pk)) != 0)
                    p = (dir < 0) ? pl : pr;
                else if ((q = pr.find(h, k, kc)) != null)
                    return q;
                else
                    p = pl;
            } while (p != null);
            return null;
        }

JDK 1.8总结

整体结构:数组+链表+红黑树 我们在看树化后的桶要以红黑树、双链表不同的角度来观察。

树化时机 :当判断桶的Node 单链表个数大于 树化阈值TREEIFY_THRESHOLD 8 时,会考虑进行树化,但是如果table 的长度 MIN_TREEIFY_THRESHOLD 64 会考虑使用扩容操作来缓减哈希冲突,而不进行树化。

扩容时机:在数组长度较小的时候树化会引起扩容。再就是再插如新元素 size >= threshold 后会resize. 当table 未初始化时也会涉及到resize 只不过 实际上执行的初始化操作。

插入节点的方式:尾插法 原因是可以保持顺序。

树化阈值8 的原因:选择8是时间和空间权衡的原因,TreeNode所占空间大约是Node 的2倍。另外理想情况下(好的哈希函数,loadfactor = 0.75),一个桶位上的节点个数满足泊松分布(λ = 0.5)

  • 0: 0.60653066
  • 1: 0.30326533
  • 2: 0.07581633
  • 3: 0.01263606
  • 4: 0.00157952
  • 5: 0.00015795
  • 6: 0.00001316
  • 7: 0.00000094
  • 8: 0.00000006

8 出现的概率很小,不必担心空间问题。

JDK HashMap1.7 和HashMap 1.8 的区别

(1)插入方式 :1.7 是使用头插法插入数据 1.8使用尾插法。

头插法在多线程环境下扩容时会发生环形链表导致死循环的问题。而尾插法可以避免,并且可以保持顺序。

(2)hash值 计算方式:1.7 使用4次移位 5次异或操作,对于String还可能使用优化后的hash函数。1.8仅使用一次移位操作+一次异或。

(3)扩容 :

时机 :1.7 当前size >= threshold && table[index] != null 时候才会扩容。1.8 是size >= threshold 会扩容。

插入新元素时机:1.7先扩容后插入 。1.8是先插入后扩容。

计算元素索引方式:1.7 hash值 & (table.length - 1)。1.8是 通过判断新参与运算的位是0 还是1 来确定放到原位置 还是 原位置 + oldCap 。

(4)对null key处理:1.7 putForNullKey函数、getForNullKey函数专门对null key处理,1.8 不再单独处理。注意1.7 1.8 对于null key 都会放在 索引为0 的桶里。

(5) 初始化:1.7 调用inflateTable 初始化。1.8 将初始化的逻辑结合到resize中,用oldTab即旧数组来判断是初始化还是扩容,如果oldTab == null 说明初始化 否则扩容。

HashMap 使用规范
key 的设计规范
不可变的 ,这可保证你put it in get it back 而不是发生内存泄漏。 String Integer 就是很好的Key

Equals 和hashCode,重写equals 时,重写hashCode 原因就是我们如果只是重写了equals时,而我们的两个equal 的对象 会产生不同的hashcode,这样我们会将其放到不同的桶位上,显然不是预期。原则就是 两个key equal时,hashcode 一定相同 hashcode 不同时,一定不equal

应该实现Comparable 接口 如果未实现,树化后的桶插入元素后,可能会进行全树搜索,红黑树不会起到作用。

初始化
初始化时指定哈希表大小,使用HashMap(int initialCapacity)来创建哈希表,如果不能确定使用默认值16。原因是比如你想要存放1024 个元素,如果使用无参构造,则需要经过8次扩容,反复重建哈希表和数据迁移,如果数据量更大时性能下降。

遍历
1.7使用entrySet 遍历元素,1.8直接使用foreach。相比于使用KeySet(),keySet相当于是遍历了两次,一次是转化为Iterator 对象的遍历,一次是从HashMap 中取出key对应的value。Java 8新特性函数式接口、lambda表达式,流式编程,使得foreach 更加简单易用,性能也不错。下图是测试图

在这里插入图片描述

如果只需要获取key集合 使用keySet 该方法返回Set, 只需要value集合 使用values 方法,该方法返回list对象。
删除
不要在foreach 中利用map 的remove 操作来删除,否则会抛出ConcurrentModificationException ,Java 8 中建议使用 map.entrySet().removeIf()来进行条件删除。

代码块
Java
HashMap<Integer,Integer> m = new HashMap<>()
m.entrySet().removeIf(entry-> entry.getValue() == 10);
添加
不要在Map 的 entrySet、keySet、values等方法返回的集合对象 进行添加元素操作,否则会抛出UnsupportedException异常。

判空
判断为Map内元素是否为空,统一使用isEmpty 而不是使用size() == 0 。isEmpty 专门为判空设计,可读性好,性能>= 其他方式。

参考资料
数据结构-红黑树的删除节点操作

Tiebreaker Regarding Java HashMap, TreeNode and TieBreakOrder

HashMap解析

美团面试题:Hashmap的结构,1.7和1.8有哪些区别,史上最深入的分析

Java开发手册

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值