经典图论算法的应用
文章目录
前言
提前声明:本篇文章为服务个人总结提升使用,有一定的局限性,如有错误在所难免,欢迎各位大佬批评指正本算法小白
一、最短路径的计数问题
这里将讨论Dijkstra算法,floyd算法,以及拓扑排序算法的计数问题,都是结合一些非常简单的dp思想实现的,并会提供洛谷中题目来源的序号。
1.朴素Dijkstra算法的最短路计数
题目来源:P1144 最短路计数
这里我对题目条件进行了一些改变,限定为有向图,并且无负环,以及源点s开始到其他点结束的最短路数目
重点是dp的思路,根据我在代码随想录中的学习,先要搞清楚dp数组的含义。
定义dp[i]为从s到i的最短路的数目
直观分析,dp[s]就是1,s到自己的路径数目为1
下面讨论边src->dst
当松弛成功时,dp[dst]更新为dp[src],这个很好理解
松弛不成功时,如果松弛前后minDist[src]相同,那么dp[dst]+=dp[src]
于是得出代码
#include<iostream>
#include<vector>
#include<list>
#include<climits>
using namespace std;
#define inf INT_MAX/2
//这里假设是有向图,并且可能不连通,也就是说index为-1的时候直接break
struct Edge {
int dst;
int cost;
Edge(int d,int c):dst(d),cost(c){}
};
//Dijkstra的最短路计数,和floyd的思路基本一致
int main() {
int n, m;
cin >> n >> m;
vector<list<Edge>> grid(n + 1);
while (m--) {
int x, y, z;
cin >> x >> y >> z;
grid[x].push_back({ y,z });
}
vector<int> minDist(n + 1, inf);
int s, t;
cin >> s >> t;//源点和终点,有时候也要求终点到其他点的最短距离
vector<bool> visited(n + 1, false);
vector<int> cnt(n + 1, 0);
cnt[s] = 1;
//实际上可以把cnt理解成dp
//dp[i]的含义为从点s到点i的最短路径个数,显然dp[s]就是s到s的最短路径个数为1
//在此基础上考虑后面的递推条件
for (int i = 1; i <= n-1; i++) {
int Min = inf;
int index = -1;
for (int j = 1; j <=n; j++) {
if (!visited[j] && Min > minDist[j]) {
Min = minDist[j];
index = j;
}
}
if (index == -1)break;
visited[index] = true;
for (Edge e : grid[index]) {
if (!visited[e.dst] && minDist[e.dst] > minDist[index] + e.cost) {
minDist[e.dst] = minDist[index] + e.cost;
cnt[e.dst] = cnt[index];
}
else if (minDist[e.dst] == minDist[index] + e.cost) {
cnt[e.dst] += cnt[index];
}
}
}
return 0;
}
2.floyd算法中搭配最短路的计数
题目来源:洛谷P2047
这里讨论的是无向图的最短路
一般识别使用floyd-warshall的场景还是挺容易的,就是点数不多,几百个左右,这样的情况基本就可以放心O(n^3)了
回到这个题目,要求统计不同点间的最短路个数,以及特定点的“影响力”,我感觉其实就是加权后结果(具体内容看洛谷题目)
这里依然是考虑dp[i][j]的含义
dp[i][j]就是从i到j的最短路个数
而dp[i][i]是否初始化为1,这与建立图的临界矩阵grid时,grid[i][i]是否初始化为0有关.
这里讨论情况为grid[i][i]=0
vector<vector<int>> grid(n+1,vector<int>(n+1,inf));
for(int i=1;i<=n;i++) grid[i][i]=0;
考虑dp[i][i]的初始化其实可以先放放,因为和后面dp的更新逻辑有关
当grid[i][j]通过中间节点k成功更新时,dp[i][j]重新计数,dp[i][j]=dp[i][k]dp[k][j],由乘法原理易得
所谓乘法原理,其实就是i到k由m种情况,k到j有n种情况,那么i到j就肯定时mn种情况
而当grid[i][j]==grid[i][k]+grid[k][j]时
dp[i][j]+=dp[i][k]*dp[k][j]
可以看出,这个更新逻辑其实和Dijkstra的更新逻辑如出一辙
知道dp的更新逻辑后,讨论dp初始化的问题
看特殊情况
grid[i][j]==grid[i][i]+grod[i][j]
由我们已经确定的grid[i][i]==0可知,这条情况显然成立
那么dp[i][i]+=dp[i][i]*dp[i][j]
如果想当然的假设dp[i][i]初始化为1,即开始时某点到自身的最短路个数就是1
此时从i到j又有路,所以dp[i][j]不为0
说到这,补充一个细节,当初始化邻接矩阵输入边x,y,z的时候,dp[x][y]=1,dp[y][x]=1
此时,dp[i][i]就会发生改变,这显然不是我们希望看到的情况
所以说,dp[i][i]应该初始化为0,我认为这是一个不符合直觉的情况,需要简单的逻辑说明一下的。
下面是从i到j经过k的最短路的个数
我们已经得到了整个图任意两点间的最短路个数
那么求其经过中间节点k的最短路个数就很简单了
grid[i][k]+grid[k][j]==grid[i][j]时
经过k的最短路就为dp[i][k]*dp[k][j]
如此完成该题的主题代码部分
//3.floyd加dp
//注意cnt的初始化
//cnt中的乘法原理
#include<iostream>
#include<vector>
#define inf INT_MAX/2
using namespace std;
int main() {
int n, e;
scanf("%d%d", &n, &e);
vector<vector<int>> grid(n + 1, vector<int>(n + 1, inf));
//注意cnt的类型为long long
vector<vector<long long>> cnt(n + 1, vector<long long>(n + 1, 0));//注意这里不需要将cnt[i][i]初始化为1
while (e--) {
int a, b, c;
scanf("%d%d%d", &a, &b, &c);
grid[a][b] = grid[b][a] = c;
cnt[a][b] = cnt[b][a] = 1;
}
for (int i = 1; i <= n; i++)grid[i][i] = 0;
for (int k = 1; k <= n; k++) {
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
if (grid[i][k] + grid[k][j] < grid[i][j]) {
grid[i][j] = grid[i][k] + grid[k][j];
cnt[i][j] = cnt[i][k] * cnt[k][j];
}
else if (grid[i][k] + grid[k][j] == grid[i][j]) {
cnt[i][j] += cnt[i][k] * cnt[k][j];
}
}
}
}
for (int k = 1; k <= n; k++) {
double influence = 0;
for (int i = 1; i <= n; i++) {
if (i == k)continue;
for (int j = 1; j <= n; j++) {
if (i == j || j == k)continue;
if (grid[i][k] + grid[k][j] == grid[i][j]) {
influence += 1.0 * grid[i][j] * double(cnt[i][k] * cnt[k][j] / cnt[i][j]);
}
}
}
printf("%.3lf\n", influence);
}
return 0;
}
3.拓扑排序中的dp,以及拓扑方案数的求解
拓扑排序就是为了dp而生的
应用场景其实也比较明显,当题目强调为有向无环图时,使用拓扑排序解决问题的可能性就很高
为什么说拓扑排序就是为dp而生呢
你可以考虑图论中拓扑排序的最基本的使用场景,就是关键路径的求取
AOV网的最早开始时间和最晚开始时间
AOE网的每个活动的最早开始时间和最晚开始时间的求取
其实就是递推,也就是dp
题目来源:P1137
先贴上代码,有时间码文的时候再添加讲解
这道题要求求有向无环图中每条路径从起点到终点经过的最多节点
可以用拓扑排序后的序列topo进行求解
其实可以边拓扑边更新dp,但是我当时傻了
dp初始化为全1,因为每个点至少都会经过自己
考虑AOE网中的活动src->dst
dp[dst]=max(dp[dst],dp[src]+1)
这就是dp数组边拓扑边更新的逻辑
其实也很好理解,脑中有一个AOE网就可以了
#include<iostream>
#include<vector>
#include<queue>
using namespace std;
//求拓扑排序的每个终点经过的最多节点数
vector<int> topoOrder(vector<vector<int>>& grid,vector<int> inDegree) {
queue<int> que;
int n = inDegree.size() - 1;
vector<int> topo(n+1, 0);
for (int i = 1; i <=n; i++) {
if (inDegree[i] == 0)que.push(i);
}
int cnt = 1;
while (!que.empty()) {
int cur = que.front();
topo[cnt] = cur;
cnt++;
que.pop();
for (int e : grid[cur]) {
inDegree[e]--;
if (inDegree[e] == 0)que.push(e);
}
}
return topo;
}
int main() {
int n, m;
cin >> n >> m;
vector<vector<int>> grid(n + 1);
vector<int> inDegree(n + 1, 0);
//建图
while (m--) {
int x, y;
cin >> x >> y ;
inDegree[y]++;
grid[x].push_back(y);
}
//进行拓扑排序
vector<int> topo = topoOrder(grid, inDegree);
vector<int> dp(n + 1, 1);
for (int i = 1; i <= n; i++) {
//按拓扑顺序遍历每个节点,和每条边,确定其可以访问的最大城市数
int cur = topo[i];
for (int e : grid[cur]) {
dp[e] = max(dp[cur] + 1, dp[e]);
}
}
for (int i = 1; i <= n; i++) {
cout << dp[i] << endl;
}
return 0;
}
题目来源:P1685
这道题目也是有向无环图
其中包括求拓扑方案数和每个方案的花费加起来的和
其实识别为拓扑排序衍生的问题后,其余问题就很好解决了
方案数和花费和都可以递推得到
设方案数为path_num数组,花费数为sumcost数组
题目还加了一个限制要求,是从点s触发到点t,不一定是从1到n
其实这里path_num和sumcost的递推实现也很简单
path_num除源点s外初始化为0,path_num[s]初始化为1
理解一下就是,当拓扑排序到s的入度全为0,s进入队列的时候,才开始递推
边拓扑,边更新path_num
对活动src->dst而言
path_num[dst]+=path_num[src]
这个也很好理解
sumcost的实现逻辑也不难
只要脑子里由AOE网,就很容易
sumcost全都初始化为0
边拓扑,边递推对于活动e{src,dst,cost}
sumcost[dst]+=sumcost[src]+path_num[src]*e.cost
最终
path_num[t],sumcost[t]就是想要的结果,即方案数和总花费数
//求拓扑排序所有路径的个数以及所有路径加在一起的权值
#include<iostream>
#include<vector>
#include<queue>
using namespace std;
struct Edge {
int dst;
int cost;
Edge(int d,int c):dst(d),cost(c){}
};
int main() {
int n, m, s,t,time;
cin >> n >> m >> s>>t >>time;
//从s出发,到t
vector<vector<Edge>> grid(n + 1);
vector<int> inDegree(n + 1, 0);
while (m--) {
int x, y, z;
cin >> x >> y >> z;
grid[x].push_back({ y,z });
inDegree[y]++;
}
queue<int> que;
vector<int> path_num(n + 1, 0);
vector<int> sumcost(n + 1, 0);
for (int i = 1; i <= n; i++) {
if (inDegree[i] == 0) {
que.push(i);
}
}
path_num[s] = 1;
while (!que.empty()) {
int cur = que.front();
que.pop();
for (Edge e : grid[cur]) {
inDegree[e.dst]--;
if (inDegree[e.dst] == 0)que.push(e.dst);
(path_num[e.dst] += path_num[cur])%=10000;
(sumcost[e.dst] += sumcost[cur]+path_num[cur] * e.cost)%=10000;
}
}
int total = sumcost[t] + (path_num[t] - 1) * time;
cout << total%10000;
return 0;
}
4.最小生成树和dp的结合
这里题目并不是来源于洛谷,而是来源于平时的一些练习,所以我把题目信息贴到这里了
给定一个包含n个顶点的无向正权连通图,顶点编号为1到n。请编写程序计算其最小支撑树中任意两个顶点间路径中,权值最大的边的权值。
输入格式:
第一行为2个正整数n和m,n为图中顶点个数,m为边的条数。接下来m行,每行3个整数a、b、c,表示顶点a和顶点b之间有一条权值为c的边。随后一行为一个正整数T,表示查询数目。接下来T行,每行2个整数a和b,表示查询最小支撑树中顶点a和b间路径中的最大边。n≤2000,m≤30000,1 ≤a,b≤ n且a
=b,c ≤65535,T ≤ 1000 。
输出格式:
对于每个查询输出一行,为1个整数,表示最小支撑树两个顶点间的路径中的最大边的权值。
输入样例:
8 20
2 7 44181
1 2 36877
3 6 2506
2 8 46829
7 1 2843
4 5 40699
1 3 15911
7 6 15553
5 6 22541
8 6 62008
3 4 62009
5 7 53337
5 3 12157
4 6 10112
1 5 22574
3 7 28993
4 7 53536
6 1 951
4 2 31411
7 8 31020
10
7 5
5 4
4 2
7 2
3 4
1 5
1 5
7 3
6 1
4 1
输出样例:
12157
12157
31411
31411
10112
12157
12157
2843
951
10112
我刚开始做这道题的时候是先用kruskal建树,后面查找最大边的时候暴力搜索,当时感觉到肯定要用到dp,但是最后并没有能力用dp写出来,暴力搜索时间没有超,但肯定是一个不好的解法
dp解决问题的思路:
1.确定dp数组的含义,dp[i][j]代表该图最小生成树上从i到j节点的最大边的长度
2.dp数组的初始化,dp在开始时全部初始化为0
3.dp的更新逻辑
事实上,我在这方面都是在确定是dp问题类型之后进行倒推
对这题来说,因为是树这种特殊结构,所以在更新的时候只要考虑新加入点集的点和与该点的前驱即可,你要理解递推的含义
每个点的前驱用pre数组存储
在新加入点v时,u=pre[v]
首先要更新dp[u][v]和dp[v][u]
dp[u][v]=dp[v][u]=minDist[v]//这里minDist数组存的是新加入的边的大小
然后要更新其他所有点到v的最大边的长度
for(int i=1;i<=n;i++){
dp[i][v]=dp[v][i]=max(dp[u][v],dp[i][u]);
}
即比较其他点到dp前驱的最大边和新加入边的大小
这样就构成了递推条件
这里附上代码
本蒟蒻居然到现在才发现自己构建最小生成树和单源最短路径居然都少进行一次操作,太震惊了
//附上代码的原因是本蒟蒻提交的代码依旧是暴力搜索的代码
//老师的prim+dp的方法
#include<iostream>
#include<vector>
using namespace std;
#define inf INT_MAX/2
int main() {
int n, e;
scanf("%d%d", &n, &e);
vector<vector<int>> grid(n + 1, vector<int>(n + 1, inf));
//建图
while (e--) {
int a, b, c;
scanf("%d%d%d", &a, &b, &c);
grid[a][b] = grid[b][a] = c;
}
//prim算法
//再保存一下前一个节点
vector<int> pre(n + 1, -1);
vector<int> minDist(n + 1, inf);
vector<int> visited(n + 1, false);
minDist[1] = 0;
vector<vector<int>> maxcost(n + 1, vector<int>(n + 1, 0));
for (int i = 0; i < n; i++) {//记住这里是<n,不是小于n-1
//这个地方我居然一直都错了
//小于n-1的意思是根据边的个数来讨论的,即加入n-1条边就可以构建处最小生成树了
// 实际上,因为第一次加入点并不包含边,所以第一次并没有加入边
//小于n是根据点集中的个数来讨论的,只有n个点都加入集合,算法才算结束
int Min = inf;
int index = -1;
for (int j = 1; j <= n; j++) {
if (!visited[j] && Min > minDist[j]) {
Min = minDist[j];
index = j;
}
}
if (index == -1)break;
visited[index] = true;
//这里进行dp操作
if (pre[index] != -1) {
int u = pre[index];
//更新时只要考虑该点和上一个点即可做到全部更新
maxcost[u][index] = maxcost[index][u] = minDist[index];//当新点集加入时,更新
for (int j = 1; j <= n; j++) {
if (j != index && j != u) {
maxcost[j][index] = maxcost[index][j] = max(maxcost[u][index], maxcost[j][u]);
}
}
}
for (int j = 1; j <= n; j++) {
if (!visited[j] && grid[index][j] < minDist[j]) {
minDist[j] = grid[index][j];
pre[j] = index;//树中一个节点的前一个结点
}
}
}
int t;
scanf("%d", &t);
while (t--) {
int a, b;
scanf("%d%d", &a, &b);
printf("%d\n", maxcost[a][b]);
}
}
引申
dp题目的空间复杂度有时候可以进行优化,最简单的例子比如力扣上的最简单迷宫问题,二维数组可以优化为滚动数组
模糊匹配算法中求编辑距离,我知道的只能是用二维数组来递推,因为要用到左上角三个元素
而且迷宫问题中有障碍物出现时,空间也不能优化为滚动数组了,因为对迷宫边界上有障碍物时要特殊考虑
而空间从数组优化到一个数的例子其实也有,比如当求最大子数组和时,可以用一个数组来存放中间结果和最终结果,也可以用一个数来存结果。
每次更新逻辑改为:
dp<0时dp=num[i]
其他情况dp+=num[i]
再次强调,本篇文章为个人复习总结提升使用,所以语言都是我自己能懂的语言