• {{item}}
  • {{item}}
  • {{item}}
  • {{item}}
  • 天祈
  • {{item.name}}
  • {{item.name}}
  • {{item.name}}
  • {{item.name}}

算法专题——最短路

最短路问题的概念

最短路问题是图论中经典的问题,主要用于求解图(有向,无向皆可)中任意两点之间的最短路问题,常见的求法有四种,接下来将介绍最短路的四种求法,以及一种常见的应用。


最短路的求法


Dijkstra算法

摘要:常用,不能处理负权,时间复杂度可以接受。

伪代码:

给定 起始点start;
给定 数组dis[] = {start点到i点的最短路|初始化为i点到i点为0,到其他点为INF}
给定 数组vis设置所有点标记为 false
While(还有没有被标记且距离不是INF的点) {
	标记一个没有被标记的最近的点tmp		//得到dis[tmp]的最小值
	从这个点出发,更新周围点的距离
}
//所有已被标记的点都已得到最小dis数组,后面加入的点不会对dis数组进行干扰

时间复杂度:O(n * m),堆优化之后可以达到O(n * logm)。

朴素代码(邻接矩阵存图):

const int INF = 0x3f3f3f3f;
const int MAXN = 1e5 + 10;
int dist[MAXN], g[MAXN][MAXN];
bool vis[MAXN];
void Dijkstra(int start) {
    memset(vis, false, sizeof(vis));                        //初始化vis,dist
    memset(dist, 0x3f, sizeof(dist));
    
    dist[start] = 0;
    
    for (int i = 0; i < n; i++) {
    	int u = -1;
    	for (int j = 1; j <= n; j++) 
    		if (!vis[j] && (u == -1 || dist[u] > dist[j])) 
    			u = j;
    			
    	vis[u] = true;
    	
    	for (int j = 1; j <= n; j++) 
    		if (g[u][j] != INF)
    			dist[j] = min(dist[j], dist[u] + g[u][j]);
    }
}

堆优化代码(邻接表实现):

const int INF = 0x3f3f3f3f;
const int MAXN = 1e5 + 10;
struct Node {
    int v, c;                                               //C仅用于优先队列排序
    Node(int v_ = 0, int c_ = 0): v(v_), c(c_) {}
    bool operator< (const Node& a) const {return c > a.c; } //小顶堆修改大顶堆
};
vector<Node> E[MAXN];
bool vis[MAXN];
int dist[MAXN];
void Dijkstra(int start, int n) {
    memset(vis, false, sizeof(vis));                        //初始化vis,dist
    memset(dist, 0x3f, sizeof(dist));
    priority_queue<Node> que;                               //初始化que
    while (!que.empty()) que.pop();

    dist[start] = 0;                                        //假设一个以被vis标记的虚点, 该点到初始点start的距离为0
    que.push(Node(start, 0));

    Node tmp;
    while (!que.empty()) {
        tmp = que.top(); que.pop();                         //得到一个距离最近且没有访问过的点,以该点为中心,对到周围的点的距离进行更新
        int u = tmp.v;
        if (vis[u]) continue;
        
        vis[u] = true;
        
        for (int i = 0; i < E[u].size(); i++) {
            int v = E[u][i].v, c = E[u][i].c;
            if (!vis[v] && dist[v] > dist[u] + c) {
                dist[v] = dist[u] + c;
                que.push(Node(v, dist[v]));
            }
        }
    }       //出来时,最后一个点被标记,与该点连接的点都已被访问,故不会更新dist
}   //所有已被vis标记的点,都以得到最优的dist值
void AddEdge(int u, int v, int w) {
    E[u].push_back(Node(v, w));
}

Bellman-Ford算法

摘要:

松弛操作:见下面代码。

if(e[i][j]>e[i][k]+e[k][j])
    e[i][j]=e[i][k]+e[k][j];

Dijkstra算法从点入手,在BFS过程中,总让点得到最优的值,Bellman-Ford算法从边入手,对全图进行n - 1次松弛操作,从而得到最优解。初次之外Bellman-Ford还可以处理权值为负的情况。代码如下:

/*
* 单源最短路 bellman_ford 算法,复杂度 O(VE)
* 可以处理负边权图。
* 可以判断是否存在负环回路。返回 true, 当且仅当图中不包含从源点可达的负权回路
* vector<Edge>E; 先 E.clear() 初始化,然后加入所有边
* 点的编号从 1 开始 (从 0 开始简单修改就可以了)
*/
const int INF = 0x3f3f3f3f;
const int MAXN = 550;
int dist[MAXN];
struct Edge {
    int u, v;
    int cost;
    Edge(int _u = 0, int _v = 0, int _cost = 0) : u(_u), v(_v), cost(_cost) {}
};
vector<Edge> E;
//点的编号从 1 开始
bool bellman_ford(int start, int n) {
    for (int i = 1; i <= n; i++)
        dist[i] = INF;
    dist[start] = 0;
    //最多做 n-1 次
    for (int i = 1; i < n; i++) {
        bool flag = false;
        for (int j = 0; j < E.size(); j++) {
            int u = E[j].u;
            int v = E[j].v;
            int cost = E[j].cost;
            if (dist[v] > dist[u] + cost) {
                dist[v] = dist[u] + cost;
                flag = true;
            }
        }
        if (!flag) return true; //没有负环回路	//没有点更新了,直接返回
    }
    for (int j = 0; j < E.size(); j++)			//如果还可以更新,说明有负环回路
        if (dist[E[j].v] > dist[E[j].u] + E[j].cost)
            return false; //有负环回路
    return true;          //没有负环回路
}

注意:Bellman-Ford算法中,最外层的for循环会迭代n - 1次,为什么是n - 1次?

迭代的次数表示的是路径长度,当迭代到i次时,所有最短路边数为i的路径都会在此次循环中得到。

不难想到,当跌倒k次时能得到边数不超过k的最短路径为多少。

不难发现函数的返回值是一个bool类型,其作用在于检测回路中是否存在,不存在返回true,存在返回false(存在负环回路

Floyd算法

摘要:

我们知道松弛操作是引入一个中转点,判断是否可以让两点之间的距离变小。而Folyd算法就是基于此得到的,下面是Floyd的基本思想。

最开始只允许经过1号顶点进行中转,接下来只允许经过1和2号顶点进行中转……允许经过1~n号所有顶点进行中转,求任意两点之间的最短路程。用一句话概括就是:从i号顶点到j号顶点只经过前k号点的最短路程。

代码:

for(k=1;k<=n;k++)			//中转点遍历
for(i=1;i<=n;i++)			//起始点
for(j=1;j<=n;j++)			//终止点
    if(e[i][j]>e[i][k]+e[k][j])
    	e[i][j]=e[i][k]+e[k][j];

SPFA算法

摘要:常用,代码短,可以处理负权,可以判断负权回路

基于BFS的思想。大致思路见下面引用:

算法大致流程是用一个队列来进行维护。 初始时将源加入队列。 每次从队列中取出一个元素,并对所有与他相邻的点进行松弛,若某个相邻的点松弛成功,则将其入队。 直到队列为空时算法结束。

对已弹入队列中的元素vis标记为true,对弹出的元素vis标记为false。这样设计可以防止对将已在队列中的元素再次弹入队列中,避免无效操作。

由于一个由n个点组成的图中,一个点至多只会与n - 1个点相连,所以只要不出现负权边,一个点至多会被更新n - 1次(这里使用被弹入队列中的次数进行)。时间复杂度为O(k * m),k为一个点被平均访问的次数。不难发现,SPFA算法处理稠密图时效果可能会达到最坏的时间复杂度,不过大多时候,没有被特意卡的话,复杂度和Dijkstra是差不多的。

同样可以解决负环回路问题。

代码:

/*
* 单源最短路 SPFA
* 时间复杂度 0(kE)
* 这个是队列实现,有时候改成栈实现会更加快,很容易修改
* 这个复杂度是不定的
*/
const int MAXN = 1010;
const int INF = 0x3f3f3f3f;
struct Edge {
    int v;
    int cost;
    Edge(int _v = 0, int _cost = 0) : v(_v), cost(_cost) {}
};
vector<Edge> E[MAXN];
void addedge(int u, int v, int w) {
    E[u].push_back(Edge(v, w));
}
bool vis[MAXN]; //在队列标志
int cnt[MAXN];  //每个点的入队列次数
int dist[MAXN];
bool SPFA(int start, int n) {
    memset(vis, false, sizeof(vis));
    memset(dist, 0x3f, sizeof(dist));
    memset(cnt, 0, sizeof(cnt));

    while (!que.empty()) que.pop();

    dist[start] = 0;
    que.push(start); vis[start] = true;
    cnt[start] = 1;
    
    while (!que.empty()) {
        int u = que.front(); que.pop();
        vis[u] = false;
        for (int i = 0; i < E[u].size(); i++) {
            int v = E[u][i].v, cost = E[u][i].c;
            if (dist[v] > dist[u] + cost) {
                dist[v] = dist[u] + cost;
                if (++cnt[v] > n) return false; //cnt[i] 为入队列次数,用来判定是否存在负环回路
                if (!vis[v]) {
                    que.push(v);
                    vis[v] = true;
                }
            }
        }
    }
    return true;
}

最短路问题分析

最短路算是一种计算模型,要用最短路解决问题重点在于建图,将实际问题抽象为图的模型,而后才通过最短路模型进行求解。

建图方式

需要做到将各种问题,各种特殊情况转换为可以用最短路解决的问题。

最优乘车

建图方式上是一个重点,将航线上所经过的点都进行处理。同一航线上的不同站点,使用权值为1的边进行连接。

昂贵的聘礼

这道题需要解决两个问题:首先是物品的交换如何进行表达,其次是不同的等级以及等级限制如何实现。

物品的交换上,我们可以将一个个的商品看做一个个的点,那么不同的商品之间的交换就可以抽象为在点与点之间连边,边的权值为附加价格。至此便完成了商品交换的表达。至于等级的限制,由于一件商品必然原本属于某人,因此可以给点附加属性——等级,这样之后,我们使用迭代的方法,求出等级限制允许内,不同范围可以得到的最短路,即for(int i = level[酋长] - m; i <= level[酋长]; i++) dijkstra(i, i + m)

综合应用

最短路与动态规划

最短路与动态规划关系密切,最短路中一个个的节点可以类比为一个个的状态,而在不同点之间连的边就是转移方程,通过转移方程可以实现不同状态之间的转移。而与一般的转移方程不同,动态规划的转移关系一般是线性的有规律的,而在图论中不同状态的转移依赖于点与点之间的边的关系,图论中的转移更加抽象,囊括更多东西。

综上,在实际解决dp,图论问题中,可以有意识的往这方面思考,将两者进行一个结合。

Telephone Lines

二分+最短路,既然是k次免费,那么显而易见的,需要要求第k + 1大的花费,将前面k个最贵的路程的花费免掉。我们可以发现问题变成了求最大值的最小值的问题了,可以想到用二分解决,接下来便要思考所求的ans是否具有单调性,以及Check函数怎么写的问题。

我们可以假设最终答案是预算而不是具体的某一条最优路线的第k+1大的路程花费,那么显然,预算越多,答案就更加成立,当预算小到一定程度,到达临界值时,就不能完成任务,到达目的地了。所以所求的ans具有单调性,可以使用二分的方法求解。

而Check函数,我们发现,如果有一条路径满足预算是路径上前k + 1大的最大花费,那么当前预算就是满足条件足够到达终点的,反之不行。

于是我们就用到了最短路的方法,在Check(int cost)函数内,求图中花费大于cost的道路的条数最少的路径是多少。

Code:

#include <bits/stdc++.h>    
using namespace std;
//STL库常用(vector, map, set, pair)
#define PB push_back
#define MP make_pair
#define pii pair<int,int>
#define pdd pair<double,double>
#define F first
#define S second
//常量
#define PI (acos(-1.0))
#define INF 0x3f3f3f3f
//必加
#define GO(i,a,b) for(int i = (a); i < (b); ++i)
#define GOE(i,a,b) for(int i = (a); i <= (b); ++i)
#define OG(i,a,b) for(int i = (a); i > (b); --i)
#define OGE(i,a,b) for(int i = (a); i >= (b); --i)
#define debug cout
typedef unsigned long long ull;
typedef long long ll;
typedef double db;
/* ----------------------------------------------------------------------------------------------------------------------------------------------------------------- */

const int MAXN = 20100, MAXNN = 1010;

struct Edge{
    int v, c;
    Edge(int _v = 0, int _c = 0) : c(_c), v(_v) {}
};

int n, m, k;
int a, b, l;
vector<Edge> E[MAXN];
bool vis[MAXNN];
int dist[MAXNN];
deque<int> deq;

void AddEdge(int a, int b, int l) {
    E[a].push_back(Edge(b, l));
    E[b].push_back(Edge(a, l));
}

bool Check(int cost) {
    memset(dist, 0x3f, sizeof(dist));
    memset(vis, false, sizeof(vis));

    dist[1] = 0;
    deq.push_back(1);

    while (deq.size()) {
        int u = deq.front(); deq.pop_front();
        if (vis[u]) continue;
        vis[u] = true;

        for (int i = 0; i < E[u].size(); i++) {
            int v = E[u][i].v, c = E[u][i].c > cost;
            if (dist[v] > dist[u] + c) {
                dist[v] = dist[u] + c;
                if (!c) deq.push_front(v);
                else deq.push_back(v);
            }
        }
    }

    return dist[n] <= k;
}

int main() {
    scanf("%d%d%d", &n, &m, &k);
    for (int i = 0; i < m; i++) {
        scanf("%d%d%d", &a, &b, &l);
        AddEdge(a, b, l);
    }

    int l = 0, r = 1e6 + 1;
    while (l < r) {
        int mid = l + r >> 1;
        if (Check(mid)) r = mid;
        else l = mid + 1;
    }
    
    if (r == 1e6 + 1) r = -1;
    printf("%d\n", r);

    return 0;
}

道路与航线

最优贸易

一个节点可以被多次访问,一条路也可以被多次访问,不是单纯的单源最短路问题,这时我们可以往动态规划的思路上靠,我们可以用一个数组保存从节点1开始到当前节点i可以买入的最低价格,用另一个数组保存从节点i到最后一个节点n可以卖出的最高价格,那最终答案就是max(dpmax[i] - dpmin[i]),而不同状态之间的转移方程也并不难得到,可以使用SPFA的算法,维护dpmin数组与dpmax数组。

下面给出维护的核心代码

bool vis[MAXN];
int dpmin[MAXN];
vector<Edge> E[MAXN];
 
while (!que.empty()) {
	int u = que.front; que,pop();
	vis[u] = false;
	for (int i = 0; i < E[u].size(); i++) {
		int v = E[u][i].v, c = E[u][i].c;
		if (dpmin[v] > min(dpmin[u], c)) {		//可以更新当前节点的dpmin,进行一个更新
			dpmin[v] = min(dpmin[u], c);
			if (!vis[v]) {
				que.push(v);
				vis[v] = true;	
			}
		}
	}
}

拓展应用

最短路的求法中有一些小技巧,可以帮助我们求一些较为特殊的模型问题。

  1. 求反图,一般的最短路是得到一个起点到多个终点的最短路,那么多个起点一个终点的问题该怎么求解呢,可以用到建反图的方法,将边的链接方向全部取反方向,这样就可以求从终点各起点的最短路了。
  2. 虚拟原点,一些原问题的之中有些数值不是赋值给边,而是可以赋值给点,这时我们可以使用虚拟原点的方法,将点权转换为边权。还可以解决多起点的问题(通过建立虚拟原点,与各起点建立边权为0的边)。
  3. 动态规划,了解不同求最短路的方法的本质,进而得知如何通过变形的Dijkstra,SPFA算法维护节点不同的信息,得到想要的值,不在拘束于最短路的方法。
posted @ 2021-09-22 19:26  TanJI_C  阅读(267)  评论(0编辑  收藏  举报