STL—stack/queue/priority_queue_/deque

STL—stack和queue

之前我们学了string和vector和list,并且完成了它们的模拟实现,他们都是很重要的容器。

这次要学习的stack和queue不是容器,是——容器适配器

stack和queue

1. stack

1.1 stack的介绍

stack 的文档介绍

翻译:

  1. stack是一种容器适配器,专门用在具有后进先出操作的上下文环境中,其删除只能从容器的一端进行元素的插入与提取操作。

  2. stack是作为容器适配器被实现的,容器适配器即是对特定类封装作为其底层的容器,并提供一组特定的成员函数来访问其元素,将特定类作为其底层的,元素特定容器的尾部(即栈顶)被压入和弹出。

  3. stack的底层容器可以是任何标准的容器类模板或者一些其他特定的容器类,这些容器类应该支持以下操作:

  • empty:判空操作

  • back:获取尾部元素操作

  • push:栈顶插入元素操作

  • pop:栈顶弹出元素操作

  1. 标准容器vector、deque、list均符合这些需求,默认情况下,如果没有为stack指定特定的底层容器,默认情况下使用deque。

image-20240816171026504

1.2 stack的使用

image-20240816171157541

stack的使用还是比较简单的。接口本身也不多,知道基本接口的功能就行了。

下面这个代码实现了遍历一个stack

void test_stack1()
{
	stack<int> st;
	st.push(1);
	st.push(2);
	st.push(3);
	st.push(4);

	// stack是没有迭代器的
	while (!st.empty())
	{
		cout << st.top() << " ";
		st.pop();
	}
	cout << endl;

}

还可以通过做一些stack的oj题来熟悉其使用

最小栈

class MinStack {
public:
    MinStack()
    {
        // 这里不用实现也是没关系的,因为我们的两个成员变量是自定义类型,会调用其系统默认的构造函数。
    }

    void push(int val)
    {
        _st.push(val);
        // 要判断_min是否为空,并且该val是否是目前所存储数据的最小值
        if (_min.empty() || _min.top() >= val)
            _min.push(val); // 是的话就要插入到_min中
    }

    void pop()
    {
        int n = _st.top();
        _st.pop();
        // 判断弹出去的元素是否是最小值
        if (_min.top() == n)
            _min.pop();
    }

    int top()
    {
        return _st.top();
    }

    int getMin()
    {
        // 返回存储最小值的栈的栈顶元素
        return _min.top();
    }

private:
    stack<int> _st; // 这个用来存放数据
    stack<int> _min; // 这个用来存放最小值
};

栈的压入、弹出序列

class Solution {
public:

    bool IsPopOrder(vector<int>& vpush, vector<int>& vpop)
    {
        // 首先判断两个序列的元素个数是否相同
        if(vpush.size() != vpop.size())
            return false;

        int outi = 0; 
        int ini = 0;
        stack<int> st; // 用st来模拟入栈出栈的过程。

        // 判断vpop的弹出顺序是否合理
        while(outi < vpop.size())
        {
            // 只要st的栈顶元素,不是弹出顺序的第outi个,那就插入压入顺序的元素,然后再判断,循环。
            while(st.empty() || st.top() != vpop[outi])
            {
                if(ini < vpush.size())
                    st.push(vpush[ini++]);
                else // 如果ini已经大于等于vpush.size()了,说明弹出顺序不合法
                    return false;
            }
            // 走到这里说明栈顶元素和弹出顺序的第outi个元素相同,那就弹出
            st.pop();
            outi++;
        }

        // 走到这里就说明st已经按照vpop的弹出顺序清空了,那说明vpop合法
        return true;
    }

};

逆波兰表达式求值

class Solution {
public:
    int evalRPN(vector<string>& tokens) 
    {
        stack<int> st;
        // 先把后缀算术表达式(逆波兰表达式)进行拆分
        // 1.如果是操作符,那就去栈里的两个数字进行计算
        // 2.如果是数字就入栈
        for(auto& str : tokens)
        {
            // 操作符就取两个数字进行运算
            if(str == "+" || str == "-" || str == "/" || str == "*")
            {
                // 要注意取出来的第一个数字在中缀表达式中是放在右边的.
                int right = st.top();
                st.pop();
                int left = st.top();
                st.pop();

                // 根据操作符,来进行相应的计算
                switch(str[0]) // switch无法处理自定义类型。
                {
                    case '+':
                        st.push(left + right);
                        break;
                    case '-':
                        st.push(left - right);
                        break;
                    case '*':
                        st.push(left * right);
                        break;
                    case '/':
                        st.push(left / right);
                        break;
                }
            }
            else
            {
                // stoi是c++11支持的接口。可以将string类转化为int类
                st.push(stoi(str));
            }
        }
        
    // 最后栈里剩下的最后一个数字就是计算之后的结果
    return st.top();
    }
};

用栈实现队列

class MyQueue {
public:
    MyQueue() 
    {
        // stack类型可以调用系统默认的构造函数
    }
    
    void push(int x) 
    {
        // 插入数据,插入到_stpush中
        _stpush.push(x);
    }
    
    int pop() 
    {
        // pop要将队头的元素移除并返回
        // 首先要判断_stpop是否为空,空的就要将_stpush的数据弹出,插入_stpop
        if(_stpop.empty())
        {
            // 这个while循环和范围for都可以
            while(!_stpush.empty())
            {
                _stpop.push(_stpush.top());
                _stpush.pop();
            }
        }
        int ret = _stpop.top();
        _stpop.pop();

        return ret;
    }
    
    int peek() 
    {
        // 首先要判断_stpop是否为空,空的就要将_stpush的数据弹出,插入_stpop
        if(_stpop.empty())
        {
            // 这个while循环和范围for都可以
            while(!_stpush.empty())
            {
                _stpop.push(_stpush.top());
                _stpush.pop();
            }
        }

        return _stpop.top();
    }
    
    bool empty() 
    {
        // 两个栈都为空,该队列才为空
        return _stpush.empty() && _stpop.empty();
    }
private:
    // 用两个栈去实现队列
    // 由于栈是先进后出,队列是先进先出
    stack<int> _stpush; // 这个栈用于插入·数据
    stack<int> _stpop; // 这个栈用于出数据
};

1.3stack的模拟实现

前面我们说了stack是一个容器适配器,我们可以借助其他容器作为实现stack的底层。

不论是list还是vector都可以实现stack。因为stack是一个特殊的vector和list。

借助模版我们就可以借助其他容器。

namespace wzf
{
	// 栈是容器适配器,可以借助vector或者list作为底层来实现
	template<class T, class container>
	class stack
	{
	public:

		void pop()
		{
			_con.pop_back();
		}

		T& top()
		{
			return _con.back();
		}

		void push(const T& x)
		{
			_con.push_back(x);
		}

		size_t size()
		{
			return _con.size();
		}

		bool empty()
		{
			return _con.empty();
		}
	private:
		container _con;
	};
}

测试代码:

	void test1()
	{
		stack<int, vector<int>> st;		
        //stack<int, list<int>> st; // list容器作为底层也可以
		st.push(1);
		st.push(2);
		st.push(3);
		st.push(4);

		while (!st.empty())
		{
			cout << st.top() << " ";
			st.pop();
		}
		cout << endl;
	}

这里这个测试我们传的是vector容器,传list也可以。

image-20240825165249634

2.queue

2.1queue的介绍

queue 的文档介绍

  1. 队列是一种容器适配器,专门用于在FIFO上下文(先进先出)中操作,其中从容器一端插入元素,另一端提取元素。

  2. 队列作为容器适配器实现,容器适配器即将特定容器类封装作为其底层容器类,queue提供一组特定的成员函数来访问其元素。元素从队尾入队列,从队头出队列。

  3. 底层容器可以是标准容器类模板之一,也可以是其他专门设计的容器类。该底层容器应至少支持以下操作:

  • empty:检测队列是否为空

  • size:返回队列中有效元素的个数

  • front:返回队头元素的引用

  • back:返回队尾元素的引用

  • push_back:在队列尾部入队列

  • pop_front:在队列头部出队列

  1. 标准容器类deque和list满足了这些要求。默认情况下,如果没有为queue实例化指定容器类,则使用标准容器deque。

2.2queue的使用

image-20240826154743614

OJ题目:

用队列实现栈

2.3queue的模拟实现

queue和stack一样属于容器适配器,但是vector并不太能作为实现queue的底层容器,因为vector的头删和头插效率比较低,但是queue又需要存在头删这个接口,因此最好用list来做queue的底层。

代码如下:

namespace wzf
{
	template<class T, class container>
	class queue
	{
	public:

		queue()
		{}

		void push(const T& x)
		{
			_con.push_back(x);
		}

		void pop()
		{
			_con.pop_front();
		}

		size_t size()
		{
			return _con.size();
		}

		T& front()
		{
			return _con.front();
		}

		T& back()
		{
			return _con.back();
		}

		bool empty()
		{
			return _con.empty();
		}


	private:
		container _con;
	};
}

测试代码:

	void test1()
	{
		queue<int, list<int>> q;
        //queue<int, deque<int>> q; //deque也可以
		q.push(1);
		q.push(2);
		q.push(3);
		q.push(4);

		while (!q.empty())
		{
			cout << q.front() << " ";
			q.pop();
		}
		cout << endl;
	}

3.priority_queue

3.1priority_queue的介绍

优先级队列文档查阅

  1. 优先队列是一种容器适配器,根据严格的弱排序标准,它的第一个元素总是它所包含的元素中最大的。

  2. 此上下文类似于堆,在堆中可以随时插入元素,并且只能检索最大堆元素(优先队列中位于顶部的元素)。

  3. 优先队列被实现为容器适配器,容器适配器即将特定容器类封装作为其底层容器类,queue提供一组特定的成员函数来访问其元素。元素从特定容器的“尾部”弹出,其称为优先队列的顶部。

  4. 底层容器可以是任何标准容器类模板,也可以是其他特定设计的容器类。容器应该可以通过随机访问迭代器访问,并支持以下操作:

  • empty:检测队列是否为空
  • size:返回队列中有效元素的个数
  • front:返回容器中第一个元素的引用
  • push_back:在容器尾部插入元素
  • pop_front:删除容器尾部元素
  1. 标准容器类vector和deque满足这些需求。默认情况下,如果没有为特定的priority_queue类实例化指定容器类,则使用vector。
  2. 需要支持随机访问迭代器,以便始终在内部保持堆结构。容器适配器通过在需要时自动调用算法函数make_heap、push_heap和pop_heap来自动完成此操作

3.2priority_queue的使用

image-20240829173615604

优先级队列的使用也不难,我们来看一段代码就行了:

void test_priority_queue1()
{
	// priority也是一个容器适配器.不支持迭代器
	priority_queue<int> pq; // 默认大的优先级高
	pq.push(1);
	pq.push(3);
	pq.push(9);
	pq.push(5);
	// 这些数据插入的时候会根据优先级排序,最大的会在队头。类似堆,其实优先级队列的底层就是一个堆

	while (!pq.empty())
	{
		cout << pq.top() << " ";
		pq.pop();
	}
	cout << endl;
}

并且优先级队列的默认优先级是大的优先,那我们如果想修改它的优先级也是可以做到的。

void test_priority_queue2()
{
	// 如果我们想改变其优先级,让优先级变成小的优先,我们要怎么做呢?
	priority_queue<int, vector<int>, greater<int> > pq; // greater<int> 让优先级变成小的优先了
	pq.push(1);
	pq.push(8);
	pq.push(5);
	pq.push(2);

	while (!pq.empty())
	{
		cout << pq.top() << " ";
		pq.pop();
	}
	cout << endl;
}

这个代码会更加直观:

void TestPriorityQueue()
{
 	// 默认情况下,创建的是大堆,其底层按照小于号比较
 	vector<int> v{3,2,7,6,0,4,1,9,8,5};
 	priority_queue<int> q1;
 	for (auto& e : v)
 		q1.push(e);
 	cout << q1.top() << endl;
 	// 如果要创建小堆,将第三个模板参数换成greater比较方式
 	priority_queue<int, vector<int>, greater<int>> q2(v.begin(), 	 v.end());
 	cout << q2.top() << endl;
}

3.3oj题目应用

数组中的第K个最大元素

class Solution {
public:
    int findKthLargest(vector<int>& nums, int k)
    {
        // 优先级队列解决
        // 时间复杂度是 O(N * logN)
        // 空间复杂度是 O(N)
        /*
        priority_queue<int> pq(nums.begin(), nums.end());

        for(int i = 0; i < k - 1; ++i)
        {
            pq.pop();
        }

        return pq.top();
        */

        /*
        // 快排 【sort底层就是快排】
        // 时间复杂度是 O(N * logN)
        // 空间复杂度是 O(1)
        sort(nums.begin(), nums.end());

        return nums[nums.size() - k];
        */

        // TopK问题解法 【找大建小堆,找小建大堆】【适合解决海量数据的问题】
        //时间复杂度是 O(N * logk)
        // 空间复杂度 O(1)
        priority_queue<int, vector<int>, greater<int>> smallhead;
        // 先往小堆里面放k个元素,保持k个元素的小堆存在、
        for (int i = 0; i < k; i++)
        {
            smallhead.push(nums[i]);
        }
        // 让剩下的数据去和堆顶做比较,只要比堆顶大,就入堆
        for (int i = k; i < nums.size(); i++)
        {
            if (nums[i] > smallhead.top())
            {
                smallhead.pop(); // 把堆顶弹出
                smallhead.push(nums[i]);
            }
        }

        // 此时的小堆,存放着k个最大元素,堆顶的元素恰好是第k大的数。
        return smallhead.top();

    }
};

TOPK解法使用于数据量很大的时候,当N趋向很大的时候,logk可以忽略不计,时间复杂度就是O(N)。

3.4priority_queue的模拟实现

优先级队列priority_queue的模拟实现。

优先级队列的底层数据结构其实就是一个堆(大堆小堆都有可能,根据需求进行选择)

priority_queue的模拟实现的代码

模拟实现过程中有几个要注意的点:

  1. 首先要熟悉堆的性质和结构

有点遗忘的话要及时复习二叉树专题

  1. 其次要熟悉堆向上调整算法堆向下调整算法
		// 堆向上调整算法
		void AdjustUp(int child)
		{
			// 从孩子的位置向上调整。
			while (child > 0)
			{
				int parent = (child - 1) / 2;
				// 判断此时父亲是否小于孩子(大堆),大于孩子(小堆)
				if (compare()(_con[parent], _con[child])) // // compare()的意思是调用默认构造函数,创建一个compare结构体的对象
				{
					swap(_con[parent], _con[child]);
					child = parent;
					parent = (child - 1) / 2;
				 }
				else
				{
					// 走到这里就说明虽然没有向上调整到底,但是此时的结构已经符合堆的结构了。不在需要调整了
					return; // break也可以
				}
			}
		
		}

		// 堆向下调整算法
		void AdjustDown(int parent)
		{
			// 这个插入的时候要符合堆的性质(优先级队列默认是大堆)
			int child = parent * 2 + 1; //默认最大的孩子是左孩子(大堆),默认最小的孩子是左孩子(小堆)

			while (child < _con.size())
			{
				// 选出左右孩子更大的那个(大堆),要注意右孩子是否越界
				if (child + 1 < _con.size() && compare()(_con[child], _con[child + 1]))
					child += 1;

				// 跟父亲节点去比较
				if (compare()(_con[parent], _con[child])) // compare()的意思是调用默认构造函数,创建一个compare结构体的对象
				{
					swap(_con[parent], _con[child]);

					// 更新父节点和孩子节点的位置
					parent = child;
					child = 2 * parent + 1;
				}
				else
				{
					// 走到这里说明符合堆的结构,不在需要向下调整了。
					return; // break也可以
				}

			}
		}
  1. 优先级队列插入数据之前,已经是个堆了,插入数据之后对新插入的数据用堆向上调整算法
void push(const T& x)
{
	_con.push_back(x);
	// 插入x这个数据之前,原本就是一个堆,因此用堆向上调整算法
	AdjustUp(size() - 1);
}
  1. 优先级队列pop的时候要注意不能直接头删,如果底层容器是顺序表的话,直接头删的效率就低了,可以先让其头尾交换,再尾删,再对交换上来的头用堆向下调整算法
	void pop() 
	{
		// 容器已经空了,就不能再删除了
		assert(!_con.empty());

		// 如果底层容器是顺序表的话,直接头删的效率就低了
		// 可以先让其头尾交换,再尾删,再对头用堆向下调整算法
		//swap(_con[0], _con[size() - 1]);
		swap(_con.front(), _con.back()); // 和上面那句等价
		_con.pop_back();
		AdjustDown(0);
	}
  1. 要理解仿函数的出现(用起来和函数一样,实际上并不是函数)。
// 仿函数 (用起来和函数一样,实际上并不是函数)
template<class T>
struct less
{
	bool operator() (const T& left, const T& right)
	{
		return left < right;
	}
};

// 仿函数
template<class T>
struct greater
{
	bool operator() (const T& left, const T& right)
	{
		return left > right;
	}
};

仿函数其实就是结构体或者类里面重载了一个(),导致用起来和函数一样。

image-20240902004245685

具体思路如下图所示:

image-20240902175458084

4.容器适配器

4.1什么是适配器

适配器是一种设计模式该种模式是将一个类的接口转换成客户希望的另外一个接口

4.2STL库中的stack和queue

前面我们模拟实现的stack和queue,都存在说要传一个容器作为模版参数的情况,但是实际上我们使用库里的stack和queue,并不存在说要传一个容器才能运行的现象,我们只需要传这个T,即数据的类型。

这是因为——库里的模版参数有缺省值

image-20240827161051745

image-20240827161100769

image-20240827162933149

我们发现缺省值有deque<T>,我们就要学习deque<T>是什么

4.3deque的介绍

deque是一个双端队列,他可以在队头和队尾都进行插入和删除的操作,并且时间复杂度都是O(1),并且它还能支持随机访问。

vector比较,头插效率高,不需要搬移元素;与list比较,空间利用率比较高

image-20240827234858830

deque的使用代码:

void test_deque1()
{
	//deque是双端队列,队列两段都可以进行插入和删除的操作。并且时间复杂度都是O(1),并且支持随机访问。
	deque<int> d;
	d.push_back(1);
	d.push_back(2);
	d.push_back(3);
	d.push_back(5);
	d.push_front(0);

	for (int i = 0; i < d.size(); ++i)
	{
		cout << d[i] << " ";
	}
	cout << endl;

}

deque看似结合了vector和list的优点,但是并不意味着它就是完美的

4.4deque的缺陷

deque的效率其实并没有那么理想,因为deque的随机访问其实是虚假的,是通过迭代器去实现的。这个可以通过了解deque的原理去理解。

我们可以来看一段代码来看vector和deque之间的随机访问的效率差距

在这段代码中,我们会让deque和vector的对象分别插入10w个数据,并且将其排序。我们来看看他们分别用了多长的时间。

void test_deque2()
{
	// deque看似非常完美将vector和list的优点结合到了一起,但是deque的缺点就是效率不够优秀
	// 下面我们就让deque和vector两个容器进行排序,看看效率的差距
	vector<int> v;
	deque<int> d;
	const int n = 100000;
	srand(time(0)); // 给个种子才是随机值

	for (int i = 0; i < n; ++i)
	{
		// 给v和d两个容器插入同样的10w个随机值
		int x = rand();
		v.push_back(x);
		d.push_back(x);
	}
	
	// 对两个容器的10w个数据进行排序
	size_t begin1 = clock();
	sort(v.begin(), v.end());
	size_t end1 = clock();

	size_t begin2 = clock();
	sort(d.begin(), d.end());
	size_t end2 = clock();

	cout << end1 - begin1 << endl; // 25
	cout << end2 - begin2 << endl; // 111
	// 可以看到deque和vector的效率大概 差了4倍之多
}

我们可以看到deque的随机访问效率比起vector低了不少。

4.5为什么栈和队列用deque作为默认的底层容器

为什么库里的stack和queue都用的是deque这个容器来作为底层呢

因为——stack和queue都存在头或者尾的插入和删除数据,而deque的双端插入删除数据的效率是O(1),并且stack和queue是容器适配器,不存在随机访问的使用情况,这样deque的缺陷就被避开了。因此deque可以完美适配stack和queue。

4.6deque的原理

我们说deque不仅支持两端的插入和删除数据,时间复杂度是O(1),并且他还支持随机访问。

这是否意味着deque和vector一样是类似数组一样的连续的空间呢?但是如果是连续的空间,挪动和删除数据必然需要耗费较大的代价

因此deque并不是真正的连续的空间,是由一段段连续的小空间组成的。

实际deque类似于一个动态的二维数组

deque是通过一个个数组buffer来存储数据的,每个数组buffer都可以存储着数据,这样deque就不会有增容的操作,因为可以直接多开一个buffer数组来存储数据

image-20240828162325322

如果是头插,也是往上面多开一个数组,并且从尾部开始插入数据。每个数组存储的元素数量是固定的,不够了直接多开一个数组。

但是这样似乎和list<vector>有点相似,但是deque还可以支持随机访问,list<vector>并不行,那deque是如何做到的呢?

答案是——中控映射

deque有一个中控数组,这个数组是一个指针数组,用来管理每个小数组之间的关系,里面存放着每个buffer数组的地址。

这样我们通过这个指针数组,就可以知道每个buffer之间的关系了。

image-20240828162718613

要注意。这个中控数组是从数组的中间位置开始记载第一个buffer数组的地址的,因为头插开辟的数组是在第一个buffer的前面的,这样每个小数组之间的关系才是正确的。

但是这样就会造成一个问题,随着buffer数组数量的增多。中控数组的容量会不足,这个时候中控数组就会进行增容操作,因此deque并不是不进行增容操作,还是有增容操作的,但是只对一个指针数组进行增容操作的代价要比对buffer数组进行增容的代价要小的多

**那operator[i]是如何实现的呢?**既然我们有了中控数组的存在,就可以计算第i个数据在那个buffer数组中,因此当数据量变得很大的时候,效率就会变得很低。

deque的底层结构如下图所示:

image-20240828163146013

**deque底层是一段假象的连续空间,实际是分段连续的,为了维护其“整体连续”以及随机访问的假象,这个任务落在了deque的迭代器身上,**因此deque的迭代器设计就比较复杂,如下图所示:

image-20240828165101388

cur是真正遍历的指针,first指向当前buffer数组的头,last指向当前buffer数组的最后一个数据的下一个位置,node指向当前buffer的地址,即中控数组的某一个元素的地址。

下面的图可以很好解释deque的迭代器是如何工作的。

一共有三个buffer数组存储数据。

image-20240828165249809

4.7对STL中的stack和queue的模拟实现

STL中的stack和queue和我们之前的模拟实现的stack和queue的最大区别就是——

传容器的模版参数是否是deque<T>作为缺省值

4.7.1stack
#pragma once
# include<iostream>
# include<deque>
using namespace std;

namespace wzf
{
	template<class T, class container = deque<T>>
	class stack
	{
	public:
		void push(const T& x)
		{
			_con.push_back(x);
		}

		void pop()
		{
			_con.pop_back();
		}

		T& top()
		{
			return _con.back();
		}

		bool empty()
		{
			return _con.empty();
		}

		size_t size()
		{
			return _con.size();
		}

	private:
		container _con;
	};

测试代码:

	void test_stack1()
	{
		stack<int> st;
        // stack<int, vector<int>> st;
        // stack<int, list<int>> st;
		st.push(1);
		st.push(2);
		st.push(3);
		st.push(4);

		while (!st.empty())
		{
			cout << st.top() << " ";
			st.pop();
		}
		cout << endl;
	}

4.7.2queue
namespace wzf
{
	template<class T, class container = deque<T>>
	class queue
	{
	public:
		void push(const T& x)
		{
			_con.push_back(x);
		}

		void pop()
		{
			_con.pop_front();
		}

		bool empty()
		{
			return _con.empty();
		}

		size_t size()
		{
			return _con.size();
		}

		T& front()
		{
			return _con.front();
		}

		T& back()
		{
			return _con.back();
		}

	private:
		container _con;
	};
}

测试代码:

void test_queue()
{
	queue<int> q;
    queue<int, list<int>> q;
	q.push(1);
	q.push(2);
	q.push(3);
	q.push(4);

	while (!q.empty())
	{
		cout << q.front() << " ";
		q.pop();
	}
	cout << endl;
}

5.总结

学到这里,目前STL中的6大组件,我已经学了5个了。

  • **容器:**string/vector/list/deque…
  • 适配器: stack/queue/priority_queue…
  • **迭代器:**iterator/const_iterator || reverse_iterator/const_reverse_iterator
  • **算法:**find/sort…
  • **仿函数:**less/greater…

还剩下空间配置器等着后面学。如果对着前面五个板块的学习内容有些许遗忘,可以看之前的博客进行复习

并且随着对c++的深入学习。还要学习一些更加复杂的序列式容器:如红黑树,哈希表等等。

  • 29
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值