图的相关知识总结

图的概念

图是有顶点集合以及顶点间的关系组成的一种数据结构:G=(V,E),其中顶点集合V是有穷非空集合;
E={(x,y)|x,y属于V}或者E={<x,y>|x,y属于V&&Path(x,y)}是顶点间关系的有穷集合,也叫做边的集合。

==(x,y)==表示x到y的一条双向通路,即(x,y)是无方向的;==Path(x,y)==表示从x到y的一条单向通路,即Path(x,y)是有方向的。

第i个顶点记作vi 。vi和vj相关联表示两点间有一条边 第k条边表示ek,ek=(vi,vj)或<vi,vj>。

有向图和无向图,有向图中边==<x,y>是有序的 <x,y>和<y,x>是不同的边,无向图中边是无序的(x,y)== (y,x)是同一条边。
完全图:在有n个顶点的无向图中,若有n*(n-1)/2条边,即任意两个顶点之间有且仅有一条边,则称此图为无向完全图;在n个顶点的有向图中,若有n*(n-1)条边,即任意两个顶点之间有且仅有方向相反的边,则称此图为有向完全图

在这里插入图片描述
邻接顶点,在无向图G中,(u,v)为图中的一条边,u和v互为邻接顶点,称边(u,v)依附于顶点u和v; 在无向图中<u,v>是图中的一条边,称点u邻接到v,v邻接自u,边<u,v>与顶点u顶点v相关联

顶点的度:顶点v的度是指与它相关联的边的条数,记作deg(v)。在有向图中,顶点的度等于该顶点的入度与出度之和,其中顶点v的入度是以v为终点的有向边的条数,记作indev(v);顶点的出度是以v为起始点的有向边的条数,记作outdev(v),dev(v)=indev(v)+outdev(v)。对于无向图顶点的度等于顶点的入度和出度,dev(v)=indev(v)=outdev(v)。

路径:在图G=(V,E)中,若从顶点vi出发有一组边使其可以到达顶点vj,则称顶点vi到顶点vj的顶点序列为从顶点vi到顶点vj的路径。

路径长度:对于不带权的图,一条路径的路径长度是指该路径上的边的条数;对于带权的图是该路径上各个边权值的总和。

简单路径与回路:若路径上各顶点v1,v2,v3…vm均不重复,则称这样的路径为简单路径,若路径上第一个顶点v1和最后一个顶点vm重合,则称这样的路径为回路或环。

子图:图G(V,E)和图G1=(V1,E1),若V1属于V且E1属于E,则称G1是G的子图。
在这里插入图片描述

连通图:在无向图中,若从顶点v1到顶点v2有路径,则称顶点v1与顶点v2是连通的。如果图中任意一对顶点都是连通的,则称此图为连通图。

强连通图:在有向图中,若在每一对顶点vi和vj之间都存在一条从vi到vj的路径,也存在一条从vj到vi的路径,则称此图是强连通图。

生成树:一个连通图的最小连通子图称作该图的生成树。有n个顶点的连通图的生成树有n个顶点和n-1条边。

图的存储结构

邻接矩阵

  1. 邻接矩阵就是用一个二维数组来表示所有点和边的关系,对于顶点我们用一个数组来存储,用数组下标来表示存储在该位置的顶点,二维数组matrix[i][j]是0或1 就表示vi和vj之间是否有边,为1就是从vi到vj有相连的边, 如果是带权值的边,就可以定义一个模板参数class w 默认一个最大值表示无穷大,就是vi和vj之间没有相连的边,否则vi和vj之间就有一条权值为w的边。

  2. 无向图的邻接矩阵是对称的,第i行(列)元素之和就是顶点i的度,有向图的邻接矩阵则不一定是对称的第i行(列)元素之和就是顶点i的出(入)度。

  3. 用邻接矩阵存储图的优点是能快速的知道两个顶点是否连通,缺点就是如果顶点比较多,边比较少是,矩阵中处存储了大量的0,比较浪费空间,并且要求两个节点直接的路径不是很好求。

template<class v,class w,w maxw=INT_MAX,bool Direction=false>  //顶点参数v 权值w ,布尔类型
class Graph                                                                                        //声明是否是有向图
{
	typedef Graph<v, w,maxw,Direction>self;
private:
	vector<v>vertex;   //存储所有点, 
	map<v, int>m;   //map中记录点与下标的隐射
	vector<vector<w>>matrix;  //矩阵中记录图中所有的边

public:
Graph(const v* a, size_t n)  //有参构造函数,保存所有的点,初始化保存边的二维数组
	{
		vertex.reserve(n);
		for (int i = 0; i < n; ++i)
		{
			vertex.push_back(a[i]);  //顶点建立好与下标的隐射关系
			m[a[i]] = i;
		}
		matrix.resize(n);
		for (int i = 0; i < n; ++i)
			matrix[i].resize(n, maxw);  //初始化边,矩阵中开始没有一条边,后面实现一个添加边的函数
	}
	Graph() = default;
	size_t GetIndex(const v& val)   //返回每个顶点的下标,好在矩阵中完善边的关系
	{
		
		
			if (m.find(val) != m.end())
				return m[val];
			else
			{
				throw "顶点错误";
				return -1;
			}
	}
		void _AddEdge(int src, int dest, const w& val)
	{
		matrix[src][dest] = val;
		if (Direction == false)
			matrix[dest][src] = val;
	}
	void AddEdge(const v& src, const v& dest, const w& val) //两个顶点src和dest之间有一条权值为val的边,
	{     
		size_t i = GetIndex(src);
		size_t j = GetIndex(dest);
		_AddEdge(i, j, val);  //添加进矩阵中
	}
		void print()
	{
		for (int i = 0; i < vertex.size(); ++i)
			cout << "[" << i << "]" << "->" << vertex[i] << endl;
		cout << endl;
		printf("%5c", ' ');
		for (int i = 0; i < vertex.size(); ++i)
			printf("%5d", i);
		cout << endl;
		for (int i = 0; i < vertex.size(); ++i)
		{
			printf("%5d", i);
			for (int j = 0; j < vertex.size(); ++j)
			{
				if (matrix[i][j] == INT_MAX)
					printf("%5c", '*');  //两点之间没有边就用*表示
				else
					printf("%5d", matrix[i][j]);
			}
			cout << endl;
		}
	}
}

测试邻接矩阵中初始化顶点和矩阵然后添加边,打印顶点和矩阵中记录的情况

string s = "0123";
	Graph<char, int,INT_MAX,true>gp(s.c_str(),s.size());
	try
	{
		gp.AddEdge('0', '1', 1);
		gp.AddEdge('0', '3', 4);
		gp.AddEdge('1', '3', 2);
		gp.AddEdge('1', '2', 9);
		gp.AddEdge('2', '3', 8);
		gp.AddEdge('2', '1', 5);
		gp.AddEdge('2', '0', 3);
		gp.AddEdge('3', '2', 6);
	}
	
	catch (const char* str)
	{
		cout << str << endl;
		return;
	}
	gp.print();

在这里插入图片描述

邻接表

邻接表是使用数组来存储顶点集合,同样建立顶点和下标的隐射关系,用链表来表示边的关系
下图是无向邻接表
在这里插入图片描述
无向图中同一条边在邻接表中出现了两次。想知道顶点vi的度,只需要知道顶点vi边链表集合中节点的数量即可。

对于有向图,那么我们记录每个点对应的边就分别,以该点为起点的链表就是记录了以该点为起点的所有边,所以有出边表,同理以该点为终点的记录链表就形成了入边表。

有向图中每条边在邻接表中只出现一次,与顶点vi对应的邻接表所含有结点的个数,就是该顶点的出度,也称出度表,要得到vi顶点的入度,必须检测其他所有顶点对应的边链表,看有多少边顶点的dst取值是i。

template<class w>     //这里我们就以出度表为例
struct Edge
{
	int dest;   //边的终点
	w _w;         //这条边的权重
	struct Edge<w>* next;  //链表指针,链表中的存储的多个dest都是起点直接相连形成的边
	Edge(int d,w we)
		:dest(d)
		,_w(we)
		,next(nullptr)
	{}
};
template<class v,class w,bool Direction=false>
class LinkGraph
{
	typedef Edge<w> Edge;
	private:
	vector<v>vertex;
	map<v, int>m;
	vector<Edge*>_table;
	public:
	LinkGraph(const v* a, size_t n)
		:vertex(a, a + n)
		,_table(n, nullptr)
	{
		for (int i = 0; i < n; ++i)
			m[a[i]] = i;
	}
	size_t getindex(const v& val)
	{
		auto it = m.find(val);
		if (it != m.end())
			return it->second;
		else
		{
			throw "顶点错误";
			return -1;
		}
	}
	void addedge(const v& x1, const v& x2,const w&we)
	{
		int src = getindex(x1);
		int dest = getindex(x2);
		Edge* e = new Edge(dest, we);
		e->next = _table[src];   //头插进以src为起点管理的链表中
		_table[src] = e;
		if (Direction == false)   //无向图 那就有两条边了
		{
			Edge* p = new Edge(src, we);
			p->next = _table[dest];
			_table[dest] = p;
		}
	}
	void print()
	{
		int n = vertex.size();
		for (int i = 0; i < n; ++i)
			cout << "[" << i << "]" << "->" << vertex[i] << endl;
		cout << endl;
		for (int i = 0; i < n; ++i)
		{
			cout << "[" << i << "]" << "->" << vertex[i] << "---";
			Edge* cur = _table[i];
			while (cur)
			{
				cout << "[" << cur->dest << "]   " << vertex[cur->dest] << "权值 " << cur->_w << "--";
				cur = cur->next;
			}
			cout << "nullptr" << endl;
		}
	}
	~LinkGraph()
	{
		for (auto e : _table)
		{
			Edge* cur = e;
			while (cur)
			{
				Edge* tail = cur->next;
				delete cur;
				cur = tail;
			}
		}
	}
};

测试邻接表

	string a[] = { "张三", "李四", "王五", "赵六" };
	LinkGraph<string, int> g1(a, 4);
	g1.addedge("张三", "李四", 100);
	g1.addedge("张三", "王五", 200);
	g1.addedge("王五", "赵六", 30);
	g1.print();

在这里插入图片描述

图的遍历

	//图的遍历   广度优先   深度优先 以在邻接矩阵中为例
	void BFS(const v& src)
	{
		int n = vertex.size();
		int sr = GetIndex(src);
		vector<bool>visited(n, false);
		queue<int>q;
		q.push(sr);
		visited[sr] = true;
		int levelsize = 1;  //可以选择记录广度优先的遍历层数
		while (!q.empty())
		{
			int size = q.size();
			while (size--)
			{
				int front = q.front();
				q.pop();
				printf("第%d层:", levelsize);
				cout << "[" << front << "]" << "->" << vertex[front] << endl;
				for (int i = 0; i < n; ++i)
				{
					if (matrix[front][i] != maxw && visited[i] == false)
					{
						q.push(i);
						visited[i] = true;
					}
				}
			}
			++levelsize;
		}
	}
	void _DFS(vector<bool>& visited, int src)  //深度优先遍历
	{
		visited[src] = true;
		cout << "[" << src << "]" << "->" << vertex[src]<<endl;
		for (int i = 0; i < vertex.size(); ++i)
		{
			if (matrix[src][i] != maxw && visited[i] == false)
				_DFS(visited, i);
		}
	}
	void DFS(const v& val)
	{
		vector<bool>visited(vertex.size(), false);
		int src = GetIndex(val);
		_DFS(visited, src);
	}

最小生成树

在这里插入图片描述

Kruskal算法

任给一个有n个顶点的连通网络N={V,E}
首先构造一个有这n个顶点组成,不含任何边的图G={V,NULL},其中每个顶点自成一个连通分量,
其次不断从E中取出权值最小的一条边(若有多条任取其一),若该边的两个顶点来自不同连通分量,则将此边加入到G中。如此重复,知道所有顶点都在同一个连通分量上为止。
核心:每次迭代时,选出一条具有最小权值,且两端点不在同一连通分量上的边,加入生成树。

   	struct Edge
	{
		int src;
		int dest;
		w _w;
		Edge(int s,int d,w we)
			:src(s)
			,dest(d)
			,_w(we)
		{}
		bool operator>(const Edge& ed)const
		{
			return _w > ed._w;
		}
	}; 
	   //全局的贪心思想
	w Kruskal(self& mintree)    //克鲁斯卡尔算法最小生成树   引用方式返回最小生成树,返回最后的总权值
	{                             //1只能使用图中的边来构造最小生成树
		int n = vertex.size();     //2只能使用恰好n-1条边来连接图中的n个顶点  依次加入权值最小的边
		mintree.vertex = vertex;   //3 选用的n-1条边不能构成回路
		mintree.m = m;             //直接将连通图里的所有边加入小堆中,在其中依次选最小边,选一条边,就将两个原本不再一个集合顶点并入一个集合,
		mintree.matrix.resize(n);   //所以要加入一条边时先判断两个顶点是否在同一集合,在就说明加入这条边就构成环了,初始时每个顶点就是一个集合
		priority_queue<Edge, vector<Edge>, greater<Edge>>pq;
		for (int i = 0; i < n; ++i)
		{
			mintree.matrix[i].resize(n, maxw);
			for (int j = 0; j < n; ++j)
			{
				if (i < j && matrix[i][j] != INT_MAX)
					pq.push(Edge(i, j, matrix[i][j]));
			}
		}
		w tatol = w();
		int i = 1;
		UnoinFind uf(n);//用到并查集 检测是否加入的边构成环,加入一条边对应的两个顶点就为一个集合,如果两个顶点已经在一个集合中则如果加入这条边就构成了环
		while (i < n && !pq.empty())
		{
			Edge ed = pq.top();
			pq.pop();
			if (!uf.Inset(ed.src, ed.dest))
			{
				uf.Unionroot(ed.src, ed.dest);
				/*printf("第%d条边:",i);
				cout << "[" << ed.src << "]" << vertex[ed.src] << "--" << "[" << ed.dest << "]" << vertex[ed.dest] <<"权值"<<ed._w<< endl;*/
				tatol += ed._w;
				mintree._AddEdge(ed.src, ed.dest, ed._w);
				++i;
			}
			else
			{    //可注释打印语句
				//cout << "[" << ed.src << "]" << vertex[ed.src] << "--" << "[" << ed.dest << "]" << vertex[ed.dest] << "构成环路" << endl;
			}
		}
		if (i == n)
			return tatol;
		else
			return w();
	}

prim算法

在这里插入图片描述

	//局部的贪心思想
	w Prim(self& mintree, const v& val)   //步骤  为了防止加入导致闭环的边,给定集合,开始只有源点一个元素,
	{                                    //从原点开始,把于该点有连接的边入小堆,选到权值最小边且终点不在集合中说明该边可以加入不会闭环,把终点加入集合
		int src = GetIndex(val);       //往外延申,以终点为起点把于其他点有链接的边且终点不在集合中的边入堆,边数加到n-1就得到了最小生成树
		mintree.vertex = vertex;
		mintree.m = m;
		int n = vertex.size();
		mintree.matrix.resize(n);
		for (int i = 0; i < n; ++i)
			mintree.matrix[i].resize(n, maxw);
		priority_queue<Edge, vector<Edge>, greater<Edge>>pq;
		vector<bool>In(n, false);
		In[src] = true;
		for (int i = 0; i < n; ++i)
		{
			if (matrix[src][i] != maxw)
				pq.push(Edge(src, i, matrix[src][i]));
		}
		int len = 1;
		w tatol = w();
		while (len < n && !pq.empty())
		{
			Edge ed = pq.top();
			pq.pop();
			if (In[ed.dest])
			{      //可注释打印语句
				//cout << "[" << ed.src << "]" << vertex[ed.src] << "--" << "[" << ed.dest << "]" << vertex[ed.dest] << "构成环路" << endl;
			}
			else
			{
				mintree._AddEdge(ed.src, ed.dest, ed._w);
				//printf("第%d条边:", len);   //打印效果语句可以注释
				//cout << "[" << ed.src << "]" << vertex[ed.src] << "--" << "[" << ed.dest << "]" << vertex[ed.dest] << "权值" << ed._w << endl;
				In[ed.dest] = true;
				tatol += ed._w;
				++len;
				for (int i = 0; i < n; ++i)
				{
					if (matrix[ed.dest][i] != maxw && In[i] == false)
						pq.push(Edge(ed.dest, i, matrix[ed.dest][i]));
				}
			}
		}
		if (len == n)
		{
			return tatol;
		}
		else
		{
			return w();
		}
	}

最短路径问题

从在带权有向图G中的某一顶点出发,找到一条通往另一顶点的最短路径,最短也就是沿路径各边权值总和达到最小

单源最短路径–Dijkstra算法-

单源最短路径问题:给定一个图G=(V,E),求源节点S∈V,到每一个结点v∈V的最短路径。Dijkstra算法就适用于解决带权重的有向图上的单源最短路径问题,同时算法要求图中所有边的权重非负。一般在求解最短路径的时候都是已知一个起点和一个终点,所以使用Dijkstra算法求解过后也就得到了所需起点到终点的最短路径。针对一个带权有向图G,将所有节点分为两组S和Q,S是已经确定最短路径的结点集合,在初始时为空(初始时就可以将源节点s放入,毕竟源节点到自己的代价是0),Q为其余未确定最短路径的结点集合,每次从Q中找出一个起点到结点代价最小的结点u,将u从Q中移出,并放入S中,对u的每一相邻结点v进行松弛操作。松弛即对每一个相邻结点v,判断源结点s到结点u的代价与u到v的代价之和是否比原来s到v的代价更小,若代价比原来小则要将s到v的代价更新为s到u和u到v的代价之和,否则维持原样。如此一直循环直至集合Q为空,即所有节点都已经查找过一遍并确定了最短路径,至于一些起点到达不了的结点在算法循环后其代价仍未初始设定的值,不发生变化。Dijkstra算法每次都是选择V-S中最小的路径节点来进行更新,并加入S中所以该算法使用的是贪心策略。存在的问题就是不支持图中带负权路径,有负权则可能找不到一些路径的最短路径。

//局部贪心思想
	void Dijkstra(const v& sr, vector<w>& dist, vector<int>& Ppath) //迪杰斯特拉算法
	{
		int src = GetIndex(sr);  //算法思想 计算源点到每个点的最短路径,用一个数组记录到该点的权值放在相应下标位置,一个数组用并查集的方式存路径
		int n = vertex.size();   //初始化 源点到源点权值为0  路径也是在相应的下标位置存他自己的下标
		dist.resize(n, maxw);    //我们在dist中找到最短路径,以该路径再去延申到其他点,更新源点到该点的权值,确定最短路径的点就不再更新了,用一个数组记录已经更新完成了的点
		Ppath.resize(n, -1);
		Ppath[src] = src;
		vector<bool>In(n, false);  //记录已经确定从源点到该点最短路径的状态标记
		dist[src] = 0;          //用 true  false 就能区分点在S集合中还是Q集合中
		for (int i = 0; i < n; ++i)//要确定到n各顶点的最短路径
		{
			int u = 0;
			int minw = maxw;
			for (int j = 0; j < n; ++j)
			{
				if (In[j] == false && dist[j] < minw) //找出还没确定最短路径的点中,目前最短的,这就已经是不能再更新的最短路径, 从这点去其他点,看路径能否更短就更新还没确定的点
				{
					u = j;      
					minw = dist[j];
				}
			}
			In[u] = true;   //这是最短的 源点到下标为u这点的最短路径就已经确定了不能再更短了,然后从这点出发比较到该点的权值加到其他点的权值是否小于源点原先到其他点的权值,小于就更新
			for (int k = 0; k < n; ++k)
			{
				if (In[k] == false && matrix[u][k] != maxw && dist[u] + matrix[u][k] < dist[k])//这里就是更新操作
				{
					dist[k] = dist[u] + matrix[u][k];
					Ppath[k] = u;    //更新了权值也就更新了该点的双亲节点,Ppath中存的是负路径,根就是源点
				}
			}
		}
	}

单源最短路径–Bellman-Ford算法

Bellman–ford算法可以解决负权图的单源最短路径问题。它的优点是可以解决有负权边的单元最短路径问题,而且可以用来判断是否有负权回路。缺点是时间复杂度O(N*E)(N是点数,E是边数)普遍是要高于Dijkstra算法O(N2)的,像这里如果使用邻接矩阵实现,那么遍历所有边的数量的时间复杂度就是O(N3),这里可以看出来Bellman_Ford就是一种暴力求解更新。

bool BellmanFord(const v& sr, vector<w>& dist, vector<int>&pPath)  //贝尔曼福特算法
	{
		int n = vertex.size();
		int src = GetIndex(sr);
		dist.resize(n, maxw);           //只要一条路径更新了就会影响包含这条路径的路径的更新
		dist[src] = 0;                 //比如  s->t 这条路径更新了  那么 s->t->z 这条路径也要更新,
		pPath.resize(n, -1);        //这里两个循环遍历所有路径算作一轮路径更新  最坏要更新n轮才能保证更新出所有点的最小路径,
		//pPath[src] = src;          //设置一个标志位,走一轮过程中有更新路径就设为真,走完一轮发现没有更新就可以break
		for (int k = 0; k < n; ++k)
		{
			bool falt = false;
			for (int i = 0; i < n; ++i)
			{
				for (int j = 0; j < n; ++j)
				{
					if (matrix[i][j] != maxw && dist[i] + matrix[i][j] < dist[j])
					{
						falt = true;
						dist[j] = dist[i] + matrix[i][j];
						pPath[j] = i;
					}
				}
			}
			if (falt == false)
				break;
		}
		for (int i = 0; i < n; ++i)
		{
			for (int j = 0; j < n; ++j)   //要考虑负权回路的问题  就是比如  a->b->c->a  本来a到a是权值为0 经过这样的回路权值变成了负值,
				                         //那这样就可以一直绕圈权值无限变小,路径永远更新不完,这种情况任何算法都无法解决,返回false
			{
				if (matrix[i][j] != maxw && dist[i] + matrix[i][j] < dist[j])
				{
					return false;      //这里再更新一轮,如果路径中没用负权回路那么前面n轮就能保证更新完,如果这里还要更新说明有负权回路就返回false
				}
			}
		}
		return true;  //这里就是正常完成
	} 

多源最短路径–Floyd-Warshall算法

在这里插入图片描述
在这里插入图片描述Floyd算法本质是在三维动态规划,D[i][j][k]表示从点i到点j只经过0到k个点最短路径,然后建立起转移方程,然后通过空间优化,优化掉最后一维度,变成一个最短路径的迭代算法,最后记得到所有点的最短路径。

	void FloydWarShall(vector<vector<w>>& vvDist, vector<vector<int>>& vvPath)  //弗洛伊德算法 计算任意两点间的最短路径
	{            //用二维数字来存个点直接最短路径的权值和,用二维数组来记录各点之间的路径
		int n = vertex.size();
		vvDist.resize(n);
		vvPath.resize(n);
		for (int i = 0; i < n; ++i)
		{                                       //初始化
			vvDist[i].resize(maxw);
			vvPath[i].resize(n, -1);
		}
		//先把直接相连的路径记录下来
		for (int i = 0; i < n; ++i)
		{
			for (int j = 0; j < n; ++j)
			{
				if (matrix[i][j] != maxw)
				{
					vvDist[i][j] = matrix[i][j];
					vvPath[i][j] = i;          //记录负路径
				}
				if (i == j)
				{
					vvDist[i][j] = w();
				}
			}
		}
		//更新路径i->j的最短路径中间经过0到k各节点 如果i->k 加上k->j的权值小于i->j权值就更新i->j 动态规划的思想
		for (int k = 0; k < n; ++k)
		{
			for (int i = 0; i < n; ++i)
			{
				for (int j = 0; j < n; ++j)
				{
					if (vvDist[i][k] != maxw && vvDist[k][j] != maxw && vvDist[i][k] + vvDist[k][j] < vvDist[i][j])
					{         //更新路径后要找到和j直接相连的上一个节点如果k直接和j相连,上一个节点就是k,如果没有直接相连上一个节点下标在pPath[k][j]中
						vvDist[i][j] = vvDist[i][k] + vvDist[k][j];
						vvPath[i][j] = vvPath[k][j];   //要注意路径的更新 一般情况 i到k i->m->k  k到j  k->h->g->j 所以更新路径此时j的双亲节点应该是g存在pPath[k][j]中
					}
				}
			}
		}
	}


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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值