deque(双端队列)容器

deque 是 double-ended queue 的缩写,又称双端队列容器。

  • deque 容器擅长在序列尾部添加或删除元素(时间复杂度为O(1)),而不擅长在序列中间添加或删除元素。
  • deque 容器也可以根据需要修改自身的容量和大小。

和 vector 不同的是,deque 还擅长在序列头部添加或删除元素,所耗费的时间复杂度也为常数阶O(1)。并且更重要的一点是,deque 容器中存储元素并不能保证所有元素都存储到连续的内存空间中。

当需要向序列两端频繁的添加或删除元素时,应首选 deque 容器。

创建deque容器的几种方式

创建 deque 容器,根据不同的实际场景,可选择使用如下几种方式。

1) 创建一个没有任何元素的空 deque 容器:

std::deque<int> d;

和空 array 容器不同,空的 deque 容器在创建之后可以做添加或删除元素的操作,因此这种简单创建 deque 容器的方式比较常见。

2) 创建一个具有 n 个元素的 deque 容器,其中每个元素都采用对应类型的默认值:

std::deque<int> d(10);

此行代码创建一个具有 10 个元素(默认都为 0)的 deque 容器。

3) 创建一个具有 n 个元素的 deque 容器,并为每个元素都指定初始值,例如:

std::deque<int> d(10, 5)

如此就创建了一个包含 10 个元素(值都为 5)的 deque 容器。

4) 在已有 deque 容器的情况下,可以通过拷贝该容器创建一个新的 deque 容器,例如:

std::deque<int> d1(5);
std::deque<int> d2(d1);

注意,采用此方式,必须保证新旧容器存储的元素类型一致。

5) 通过拷贝其他类型容器中指定区域内的元素(也可以是普通数组),可以创建一个新容器,例如:

//拷贝普通数组,创建deque容器
int a[] = { 1,2,3,4,5 };
std::deque<int>d(a, a + 5);
//适用于所有类型的容器
std::array<int, 5>arr{ 11,12,13,14,15 };
std::deque<int>d(arr.begin()+2, arr.end());//拷贝arr容器中的{13,14,15}

deque容器可利用的成员函数

函数成员函数功能
begin()返回指向容器中第一个元素的迭代器。
end()返回指向容器最后一个元素所在位置后一个位置的迭代器,通常和 begin() 结合使用。
rbegin()返回指向最后一个元素的迭代器。
rend()返回指向第一个元素所在位置前一个位置的迭代器。
cbegin()和 begin() 功能相同,只不过在其基础上,增加了 const 属性,不能用于修改元素。
cend()和 end() 功能相同,只不过在其基础上,增加了 const 属性,不能用于修改元素。
crbegin()和 rbegin() 功能相同,只不过在其基础上,增加了 const 属性,不能用于修改元素。
crend()和 rend() 功能相同,只不过在其基础上,增加了 const 属性,不能用于修改元素。
size()返回实际元素个数。
max_size()返回容器所能容纳元素个数的最大值。这通常是一个很大的值,一般是 232-1,我们很少会用到这个函数。
resize()改变实际元素的个数。
empty()判断容器中是否有元素,若无元素,则返回 true;反之,返回 false。
shrink _to_fit()将内存减少到等于当前元素实际所使用的大小。
at()使用经过边界检查的索引访问元素。
front()返回第一个元素的引用。
back()返回最后一个元素的引用。
assign()用新元素替换原有内容。
push_back()在序列的尾部添加一个元素。
push_front()在序列的头部添加一个元素。
pop_back()移除容器尾部的元素。
pop_front()移除容器头部的元素。
insert()在指定的位置插入一个或多个元素。
erase()移除一个元素或一段元素。
clear()移出所有的元素,容器大小变为 0。
swap()交换两个容器的所有元素。
emplace()在指定的位置直接生成一个元素。
emplace_front()在容器头部生成一个元素。和 push_front() 的区别是,该函数直接在容器头部构造元素,省去了复制移动元素的过程。
emplace_back()在容器尾部生成一个元素。和 push_back() 的区别是,该函数直接在容器尾部构造元素,省去了复制移动元素的过程。

SGI-STL-2.9 deque的实现

首先来一张图

deque 和 vector 的最大差异一在于 deque 允许常数时间内对头端或尾端进行元素的插入或移除操作。 vector的开销太大,需要开辟新空间,拷贝。

deque 没有所谓的容量概念,因为它是动态地以分段连续空间组合而成随时可以增加一块新的空间并拼接起来。

虽然 deque 也提供 随机访问的迭代器,但它的迭代器和前面两种容器的都不一样,其设计相当复杂度和精妙,因此,会对各种运算产生一定影响,除非必要,尽可能的选择使用 vector 而非 deque。

deque 的中控器

deque 在逻辑上看起来是连续空间,内部是由一段一段的定量连续空间构成。 一旦有必要在 deque 的前端或尾端增加新空间,便配置一段定量的连续空间,串接在整个 deque 的头部或尾部。

设计 deque 的大师们,想必是让 deque 的最大挑战就是在这些分段的定量连续空间上,维护其整体连续的假象,并提供其随机存取的接口,从而避开了像 vector 那样的“重新配置-复制-释放”开销三部曲。这样一来,虽然开销降低,却提高了复杂的迭代器架构。

因此数据结构的设计和迭代器前进或后退等操作都非常复杂。

deque 采用一块所谓的 map (注意不是STL里面的map容器)作为中控器,其实就是一小块连续空间,其中的每个元素都是指针,指向另外一段较大的连续线性空间,称之为缓冲区。,在后面我们看到,缓冲区才是 deque 的储存空间主体。

deque类中存储的数据

template <class T, class Alloc = alloc, size_t BufSiz = 0> 
class deque {

protected:                      // Data members
  iterator start;         //迭代器指向deque的第一个元素
  iterator finish;        //迭代器指向deque最后一个元素的下一个节点

  map_pointer map;      //一个双重指针 一个存放指针的数组 数组里的每个元素指向一个存放元素的数组(就是一个缓冲区)
  size_type map_size;   //map的大小

  ...
}

迭代器中存储的数据

template <class T, class Ref, class Ptr, size_t BufSiz>
struct __deque_iterator {

  typedef random_access_iterator_tag iterator_category;
  typedef T value_type;
  typedef Ptr pointer;
  typedef Ref reference;
  typedef size_t size_type;
  typedef ptrdiff_t difference_type;
  typedef T** map_pointer;

  typedef __deque_iterator self;


  T* cur;             //指向当前节点
  T* first;           //指向当前数组(缓冲区)中的第一个位置
  T* last;            //指向当前数组最后一个元素的下一个地址
  map_pointer node;   //当前数组存储在map里的指针    这个减去deque类中的map就是第几个数组

}

 deque 的迭代器

deque 是分段连续空间,维持其“整体连续”假象的任务,就靠它的迭代器来实现,也就是 operator++ 和 operator-- 两个运算子上面。

在看源码之前,我们可以思考一下,如果让你来设计,你觉得 deque 的迭代器应该具备什么样的结构和功能呢?

首先第一点,我们能想到的是,既然是分段连续,迭代器应该能指出当前的连续空间在哪里;

其次,第二点因为缓冲区有边界,迭代器还应该要能判断,当前是否处于所在缓冲区的边缘,如果是,一旦前进或后退,就必须跳转到下一个或上一个缓冲区;

第三点,也就是实现前面两种情况的前提,迭代器必须能随时控制中控器。

有了这样的思想准备之后,我们再来看源码,就显得容易理解一些了。

template <class T, class Ref, class Ptr, size_t BufSiz>
struct __deque_iterator {
  //类型定义
  typedef __deque_iterator<T, T&, T*, BufSiz>             iterator;
  typedef __deque_iterator<T, const T&, const T*, BufSiz> const_iterator;
  //静态方法获取每个buffer数组的大小
  static size_t buffer_size() {return __deque_buf_size(BufSiz, sizeof(T)); }

  //萃取器类型定义
  typedef random_access_iterator_tag iterator_category;
  typedef T value_type;
  typedef Ptr pointer;
  typedef Ref reference;
  typedef size_t size_type;
  typedef ptrdiff_t difference_type;
  typedef T** map_pointer;

  typedef __deque_iterator self;

  T* cur;             //指向当前节点
  T* first;           //指向当前数组(缓冲区)中的第一个位置
  T* last;            //指向当前数组最后一个元素的下一个地址
  map_pointer node;   //当前数组存储在map里的指针    这个减去deque类中的map就是第几个数组

  //构造函数
  //需要传进来元素的当前地址,元素所存buffer数组在map中的地址
  __deque_iterator(T* x, map_pointer y) 
    : cur(x), first(*y), last(*y + buffer_size()), node(y) {}

  __deque_iterator() : cur(0), first(0), last(0), node(0) {}

  __deque_iterator(const iterator& x)
    : cur(x.cur), first(x.first), last(x.last), node(x.node) {}


...
}

//如果 n 不为0,则返回 n,表示缓冲区大小由用户自定义
//如果 n == 0,表示 缓冲区大小默认值
//如果 sz = (元素大小 sizeof(value_type)) 小于 512 则返回 521/sz
//如果 sz 不小于 512 则返回 1
inline size_t __deque_buf_size(size_t n, size_t sz)
{
  return n != 0 ? n : (sz < 512 ? size_t(512 / sz) : size_t(1));
}

那,为什么要这样设计呢?回到前面我们刚才说的,因为它是分段连续的空间,下图描绘了 deque 的中控器、缓冲区、迭代器之间的相互关系 :

看明白了吗,每一段都指向一个缓冲区 buffer,而缓冲区是需要知道每个元素的位置的,所以需要这些迭代器去访问。

其中 cur 表示当前所指的位置;

first 表示当前数组中头的位置;

last 表示当前数组中尾的位置。

这样就方便管理,需要注意的是 deque 的空间是由 map 管理的, 它是一个指向指针的指针, 所以三个参数都是指向当前的数组,但这样的数组可能有多个,只是每个数组都管理这3个变量。

那么,缓冲区大小是谁来决定的呢?这里呢,用来决定缓冲区大小的是一个全局函数:

inline size_t __deque_buf_size(size_t n, size_t sz) {
  return n != 0 ? n : (sz < 512 ? size_t(512 / sz): size_t(1));
}
//如果 n 不为0,则返回 n,表示缓冲区大小由用户自定义
//如果 n == 0,表示 缓冲区大小默认值
//如果 sz = (元素大小 sizeof(value_type)) 小于 512 则返回 521/sz
//如果 sz 不小于 512 则返回 1

假设我们现在构造了一个 int 类型的 deque,设置缓冲区大小等于 32,这样一来,每个缓冲区可以容纳 32/sizeof(int) = 4 个元素。经过一番操作之后,deque 现在有 20 个元素了,那么成员函数 begin() 和 end() 返回的两个迭代器应该是怎样的呢?如下图所示:

20 个元素需要 20/(sizeof(int)) = 3 个缓冲区。所以 map 运用了三个节点。迭代器 start 内的 cur 指针指向缓冲区的第一个元素,迭代器 finish 内的 cur 指针指向缓冲区的最后一个元素(的下一个位置)。

注意,最后一个缓冲区尚有备用空间,如果之后还有新元素插入,则直接插入到备用空间。

deque 迭代器的操作

operator++ 操作代表是需要切换到下一个元素,这里需要先切换再判断是否已经到达缓冲区的末尾。

 self& operator++() {
    //将当前指针++
    ++cur;
    //如果+1后当前指针,指向当前buffer数组最后一个元素的下一个地址,说明这个buffer数组放不下了
    if (cur == last) {
      //指向下一个buffer数组,并调整first,last,node指针
      set_node(node + 1);
      cur = first;
    }
    return *this; 
  }

  //后置加加
  self operator++(int)  {
    //将当前迭代器临时保存
    self tmp = *this;
    //调用前面的前置++
    ++*this;
    //返回++前的迭代器
    return tmp;
  }

  self& operator--() {
    //如果当前元素是当前数组的第一个元素,说明上一个元素在上一个buffer数组
    if (cur == first) {
      //指向上一个buffer数组,并调整first,last,node指针
      set_node(node - 1);
      //调整cur元素到上一个buffer数组的最后一个元素的下一个地址,后面会--到最后一个元素
      cur = last;
    }
    //将当前指针--
    --cur;
    return *this;
  }

  self operator--(int) {
    self tmp = *this;
    --*this;
    return tmp;
  }

  //迭代器往后跳指定个元素
  self& operator+=(difference_type n) {
    //(cur - first)计算当前元素是这个buffer数组的第几个元素 (cur - first)就是计算想要跳到的元素基于当前buffer数组的第一个元素的差值
    difference_type offset = n + (cur - first);
    //如果结果还在当前buffer数组内
    if (offset >= 0 && offset < difference_type(buffer_size()))
      //直接当前指针+n即可
      cur += n;
    else {
      difference_type node_offset =
        offset > 0 ? offset / difference_type(buffer_size()) //如果是往后加 需要加多少个buffer数组
                   : -difference_type((-offset - 1) / buffer_size()) - 1;//如果是往前减 需要减多少个buffer数组
      //指向结果的buffer数组,并调整first,last,node指针
      set_node(node + node_offset);
      //剩余需要偏移的个数
      cur = first + (offset - node_offset * difference_type(buffer_size()));
    }
    return *this;
  }

  self operator+(difference_type n) const {
    self tmp = *this;
    return tmp += n;
  }

  self& operator-=(difference_type n) { return *this += -n; }
 
  self operator-(difference_type n) const {
    self tmp = *this;
    return tmp -= n;
  }

 deque 的构造和析构函数

构造函数. 有多个重载函数, 接受大部分不同的参数类型. 基本上每一个构造函数都会调用create_map_and_nodes, 这就是构造函数的核心, 待会就来分析这个函数实现.

public:                         // Constructor, destructor.
  //构造函数
  deque()
    : start(), finish(), map(0), map_size(0)  //初始化成员数据
  {
    create_map_and_nodes(0);
  }

  deque(const deque& x)
    : start(), finish(), map(0), map_size(0)
  {
    create_map_and_nodes(x.size());
    __STL_TRY {
      uninitialized_copy(x.begin(), x.end(), start);
    }
    __STL_UNWIND(destroy_map_and_nodes());
  }

  // 接受 n:初始化大小, value:初始化的值
  deque(size_type n, const value_type& value)
    : start(), finish(), map(0), map_size(0)
  {
    fill_initialize(n, value);
  }

//将map数组和buffer数组的空间申请好
template <class T, class Alloc, size_t BufSize>
void deque<T, Alloc, BufSize>::create_map_and_nodes(size_type num_elements) {
  //需要节点数= (每个元素/每个缓冲区可容纳的元素个数+1)
  //如果刚好整除,多配一个节点
  size_type num_nodes = num_elements / buffer_size() + 1;

  //一个 map 要管理几个节点,最少 8 个,最多是需要节点数+2
  //buffer数组的个数
  map_size = max(initial_map_size(), num_nodes + 2);

  //申请map数组的空间
  map = map_allocator::allocate(map_size);

  // 从map的中间开始使用
  map_pointer nstart = map + (map_size - num_nodes) / 2;
  map_pointer nfinish = nstart + num_nodes - 1;
  
  //循环申请每个buffer数组的空间
  map_pointer cur;
  for (cur = nstart; cur <= nfinish; ++cur)
      *cur = allocate_node();


  //调整start,finish迭代器
  start.set_node(nstart);
  finish.set_node(nfinish);
  start.cur = start.first;
  finish.cur = finish.first + num_elements % buffer_size();
}

pointer allocate_node() 
{ 
    return data_allocator::allocate(buffer_size()); 
}

template <class T, class Alloc, size_t BufSize>
void deque<T, Alloc, BufSize>::fill_initialize(size_type n,
                                               const value_type& value) {
  //申请map数组和buffer数组的空间
  create_map_and_nodes(n);
  map_pointer cur;

    //循环填充每个buffer数组
    for (cur = start.node; cur < finish.node; ++cur)
      uninitialized_fill(*cur, *cur + buffer_size(), value);
    //填充最后一个buffer数组
    uninitialized_fill(finish.first, finish.cur, value);

}

注意了,看了源码之后才知道:deque 的 begin 和 end 不是一开始就是指向 map 中控器里开头和结尾的,而是指向所拥有的节点的最中央位置。

这样带来的好处是可以使得头尾两边扩充的可能性和一样大,换句话来说,因为 deque 是头尾插入都是 O(1), 所以 deque 在头和尾都留有空间方便头尾插入。

deque 的插入元素和删除元素

因为 deque 的是能够双向操作,所以其 push 和 pop 操作都类似于 list 都可以直接有对应的操作,需要注意的是 list 是链表,并不会涉及到界线的判断, 而deque 是由数组来存储的,就需要随时对界线进行判断。

template <class T, class Alloc = alloc, size_t BufSiz = 0> 
class deque {
    ...
public:                         // push_* and pop_*
  void push_back(const value_type& t) {
    //如果最后一个buffer数组的空间够用
    if (finish.cur != finish.last - 1) {
      //构造元素
      construct(finish.cur, t);
      //指针往后移
      ++finish.cur;
    }
    else//如果空间不够
      push_back_aux(t);
  }

  void push_front(const value_type& t) {
    //如果第一个buffer数组的空间够用
    if (start.cur != start.first) {
      //构造元素
      construct(start.cur - 1, t);
      //指针往前移
      --start.cur;
    }
    else
      push_front_aux(t);
  }

template <class T, class Alloc, size_t BufSize>
void deque<T, Alloc, BufSize>::push_back_aux(const value_type& t) {
  value_type t_copy = t;
  //重新调整map确保map足够用
  reserve_map_at_back();
  //新申请一个buffer数组
  *(finish.node + 1) = allocate_node();
  //保存值
  __STL_TRY {
    construct(finish.cur, t_copy);
    finish.set_node(finish.node + 1);
    finish.cur = finish.first;
  }
  __STL_UNWIND(deallocate_node(*(finish.node + 1)));
}

  // 如果 map 尾端的节点备用空间不足,符合条件就配置一个新的map(配置更大的,拷贝原来的,释放原来的)
  void reserve_map_at_back (size_type nodes_to_add = 1) {
    if (nodes_to_add + 1 > map_size - (finish.node - map))
      reallocate_map(nodes_to_add, false);
  }

//nodes_to_add 需要扩大几个buffer数组, add_at_front是否扩在前面
template <class T, class Alloc, size_t BufSize>
void deque<T, Alloc, BufSize>::reallocate_map(size_type nodes_to_add,
                                              bool add_at_front) {
  //现在的buffer数组个数
  size_type old_num_nodes = finish.node - start.node + 1;
  //目标的buffer数组个数
  size_type new_num_nodes = old_num_nodes + nodes_to_add;

  map_pointer new_nstart;
  //如果map中可以存放目标个数的2倍,那么map就不需要扩充了
  if (map_size > 2 * new_num_nodes) {
    //计算调整后map的开始位置地址
    new_nstart = map + (map_size - new_num_nodes) / 2 
                     + (add_at_front ? nodes_to_add : 0);
    //如果调整后的map开始位置在,现在开始位置的前面      
    if (new_nstart < start.node)
      copy(start.node, finish.node + 1, new_nstart); //往前拷贝就好了
    else
      copy_backward(start.node, finish.node + 1, new_nstart + old_num_nodes); //往后拷贝
  }
  else {
    //新map的大小
    size_type new_map_size = map_size + max(map_size, nodes_to_add) + 2;
    //申请新map的空间
    map_pointer new_map = map_allocator::allocate(new_map_size);
    //计算新map的起始地址
    new_nstart = new_map + (new_map_size - new_num_nodes) / 2
                         + (add_at_front ? nodes_to_add : 0);
    //将老数据拷贝到新map
    copy(start.node, finish.node + 1, new_nstart);
    //释放老map空间
    map_allocator::deallocate(map, map_size);

    map = new_map;
    map_size = new_map_size;
  }

  start.set_node(new_nstart);
  finish.set_node(new_nstart + old_num_nodes - 1);
}


template <class T, class Alloc, size_t BufSize>
void deque<T, Alloc, BufSize>::push_front_aux(const value_type& t) {
  value_type t_copy = t;
  //重置map,确保map够用
  reserve_map_at_front();
  *(start.node - 1) = allocate_node();
  __STL_TRY {
    start.set_node(start.node - 1);
    start.cur = start.last - 1;
    construct(start.cur, t_copy);
  }
#     ifdef __STL_USE_EXCEPTIONS
  catch(...) {
    start.set_node(start.node + 1);
    start.cur = start.first;
    deallocate_node(*(start.node - 1));
    throw;
  }
#     endif /* __STL_USE_EXCEPTIONS */
} 


  // 如果 map 前端的节点备用空间不足,符合条件就配置一个新的map(配置更大的,拷贝原来的,释放原来的)
  void reserve_map_at_front (size_type nodes_to_add = 1) {
    if (nodes_to_add > start.node - map)
      reallocate_map(nodes_to_add, true);
  }
    ...
};

pop 实现

template <class T, class Alloc = alloc, size_t BufSiz = 0> 
class deque {
    ...
public:                         // push_* and pop_*
   void pop_back() {
    //如果需要pop的元素就是finish迭代器指向的buffer数组中
    if (finish.cur != finish.first) {
      //调整指针
      --finish.cur;
      //析构元素
      destroy(finish.cur);
    }
    else
      pop_back_aux(); //如果在前一个buffer数组中
  }

template <class T, class Alloc, size_t BufSize>
void deque<T, Alloc, BufSize>:: pop_back_aux() {
  //释放finish迭代器指向buffer的数组空间
  deallocate_node(finish.first);
  //调整迭代器
  finish.set_node(finish.node - 1);
  finish.cur = finish.last - 1;
  //析构元素
  destroy(finish.cur);
}


template <class T, class Alloc, size_t BufSize>
void deque<T, Alloc, BufSize>::pop_front_aux() {
  //析构元素
  destroy(start.cur);
  //释放start迭代器指向buffer数组的空间
  deallocate_node(start.first);
  //调整迭代器
  start.set_node(start.node + 1);
  start.cur = start.first;
}    

  void pop_front() {
    //如果需要pop的元素不是start迭代器指向的buffer数组的最后一个
    if (start.cur != start.last - 1) {
      //析构元素
      destroy(start.cur);
      //调整指针
      ++start.cur;
    }
    else 
      pop_front_aux();
  }

 
}

删除操作

不知道还记得,最开始构造函数调用 create_map_and_nodes 函数,考虑到 deque 实现前后插入时间复杂度为O(1),保证了在前后留出了空间,所以 push 和 pop 都可以在前面的数组进行操作。

现在就来看 erase,因为 deque 是由数组构成,所以地址空间是连续的,删除也就像 vector一样,要移动所有的元素。

deque 为了保证效率尽可能的高,就判断删除的位置是中间偏后还是中间偏前来进行移动。

template <class T, class Alloc = alloc, size_t BufSiz = 0> 
class deque {
    ...
public:                         // Erase
  iterator erase(iterator pos) 
  {
    iterator next = pos;
    ++next;
    difference_type index = pos - start;
      // 删除的地方是中间偏前, 移动前面的元素
    if (index < (size() >> 1)) 
    {
      copy_backward(start, pos, next);
      pop_front();
    }
      // 删除的地方是中间偏后, 移动后面的元素
    else {
      copy(next, finish, pos);
      pop_back();
    }
    return start + index;
  }
 // 范围删除, 实际也是调用上面的erase函数.
  iterator erase(iterator first, iterator last);
  void clear(); 
    ...
};

最后讲一下 insert 函数

deque 源码的基本每一个insert 重载函数都会调用了 insert_auto 判断插入的位置离头还是尾比较近。

如果离头进:则先将头往前移动,调整将要移动的距离,用 copy 进行调整。

如果离尾近:则将尾往前移动,调整将要移动的距离,用 copy 进行调整。

注意 : push_back是先执行构造在移动 node, 而 push_front 是先移动 node 在进行构造. 实现的差异主要是finish是指向最后一个元素的后一个地址而first指向的就只第一个元素的地址. 下面 pop 也是一样的。

源码里还有一些其它的成员函数,限于篇幅,这里就不贴源码,简单的过一遍 还有一些函数: **reallocate_map:**判断中控器的容量是否够用,如果不够用,申请更大的空间,拷贝元素过去,修改 map 和 start,finish 的指向。

fill_initialize 函数::申请空间,对每个空间进行初始化,最后一个数组单独处理. 毕竟最后一个数组一般不是会全部填充满。

clear函数. 删除所有元素. 分两步执行: 首先从第二个数组开始到倒数第二个数组一次性全部删除,这样做是考虑到中间的数组肯定都是满的,前后两个数组就不一定是填充满的,最后删除前后两个数组的元素。

deque的swap操作也只是交换了start, finish, map, 并没有交换所有的元素.

resize函数. 重新将deque进行调整, 实现与list一样的.

析构函数: 分步释放内存.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值