------------------------------施工中---------------------------
前置知识:递归、DFS、BFS、回溯、栈、队列、树、基础动态规划、链表
(下文中,n 表示图中点的个数,m 表示图中边的个数)
一、图的存储
1、邻接矩阵:
用二维矩阵 {ai,j}(n*n) 表示点 i 与点 j 之间是否有边直接相连以及边的状态。
2、边表:
存储图中的所有边,示例代码中 x 和 y 为边的两个端点,v 为边的状态。
1 struct edge { 2 int x, y, v; 3 } edge[MAX + 10];
邻接矩阵与边表的对比:
邻接矩阵以矩阵的方式将图存储了起来,显然空间复杂度为 O(n2),边表则仅存储图中边的信息,显然空间复杂度为 O(m),因而稀疏图中,边表存储效率更高,稠密图中二者相差不大
1)对于询问图中某两点之间是否有边直接相连,邻接矩阵可以直接检查 ai,j 的具体值,边表则需进行遍历。
显然,邻接矩阵的效率更高
2)对于查找所有与 i 直接相连的点,可以检查 ai,j(j ∈ [1, n],j ≠ 0) 的值,时间复杂度为 O(n),边表仍需遍历,时间复杂度为 O(m)
一般情况下邻接矩阵的效率更高。
为了提高这类问题下边表的效率,将边表中的边改为单向边,引入前向星和邻接表。
3、前向星:
对于边集 E,获取其对应单向边表(x 为起点(第一关键字),y 为终点(第二关键字)),将边表按照第一关键字进行排序,对于某一点 i ,记录其在排序后的边表中,以该点为起点的边所在的边表下标区间 [l, r]。
显然,如果要查找所有与 i 直接相连的点,仅需从 l 遍历至 r 即可。
1 int l[MAXN + 10], r[MAXN + 10]; 2 struct Edge { 3 int x, y; 4 friend bool operator < (const Edge a, const Edge b); 5 } edge_list[MAXM + 10]; 6 7 bool operator < (const Edge a, const Edge b) { 8 return a.x < b.x; 9 } 10 11 void prepare() { 12 std::sort(edge_list + 1, edge_list + 1 + m * 2); 13 l[edge_list[1].x] = 1; r[edge_list[m * 2].x] = m * 2; 14 for (int i = 2; i <= m * 2; ++i) if (edge_list[i].x != edge_list[i - 1].x) { 15 r[edge_list[i - 1].x] = i - 1; 16 l[edge_list[i].x] = i; 17 } 18 }
4、邻接表:
两种实现方法:
1)将单向边表以 x 为基 准,剖分成若干条链表;
2)利用动态数组,直接记录所有与 x 直接相连的点。
1 int len, lin[100010];//len 为邻接表的当前长度,lin[i]为以 i 为起点的边所剖成的链表的起点 2 struct node { 3 int y, ne; 4 } edge[200010];//ne 为链表的下一个元素的下标 5 6 inline void addedge(int x, int y) {//在链表头部插入新边的信息 7 edge[++len].y = y ; edge[len].ne = lin[x]; lin[x] = len; 8 }
动态数组的实现方法可以直接用vector来实现
1 std::vector<int> link[100010]; 2 inline void addedge(int x, int y) { 3 link[x].push_back(y); 4 }
基础问题:对图进行遍历:
显然可以用floodfill解决此类问题。将当前点的所有有边直接与该点相连的点进行标记并对这些点进行floodfill即可。实现方法自然有DFS和BFS两种,这里仅给出DFS的代码。
1 bool vis[100010];//vis[i]表示 i 是否已被访问 2 //vector邻接表 3 void DFS(int x) { 4 vis[x] = true; 5 //c++11 6 for (auto y : link[x]) if (!vis[y]) DFS(y); 7 //below c++11 8 for (std::vector<int>::iterator it = link[x].begin(); it != link[x].end(); ++it) 9 if (!vis[*it]) DFS(*it); 10 //without pointer 11 for (std::vector<int>::size_type i = 0; i < link[x].size(); ++i) 12 if (!vis[link[x][i]]) DFS(link[x][i]); 13 } 14 //邻接表 15 void DFS(int x) { 16 vis[x] = true; 17 for (int i = lin[x], y; i; i = edge[i].ne) 18 if (!vis[y = edge[i].y]) DFS(y); 19 } 20 //前向星 21 void DFS(int x) { 22 vis[x] = true; 23 for (int i = l[x], y; i <= r[x]; ++i) 24 if (!vis[y = edge[i].y]) DFS(y); 25 } 26 //邻接矩阵 27 void DFS(int x) { 28 vis[x] = true; 29 for (int i = 1; i <= n; ++i) 30 if (!vis[i]) DFS(i); 31 }
二、图的最短路问题
1、带权图:
已知图 G=(V,E) ,对于边集 E 中的每条边 e 都有一权值 vale ,称 G 为带权图。
若存在vale 为负值,可称图 G 为负权图。
2、路径和路径长度:
对于带权图 G 中的某点对 (a,b) ,从 a 沿图中的若干条边移动至 b,得到边序列 V' ,定义 V' 为 (a,b) 的一条路径, V' 中所有边权值之和为该路径的长度,记为 V(a,b)。
3、负环/正环:
对于 (a,a) 的一条路径 V',若其路径长度为负值,则称 V' 为图 G 的一个负环。
相对地,对于 (a,a) 的一条路径 V',若其路径长度为正值,则称 V' 为图 G 的一个正环。
可以以正/负环是否存在来判断图 G 的最长/最短路是否存在。
4、最短路/最长路:
点对 (a,b) 之间的所有路径中,长度最短的路径 V' 为 (a,b) 的最短路。相对地,点对 (a,b) 之间的所有路径中,长度最长的路径 V' 为 (a,b) 的最长路。
将最短路/最长路记为 min dis(a,b),
显然负环图不存在最短路,正环图不存在最长路,因为可以通过无限次经过负/正环来增加/减少 (a,b) 间的路径长度。
5、松弛
在无负环图中,对于已求得的一条路径 V(a,b) ,若存在中间点 c,使得 V(a,c) + V(c,b) < V(a,b) ,则称令 V(a,b) = V(a,c) + V(c,b) 为一次松弛操作。对于正环图可以得到相似定义。通过松弛操作,可以不断缩小最短路/最长路的上界/下界
6、最短路的 Floyd-Warshall Algorithm:
令 A 为图 G 的邻接矩阵,其中 Ai, j 的值为 i 于 j 之间最短的边的长度,Ai, i = 0 。若 i ≠ j ,且 i 与 j 没有边直接相连,则 Ai, j = ∞
令 F0, i, j = Ai, j ,基于动态规划思想,可得出状态转移方程:
Fk, i, j = min(Fk-1, i, j, Fk-1, i, k + Fk-1, k, j), k > 0
显然可以进行滚动数组优化,节省掉 F 的第一维空间。
具体代码实现如下:
1 int a[510][510]; 2 void floyd() { 3 for (int i = 1; i <= n; ++i) a[i][i] = 0; 4 for (int k = 1; k <= n; ++k) for (int i = 1; i <= n; ++i) for (int j = 1; j <= n; ++j) 5 if (a[i][j] > a[i][k] + a[k][j]) a[i][j] = a[i][k] + a[k][j]; 6 }
显然,该算法的时间复杂度为 O(n3)。有关该算法的无后效性及最优子结构属性的证明暂未给出。
需要注意的是,进行一次 Floyd-Warshall 算法之后,对于任意 i, j ,所求得的 Fi, j 均为最短距离。称这样的算法为全源最短路径算法。相对地,进行一次单源最短路径算法,求得的则是从某个特定的点出发,到其他点的最短距离。
7、最短路的 Dijkstra's Algorithm 及其堆优化
在某些情况下并没有必要求全源最短路,比如询问均是从某个特定的点出发的,显然这时候仅求单源最短路即可。
1>引入 Dijkstra's Algorithm ,该算法的正确性仅基于非负权图,其具体思想大致如下:
1)初始化:新建一个空白图,仅将某个特定的点加入图中,视这个点为源点 s 。
2)设 disi 为当前图中从源点到点 i 的最短距离,显然最初只有 diss 的值为0,其他点的 dis 值均为 ∞ 。
3)基于贪心思想,寻找当前图中的所有之前没有当过中间点的所有点中,dis 值最小的一个,若不存在这样的点,则算法结束。
4)对于找到的点,将其作为中间点,更新其他点的 dis 值(如果可能)。
5)将该点打上“已作为中间点使用过”的标记,并重复步骤 3-5 。
2>下面证明该算法的正确性:
假设当前中间点比之前使用过的中间点的 dis 值更小,那么由于原图为非负权图,当前点一定在之前那个 dis 值更大的点被选择的时候, dis 值更小,因而当前点一定不会在那个 dis 值更大的点之后更新。这样就保证了所使用的中间点的 dis 值的不递减性。在这基础上,当某个点作为中间点被更新时,其 dis 值一定已经达到了最小,因为之后使用的中间点的 dis 值都大于该点的 dis 值,自然不可能通过非负权边来更新该点的 dis 值。
因此,当某个点的 dis 值不是最短路径时,仅有两种情况,一是该点还未被某中间点更新到,二是从源点没有能够到达该点的路径。
显然,对点 s 进行一次 Dijkstra's Algorithm,最终求得的 dis 值,若其值为 ∞ ,则 s 到这个点没有路径,其余情况下的 dis 值均为点 s 到该点的最短路径长度。
代码如下:
1 int dis[10010], a[10010][10010], n; 2 bool vis[10010]; 3 void dijkstra(int s) { 4 memset(vis, false, sizeof(vis)); 5 memset(dis, 0x3f, sizeof(dis)); 6 dis[s] = 0; 7 int x; 8 for (x = 0; ; x = 0) { 9 for (int i = 1; i <= n; ++i) { 10 if (vis[i]) continue; 11 if (dis[i] < dis[x]) x = i; 12 } 13 if (!x) return; 14 vis[x] = true; 15 for (int i = 1; i <= n; ++i) if (dis[i] > dis[x] + a[x][i]) dis[i] = dis[x] + a[x][i]; 16 } 17 }
显然,Dijkstra's Algorithm 的时间复杂度为 O(n2)
3>堆优化
由于每次取的点都是未作为中间点的 dis 值最小的点,可以定义一个小根堆,并将 dis 值作为第一关键字,在每次更新某个点的 dis 值时,将这个点的 dis 值和标号放入小根堆中(显然可以用 STL 中的 pair 或者自定义数据结构(需重载 <)进行存储。),这样就能保证每次取出的点在被放入堆中时, dis 值都是最小的,剩下需要做的就只剩去重了。当然,去重也很简单,如果这个点已被标记,那么再取一个即可。
考虑这样进行优化的时间复杂度,因为共有 m 条边,最坏情况下 m 条边都会被用来更新 dis 值,这就意味着堆中最多只有 m 个点,时间复杂度为 O(mlogm) 每个点只被作为中间点使用 1 次,因此更新的总复杂度是 O(m)。进行一次堆优化的 Dijkstra's Algorithm 的时间复杂度则为 O(mlogm),但显然通常不会达到这样的复杂度,因为一般来说 m 条边不可能都会被用来更新 dis 值。
堆优化后的 Dijkstra's Algorithm 代码如下:
1 using pii = std::pair<int, int>; 2 std::priority_queue< pii, std::vector<pii>, std::greater<pii> > q; 3 int len = 0, lin[100010]; 4 struct node { 5 int y, ne, v; 6 } edge[1000010]; 7 8 inline void add_edge(int x, int y, int v) { 9 edge[++len].v = v; edge[len].ne = lin[x]; edge[len].y = y; lin[x] = len; 10 } 11 12 int dis[100010]; 13 bool vis[100010]; 14 void dijkstra(int s) { 15 memset(dis, 0x3f, sizeof(dis)); 16 memset(vis, false, sizeof(vis)); 17 dis[s] = 0; 18 q.push(std::make_pair(0, s)); 19 while (!q.empty()) { 20 int x; 21 pii tmp = q.top(); 22 q.pop(); 23 x = tmp.second; 24 if (vis[x]) continue; 25 vis[x] = true; 26 for (int i = lin[x], y; i; i = edge[i].ne) if (dis[y = edge[i].y] > dis[x] + edge[i].v) { 27 dis[y] = dis[x] + edge[i].v; 28 q.push(std::make_pair(dis[y], y)); 29 } 30 } 31 }
但是Dijkstra是不能求带有负权路的最短路径的,因为当出现负权路时,已经松弛过的点还能继续进行松弛,而Dijkstra是基于每个点只松弛一次,所以求不出带负权边的最短路。
这时候我们还是只需要更新n次,每次基于所有边进行松弛,如果不能松弛则退出。这就是Bellman-Ford的思路。代码如下:
1 void bellmanford(int s) 2 { 3 memset(dis,0X3f,sizeof(dis)); 4 dis[s]=0; 5 bool rel; 6 for (int i=1;i<=n;i++) 7 { 8 rel=false; 9 for (int j=1;j<=len;j++) 10 if (dis[edge[j].y]>dis[edge[j].x]+edge[j].v) 11 { 12 dis[edge[j].y]=dis[edge[j].x]+edge[j].v; 13 rel=true; 14 } 15 if (!rel) return; 16 } 17 }
需要注意的是Bellman-Ford使用的是边表,时间复杂度为V*E。
因为Bellman的特性,我们可以对其进行优化,我们用队列维护所有更新过的点,每次取队头的点进行更新,当更新到最短路时就将最短路的终点加入队列中(前提是这个点不在队列中),这就是SPFA的大致思路。代码如下:
1 int queue[MAX]; 2 void spfa(int s) 3 { 4 int Head=0,tail=1; 5 memset(vis,false,sizeof(vis)); 6 memset(dis,0x3f,sizeof(dis)); 7 queue[1]=s; dis[s]=0; vis[s]=true; 8 while (Head<tail) 9 { 10 int tn=queue[++Head]; 11 vis[tn]=false; 12 int te=head[tn]; 13 for (int i=te;i;i=edge[i].ne) 14 { 15 int tmp=edge[i].y; 16 if (dis[tmp]>dis[tn]+edge[i].v) 17 { 18 dis[tmp]=dis[tn]+edge[i].v; 19 if (!vis[tmp]) 20 { 21 vis[tmp]=true; 22 queue[++tail]=tmp; 23 } 24 } 25 } 26 } 27 }
注意:邻接表
我们可以发现,无论是哪种算法,都是严格按照三角形定律进行更新的,即:如果两边之和小于第三边,那么这两边之和就是起点到终点的新的最短路。
接下来是图的最小生成树问题。
所谓的生成树,就是通过点与点之间的关系,将某个点作为整个生成树的根,连接图中的所有点。最小生成树就是求生成树中用到的边权值的最小和。
需要注意的几点:1、我们求得的生成树连接了所有的点,因此图必须保证是连通的。2、已经连接到的点是否需要再次进行连接?我们可以运用树的特性来证明这一点:在一个树中,根节点没有父节点,除了根节点之外的所有点的父节点只有一个,因此不需要连接重复的点。这样的话我们就可以建立一个bool数组,记录每个点是否已经在最小生成树中。3、每次应该如何取边?同样是贪心的方法:我们用一个dis数组记录当前已经搜索到的能到达某个点的最短边,每次取出最短的边,然后将这个边的末端的点进行更新,即如果这个点能够连接到的边权值小于当前已知的最小边权值时,用这个边权值来替代它。这就是Prim算法的具体思想,代码实现和Dijkstra很相似,如下:
1 void prim(int s) 2 { 3 memset(dis,0x3f,sizeof(dis)); 4 memset(vis,false,sizeof(vis)); 5 for (int i=1;i<=n;i++) dis[i]=a[s][i]; 6 vis[s]=true; sumn=0;//只有点s已做过松弛 7 for (int i=2;i<=n;i++) 8 { 9 int minn=MAX,c=0; 10 for (int j=1;j<=n;j++)//搜索能到达的最短的边 11 if (!vis[j]&&dis[j]<minn) 12 { 13 minn=dis[j]; 14 c=j; 15 } 16 vis[c]=true; 17 sumn+=minn; 18 for (int j=1;j<=n;j++)//基于这个点进行松弛 19 if (a[c][j]<dis[j]&&!vis[j]) 20 dis[j]=a[c][j]; 21 } 22 }
注意这里使用了邻接矩阵,因此复杂度是V*V的。因为其思想与Dijkstra相似,所以同样也可以进行堆优化,堆优化的思路与Dijkstra的堆优化思路相似,这里不作证明,只给出代码:
1 typedef pair <int,int> pii; 2 void prim(int s) 3 { 4 priority_queue<pii,vector<pii>,greater<pii> > q; 5 memset(vis,false,sizeof(vis)); 6 memset(dis,0,sizeof(dis)); 7 vis[s]=true; sumn=0; 8 for (int i=head[s];i;i=edge[i].ne){ 9 q.push(make_pair(edge[i].v,edge[i].y)); 10 dis[edge[i].y]=edge[i].v; 11 } 12 for (int i=2;i<=n;i++){ 13 pii a=q.top(); q.pop(); 14 int minn=a.first,p=a.second; 15 while (vis[p]){ 16 pii a=q.top(); q.pop(); 17 minn=a.first,p=a.second; 18 } 19 vis[p]=true; 20 sumn+=minn; 21 for (int i=head[p];i;i=edge[i].ne) 22 if (!vis[edge[i].y]) q.push(make_pair(edge[i].v,edge[i].y)); 23 } 24 }
这个代码未经证明,使用的时候需要注意一下。堆优化的Prim同样也是使用了邻接表,时间复杂度也是V*logE。
由Prim算法的证明我们可以得知每次取的都是最短的边,这样的话我们可以想到另外一种算法,既然取最短的边的话,我们可以使用边表,将边的权值按照从小到大进行排列,这样的话我们只需要一次遍历就能求出来最小生成树了,问题又来了:我们如何判断是否将当前边所连接的点已经在生成树中?同样根据树的特性我们可以采用并查集的方法将点进行合并,如果当前边所连接的点不在生成树中就将其加入生成树中,这就是Kruskal算法的大致思路,代码如下:
1 #include<algorithm> 2 struct edges{ 3 int x,y,v; 4 }edge[MAX]; 5 int father[x]; 6 int getfather(int x) 7 {return (father[x]==x)? x:father[x]=getfather(father[x]);} 8 9 bool mycmp(edges x,edges y) 10 {return x.v<y.v;} 11 12 void kruskal() 13 { 14 for (int i=1;i<=n;i++) father[i]=i; 15 sort(edge+1,edge+1+len,mycmp); 16 int cnt=0; 17 for (int i=1;i<=len;i++){ 18 int v=getfather(edge[i].x); 19 int u=getfather(edge[i].y); 20 if (v!=u){ 21 father[v]=u; 22 if (++cal==n-1) 23 return; 24 } 25 } 26 }
我们可以发现这种算法的复杂度基本是由排序算法的复杂度决定的,我们使用了快排,因此复杂度为E*logE。
接下来是拓扑排序(Topsort),Topsort维护的是图中的先后顺序,因此当图中出现环的时候是无法求出拓扑序的。那么当图中没有环时应该如何进行拓扑排序?仔细想想,我们可以记录所有点的入度,然后将所有入度为0的点入队,每次枚举队头,将其能到达的点的入度减一,我们称这个操作为删边,当某个点的入度为0时就将其进入队列,这是BFS求拓扑序的思路,DFS思路和图的遍历的DFS方法是差不多的,注意删边。
仔细想想:我们根据BFS的算法思路可以得知一次BFS只能求出一种拓扑序,如果要求出所有的拓扑序的话,我们需要使用BFS,并在BFS上加入回溯即可。
Topsort算法代码如下:
1 //Topsort(邻接矩阵,队列,BFS) 2 void topsort() 3 { 4 int head=0,tail=0; 5 for (int i=1;i<=n;i++)//初始化队列,使队列中所有入度为0的点入队 6 if (id[i]==0) queue[++tail]=i;//id为i的入度 7 while (head<tail){ 8 int i=queue[++head]; 9 for (int j=1;j<=n;j++) 10 if (a[i][j]){ 11 id[j]--; 12 if (id[j]==0) queue[++tail]=j; 13 } 14 } 15 } 16 17 //Topsort(邻接表,队列,BFS) 18 void topsort() 19 { 20 int Head=0,tail=0; 21 for (int i=1;i<=n;i++) 22 if (id[i]==0) queue[++tail]=i; 23 while (Head<tail){ 24 int te=queue[++Head]; 25 int tn=head[te]; 26 for (;tn!=-1;tn=edge[tn].ne){ 27 id[edge[tn].y]--; 28 if (id[edge[tn].y]==0) queue[++tail]=edge[tn].y; 29 } 30 } 31 } 32 33 //Topsort(邻接矩阵,DFS,可求出所有拓扑序) 34 void topsort(int i,int sum)//i为当前元素的位置,sum为队列中的元素个数 35 { 36 if (sum==n){ 37 flag=true; return; 38 } 39 for (int j=1;j<=n;j++) 40 if (a[i][j]) 41 id[j]--; 42 for (int j=1;j<=n;j++){ 43 if (!used[j]&&id[j]==0){ 44 used[j]=true; 45 q[sum+1]=j;//将点加入队列中 46 dfs(j,sum+1); 47 used[j]=false; 48 } 49 if (flag) return;//不加上则可求出所有的拓扑序,但需要特殊处理 50 } 51 for (int j=1;j<=n;j++) 52 if (a[i][j]) 53 id[j]++;//回溯,可求出所有拓扑序 54 } 55 56 //Topsort(邻接表,DFS,可求出所有拓扑序) 57 void topsort(int i,int sum) 58 { 59 if (sum==n){ 60 flag=true; return; 61 } 62 for (int j=head[i];j!=-1;j=edge[j].ne) id[edge[j].y]--; 63 for (int j=head[i],y;j!=-1;j=edge[j].ne){ 64 if (id[edge[j].y]==0&&!used[y=edge[j].y]){ 65 used[y]=true; 66 q[sum+1]=y; 67 dfs(y,sum+1); 68 used[y]=false; 69 } 70 if (flag) return; 71 } 72 for (int j=head[i];j!=-1;j=edge[j].ne) id[edge[j].y]++; 73 }
接下来是图的割边,割点以及强连通分量,因为在这里仅讨论Tarjan算法,所以我们将这三者同时进行讨论:
对于割点来说,我们可以用N次DFS来判断,每次删除一个点,然后判断图是否连通,这样的算法效率显然是极低的。我们知道DFS算法会形成一颗树,对于每一棵DFS树来说,其子节点不会通向任意一个根节点,我们称这个子节点到其之前的边为返祖边,那么如果我们要判断一个点是否为割点,在它形成的DFS树中,不会有任何一个点能够通向其根节点之前的点,根据这样的思路,我们可以建立一个dfn数组和一个low数组,dfn记录的是点当前的DFS层数,low记录点的最小的DFS层数,我们在DFS的同时对这两个数组进行更新,对于当前点i,如果其能够通向其之前的任意一个节点j,那么用dfn[j]来更新low[i],如果这个节点的子节点j的low值小于low[i],用low[j]来更新low[i],并将更新后的low[i]与dfn[j]进行比较,如果dfn[j]>=low[i],那么将i的子节点加一,这样我们判断割点的情况就很简单了,一个点是割点,如果这个点存在父节点,那么它的子节点的个数一定大于等于1,如果不存在父节点,那么它的子节点个数一定大于等于2,这样的点就是要求的割点。代码如下:
int dfn[MAX],low[MAX],ind=0; void tarjan(int x,int par=0){ dfn[x]=low[x]=++ind; son=0; for (int i=head[x],y;i;i=edge[i].ne) if ((y=edge[i].y)!=par){//防止访问父节点 if (!dfn[y]){ tarjan(y,x); if (low[y]<low[x]) low[x]=low[y];//更新low[x] if (low[y]>=dfn[x]) son++;//如果low[y]>dfn[x],则此子树上没有返祖边 } else if (dfn[y]<low[x]) low[x]=dfn[y]; } if (son>=2||(son==1&&par)) ans[++tot]=x; }
割边的思路与割点相似,其父边需要用一个反向边的下标来注释,一条边是割边,当且仅当其起点的dfn值等于low值,证明思路与求割点相似。代码如下:
1 void tarjan(int x,int par=0){ 2 dfn[x]=low[x]=++ind; 3 for (int i=head[x],y;i;i=edge[i].ne) 4 if (i!=par){ 5 if (!dfn[y=edge[i].y]){ 6 tarjan(y,rev[i]);//rev[i]为i的反向边 7 if (low[y]<low[x]) low[x]=low[y]; 8 } 9 else if (dfn[y]<low[x]) low[x]=dfn[y]; 10 } 11 if (low[x]==dfn[x]) ans[++tot]=par; 12 }
强连通分量与割边和割边不太相似,后两者是无向图,而强连通分量则是存在于有向图中的,它指的是无向图中的极大连通子图,我的理解就是无向图中的不被任何其他环所包括的环。如何判断环呢?其实很简单,我们只需要判断一个点i所连接的一个点j是否能够相互连通就好了,但是我们不能保证这个环不被其他环所包括,且无法确定某个点处于哪个强连通分量中。
为了解决这个问题,我们需要引入栈,将所有访问过的点压入栈中,然后和割边割点一样的方法对dfnlow进行更新,如果出现dfn[i]==low[i]时,将i之后能访问到的所有点弹出栈,并将其记录在同一个强连通分量中,这样的操作我们也称为缩点,代码如下:
1 int stack[MAX],top=0; 2 void tarjan(int x){ 3 dfn[x]=low[x]=++ind; 4 vis[stack[++top]=x]=true; 5 for (int i=head[x],y;i;i=edge[i].ne){ 6 if (!dfn[y=edge[i].y]){ 7 tarjan(y); 8 if (low[y]<low[x]) low[x]=low[y]; 9 } 10 else if (vis[y]&&dfn[y]<low[x]) low[x]=dfn[y]; 11 } 12 if (dfn[x]==low[x]){ 13 int k; tot++; 14 do{ 15 k=stack[top--]; 16 vis[k]=false; 17 bel[k]=tot; 18 }while (k!=x); 19 } 20 }
接下来是最后一环——差分约束系统。
差分约束系统只是一种建图的方法。
我们先来看一些不等式组:a>b; b=a; c>=a; b<c,我们可以将其转化为:b+1<=a; b+0<=a; a+0<=b; a+0<=c; b+1<=c;
对于这样的不等式组,我们可以想到SPFA中的松弛操作:
if (dis[tn]+edge[i].v<dis[tmp]) dis[tmp]=dis[tn]+edge[i].v;
那么我们就可以将这个不等式组转化为图的形式:
对于一个不等式组b+1<=a来说,我们可以将b看做一条边的起点,将a看做该边的终点,1为边权值,这样我们就可以建立条从b到a的有向边,这条边的边权值为1.
这就是差分约束的具体建图方法。
我们来列出对于所有不等式的建图方法:
1、a>b+n -> b+1+n<=a -> b到a有一条边权值为1+n的边
2、a>=b+n -> b+n<=a -> b到a有一条边权值为n的边
3、a==b+n -> a>=b+n&&a<=b+n -> b到a有一条边权值为0的双向边
4、a<=b+n -> a到b有一条边权值为-n的边
5、a<b+n -> a+1-n<=b -> a到b有一条边权值为1-n的边
建图的方法有了,那么我们如何求最小或最大的k值,使得对于任意一个点都有一个值v,使得0<=v<=k,并让图中的所有不等式都成立呢?都有边了直接SPFA不就好了嘛= =
当然方法不止SPFA。
如果当一个不等式中不存在2、3、4条件时,显而易见我们可以进行Topsort来求k值,这样的效率大概是快于SPFA的,代码如下:
1 bool spfa() 2 { 3 memset(dis,0,sizeof(dis)); 4 int Head=0,tail=0; 5 for (int i=1;i<=n;i++) 6 if (id[i]==0) queue[++tail]=i; 7 while (Head<tail){ 8 int tn=queue[++tail]; 9 for (int i=head[tn],y;i;i=edge[i].ne){ 10 id[y=edge[i].ne]--; 11 dis[y]=max(dis[y],dis[tn]+edge[i].v); 12 if (id[y]==0) queue[++tail]=y; 13 } 14 } 15 if (tail<n) return true;//如果访问的点的个数小于当前点的个数,返回真,否则返回假 16 return false; 17 }
3条件存在时我们需要将双向边改为单向边,据不完全测试,Topsort可以求出最小k值= =
注意在这些不等式组建立成的图中是可以出现环的,但无论是自环还是其它环(前提是这个环中存在一个边的边权值e[i].v!=0)都不能使这个不等式组成立,我们在Topsort中采用了删边,所以出现环的时候我们访问不到所有的边,这时候只需要将队列中元素的个数与点的个数比较就好,但是如果SPFA中出现了环了呢?
显然我们可以证明出现自环的情况下会做无限松弛,一个特判就好了(其实也可以tarjan求强连通分量个数,前提是不存在==的约束边)。
于此同时我们一般上要建立一个超级源点s,这个源点到其它所有的点都存在边,我们只需要关于s做SPFA,代码如下
bool spfa() { while (Head<tail){ int tn=queue[++Head]; for (int i=head[tn],y;i;i=edge[i].ne) if (dis[y=edge[i].y]<dis[tn]+edge[i].v){ dis[y]=dis[tn]+edge[i].v; if (dis[y]>n) return true; if (!vis[y]){ vis[y]=true; queue[++tail]=y; } } vis[tn]=false; } return false; }
当然我们还可以采用另外一种方法:先将所有点入队后再开始做SPFA,实际上是和建立源点S一样的,但是这种方法莫名地比建立源点快= =所以对于查分约束系统来说我们可以直接采用这一种方法来求解。此外还需要判断题意,即题目要求解出最大k值还是最小k值,然后根据题意建立适当的不等式图。当我们要求最小k值时,以<=为基准建立有向图,SPFA的松弛操作中以大于为基准,求最大k值时则反过来。