第六章 Java容器类

Java 集合框架

Java 集合类继承关系

在这里插入图片描述

Java 集合类简介

Collection (Interface)

Collection是最基本的集合接口,存储对象元素集合。一个Collection代表一组Object(元素)。
适用Iterator迭代器遍历集合元素

Collection<Day> days = new ArrayList<Day>();
        for(int i =0;i<10;i++){
            Day day = new Day(i,i*60,i*3600);
            days.add(day);
        }
        //获取days集合的迭代器
        Iterator<Day> iterator = days.iterator();
        while(iterator.hasNext()){//判断是否有下一个元素
            Day next = iterator.next();//取出该元素
            //逐个遍历,取得元素后进行后续操作
            .....
        }
  • List (Interface)
    List是一个允许重复元素的指定索引、有序集合。集合中每个元素都有其对应的顺序索引。List集合允许使用重复元素,可以通过索引来访问指定位置的集合元素 。List集合默认按元素的添加顺序设置元素的索引,例如第一个添加的元素索引为0,第二个添加的元素索引为1…
    从List接口的方法来看,List接口增加了面向位置的操作,允许在指定位置上插入/访问元素。
List实现数据特点使用场景
ArrayList实现了List接口的动态大小的数组查找元素
LinkedSet实现了List接口的链表维护的序列容器(双向链表)插入/删除元素
  • Set (Interface)
    Set是一种不包含重复的元素的Collection,即任意的两个元素e1和e2都有e1.equals(e2)=false,Set最多有一个null元素。
    如果试图把两个相同的元素加入同一个Set集合中,则添加操作失败,add()方法返回false,且新元素不会被加入。
Set实现数据特点数据结构
HashSet无序的、无重复的数据集合基于HashMap(使用HashMap的key作为单个元素存储)
LinkedSet维护次序的HashSet基于LinkedHashMap
TreeSet保持元素大小次序的集合,元素需要实现Comparable接口基于TreeMap
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {... }
  1. 首先判断set集合中是否有与新添加数据hashcode值一致的元素
  2. 如果有hashcode相同的元素,再调用equals方法进一步判断(若集合中没有与新添加数据hashcode值一致的元素,则不调用equals方法)

因此,在Java运行时环境判断HashSet和HastMap中的两个对象相同或不同应该先判断hashcode是否相等,再判断是否equals。 只有两者均相同,才能保证对象的一致性。
为了保证HashSet中的对象不会出现重复值,在被存放元素的类中必须要重写hashCode()和equals()这两个方法。

重写规范

  1. 如果两个对象相同,那么他们的hashcode应该相等
  2. 如果两个对象不相同,他们的hashcode可能相同
  • Queue (Interface)
    Queue用户模拟队列这种数据结构,队列通常是指“先进先出”(FIFO,first-in-first-out)的容器。队列的头部是在队列中存放时间最长的元素,队列的尾部是保存在队列中存放时间最短的元素。新元素插入(offer)到队列的尾部,访问元素(poll)操作会返回队列头部的元素。通常,队列不允许随机访问队列中的元素。
Queue实现数据特点
Deque(Interface)扩展自Queue的双端队列,它支持在两端插入和删除元素,LinkedList实现Deque接口
PriorityQueue一个基于优先级堆的无界优先级队列。优先级队列的元素按照其自然顺序进行排序,或者根据构造队列时提供的 Comparator 进行排序

Map (Interface)

Map是图接口,存储键值对映射的容器类。Map提供key到value的映射。一个Map中不能包含相同的key,每个key只能映射一个value。

Map实现使用场景数据结构
HashMap哈希表存储键值对,key不重复,无序哈希散列表
LinkedHashMap可以记录插入顺序的HashMapHash表和链表的实现,并且依靠着双向链表保证了迭代顺序是插入的顺序
TreeMap具有元素排序功能的HashMap,即保持key的大小顺序红黑树(中序遍历迭代输出有序序列)
WeakHashMap弱键映射,映射之外无引用的键,可被垃圾回收哈希散列表
  • Map的遍历方式
    Map集合提供3种遍历访问方法,
  1. Set keySet() 获得所有key的集合然后通过key访问value
Set<String> keySet = map.keySet();	//先获取map集合的所有键的Set集合
Iterator<String> it = keySet.iterator();	//有了Set集合,就可以获取其迭代器。

while(it.hasNext()) {
       String key = it.next();					// 获取键值
       String value = map.get(key);		// 有了键可以通过map集合的get方法获取其对应的值。
}
  1. Collection values() 获得value的集合
Collection<String> collection = map.values();//返回值是 值的Collection集合
  1. Set< Map.Entry< K, V>> entrySet() 获得key-value键值对的集合
//通过entrySet()方法将map集合中的映射关系取出(这个关系就是Map.Entry类型)
Set<Map.Entry<String, String>> entrySet = map.entrySet();
//将关系集合entrySet进行迭代,存放到迭代器中                
Iterator<Map.Entry<String, String>> it = entrySet.iterator();

while(it.hasNext()) {
       Map.Entry<String, String> me = it.next();//获取Map.Entry关系对象me
       String key = me.getKey();//通过关系对象获取key
       String value = me.getValue();//通过关系对象获取value
}

Entry<K,V>为Map<K,V>的一个内部接口,其实每一个键值对都是一个Entry的实例关系对象,所以Map实际其实就是Entry的一个Collection。

HashMap

HashMap就是最基础最常用的一种Map,它无序,以散列表(数组+链表/红黑树)的方式进行存储,存储内容是键值对映射。是一种非同步的容器类,故它的线程不安全。

存储结构

  • 内部原理
    HashMap采用散列表(哈希表)存储。即由数组和单向链表共同完成,当链表长度超过8个时会转化为红黑树(实现查找时间复杂度O(logn))
    哈希表是通过哈希函数把特定的键值映射到表中一个位置来访问记录的数据结构,哈希函数用来维护键与值之间一一对应关系。
    它的特点是查询快,时间复杂度是O(1),插入和删除的操作比较慢,时间复杂度是O(n)
  • 内部实现
    内部包含了一个 Entry 类型的数组 table。
transient Entry[] table;

Entry 存储着键值对。它包含了四个字段,从 next 字段我们可以看出 Entry 是一个链表。即数组中的每个位置被当成一个桶,一个桶存放一个链表。HashMap 使用拉链法来解决冲突,同一个链表中存放哈希值和散列桶取模运算结果相同的 Entry。

static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;
    int hash;
}

在这里插入图片描述

工作原理

  1. 确认桶下标——计算hash & 取模
// 计算hash值
int hash = hash(key);
// 将 key 的 hash 值对桶个数取模:hash%capacity 得到下标
int i = indexFor(hash, table.length);
/* indexFor的实现:
   如果能保证 capacity 为 2 的 n 次方,那么就可以将这个操作转换为位运算。
   static int indexFor(int h, int length) {
      return h & (length-1);
   }
*/

HashMap中hash函数的实现:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

高16bit不变,低16bit和高16bit做了一个异或。
在这里插入图片描述
主要是从速度、功效、质量来考虑的,这么做可以在bucket的n比较小的时候,也能保证考虑到高低bit都参与到hash的计算中,同时不会有太大的开销。

哈希函数的设计:保证将关键字均匀地分配到每个桶中
(1)计算结果为int类型
(2)数组长度范围内(0~length-1)
(3)尽可能充分利用数组中每一个位置

  • 除留余数法:用一个特定的质数来除所给定的关键字,所得余数即为该关键字的哈希值
  1. 冲突处理——拉链法

(1)查找

  • 计算键值对所在的桶下标
  • 在链表上顺序查找,时间复杂度与链表长度成正比

(2)插入

  • 采用链表头插法,也就是新的键值对插在链表的头部
void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }

    createEntry(hash, key, value, bucketIndex);
}

void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    // 头插法,链表头部指向新的键值对
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}

哈希表冲突处理方法:

  • 开放定址法(线性探测再散列,二次探测再散列,伪随机探测再散列):发生哈希冲突,寻找另一个未被占用的数组地址;
  • 链地址法(HashMap):将新节点添加到对应哈希值所在链表链尾;
  • 再哈希法:提供多个哈希函数,直到不再产生冲突;
  • 建立公共溢出区:将哈希表分为基本表和溢出表,产生哈希冲突的节点放入移除表。

源码解析

  • put 解析
  1. 如果HashMap为空,则进行初始化;
  2. 调用哈希函数对Key求Hash值,然后再计算下标。并查找所在链表。
  3. 如果链表长度超过阈值(TREEIFY_THRESHOLD == 8),就把链表转换成红黑树。
  4. 如果结点的键已经存在就替换旧值。否则用头插法插入新结点。
  5. 如果桶满了(容量+加载因子),就需要resize(双倍扩容,保证2的n次幂)进行扩容,并且为了使结点均匀分散,应该重新分配结点位置
    if(++size>threshold)resize();
public V put(K key, V value) {
    if (table == EMPTY_TABLE) {
    // 1. HashMap 为空,则进行初始化
        inflateTable(threshold);
    }
    // 键为 null 单独处理
    if (key == null)
        return putForNullKey(value);
    // 2. 调用哈希函数对key求hash值,并取模得桶下标
    int hash = hash(key);
    int i = indexFor(hash, table.length);
    // 3. 先找出是否已经存在键为 key 的键值对
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        // 注:散列类容器唯一性判断
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
        // 如果存在的话就更新这个键值对的值为 value
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }

    modCount++;
    // 4. 插入新键值对(如果碰撞了则头插法,没有碰撞则直接插在)
    addEntry(hash, key, value, i);
    return null;
}

/*
HashMap 允许插入键为 null 的键值对。但是因为无法调用 null 的 hashCode() 方法,也就无法确定该键值对的桶下标,只能通过强制指定一个桶下标来存放。HashMap 使用第 0 个桶存放键为 null 的键值对。
private V putForNullKey(V value) {
    for (Entry<K,V> e = table[0]; e != null; e = e.next) {
        if (e.key == null) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    modCount++;
    addEntry(0, null, value, 0);
    return null;
}
*/
  • get 解析
  1. 调用哈希函数对Key求Hash值,然后再计算Entry数组的索引i。
  2. 遍历table[i]为头结点的链表/红黑树,如果发现有节点hash,key都相同的节点,则取出该节点的值。
public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}
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) {
        // bucket里的第一个节点,直接命中;
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // 未命中(有冲突),则查找对应的entry
        if ((e = first.next) != null) {
            // 若为树,则在树中查找对应Entry,O(logn)
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            // 若为链表,则在链表中查找对应Entry,O(n)
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}
  • resize 解析(扩容+重新分配)
    当put时,如果发现目前的bucket占用程度已经超过了Load Factor(负载因子)所希望的比例,那么就会发生resize。
  1. 将新结点加到链表后
  2. bucket容量扩充为原来的两倍,然后对每个结点重新计算哈希值
  3. 这个值只可能在两个地方,一个是原下标的位置,另一种是在下标为<原下标+原容量>的位置
// 需要扩容时,令 capacity 为原来的两倍
void addEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    if (size++ >= threshold)
        resize(2 * table.length);
}

// 扩容操作同样需要把 oldTable 的所有键值对重新插入 newTable 中(对每个结点重新计算hash值)
void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }
    Entry[] newTable = new Entry[newCapacity];
    transfer(newTable);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}

void transfer(Entry[] newTable) {
    Entry[] src = table;
    int newCapacity = newTable.length;
    for (int j = 0; j < src.length; j++) {
        Entry<K,V> e = src[j];
        if (e != null) {
            src[j] = null;
            do {
                Entry<K,V> next = e.next;
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            } while (e != null);
        }
    }
}

HashMap、HashSet、HashTable区别

HashTableHashMapHashSet
接口MapMapSet
存储类型键值对键值对元素(HashSet的集合其实就是HashMap的key的集合)
线程安全同步(synchronized)不同步不同步
是否可以插入NULL不可以可以可以

面试

  1. 什么时候会使用HashMap?他有什么特点?
    是基于Map接口的实现,存储键值对时,它可以接收null的键值,是非同步的,HashMap存储着Entry(hash, key, value, next)对象。
  2. 你知道HashMap的工作原理吗?
    通过hash的方法,通过put和get存储和获取对象。存储对象时,我们将K/V传给put方法时,它调用hashCode计算hash从而得到bucket位置,进一步存储,HashMap会根据当前bucket的占用情况自动调整容量(超过Load Facotr则resize为原来的2倍)。获取对象时,我们将K传给get,它调用hashCode计算hash从而得到bucket位置,并进一步调用equals()方法确定键值对。如果发生碰撞的时候,Hashmap通过链表将产生碰撞冲突的元素组织起来,在Java 8中,如果一个bucket中碰撞冲突的元素超过某个限制(默认是8),则使用红黑树来替换链表,从而提高速度。
  3. 你知道get和put的原理吗?equals()和hashCode()的都有什么作用?
    通过对key的hashCode()进行hashing,并计算下标( (n-1) & hash),从而获得buckets的位置。如果产生碰撞,则利用key.equals()方法去链表或树中去查找对应的节点
  4. 你知道hash的实现吗?为什么要这样实现?
    在Java 1.8的实现中,是通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在bucket的n比较小的时候,也能保证考虑到高低bit都参与到hash的计算中,同时不会有太大的开销。
  5. 如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?
    如果超过了负载因子(默认0.75),则会重新resize一个原来长度两倍的HashMap,并且重新调用hash方法。

List

ArrayList

以数组实现。节约空间,但数组有容量限制。超出限制时会增加50%容量,用System.arraycopy()复制到新的数组,因此最好能给出数组大小的预估值。默认第一次插入元素时创建大小为10的数组。
按数组下标访问元素—get(i)/set(i,e) 的性能很高,这是数组的基本优势。
直接在数组末尾加入元素—add(e)的性能也高,但如果按下标插入、删除元素—add(i,e), remove(i), remove(e),则要用System.arraycopy()来移动部分受影响的元素,性能就变差了,这是基本劣势。

  • ArrayList “动态数组” 扩容机制
    ArrayList是基于数组实现的,添加元素时若数组的容量不够,ArrayList会自动扩容:
  1. 添加元素前判断数组容量是否足够,若不够,则先扩容
  2. 每次扩容都是按原容量的1.5倍进行扩容(新数组容量 = 原数组容量*1.5 + 1)
  3. 原数组通过Arrays.copyOf()将原数据元素拷贝到心数组
public void ensureCapacity(int minCapacity) {  
    if (minCapacity > oldCapacity) {  // 判断原数组容量是否足够,若不够
        int newCapacity = oldCapacity + (oldCapacity >> 1);  // 新数组长度为原数组1.5倍扩容
     	elementData = Arrays.copyOf(elementData, newCapacity);  //将原数组拷贝一份到新数组
    }  
 }
  • add方法
// 在ArrayList中增加元素的时候,会使用add函数。他会将元素放到末尾。
public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // 自动扩容机制的核心
    elementData[size++] = e;
    return true;
}

当增加数据的时候,如果ArrayList的大小已经不满足需求时,那么就将数组变为原长度的1.5倍,之后的操作就是把老的数组拷到新的数组里面。

  • set/get方法
// 先做index检查,然后执行赋值或访问操作
public E set(int index, E element) {
    rangeCheck(index);
    E oldValue = elementData(index);
    elementData[index] = element;
    return oldValue;
}
public E get(int index) {
    rangeCheck(index);
    return elementData(index);
}
  • remove方法
public E remove(int index) {
    rangeCheck(index);
    modCount++;
    E oldValue = elementData(index);
    int numMoved = size - index - 1;
    if (numMoved > 0)
        // 把后面的往前移
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    // 把最后的置null
    elementData[--size] = null; // clear to let GC do its work
    return oldValue;
}

LinkedList

以双向链表(head、tail)实现。链表无容量限制,但双向链表本身使用了更多空间,也需要额外的链表指针操作。
按下标访问元素—get(i)/set(i,e) 要悲剧的遍历链表将指针移动到位(如果i>数组大小的一半,会从末尾移起)。
插入、删除元素时修改前后节点的指针即可,但还是要遍历部分链表的指针才能移动到下标所指的位置,只有在链表两头的操作—add(),addFirst(),removeLast()或用iterator()上的remove()能省掉指针的移动。

  • set/get()
public E set(int index, E element) {
    checkElementIndex(index);
    Node<E> x = node(index);
    E oldVal = x.item;
    x.item = element;
    return oldVal;
}
public E get(int index) {
    checkElementIndex(index);
    return node(index).item;
}

这两个函数都调用了node函数,该函数会以O(n/2)的性能去获取一个节点,具体实现如下所示:

Node<E> node(int index) {
    // assert isElementIndex(index);
    if (index < (size >> 1)) {
        Node<E> x = first;
        for (int i = 0; i < index; i++)
            x = x.next;
        return x;
    } else {
        Node<E> x = last;
        for (int i = size - 1; i > index; i--)
            x = x.prev;
        return x;
    }
}

就是判断index是在前半区间还是后半区间,如果在前半区间就从head搜索,而在后半区间就从tail搜索。而不是一直从头到尾的搜索。如此设计,将节点访问的复杂度由O(n)变为O(n/2)。

红黑树

  • 红黑树特点

二叉查找树(BST)特性:

  1. 左子树上节点值均小于等于根节点的值
  2. 右子树上节点值均大于等于根节点的值
  3. 左右子树均为二叉排序树

二叉查找树采用二分查找的思想,查找所需最大次数等同于二叉查找树的高度。
红黑树(Red Black Tree)是自平衡(防止高度过高)的二叉查找树,特性:

  1. 节点是红色或黑色
  2. 根节点是黑色
  3. 叶子节点是黑色的空节点
  4. 每个红色节点的2个子结点都是黑色
  5. 从任一节点到其每个叶子的所有路径都包含相同数目黑色节点

在这里插入图片描述

  • 红黑树操作
    红黑树和AVL树类似,都是在进行插入和删除操作时通过特定操作保持二叉查找树的平衡,从而获得较高的查找性能。它可以在O(log n)时间内做查找,插入和删除,这里的n 是树中元素的数目。
    由于插入新结点后可能会破坏红黑树的规则,此时需要进行调整,包括变色和旋转,旋转包括左旋和右旋。
  • 红黑树后继节点
    a. 空节点,没有后继
    b. 有右子树的节点,后继就是右子树的“最左节点”
    c. 无右子树的节点,后继就是该节点所在左子树的第一个祖先节点
    有右子树的节点,节点的下一个节点,肯定在右子树中,而右子树中“最左”的那个节点则是右子树中最小的一个,那么当然是右子树的“最左节点”,就好像下图所示:
    在这里插入图片描述
    无右子树的节点,先找到这个节点所在的左子树(右图),那么这个节点所在的左子树的父节点(绿色节点),就是下一个节点。
    在这里插入图片描述
static <K,V> TreeMap.Entry<K,V> successor(Entry<K,V> t) {
    if (t == null)
        return null;
    else if (t.right != null) {
        // 有右子树的节点,后继节点就是右子树的“最左节点”
        // 因为“最左子树”是右子树的最小节点
        Entry<K,V> p = t.right;
        while (p.left != null)
            p = p.left;
        return p;
    } else {
        // 如果右子树为空,则寻找当前节点所在左子树的第一个祖先节点
        // 因为左子树找完了,根据LDR该D了
        Entry<K,V> p = t.parent;
        Entry<K,V> ch = t;
        // 保证左子树
        while (p != null && ch == p.right) {
            ch = p;
            p = p.parent;
        }
        return p;
    }
}
  • 红黑树应用
    红黑树应用很多,其中JDK的集合类TreeMap和TreeSet底层使用红黑树实现,Java8中HashMap也是用红黑树实现。

参考:漫画算法:什么是红黑树

谈谈Java集合中那些线程安全的集合 & 实现原理?

  1. 同步集合类
    采用synchronized锁机制保证线程安全。
    包括Hashtable、Vector、同步集合包装类,Collections.synchronizedMap()和Collections.synchronizedList()
// HashTable 的 get、put方法
public synchronized V get(Object key) {...}
public synchronized V put(K key, V value) {...}
  1. 并发集合类
    同步集合比并发集合会慢得多,主要原因是使用一个全局的锁来同步不同线程间的并发访问。同一时间点,只能有一个线程持有锁,也就是说在同一时间点,只能有一个线程能访问容器。这虽然保证多线程间的安全并发访问,但同时也导致对容器的访问变成串行化的了。
  • ConcurrentHashMap
    ConcurrentHashMap通过使用分离锁,只针对某(键的hash值对应的)具体的Segment(而不是整个ConcurrentHashMap。因为插入键值对的操作只在这个Segment包含的桶中完成,因此不需要锁定整个ConcurrentHashMap。此时其他写进程对另外15个Segment加锁并不会因为对当前这个Segment加锁而阻塞。同时所有读线程几乎不会因本线程加锁而阻塞)进行加锁,同时允许多线程访问其他未上锁Segment。
    相比较于 HashTable 和由同步包装器包装的 HashMap每次只能有一个线程执行读或写操作,ConcurrentHashMap 在并发访问性能上有了质的提高。在理想状态下,ConcurrentHashMap 可以支持 16 个线程执行并发写操作(如果并发级别设置为 16),及任意数量线程的读操作。
  • CopyOnWrite容器(CopyOnWriteArrayList、CopyOnWriteHashSet)
    CopyOnWrite容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
    CopyOnWrite并发容器用于读多写少的并发场景。
  • 3
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

李一恩

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

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

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

打赏作者

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

抵扣说明:

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

余额充值