C++【红黑树】

✨个人主页: 北 海
🎉所属专栏: C++修行之路
🎃操作环境: Visual Studio 2019 版本 16.11.17

成就一亿技术人



🌇前言

红黑树是平衡二叉搜索树中的一种,红黑树性能优异,广泛用于实践中,比如 Linux 内核中的 CFS 调度器就用到了红黑树,由此可见红黑树的重要性。红黑树在实现时仅仅依靠 红 与 黑 两种颜色控制高度,当触发特定条件时,才会采取 旋转 的方式降低树的高度,使其平衡

csf调度器


🏙️正文

1、认识红黑树

红黑树德国·慕尼黑大学Rudolf Bayer 教授于 1978 年发明,后来被 Leo J. GuibasRobert Sedgewick 修改为如今的 红黑树

大佬
红黑树 在原 二叉搜索树 节点的基础上,加上了 颜色 Color 这个新成员,并通过一些规则,降低二叉树的高度

如果说 AVL 树是天才设计,那么 红黑树 就是 天才中的天才设计,不同于 AVL 树的极度自律,红黑树 只在条件符合时,才会进行 旋转降高度,因为旋转也是需要耗费时间的

红黑树在减少旋转次数时,在整体性能上仍然没有落后 AVL 树太多

先来一睹 红黑树 的样貌

红黑树

注:红黑树在极限场景下,与 AVL 树的性能差不超过 2

1.1、红黑树的定义

红黑树 也是 三叉链 结构,不过它没有 平衡因子,取而代之的是 颜色

图示

红黑树 的节点定义如下:(这里是通过 枚举 定义的颜色)

//枚举出 红、黑 两种颜色
enum Color
{
	RED, BLACK
};

//红黑树的节点类
template<class K, class V>
struct RBTreeNode
{
	RBTreeNode(std::pair<K, V> kv)
		:_left(nullptr)
		, _right(nullptr)
		, _parent(nullptr)
		, _kv(kv)
		, _col(RED)	//默认新节点为红色,有几率被调整
	{}

	RBTreeNode<K, V>* _left;
	RBTreeNode<K, V>* _right;
	RBTreeNode<K, V>* _parent;
	std::pair<K, V> _kv;

	Color _col;
};

注意: 定义新节点时,颜色可以为 红 也可以为 黑,推荐为 红色,具体原因后面解释

1.2、红黑树的性质

结构上的绝妙注定了其离不开规则的限制红黑树 有以下几条性质:

  1. 每个节点不是 红色 就是 黑色
  2. 根节点是 黑色 的
  3. 如果一个节点是 红色 的,那么它的两个孩子节点都不能是 红色 的(不能出现连续的红节点)
  4. 对于每个节点,从该节点到其所有后代的 NIF 节点的简单路径上,都包含相同数目的黑色节点(每条路径上都有相同数目的 黑色 节点)
  5. 每个叶子节点的 nullptr 称为 NIF 节点,并且默认为黑色,此处黑色仅用于路径判断,不具备其他含义

在这些规则的限制之下,红黑树 就诞生了

图示

红黑树 的性质还是比较重要的,可以花点时间结合图示深入理解

1.3、红黑树的特点

红黑树 在插入节点后,可以选择新节点为

  • 如果为 红 ,可能违反原则3,需要进行调整
  • 如果为 黑,必然违反原则4,并且因为这一条路,影响了其他所有路径,调整起来比较麻烦

因此 推荐在插入时,新节点默认为 红色,插入后,不一定调整,即使调整,也不至于 影响全局

图示

显然,红黑树 中每条路径都是 红黑相间 的,因为不能出现连续的 红节点,所以 黑节点的数量 >= 红节点

也就是说:红黑树中,最长路径至多为最短路径的两倍

  • 最长路径:红黑相间
  • 最短路径:全是黑节点

上图中的 最短路径3最长路径4,当然,最短路径 可以为 2

对于 AVL 树来说,下面这个场景必然 旋转降高度,但 红黑树 就不必,因为 没有违背性质

图示

综上, 红黑树 是一种折中且优雅的解决方案,不像 AVL 那样极端(动不动就要旋转),而是只有触发特定条件时,才会发生旋转,并且在极端场景下, 两者查询速度差异不过 2 倍,但在插入、删除、修改等可能涉及旋转的操作中,红黑树就领先太多了

假设在约 10 亿 大小的数据中进行查找

  • AVL 树至多需要 30 次出结果
  • 红黑树至多需要 60 次出结果

但是,区区几十次的差异,对于 CPU 来说几乎无感,反而是频繁的旋转操作令更费时间

记住:红黑树在实际中性能更好,适用性更强;AVL 树适用存储静态、不轻易修改的数据


2、红黑树的插入操作

2.1、抽象图

在演示 红黑树 的插入操作时,也需要借助 抽象图,此时的 抽象图 不再代表高度,而是代表 黑色节点 的数量

图示

抽象图中关注的是 黑色节点 的数量

2.2、插入流程

红黑树 的插入流程也和 二叉搜索树 基本一致,先找到合适的位置,然后插入新节点,当节点插入后,需要对颜色进行判断,看看是否需要进行调整

插入流程:

  • 判断根是否为空,如果为空,则进行第一次插入,成功后返回 true
  • 找到合适的位置进行插入,如果待插入的值比当前节点值大,则往 右 路走,如果比当前节点值小,则往 左 路走
  • 判断父节点与新节点的大小关系,根据情况判断链接至 左边 还是 右边
  • 根据颜色,判断是否需要进行 染色、旋转 调整高度

整体流程如下(不包括染色调整的具体实现)

bool Insert(const std::pair<K, V> kv)
{
	if (_root == nullptr)
	{
		_root = new Node(kv);
		_root->_col = BLACK;	//根节点一定是黑色
		return true;
	}

	//寻找合适位置
	Node* parent = nullptr;
	Node* cur = _root;
	while (cur)
	{
		if (cur->_kv.first < kv.first)
		{
			parent = cur;
			cur = cur->_right;
		}
		else if (cur->_kv.first > kv.first)
		{
			parent = cur;
			cur = cur->_left;
		}
		else
		{
			//插入失败
			return false;
		}
	}

	//插入节点
	cur = new Node(kv);
	if (parent->_kv.first < kv.first)
		parent->_right = cur;
	else
		parent->_left = cur;
	cur->_parent = parent;

	//判断是否需要 染色、旋转
	while (parent && parent->_col == RED)
	{
		Node* grandfather = parent->_parent;	//祖父节点
		//……
	}
	
	return true;
}

红黑树 如何调整取决于 叔叔,即 父亲兄弟节点

并且如果 父亲,直接插入就行了,不必调整

如果 父亲为红 并且 叔叔也为红,可以只通过 染色 解决当前问题,然后向上走,继续判断是否需要调整

如果 父亲为红 并且 叔叔为黑 或者 叔叔不存在,此时需要 旋转 + 染色根据当前节点与父亲的位置关系,选择 单旋 或 双旋,值得一提的是 旋转 + 染色 后,不必再向上判断,可以直接结束调整

关于旋转的具体实现,这里不再展开叙述,可以复用 AVL 中的旋转代码,并且最后不需要调整平衡因子

C++【AVL树】

注意: 红黑树的调整可以分为 右半区 和 左半区 两个方向(根据 grandfatherparent 的位置关系而定),每个方向中都包含三种情况:单纯染色、单旋+染色、双旋+染色,逐一讲解费时费力,并且两个大方向的代码重复度极高,因此 下面的旋转操作基于 右半区

左半区 的操作和 右半区 基本没啥区别,可以去完整代码中求证

2.3、单纯染色

如果 父亲 为黑色,则不需要调整,不讨论这种情况,下面三种情况基本要求都是:父亲为红

当新节点插入后,如果 叔叔 节点也为 红色,那么可以通过将 祖父 节点的黑色素下放给 父亲和叔叔祖父节点 变为 红色,这样调整仍可确保 每条路径中的黑色节点数目相同

单次染色还不够,需要从 grandfather 处继续向上判断是否需要 调整单纯染色后,向上判断可能会变成其他情况,这是不确定的,具体情况具体分析

单纯染色 的操作如下:

注意:c 表示当前节点,p 表示父亲节点,u 表示叔叔节点,g 表示祖父节点

单纯染色
修正: 动图中语句修正为 “父亲为红,叔叔也为红,直接染色即可”

单次染色 结束后,更新 curgrandfather 的位置,并同步更新 parent,继续判断是需要进行 单纯染色单旋 + 染色 还是 双旋 + 染色

本质:将公共的黑色下放给两个孩子

代码片段如下(右半分区)

//在右半区操作
Node* uncle = grandfather->_left;	//叔叔节点

if (uncle && uncle->_col == RED)
{
	//染色、向上更新即可
	grandfather->_col = RED;
	parent->_col = uncle->_col = BLACK;
	cur = grandfather;
	parent = cur->_parent;
}
else
{
	//此时需要 旋转 + 染色
	//……
}

叔叔 存在且为 很好处理,难搞的是 叔叔 不存在或 叔叔,需要借助 旋转 降低高度

注意: 此时的五个抽象图,都代表同一个具象图;如果 parent 为空,证明 cur 为根节点,此时需要把根节点置为 黑色,在返回 true 前统一设置即可

2.4、左单旋 + 染色

单旋:右右、左左,此时在 右半区,所以当 叔叔 不存在或者为 黑色 且节点位于 父亲右边 时,可以通过 左单旋 降低高度

如果在左半区,节点位于父亲的左边时,则使用 右单旋 降低高度

在高度降低后,需要使用 染色 确保符合 红黑树 的性质

旋转 思想很巧妙,在 旋转 + 染色 后,可以跳出循环,结束调整

左旋转 + 染色 的操作如下:

注意:c 表示当前节点,p 表示父亲节点,u 表示叔叔节点,g 表示祖父节点

图示

显然,旋转 + 染色 后,parent 是一定会被修改为 黑色 的,所以不必再往上判断调整,因为现在已经很符合性质了(即使 parent 的父亲是 红色,也不会出现连续的 红色节点

本质:parent 的左孩子托付给 grandfather 后,parent 往上提,并保证不违背性质

代码片段如下(右半分区)

//在右半区操作
Node* uncle = grandfather->_left;	//叔叔节点

if (uncle && uncle->_col == RED)
{
	//染色、向上更新即可
	//……
}
else
{
	//此时需要 旋转 + 染色
	if (parent->_right == cur)
	{
		//右右,左单旋 ---> parent 被提上去了
		RotateL(grandfather);
		grandfather->_col = RED;
		parent->_col = BLACK;
		cur->_col = RED;
	}
	else
	{
		//右左,右左双旋 ---> cur 被提上去了
		//……
	}

	//旋转后,保持平衡,可以结束调整
	break;
}

注意: 这种情况多半是由 单纯染色 转变而来的,所以不同区域的抽象图有不同的情况,必须确保能符合红黑树的性质

2.5、右左双旋 + 染色

双旋:右左、左右,此时在 右半区,所以当 叔叔 不存在或者为 黑色 且节点位于 父亲左边 时,可以通过 右左双旋 降低高度

如果在左半区,节点位于父亲的右边时,则使用 左右双旋 降低高度

在高度降低后,需要使用 染色 确保符合 红黑树 的性质

旋转 思想很巧妙,在 旋转 + 染色 后,可以跳出循环,结束调整

右左双旋 + 染色 的操作如下:

注意:c 表示当前节点,p 表示父亲节点,u 表示叔叔节点,g 表示祖父节点

图示

双旋 其实就是两个不同的 单旋,不过对象不同而已,先 右旋转 parent,再 左旋转 grandfather 就是 右左双旋

本质:cur 的右孩子托付给 parent,左孩子托付给 grandfather 后,把 cur 往上提即可,并保证不违背 红黑树 的性质

代码片段如下(右半分区)

Node* grandfather = parent->_parent;	//祖父节点
if (grandfather->_right == parent)
{
	//在右半区操作
	Node* uncle = grandfather->_left;	//叔叔节点

	if (uncle && uncle->_col == RED)
	{
		//染色、向上更新即可
		//……
	}
	else
	{
		//此时需要 旋转 + 染色
		if (parent->_right == cur)
		{
			//右右,左单旋 ---> parent 被提上去了
			//……
		}
		else
		{
			//右左,右左双旋 ---> cur 被提上去了
			RotateR(parent);
			RotateL(grandfather);
			grandfather->_col = RED;
			parent->_col = RED;
			cur->_col = BLACK;
		}

		//旋转后,保持平衡,可以结束调整
		break;
	}

注意: 双旋的情况也可以由 单纯变色 转变而来,同样的,不同区域的抽象图代表不同的含义;对 parent 进行右单旋,对 grandfather 进行左单旋

2.6、具体实现代码

总的来说,红黑树 的插入操作其实比 AVL 还要略显简单,画图分析后,确认如何 染色 就行了,下面是插入操作的完整源码(包括左、右单旋)

插入

bool Insert(const std::pair<K, V> kv)
{
	if (_root == nullptr)
	{
		_root = new Node(kv);
		_root->_col = BLACK;	//根节点一定是黑色
		return true;
	}

	//寻找合适位置
	Node* parent = nullptr;
	Node* cur = _root;
	while (cur)
	{
		if (cur->_kv.first < kv.first)
		{
			parent = cur;
			cur = cur->_right;
		}
		else if (cur->_kv.first > kv.first)
		{
			parent = cur;
			cur = cur->_left;
		}
		else
		{
			//插入失败
			return false;
		}
	}

	//插入节点
	cur = new Node(kv);
	if (parent->_kv.first < kv.first)
		parent->_right = cur;
	else
		parent->_left = cur;
	cur->_parent = parent;

	//判断是否需要 染色、旋转
	while (parent && parent->_col == RED)
	{
		Node* grandfather = parent->_parent;	//祖父节点
		if (grandfather->_right == parent)
		{
			//在右半区操作
			Node* uncle = grandfather->_left;	//叔叔节点

			if (uncle && uncle->_col == RED)
			{
				//染色、向上更新即可
				grandfather->_col = RED;
				parent->_col = uncle->_col = BLACK;
				cur = grandfather;
				parent = cur->_parent;
			}
			else
			{
				//此时需要 旋转 + 染色
				if (parent->_right == cur)
				{
					//右右,左单旋 ---> parent 被提上去了
					RotateL(grandfather);
					grandfather->_col = RED;
					parent->_col = BLACK;
					cur->_col = RED;
				}
				else
				{
					//右左,右左双旋 ---> cur 被提上去了
					RotateR(parent);
					RotateL(grandfather);
					grandfather->_col = RED;
					parent->_col = RED;
					cur->_col = BLACK;
				}

				//旋转后,保持平衡,可以结束调整
				break;
			}
		}
		else
		{
			//在左半区操作
			Node* uncle = grandfather->_right;	//叔叔节点

			//同理,进行判断操作
			if (uncle && uncle->_col == RED)
			{
				//直接染色
				grandfather->_col = RED;
				parent->_col = uncle->_col = BLACK;
				cur = grandfather;
				parent = cur->_parent;
			}
			else
			{
				//需要 旋转 + 染色
				if (parent->_left == cur)
				{
					//左左,右单旋 ---> parent 被提上去
					RotateR(grandfather);
					grandfather->_col = RED;
					parent->_col = BLACK;
					cur->_col = RED;
				}
				else
				{
					//左右,左右双旋 ---> cur 被提上去
					RotateL(parent);
					RotateR(grandfather);
					grandfather->_col = RED;
					parent->_col = RED;
					cur->_col = BLACK;
				}

				break;
			}
		}
	}

	//再次更新根节点的颜色,避免出问题
	_root->_col = BLACK;
	return true;
}

左单旋

//左单旋
void RotateL(Node* parent)
{
	Node* subR = parent->_right;
	Node* subRL = subR->_left;

	//将 subR 的孩子交给 parent
	parent->_right = subRL;
	if (subRL != nullptr)
		subRL->_parent = parent;

	//提前保存 parent 的父节点信息
	Node* pparent = parent->_parent;

	//将 parent 变成 subR 的左孩子
	subR->_left = parent;
	parent->_parent = subR;

	//更新 subR 的父亲
	if (pparent == nullptr)
	{
		//此时 parent 为根,需要改变根
		_root = subR;
		_root->_parent = nullptr;
	}
	else
	{
		//根据不同情况进行链接
		if (pparent->_right == parent)
			pparent->_right = subR;
		else
			pparent->_left = subR;
		subR->_parent = pparent;
	}
}

右单旋

//右单旋
void RotateR(Node* parent)
{
	//基本原理和左单旋一致

	Node* subL = parent->_left;
	Node* subLR = subL->_right;

	parent->_left = subLR;
	if (subLR != nullptr)
		subLR->_parent = parent;

	Node* pparent = parent->_parent;

	subL->_right = parent;
	parent->_parent = subL;

	if (parent == _root)
	{
		_root = subL;
		_root->_parent = nullptr;
	}
	else
	{
		if (pparent->_right == parent)
			pparent->_right = subL;
		else
			pparent->_left = subL;
		subL->_parent = pparent;
	}
}

关于左右单旋的详细细节,可以去隔壁 AVL 树的文章查看,红黑树 这里不必阐述

2.6、注意事项及调试技巧

红黑树 这里也涉及很多等式 == 判断,一定要多多注意,不要漏写 =

三叉链 结构,要注意 父指针 的调整

红黑树 的调整情况如下:

  • 右半区
    • 右右 左单旋
    • 右左,右左双旋
  • 左半区
    • 左左,右单旋
    • 左右,左右双旋

得益于前面 AVL 树旋转操作的学习,红黑树 这里在编写 旋转 相关代码时,没什么大问题

红黑树DeBug 逻辑与 AVL 树一致,这里额外分享一个 DeBug 技巧:

  • 当 随机插入 数据出错时,可以借助文件读写操作,将出错的数据保存下来,然后再次输入,反复进行调试,即可找出 Bug
  • 因为是 随机插入 时出现的问题,所以需要保存一下数据样本

关于 红黑树 详细操作可以参考这篇 Blog:《红黑树(C++实现)


3、AVL树 VS 红黑树

AVL 树 和 红黑树平衡二叉搜索树 的两种优秀解决方案,既然两者功能一致,那么它们的实际表现如何呢?可以通过大量随机数插入,得出结果

当然,在切磋之前,需要先验证一下之前写的 红黑树 的正确性

3.1、红黑树的检验

可以借助红黑树 的性质,从下面这三个方面进行检验:

  • 验证根节点是否为 黑色节点
  • 验证是否出现连续的 红色节点
  • 验证每条路径中的 黑色节点 数量是否一致

判断黑色节点数量,需要先获取 基准值

  • 简单,先单独遍历一遍,其中的路径,这里选择了最左路径,将这条路径中获取的黑色节点数作为基准值,传给函数判断使用

孩子不一定存在,但父亲一定存在(当前节点为 红色 的情况下)

  • 所以当节点为 红色 时,判断父亲是否为黑色,如果不是,则非法!
		//合法性检验
		bool IsRBTree() const
		{
			if (_root->_col != BLACK)
			{
				std::cerr << "根节点不是黑色,违反性质二" << std::endl;
				return false;
			}

			//先统计最左路径中的黑色节点数量
			int benchMark = 0;	//基准值
			Node* cur = _root;
			while (cur)
			{
				if (cur->_col == BLACK)
					benchMark++;
				cur = cur->_left;
			}

			//统计每条路径的黑色节点数量,是否与基准值相同
			int blackNodeSum = 0;
			return _IsRBTree(_root, blackNodeSum, benchMark);
		}

protected:
		bool _IsRBTree(Node* root, int blackNodeSum, const int benchMark) const
		{
			if (root == nullptr)
			{
				if (blackNodeSum != benchMark)
				{
					std::cerr << "某条路径中的黑色节点数出现异常,违反性质四" << std::endl;
					return false;
				}
				return true;
			}

			if (root->_col == BLACK)
			{
				blackNodeSum++;
			}
			else
			{
				//检查当前孩子的父节点是否为 黑节点
				if (root->_parent->_col != BLACK)
				{
					std::cerr << "某条路径中出现连续的红节点,违反性质三" << std::endl;
					return true;
				}
			}

			return _IsRBTree(root->_left, blackNodeSum, benchMark) && _IsRBTree(root->_right, blackNodeSum, benchMark);
		}

通过代码插入约 10000 个随机数,验证是否是红黑树

结果

鉴定为 合法,并且高度有 16,比 AVL 树略高一层(情理之中)

3.2、性能对比

红黑树 不像 AVL 树那样过度自律,其主要优势体现在 插入数据 时的效率之上,可以通过程序对比一下

void RBTreeTest2()
{
	srand((size_t)time(NULL));

	AVLTree<int, int> av;
	RBTree<int, int> rb;

	int begin1, begin2, end1, end2, time1 = 0, time2 = 0;

	int n = 100000;
	int sum = 0;
	for (int i = 0; i < n; i++)
	{
		int val = (rand() + i) * sum;
		sum ++;

		begin1 = clock();
		av.Insert(make_pair(val, val));
		end1 = clock();
		
		begin2 = clock();
		rb.Insert(make_pair(val, val));
		end2 = clock();

		time1 += (end1 - begin1);
		time2 += (end2 - begin2);
	}

	cout << "插入 " << sum << " 个数据后" << endl;
	cout << "AVLTree 耗时: " << time1 << "ms" << endl;
	cout << "RBTree 耗时: " << time2 << "ms" << endl;
	cout << "=================================" << endl;
	cout << "AVLree: " << av.IsAVLTree() << " | " << "高度:" << av.getHeight() << endl;
	cout << "RBTree: " << rb.IsRBTree() << " | " << "高度:" << rb.getHeight() << endl;
}

图示

此时数据量太小了,还不能体现 红黑树 的价值,还好这次测试,红黑 比 AVL

红黑树还是有实力的

红黑树setmap 的底层数据结构,在下篇文章中,将会进一步完善 红黑树,并用我们自己写的 红黑树 封装 set / map,最后可以和库中的切磋一下~

本文中涉及的源码:《RBTree 博客


🌆总结

以上就是本次关于 C++【红黑树】的全部内容了,在本文中,我们首先了解了什么是 红黑树,然后对其进行了实现,作为数据结构中的大哥,红黑树 还是有一定难度的,作为被广泛使用的优秀数据结构,简单掌握还是很有必要的


星辰大海

相关文章推荐

C++ 进阶知识

C++【AVL树】

C++【set 和 map 学习及使用】

C++【二叉搜索树】

C++【多态】

C++【继承】

STL 之 泛型思想

C++【模板进阶】

C++【模板初阶】

  • 114
    点赞
  • 124
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 153
    评论
抱歉,我是一名语言模型AI,无法为您提供演示实时代码功能。但是,我可以向您解释的基本原理和操作。是一种自平衡二叉搜索,它的每个节点都有一个颜色属性,色或色,具有以下特性: 1. 根节点为色。 2. 所有叶子节点(NIL节点)为色。 3. 如果一个节点为色,则它的两个子节点必须都为色。 4. 从任意一个节点到其每个叶子节点的所有路径都包含相同数量的色节点。 的基本操作包括插入、删除和查找。插入和删除操作会破坏的平衡性,需要通过旋转和重新着色来修复平衡。查找操作与二叉搜索相同,具有O(log n)的时间复杂度。 以下是的插入操作的伪代码: 1. 新节点插入到中,颜色为色。 2. 如果新节点的父节点为色,则不需要做任何操作,仍然平衡。 3. 如果新节点的父节点为色,需要进行以下操作: (1) 如果新节点的叔节点(父节点的兄弟节点)为色,则将父节点和叔节点涂,祖父节点涂,然后将当前节点指向祖父节点,重复步骤2。 (2) 如果新节点的叔节点为色,并且新节点是父节点的右子节点(父节点为祖父节点的左子节点),则将父节点左旋转,将当前节点指向父节点,重复步骤4。 (3) 如果新节点的叔节点为色,并且新节点是父节点的左子节点(父节点为祖父节点的右子节点),则将父节点右旋转,将当前节点指向父节点,重复步骤4。 4. 将父节点涂,祖父节点涂,然后进行以下操作: (1) 如果当前节点是父节点的左子节点,将祖父节点右旋转。 (2) 如果当前节点是父节点的右子节点,将祖父节点左旋转。 以上是的基本操作,希望能够帮助您理解的原理。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

北 海

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

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

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

打赏作者

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

抵扣说明:

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

余额充值