自己手写HashMap——红黑树的Java实现

0.引言

(1)HashMap简单介绍

你好,这篇文章是《自己手写HashMap》的第一篇。 在java7之前,HashMap是用数组(hash桶)+链表的形式实现的,大概的原理就是对key求hashCode,hashCode对当前数组的大小求模即为key对应的数组下标,hashCode值相同放在同一个下标,按链表的形式储存,大概像下面这样

(图片来自网络)

对,就是这么简单。当然java7以及以前,大家对HashMap的性能一直是存疑的,因为如果出现大量的hashCode碰撞的话,那就不可避免的要花费O(N)的查找时间,这跟普通线性表没啥区别。 所以在java8的时候对HashMap的结构进行了改整,基本结构依然是数组(hash桶)+链表的形式,但是求hashCode的时候加入了扰动函数用来减少碰撞,之前的普通链表也改为了红黑树(在元素个数极少的时候依然是普通链表),大概像下面这样*

(图片来自网络) (以上图片仅供参考,并不代表真实数据)

这次主要讲的就是红黑树,进入正题。

1.红黑树的基本定义简单概述

红黑树是一棵二叉搜索树(什么是二叉搜索树?),跟普通的二叉树不同的是,它在每个结点上增加了一个颜色属性,就是red或black,然后通过颜色上的约束让数据比较均衡的分布在各个结点,红黑树就是一颗近似的平衡树,而为什么不直接使用平衡二叉树,当然是为了平衡各种操作的性能。 篇幅有限,具体红黑树性质点这里,但是不看也没关系,因为后边贴代码的时候会详细讲

2.为什么选择红黑树(红黑树的性能)

这里直接讲结论,一颗有n个内部结点的红黑树的高度最多为2lg(n+1), 对红黑树的基本增删查操作,包括求最大最小值,其时间复杂度最坏为O(lgn)。

3.红黑树的基本操作(添加、遍历、删除)

接下来就是代码部分

(1)实体类组织结构

1.首先要有一个对象用来储存当前节点的信息,包含以下几个属性

  1. 颜色,red或者black、
  2. 当前结点的key和value
  3. 父结点(根结点的父结点为null)
  4. 左子结点
  5. 右子结点

如下

import java.io.Serializable;
import java.util.Objects;

public class Node<K, V> implements Serializable , Comparable<Node<K, V>>{

    private int color;
    private K key;
    private V value;
    private Node<K, V> pro;
    private Node<K, V> left;
    private Node<K, V> right;

    public Node(int color, K key, V value, Node<K, V> pro, Node<K, V> left, Node<K, V> right) {
        this.color = color;
        this.key = key;
        this.value = value;
        this.pro = pro;
        this.left = left;
        this.right = right;
    }

    public Node() {
    }

    public int getColor() {
        return color;
    }

    public void setColor(int color) {
        this.color = color;
    }

    public K getKey() {
        return key;
    }

    public void setKey(K key) {
        this.key = key;
    }

    public V getValue() {
        return value;
    }

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

    public Node<K, V> getPro() {
        return pro;
    }

    public void setPro(Node pro) {
        this.pro = pro;
    }

    public Node<K, V> getLeft() {
        return left;
    }

    public void setLeft(Node left) {
        this.left = left;
    }

    public Node<K, V> getRight() {
        return right;
    }

    public void setRight(Node right) {
        this.right = right;
    }


    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Node<?, ?> node = (Node<?, ?>) o;
        return Objects.equals(key, node.key);
    }

    @Override
    public int hashCode() {
        return Objects.hash(key);
    }

    @Override
    public int compareTo(Node<K, V> o) {
        return this.hashCode() - o.hashCode();
    }
}

复制代码

这里重写了equals、hashCode和compareTo方法,主要是为了之后的代码写起来方便,因为经常要用到比较操作,并且这里加入了泛型

2.其次就是红黑树对象

  1. 需要有一个属性来保存根结点

基本结构就像这样

public class Tree<K, V> implements Serializable, Iterable<Node<K, V>> {

    private Node<K, V> root;

    private int size;

    private int BLACK = 0;
    private int RED = 1;

    public Tree() {
        this.size = 0;
    }

    public int size() {
        return this.size;
    }
}
复制代码

继承Iterable是因为要实现迭代器。我们定义了两个常量,BLACK与RED,用来标记结点的颜色

目前为止要实现一棵红黑树,用到的实体类就这么多。

(2)put操作

插入数据并不难,难的是必须要在数据插入之后要让当前树依然符合红黑树的性质,所以我们先来看一下红黑树的约束条件

  1. 每个结点要么是红色的要么是黑色的
  2. 根结点是黑色的
  3. 红色结点的两个子结点必须是黑色的
  4. 对于每个结点,从该结点到其所有后代叶结点(最后一个没有分支的子结点)的简单路径上,均包含相同数目的黑色结点

乍一看规则还挺多,其实红黑树的最终追求就是希望结点可以平均分布,每个分支上的高度不要相差太多,之所以为每个结点添加颜色约束也是为了达到这个目的。 而为了实现这个目的,我们需要在插入数据之后,调整红黑树以符合性质

先一步一步来,写一个方法来将一个结点插入到当前树

    public void put(K key, V value) {
        Node<K, V> z = new Node<>();
        z.setKey(key);
        z.setValue(value);
        z.setColor(RED);
        this.size++;

        Node<K, V> y = null;
        Node<K, V> x = root;
        while (x != null) {
            y = x;
            int sp = z.compareTo(x);
            if (sp < 0) {
                x = x.getLeft();
            } else if (sp > 0) {
                x = x.getRight();
            } else {
                x.setValue(value);
                this.size--;
                return;
            }
        }
        z.setPro(y);

        if (y == null) {
            root = z;
        } else if (z.compareTo(y) < 0) {
            y.setLeft(z);
        } else if (z.compareTo(y) > 0) {
            y.setRight(z);
        }


        //调整红黑树
        this.fixup(z);

    }
复制代码

以上,我们一开始新建了一个红色结点,将key value 值 set进去,并将树的大小加一,之所以要把新结点设成红色,是因为插入一个红色结点要比插入一个黑色结点要简单的多,下边会详细讲。 之后通过循环将新结点和当前节点比较大小,来确定新结点插入的位置,小的插入左子节点,大的插入右子结点,当然如果结点相等,直接覆盖就好了。 这还不算完,找到插入位置后要设置一下新结点的父结点,并将父结点的子结点指向新结点,如果当前根结点为空,就直接将新结点设置成根结点就好了, 最后,插入新的结点之后可能会违反红黑树的性质,所以我们调用fixup方法来调整红黑树

    private void fixup(Node<K, V> z) {

        while (z.getPro() != null && z.getPro().getColor() == RED) {
            if (z.getPro().getPro() != null) {
                if (z.getPro().equals(z.getPro().getPro().getLeft())) {
                    Node<K, V> y = z.getPro().getPro().getRight();
                    if (y != null && y.getColor() == RED) {
                        z.getPro().setColor(BLACK);
                        y.setColor(BLACK);
                        z.getPro().getPro().setColor(RED);
                        z = z.getPro().getPro();
                    } else {
                        if (z.equals(z.getPro().getRight())) {
                            z = z.getPro();
                            this.leftRotate(z);
                        }
                        z.getPro().setColor(BLACK);
                        z.getPro().getPro().setColor(RED);
                        this.rightRotate(z.getPro().getPro());
                    }
                } else if (z.getPro().equals(z.getPro().getPro().getRight())) {
                    Node<K, V> y = z.getPro().getPro().getLeft();
                    if (y != null && y.getColor() == RED) {
                        z.getPro().setColor(BLACK);
                        y.setColor(BLACK);
                        z.getPro().getPro().setColor(RED);
                        z = z.getPro().getPro();
                    } else {
                        if (z.equals(z.getPro().getLeft())) {
                            z = z.getPro();
                            this.rightRotate(z);
                        }
                        z.getPro().setColor(BLACK);
                        z.getPro().getPro().setColor(RED);
                        this.leftRotate(z.getPro().getPro());
                    }
                }
            }
        }
        this.root.setColor(BLACK);
    }
复制代码

来简单的分析一下,当我们插入一个新结点的时候,哪些性质可能会被破坏,性质1肯定是成立的,因为我们插入的是一个红色结点,所以性质4也是成立的,但是性质2可能会被破坏,如果父结点也是红色的,那么性质3也会被破坏。 所以满足性质2和3是我们调整当前树的目标, 现在是不是在想,如果我们插入的是一个黑色结点,性质2、3不就满足了,但是如果那样的话,性质4就不满足了,我们可能需要调整非常多的结点才能满足所有性质,如果是插入一个红色结点的话,我们只需要把调整目标放在左或者右一边就好了,继续往下看, 我们知道了现在需要做的事情,就是调整树结构满足性质2和3, 简单思考一下,如果性质2被破坏,那么原因肯定是当前根节点为空,而违反性质3,那肯定是新结点的父节点为红色,因为根结点必须为黑色,所以新节点的父结点必然不是根结点,所以性质2和3并不会同时违反,单次插入操作,只会违反其中一个,这样推理的话,情况就变的简单多了。 性质2比较好处理,直接将新结点或者说根结点设置成黑色就好,违反性质3就稍微复杂了一点,这里总共存在6种情况,但是因为插入左边和插入右边是对称操作的原因,我们只需要思考其中三种情况就好,另外三种反过来操作即可。

先声明一点,违反性质3仅在新结点的父结点是红色的情况下

  1. 新结点的叔结点是红色的
  2. 新结点的叔结点是黑色的且新结点是一个右子结点
  3. 结点的叔结点是黑色的且新结点是一个左子结点

叔结点就是与当前节点的父结点平级的另一个结点

第一种情况就是新结点,父结点,叔结点都是红色的,试想一下,如果父节点是红色的,那么父结点的父结点必定是黑色的,所以将父结点和叔结点都着为黑色来满足性质3,但是因为黑色结点的增加,会违反性质4,所以将父节点的父节点着为红色(上边代码8-11行),至此情况一解决。

如下图

第二种情况,叔结点是黑色的,新结点是右子结点,聪明的同学可以想到,这个和情况三是对称的,所以我们可以通过一些操作,将这两种情况互转,然后处理其中一种即可,然而如何才能在保证其他三条性质不会违反的情况下转换树的结构呢,那就是旋转。 旋转分为左旋和右旋

左旋

解析一下左旋操作,左旋就是将当前结点(E)移动到左边,让它的右子结点(S)成为它的父结点并顶替之前它的位置,然后让它的右子结点的左子结点成为它的新右子结点,说起来很绕,但是看图其实很简单。

    private void leftRotate(Node<K, V> x) {
        Node<K, V> y = x.getRight();
        x.setRight(y.getLeft());
        if (y.getLeft() != null) {
            y.getLeft().setPro(x);
        }
        y.setPro(x.getPro());
        if (x.getPro() == null) {
            this.root = y;
        } else if (x.equals(x.getPro().getLeft())) {
            x.getPro().setLeft(y);
        } else {
            x.getPro().setRight(y);
        }
        y.setLeft(x);
        x.setPro(y);
    }
复制代码

这样移动后我想大家最担心就是大小顺序问题了,我们看将右子节点(S)上移成为父结点,因为右子结点是肯定比当前节点(E)大的,换句话说就是E是肯定比S小的,所以让E成为S的左子结点并没有什么问题,同理S的左子结点也是比E要大的,因为比E小的节点并不会插入到S的子结点上。 左旋搞明白了,右旋就简单了,我们把刚才的操作反向再来一遍就是右旋了。

右旋

    private void rightRotate(Node<K, V> x) {
        Node<K, V> y = x.getLeft();
        x.setLeft(y.getRight());
        if (y.getRight() != null) {
            y.getRight().setPro(x);
        }
        y.setPro(x.getPro());
        if (x.getPro() == null) {
            this.root = y;
        } else if (x.equals(x.getPro().getLeft())) {
            x.getPro().setLeft(y);
        } else {
            x.getPro().setRight(y);
        }

        y.setRight(x);
        x.setPro(y);
    }
复制代码

(图片来自网络)

我们通过一个简单的左旋,可以将情况2转换为情况3(13-16),因为新结点和父节点都为红色,所以并不会影响除性质3以外的其他性质,然后我们处理情况3,最直观的办法就是把父结点着为黑色,这样性质3就不会冲突了,但是性质4又不符合了,那怎么办,为了平衡性质4,我们再把父结点的父结点着为红色,这样做性质3性质4就都满足了。但是仔细想想,如果父结点的父结点是根结点怎么办,岂不是又不符合性质2了,所以目前来看只要解决最后一个问题,调整就可以立马完成,但是该如何解决最后一个问题呢,其实这时候我们只需要在不违反其他性质的同时,转换树的结构就好——右旋 如下

至此,红黑树的调整工作就圆满达成了,其实以上的操作目的,都是尽量在移动最少的结点下,把红黑树调整到合法的状态 现在再想想如果我们一开始插入的是黑色结点而不是红色结点,那么每次插入的时候性质4都会被违反,但如果插入的是一个红色结点,那么只会可能违反性质2或性质3,也有可能不会违反任何性质。

总结

到此,插入一条数据过操作就结束了,我们来简单总结一下,先按照普通二叉搜索树的方式,将一条数据插入到合适的位置,然后如果红黑树的性质被违反的话,我们通过变色和旋转,局部调整树的结构,使其整体符合红黑树的约束条件,插入操作结束

(3)遍历、查找

红黑树的遍历跟普通二叉搜索树的遍历方式一样,遍历方式可分为中序、先序、后序遍历,当然也有层级遍历,我们先用中序遍历的方式来实现,所谓中序遍历,就是当前输出的结点对象,在其左子结点和右子结点的中间输出,以下是通过递归实现

    public void inorder(Node<K, V> x){
        if(x!=null){
            inorder(x.getLeft());
            System.out.println(x.getKey() + ":" + x.getValue());
            inorder(x.getRight());
        }
    }
复制代码

然后测试一下

public static void main(String[] args) {
        Tree<String, Integer> tree = new Tree<>();
        tree.put("1", 333);
        tree.put("12", 3331);
        tree.put("41", 3313);
        tree.put("21", 3133);
        tree.put("4", 33343);
        tree.put("33", 3353);

       tree.inorder(tree.getRoot());


    }
复制代码

我们发现中序遍历就是按照从小到大输出的。

先序和后序遍历只要移动一下当前结点输出的位置即可。

递归的方式实现很简单,但是对于大多数情况下,递归需要频繁的压栈,我们可以通过迭代的方式来改善这种情况, 既然要迭代,我们就来实现一下jdk自带的迭代器好了,因为这样我们以后就可以用迭代器或增强for循环的方式来遍历了。


    @Override
    public Iterator<Node<K, V>> iterator() {
        return new Iter();
    }

    private class Iter implements Iterator<Node<K, V>> {

        List<Node<K, V>> array;

        int cur;

        public Iter() {
            cur = 0;
            array = new ArrayList<>();
            Stack<Node<K, V>> stack = new Stack<>();
            Node<K, V> next = root;
            while (true) {
                while (next != null) {
                    stack.push(next);
                    next = next.getLeft();
                }
                if (stack.isEmpty()) break;
                next = stack.pop();
                array.add(next);
                next = next.getRight();
            }
        }

        @Override
        public boolean hasNext() {
            return cur < array.size();
        }

        @Override
        public Node<K, V> next() {
            Node<K, V> node = array.get(cur);
            cur++;
            return node;
        }
    }

复制代码

首先我们实现了迭代器,然后通过while循环的方式展开递归,先用类似于扫描的方式,循环把所有左子结点放入一个临时队列,最后将队列的结点取出,找到它的右子结点,进入下一次循环。

测试一下

    public static void main(String[] args) {
        Tree<String, Integer> tree = new Tree<>();
        tree.put("1", 333);
        tree.put("12", 3331);
        tree.put("41", 3313);
        tree.put("21", 3133);
        tree.put("4", 33343);
        tree.put("33", 3353);

       for(Node node : tree){
           System.out.println(node.getKey() + ":" + node.getValue());
       }


    }
复制代码

相比遍历,查找就简单多了。

    public V get(K key) {
        Node<K, V> node = getNode(key);
        return node != null ? node.getValue() : null;
    }

    private Node<K, V> getNode(K key) {
        if (this.root == null)
            return null;
        Node<K, V> x = this.root;
        while (x != null && !x.getKey().equals(key)) {
            if (key.hashCode() - x.getKey().hashCode() < 0) {
                x = x.getLeft();
            } else if (key.hashCode() - x.getKey().hashCode() > 0) {
                x = x.getRight();
            }
        }
        return x;
    }
复制代码

*拓展 求最大值与最小值

    public Node<K, V> getMaximum(Node<K, V> node) {
        while (node.getLeft() != null) {
            node = node.getRight();
        }
        return node;
    }
    public Node<K, V> getMinimum(Node<K, V> node) {
        while (node.getLeft() != null) {
            node = node.getLeft();
        }
        return node;
    }
复制代码

(4)删除

相比插入操作,删除操作需要考虑更多情况,所以一般认为删除要复杂一些,但是并不难。 我们可以按照之前做插入操作的思维方式,进行删除操作的分析。

首先先看一棵普通二叉树是如何删除的。

思考一下,我们该如何删除一棵二叉树中的随机结点, 这大概分为三种情况,我们设需要删除的结点为z

  1. z没有子结点
  2. z只有一个子结点
  3. z同时有左右两个字结点

前两种都比较简单,情况1直接将x删除,情况2将z删除,用子节点替换z的位置, 情况3稍微棘手一点,随意找一个z的子节点替换z当前的位置是不行的,因为我们得保证大小顺序结构,替换上来的结点必然要比z的所有左子节点大并且比z的所有右子结点小,先停顿两分钟来思考一下这个问题。 还记得我们之前讲到的中序遍历吗,对的,我们可以按照中序遍历的思路,从z的右子结点开始向下搜索(上图c、d所演示的),找到右子树上最小的结点y,右子树上最小的结点y也一定比左子树上的所有结点大,拿这个结点替换x最好不过了。

上边是普通二叉树的删除操作,仔细琢磨并没有多复杂,我们尝试的在普通二叉树上加上颜色上的约束,就可以做红黑树的删除操作分析了。 这里区别于普通二叉树,有两种情况,就是删除的节点z是红色结点还是黑色结点。删除一个红色结点并不会影响性质1、2、3,但是如果将子结点y上移替换z,并且y是黑色的,可能会影响性质4,但是我们可以通过简单的变色来解决这个问题。删除一个黑色结点可能会影响性质4,并且还可能影响到性质2、3,具体是否会影响,这可能取决于替换上来的结点是不是黑色的,目前来看删除一个黑色结点是最棘手的。

同插入操作一个原理,我们先按照普通二叉树的删除逻辑删除一个结点,最后通过fixup方法调整红黑树。

    public void remove(K key) {
        Node<K, V> z = getNode(key);
        Node<K, V> y = z;
        Node<K, V> x;
        int oColor = y.getColor();
        if (z.getLeft() == null) {
            x = z.getRight();
            this.RBTransplant(z, z.getRight());
        } else if (z.getRight() == null) {
            x = z.getLeft();
            this.RBTransplant(z, z.getLeft());
        } else {
            y = this.getMinimum(z.getRight());
            oColor = y.getColor();
            x = y.getLeft();
            if (y.getPro().equals(z)) {
                x.setPro(y);
            } else {
                this.RBTransplant(y, y.getRight());
                y.setRight(z.getRight());
                y.getRight().setPro(y);
            }
            this.RBTransplant(z, y);
            y.setLeft(z.getLeft());
            y.getLeft().setPro(y);
            y.setColor(z.getColor());
        }
        if (oColor == this.BLACK) {
            this.RBRemoveFixup(x);
        }
        this.size--;
    }

    private void RBTransplant(Node<K, V> u, Node<K, V> v) {
        if (u.getPro() == null) {
            this.root = v;
        } else if (u.equals(u.getPro().getLeft())) {
            u.getPro().setLeft(v);
        } else {
            u.getPro().setRight(v);
        }
        if (v != null)
            v.setPro(u.getPro());
    }
复制代码

普通二叉树的删除逻辑上边已经分析过了,这里代码就不一一介绍了,基本就是按我们之前的思路写的。RBTransplant操作用来替换结点,这里比较关键点在于,我们如果删除的是一个黑色结点,或者当在x有左右两棵子结点的时候,如果替换上来的结点y不为红色时,就交给RBRemoveFixup调整结构。 我们来看看RBRemoveFixup是如何工作的

    private void RBRemoveFixup(Node<K, V> x) {
        while (x != null && !x.equals(this.root) && x.getColor() == this.BLACK) {
            if (x.equals(x.getPro().getLeft())) {
                Node<K, V> w = x.getPro().getRight();
                if (w.getColor() == this.RED) {
                    w.setColor(this.BLACK);
                    x.getPro().setColor(this.RED);
                    this.leftRotate(x.getPro());
                    w = x.getPro().getRight();
                }

                if (w.getLeft().getColor() == this.BLACK && w.getRight().getColor() == this.BLACK) {
                    w.setColor(RED);
                    x = x.getPro();
                } else if (w.getRight().getColor() == BLACK) {
                    w.getLeft().setColor(BLACK);
                    w.setColor(RED);
                    this.rightRotate(w);
                    w = x.getPro().getRight();
                }

                w.setColor(x.getPro().getColor());
                x.getPro().setColor(BLACK);
                w.getRight().setColor(BLACK);
                this.leftRotate(x.getPro());
                x = root;
            } else {
                Node<K, V> w = x.getPro().getLeft();
                if (w.getColor() == this.RED) {
                    w.setColor(this.BLACK);
                    x.getPro().setColor(this.RED);
                    this.rightRotate(x.getPro());
                    w = x.getPro().getLeft();
                }

                if (w.getRight().getColor() == this.BLACK && w.getLeft().getColor() == this.BLACK) {
                    w.setColor(RED);
                    x = x.getPro();
                } else if (w.getLeft().getColor() == BLACK) {
                    w.getRight().setColor(BLACK);
                    w.setColor(RED);
                    this.leftRotate(x);
                    w = x.getPro().getLeft();
                }

                w.setColor(x.getPro().getColor());
                x.getPro().setColor(BLACK);
                w.getLeft().setColor(BLACK);
                this.rightRotate(x.getPro());
                x = root;
            }
        }
        if (x != null)
            x.setColor(BLACK);
    }
复制代码

看到这茫茫的代码第一感觉可能就已经蒙了,说实话虽然是我写的,我这么乍一看也有点蒙。但是如果我们借鉴之前的经验,简单分析一下,立马就会豁然开朗了不是吗。 我们将替换上来的结点设为x,如果x为黑色的,我们需要调整一下来平衡红黑树,既通过变色和旋转操作,将一个额外的黑色结点上移,直到

  1. x为红色节点
  2. x为根节点
  3. x不为null

我们从这个角度,将调整操作分为了8种情况,这跟插入操作比较相似,是两两对称的,所以我们只需要考虑其中4种情况,另外4种情况反向操作就好

我们这里分析的是x为左子结点的情况

  1. x的兄弟结点w是红色的
  2. x的兄弟结点w是黑色的,而且w的两个子结点都是黑色的
  3. x的兄弟结点w是黑色的,w的左子结点是红色的,w的右子结点是黑色的
  4. x的兄弟结点w是黑色的,且w的右子结点是红色的

这4种情况也是可以相互转换,我们先来看情况1(上图a) 因为w为红色,w的子结点为黑色,我们可以改变w和x.pro的颜色,并对x.pro做一次左旋,这样情况1就转换为了情况2、3、4。 情况2的两个子结点都是黑色的,因为w也是黑色的,所以我们将w设为红色,然后指针上移x=x.pro,重新循环。当然如果是从情况1进入到情况2,因为x.pro是红色的,所以这里应该就会退出循环了 情况3,w的左子结点为红色,右子结点为黑色,我们将w设为红色,,将左子结点设为黑色,然后对w右旋,转换成情况4 情况4,w的右子结点为红色,w的左子结点可能是红色也可能是黑色,我们将w的右子结点设为黑色,将w的颜色设置成父结点的颜色,将父结点设为黑色,然后左旋,重新循环。 循环会一直进行,直到符合我们的预期,最后将x重新设回黑色,调整就结束了。

以上便是红黑树的删除操作,在红黑树上删除一个元素是很麻烦的,但是麻烦也仅限于对于人,因为对于机器来说,它的时间复杂度依然是log(n)

4.结语

HashMap的核心——红黑树的结构以及基本操作就算是实现了,接下来我们在下一篇,将深入实现HashMap的hash算法,思考如何减少碰撞,以及传说中的动态扩容,当然也有基本的增删查、迭代器,并且探讨HashMap为什么不是线程安全的,为什么会出现死锁。

我们第二篇见。

本篇完整代码 链接:pan.baidu.com/s/11CpqUZGD… 提取码:v1j3

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值