深入剖析LinkedList:揭秘底层原理

在这里插入图片描述

一、 概述LinkedList

1.1 LinkedList简介

LinkedList是Java中的一种双向链表数据结构实现类,它实现了ListDeque接口。

LinkedList的特点主要包括以下几点

  1. 链表结构:LinkedList内部使用链表来存储元素,每个节点都包含当前元素的值以及指向前一个节点和后一个节点的引用。这种链表结构使得插入和删除元素的操作效率较高。
  2. 双向访问:每个节点都有指向前一个节点和后一个节点的引用,这使得在LinkedList中可以通过前向或后向遍历访问元素,而不需要像ArrayList那样进行元素的整体移动。
  3. 动态大小:LinkedList没有固定的容量限制,可以根据需要动态地添加或删除元素,并且不会出现数组扩容的情况。
  4. 随机访问较慢:由于LinkedList是基于链表的数据结构,因此在访问特定索引位置的元素时,需要从头或尾部开始遍历到目标位置。相比之下,ArrayList可以通过索引直接访问元素,因此在随机访问元素时效率更高。
  5. 支持队列和栈操作:作为Deque接口的实现类,LinkedList可以被用作队列(先进先出)和栈(后进先出)的数据结构,可以使用addFirst、removeFirst等方法模拟栈操作,使用addLast、removeFirst等方法模拟队列操作。

总体而言,LinkedList适用于频繁地插入和删除元素的场景,但对于随机访问元素的需求不是很高时,可以考虑使用它。

1.2 LinkedList的优点和缺点

优点

  1. 链表的插入和删除操作比较快速,因为只需要改变指针指向即可。
  2. 可以在任意位置进行插入和删除操作,而不需要像数组那样需要移动其他元素。
  3. 在迭代时可以快速获取下一个元素,因为每个节点都有指向前后节点的指针。

缺点

  1. 链表的访问操作比较慢,因为需要遍历整个链表才能找到对应的元素。
  2. 由于链表每个节点都需要额外存储前后节点的指针,因此占用的内存空间比数组大。
  3. 链表没有像数组那样可以直接访问任意位置的元素,因此不能使用索引随机访问元素。
  4. 当需要频繁进行随机访问时,由于缺乏连续的内存空间,可能会导致缓存命中率降低,从而影响性能。

综上所述,LinkedList 适合在需要频繁进行插入和删除操作,但是不需要频繁随机访问元素的场景下使用。如果需要随机访问或者占用空间比较重要时,可以考虑使用其他数据结构,比如 ArrayList

二、 LinkedList数据结构分析

2.1 Node节点结构体解析

在Java中,LinkedList的实现是通过一个双向链表来实现的。在LinkedList中,每个节点都有一个指向前一个节点和一个指向后一个节点的引用。

在LinkedList中,Node节点结构体可以被定义如下

private static class Node<E> {
    E item;
    Node<E> next;
    Node<E> prev;

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

上面的代码定义了一个私有静态内部类Node,该类包含了一个泛型元素item,以及两个Node类型的引用nextprev,分别表示指向下一个节点和上一个节点的引用。此外,Node类还有一个带有三个参数的构造函数,用于初始化节点的prev、itemnext属性。

通过这样的设计,LinkedList可以通过Node节点来构建双向链表结构,实现了在任意位置进行节点的插入和删除操作。

2.2 LinkedList实现了双向链表的原因

Java中的LinkedList实现了双向链表是因为双向链表具有以下优点

  1. 双向链表可以从前向后或从后向前遍历,而单向链表只能从前往后遍历,这使得双向链表更加灵活和高效。
  2. 双向链表能够提供O(1)时间复杂度的前驱和后继节点访问操作,而单向链表则需要O(n)时间复杂度的遍历操作才能找到前驱节点。
  3. 双向链表相对于单向链表来说,其插入和删除节点的操作更加高效,因为只需要改变前后节点的指针指向即可,而单向链表需要先找到前驱节点再进行操作。

因此,双向链表在实现一些需要频繁插入和删除操作的场景下,比单向链表更加适用。Java中的LinkedList就是基于双向链表实现的,所以它能够高效地支持插入、删除等操作,同时也提供了迭代器和列表迭代器等许多方便的操作。

2.3 LinkedList如何实现了链表的基本操作(增删改查)

在Java中,LinkedList类实现了链表的基本操作,包括增加(添加)、删除、修改和查询等功能。下面是一些常用的方法:

  1. 添加操作:
    • addFirst(E e):在链表的开头添加元素。
    • addLast(E e):在链表的末尾添加元素。
    • add(int index, E element):在指定位置插入元素。
  2. 删除操作
    • removeFirst():删除链表的第一个元素。
    • removeLast():删除链表的最后一个元素。
    • remove(int index):删除指定位置的元素。
    • remove(Object o):删除指定元素。
  3. 修改操作
    • set(int index, E element):将指定位置的元素替换为新的元素。
  4. 查询操作
    • getFirst():返回链表的第一个元素。
    • getLast():返回链表的最后一个元素。
    • get(int index):返回指定位置的元素。
    • indexOf(Object o):返回指定元素在链表中的索引位置。
    • contains(Object o):判断链表是否包含指定元素。

LinkedList类还提供了其他一些方法用于获取链表的大小、清空链表、判断链表是否为空等。

2.4 LinkedList的遍历方式

在Java中,你可以使用以下几种方式对LinkedList进行遍历:

1.使用迭代器(Iterator)进行遍历

LinkedList<String> linkedList = new LinkedList<>();
// 假设已经向linkedList中添加了元素
Iterator<String> iterator = linkedList.iterator();
while (iterator.hasNext()) {
    String element = iterator.next();
    // 对element进行处理
}

2.使用增强型for循环(foreach)进行遍历

LinkedList<String> linkedList = new LinkedList<>();
// 假设已经向linkedList中添加了元素
for (String element : linkedList) {
    // 对element进行处理
}

这两种方式都可以用来遍历LinkedList中的元素,你可以根据实际情况选择其中一种来进行遍历操作。

三、 源码分析

3.1 成员变量

在这里插入图片描述

3.2 构造方法

/**
 * 默认构造函数,它不带任何参数,用来创建一个空的 LinkedList 对象
 */
public LinkedList() {
}

/**
 * 带有参数的构造函数,它接受一个类型为 Collection 的参数 c
 */
public LinkedList(Collection<? extends E> c) {
    // 调用无参构造函数
    this();
    // 将参数 c 中的所有元素添加到新创建的 LinkedList 对象中
    addAll(c);
}

/**
 * LinkedList 类中的 addAll 方法的定义
 */
public boolean addAll(Collection<? extends E> c) {
    // size表示为当前 LinkedList 中的元素数量
    // 调用另一个名为 addAll 的方法来将参数 c 中的所有元素添加到 LinkedList 对象中
    return addAll(size, c);
}

/**
 * 用于将指定集合中的元素插入到指定位置
 */
public boolean addAll(int index, Collection<? extends E> c) {
    // 检查索引的有效性,确保索引在范围内
    checkPositionIndex(index);

    // 将集合 c 转换为数组,并将其赋值给对象数组 a
    Object[] a = c.toArray();
    // 获取新添加元素的数量
    int numNew = a.length;
    // 如果新添加元素的数量为 0,则直接返回 false
    if (numNew == 0)
        return false;

    // 定义两个节点,pred 表示当前节点的前一个节点,succ 表示当前节点的后一个节点
    LinkedList.Node<E> pred, succ;
    // 根据插入位置确定 succ 和 pred 的值
    // 如果插入位置在末尾,则将 succ 设置为 null,将 pred 设置为最后一个节点
    if (index == size) {
        succ = null;
        pred = last;
    } else {
        // 否则,通过 node(index) 方法找到指定位置的节点,并将其设置为 succ,同时将其前一个节点设置为 pred
        succ = node(index);
        pred = succ.prev;
    }

    // 遍历数组 a 中的元素
    for (Object o : a) {
        @SuppressWarnings("unchecked") E e = (E) o;
        // 将每个元素转换为泛型类型 E,并创建一个新的节点 newNode,该节点的前一个节点为 pred,值为 e,后一个节点为 null
        LinkedList.Node<E> newNode = new LinkedList.Node<>(pred, e, null);
        // 如果 pred 为 null,则将 newNode 设置为链表的第一个节点
        if (pred == null)
            first = newNode;
        else
            // 否则,将 pred 的下一个节点设置为 newNode
            pred.next = newNode;
        // 将 pred 更新为 newNode
        pred = newNode;
    }

    // 根据 succ 是否为 null 来确定插入后的链表结构
    // 如果 succ 为 null,则表示插入位置在末尾,将 pred 设置为最后一个节点
    if (succ == null) {
        last = pred;
    } else {
        // 否则,将 pred 的下一个节点设置为 succ,并将 succ 的前一个节点设置为 pred
        pred.next = succ;
        succ.prev = pred;
    }

    // 更新链表的大小和修改计数器
    size += numNew;
    modCount++;
    // 返回 true,表示添加操作成功完成
    return true;
}

3.3 add()方法

/**
 * 用于向链表末尾添加一个元素 e
 */
public boolean add(E e) {
    // 将元素 e 添加到链表的末尾
    linkLast(e);
    // 添加操作成功
    return true;
}

/**
 * 向链表末尾添加一个元素的操作
 */
void linkLast(E e) {
    // 创建一个名为 l 的局部变量,用于保存当前链表的最后一个节点的引用,通过访问 last 字段获取最后一个节点的引用
    final LinkedList.Node<E> l = last;
    // 创建一个新的节点 newNode,并将其初始化为一个具有前驱节点为 l、元素为 e、后继节点为 null 的节点
    final LinkedList.Node<E> newNode = new LinkedList.Node<>(l, e, null);
    // 将链表的 last 指针指向新的节点 newNode,使其成为最后一个节点
    last = newNode;
    // 如果 l 为空(即链表为空),则将链表的 first 指针指向新的节点 newNode,否则将 l 节点的后继节点指向新的节点 newNode
    if (l == null)
        first = newNode;
    else
        l.next = newNode;
    // 增加链表的大小,将链表中元素的数量加1
    size++;
    // 增加修改计数器 modCount 的值,用于追踪链表结构的修改次数
    modCount++;
}

3.4 remove()方法

/**
 * 用于从 LinkedList 中删除指定的元素
 */
public boolean remove(Object o) {
    // 判断传入的参数 o 是否为 null
    // 如果 o 为 null,则说明要删除的元素是 null 值
    if (o == null) {
        // 因此需要遍历 LinkedList 中的所有节点并查找 item 属性为 null 的节点
        // 循环体中的变量 x 表示当前正在遍历的节点,初始化为 first(即头节点),在每次迭代后更新为下一个节点,直到遍历完整个 LinkedList
        for (LinkedList.Node<E> x = first; x != null; x = x.next) {
            // 如果当前节点的 item 属性为 null(在 o 为 null 的情况下),则调用辅助方法 unlink 删除该节点并返回 true
            if (x.item == null) {
                unlink(x);
                return true;
            }
        }
    } else {
        // 否则需要遍历所有节点并查找 item 属性等于 o 的节点
        for (LinkedList.Node<E> x = first; x != null; x = x.next) {
            // 如果当前节点的 item 属性等于 o(在 o 不为 null 的情况下),则调用辅助方法 unlink 删除该节点并返回 true
            if (o.equals(x.item)) {
                unlink(x);
                return true;
            }
        }
    }
    return false;
}

/**
 * 这是一个辅助方法,用于删除指定节点 x
 */
E unlink(LinkedList.Node<E> x) {
    // assert x != null;
    // 保存被删除节点的元素值
    final E element = x.item;
    // 存储被删除节点的前驱和后继节点
    final LinkedList.Node<E> next = x.next;
    final LinkedList.Node<E> prev = x.prev;

    // 如果被删除节点是头节点,则将头指针指向其后继节点
    if (prev == null) {
        first = next;
    } else {
        // 否则更新被删除节点的前驱节点的 next 属性为其后继节点,并将被删除节点的 prev 属性设为 null
        prev.next = next;
        x.prev = null;
    }

    // 如果被删除节点是尾节点,则将尾指针指向其前驱节点
    if (next == null) {
        last = prev;
    } else {
        // 否则更新被删除节点的后继节点的 prev 属性为其前驱节点,并将被删除节点的 next 属性设为 null
        next.prev = prev;
        x.next = null;
    }

    // 将被删除节点的 item 属性设为 null
    x.item = null;
    // 更新 LinkedList 的元素数量和修改次数
    size--;
    modCount++;
    // 返回被删除节点的元素值
    return element;
}

3.5 get()方法

/**
 * 用于获取指定索引处的元素
 */
public E get(int index) {
    // 检查索引的有效性,然后调用另一个私有辅助方法 node 来获取对应索引处的节点
    checkElementIndex(index);
    // 返回该节点的元素值
    return node(index).item;
}

/**
 * 检查指定索引是否在有效范围内
 */
private void checkElementIndex(int index) {
    // 调用 isElementIndex 方法来判断索引是否在有效范围内
    if (!isElementIndex(index))
        // 如果不在,则抛出 IndexOutOfBoundsException 异常,异常消息由 outOfBoundsMsg 方法返回
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}

/**
 * 判断指定索引是否在有效范围内
 */
private boolean isElementIndex(int index) {
    // 如果索引大于等于 0 并且小于 size,则返回 true;否则返回 false
    return index >= 0 && index < size;
}

3.6 set()方法

/**
 * 用于将指定索引(index)位置的元素替换为新的元素,并返回被替换掉的旧元素
 */
public E set(int index, E element) {
    // 用于将指定索引(index)位置的元素替换为新的元素,并返回被替换掉的旧元素
    checkElementIndex(index);
    // 获取指定索引位置上的节点
    LinkedList.Node<E> x = node(index);
    // 将指定索引位置上的节点的元素值赋给变量 oldVal,即记录旧元素的值
    E oldVal = x.item;
    // 将指定索引位置上的节点的元素值替换为新的元素值 element
    x.item = element;
    // 返回被替换掉的旧元素值
    return oldVal;
}

/**
 * 检查指定索引是否在有效范围内
 */
private void checkElementIndex(int index) {
    // 调用 isElementIndex 方法来判断索引是否在有效范围内
    if (!isElementIndex(index))
        // 如果不在,则抛出 IndexOutOfBoundsException 异常,异常消息由 outOfBoundsMsg 方法返回
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}

/**
 * 判断指定索引是否在有效范围内
 */
private boolean isElementIndex(int index) {
    // 如果索引大于等于 0 并且小于 size,则返回 true;否则返回 false
    return index >= 0 && index < size;
}

3.7 clear()方法

/**
 * 用于清空整个链表的内容,并释放相关的内存空间
 */
public void clear() {
    // Clearing all of the links between nodes is "unnecessary", but:
    // 解释了清除所有节点之间链接的操作虽然“不必要”,但有两个原因:
    // - helps a generational GC if the discarded nodes inhabit
    // 帮助分代垃圾回收(generational GC)
    //   more than one generation
    // - is sure to free memory even if there is a reachable Iterator
    // 和确保释放内存即使存在可达的迭代器
    // 循环体中的变量 x 初始化为头节点 first,然后在每次迭代中更新为下一个节点,直到遍历完整个链表
    for (LinkedList.Node<E> x = first; x != null; ) {
        // 用于保存当前节点 x 的后继节点的引用,以防止在清空当前节点之后丢失对后继节点的引用
        LinkedList.Node<E> next = x.next;
        // 将当前节点 x 的元素值、前驱节点和后继节点都设置为 null,从而切断当前节点与前后节点的关联
        x.item = null;
        x.next = null;
        x.prev = null;
        // 将 x 更新为下一个节点,以便进行下一次循环迭代
        x = next;
    }
    // 清空头节点和尾节点的引用,使整个链表为空
    first = last = null;
    // 将链表的大小设置为 0,表示链表中不再包含任何元素
    size = 0;
    // 更新修改次数计数器,用于在迭代过程中检测并发修改
    modCount++;
}

3.8 indexOf()方法

/**
 * 用于查找指定元素在链表中第一次出现的位置(索引)
 */
public int indexOf(Object o) {
    // 初始化索引变量为 0,表示从头节点开始查找
    int index = 0;
    // 如果要查找的元素 o 是 null,则执行下面的 for 循环,按顺序遍历链表,查找第一个元素值为 null 的节点
    if (o == null) {
        // 变量 x 初始化为头节点 first,然后在每次迭代中更新为下一个节点,直到遍历完整个链表
        for (LinkedList.Node<E> x = first; x != null; x = x.next) {
            // 判断当前节点 x 的元素值是否为 null,如果是,则说明找到了要查找的元素,返回当前索引 index 表示该元素在链表中的位置
            if (x.item == null)
                return index;
            // 如果当前节点不是要查找的元素,则将索引变量 index 加 1,继续向下查找
            index++;
        }
    } else {
        // 如果要查找的元素 o 不是 null,则执行下面的 for 循环,按顺序遍历链表,查找第一个元素值与 o 相等的节点
        for (LinkedList.Node<E> x = first; x != null; x = x.next) {
            // 判断当前节点 x 的元素值是否与要查找的元素 o 相等,如果是,则说明找到了要查找的元素,返回当前索引 index 表示该元素在链表中的位置
            if (o.equals(x.item))
                return index;
            // 如果当前节点不是要查找的元素,则将索引变量 index 加 1,继续向下查找
            index++;
        }
    }
    // 如果整个链表都遍历完了还没有找到要查找的元素,则返回 -1,表示该元素不存在于链表中
    return -1;
}

四、 总结及实战应用

4.1 LinkedList适用场景

LinkedList在Java中适用于以下场景

  1. 需要频繁进行插入和删除操作:由于LinkedList是基于链表结构实现的,插入和删除操作的时间复杂度为O(1),因此适合在需要频繁执行这些操作的场景下使用。
  2. 需要实现队列(Queue)或双端队列(Deque)功能:LinkedList实现了QueueDeque接口,可以作为队列或双端队列来使用。
  3. 不需要频繁进行随机访问:由于LinkedList对于随机访问的效率较低,如果不需要频繁通过索引来访问元素,而是更多地进行顺序访问或者在头尾进行操作,那么LinkedList比较适合。
  4. 对内存占用没有过高要求:相比于ArrayList,在一些情况下,由于它的存储结构,LinkedList可能会占用更多的内存空间。

需要注意的是,在某些特定的场景下,可能需要根据具体的需求进行性能测试和选择,以确定使用LinkedList是否能够带来性能上的提升。

4.2 LinkedList与ArrayList的比较

LinkedList和ArrayList是Java中两种常见的集合实现类,它们具有一些不同的特点和适用场景。

LinkedList的特点

  • 基于双向链表实现,每个节点都包含指向前一个节点和后一个节点的引用。
  • 高效地支持插入和删除操作,因为只需要改变前后节点的指针指向即可。
  • 在使用迭代器进行遍历时,效率较高。
  • 对于频繁的插入和删除操作,LinkedList通常比ArrayList更加高效。

ArrayList的特点

  • 基于动态数组实现,内部使用数组来存储元素。
  • 支持随机访问,通过索引可以快速访问元素。
  • 在获取元素和遍历操作方面,ArrayList相对更高效。
  • 对于需要频繁随机访问元素的操作,ArrayList通常比LinkedList更加高效。

综上所述,选择LinkedList还是ArrayList取决于具体的使用场景和需求

  • 如果需要频繁进行插入和删除操作,而对于随机访问的需求较少,则选择LinkedList更合适。
  • 如果需要频繁进行随机访问,插入和删除操作相对较少,则选择ArrayList更合适。

需要注意的是,在多线程环境下,LinkedList和ArrayList都不是线程安全的,如果需要在多线程环境下使用,需要进行适当的同步处理或使用线程安全的集合类。

4.3 LinkedList的使用注意事项

在使用 Java 中的 LinkedList 时,有一些需要注意的事项,包括但不限于以下几点:

  1. 插入和删除效率高:LinkedList 在插入和删除操作上有较高的效率,因为它基于双向链表实现。因此,在需要频繁进行插入和删除操作的场景下,可以考虑使用 LinkedList。
  2. 随机访问效率低:相比于 ArrayList,LinkedList 对于随机访问的效率较低,因为要通过指针一个个地找到目标位置。因此,在需要频繁进行随机访问的场景下,最好选择 ArrayList。
  3. 迭代器遍历高效:LinkedList 的迭代器遍历效率较高,可以高效地进行前向和后向遍历操作。
  4. 注意空间开销:由于 LinkedList 中每个节点都需要存储额外的指针信息,因此相比于 ArrayList,它在存储同样数量的元素时会占用更多的内存空间。
  5. 不是线程安全的:LinkedList 不是线程安全的,如果需要在多线程环境中使用,需要进行适当的同步处理或考虑使用线程安全的集合类。
  6. 谨慎使用大数据量:在处理大数据量的情况下,由于 LinkedList 涉及频繁的节点创建和指针操作,可能会导致性能下降,需要谨慎使用。

综上所述,使用 LinkedList 时需要根据具体的场景和需求进行权衡,特别是在涉及到插入、删除、遍历和空间占用等方面需要特别留意。

4.4 实战应用:设计一个基于LinkedList的LRU缓存算法

LRU(Least Recently Used)算法是一种缓存置换策略,它根据数据最近被访问的时间来决定哪些数据会被保留,哪些数据会被淘汰。在 Java 中,可以通过 LinkedList HashMap 来实现 LRU 缓存算法。

具体实现步骤如下

1.定义一个双向链表和一个哈希表,用于存储缓存数据和快速定位数据。

private class CacheNode {
  private String key;
  private Object value;
  private CacheNode prev;
  private CacheNode next;

  public CacheNode(String key, Object value) {
    this.key = key;
    this.value = value;
  }
}

private Map<String, CacheNode> cacheMap;
private CacheNode head;
private CacheNode tail;
private int capacity;

2.在构造函数中初始化双向链表和哈希表,并设置缓存容量。

public LRUCache(int capacity) {
  this.capacity = capacity;
  cacheMap = new HashMap<>(capacity);
  head = new CacheNode(null, null);
  tail = new CacheNode(null, null);
  head.next = tail;
  tail.prev = head;
}

3.实现 get 方法,每次获取数据时,将数据移到链表头部,并更新哈希表中的位置信息。

public Object get(String key) {
  CacheNode node = cacheMap.get(key);
  if (node == null) {
    return null;
  }
  // 将节点移动到链表头部
  moveToHead(node);
  return node.value;
}

private void moveToHead(CacheNode node) {
  // 先将节点从原有位置删除
  removeNode(node);
  // 将节点插入到链表头部
  insertNodeAtHead(node);
}

private void removeNode(CacheNode node) {
  node.prev.next = node.next;
  node.next.prev = node.prev;
}

private void insertNodeAtHead(CacheNode node) {
  node.next = head.next;
  node.next.prev = node;
  head.next = node;
  node.prev = head;
}

4.实现 put 方法,每次存储数据时,如果缓存已满,则淘汰链表尾部的数据,并在哈希表中删除相应的位置信息。

public void put(String key, Object value) {
  CacheNode node = cacheMap.get(key);
  if (node != null) {
    // 如果键已经存在,则更新值,并移到链表头部
    node.value = value;
    moveToHead(node);
  } else {
    // 如果键不存在,则插入新节点到链表头部,并添加位置信息到哈希表
    node = new CacheNode(key, value);
    cacheMap.put(key, node);
    insertNodeAtHead(node);
    if (cacheMap.size() > capacity) {
      // 如果缓存已满,则淘汰链表尾部的节点,并删除相应位置信息
      CacheNode removedNode = removeNodeAtTail();
      cacheMap.remove(removedNode.key);
    }
  }
}

private CacheNode removeNodeAtTail() {
  CacheNode removedNode = tail.prev;
  removeNode(removedNode);
  return removedNode;
}

这样,一个基于 LinkedList 的 LRU 缓存算法就实现了。

可以通过如下代码进行简单的测试

LRUCache cache = new LRUCache(2);
cache.put("A", 1);
cache.put("B", 2);
System.out.println(cache.get("A")); // 输出 1
cache.put("C", 3);
System.out.println(cache.get("B")); // 输出 null

盈若安好,便是晴天

评论 256
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

忆~遂愿

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值