利用Java手写LinkedList

16 篇文章 0 订阅

利用Java手写LinkedList

和ArrayList不同的是,LinkedList是采用链表实现的,链表的特点就是每个节点存储的是value和下个节点的地址,所以不存在类似ArrayList的扩容问题,添加节点只需要一个新的节点对象然后链表末尾指向它就可以了。参考Java官方的LinkedList实现:java.util.LinkedList。不过Java官方使用双向链表实现。

链表和节点

链表有n多个链表节点组成,每个节点存储的都是元素+下个节点的内存地址。如何得到节点中存储的元素实际上是通过从链表的头一个节点开始迭代,然后得到当前节点。

私有属性

动态数组大小size,以及用于存放该动态数组的头节点first

注意:为什么要有头节点?

是因为链表元素的获取是利用迭代来不断得到next元素的,也就是说你迭代到了中间某一元素后,你就没有办法回溯了,你这个链表就暂时停留在这里了(因为该元素前的所以链表的内存地址已丢失),所以就需要保存一个头节点的内存地址,以确保每次都能够从头开始依次访问链表中的元素

public class LinkedList<E> {

    /**
     * 动态数组大小
     */
    private int size;

    /**
     * 头节点
     */
    private Node<E> first;
    
}	

节点类

通过静态内部类的方式来声明链表中的节点Node类

public class LinkedList<E> {

    /**
     * 动态数组大小
     */
    private int size;

    /**
     * 头节点
     */
    private Node<E> first;
    
    /**
     * 静态内部类
     */
    private static class Node<E>{

        /**
         * 该节点存储的元素
         */
        private E element;

        /**
         * 该节点所指向的下一个节点
         */
        private Node<E> next;

        public Node(E element, Node<E> next) {
            this.element = element;
            this.next = next;
        }
    }
    
}	

Node类主要包含节点存储的具体元素、下个节点的内存地址,以及节点的构造方法(指定元素,以及指定下一个节点)。

通过下标获得具体node节点

链表与数组不同的是,它是利用迭代来定位到具体某个节点的,再很多add、remove的场景中我们可能会经常需要定位到某个具体的节点(因为需要获取该节点前后的节点)

	/**
     * 获取具体某一下标的节点
     * @param index      下标
     * @return           该下标的node节点
     */
    private Node<E> node(int index){
        Node<E> node = first;
        for (int i = 0; i < index; i++) {
            node = node.next;
        }
        return node;
    }

可以看到上述方法就是通过for循环的方式,node.next不断迭代获得node节点,最后获取到位于index位置的node元素。

构造方法

LinkedList不同于ArrayList,因为它不需要初始化数组容量,所以它没有构造函数。

基本方法

和ArrayList类似,LinkedList具有以下基本方法

public class LinkedList<E> {


    /**
     * 动态数组大小
     * @return      动态数组大小
     */
    public int size(){}

    /**
     * 动态数组是否为空
     * @return      动态数组是否为空
     */
    public Boolean isEmpty(){}

    /**
     * 添加元素
     * @param element   元素
     */
    public void add(E element){}

    /**
     *
     *  0 1 2 3 4 5 6 7 8 9
     *  1 2 3   4 5 6 7 8
     * 向指定位置添加元素
     * @param index     位置
     * @param element   元素
     */
    public void add(int index,E element){}

    /**
     *
     *  0 1 2 3 4 5 6 7 8 9
     *  a b c 1 d e f g h
     * 移除指定位置的元素
     * @param index     位置
     */
    public void remove(int index){}

    /**
     * 删除指定元素
     * @param element   元素
     */
    public void remove(E element){}

    /**
     * 清空动态数组中的元素
     */
    public void clear(){}

    /**
     * 修改指定位置的元素
     * @param index      位置
     * @param element    元素
     */
    public void set(int index,E element){}

    /**
     * 获得指定位置的元素
     * @param index     位置
     * @return          元素
     */
    public E get(int index){}

    /**
     * 判断数组是否包含该元素
     * @param element    元素
     * @return           true包含,false不包含
     */
    public Boolean contains(E element){}

    /**
     * 该元素第一次出现的下标
     * @param element    元素
     * @return           下标
     */
    public int indexOf(E element){}

    @Override
    public String toString() {}
}

size()

返回动态数组的大小

public int size() { 
    return size; 
}

isEmpty()

返回该动态数组是否为空,即判断size是否为0

public boolean isEmpty() { 
    return size == 0; 
}

toString()

打印该链表,依旧是从头节点开始迭代然后打印

	@Override
    public String toString() {
        StringBuilder string = new StringBuilder();
        string.append("LinkedList{");
        string.append("size=" + size + ", elements=[");
        Node<E> node = first;
        for (int i = 0; i < size; i++) {
            string.append(node.element);
            node = node.next;
            if (i!=size-1){
                string.append(", ");
            }
        }
        string.append("]");
        string.append("}");
        return string.toString();
    }

indexOf()和contains()

indexOf(E element)就是返回该元素所在的位置下标,如果不存在则返回-1。同样地,利用从first开始迭代node,如果节点的元素等于传入的元素则直接返回该下标。

    @Override
    public int indexOf(E element) {
        Node<E> node = first;
        for (int i = 0; i < size; i++) {
            if ( (node.element).equals(element) ) return i;
            node = node.next;
        }
        return -1;
    }

contains(E element)就是判断链表是否包含该元素,如果包含则返回true。

	@Override
    public boolean contains(E element) {
        return indexOf(element) >= 0;
    }

get()

通过节点下标获得该节点

	@Override
    public E get(int index) {
        return node(index).element;
    }

set()

修改某位置节点的元素

	@Override
    public void set(int index, E element) {
        node(index).element = element;
    }

add()

链表添加元素。链表添加元素的原理大致是:

  1. 获取该下标位置的前一个节点(目的是拿到该下标位置的地址)
  2. 创建一个新的Node节点,将当前位置的地址赋值给该Node节点,同时插入的元素也赋值给该节点
  3. 前一个节点next指向新创建的Node节点。
  4. 这样原先的前一个节点的next指向新创建的节点,新创建的节点指向原本该下标的节点,这样就完成了插入。最后size++

步骤图:

  1. 原链表

    在这里插入图片描述

  2. 执行插入

    在这里插入图片描述

@Override
    public void add(int index, E element) {
        if (index == 0){
            first = new Node<>(element, first);
        } else {
            Node<E> pNode = node(index - 1);             //当前index的前一个节点
            pNode.next = new Node<>(element, pNode.next);      //当前节点的下一个节点赋给待添加的节点
        }
        size++;
    }

**注意:**需要特别注意的就是,如果向第一个位置进行插入,那么利用上述的node(int index)方法是没有办法获取到头节点的前一个节点的(因为不存在)。所以如果是往index为0的位置插入需要额外写方法。逻辑就是直接将first指向新创建的node节点就可以了,同时将原有的first指向存入新创建的node节点。

remove()

链表移除元素。原理同样是改变元素指向:

  1. 获取到移除元素的前一个节点,命名为pNode
  2. 将要移除元素的next赋值给pNode.next
  3. 这样pNode直接指向要移除元素的下一个元素。最后size–

步骤图:

  1. 原链表

    在这里插入图片描述

  2. 执行删除

    在这里插入图片描述

	@Override
    public void remove(int index) {
        if (index == 0) {
            first = first.next;
        } else {
            Node<E> pNode = node(index - 1);
            Node<E> node = pNode.next;
            pNode.next = node.next;
        }
        size--;
    }

pNode直接指向当前node.next后,当前node没有被指向了,那么它就会被Java的垃圾回收机制给自动清理掉。同样需要注意的是,如果需要删除第一个元素,你同样也是没办法获取到first的。所以也是需要额外处理,直接将当前的first指向first.next,那么原来first指向的节点也会被Java垃圾回收机制给清理掉。

clear()

清空数组的所有元素,直接置size为0,并且first指向null就可以了,因为first指向null,所以first以及first后面所指向的所有节点都会被销毁。

	@Override
    public void clear() {
        size = 0;
        first = null;
    }

下标越界

和ArrayList同样的,在执行add或者remove前都需要检查一下传入的index是否在范围内。

注意

add()和remove()、get()、set()的index范围有所区别。添加元素允许往动态数组的最后一个位置添加元素,所以index是可以访问到size的,但是删除、查询和修改都是在元素已经在动态数组中存在的基础上,所以index是不可以访问到size的,切记!!!

checkIndex()

检查非添加时的下标index,不能小于0或者不能大于等于size(0<index<size)

private void checkIndex(int index){
    if (index<0||index>=size) indexOutOfBoundsException(index);
}

checkIndexAdd()

检查添加时下标index,不能小于0或者不能大于size(0<index ⩽ \leqslant size)

private void checkIndexAdd(int index){
    if (index<0||index>size) indexOutOfBoundsException(index);
}

indexOutOfBoundsException()

下标越界时抛出的自定义异常

private void indexOutOfBoundsException(int index){
    throw new IndexOutOfBoundsException("index="+index+", size="+size);
}

List接口

在很多时候一种思想可以用不同的解决办法来实现,比如动态数组就可以利用底层分别是数组和链表的ArrayList和LinkedList来实现,他们需要实现的方法是一样的,但是方法内部的实现是不一样的,所以就可以为他们共同指定一个**“标准”,这个标准就是接口**。接口里只定义需要实现的方法(返回值类型、传入的参数、方法名),接口不需要关心方法如何具体实现,只需要指定方法就可以了,这样不同的解决方法就可以有不同的具体实现,虽然是实现同一个方法。就类似公司领导给两个人下达了同一任务,只告诉了这个任务需要什么,并且要得到什么,但是他不关心具体实现过程,由员工来按照要求进行实现。不同员工实现该任务肯定各有优劣,领导就会根据他想要的目的来选用哪个具体的解决方案。

那么List接口应该有如下方法:

public interface List<E> {

    /**
     * 动态数组大小
     * @return      动态数组大小
     */
    int size();

    /**
     * 动态数组是否为空
     * @return      动态数组是否为空
     */
    boolean isEmpty();

    /**
     * 添加元素
     * @param element   元素
     */
    void add(E element);

    /**
     *
     *  0 1 2 3 4 5 6 7 8 9
     *  1 2 3   4 5 6 7 8
     * 向指定位置添加元素
     * @param index     位置
     * @param element   元素
     */
    void add(int index,E element);

    /**
     *
     *  0 1 2 3 4 5 6 7 8 9
     *  a b c 1 d e f g h
     * 移除指定位置的元素
     * @param index     位置
     */
    void remove(int index);

    /**
     * 删除指定元素
     * @param element   元素
     */
    void remove(E element);

    /**
     * 清空动态数组中的元素
     */
    void clear();

    /**
     * 修改指定位置的元素
     * @param index      位置
     * @param element    元素
     */
    void set(int index,E element);

    /**
     * 获得指定位置的元素
     * @param index     位置
     * @return          元素
     */
    E get(int index);

    /**
     * 判断数组是否包含该元素
     * @param element    元素
     * @return           true包含,false不包含
     */
    boolean contains(E element);

    /**
     * 该元素第一次出现的下标
     * @param element    元素
     * @return           下标
     */
    int indexOf(E element);

}

AbstractList抽象类

如果说接口是严格的标准,那么抽象类就是宽松一点的标准,抽象类里可以实现接口,甚至可以预先实现部分方法。通常抽象类实现接口后可以将可能会用到的重复代码或者方法或者属性抽离到抽象类中。

比如ArrayList和LinkedList都需要用到获取动态数组大小和判断动态数组是否为空,那么就可以将这两个方法抽离出来,以及判断index下标是否越界也是可以抽离的。那么AbstractList抽象类如下:

public abstract class AbstractList<E> implements List<E> {

    /**
     * list大小
     */
    protected int size;

    public int size() { return size; }

    public boolean isEmpty() { return size == 0; }

    /**
     * index超出范围后的报错信息
     */
    private void indexOutOfBoundsException(int index){
        throw new IndexOutOfBoundsException("index="+index+", size="+size);
    }

    /**
     * 检查删改查元素的index
     */
    protected void checkIndex(int index){
        if (index<0 || index>=size) indexOutOfBoundsException(index);
    }

    /**
     * 检查增元素的index
     */
    protected void checkIndexForAdd(int index){
        if (index<0 || index>size) indexOutOfBoundsException(index);
    }

}

需要特别注意方法和属性的访问范围限制,protected是当前类和子类都能访问,private是只能当前类访问。

所以List接口、AbstractList抽象类、ArrayList和LinkedList类的继承关系如下:

在这里插入图片描述

内存或时间消耗

ArrayList底层就是数组,数组的存储空间是一串连续的内存地址,是比较省空间的。在add或者remove中间的元素需要不断移动后面的元素,存储空间存储的内存地址不断被重新赋值。往数组末尾添加或者删除元素的时直接加在数组尾或者移除数组尾元素,是不需要移动任何元素的。扩容的时候需要请求内存并且重新循环给数组赋值。如果数组没有被元素填满的话,可能存在浪费。

LinkedList底层是链表,每个节点存储的是元素和下一个节点的内存地址,内存地址是散乱的,在add或者remove的时候只需要改变节点存储的下一个节点的指向就可以了,比较方便,并且不需要扩容,即加一个元素就直接加一个元素,移除一个元素就移除一个元素。

总结

public class LinkedList<E> extends AbstractList<E>{

    /**
     * 头节点
     */
    private Node<E> first;

    /**
     * 静态内部类
     */
    private static class Node<E>{

        /**
         * 该节点存储的元素
         */
        private E element;

        /**
         * 该节点所指向的下一个节点
         */
        private Node<E> next;

        public Node(E element, Node<E> next) {
            this.element = element;
            this.next = next;
        }
    }

    /**
     * 获取具体某一下标的节点
     * @param index      下标
     * @return           该下标的node节点
     */
    private Node<E> node(int index){
        Node<E> node = first;
        for (int i = 0; i < index; i++) {
            node = node.next;
        }
        return node;
    }

    @Override
    public void add(E element) {
        add(size,element);
    }

    @Override
    public void add(int index, E element) {
        checkIndexForAdd(index);
        if (index == 0){
            first = new Node<>(element, first);
        } else {
            Node<E> pNode = node(index - 1);             //当前index的前一个节点
            pNode.next = new Node<>(element, pNode.next);      //当前节点的下一个节点赋给待添加的节点
        }
        size++;
    }

    @Override
    public void remove(int index) {
        checkIndex(index);
        if (index == 0) {
            first = first.next;
        } else {
            Node<E> pNode = node(index - 1);
            Node<E> node = pNode.next;
            pNode.next = node.next;
        }
        size--;
    }

    @Override
    public void remove(E element) {
        remove(indexOf(element));
    }

    @Override
    public void clear() {
        size = 0;
        first = null;
    }

    @Override
    public void set(int index, E element) {
        checkIndex(index);
        node(index).element = element;
    }

    @Override
    public E get(int index) {
        checkIndex(index);
        return node(index).element;
    }

    @Override
    public boolean contains(E element) {
        return indexOf(element) >= 0;
    }

    @Override
    public int indexOf(E element) {
        Node<E> node = first;
        for (int i = 0; i < size; i++) {
            if ( (node.element).equals(element) ) return i;
            node = node.next;
        }
        return -1;
    }

    @Override
    public String toString() {
        StringBuilder string = new StringBuilder();
        string.append("LinkedList{");
        string.append("size=" + size + ", elements=[");
        Node<E> node = first;
        for (int i = 0; i < size; i++) {
            string.append(node.element);
            node = node.next;
            if (i!=size-1){
                string.append(", ");
            }
        }
        string.append("]");
        string.append("}");
        return string.toString();
    }
}

虚拟头节点

在add以及remove的时候,常常需要判断index是否为0,其实这个时候可以引入虚拟头节点的方式来保证每个节点的前一个节点都是存在的。

即在构造方法中,创建LinkedList对象时候就创建一个虚拟头节点:

	public LinkedList2(){
        //虚拟头节点
        first = new Node<E>(null,null);
    }

同样的,使用index来查找链表中节点的时候,如果index为-1,则返回这个头节点,其余时候正常返回:

	private Node<E> node(int index){
        Node<E> node = first.next;
        if (index == -1) return first;
        for (int i = 0; i < index; i++) {
            node = node.next;
        }
        return node;
    }

那么在add或者remove的时候就可以直接获取前一个节点就可以了,即使是index为0的时候,因为index为0时,它的前结点是index为-1的虚拟头节点:

	
	@Override
    public void add(int index, E element) {
        checkIndex(index);
        Node<E> preNode = node(index - 1);
        preNode.next = new Node<>(element,preNode.next);
        size++;
    }

	@Override
    public void remove(int index) {
        checkIndex(index);
        Node<E> preNode = node(index - 1);
        preNode.next = preNode.next.next;
        size--;
    }

双向链表

双向链表会在单向链表的基础上增加尾节点的概念,以及节点Node上会多一个前一个节点的内存地址。目的就是为了增加从尾节点开始迭代访问节点的效率。

与单向链表的差别

单向链表每次访问index位置的元素都是从first开始访问,但是如果链表存储的元素过多,需要访问比较靠后的元素花费的时间就比较长,此时引入双向链表的概念(即last开始访问)。将链表中元素根据size分为前半部分和后半部分,如果index靠近前半部分就从first开始迭代,反之则从last开始迭代。同时node节点中需要使用prev来引用前一个节点的地址。所以其实双向链表是对称的。

它的结构如图:在这里插入图片描述

first指向首节点,last指向尾节点

那么它的私有属性以及内部类则就是:

public class LinkedList<E> extends AbstractList<E> {

    //首节点
    private Node<E> first;

    //尾节点
    private Node<E> last;

    //节点
    private static class Node<E>{
        private E element;
        private Node<E> prev;
        private Node<E> next;
        public Node(E element, Node<E> prev, Node<E> next) {
            this.element = element;
            this.prev = prev;
            this.next = next;
        }
    }
}

则它根据index获取对应位置的节点的方法就是:

index与一半size进行比较,如果index位于前半部分则从first开始迭代,反正则从last开始迭代。(提高了时间效率,不再像单向链表一样每次都从前往后迭代访问)

    /**
         * 双向链表可以根据index距离头节点更近或者尾节点更近来选择从那边开始迭代
         * @param index     下标
         * @return          node节点
         */
    private Node<E> node(int index){
        int halfSize = size >> 1;
        if (index<=halfSize) {//如果要访问的下标在前一半链表
            Node<E> node = first;
            for (int i = 0; i < index; i++) {
                node = node.next;   //依次往后迭代
            }
            return node;
        } else {//如果要访问的下标在后一半链表
            Node<E> node = last;
            for (int i = size-1; i > index ; i--) {
                node = node.prev;   //依次往前迭代
            }
            return node;
        }
    }

add

与单向链表会有一些差别,因为涉及到prev引用。

普遍情况(size > 1并且不是往首或尾添加)
  1. 获取到当前index位置的节点(命名nextNode)以及它的前一个位置的节点(命名prevNode)

    在这里插入图片描述

  2. 创建一个Node对象,它的prev指向prevNode,它的next指向nextNode

    在这里插入图片描述

  3. prevNode的next指向新创建的Node对象,nextNode的prev指向新创建的Node对象,完成add操作

    在这里插入图片描述

    Node<E> nextNode = node(index);//index位置的节点
    Node<E> prevNode = nextNode.prev;//index位置的前节点
    Node<E> node = new Node<>(element, prevNode, nextNode);//新加入的节点
    prevNode.next = node;
    nextNode.prev = node;
空链表的情况(size = 0)

直接创建一个节点,该节点的prev和next都为null,然后链表的first和last都指向该节点

    Node<E> node = new Node<>(element, null, null);
    first = node;
    last = node;
向首位置插入(index = 0 且 size > 0)

(如果size为0往首位置插入实际上就是往空链表插入所以和上面的情况没差别则不讨论)

  1. 首先获取到插入前的首节点,即first(命名为oldFirst)

    在这里插入图片描述

  2. 创建新的节点node,它的next指向oldFirst,prev指向null

    在这里插入图片描述

  3. first指向新的节点node,之前的oldFirst的prev指向新的节点,完成add操作

    在这里插入图片描述

    Node<E> oldFirst = first;	//获取first节点
    Node<E> node = new Node<>(element, null, oldFirst);
    first = node;
    oldFirst.prev = node;
向尾位置插入(index = size且size>0)

和向首位置插入基本类似,对称的。

  1. 获取原本的尾节点oldLast

    在这里插入图片描述

  2. 创建新的节点node,它的prev指向oldLast,next指向null

    在这里插入图片描述

  3. 链表的last指向新创建node节点,oldLast的next也指向新创建的node节点,完成add操作

    在这里插入图片描述

    Node<E> oldLast = last; //原本的最后一个元素
    Node<E> node = new Node<>(element, oldLast, null);
    last = node;
    oldLast.next = node;
合并
    @Override
    public void add(int index, E element) {
        checkIndexForAdd(index);
        if (size == 0) {    //链表中还没有任何元素(即即将添加的是第一个元素)
            Node<E> node = new Node<>(element, null, null);
            first = node;
            last = node;
        } else {    //链表中已经存在至少一个元素
            if (index == 0) {   //往第一个位置添加元素
                Node<E> oldFirst = first;
                Node<E> node = new Node<>(element, null, oldFirst);
                first = node;
                oldFirst.prev = node;
            } else if (index == size){  //往最后一个位置添加元素
                Node<E> oldLast = last; //原本的最后一个元素
                Node<E> node = new Node<>(element, oldLast, null);
                last = node;
                oldLast.next = node;
            } else {    //往中间添加元素
                Node<E> nextNode = node(index);//index位置的节点
                Node<E> prevNode = nextNode.prev;//index位置的前节点
                Node<E> node = new Node<>(element, prevNode, nextNode);//新加入的节点
                prevNode.next = node;
                nextNode.prev = node;
            }
        }
        size++;
    }

remove

remove移除节点考虑的和add差不太多,主要也是考虑没有节点时候的删除以及删除首或者尾节点

普遍情况(size > 1 且不是从首或尾移除)
  1. 获取到当前index位置节点(indexNode)的前一个节点(prevNode)以及它的后一个节点(nextNode)

    在这里插入图片描述

  2. prevNode的next指向nextNode,nextNode的prev指向prevNode(此时indexNode没有被引用,根据Java垃圾回收机制就会被清空),remove操作完成

    在这里插入图片描述

    Node<E> indexNode = node(index);    //当前index的节点
    Node<E> prevNode = indexNode.prev;  //当前index的前一节点
    Node<E> nextNode = indexNode.next;  //当前index的后一节点
    prevNode.next = nextNode;
    nextNode.prev = prevNode;
链表中只有一个元素(size = 1)

直接first和last都指向null

    first = null;
    last = null;
移除首节点(size > 1 且 index = 0)
  1. 获取到首节点first,以及首节点的下一个节点first.next

    在这里插入图片描述

  2. first.next的prev指向null,然后链表的first指向first.next(此时之前的first节点没有被指向了,就会被垃圾回收掉),此时remove操作完成

    在这里插入图片描述

    first.next.prev = null;
    first = first.next;
移除尾节点(size > 1 且 index = size -1 )

移除尾节点和移除首节点类似,只是first换为last,并且prev换位next

    last.prev.next = null;
    last = last.prev;
合并
    @Override
    public void remove(int index) {
        checkIndex(index);
        if (size == 1) {    //只有一个元素的时候
            first = null;
            last = null;
        } else {    //至少两个元素
            if (index == 0) {  //移除第一个元素
                first.next.prev = null;
                first = first.next;
            } else if (index == size -1) {  //移除最后一个元素
                last.prev.next = null;
                last = last.prev;
            } else {
                Node<E> indexNode = node(index);    //当前index的节点
                Node<E> prevNode = indexNode.prev;  //当前index的前一节点
                Node<E> nextNode = indexNode.next;  //当前index的后一节点
                prevNode.next = nextNode;
                nextNode.prev = prevNode;
            }
        }
        size--;
    }

clear

即first和last都指向null,并且size = 0

    @Override
    public void clear() {
        size = 0;
        first = null;
        last = null;
    }

lastIndexOf

增加了可以从链表尾部开始查找

    public int lastIndexOf(E element) {
        Node<E> node = last;
        for (int i = size-1; i >= 0; i--) {
            if ( (node.element).equals(element) ) return i;
            node = node.prev;
        }
        return -1;
    }

单向链表和双向链表的区别

双向链表引入了尾节点,可以从尾节点开始迭代访问节点元素,在一定程度上增加了效率,并且通过index与size/2进行比较,决定是从哪边开始访问。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值