【数据结构】二叉搜索树/搜索二叉树(C++实现)

目录

一、二叉搜索树的概念

二、二叉搜索树的实现

2.1 节点的定义及构造

2.2 树的结构及功能展示

2.3 树的 Insert

2.4 树的中序遍历

2.4 树的 Find

2.5 树的 Erase

2.6 拷贝构造、赋值运算符重载、析构函数

三、递归实现树的增删查 

3.1 递归实现 FindR

3.2 递归实现 InsertR

3.3 递归实现 EraseR

四、二叉树搜索树的应用

4.1 key 模型

4.2 key-value 模型

五、二叉树搜索树的性能分析

六、二叉搜索树(key、key-value)代码


一、二叉搜索树的概念

二叉搜索树又称二叉排序树,它或者是一棵空树 ,或者是具有以下性质的二叉树:
  • 若它的左子树不为空,则左子树上所有的节点的值都小于根节点的值。
  • 若它的右子树不为空,则右子树上所有的节点的值都大于根节点的值。
  • 它的左右子树也分别为二叉搜索树。

二、二叉搜索树的实现

2.1 节点的定义及构造

创建树之前必然要先定义好节点,跟之前普通链式二叉树没有什么区别。

注意:

①我们会不断的通过 key 创建树节点, new 出的对象会调用带参的构造函数。所以,我们定义好节点中的成员变量后还要书写好构造函数。

②因为树节点会频繁访问成员变量,所以我们要将其置为公有成员(public),如果觉得麻烦,可以直接使用 struct 定义节点。

template <class K>
class BSTreeNode
{	
public:
	BSTreeNode<K>* _left;
	BSTreeNode<K>* _right;
	K _key;

	//用 key 构造一个树节点
	BSTreeNode(const K& key)
		:_left(nullptr)
		,_right(nullptr)
		,_key(key)
	{}
};

2.2 树的结构及功能展示

接下来看看 BSTree 类中的我们要实现的成员函数及变量定义。

template <class K>
	class BSTree
	{
		typedef BSTreeNode<K> Node;

	private:
		Node* _root = nullptr;

	public:
        //默认成员函数
        BSTree ();
        BSTree (const K& key);
        BSTree& operator=(BSTree Tree);
        ~BSTree();
        
		bool Insert(const K& key);
		void Inorder();
		bool find(const K& key);
		bool Erase(const K& key);
        
		//递归实现   
		bool FindR(const K& key);
		bool InsertR(const K& key);
		bool EraseR(const K& key);
    }

2.3 树的 Insert

首先,我们实现树的插入。我们要明确,这个插入要符合二叉搜索树的特性,即左子树的值小于根节点的值,右节点的值都大于根节点的值。

共分为以下几种情况和步骤:

  1. 传入空树直接 new 一个节点,将其置为 root 。
  2. 找到 key 值该在的位置,如果 key 大于 当前节点的 _key,则往右走,小于则往左走。
  3. 如果 key 等于当前节点的 _key,直接返回 false。
  4. 直到 cur 走到空,此时 cur 指向的便是 key 应当存放的地方。
  5. 创建一个节点并链接到树中(链接到 parent 节点的左或右)
	bool Insert(const K& key)
		{
			//如果当前树为空
			if (_root == nullptr)
			{
				_root = new Node(key);
				return true;
			}
			Node* parent = nullptr;
			Node* cur = _root;
			//直到 cur 指向 nullptr
			while (cur)
			{
				//cur->_key 小于 key 走右子树
				if (cur->_key < key)
				{
					parent = cur;
					cur = cur->_right;
				}
				//cur->_key 小于  走左子树
				else if (cur->_key > key)
				{
					parent = cur;
					cur = cur->_left;
				}
				//cur->_key == key 不允许插入
				else
				{
					return false;
				}
			}
			//此时cur处于正确的位置。
			cur = new Node(key);
			//判断 key 应该在 parent 的左边还是右边
			if (parent->_key > key)
				parent->_left = cur;
			else
				parent->_right = cur;
			return true;
		}

2.4 树的中序遍历

插入完成后,接下来我们就测试一下我们的代码。

因为搜索树的规律为,左子树<根节点<右子树。所以说我们只要先打印左子树,在打印根节点,最后打印右子树,就可以按顺序输出树中存放的 key 值。

而这个顺序正好对应我们二叉树中的中序遍历。接下来我们就来实现一个中序遍历吧。

类中的递归函数并不容易被调用。如果我们直接使用 root 作为函数的 Insert 的参数,就不得不将_root 变为 公有成员

代码检测:

2.4 树的 Find

find 和 Insert 核心代码完全相同。

    bool Find(const K& key)
		{
			Node* cur = _root;
			while (cur)
			{
				if (cur->_key > key)
				{
					cur = cur->_left;
				}
				else if (cur->_key<key)
				{
					cur = cur->_right;
				}
				else
				{
					return true;
				}
			}
			return false;
		}

效果检测:

2.5 树的 Erase

Erase 删除分为以下三种情况:

  1. 删除节点为叶子节点
  2. 删除节点有一个子节点
  3. 删除节点有两个子节点

情况一和情况二非常好解决,其本质都属于左或右节点为空,当该节点只有一个孩子或无孩子时,直接让 parent 指向该节点子节点,然后将此节点移除出树。 

我们先来解决前两种情况。其中有这几个点需要我们注意:

1.删除的是根节点

如果parent指向的是nullptr,则直接让_root后移动即可。

2. 链接时,应该链接到父亲的左还是右。

如果parent的左边是待删节点,即parent->left==cur,则将cur的右边链接到parent的左边

如果parent的右边是待删节点,即parent->right==cur,将cur的右边链接到parent的右边

代码如下(情况1、2的解决方法):

bool _Erase(const K& key)
{
	Node* parent = nullptr;
	Node* cur = _root;
	//找key
	while (cur)
	{
		if (cur->_key > key)
		{
			parent = cur;
			cur = cur->_left;
		}
		else if (cur->_key < key)
		{
			parent = cur;
			cur = cur->_right;
		}
		//找到存放 key的节点
		else
		{
			//key的左子树为空 所以父节点链接右子树
			if (cur->_left == nullptr)
			{
				//如果删除的是根节点,此时父节点指向为nullptr
				if (parent == nullptr)
				{
					//直接让_root指向下一个节点
					_root = _root->_right;
				}
				else
				{
					//判断应该链接到父节点的左还是右
					if (cur == parent->_left)
						parent->_left = cur->_right;
					else
						parent->_right = cur->_right;
				}
				delete cur;
			}
			//key的右子树为空 所以父节点链接左子树
			else if (cur->_right == nullptr)
			{
				//删除的为根节点的情况
				if (parent == nullptr)
				{
					_root = _root->_left;
				}
				else
				{
					//判断应当链接到父节点的左还是右
					if (cur == parent->_left)
					{
						parent->_left = cur->_left;
					}
					else
					{
						parent->_right = cur->_left;
					}
				}
				delete cur;
			}
			//删除节点左、右都不为空
			else
			{
				
			}
			return true;
		}
	}
	return false;
}

情况三采用的是替换法删除,我们观察下图,删除 3 的情况。 

为什么会这样呢?我们结合搜索树的性质和中序遍历,中序遍历中打印根节点的上一个节点是左子树的最右节点,根节点的下一个节点是右子树的最左节点,这两个节点的值于根节点的值最相近,所以,这两个节点是替换根节点的最好节点,替换后能不破坏搜索树的结构。 

我们把 3 看作根为一棵搜索树

  • 左子树的最大节点——左子树的最右节点,即 2
  • 右子树的最小节点——右子树的最左节点,即 4

所以这里我们找右子树的最小节点进行替换。

此时我们开始编写代码,不考虑一些特殊情况。

//删除节点左、右都不为空
	else
    {
		Node* min = cur->_right;
		while (cur->_right == nullptr)
		{
			min = min->_left;
		}
		swap(cur->_key, cur->_key);
		delete cur;
	}

 好的,接下来我们分析上面的代码会造成什么问题。

1. 此时 6 节点的_left 仍然指向原来的 4 节点,出现野指针的问题。并且如果 4 节点还有右子树呢?如图:

2. 无法删除根节点(8)

解决问题1,我们的方法是仍要记录下min的父节点,让父节点指向 min->right,此时无论min->right有子树还是min->right==nullptr,都可以很好的解决该问题,代码如下:

解决问题2:

删除8节点,此时 min 指向了cur->right,min ->left 为空,没有进入循环,导致minparent 为空指针,指向 minparent->_left = min->right;出现非法访问。

所以我们要将minparent初始化为cur。如果删除8节点,min节点往下找右子树的最左节点,再让 parent 指向右子树的最左节点的右子树,此时就会破坏树的结构,如图:

 所以,我们还是要判断,如果 min 在 minparent 的左子树,就改变minparent的左子树;如果 min在minparent的右子树,就改变minparent的右子树。

 

 好的,这两个棘手的问题我们就顺利解决了,我们来看看整体的 Erase 代码。

//删除
bool _Erase(const K& key)
{
	Node* parent = nullptr;
	Node* cur = _root;
	//找key
	while (cur)
	{
		if (cur->_key > key)
		{
			parent = cur;
			cur = cur->_left;
		}
		else if (cur->_key < key)
		{
			parent = cur;
			cur = cur->_right;
		}
		//找到存放 key的节点
		else
		{
			//key的左子树为空 所以父节点链接右子树
			if (cur->_left == nullptr)
			{
				//如果删除的是根节点,此时父节点指向为nullptr
				if (parent == nullptr)
				{
					//直接让_root指向下一个节点
					_root = _root->_right;
				}
				else
				{
					//判断应该链接到父节点的左还是右
					if (cur == parent->_left)
						parent->_left = cur->_right;
					else
						parent->_right = cur->_right;
				}
				delete cur;
			}
			//key的右子树为空 所以父节点链接左子树
			else if (cur->_right == nullptr)
			{
				//删除的为根节点的情况
				if (parent == nullptr)
				{
					_root = _root->_left;
				}
				else
				{
					//判断应当链接到父节点的左还是右
					if (cur == parent->_left)
					{
						parent->_left = cur->_left;
					}
					else
					{
						parent->_right = cur->_left;
					}
				}
				delete cur;
			}
			//删除节点左、右都不为空
			else
			{
				//指向cur,防止非法访问
				Node* minparent = cur;
				Node* min = cur->_right;
				while (min->_left != nullptr)
				{
					minparent = min;
					min = min->_left;
				}
				swap(cur->_key, min->_key);
				//解决野指针或min->right有子树的情况
				//minparent->_left = min->_right;
				//判断min在minparent的左或右
				if (minparent->_left == min)
					minparent->_left = min->_right;
				else
					minparent->_right = min->_right;
				delete min;
			}
			return true;
		}
	}
	return false;
}

好的,我们来测试一下代码。

2.6 拷贝构造、赋值运算符重载、析构函数

析构函数

与普通的二叉树Destory代码几乎一样。

构造与拷贝构造函数函数

拷贝构造函数就是根据前序构造出一棵树,如下:

注意,如果写了默认拷贝构造函数编译器就不会默认生成构造函数了,所以这里我们也要提供一个默认构造函数或者强制编译器为我们默认生成一个构造函数。如下:

 

赋值运算符重载

因为我们已经实现了拷贝构造函数,所以我们可以套用拷贝构造函数来实现赋值运算符重载。

 测试:

三、递归实现树的增删查 

3.1 递归实现 FindR

实现递归版本的 Find ,总共分4步:

  1. 如果 root 指向为 nullptr ,说明未找到 key 值
  2. 如果 key 大于 root->key,说明 key 在 root 的右边,使用root->right继续递归。
  3. 如果 key 小于 root->key,说明 key 在 root 的左边,使用root->left继续递归。
  4. 最后就是 key==root->key 的情况,返回 true 。

代码如下:

//Find的递归版本
bool FindR(const K& key)
{
	return _FindR(_root, key);
}

bool _FindR(const Node* root, const K& key)
{
	if (root == nullptr)
		return false;
	if (root->_key < key)
	{
		_FindR(root->_right, key);
	}
	else if (root->_key > key)
	{
		_FindR(root->_left, key);
	}
	else
	{
		return true;
	}
}

3.2 递归实现 InsertR

在实现了FindR之后,实现出InsertR应该不难,代码大致如下:


bool InsertR(const K& key)
{
	return _InsertR(_root, key)
}

bool _InsertR(const Node* root, const K& key)
{
	if (root == nullptr)
	{
		root = new Node(key);
		return true;
	}
	if (root->_key < key)
	{
		return _InsertR(root->_right, key);
	}
	else if (root->_key > key)
	{
		return _InsertR(root->_left, key);
	}
	else
	{
		//已存在 key 不允许插入
		return false;
	}
}

可是,发现一个问题,这样的写法是无法修改外部实参的,即无法链入搜索树中,所以我们要采用引用传参或二级指针传参,这样才能实质修改外部的变量。

root == nullptr 就将 key 链入树中,此时 root 为最后一个节点左或右子树的别名

 

3.3 递归实现 EraseR

递归删除的主题逻辑与上面大致相同。

步骤如下:

1.root == nullptr,则返回false,即未找到 key,删除失败

2.如果root->_key 小于 key,递归走右子树,

3.如果root->_key 大于 key,递归走左子树

4.最后就是 root->key == key,则开始删除节点。

bool _EraseR(Node* root, const K& key)
{
	if (root == nullptr)
	{
		return false;
	}
	if (root->_key < key)
	{
		return _EraseR(root->_right, key);
	}
	else if (root->_key > key)
	{
		return _EraseR(root->_left, key);
	}
	//找到key,删除
	else
	{
	}
}

当已经找到key值,进行删除时

1.如果左子树为空,则让root指向其右子树。如图:

 2.如果右子树为空,则让root指向左子树,如图:

3.当左子树、右子树都不为空时,采用替换法删除,交换 key 值,然后删除被替换的节点。

 交换过后,我们要删除 key 节点此时要使用root->right再次调用 _EraseR,如果直接使用  _EraseR(key),则会删除失败,因为树的结构已经被破坏。

代码如下:

bool EraseR(const K& key)
{
	return _EraseR(root, key);
}
bool _EraseR(Node*& root, const K& key)
{
	if (root == nullptr)
	{
		return false;
	}
	if (root->_key < key)
	{
		return _EraseR(root->_right, key);
	}
	else if (root->_key > key)
	{
		return _EraseR(root->_left, key);
	}
	//找到key,删除
	else
	{
        //情况1、2要记录待删除的节点。
		Node* del = root;
		if (root->_left == nullptr)
		{
			root = root->_right;
		}
		else if (root->_right == nullptr)
		{
			root = root->_left;
		}
		//左右都有孩子
		else
		{
			Node* min = root->_right;
			while (min->_left)
			{
				min = min->_left;
			}
			swap(root->_key, min->_key);
			//这里不能直接调用erase,交换后,树的结构已经破坏,显示找不到key值
			//return EraseR(key); 
			return _EraseR(root->_right, key);
		}
		delete del;
		return true;
	}
}

四、二叉树搜索树的应用

1.K模型:K模型即只有 key 作为关键码,结构中只需要存储 key 即可,关键码即为需要搜索到的值。

比如: 给一个单词 word,判断该单词是否拼写正确,具体方法如下:

  • 以词库中所有单词集合中的每个单词作为key,构造一棵二叉搜索树
  • 在二叉搜索树中检索该单词是否存在,存在则拼写正确,不存在则拼写错误。

2.KV模型:每一个关键码 key ,都有与之对应的值 value,即<key,value>的键值对。该种方法式在现实生活中非常常见:

  • 比如英汉词典就是英文与中文的对应关系,通过英文可以快速找到与其对应的中文,英文单词与其对应的中文<word,chinese>就构成一种键值对;
  • 再比如统计单词次数,统计成功后,给定单词就可快速找到其出现的次数,单词与其出现的次数就是<world,count>,就够成一种键值对。

4.1 key 模型

现在我们将二叉搜索树的K模型套入实际案例中,顺便练习编程能力。

将要拼写的单词插入到二叉搜索树中,用户开始拼写单词,如果用户输入的单词在词库中并拼写正确,则输出"拼写正确",否则输出"拼写错误"。代码如下:

4.2 key-value 模型

现在我们将二叉搜索树的 key-value 模型套入实际案例中,顺便练习编程能力。

我们现在创建一个字典,用户输入英文,程序打印出中文。

首先我们要创建 key-value 模型,然后将值插入,通过查找key,然后输出其value值。注意,如果是k-value模型,find的返回值就应为节点的指针。

我们看看代码是如何书写的,并且尝试运行一下:

 通过key-value模型我们还可以实现统计次数的程序。

例如我们往树中插入字符串,如果该字符串已存在,则++该字符串的计数。最后使用 Inorder 打印树中的元素,注意,要将Inorder中的输出语句带上value进行输出噢~

代码及结果如下:

五、二叉树搜索树的性能分析

在实现完功能之后,我们来对二叉搜索树进行性能分析。

问:搜索二叉树增删查的时间复杂度为多少?

答:最坏的情况下为 O(h)  —— h为高度。

为什么不是lgN,而是O(h) ,我们来看看如果树的形状是以下这几种情况的呢?

对有 n 个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二叉搜索树的深度函数,即结点越深,比较次数越多。

但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:

最优情况下:二叉搜索树为完全二叉树(或接近完全二叉树),其平均比较次数为 lgN.

最差情况下:二叉搜索树退化为单支树(或者类似单支),其平均比较次数为 N/2. 

所以说,二叉搜索树的效率在这种情况下跟O(N)几乎没有区别。其本质是不平衡的,所以后面我们要学习AVL树和红黑树来保持平衡。这样,搜索的效率就会极高。

六、二叉搜索树(key、key-value)代码

Key模型。

//节点的定义
template <class K>
class BSTreeNode
{
public:
	BSTreeNode<K>* _left;
	BSTreeNode<K>* _right;
	K _key;

	BSTreeNode(const K& key)
		:_left(nullptr)
		, _right(nullptr)
		, _key(key)
	{}

};

template <class K>
class BSTree
{
	typedef BSTreeNode<K> Node;
public:
	//C++11用法,作用:强制编译器生成默认的构造
	BSTree() = default;


	bool Insert(const K& key)
	{
		if (_root == nullptr)
		{
			_root = new Node(key);
			return true;
		}
		Node* parent = nullptr;
		Node* cur = _root;
		while (cur)
		{
			if (cur->_key < key)
			{
				parent = cur;
				cur = cur->_right;
			}
			else if (cur->_key > key)
			{
				parent = cur;
				cur = cur->_left;
			}
			else
			{
				return false;
			}
		}
		cur = new Node(key);
		if (parent->_key > key)
			parent->_left = cur;
		else
			parent->_right = cur;
		return true;
	}

	void Inorder()
	{
		_Inorder(_root);
		cout << endl;
	}

	bool Find(const K& key)
	{
		Node* cur = _root;
		while (cur)
		{
			if (cur->_key > key)
			{
				cur = cur->_left;
			}
			else if (cur->_key < key)
			{
				cur = cur->_right;
			}
			else
			{
				return true;
			}
		}
		return false;
	}

	bool _Erase(const K& key)
	{
		Node* parent = nullptr;
		Node* cur = _root;
		while (cur)
		{
			if (cur->_key > key)
			{
				parent = cur;
				cur = cur->_left;
			}
			else if (cur->_key < key)
			{
				parent = cur;
				cur = cur->_right;
			}
			else
			{
				if (cur->_left == nullptr)
				{
					if (parent == nullptr)
					{
						_root = _root->_right;
					}
					else
					{
						if (cur == parent->_left)
							parent->_left = cur->_right;
						else
							parent->_right = cur->_right;
					}
					delete cur;
				}
				else if (cur->_right == nullptr)
				{
					if (parent == nullptr)
					{
						_root = _root->_left;
					}
					else
					{
						if (cur == parent->_left)
						{
							parent->_left = cur->_left;
						}
						else
						{
							parent->_right = cur->_left;
						}
					}
					delete cur;
				}
				else
				{
					Node* minparent = cur;
					Node* min = cur->_right;
					while (min->_left != nullptr)
					{
						minparent = min;
						min = min->_left;
					}
					swap(cur->_key, min->_key);
					if (minparent->_left == min)
						minparent->_left = min->_right;
					else
						minparent->_right = min->_right;
					delete min;
				}
				return true;
			}
		}
		return false;
	}
	bool FindR(const K& key)
	{
		return _FindR(_root, key);
	}

	bool InsertR(const K& key)
	{
		return _InsertR(_root, key);
	}

	bool EraseR(const K& key)
	{
		return _EraseR(_root, key);
	}
	~BSTree()
	{
		_Destory(_root);
	}

	BSTree(const BSTree<K>& t)
	{
		_root = _Copy(t._root);
	}

	BSTree<K>& operator=(BSTree<K> t)
	{
		swap(t._root, _root);
		return *this;
	}


private:
	Node* _Copy(Node* root)
	{
		if (root == nullptr)
		{
			return nullptr;
		}
		Node* copyRoot = new Node(root->_key);
		copyRoot->_left = _Copy(root->_left);
		copyRoot->_right = _Copy(root->_right);
		return copyRoot;
	}

	void _Destory(Node* root)
	{
		if (root)
		{
			_Destory(root->_left);
			_Destory(root->_right);
			delete root;
		}
	}
	bool _EraseR(Node*& root, const K& key)
	{
		if (root == nullptr)
		{
			return false;
		}
		if (root->_key < key)
		{
			return _EraseR(root->_right, key);
		}
		else if (root->_key > key)
		{
			return _EraseR(root->_left, key);
		}
		//找到key,删除
		else
		{
			Node* del = root;
			if (root->_left == nullptr)
			{
				root = root->_right;
			}
			else if (root->_right == nullptr)
			{
				root = root->_left;
			}
			//左右都有孩子
			else
			{
				Node* min = root->_right;
				while (min->_left)
				{
					min = min->_left;
				}
				swap(root->_key, min->_key);
				//这里不能直接调用erase,交换后,树的结构已经破坏,显示找不到key值
				return _EraseR(root->_right, key);
			}
			delete del;
			return true;
		}
	}


	//传引用
	bool _InsertR(Node*& root, const K& key)
	{
		if (root == nullptr)
		{
			root = new Node(key);
			return true;
		}
		if (root->_key < key)
		{
			return _InsertR(root->_right, key);
		}
		else if (root->_key > key)
		{
			return _InsertR(root->_left, key);
		}
		else
		{
			//已存在 key 不允许插入
			return false;
		}
	}


	bool _FindR(const Node* root, const K& key)
	{
		if (root == nullptr)
			return false;
		if (root->_key < key)
		{
			_FindR(root->_right, key);
		}
		else if (root->_key > key)
		{
			_FindR(root->_left, key);
		}
		else
		{
			return true;
		}

	}

	void _Inorder(Node* root)
	{
		if (root)
		{
			_Inorder(root->_left);
			cout << root->_key << " ";
			_Inorder(root->_right);
		}
	}

	Node* _root = nullptr;
};

Key-Value模型,Find、Insert函数做出相应修改,并删除了部分函数

class BSTreeNode
{
public:
	BSTreeNode<K, Value>* _left;
	BSTreeNode<K, Value>* _right;
	K _key;
	Value _value;
	BSTreeNode(const K& key, const Value& value)
		:_left(nullptr)
		, _right(nullptr)
		, _key(key)
		, _value(value)
	{}

};

template <class K, class V>
class BSTree
{
	typedef BSTreeNode<K, V> Node;
public:
	bool Insert(const K& key, const V& value)
	{
		if (_root == nullptr)
		{
			_root = new Node(key, value);
			return true;
		}
		Node* parent = nullptr;
		Node* cur = _root;
		while (cur)
		{
			if (cur->_key < key)
			{
				parent = cur;
				cur = cur->_right;
			}
			else if (cur->_key > key)
			{
				parent = cur;
				cur = cur->_left;
			}
			else
			{
				return false;
			}
		}
		cur = new Node(key, value);
		if (parent->_key > key)
			parent->_left = cur;
		else
			parent->_right = cur;
		return true;
	}


	void Inorder()
	{
		_Inorder(_root);
		cout << endl;
	}

	Node* Find(const K& key)
	{
		Node* cur = _root;
		while (cur)
		{
			if (cur->_key > key)
			{
				cur = cur->_left;
			}
			else if (cur->_key < key)
			{
				cur = cur->_right;
			}
			else
			{
				return cur;
			}
		}
		return nullptr;
	}

private:
	void _Inorder(Node* root)
	{
		if (root)
		{
			_Inorder(root->_left);
			cout << root->_key << ":" << root->_value << endl;
			_Inorder(root->_right);
		}
	}
	Node* _root = nullptr;
};

  • 6
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Brant_zero2022

素材免费分享不求打赏,只求关注

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

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

打赏作者

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

抵扣说明:

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

余额充值