笔记:基础图论

UPD20221004:将部分数学公式修改为使用LaTeX进行渲染,新增了 Bellman-Ford 算法的推导,Floyd-Warshall 算法的部分完善。由于现在的代码风格和以前相差极大,以及随着 OI 竞赛普及 O2 优化,手写 inline、register 等关键字进行优化对性能已经几乎不会产生任何提升,近期或许会迭代新的一版代码。

目前包含内容:

  图的定义(邻接矩阵、边表、前向星、邻接表及其 std::vector 实现方式),最短路算法(Floyd-Warshall,Dijkstra 及其堆优化,Bellman-Ford,SPFA),最小生成树算法(Prim 及其堆优化,Kruskal),拓扑排序,图的割点、割边及强联通分量的 Tarjan 算法,差分约束系统。

  拓扑排序中或许会更新 DAG DP

前置知识:递归、DFS、BFS、回溯、栈、队列、树、基础动态规划、链表

(下文中,n 表示图中点的个数,m 表示图中边的个数)

 

一、图的存储

  1、邻接矩阵:

    用二维矩阵  $a_{i,j}, i,j\leq n$ 表示点 $i$ 与点 $j$ 之间是否有边直接相连以及边的状态。

 

  2、边表:

    存储图中的所有边,示例代码中 $x$ 和 $y$ 为边的两个端点,$v$ 为边的状态。

1 struct edge {
2     int x, y, v;
3 } edge[MAX + 10];

 

  邻接矩阵与边表的对比:

    邻接矩阵以矩阵的方式将图存储了起来,显然空间复杂度为 $O(n^2)$,边表则仅存储图中边的信息,显然空间复杂度为 $O(m)$,因而稀疏图中,边表存储效率更高,稠密图中二者相差不大

    1)对于询问图中某两点之间是否有边直接相连,邻接矩阵可以直接检查 $a_{i,j}$的具体值,边表则需进行遍历。

      显然,邻接矩阵的效率更高

    2)对于查找所有与 i 直接相连的点,可以检查 $a_{i,j}, j\in [1, n],j \ne 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 }
邻接表

 

     动态数组的实现方法可以直接用 std::vector 来实现

1 std::vector<int> link[100010];
2 inline void addedge(int x, int y) {
3     link[x].push_back(y);
4 }
vector邻接表

 

  基础问题:对图进行遍历:

    显然可以用 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 }
DFS

 

二、图的最短路问题

  1、带权图:

    已知图 $G=(V,E)$ ,对于边集 $E$ 中的每条边 $e$ 都有一权值 $val_e$ ,称 $G$ 为带权图。

    若存在 $val_e$ 为负值,可称图 $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)$ 的最长路。

    将最短路/最长路记为 $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$ 的邻接矩阵,其中 $A_(i,j)$ 的值为 $i$ 于 $j$ 之间最短的边的长度,$A_{i,i} = 0$ 。若 $i\ne j$ ,且 $i$ 与 $j$ 没有边直接相连,则 $A_{i,j}=\infty $

    令 $F_{0,i,j}=A_{i,j}$,$F{k,i,j}$ 表示从 $i$ 到 $j$ 的路径中,除起点和终点外,仅经过前 $k$ 个点的最短路径,基于动态规划思想,可得出状态转移方程:

      $F_{k,i,j}=min(F_{k-1.i,j}, F_{k-1,i,k}+F_{k-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 }
Floyd-Warshall Algorithm

 

    显然,该算法的时间复杂度为 $O(n^3)$。有关该算法的无后效性及最优子结构属性的证明暂未给出。

    需要注意的是,进行一次 Floyd-Warshall 算法之后,对于任意 $i,j$ ,所求得的 $F_{i,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

 

      显然,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's Algorithm With priority_queue

     

    由于 Dijkstra's Algorithm 的正确性成立建立在边的权值均为负之上,因而当边权存在负值时,该算法的正确性不成立,这时候可以考虑使用 Bellman-Ford 算法,该算法的正确性建立在一个定理之上:对于无负环图而言,任何两个点之间的最短路径中,边的个数最多为 $n-1$,下对该定理进行一个简单的证明:

       假设存在一个无负环图,在这个无负环图中,存在一对点  $(x,y)$ 点 $x$ 到点 $y$ 之间存在一条长度为 $t,t>n-1$ 的严格最短路(任何一条其他路径的长度都小于等于这条路径的长度),那么从 $x$ 到 $y$,沿着最短路走,能够得到点的遍历顺序 $x,a_1,a_2,\cdots ,a_{t-2},a_{t-1},y$,一共 $t+1>n$ 个点,由于这个图一共有 $n$ 个结点,因而这个遍历顺序中,一定存在至少一对编号相同的点 $z$,因而该遍历顺序可以写作 $x,a_1,\cdots ,a_{p-1},z,a_{p+1},\cdots ,a_{q-1}, z,a_{q+1},\cdots ,a_{t-1},y$,可见这条最短路中一定存在一条环 $z,a_{p+1},\cdots ,a_{q-1}, z$,如果这个环的长度大于等于0,那么显然去掉这个环的路径 $x,a_1,\cdots ,a_{p-1},z,a_{q+1},\cdots ,a_{t-1},y$ 的长度要小于等于原始路径的长度,这样就与原始路径为最短路相矛盾,因而这个环的长度小于零,即这个环是一个负环,与假设相矛盾,因而原命题得证。

 

    那么就有以下 DP 式成立:

      假设 $F_{i,j}, i\leq n-1, j\leq n$ 表示从点 $x$ 出发,经过最多 $i$ 条边到达点 $j$ 的最短路,那么有: $F_{i,j}=min(F_{i-1,j}, F_{i-1,k}+v_{k,j})$,显然这个 DP 式也可以用滚动数组来优化。代码如下:

 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值时则反过来。

 

posted @ 2017-03-29 13:53  hinanawi  阅读(1033)  评论(2编辑  收藏  举报