目录
图的基础问题
图上的环
无向图的环
DAG图与拓扑排序
拓扑排序
卡恩算法(BFS)
算法描述
统计图中每个点的入度(即连向该点的边数)
拓扑排序的DFS算法
算法描述
拓扑排序的DFS的实现简单,从一个入度为0的节点开始做DFS,每访问到一个节点,标注为已访问,下次重复访问到时直接跳过。当所有子节点都访问过后,将当前节点加入列表。这样最终将会得到一个叶子节点在前,根结点在最后的节点列表。将该列表反转,得到的就是拓扑排序好的节点。
最小生成树
最小生成树定义
Kruskal算法
最短路径算法
SPFA算法
算法描述
最短路径问题
单源最短路问题
Dijkstra算法
算法描述
Dijkstra优化
算法描述
权值为1的最短路
Manacher算法
最长回文子串问题
暴力算法
算法描述
Manacher 算法是一种可以在O(n)的时间复杂度求出一个长为 n 的字符串的最长回文子串的算法,它思路简单、代码简单,但功效却十分强大。
算法证明
DAG图与拓扑排序
拓扑排序
卡恩算法(BFS)
算法描述
统计图中每个点的入度(即连向该点的边数)
拓扑排序的DFS算法
算法描述
有向无环图(DAG)
DAG图的判定
图的基础问题
图上的环
环也称回路,是图论里的概念。一个环是一个边的排列,并且沿着这个排列走一次可以走回起点。有向图和无向图中均存在环。
在无向图中,不存在环路的图是一棵树。
在有向图中,不存在环路的图是一张有向无环图。
无向图的环
判断一个无向图中是否存在环的方法很简单:
从任意点开始,对图做DFS。遍历点的过程中,将点标注为已访问。
如果遍历到某个点时,发现其已经被标注为已访问,则表示图中存在环。
bool Dfs(int u){if(visted[u])return true; visted[u] = true;for(v in 与点u有连边的点)if(Dfs(v))return true;return false;
}
DAG图与拓扑排序
拓扑排序
在图论中,由一个有向无环图的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑排序:每个顶点出现且只出现一次;
若A在序列中排在B的前面,则在图中不存在从B到A的路径。
从拓扑排序的概念我们就可以看出,只有有向无环图才存在拓扑排序。因此,如果有算法可以帮我们判断一张图是否存在拓扑排序,我们就能判断这张图是否为有向图。事实上,我们不仅能很快的判断一张图是否存在拓扑排序,还能顺便求出它的拓扑排序。
一个图G的拓扑排序大多情况下是不唯一的。
卡恩算法(BFS)
算法描述
统计图中每个点的入度(即连向该点的边数)
将入度为0的点放入队列
每次从队列中取出一个点u,遍历从这个点出发的所有出边 (u−>v) ,删除u−>v这条边(代码实现上仅表现为让v的入度−1),若v的入度变为0,则将v放入队列
重复(3)直到队列为空,若所有点都进出过队列,则点的出队顺序即为这张图的拓扑序,否则说明该图不存在拓扑排序(存在有向环)。
using namespace std;
const int N = 2e5 + 100;
const int M = 4e5 + 100;
int head[N], Next[M], ver[M], tot;
int deg[N];
void add(int x, int y) {ver[++tot] = y;Next[tot] = head[x];head[x] = tot;
}
queue<int>qc;
int topsort(int n) {int cnt = 0;while (qc.size())qc.pop();for (int i = 1; i <= n; i++) {if (deg[i] == 0) {qc.push(i);} }while (qc.size()) {int x = qc.front();cnt++;qc.pop();for (int i = head[x]; i; i = Next[i]) {int y = ver[i];if (--deg[y] == 0)qc.push(y);} }return cnt == n;
}
int main() {int n, m, x, y;cin >> n >> m;for (int i = 1; i <= m; i++) {cin >> x >> y;add(x, y);deg[y]++;}if (topsort(n))cout << "Yes" << endl;else cout << "No" << endl;
}
拓扑排序的DFS算法
算法描述
拓扑排序的DFS的实现简单,从一个入度为0的节点开始做DFS,每访问到一个节点,标注为已访问,下次重复访问到时直接跳过。当所有子节点都访问过后,将当前节点加入列表。这样最终将会得到一个叶子节点在前,根结点在最后的节点列表。将该列表反转,得到的就是拓扑排序好的节点。
最小生成树
最小生成树定义
G是一张无向图,边集为E(第i条边的边权为wi),点集为V,在E中选出|V|−1条边,恰好构成一颗包含V中所有点的树T,就称T是G的一颗生成树。最小生成树即为这些生成树中边权和最小的树。
解决最小生成树问题,在信息学竞赛中我们常使用Kruskal算法或Prim算法,下面我们将逐个介绍这两个算法。
Kruskal算法
Kruskal算法是一种用来查找最小生成树的算法,由Joseph Kruskal在1956年发表。Kruskal算法利用了贪心的思想。
考虑构造最小生成树T,开始时T中只有n个点,没有加任何边,按边权从小到大的顺序枚举m 条边,如果加入当前边不会使得我们的T中出现环,就把这条边加入T中,最后如果T中加入了n−1条边,则T为我们要求的最小生成树,否则生成树不存在。
那么,在什么时候加入一条边(u,v会使得T中出现环呢?
显然,如果原本就存在一条u和v之间的路径(即u,v连通),那么加入(u,v)这条边后,就会出现环。所以我们只需要判断一下u,v当前是否连通,就能知道加入这条边后T中是否会出现环了。
而不断加边并判断某两个点是否连通,在前面的章节中我们学到,用并查集可以判断无向图中两点是否连通。
最短路径算法
SPFA算法
除了Dijkstra算法外,我们还有另外一种在大多情况下都可以高效解决单源最短路问题的算法——SPFA 算法。SPFA 算法是 Bellman−Ford 算法 的队列优化算法的别称,通常用于求含负权边的单源最短路径,以及判负权环。SPFA最坏情况下复杂度和朴素Bellman−Ford相同,为O(MN) 。SPFA 可以处理有负权(边权大小为负数)的情况,同时还可以处理图中的负环(权值为负数的环路)。SPFA算法在大多数情况下表现良好,与Dijkstra 表现不相上下,但在网格图中SPFA算法的复杂度会退化到O(nm)。
算法描述
同样用dis[i]表示源点s到i的最短路径长度,初始时dis[s]=0,其余点dis值为无穷大
将s放入队列。每次取出队首节点u,遍历从u出发的所有边u−v(假设边权为c),尝试用dis[u]+c更新dis[v],若dis[v]>dis[u]+c,则令dis[v]=dis[u]+c,并把 v 放入队列
重复(3)直到队列为空
算法实现
#define inf 1000000000
#define N 200020
using namespace std;
queue<int>Q;
int dis[N];
bool vis[N];
int n, m, s;
int head[N], pos;
struct edge { int to, next, c; }e[N];
void add(int a, int b, int c){pos++; e[pos].to = b, e[pos].c = c, e[pos].next = head[a], head[a] = pos;
}
void spfa(){for (int i = 1; i <= n; i++)dis[i] = inf, vis[i] = 0;dis[s] = 0, vis[s] = 1, Q.push(s);while (!Q.empty()) {int u = Q.front(); Q.pop(); vis[u] = 0;for (int i = head[u]; i; i = e[i].next){int v = e[i].to;if (dis[v]>dis[u] + e[i].c) {dis[v] = dis[u] + e[i].c;if (!vis[v])vis[v] = 1, Q.push(v);}}}}
int main(){cin >> n >> m >> s;for (int i = 1; i <= m; i++) {int x, y, c;cin >> x >> y >> c;add(x, y, c);add(y, x, c);}spfa();}
最短路径问题
单源最短路问题
给出一张n节点,m条边的图(可以是有向图也可以是无向图),第i条边有边权wi,求出给定点s到每个点的最短路的长度。
Dijkstra算法
迪杰斯特拉算法(Dijkstra)是由荷兰计算机科学家Dijkstra于1959 年提出的。是从一个顶点到其余各顶点的最短路径算法,解决的是有权图中最短路径问题。迪杰斯特拉算法主要特点是以起始点为中心向外层层扩展,直到扩展到终点为止。
Dijkstra的朴素实现的复杂度为O(n2),用堆进行优化后的复杂度为O((n+m)×log(n+m)) 。
算法描述
用dis[u]表示从源点s到u当前找到的最短路径长度
如果存在一条边u−>v,边权为c,那么dis[v]=min(dis[v],dis[u]+c),即可以先从s走到u,再从u通过这条边走到v。
因此,对于最终的dis数组,我们有不等式关系dis[v]<=dis[u]+c(存在边 u−>v ,边权为c)
那么,如何利用这种关系求出最后的dis数组呢
Dijkstra的解决方案是: 把点分成两个集合A,B,集合B中是尚未确定最终dis值的点,集合A中是已经确定dis值的点,初始时所有点都在 B 中,且除了s的dis值为0外,其余点的dis值为无穷大(取尽量大的值作为无穷大)
每次从集合B中取出dis值最小的点u(u为集合B中到s最近的点),遍历所有从u出发的边(u−>v),假设边权为c,用dis[u]+c更新 dis[v] ,即dis[v]=min(dis[v],dis[u]+c)
把u点放入A集合
循环(2)(3)直到B集合为空
Dijkstra的普通实现复杂度为O(n2) 。
特别提醒:Dijkstra不能处理有负权(边权大小为负数)的情况。
#include<bits/stdc++.h>
#define inf 1000000000
#define N 200200
using namespace std;
int head[N], pos;
struct edge { int to, next, c; } e[N];
void add(int a, int b, int c) {pos++; e[pos].c = c, e[pos].to = b, e[pos].next = head[a], head[a] = pos;
}
int n, m, s;
bool vis[N];
int dis[N];
void dijkstra(){for (int i = 1; i <= n; i++)dis[i] = inf;dis[s] = 0;int cnt = 0;while (cnt < n) {int mn = inf, u = 0;for (int i = 1; i <= n; i++)if (!vis[i] && dis[i] < mn)mn = dis[i], u = i;vis[u] = 1;//vis[u]=1意味着把u放入了A集合for (int i = head[u]; i; i = e[i].next){int v = e[i].to;if (!vis[v] && dis[v] > dis[u] + e[i].c)dis[v] = dis[u] + e[i].c;}cnt++;}
}
int main(){cin >> n >> m >> s;for (int i = 1; i <= m; i++) {int x, y, c;cin >> x >> y >> c;add(x, y, c);add(y, x, c);}dijkstra();for (int i = 1; i <= n; i++)cout << dis[i] << " ";
Dijkstra优化
分析 Dijkstra 的算法流程,第 (2) 步中,找到 B 集合 dis 值最小点这一操作,我们在上述代码的实现中,是通过枚举所有 B 集合中的点来查找的。每次进行这一操作的复杂度为 O(n) ,但我们知道,每次取一些数据的最小/最大值可以用优先队列/堆在单次 O(logn) 的复杂度内来实现。
这里我们只介绍用优先队列实现 Dijkstra 的方法,优先队列本质上也是堆的一种,因此同学们也可以自行尝试用自己写的堆来代替优先队列优化 Dijkstra 。
算法描述
把源点 s 的 dis 值 dis[s] 赋值为 0 ,其余点的 dis 值赋值为无穷大,并把 pair(s,ds) 放入优先队列。
每次从优先队列中取出 dis 值最小的 (u,du) 。
如果 du!=dis[u],说明现在的 dis[u] 相较于放入 (u,du) 时的 dis[u] 已经变小,那么显然用 (u,dis[u]) 更新其他节点更优,因此没必要再用 (u,du) 更新其他节点,于是重复进行第 (2) 步。
如果 du==dis[u],遍历 u 能到达的所有节点 v,假设 u−>v 的边权为 c,比较 dis[v] 和 dis[u]+c 的大小,如果 dis[v]>dis[u]+c ,令 dis[v]=dis[u]+c,并把 (v,dis[v]) 放入优先队列。
重复 (2)(3)(4) 直到优先队列为空。
实际上,上述算法其实就是用 priority_queue (优先队列)优化了每次在 B 集合中查找 dis 值最小的点的过程,使其复杂度降至 log 级别,用优先队列优化的 Dijkstra 算法复杂度为 O(mlog(m)+n)。
#include<bits/stdc++.h>
#define inf 1000000000
#define N 2000200
using namespace std;
int n, m, s;
int head[N], pos;
struct edge { int to, next, c; }e[N << 1];
void add(int a, intb, int c){pos++; e[pos].to = b, e[pos].next = head[a], e[pos].c = c, head[a] = pos;
}
struct node { int d, id; };
bool operator < (node x, node y) { return x.d > y.d; }
priority_queue< node > Q;
int dis[N];
void dijkstra(){for (int i = 1; i <= n; i++)dis[i] = inf;dis[s] = 0; nodest; st.d = 0, st.id = s;Q.push(st);while (!Q.empty()) {node u = Q.top(); Q.pop();if (u.d > dis[u.id])continue;for (int i = head[u.id]; i; i = e[i].next) {int to = e[i].to;if (dis[to] > dis[u.id] + e[i].c) {dis[to] = dis[u.id] + e[i].c;node v; v.id = to, v.d = dis[to];Q.push(v);
.
.}}}
}
int main(){cin >> n >> m >> s;for (int i = 1; i <= m; i++) {int x, y, c;cin >> x >> y >> c;add(x, y, c);add(y, x, c);}dijkstra();for (int i = 1; i <= n; i++)cout << dis[i] << " ";
}
权值为1的最短路
在网格图中,经常会遇到最少走多少步?最少消耗多代价的问题。这类问题点和点之间的距离都是一样的,被当作1来处理。
在普通图中,也经常遇到最少经过多少条边移动几次的问题。这类问题中,每条连接点的边都是一样的,权值被当作1来处理。遇到这类问题,仍可以沿用BFS。
字符串匹配
Manacher算法
最长回文子串问题
回文串: 对于一个字符串 s,如果 s=rev(s),就称 s 是回文串。其中 rev(s) 表示字符串 s 的翻转。例如,"ababa" 或者 "moom" 就是回文串。
回文子串:s 的子串 t 如果是回文串,我们就称 t 是 s 的回文子串。
最长回文子串问题: 给出一个字符串 s ,求出 s最长的回文子串长度。
暴力算法
解决最长回文子串问题,最为朴素的算法就是依次以每个字符或者两个字符之间的空隙为中心,当两侧对应字符相同时向两侧进行扩展,扩展到不能再扩展时,我们就得到了该中心对应的回文子串最大长度。
代码实现也非常简单:
int work(char* s, int n) {int ans = 0;for (int i = 1; i <= n; i++) {int l = i, r = i;while (l - 1 != 0 && r + 1 <= n && s[l - 1] == s[r + 1])l--, r++;ans = max(ans, r - l + 1);if (i < n && s[i] == s[i + 1]) {l = i, r = i + 1;while (l - 1 != 0 && r + 1 <= n && s[l - 1] == s[r + 1])l--, r++;ans = max(ans, r - l + 1);}}return ans;
}
对于每个枚举的回文中心,最坏情况下要向外扩展 O(n) 次,而回文中心有 O(n) 个,因此总复杂度为 O(n2) 。
算法描述
Manacher 算法是一种可以在O(n)的时间复杂度求出一个长为 n 的字符串的最长回文子串的算法,它思路简单、代码简单,但功效却十分强大。
通过暴力算法也不难发现,求回文串时需要分奇偶,因此在提出 Manacher 算法前,我们需要先对给出的字符串做些简单的处理,来使得奇偶长度的字符串可以统一考虑。
处理的方法很简单,就是在每个相邻的字符之间插入一个分隔符,字符串的首尾也要加,这个分隔符需要是一个在原串中没有出现过的字符。一般我们用'#',例如:
原串 s : abaab
新串 t : #a#b#a#a#b#
这样处理后,原来是奇数长度的回文串还是奇数长度,原来是偶数长度的回文串就变成以 # 为中心的奇数回文串了,因此我们之后只需要讨论奇数长度的回文串的求解方法即可。
在 Manacher 算法中,我们用一个辅助数组 p 记录以每个字符为中心的最长回文半径,也就是 p[i] 记录以 t[i] 为中心的最长回文子串的半径。显然, p[i] 最小为 1 , p[i] 为 1 时对应的回文子串就是字符 t[i] 本身。
我们对上面的例子写出对应的 p 数组:
原串 s | abaab |
新串 t | #a#b#a#a#b# |
p 数组 | 1 2 1 4 1 2 5 2 1 2 1 |
接下来我们证明 p[i]−1 就是以 t[i] 为中心的最长回文子串在原串中的长度。
算法证明
显然 L=2∗p[i]−1 是新串 t 中以 t[i] 为中心的最长回文子串长度。
根据我们构造新串的方式可以发现,以 t[i] 为中心的最长回文子串一定是以 # 开头和结尾的。所以 L 减去最前或者最后的 # 字符之后,就是原串中对应回文子串长度的两倍。即原串长度为 (L−1)/2=p[i]−1 ,得证。
这里用到了动态规划的思想,依次求 p[1],p[2],p[3]...p[n] 的值,在要求解 p[i] 时,之前的 p[] 值已经得到了,我们就可以利用回文串的性质和之前的 p[] 值来对求解 p[i] 的过程进行优化。
先给出求解 p 数组的核心代码,为了防止在扩展回文串的过程中越界,在 t 两侧分别添上了字符:
for (int i = 1; i <= m; i++){
p[i] = 1;
if (maxid > i)
p[i] = min(p[2 * id - i], maxid - i);
while (t[i - p[i]] == t[i + p[i]])
p[i]++;
if (i + p[i] > maxid)
id = i, maxid = i + p[i];
}
while(t[i-p[i]] == t[i+p[i]])
p[i]++;
上面这段代码的作用不难理解,就是暴力的向两侧扩展回文子串。
if (maxid > i)
p[i] = min(p[2 * id - i], maxid - i);
以上这段代码又是怎么一回事呢?
事实上, maxid 记录的是在求解 p[i] 之前,我们找到的回文串中,延伸到最右端的回文串的右端点位置,而 id 记录的就是这个回文串的中心的下标。
可以这样推导的原因,是利用了回文串的对称性,
j=2∗id−i 就是 i 关于 id 的对称点,根据回文串的对称性, p[j] 代表的回文串时可以对称到 i 这边的,但是如果 p[j] 代表的回文串对称过来以后超过 maxid 的话,超出的部分就不能对称过来了(因为不能保证左侧超出的部分和右侧超出的部分相同),所以 p[i] 的下限就取 p[2∗id−i] 和 maxid−i 的较小者,即 :
if(maxid > i)
p[i] = min(p[2 * id - i], maxid - i);
有了一个下限之后,再暴力的扩展 p[i] 。这样做的时间复杂度是怎样的呢?我们只需要关注暴力扩展 p[i] 的复杂度。
需要暴力扩展 p[i] ,就意味着在暴力扩展前, p[i] 代表的回文串右端点就已经到了 maxid 了,那么我们每暴力扩展一次,就会使得 maxid 增加 1 ,而 maxid 最多为 m ,因此在整个过程中最多只会暴力扩展 O(m) 次,其中 m 表示新串 t 的长度。
完整的代码我们在下面的例题中展示。
DAG图与拓扑排序
拓扑排序
在图论中,由一个有向无环图的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑排序:
每个顶点出现且只出现一次;若 A 在序列中排在 B 的前面,则在图中不存在从 B 到 A 的路径。
从拓扑排序的概念我们就可以看出,只有有向无环图才存在拓扑排序。因此,如果有算法可以帮我们判断一张图是否存在拓扑排序,我们就能判断这张图是否为有向图。事实上,我们不仅能很快的判断一张图是否存在拓扑排序,还能顺便求出它的拓扑排序。
一个图 G 的拓扑排序大多情况下是不唯一的。
卡恩算法(BFS)
算法描述
统计图中每个点的入度(即连向该点的边数)
将入度为 0 的点放入队列
每次从队列中取出一个点 u ,遍历从这个点出发的所有出边 (u−>v) ,删除 u−>v 这条边(代码实现上仅表现为让 v 的入度 −1 ),若 v 的入度变为 0 ,则将 v 放入队列
重复(3)直到队列为空,若所有点都进出过队列,则点的出队顺序即为这张图的拓扑序,否则说明该图不存在拓扑排序(存在有向环)。
const int N = 2e5 + 100;
const int M = 4e5 + 100;
int head[N], Next[M], ver[M], tot;
int deg[N];
void add(int x, int y) {ver[++tot] = y;Next[tot] = head[x];head[x] = tot;
}
queue<int>qc;
int topsort(int n) {int cnt = 0;while (qc.size())qc.pop();for (int i = 1; i <= n; i++) {if (deg[i] == 0) {qc.push(i);}}while (qc.size()) {int x = qc.front();cnt++;qc.pop();for (int i = head[x]; i; i = Next[i]) {int y = ver[i];if (--deg[y] == 0)qc.push(y);}
}return cnt == n;
}
int main() {int n, m, x, y;cin >> n >> m;for (int i = 1; i <= m; i++) {cin >> x >> y;add(x, y);deg[y]++;}if (topsort(n))cout << "Yes" << endl;else cout << "No" << endl;
}
拓扑排序的DFS算法
算法描述
拓扑排序的 DFS 的实现更加简单,从一个入度为0的节点开始做 DFS ,每访问到一个节点,标注为已访问,下次重复访问到时直接跳过。当所有子节点都访问过后,将当前节点加入列表。这样最终将会得到一个叶子节点在前,根结点在最后的节点列表。将该列表反转,得到的就是拓扑排序好的节点。
有向无环图(DAG)
有向图中,每条边都有特定的方向,当我们能从某一个点 u 出发,通过图中的有向边经过若干个点后又回到 u 点 (u−>v1−>v2−>v3...−>vk−>u)[k>=1] ,说明这张有向图是存在环的,而若 v1v2v3...vk 互不相同,则 u−>v1−>v2−>v3...−>vk−>u 就是这张图中的一个简单环。在图论中,我们把不存在环的有向图称为有向无环图( DAG )
不难想到, DAG 是有向图的一种,由于 DAG 中不含有向环,因此相比于一般有向图, DAG 的相关性质总是更加容易探索。在解决实际问题时,我们也往往会把一般有向图转化为 DAG 。但在此之前,我们首先要学习如何判断一张图是否是有向无环图。
DAG图的判定
在第二章中,我们学习了两种图的遍历方式: BFS (广度优先搜索)与 DFS (深度优先搜索)。我们了解到, BFS 是一种利用队列实现的,基于循环的遍历方式,与 DFS 相比,它具有高效、所需栈空间小的特点。
考虑一张有向无环图,从任意点 u 出发,对这张图进行遍历时,如果点 u 被经过了多于一次(尽管没有再次将这个点放入队列),则说明我们找到了一个有向环(从点 u 出发有一条路径回到点 u )
#define N 200200
using namespace std;
queue<int>Q;
vector<int>G[N];
int n, m, tot;
int in[N], topo[N];
int main(){cin >> n >> m;for (int i = 1; i <= m; i++) {int x, y;cin >> x >> y;G[x].push_back(y);in[y]++;}for (int i = 1; i <= n; i++)if (!in[i])Q.push(i);while (!Q.empty()) {int u = Q.front(); Q.pop();topo[++tot] = u;for (int i = 0; i < G[u].size(); i++) {int v = G[u][i];in[v]--;if (!in[v])Q.push(v);}}
.
.if (tot < n)puts("circle!");else {puts("toposort: ");for (int i = 1; i <= tot; i++)printf("%d ", topo[i]);}
}
上述代码判断有向图是否存在环的复杂度为 O(n∗m) ,其中 n 为有向图的点的个数, m 为有向图的边数。
对于规模较大的有向图,用上面的代码需要耗费大量时间才能跑出结果,事实上,我们有更优美的算法来判断有向图中是否存在环,它就是拓扑排序