关于最短路

定义

  • 单源最短路:从一个点出发,到其他所有点的最短距离
  • 多源最短路:从图中任意一点出发,到其他所有点的最短距离

记号

  • \(n~\)为图上点的数目,\(m~\)为图上边的数目;
  • \(s~\)为最短路的源点;
  • \(D(u)~\)\(~s~\)点到\(~u~\)点的 实际 最短路长度;
  • \(~dis(u)~\)为 s 点到 u 点的 估计 最短路长度。任何时候都有\(~dis(u) \geq D(u)~\)。特别地,当最短路算法终止时,应有\(~dis(u)=D(u)~\)
  • \(~w(u,v)~\)\(~(u,v)~\)这一条边的边权。

性质

  • 对于边权为正的图,任意两个结点之间的最短路,不会经过重复的结点。
  • 对于边权为正的图,任意两个结点之间的最短路,不会经过重复的边。
  • 对于边权为正的图,任意两个结点之间的最短路,任意一条的结点数不会超过\(~n\),边数不会超过\(~n-1\)

那么,现在将开始对于最短路的探索。

单源最短路实现

\(Dijkstra\)

介绍

迪杰斯特拉算法(Dijkstra)是由荷兰计算机科学家狄克斯特拉于1959年提出的,因此又叫狄克斯特拉算法。是从一个顶点到其余各顶点的最短路径算法,解决的是有权图中最短路径问题。迪杰斯特拉算法主要特点是从起始点开始,采用贪心算法的策略,每次遍历到始点距离最近且未访问过的顶点的邻接节点,直到扩展到终点为止。

定义

Dijkstra算法一般的表述通常有两种方式,一种用永久和临时标号方式,一种是用OPEN, CLOSE表的方式,这里均采用永久和临时标号的方式。注意该算法要求图中不存在负权边。

原理

  1. 首先,\(D~\)的每个元素\(~D_i~\)表示当前所找到的从起始点\(~u\) (即源点\(~u\))到其它每个顶点\(~u_i~\)的长度。
  2. \(D~\)的初始状态为:若从\(~u~\)\(~v_i~\)有弧(即从\(~u~\)\(~v_i~\)存在连接边),则\(~D_i~\)为弧上的权值(即为从\(~u~\)\(~v_i~\)的边的权值);否则置\(~D_i~\)为∞。
  3. 那么,下一条长度次短的是哪一条呢?也就是找到从源点\(~u~\)到下一个顶点的最短路径长度所对应的顶点,且这条最短路径长度仅次于从源点\(~u~\)到顶点\(~v_j~\)的最短路径长度。假设该次短路径的终点是 ,则可想而知,这条路径要么是(\(u,~v_k\)),或者是(\(u,~v_j,~v_k\))。它的长度或者是从\(~u~\)\(~v_k~\)的弧上的权值,或者是\(~D_j~\)加上从\(~v_j~\)\(~v_k~\)的弧上的权值。
  4. 一般情况下,假设\(~S~\)为已求得的从源点\(~u~\)出发的最短路径长度的顶点的集合,则可证明:下一条次最短路径(设其终点为\(~x\))要么是弧(\(u,~x\)),或者是从源点\(~u~\)出发的中间只经过\(~S~\)中的顶点而最后到达顶点\(~x~\)的路径。
    因此,下一条长度次短的的最短路径长度必是\(~D = Min\{D~|~∈V-S \}\),其中\(~D_i~\)要么是弧(\(u,~v_i\))上的权值,要么是\(D_k\)(\(v_k~\)\(~S\))和弧(\(v_k, v_i\))上的权值之和。

过程

将结点分成两个集合:已确定最短路长度的点集(记为\(~S~\)集合)的和未确定最短路长度的点集(记为\(~T~\)集合)。一开始所有的点都属于\(~T~\)集合。

初始化\(~dis(s)=0\),其他点的\(~dis~\)均为\(~+\infty\)

然后重复这些操作:

  1. \(~T~\)集合中,选取一个最短路长度最小的结点,移到\(~S~\)集合中。
  2. 对那些刚刚被加入\(~S~\)集合的结点的所有出边执行松弛操作。
    直到\(~T~\)集合为空,算法结束。

证明(摘自 OI wiki)

下面用数学归纳法证明,在 所有边权值非负 的前提下,\(Dijkstra\) 算法的正确性。

简单来说,我们要证明的,就是在执行 1 操作时,取出的结点 \(u\) 最短路均已经被确定,即满足 \(D(u) = dis(u)\)

初始时 \(S = \varnothing\),假设成立。

接下来用反证法。

\(~u\) 点为算法中第一个在加入\(~S~\)集合时不满足\(~D(u) = dis(u)\) 的点。因为\(~s\) 点一定满足\(~D(u)=dis(u)=0\),且它一定是第一个加入\(~S\) 集合的点,因此将\(~u\) 加入\(~S\) 集合前\(~S \neq \varnothing\),如果不存在\(~s\)\(~u\) 的路径,则\(~D(u) = dis(u) = +\infty\),与假设矛盾。

于是一定存在路径\(~s \to x \to y \to u\),其中\(~y\)\(s \to u\) 路径上第一个属于\(~T\) 集合的点,而\(~x\)\(~y\) 的前驱结点(显然\(~x \in S\))。需要注意的是,可能存在\(~s = x\)\(~y = u\) 的情况,即\(~s \to x\)\(y \to u\) 可能是空路径。

因为在\(~u\) 结点之前加入的结点都满足\(~D(u) = dis(u)\),所以在\(~x\) 点加入到\(~S\) 集合时,有\(~D(x) = dis(x)\),此时边\(~(x,y)\) 会被松弛,从而可以证明,将\(~u\) 加入到\(~S\) 时,一定有\(~D(y)=dis(y)\)

下面证明\(~D(u) = dis(u)\) 成立。在路径\(~s \to x \to y \to u\) 中,因为图上所有边边权非负,因此\(~D(y) \leq D(u)\)。从而\(~dis(y) \leq D(y) \leq D(u)\leq dis(u)\)。但是因为\(~u\) 结点在 1 过程中被取出\(~T\) 集合时,\(~y\) 结点还没有被取出\(~T\) 集合,因此此时有\(~dis(u)\leq dis(y)\),从而得到\(~dis(y) = D(y) = D(u) = dis(u)\),这与\(~D(u)\neq dis(u)\) 的假设矛盾,故假设不成立。

因此我们证明了,1 操作每次取出的点,其最短路均已经被确定。命题得证。

实现(仅 C++)

int g[N][N];  // 存储每条边
int dist[N];  // 存储1号点到每个点的最短距离
bool st[N];   // 存储每个点的最短路是否已经确定

// 求1号点到n号点的最短路,如果不存在则返回-1
int dijkstra()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;

    for (int i = 0; i < n - 1; i ++ )
    {
        int t = -1;     // 在还未确定最短路的点中,寻找距离最小的点
        for (int j = 1; j <= n; j ++ )
            if (!st[j] && (t == -1 || dist[t] > dist[j]))
                t = j;

        // 用t更新其他点的距离
        for (int j = 1; j <= n; j ++ )
            dist[j] = min(dist[j], dist[t] + g[t][j]);

        st[t] = true;
    }

    if (dist[n] == 0x3f3f3f3f) return -1;
    return dist[n];
}

堆优化

如果边数远小于\(~n^2\),对此可以考虑用堆这种数据结构进行优化,取出最短路径的复杂度降为\(~O(1)\);每次调整的复杂度降为\(~O(mlogn)\)\(m~\)为该点的边数。

具体步骤:

  1. 将源点加入堆,并调整堆。
  2. 选出堆顶元素\(~u\)(即代价最小的元素),从堆中删除,并对堆进行调整。
  3. 处理与\(~u~\)相邻的,未被访问过的,满足三角不等式的顶点。
    1): 若该点在堆里,更新距离,并调整该元素在堆中的位置。
    2): 若该点不在堆里,加入堆,更新堆。
  4. 若取到的u为终点,结束算法;否则重复步骤2、3。

堆优化实现

typedef pair<int, int> PII;

int n;      // 点的数量
int h[N], w[N], e[N], ne[N], idx;       // 邻接表存储所有边
int dist[N];        // 存储所有点到1号点的距离
bool st[N];     // 存储每个点的最短距离是否已确定

// 求1号点到n号点的最短距离,如果不存在,则返回-1
int dijkstra()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;
    priority_queue<PII, vector<PII>, greater<PII>> heap;
    heap.push({0, 1});      // first存储距离,second存储节点编号

    while (heap.size())
    {
        auto t = heap.top();
        heap.pop();

        int ver = t.second, distance = t.first;

        if (st[ver]) continue;
        st[ver] = true;

        for (int i = h[ver]; i != -1; i = ne[i])
        {
            int j = e[i];
            if (dist[j] > distance + w[i])
            {
                dist[j] = distance + w[i];
                heap.push({dist[j], j});
            }
        }
    }

    if (dist[n] == 0x3f3f3f3f) return -1;
    return dist[n];
}

\(Bellman-Ford\)

介绍

贝尔曼-福特算法(Bellman-Ford)是由理查德·贝尔曼(Richard Bellman) 和 莱斯特·福特 创立的,求解单源最短路径问题的一种算法。有时候这种算法也被称为 Moore-Bellman-Ford 算法,因为 Edward F. Moore 也为这个算法的发展做出了贡献。它的原理是对图进行\(~V-1~\)次松弛操作,得到所有可能的最短路径。其优于迪科斯彻算法的方面是边的权值可以为负数、实现简单,缺点是时间复杂度过高,高达O(VE)。但算法可以进行若干种优化,提高了效率。

过程

先介绍 \(Bellman–Ford\) 算法要用到的松弛操作(\(Dijkstra\) 算法也会用到松弛操作)。

对于边 \((u,v)\),松弛操作对应下面的式子:\(dis(v) = \min(dis(v), dis(u) + w(u, v))\)

这么做的含义是显然的:我们尝试用 \(S \to u \to v\)(其中 \(S \to u\) 的路径取最短路)这条路径去更新 \(v\) 点最短路的长度,如果这条路径更优,就进行更新。

\(Bellman–Ford\) 算法所做的,就是不断尝试对图上每一条边进行松弛。我们每进行一轮循环,就对图上所有的边都尝试进行一次松弛操作,当一次循环中没有成功的松弛操作时,算法停止。

每次循环是 \(O(m)\) 的,那么最多会循环多少次呢?

在最短路存在的情况下,由于一次松弛操作会使最短路的边数至少 \(+1\),而最短路的边数最多为 \(n-1\),因此整个算法最多执行 \(n-1\) 轮松弛操作。故总时间复杂度为 \(O(nm)\)

但还有一种情况,如果从 \(S\) 点出发,抵达一个负环时,松弛操作会无休止地进行下去。注意到前面的论证中已经说明了,对于最短路存在的图,松弛操作最多只会执行 \(n-1\) 轮,因此如果第 \(n\) 轮循环时仍然存在能松弛的边,说明从 \(S\) 点出发,能够抵达一个负环。

实现

int n, m;       // n表示点数,m表示边数
int dist[N];        // dist[x]存储1到x的最短路距离

struct Edge     // 边,a表示出点,b表示入点,w表示边的权重
{
    int a, b, w;
}edges[M];

// 求1到n的最短路距离,如果无法从1走到n,则返回-1。
int bellman_ford()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;

    // 如果第n次迭代仍然会松弛三角不等式,就说明存在一条长度是n+1的最短路径,由抽屉原理,路径中至少存在两个相同的点,说明图中存在负权回路。
    for (int i = 0; i < n; i ++ )
    {
        for (int j = 0; j < m; j ++ )
        {
            int a = edges[j].a, b = edges[j].b, w = edges[j].w;
            if (dist[b] > dist[a] + w)
                dist[b] = dist[a] + w;
        }
    }

    if (dist[n] > 0x3f3f3f3f / 2) return -1;
    return dist[n];
}

优化后的\(~Bellman-Ford\)——\(SPFA\)

介绍和证明

若给定的图存在负权边,类似\(~Dijkstra~\)算法等算法便没有了用武之地,\(~SPFA~\)算法便派上用场了。简洁起见,我们约定加权有向图G不存在负权回路,即最短路径一定存在。用数组\(~d~\)记录每个结点的最短路径估计值,而且用邻接表来存储图\(~G\)。我们采取的方法是动态逼近法:设立一个先进先出的队列用来保存待优化的结点,优化时每次取出队首结点\(~u~\),并且用\(~u~\)点当前的最短路径估计值对离开\(~u~\)点所指向的结点\(~v~\)进行松弛操作,如果v点的最短路径估计值有所调整,且\(~v~\)点不在当前的队列中,就将v点放入队尾。这样不断从队列中取出结点来进行松弛操作,直至队列空为止。
定理:只要最短路径存在,上述\(~SPFA~\)算法必定能求出最小值。证明:每次将点放入队尾,都是经过松弛操作达到的。换言之,每次的优化将会有某个点v的最短路径估计值\(~d_v~\)变小。所以算法的执行会使\(~d~\)越来越小。由于我们假定图中不存在负权回路,所以每个结点都有最短路径值。因此,算法不会无限执行下去,随着\(~d~\)值的逐渐变小,直到到达最短路径值时,算法结束,这时的最短路径估计值就是对应结点的最短路径值。
实际上,如果一个点进入队列达到\(~n~\)次,则表明图中存在负环,没有最短路径。
段凡丁论文中的复杂度证明 (\(O(kE)\)\(~k~\)是小常数)是错误的,在此略去。该算法的最坏时间复杂度为 \(O(VE)\)
\(~SPFA~\)的一个很直观的理解就是由无权图的\(~BFS~\)转化而来。在无权图中,\(BFS~\)首先到达的顶点所经历的路径一定是最短路(也就是经过的最少顶点数),所以此时利用数组记录节点访问可以使每个顶点只进队一次,但在带权图中,最先到达的顶点所计算出来的路径不一定是最短路。一个解决方法是放弃数组,此时所需时间自然就是指数级的,所以我们不能放弃数组,而是在处理一个已经在队列中且当前所得的路径比原来更好的顶点时,直接更新最优解。

实现

int n;      // 总点数
int h[N], w[N], e[N], ne[N], idx;       // 邻接表存储所有边
int dist[N];        // 存储每个点到1号点的最短距离
bool st[N];     // 存储每个点是否在队列中

// 求1号点到n号点的最短路距离,如果从1号点无法走到n号点则返回-1
int spfa()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;

    queue<int> q;
    q.push(1);
    st[1] = true;

    while (q.size())
    {
        auto t = q.front();
        q.pop();

        st[t] = false;

        for (int i = h[t]; i != -1; i = ne[i])
        {
            int j = e[i];
            if (dist[j] > dist[t] + w[i])
            {
                dist[j] = dist[t] + w[i];
                if (!st[j])     // 如果队列中已存在j,则不需要将j重复插入
                {
                    q.push(j);
                    st[j] = true;
                }
            }
        }
    }

    if (dist[n] == 0x3f3f3f3f) return -1;
    return dist[n];
}

多源最短路实现

\(Floyd\)

介绍

\(Floyd~\)算法又称为插点法,是一种利用动态规划的思想寻找给定的加权图中多源点之间最短路径的算法,与\(~Dijkstra~\)算法类似。该算法名称以创始人之一、\(1978~\)年图灵奖获得者、斯坦福大学计算机科学系教授罗伯特·弗洛伊德命名。

步骤

我们定义一个数组 \(f[k][x][y]\),表示只允许经过结点 \(1\)\(k\)(也就是说,在子图 \(V'={1, 2, \ldots, k}\) 中的路径,注意,\(x\)\(y\) 不一定在这个子图中),结点 \(x\) 到结点 \(y\) 的最短路长度。

很显然,\(f[n][x][y]\) 就是结点 \(x\) 到结点 \(y\) 的最短路长度(因为 \(V'={1, 2, \ldots, n}\) 即为 \(V\) 本身,其表示的最短路径就是所求路径)。

接下来考虑如何求出 \(f\) 数组的值。

\(f[0][x][y]\)\(x\)\(y\) 的边权,或者 \(0\),或者 \(+\infty\)\(f[0][x][y]\) 什么时候应该是 \(+\infty\)?当 \(x\)\(y\) 间有直接相连的边的时候,为它们的边权;当 \(x = y\) 的时候为零,因为到本身的距离为零;当 \(x\)\(y\) 没有直接相连的边的时候,为 \(+\infty\))。

\(f[k][x][y] = min(f[k-1][x][y], f[k-1][x][k]+f[k-1][k][y])\)\(f[k-1][x][y]\),为不经过 \(k\) 点的最短路径,而 \(f[k-1][x][k]\)+\(f[k-1][k][y]\),为经过了 \(k\) 点的最短路)。

上面两行都显然是对的,所以说这个做法空间是 \(O(N^3)\),我们需要依次增加问题规模(\(k\)\(1\)\(n\)),判断任意两点在当前问题规模下的最短路。

实现

初始化:
    for (int i = 1; i <= n; i ++ )
        for (int j = 1; j <= n; j ++ )
            if (i == j) d[i][j] = 0;
            else d[i][j] = INF;

// 算法结束后,d[a][b]表示a到b的最短距离
void floyd()
{
    for (int k = 1; k <= n; k ++ )
        for (int i = 1; i <= n; i ++ )
            for (int j = 1; j <= n; j ++ )
                d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
}
posted @ 2024-08-05 14:49  LTC_Augenstern  阅读(18)  评论(2编辑  收藏  举报