【全程NOIP计划】图论算法

【全程NOIP计划】图论算法

最短路算法

常用的最短路算法SPFA,Dijkstra,Floyd算法

最短路问题,就是对于有权图的两个点,找到一条连接两个点的路径,使得路径的权值和最小

在说最短路算法之前,必须了解松弛的概念

其实n简单,如果\(a \rightarrow b+b \rightarrow c\)的距离比\(a \rightarrow c\)的小,那么就可以用前者代替a到c的距离

各种各样 最短路实际上就是不断做松弛操作

Floyd

可以求出图中任意两点的最短路,过程很简单

首先枚举松弛操作的中间点,再枚举松弛的左右两个点,然后做松弛操作

由于Floyd算法暴力枚举的特性,所以用邻接矩阵很方便

很显然,复杂度为\(O(n^3)\)

for(int k=1;k<=n;k++)
    for(int i=1;i<=n;i++)
        for(int j=1;j<=n;j++)
            a[i][j]=min(a[i][j],a[i][k]+a[k][j]);
正确性

怎么证明正确性?

对于任意两个点之间的最短路,假设有m个节点那么m一定小于n,因为重复经过同样的点没有意义(除非有负环,但是如果有负环最短路就没有意义,因为可以通过刷负环来刷最短路)

那么这m个点在外层循环都会被枚举一次,接着内层的两重循环一定会枚举到这个点在最短路上相邻的两个点

这个点被松弛之后,我们就可以认为它已经不在最短路上了,因为此时的\(a \rightarrow b \rightarrow c\)\(a \rightarrow c\)是一样的

外层循环做完之后,两个点之间的所有m个点也都被消除完了,这两个点的距离就是最短路

易错点

有一个易错点,就是三个循环的顺序不要搞错了

先枚举中间,再枚举两边

因为,如果左边先枚举,那么n次循环之后,松弛的点对都是从左边出发的,如果没有恰好沿着路径的顺序去枚举中间的点,就无法松弛整条路径

有一个神奇的结论,只要Floyd整个过程连续做三遍,不管三个循环是什么顺序,跑出来的结果都是对的,但是不建议使用,主要是慢啊

作用

裸的最短路实际上用的不多,用到了也很简单,基本上是看做一个工具来用

Floyd实际上还可以处理除了最短路以外其他的问题

P2419 Cow Contest S

思路

拓扑排序可以,但是Floyd也可以

这样点很少,边很多的图就很适合使用Floyd

如果用\(e[i][j]\)表示能不能推出i<j的关系,如果\(e[i][k]=1且e[k][j]=1\),那么\(e[i][j]=1\)

这样我们就可以处理出任意两点间的大小关系,有或者没有

然后如果对于一个点,我们能确定剩下所有点和它的大小关系

那么这个点的排名就被确定了

如果一个点,对于其他任何一个点都能推出它的关系,然后它的排名就确定了

#include <iostream>
#include <cstdio>
#include <algorithm>
#include <cstring>
#include <string>
using namespace std;
int n,m;
int e[105][105];
void floyd()
{
    for(int k=1;k<=n;k++)
        for(int i=1;i<=n;i++)
            for(int j=1;j<=n;j++)
                e[i][j]|=(e[i][k]&e[k][j]);//用floyd来解决直接的关系 
}
int main()
{
    cin>>n>>m;
    int l,r;
    while(m--)
    {
        cin>>l>>r;
        e[l][r]=1;
    }
    floyd();
    int cnt=0;
    for(int i=1;i<=n;i++)
    {
        int mark=1;
        for(int j=1;j<=n;j++)
            if(j!=i&&e[j][i]==0&&e[i][j]==0)//如果有谁不能确定 
            {
                mark=0;
                break;
            }
        cnt+=mark;
    }
    cout<<cnt<<endl;
    return 0;
}

P2888 Cow Hurdles S

思路

让我想到了营救那道题目

实际上就是二分答案加判断

但是用Floyd处理dp关系可以更简单的做这个题目

\(e[i][j]\)表示从i到j经过的路径中,最小的最大栏杆高度

容易得到类似于Floyd的关系

也就是说如果走\(i \rightarrow k \rightarrow j\)的路线,那么栏杆的最大高度为\(max(e[i][k],e[k][j])\)

如果这个最大值小于\(i \rightarrow j\)之间的最大值,那么就可以更新这个最小的最大值

也就是先用\(O(n^3)\)来处理一个Floyd,然后再用\(O(t)\)处理答案

#include <iostream>
#include <cstdio>
#include <algorithm>
#include <cstring>
#include <string>
using namespace std;
const int INF=0x3f3f3f3f;
const int maxn=305;
int n,m,T;
int a[maxn][maxn];
int main()
{
	memset(a,20,sizeof(a));
	cin>>n>>m>>T;
	for(int i=1;i<=m;i++)
	{
		int s,e,h;
		cin>>s>>e>>h;
		a[s][e]=h;
	}
	for(int k=1;k<=n;k++)
		for(int i=1;i<=n;i++)
			for(int j=1;j<=n;j++)
			a[i][j]=min(a[i][j],max(a[i][k],a[k][j]));
	while(T--)
	{
		int l,r;
		cin>>l>>r;
		if(a[l][r]!=336860180)
		cout<<a[l][r]<<'\n';
		else
		cout<<-1<<'\n';
	}
	return 0;
}

P2047 社交网络

思路

这道题目和前两道题目差不多,只不过除了求i到j的路径数量,还要求i经过k到j的路径数量

最短路径比较好求,如果\(e[i][k]和e[k][j]\)更新了\(e[i][j]\)的话,让\(cnt[i][j]=cnt[i][k]*cnt[k][j]\)就可以了

如果\(e[i][k]+e[k][j]=e[i][j]\)那么还要让\(cnt[i][j]+=cnt[i][k]*cnt[k][j]\)

因为数据量过小,所以这样来表示是可以的,主要运用的是乘法原理

题目个每个点求\(\sum_{s!=v,t!=v}c(s,t,v)c(s,t)\)

那么就先跑Floyd,然后枚举每个点,再枚举s和t,用\(cnt[s][v]*cnt[v][t]/cnt[s][t]\)计算就可以了

void floyd()
{
    for(int k=1;k<=n;k++)
        for(int i=1;i<=n;i++)
            for(int j=1;j<=n;j++)
            {
                if(e[i][k]+e[k][j]<e[i][j])
                {
                    e[i][j]=e[i][k]+e[k][j];
                    cnt[i][j]=cnt[i][k]*cnt[k][j];
                }
                else if(e[i][j]+e[k][j]==e[i][j])
                {
                    cnt[i][j]+=cnt[i][k]*cnt[k][j];
                }
            }
}
int main()
{
    for(int i=1;i<=n;i++)
    {
        for(int j=1;j<=n;j++)
            e[i][j]=INF;
        e[i][i]=0;
    }
    int l,r,v;
    while(m-->0)
    {
        cin>>l>>r>>v;
        e[l][r]=v;
        e[r][l]=v;
        cnt[l][r]=1;
        cnt[r][l]=1;
    }
    floyd();
    for(int k=1;k<=n;k++)
    {
        double ans=0;
        for(int i=1;i<=n;i++)
            for(int j=1;j<=n;j++)
                if(cnt[i][j]&&e[i][k]+e[k][j]==e[i][j])
                    ans+=(double)(cnt[i][k]*cnt[k][j]/cnt[i][j]);
        printf("%.3lf",ans);
    }
}

Bellman-Ford

这是一个单源最短路算法

也就是能求出从某个点出发到剩下所有点的最短路

这个算法用\(dis[v]\)表示从s到v的距离

算法总共进行n-1轮,每轮枚举所有的边u到v,来做s到u到u的松弛操作

不难理解,第一轮会求出和s间距一条边的最短路,第二轮会求出间距小于等于2条边的最短路,第n-1轮回求出间距小于等于n-1条边的最短路

由于最短路最多有n-1条边,所以n-1轮之后每个点都求到了真正的最短路

显然这个算法的复杂度是\(O(NM)\)的,n是点数,m是边数

显然这个算法适合直接结构体来存边,也就是边表

void bellman_ford()
{
    for(int i=1;i<=n;i++)
        dis[i]=INF;
    dis[s]=0;
    for(int k=1;k<n;k++)
    {
        for(int i=1;i<=ecnt;i++)
            if(dis[e[i].x]+e[i].v<dis[e[i].y])
                dis[e[i].y]=dis[e[i].x]+e[i].v;
    }
}

SPFA

上面的一个算法有一个优化,实际上就是spfa

我们每轮操作其实并不一定要把所有的边都松弛一遍

因为有一些店在上一轮之后最短路并没有发生变化,那么从这个点出发的边做松弛也一定没有变化

所以我们不再枚举n-1轮,而是用一个队列,表示刚刚发生过变化,准备要进行松弛的点

每次从队列里拿出一个点,然后枚举这个点的出边并进行松弛,如果出点的值边了,就把出点也加入队列

一个进一步的优化用\(flag[v]\)表示点v是否在队列里,如果已经在了,那显然是不用重复进队的

显然一开始就应该把s放进队列,然后从s开始松弛

也是用邻接链表来村边

void spfa()
{
    for(int i=1;i<=n;i++)
        flag[i]=false;
    for(int i=1;i<=n;i++)
        dis[i]=INF;
    dis[s]=0;
    q[++head]=s;
    for( ;tail<=head; ++tail)
    {
        flag[q[tail]]=0;
        for(int i=1)
    }
}
复杂度

时间复杂度为\(O(VE)\),有的时候并不比Bellman-Ford更快

作用

但是spfa并不是一无是处,当图中有负环的时候,Dijkstra这种纯粹求最短路的算法就挂了

因为负环实际上意味着图中没有最短路

而spfa有三种方式来判断是否存在负环:

1.用\(cnt[v]\)来表示从s到v的最短路经过了多少个点,如果u到v送出了s到v,那么就让\(cnt[v]\)更新为\(cnt[u]+1\),之前提到过,一个正常的最短路不应该有超过n个点的,因此当\(cnt[v]>n\)的时候有内鬼,终止交易

2.统计进队次数,一个点如果入队大于n次

3.dfs班也很简单,如果一个点被松弛了,就直接递归进这个点去松弛别人就可以了。如果一个点递归了一圈又回到自己了,显然有负环。一般第二种方法比第一种方法快一点,而第三种方法比前两种都要快。spfa判负环只能判有没有,不能找哪里,如果要找哪里,要用tarjan算法

P3199 最小圈

思路

问题实际上是求\(C=\sum w[i]/k\),其中i是边,\(w[i]\)是边权,k是边数

问题显然存在二分单调性,也就是如果答案太大,那么不符合最小的要求,但是一定可以找出来一个圈,使得比值小于等于这个答案,如果答案太小,那么一定找不到一个圈,使得比值满足这个答案

那么就可以二分答案ans,如果不存在\(\sum w[i]/k \le ans\),则答案小,否则答案大

把这个式子变一下,就得到\(\sum w[i]-k*ans=\sum(w[i]-ans) \le 0\)

需要注意,负圈不一定是要每条边都小于0,而是只要权值和小于0的就可以刷最短路

所以满足\(\sum(w[i]-ans)\le 0\)的圈实际上就是一个负圈,因为答案是浮点数,所以$<0和 \le0 $的区别不大

那么枚举出答案ans后给每个边权都减去ans,然后spfa判负环就可以了

这个实际上是一个01分数规划的过程

复杂度上线为\(O(nmlogw)\),比较危险

Dijkstra

只要出现单源最短路一定要卡spfa的今天,单源最短路的最佳解法就是Dijkstra

实际上如果用stl的优先队列来写,Dijkstra也不会比spfa复杂到哪里去

Dijkstra其实就是一个堆优化的spfa

spfa每次是从已经准备要松弛别人的点中选出某一个,而Dijkstra则是直接使用优先队列贪心地从中选出dis最小的那个

至于这个贪心的全局最优的证明,可以使用归纳法严格证明

每条边至多被访问一次,所以每个点的松弛次数不会超过边数

所以Dijkstra的时间复杂度为\(O(MlogN)\)

另一种理解

Dijkstra的思路其实是每次从dis中挑出dis最短的那个,之前没有被挑过的点出来松弛

Dijkstra实际上有一个\(O(nm)\)的做法,就是直接用for循环去找这个最短的的点,这也是为什么会有堆优化的Dijkstra的这种说法

本质上是用堆来维护dis数组的,但是要注意

不能直接对dis建堆,然后随着松弛操作改堆里的数据

如果直接改堆的数据,会破坏堆的结构,导致堆不能完成它应有的功能

因此还要没松弛一次就把出点入堆,然后出堆的时候去更新dis

void dijkstra()
{
    for(int i=1;i<=n;i++)
        dis[i]=INF;
    dis[s]=0;
    size=0;
    push((nod){x,0});
    while(size)
    {
        while(size&&heap[1].y>dis[heap[1].x]) pop();
        if(!size) break;
        x=heap[1].x;
        dis[x]=heap[1].y;
        pop();
        for(int i=link[x];i;i=e[i].next)
            if(dis[x]+e[i].v<dis[e[i].y])
            {
                dis[e[i].y]=dis[x]+e[i].v;
                push((node){e[i].y,dis[e[i].y]});
            }
    }
}

P2837 Milk Pumping G

思路

这个题看上去有二分单调性,又涉及到分数,似乎是分数规划

实际上并没有这么复杂,因为n很小,流量的上线也很小

所以直接枚举路径的流量,流量确定之后最大化流量/花费,实际上就是最小化花费

于是就可以跑Dijkstra,要求流量大于等于枚举的流量的边才能参与松弛就行了

int dijkstra(int y)
{
    for(int i=1;i<=n;i++)
        dis[i]=INF;
    dis[1]=0;
    size=0;
    push((nod){1,0});
    while(size)
    {
        while(size&&heap[1].y>dis[heap[1].x]) pop();
        if(!size) break;
        x=heap[1].x;
        dis[x]=heap[1].y;
        pop();
        for(int i=link[x];i;i=e[i].next)
            if(e[i].w>=y&&dis[x]+e[i].v<dis[e[i].y])
            {
                dis[e[i].y]=dis[x]+e[i].v;
                push((node){e[i].y,dis[e[i].y]});
            }
    }
    return dis[n];
}
int main()
{
    ……
    dounble ans=0;
    for(int i=1;i<=1000;i++)
        ans=max(ans,(double)i/dij(i));
    printf("%lld\n",(long long)(ans*1e6));
    ……
}

最短路建模

差分约束

差分约束系统就是给你一堆变量,然后给你一堆形如\(a_i-a_j \le c\)的不等式

像这样两个变量相减的形式就叫做差分,一堆变量相减就是差分系统

差分约束能告诉你差分系统中,任意两个变量最多是多少,或者是最少是多少

做法

如果有a1-a2<=b,a2-a3<=d,a1-a3<=e

假设要求a1-a3的范围,我们发现除了a1-a3<=e的条件之外,还有a1-a2+a2-a3=a1-a3<=b+d

那么如果b+d<=e,a1-a3就<=b+d,否则a1-a3<=e

发现了没,这个操作和最短路的松弛操作特别像

我们用\(e[i][j]\)来表示\(a_i-a_j\le e[i][j]\),那么不难发现,对于一个中间点k,我们有\(e[i][j]=min(e[i]][j],e[i][k]+e[k][j])\),于是我们就用一个最短路的模型表示一个差分约束系统,然后就可以用最短路算法解出想要的变量的差分关系

有的人就会问,如果同时出现左边右边符号颠倒的情况怎么办,一般就可以把符号和两个数的位置变化一下,一般不会出现变化不了的情况

还有一种题型就是判断有没有解,我们要考虑什么情况下无解,情况有很多,但是可以转化为一个情况,就是a-b<=c且a-b>=d而且c<d,可以看成c-d<0,这就意味着一条循环约束出现了负环,如果常规差分约束建完图之后有负环,那么就无解了

模板代码

#include <iostream>#include <cstdio>#include <algorithm>#include <cstring>#include <string>#include <queue>#define int long long using namespace std;const int maxn=50005;struct edge{	int e,next,val;}ed[maxn*2];int en,first[maxn];void add_edge(int s,int e,int val){	en++;	ed[en].next=first[s];	first[s]=en;	ed[en].e=e;	ed[en].val=val;}int n,m;int d[maxn],num[maxn];bool vis[maxn];queue <int> q;bool spfa(int x){	d[x]=0;	q.push(x); 	vis[x]=true;	num[x]++;	while(q.size())	{		int x=q.front();		q.pop();		vis[x]=false;		for(int i=first[x];i;i=ed[i].next)		{			int e=ed[i].e,val=ed[i].val;			if(d[e]>d[x]+val)			{				d[e]=d[x]+val;				if(!vis[e])				{					q.push(e);					vis[e]=true;					num[e]++;					if(num[e]==n+1)					return false;				}			}		}	}	return true; }signed main(){	cin>>n>>m;	for(int i=1;i<=n;i++)	d[i]=2147483647;	for(int i=1;i<=m;i++)	{		int x,y,z;		cin>>x>>y>>z;		add_edge(y,x,z);	}	for(int i=1;i<=n;i++)	add_edge(n+1,i,0);	if(!spfa(n+1))	{		cout<<"NO"<<'\n';		goto end;	}	for(int i=1;i<=n;i++)	cout<<d[i]<<" ";	end: ;	return 0;}

P1993 小k的农场

思路

直接差分约束系统,判断是否有解就可以了

#include <iostream>#include <cstdio>#include <algorithm>#include <cstring>#include <string>#include <queue>#define int long long using namespace std;const int maxn=50005;struct edge{	int e,next,val;}ed[maxn*2];int en,first[maxn];void add_edge(int s,int e,int val){	en++;	ed[en].next=first[s];	first[s]=en;	ed[en].e=e;	ed[en].val=val;}int n,m;int d[maxn],num[maxn];bool vis[maxn];queue <int> q;bool spfa(int x){	d[x]=0;	q.push(x); 	vis[x]=true;	num[x]++;	while(q.size())	{		int x=q.front();		q.pop();		vis[x]=false;		for(int i=first[x];i;i=ed[i].next)		{			int e=ed[i].e,val=ed[i].val;			if(d[e]>d[x]+val)			{				d[e]=d[x]+val;				if(!vis[e])				{					q.push(e);					vis[e]=true;					num[e]++;					if(num[e]==n+1)					return false;				}			}		}	}	return true; }signed main(){	cin>>n>>m;	memset(d,0x3f,sizeof(d));	for(int i=1;i<=m;i++)	{		int op;		cin>>op;		if(op==1)		{			int a,b,c;			cin>>a>>b>>c;			add_edge(a,b,-c);		}		else if(op==2)		{			int a,b,c;			cin>>a>>b>>c;			add_edge(b,a,c);		}		else		{			int a,b;			cin>>a>>b;			add_edge(a,b,0);			add_edge(b,a,0);		}	}	for(int i=1;i<=n;i++)	add_edge(n+1,i,0);	if(!spfa(n+1))	{		cout<<"No"<<'\n';		return 0;	}	cout<<"Yes"<<'\n';	return 0;}

P3275 糖果

思路

跟上一道题目一样差不多,处理一下差分约束系统

a不比b少就是a>=b

a比b少怎么办?

实际上就是a<b等价于a<=b-1

于是a比b少表示为b-a>=1

现在约束能建了,问题是要求总数最小

可以建立一个抽象节点,表示0颗糖的基准线

然后抽象点往所有点约束为1的边,表示每人至少分一颗糖

这里需要注意,由于求的是最小值,约束是a-b>=c,因此这里求的是最长路,要找出最大的那个下限

最后所有人dis就是不得不满足的下限,然后就能得到答案了

传递闭包

传递闭包的数学概念比较抽象,不太好懂,但是做出来很简单

简单说就是,两个关系i-->k和k-->j可以复合出一个新关系i-->j,这就是传递性

给你一个集合,集合里定义了一个关系i-->j,再定义一个关系i-->j,满足:

1.对于所有满足i-->j,使得i能通过-->的复合关系连接到j,但是不满足i-->j

2.除此之外不存在i和j,使得i能通过-->的复合关系链接到j,但是不满足i-->j

精简版说法,就是传递闭包是关系的极大生成集,因为

1.如果a是b的祖先,那么a肯定是b的父母的父母的父母的……

2.不存在如果a是b父母的父母的父母的……,我们就不能称a为b的祖先的情况

实际上还是有点难理解,但是直接看怎么做吧

对于一个邻接矩阵e,\(e[i][j]=0\)不满足关系i-->j,\(e[i][j]=1\)表示满足关系i-->j

然后我们对e做与操作的Floyd,也就是松弛操作为\(e[i][j]=e[i][k] and[k][j]\)

就这?对,我们之前实际上遇到了一个差不多的例题

强连通分量

强连通分量指的是图的一个极大的子图,满足图内任意两点内任意两点之间可以相互到达

需要注意的是,强连通分量的概念是针对有向图的,无向图没有强连通分量的说法,因为对于无向图,只要是一个连通图就能任意互相到达

强连通分量分成两个部分第一个是任意两个点可以相互到达,这个比较好理解

比如完全图,也就是任意两个点都连接两个方向的边

再比如一个有向环,也满足这个条件

而极大的子图就是说,再加入原图中的任意一个点以及这个点的边到这个子图内,都不能满足互相到达的条件

比如一个完全图子完全图,虽然能互相到达,但是不是极大的

Tarjan算法

求一个图的强连通分量一般用tarjan算法,这里的tarjan算法只指dfs树,也就是dfn-low的那一套理论

首先对于任意一个有向图,我们显然可以用dfs来遍历整个图,每个点只经过一次,并不用管所有点都经过没,那么按照dfs递归的关系,把遍历过程画出来,就是一个dfs树

tarjan在dfs树的基础上定义了dfn和low的概念

dfn就是dfs过程被访问到的顺序

而low表示的是这个点能到达的所有点中,dfn最小的点

tarjan算法首先用一个栈按顺序记录dfs遍历过的点,同时计算dfn和low,每当发现一个点的dfn等于low,那么就把这个点以及栈中之后的所有点弹出来,作为一个强连通分量

void tarjan(int x,int fa){    vis[x]=true;    dfn[x]=low[x]=++dfs_cnt;    s[++top]=x;    for(int i=link[x];i;i=e[i].next)    {        if(!dfn[e[i].y])        {            tarjan(e[i].y,x);            low[x]=min(low[x],low[e[i].y]);        }        else if(vis[e[i].y])            low[x]=min(low[x],dfn[e[i].y]);    }    if(dfn[x]==low[x])    {        grouP_cnt++;        int temp;        do{            temp=s[top--];            group[temp]=group_cnt;            vis[temp]=false;        }while(temp!=x)    }}
正确性

为什么 ?

对于子图的一个点,如果这个点可以到达任意一个点,而任意一个点也可以到达这个点,我们就说这个子图是任意两点互相到达的

在tarjan算法中,这个点就是dfn等于low的点

我们注意到,在tarjan算法中,这个点就是dfn等于low的点

我们注意到,在tarjan算法中,只要\(dfn[x]=low[x]\),那么x就会被弹出来,因此对于一个点x的子树中的点y,只有两种情况:要么已经被弹出来了,要么\(low[y]=low[x]\)

不可能\(low[y]<low[x]\),因为x可以到y,所以y能到的x也能到,那么应该\(low[x]\le low[y]\)才对

而如果\(low[y]>low[x]\),那么到回溯到\(low[y]\)对应的那个点的时候,y就被弹出栈了

所以栈里面留下来的也一定都能到达x

同时显然x也能到达它的子节点们

因此当\(dfn[x]=low[x]\)的时候,我们就说x的子树内任意互达

同时这些子树上的点往上最多只能到达x,到不了子树外面的点

因此把任意子树外的点加进来,都会破坏任意互达的条件

再来看为\(dfn[y]=low[y]\)而被提前弹出栈的的y的子树的点

这些碘同样往外最多只能到达y,到达不了x,因此把y子树中的点加进来也不能满足任意互达的条件

所以tarjan算法找到的点的集合,当然也包括这些点之间的边的集合,是强联通分量

P2341 最受欢迎的牛

思路

我们发现对于一个强联通分量内的牛的机会是相同的,要么一起当明星,要么都当不了明星

因为团体内的任意一头牛会爱慕别的牛,如果团体外的所有牛都爱它,那么就会传递到剩下的所有牛的身上

因此我们可以先做tarjan缩点,把一个强连通分量的牛看成一个整体来考虑

原来牛之间的边要转化为团体间的边

这是我们发现,缩点之后的图变成了一个DAG

前面我们提到过,环是强连通分量,因此如果图中还有环,显然还可以继续缩点

因此把所有强连通分量都缩完的图一定是一个DAG

如果整个DAG只有一个点的出度为n,那么显然所有的点都可以到达这个点

所以当我们缩完点之后发现DAG中只有一个点出度为0,那么我们说这个点中的所有的奶牛都可以当明星

本博文为wweiyi原创,若想转载请联系作者,qq:2844938982
原文地址:https://www.cnblogs.com/wweiyi2004/p/15577503.html