算法设计与分析


单源最短路径

计算机学院    张腾

tengzhang@hust.edu.cn

问题背景

武汉到北京的

  • 最短行驶距离路线
  • 最短行驶时间路线
  • 最少通行费路线
问题描述

给定带权有向图$\Gcal = (\Vcal, \Ecal, w)$

  • 点集$\Vcal$对应城市
  • 边集$\Ecal$对应直接连接两城市的道路
  • 边上的权重由函数$w: \Ecal \mapsto \Rbb$给出,对应距离、时间、路费等

路径$p = \langle v_0, v_1, \ldots, v_k \rangle$的长度$l(p) = \sum_{i=1}^k w(v_{i-1}, v_i)$是路径上所有边的权重之和

问题目标:寻找源点$s$到目的点$t$的最短路径:

$$ \begin{align*} \quad \delta(s,t) = \begin{cases} \min_p \{ l(p) : s \overset{p}{\rightsquigarrow} t \}, & \text{如果存在 } s \text { 到 } t \text{ 的路径} \\ \infty, & \text{其它} \end{cases} \end{align*} $$

权重函数$w: \Ecal \mapsto \Rbb$的值域为$\Rbb$表示边上权重可以为负,对应于走该边可以挣一些钱

问题描述

几乎所有求图上两点间最短路径的问题都会归结为更一般的

单源最短路径问题 (single source shortest path, SSSP)

目标:求源点$s$到其它所有点的最短路径

输出:以源点$s$为根结点的最短路径树 (shortest path tree)

问题约定

最短路径是简单路径,即不包含环、最多有$|\Vcal|-1$条边

  • 若最短路径包含正环,去掉可以得到更短的路径,矛盾
  • 若最短路径包含零环,去掉该环依然是最短路径
  • 若最短路径包含负环,沿着环一直转可以无限降低权重,即最短路径不存在

严格来说,路径 (path) 就是要求每个点最多访问一次,否则称之为游走 (walk)

Ford 算法框架

对任意点$v$

  • $d_v$表示当前已知的$s \rightsquigarrow v$的最短路径距离,若无路径则为$\infty$
  • $p_v$表示当前已知的$s \rightsquigarrow v$的最短路径上$v$的前驱结点,若无则为空

初始化:$d_s = 0$$d_{v \ne s} = \infty$$p_v = \text{NULL}$

松弛 (relax):若$d_v > d_u + w(u,v)$,令$d_v = d_u + w(u,v)$$p_v = u$

Ford 算法框架:初始化所有点,若还有边可以松弛,松弛该边

  • 如何寻找可松弛的边?
  • 若有多条边可松弛,先松弛哪条?
无权图

$\Gcal$是无权图,路径长度即为经过的边的数目

Ford 算法框架实现:

  • 初始化距离数组$d$和前驱数组$p$
  • 源点$s$入队
  • 当队列非空时,队首元素$u$出队,遍历$u$的所有出边,若边$(u,v)$可松弛,即$d_v > d_u + 1$,更新$d_v = d_u + 1$$p_v = u$,点$v$入队

基于广度优先搜索 (BFS) 实现的时间复杂度为$O(|\Vcal| + |\Ecal|)$

无权图

def bfs(g, s):
    visted = [s]  # 初始化已访问列表
    q = [s]  # 源点入队
    while q:  # 当队列非空时
        u = q.pop(0)  # 队首元素出队
        for v in g[u]:  # 对每条边(u,v)
            if v not in visted:
                visted.append(v)
                q.append(v)  # 点v入队
    return visted


def init_sssp(g, s):
    dist, pred = dict(), dict()
    for v in g:
        dist[v] = float("inf") if v != s else 0
        pred[v] = None
    return dist, pred


def bfs_sssp(g, s):
    dist, pred = init_sssp(g, s)  # 初始化距离和前驱
    q = [s]  # 源点入队
    while q:  # 当队列非空时
        u = q.pop(0)  # 队首元素出队
        for v in g[u]:  # 对每条边(u,v)
            if dist[v] > dist[u] + 1:  # 若可以松弛
                dist[v] = dist[u] + 1  # 更新距离
                pred[v] = u  # 更新前驱
                q.append(v)  # 点v入队
    return dist, pred


g = {
    "s": ["a", "b"],            #    a     e
    "a": ["s", "c"],            #   / \   /|
    "b": ["s", "c", "d"],       #  /   \ / |
    "c": ["a", "b", "d", "e"],  # s     c  |
    "d": ["b", "c", "e"],       #  \   / \ |
    "e": ["c", "d"],            #   \ /   \|
}                               #    b-----d

visited = bfs(g, "s")
print(visited)
# --------------------------------------------
# ['s', 'a', 'b', 'c', 'd', 'e']

dist, pred = bfs_sssp(g, "s")
print(dist)
print(pred)
# --------------------------------------------
# {'s': 0, 'a': 1, 'b': 1, 'c': 2, 'd': 2, 'e': 3}
# {'s': None, 'a': 's', 'b': 's', 'c': 'a', 'd': 'b', 'e': 'c'}

正整数权重图

问题归约:若$\Gcal$的所有边的权重为正整数,将权重为$l$的边分解成$l$个长度为$1$的边,得到无权图$\Gcal'$,对$\Gcal'$运行bfs_sssp算法

每个权重为$l$的边会引入$l-1$个新点、新边,设$W$为所有边的权重和,则$\Gcal'$会引入$W - |\Ecal|$个新点、新边

  • 新点数$|\Vcal'| = |\Vcal| + W - |\Ecal|$
  • 新边数$|\Ecal'| = |\Ecal| + W - |\Ecal| = W$
  • 时间复杂度$O(|\Vcal'| + |\Ecal'|) = O(|\Vcal| + 2 W - |\Ecal|)$
有向无环图 DAG

松弛

$$ \begin{align*} \quad d_v = \begin{cases} 0, & v = s \\ \min_u \{ d_u + w(u,v) \}, & \text{其它} \end{cases} \end{align*} $$

Ford 算法框架实现:

  • 若图上有边$(u,v)$,则$d_v$依赖$d_u$
  • 利用后序 (posrorder) 形式的深度优先搜索得到结点的拓扑序
  • 根据拓扑序依次松弛每个点的入边

基于深度优先搜索 (DFS) 实现的时间复杂度为$O(|\Vcal| + |\Ecal|)$

有向无环图 DAG

def dfs(g, u):
    if u not in visited:
        visited.append(u)
        for v in g[u]:
            dfs(g, v)
        label.append(u)  # 后序DFS可以保得到结点的逆拓扑序


g = {
    "s": {"a": 3, "b": 8, "c": 7},
    "a": {"b": 6, "d": 12},
    "b": {"c": -2, "d": 5, "e": 0},
    "c": {"e": 10},
    "d": {"e": 1, "f": -3},
    "e": {"f": 1},
    "f": {}
}
visited, label = [], []
dfs(g, "s")
label.reverse()  # 反转得到拓扑序
print(label)
# -------------------------------------------
# ['s', 'a', 'b', 'd', 'c', 'e', 'f']

gg = {}  # g记录每个点的出边 gg记录每个点的入边
for u in g:
    gg[u] = {}
for u in g:
    for v in g[u]:
        gg[v][u] = g[u][v]

dist, pred = {"s": 0}, {"s": None}
for v in label:  # 按拓扑序遍历求最短路径
    if v != "s":
        dist[v] = float("inf")
        for u in gg[v]:
            if dist[v] > dist[u] + gg[v][u]:
                dist[v] = dist[u] + gg[v][u]
                pred[v] = u
print(dist)
print(pred)
# ----------------------------------------------------------------------
# {'s': 0, 'a': 3, 'b': 8, 'd': 13, 'c': 6, 'e': 8, 'f': 9}
# {'s': None, 'a': 's', 'b': 's', 'd': 'b', 'c': 'b', 'e': 'b', 'f': 'e'}

最短路径 最优子结构

设路径$p = \langle v_0, v_1, \ldots, v_k \rangle$是从$v_0$$v_k$的一条最短路径,则其任意子路径$\langle v_i, v_{i+1}, \ldots, v_j \rangle$是从$v_i$$v_j$的最短路径

此时路径$p$分为三部分,$v_0 \overset{p_1}{\rightsquigarrow} v_i \overset{p_2}{\rightsquigarrow} v_j \overset{p_3}{\rightsquigarrow} v_k$

$p_2$不是最短路径,设$p'_2$更短,则$v_0 \overset{p_1}{\rightsquigarrow} v_i \overset{p'_2}{\rightsquigarrow} v_j \overset{p_3}{\rightsquigarrow} v_k$也更短


源点到不同点的最短路径显然存在公共的子路径

最优化问题 + 最优子结构 + 公共子问题 ?

最短路径 动态规划

如何定义子问题并确定其递推关系?若采用自底向上,何为底?

以有向边确定子问题间的依赖?环会导致死循环

最短路径的边数有上界,不超过$|\Vcal|-1$

$$ \begin{align*} \qquad \qquad \begin{matrix} \text{经过不超过 }|\Vcal|-1\text{ 条边的最短路径的长度} \\ \vdots \\ \text{经过不超过 }2\text{ 条边的最短路径的长度} \\ \text{经过不超过 }1\text{ 条边的最短路径的长度} \\ \end{matrix} \qquad \quad \Bigg \Uparrow \end{align*} $$

最短路径 递推关系式

$d_v^{(k)}$:从$s$$v$经过不超过$k$条边的最短路径的长度

  • 初始$k=0$$s$到其他点都不可达,$d_v^{(0)} = \infty$
  • $k \ge |\Vcal| - 1$$d_v^{(k)}$不再变化,因为最短路径的边数不超过$|\Vcal|-1$

直觉上允许经过的边越多,最短路径越短

当允许经过的边数从$k-1$条放宽至$k$

  • 没有改进,此时$d_v^{(k)} = d_v^{(k-1)}$
  • 有改进,经过$k-1$条边到点$u$后再到$v$$d_v^{(k)} = d_u^{(k-1)} + w(u,v)$

$$ \begin{align*} \quad d_v^{(k)} = \begin{cases} \infty, & k = 0 \\ \min \{ d_v^{(k-1)}, ~ \min_{u \ne v} \{ d_u^{(k-1)} + w(u,v) \} \}, & k \ge 1 \end{cases} \end{align*} $$

Bellman-Ford 填表

$$ \begin{align*} \quad d_v^{(k)} = \begin{cases} \infty, & k = 0 \\ \min \{ d_v^{(k-1)}, ~ \min_{u \ne v} \{ d_u^{(k-1)} + w(u,v) \} \}, & k \ge 1 \end{cases} \end{align*} $$

最终$d_u^{(|\Vcal|-1)} = \delta(s,v)$就是源点$s$$v$的最短路径的长度

def bellman_ford(s):
    d, p = dict(), dict()
    for v in g:  # 距离初始化为无穷大 前驱初始化为空
        d[v], p[v] = float("inf"), None
    d[s] = 0  # 源点到自己的最短路径长度为零

    for _ in range(len(g) - 1):  # 遍历|V|-1次
        for u in g:
            for v in g[u]:  # 内部的二重for循环遍历所有边
                if d[v] > d[u] + g[u][v]:           # 松弛
                    d[v], p[v] = d[u] + g[u][v], u  # 更新当前最短距离和前驱

内层二重 for 循环其实是遍历所有边,时间复杂度$\Theta(|\Vcal||\Ecal|)$

Bellman-Ford 例子

$\quad \small |\Vcal|=5, ~ d_v^{(k)} = \begin{cases} \infty, & k = 0 \\ \min \{ d_v^{(k-1)}, ~ \min_{u \ne v} \{ d_u^{(k-1)} + w(u,v) \} \}, & k \ge 1 \end{cases}$

Bellman-Ford 负环检测

对任意边$(u,v)$应有三角不等式$\delta(s,v) \le \delta(s,u) + w(u,v)$

假如图中有从源点可达的负环$v_i, v_{i+1}, \ldots, v_{j-1}, v_j$,其中$v_j = v_i$

$$ \begin{align*} \quad 0 & = \sum_{k=i}^{j-1} \delta(s,v_{k+1}) - \sum_{k=i}^{j-1} \delta(s,v_k) \overset{\text{三角不等式}}{\le} ~ \sum_{k=i}^{j-1} w(v_k,v_{k+1}) \overset{\text{负环}}{<} 0 \end{align*} $$

故图中若有负环,三角不等式不可能成立

负环检测:在求完所有最短路径后,对所有边检测一遍三角不等式,时间复杂度$\Theta(|\Ecal|)$

Bellman-Ford 实现

def bellman_ford(g, s):
    d, p = dict(), dict()
    for v in g:  # 距离初始化为无穷大 前驱初始化为空
        d[v], p[v] = float("inf"), None
    d[s] = 0  # 源点到自己的最短路径长度为零
    print(0, d)
    for i in range(len(g) - 1):  # 遍历|V|-1次
        dd = {key: value for (key, value) in d.items()}  # 备份上一轮的d
        for u in g:
            for v in g[u]:  # 内部的二重for循环遍历所有边
                if d[v] > dd[u] + g[u][v]:           # 松弛
                    d[v], p[v] = dd[u] + g[u][v], u  # 更新当前最短距离和前驱
        print(i+1, d)

    for u in g:
        for v in g[u]:
            assert (d[v] <= d[u] + g[u][v]), "有负环"

    return d, p


g = {                                # 用集合表示有向图g 元素为字典
    "s": {"t": 6, "y": 7},           # w(s,t) = 6, w(s,y) = 7
    "t": {"x": 5, "z": -4, "y": 8},  # w(t,x) = 5, w(t,z) = -4, w(t,y) = 8
    "y": {"z": 9, "x": -3},          # w(y,z) = 9, w(y,x) = -3
    "z": {"x": 7, "s": 2},           # w(z,x) = 7, w(z,s) = 2
    "x": {"t": -2},                  # w(x,t) = -2
}

d, p = bellman_ford(g, s="s")
# ---------------------------------------------------
# 0 {'s': 0, 't': inf, 'y': inf, 'z': inf, 'x': inf}
# 1 {'s': 0, 't': 6, 'y': 7, 'z': inf, 'x': inf}
# 2 {'s': 0, 't': 6, 'y': 7, 'z': 2, 'x': 4}
# 3 {'s': 0, 't': 2, 'y': 7, 'z': 2, 'x': 4}
# 4 {'s': 0, 't': 2, 'y': 7, 'z': -2, 'x': 4}
Bellman-Ford 说明

Q1:外层循环一定要迭代$|\Vcal| - 1$次吗?

A:不是,当相邻两轮的$d[]$表不再有更新时即可停止

Q2:迭代多少轮$d[]$才会不再有更新呢?

A:若所有点的最短路径的边数最大为$k$,则需迭代$k$

证明:设$s \rightsquigarrow v$的最短路径上$v$的前驱是$u$,即$s \rightsquigarrow u \rightarrow v$

若某轮$d_u$更新为$\delta (s,u)$,则下一轮$d_v = \delta (s,u) + w(u,v) = \delta (s,v)$

初始$d_s = \delta (s,s) = 0$,第一轮后最短路径恰为 1 条边的点就对了

依此类推,第$i$轮后最短路径恰为$i$条边的点就对了

最短路径最长为$|\Vcal| - 1$,最坏情况下需迭代$|\Vcal| - 1$

Bellman-Ford 说明

def bellman_ford(g, s):
    d, p = dict(), dict()
    for v in g:  # 距离初始化为无穷大 前驱初始化为空
        d[v], p[v] = float("inf"), None
    d[s] = 0  # 源点到自己的最短路径长度为零
    print(0, d)
    for i in range(len(g) - 1):  # 遍历|V|-1次
        dd = {key: value for (key, value) in d.items()}  # 备份上一轮的d
        for u in g:
            for v in g[u]:  # 内部的二重for循环遍历所有边
                if d[v] > dd[u] + g[u][v]:           # 松弛
                    d[v], p[v] = dd[u] + g[u][v], u  # 更新当前最短距离和前驱
        print(i+1, d)

    for u in g:
        for v in g[u]:
            assert (d[v] <= d[u] + g[u][v]), "有负环"

    return d, p


g = {  # s -> x -> y -> z -> t
    "s": {"x": 1},  # w(s,x) = 1
    "x": {"y": 1},  # w(x,y) = 1
    "y": {"z": 1},  # w(y,z) = 1
    "z": {"t": 1},  # w(z,t) = 1
    "t": {}
}

d, p = bellman_ford(g, s="s")
# ---------------------------------------------------
# 0 {'s': 0, 'x': inf, 'y': inf, 'z': inf, 't': inf}
# 1 {'s': 0, 'x': 1, 'y': inf, 'z': inf, 't': inf}
# 2 {'s': 0, 'x': 1, 'y': 2, 'z': inf, 't': inf}
# 3 {'s': 0, 'x': 1, 'y': 2, 'z': 3, 't': inf}
# 4 {'s': 0, 'x': 1, 'y': 2, 'z': 3, 't': 4}

g = {  # s -> x -> y -> {z, t}
    "s": {"x": 1},          # w(s,x) = 1
    "x": {"y": 1},          # w(x,y) = 1
    "y": {"z": 1, "t": 1},  # w(y,z) = 1, w(y,t) = 1
    "z": {},
    "t": {}
}

d, p = bellman_ford(g, s="s")
# ---------------------------------------------------
# 0 {'s': 0, 'x': inf, 'y': inf, 'z': inf, 't': inf}
# 1 {'s': 0, 'x': 1, 'y': inf, 'z': inf, 't': inf}
# 2 {'s': 0, 'x': 1, 'y': 2, 'z': inf, 't': inf}
# 3 {'s': 0, 'x': 1, 'y': 2, 'z': 3, 't': 3}
# 4 {'s': 0, 'x': 1, 'y': 2, 'z': 3, 't': 3}

g = {  # s -> {x, y}, y -> {z, t}
    "s": {"x": 1, "y": 1},  # w(s,x) = 1, w(s,y) = 1
    "x": {},
    "y": {"z": 1, "t": 1},  # w(y,z) = 1, w(y,t) = 1
    "z": {},
    "t": {}
}

d, p = bellman_ford(g, s="s")
# ---------------------------------------------------
# 0 {'s': 0, 'x': inf, 'y': inf, 'z': inf, 't': inf}
# 1 {'s': 0, 'x': 1, 'y': 1, 'z': inf, 't': inf}
# 2 {'s': 0, 'x': 1, 'y': 1, 'z': 2, 't': 2}
# 3 {'s': 0, 'x': 1, 'y': 1, 'z': 2, 't': 2}
# 4 {'s': 0, 'x': 1, 'y': 1, 'z': 2, 't': 2}
Bellman-Ford 说明

《算法导论》上 Bellman-Ford 算法没要求备份上一轮的$d[]$

$$ \begin{align*} \quad d_v = \begin{cases} \infty, & k = 0 \\ \min \{ d_v, ~ \min_{u \ne v} \{ d_u + w(u,v) \} \}, & k \ge 1 \end{cases} \end{align*} $$

区别:如果本轮$d_u$更新了

  • $d_v$也可以紧接着更新,减少迭代次数,不过算法不能再叫动态规划了
  • 而在动态规划的实现中,$d_v$值必须到下一轮才能更新,因为本轮用的还是上一轮的$d_u$

任意点的$d$值单调递减,最新的就是最好的,用最好的没毛病!

但是当采用即时更新时,点的更新顺序会产生影响

Bellman-Ford 说明

def bellman_ford_dp(g, s):
    d = dict()
    for v in g:  # 距离初始化为无穷大 前驱初始化为空
        d[v] = float("inf")
    d[s] = 0  # 源点到自己的最短路径长度为零
    print(0, d)
    for i in range(len(g) - 1):  # 遍历|V|-1次
        dd = {key: value for (key, value) in d.items()}  # 备份上一轮的d
        for u in g:
            for v in g[u]:  # 内部的二重for循环遍历所有边
                d[v] = min(d[v], dd[u] + g[u][v])  # 松弛
        print(i+1, d)
    return d


def bellman_ford(g, s):
    d = dict()
    for v in g:  # 距离初始化为无穷大 前驱初始化为空
        d[v] = float("inf")
    d[s] = 0  # 源点到自己的最短路径长度为零
    print(0, d)
    for i in range(len(g) - 1):  # 遍历|V|-1次
        for u in g:
            for v in g[u]:  # 内部的二重for循环遍历所有边
                d[v] = min(d[v], d[u] + g[u][v])  # 松弛
        print(i+1, d)
    return d


g = {  # 顺序遍历所有点 s -> x -> y -> z -> t
    "s": {"x": 1},  # w(s,x) = 1
    "x": {"y": 1},  # w(x,y) = 1
    "y": {"z": 1},  # w(y,z) = 1
    "z": {"t": 1},  # w(z,t) = 1
    "t": {}
}

print("顺序 使用上一轮的d")
d = bellman_ford_dp(g, s="s")
# ---------------------------------------------------
# 顺序 使用上一轮的d
# 0 {'s': 0, 'x': inf, 'y': inf, 'z': inf, 't': inf}
# 1 {'s': 0, 'x': 1, 'y': inf, 'z': inf, 't': inf}
# 2 {'s': 0, 'x': 1, 'y': 2, 'z': inf, 't': inf}
# 3 {'s': 0, 'x': 1, 'y': 2, 'z': 3, 't': inf}
# 4 {'s': 0, 'x': 1, 'y': 2, 'z': 3, 't': 4}

print("顺序 使用即时的d")
d = bellman_ford(g, s="s")
# ---------------------------------------------------
# 顺序 使用即时的d
# 0 {'s': 0, 'x': inf, 'y': inf, 'z': inf, 't': inf}
# 1 {'s': 0, 'x': 1, 'y': 2, 'z': 3, 't': 4}
# 2 {'s': 0, 'x': 1, 'y': 2, 'z': 3, 't': 4}
# 3 {'s': 0, 'x': 1, 'y': 2, 'z': 3, 't': 4}
# 4 {'s': 0, 'x': 1, 'y': 2, 'z': 3, 't': 4}

g = {  # 逆序遍历所有点 t -> z -> y -> x -> s
    "t": {},
    "z": {"t": 1},  # w(z,t) = 1
    "y": {"z": 1},  # w(y,z) = 1
    "x": {"y": 1},  # w(x,y) = 1
    "s": {"x": 1},  # w(s,x) = 1
}

print("逆序 使用上一轮的d")
d = bellman_ford_dp(g, s="s")
# ---------------------------------------------------
# 逆序 使用上一轮的d
# 0 {'t': inf, 'z': inf, 'y': inf, 'x': inf, 's': 0}
# 1 {'t': inf, 'z': inf, 'y': inf, 'x': 1, 's': 0}
# 2 {'t': inf, 'z': inf, 'y': 2, 'x': 1, 's': 0}
# 3 {'t': inf, 'z': 3, 'y': 2, 'x': 1, 's': 0}
# 4 {'t': 4, 'z': 3, 'y': 2, 'x': 1, 's': 0}

print("逆序 使用即时的d")
d = bellman_ford(g, s="s")
# ---------------------------------------------------
# 逆序 使用即时的d
# 0 {'t': inf, 'z': inf, 'y': inf, 'x': inf, 's': 0}
# 1 {'t': inf, 'z': inf, 'y': inf, 'x': 1, 's': 0}
# 2 {'t': inf, 'z': inf, 'y': 2, 'x': 1, 's': 0}
# 3 {'t': inf, 'z': 3, 'y': 2, 'x': 1, 's': 0}
# 4 {'t': 4, 'z': 3, 'y': 2, 'x': 1, 's': 0}
Bellman-Ford 小结

设所有点的最短路径边数最长的为$k$

动态规划 非动态规划
使用$d[]$ 上一轮 即时
点的更新顺序 没影响 有影响
最好情况 $k$ $1$轮,点的更新顺序恰是最短路径上的顺序
最坏情况 $k$ $k$轮,点的更新顺序恰是最短路径上的逆序
最短路径 贪心加速?

$s$出发只有$t$$y$一步能到,其它点至少需经过其中某个点

注意$w(s,t) = 6 < 7 = w(s,y)$,若做贪心选择,应该先选择$t$

但实际却是从$y$$x$中转

$y$出发后续有负权重的边可以再减少路径长度

若想贪心,不能有负权重的边

Dijkstra 算法

假设图中不再有负权重的边来减少路径长度

引入已确定最短路径的顶点集合$\Scal$,初始为空

贪心选择:从$\Vcal \setminus \Scal$中选择最短路径估计值最小的结点加入$\Scal$

$d_s = 0$$d_y = d_t = d_x = d_z = \infty$

$s$加入$\Scal$$\Scal = \{ s \}$

Dijkstra 算法

$\Scal = \{ s \}$,根据$\delta(s,s) = 0$更新最短路径估计值

  • $d_t = \infty \rightarrow d_s = \delta(s,s) + w(s,t) = 0 + 10 = 10$
  • $d_y = \infty \rightarrow d_y = \delta(s,s) + w(s,y) = 0 + 5 = 5$
  • $d_x = d_z = \infty$

$y$加入$\Scal$$\Scal = \{ s, y \}$

Dijkstra 算法

$\Scal = \{ s, y \}$,根据$\delta(s,y) = 5 ~ (?)$更新最短路径估计值

  • $d_t = 10 \rightarrow d_t = \delta(s,y) + w(y,t) = 5 + 3 = 8$
  • $d_x = \infty \rightarrow d_x = \delta(s,y) + w(y,x) = 5 + 9 = 14$
  • $d_z = \infty \rightarrow d_z = \delta(s,y) + w(y,z) = 5 + 2 = 7$

$z$加入$\Scal$$\Scal = \{ s, y , z \}$,如此迭代下去

Dijkstra 例子

Dijkstra 实现

# python自带的二叉堆库heapq不支持decrease-key操作
# 采用第三方库heapdict https://pypi.org/project/HeapDict/
import heapdict


def init_sssp(g, s):
    dist, pred = dict(), dict()
    for v in g:
        dist[v] = float("inf") if v != s else 0
        pred[v] = None
    return dist, pred


def dijkstra_no_negative(g, s):
    dist, pred = init_sssp(g, s)  # 初始化距离和前驱
    h, in_S = heapdict.heapdict(), dict()
    for v in g:
        h[v] = float("inf") if v != s else 0
        in_S[v] = False

    iter = 0
    while h:
        iter += 1
        print(f"第{iter}轮")
        u, d = h.popitem()  # 弹出最小元
        dist[u] = d  # 此时u的值已经是最短路径
        in_S[u] = True
        print(f"({u},{d})出堆,{s}{u}的最短路径长度确定为{d}")
        for v in g[u]:  # 遍历u的所有出边(u,v)
            if not in_S[v] and h[v] > dist[u] + g[u][v]:
                print(f"放松边({u}->{v}),{s}{v}的最短路径:{h[v]} => {dist[u] + g[u][v]}")
                h[v] = d + g[u][v]
                pred[v] = u
        print()
    return dist, pred


g = {
    "s": {"t": 10, "y": 5},  # w(s,t) = 6, w(s,y) = 5
    "t": {"x": 1, "y": 2},  # w(t,x) = 1, w(t,y) = 2
    "y": {"t": 3, "z": 2, "x": 9},  # w(y,t) = 3, w(y,z) = 2, w(y,x) = 9
    "z": {"s": 7, "x": 6},  # w(z,s) = 7, w(z,x) = 6
    "x": {"z": 4},  # w(x,z) = 4
}

dist, pred = dijkstra_no_negative(g, "s")
print(dist)
print(pred)
# --------------------------------------
# 第1轮
# (s,0)出堆,s到s的最短路径长度确定为0
# 放松边(s->t),s到t的最短路径:inf => 10
# 放松边(s->y),s到y的最短路径:inf => 5
#
# 第2轮
# (y,5)出堆,s到y的最短路径长度确定为5
# 放松边(y->t),s到t的最短路径:10 => 8
# 放松边(y->z),s到z的最短路径:inf => 7
# 放松边(y->x),s到x的最短路径:inf => 14
#
# 第3轮
# (z,7)出堆,s到z的最短路径长度确定为7
# 放松边(z->x),s到x的最短路径:14 => 13
#
# 第4轮
# (t,8)出堆,s到t的最短路径长度确定为8
# 放松边(t->x),s到x的最短路径:13 => 9
#
# 第5轮
# (x,9)出堆,s到x的最短路径长度确定为9
#
# {'s': 0, 't': 8, 'y': 5, 'z': 7, 'x': 9}
# {'s': None, 't': 'y', 'y': 's', 'z': 'y', 'x': 't'}

# dijkstra_no_negative处理带负边的图的会输出错误的结果
# 下图为Bellman-Ford例子中的图
g = {
    "s": {"t": 6, "y": 7},  # w(s,t) = 6, w(s,y) = 7
    "t": {"x": 5, "z": -4, "y": 8},  # w(t,x) = 5, w(t,z) = -4, w(t,y) = 8
    "y": {"z": 9, "x": -3},  # w(y,z) = 9, w(y,x) = -3
    "z": {"x": 7, "s": 2},  # w(z,x) = 7, w(z,s) = 2
    "x": {"t": -2},  # w(x,t) = -2
}

dist, pred = dijkstra_no_negative(g, "s")
print(dist)
print(pred)
# --------------------------------------
# 第1轮
# (s,0)出堆,s到s的最短路径长度确定为0
# 放松边(s->t),s到t的最短路径:inf => 6
# 放松边(s->y),s到y的最短路径:inf => 7
#
# 第2轮
# (t,6)出堆,s到t的最短路径长度确定为6
# 放松边(t->x),s到x的最短路径:inf => 11
# 放松边(t->z),s到z的最短路径:inf => 2
#
# 第3轮
# (z,2)出堆,s到z的最短路径长度确定为2
# 放松边(z->x),s到x的最短路径:11 => 9
#
# 第4轮
# (y,7)出堆,s到y的最短路径长度确定为7
# 放松边(y->x),s到x的最短路径:9 => 4
#
# 第5轮
# (x,4)出堆,s到x的最短路径长度确定为4
#
# {'s': 0, 't': 6, 'y': 7, 'z': 2, 'x': 4}
# {'s': None, 't': 's', 'y': 's', 'z': 't', 'x': 'y'}

Dijkstra 正确性

不变式:在外层 for 循环每次执行前,对$\forall u \in \Scal$$d_u = \delta (s,u)$

  • 根据算法流程,点$u$一旦加入$\Scal$,就不会再修正$d_u$
  • 只需证明对$\forall u \in \Scal$,当其被加入到$\Scal$时有$d_u = \delta (s,u)$

初始$\Scal = \emptyset$,之后源点$s$第一个加入$\Scal$,显然$d_s = \delta (s,s) = 0$

$u$第一个加入$\Scal$$d_u \ne \delta (s,u)$的点

此时必存在$s$$u$的路径,否则$d_u = \delta (s,u) = \infty$,与假设矛盾

$s$$u$的最短路径为$p$

Dijkstra 正确性

下面说明$u$是路径$p$上第一个 (也是唯一一个) 不在$\Scal$中的点

否则设$u$前还有$y \not \in \Scal$,由于$d_y < d_u$,应该加入的是$y$不是$u$

$u$的前驱$x \in \Scal$$x$$u$之前加入$\Scal$,因此$d_x = \delta(s,x)$

$x$加入$\Scal$时会更新$x$指向的所有点的最短路径估计值

$d_u = d_x + w(x,u) = \delta(s,x) + w(x,u)$

根据最优子结构性,$d_u = \delta(s,u)$

Dijkstra 负边

若允许每个点多次出堆、入堆,对有负边的图也是可以得到最短路径的,但时间复杂度没有保证,最坏情况下为指数复杂度

# python自带的二叉堆库heapq不支持decrease-key操作
# 采用第三方库heapdict https://pypi.org/project/HeapDict/
import heapdict


def init_sssp(g, s):
    dist, pred = dict(), dict()
    for v in g:
        dist[v] = float("inf") if v != s else 0
        pred[v] = None
    return dist, pred


def dijkstra(g, s):
    dist, pred = init_sssp(g, s)  # 初始化距离和前驱
    h = heapdict.heapdict()
    h[s] = 0  # 将源点加入二叉堆

    # 有负边时堆首弹出的点的最短路径不一定正确 故无需集合S
    # 引入in_heap判断某个点是否在堆中
    in_heap = dict()
    for v in g:
        in_heap[v] = False if v != s else True

    iter = 0
    while h:
        iter += 1
        print(f"第{iter}轮")
        u, d = h.popitem()  # 弹出最小元
        print(f"({u},{d})出堆")
        in_heap[u] = False
        for v in g[u]:  # 遍历u的所有出边(u,v)
            if dist[v] > dist[u] + g[u][v]:  # 边(u,v)可以松弛
                print(f"放松边({u}->{v}),{s}{v}的最短路径:{dist[v]} => {dist[u] + g[u][v]}")

                # 边(u,v)松弛后 v出发的边或许可以继续松弛 故v入堆
                if not in_heap[v]:
                    in_heap[v] = True
                    h[v] = dist[v] = dist[u] + g[u][v]
                    print(f"({v},{h[v]})入堆")
                else:
                    print(f"更新:({v},{h[v]}) => ({v},{dist[u] + g[u][v]})")
                    h[v] = dist[v] = dist[u] + g[u][v]
                pred[v] = u
        print()
    return dist, pred

# 下图为Bellman-Ford例子中的图 dijkstra_no_negative无法得到正确结果 dijkstra可以得到正确结果
g = {
    "s": {"t": 6, "y": 7},  # w(s,t) = 6, w(s,y) = 7
    "t": {"x": 5, "z": -4, "y": 8},  # w(t,x) = 5, w(t,z) = -4, w(t,y) = 8
    "y": {"z": 9, "x": -3},  # w(y,z) = 9, w(y,x) = -3
    "z": {"x": 7, "s": 2},  # w(z,x) = 7, w(z,s) = 2
    "x": {"t": -2},  # w(x,t) = -2
}

dist, pred = dijkstra(g, "s")
print(dist)
print(pred)
# --------------------------------------
# 第1轮
# (s,0)出堆
# 放松边(s->t),s到t的最短路径:inf => 6
# (t,6)入堆
# 放松边(s->y),s到y的最短路径:inf => 7
# (y,7)入堆
#
# 第2轮
# (t,6)出堆
# 放松边(t->x),s到x的最短路径:inf => 11
# (x,11)入堆
# 放松边(t->z),s到z的最短路径:inf => 2
# (z,2)入堆
#
# 第3轮
# (z,2)出堆
# 放松边(z->x),s到x的最短路径:11 => 9
# 更新:(x,11) => (x,9)
#
# 第4轮
# (y,7)出堆
# 放松边(y->x),s到x的最短路径:9 => 4
# 更新:(x,9) => (x,4)
#
# 第5轮
# (x,4)出堆
# 放松边(x->t),s到t的最短路径:6 => 2
# (t,2)入堆
#
# 第6轮
# (t,2)出堆
# 放松边(t->z),s到z的最短路径:2 => -2
# (z,-2)入堆
#
# 第7轮
# (z,-2)出堆
#
# {'s': 0, 't': 2, 'y': 7, 'z': -2, 'x': 4}
# {'s': None, 't': 'x', 'y': 's', 'z': 't', 'x': 'y'}

单源最短路径 小结

方法 条件 实现 一般时间复杂度
Bellman-Ford 动态规划 可以有负边 $\Theta(\shu\Vcal\shu \shu\Ecal\shu)$
Dijkstra 贪心法 必须无负边 线性数组 $\Theta(\shu\Vcal\shu^2 + \shu\Ecal\shu)$
二叉堆 $\Theta((\shu\Vcal\shu + \shu\Ecal\shu) \lg \shu\Vcal\shu)$
斐波那契堆 $\Theta(\shu\Vcal\shu \lg \shu\Vcal\shu + \shu\Ecal\shu)$

Bellman-Ford 算法可检测负环,进一步考虑图的稀疏性有

实现 一般时间复杂度 稠密图 稀疏图
Bellman-Ford $\Theta(\shu\Vcal\shu \shu\Ecal\shu)$ $\Theta(\shu\Vcal\shu^3)$ $\Theta(\shu\Vcal\shu^2)$
Dijkstra 线性数组 $\Theta(\shu\Vcal\shu^2 + \shu\Ecal\shu)$ $\Theta(\shu\Vcal\shu^2)$ $\Theta(\shu\Vcal\shu^2)$
二叉堆 $\Theta((\shu\Vcal\shu + \shu\Ecal\shu) \lg \shu\Vcal\shu)$ $\Theta(\shu\Vcal\shu^2 \lg \shu\Vcal\shu)$ $\Theta(\shu\Vcal\shu \lg \shu\Vcal\shu)$
斐波那契堆 $\Theta(\shu\Vcal\shu \lg \shu\Vcal\shu + \shu\Ecal\shu)$ $\Theta(\shu\Vcal\shu^2)$ $\Theta(\shu\Vcal\shu \lg \shu\Vcal\shu)$
差分约束系统

假设一个生产工序有$n$个步骤,在时刻$x_i$进行第$i$个步骤

步骤的执行时间会有一些约束

在时刻$x_i$使用一种需要$2$个小时才能风干的粘贴剂材料

下一个步骤需要$2$小时后等粘贴剂干了才能在时刻$x_{i+1}$安装

这样就有约束条件$x_i - x_{i+1} \le -2$

差分约束系统

把所有的约束条件写到一起就是差分约束系统

$$ \begin{align*} \quad \begin{cases} x_1 - x_2 \le 0 \\ x_1 - x_5 \le -1 \\ x_2 - x_5 \le 1 \\ x_3 - x_1 \le 5 \\ x_4 - x_1 \le 4 \\ x_4 - x_3 \le -1 \\ x_5 - x_3 \le -3 \\ x_5 - x_4 \le -3 \end{cases} \quad \Longleftrightarrow \quad \underbrace{\begin{bmatrix} 1 & -1 & 0 & 0 & 0 \\ 1 & 0 & 0 & 0 & -1 \\ 0 & 1 & 0 & 0 & -1 \\ -1 & 0 & 1 & 0 & 0 \\ -1 & 0 & 0 & 1 & 0 \\ 0 & 0 & -1 & 1 & 0 \\ 0 & 0 & -1 & 0 & 1 \\ 0 & 0 & 0 & -1 & 1 \end{bmatrix}}_{\Av ~ \in ~ \{ \pm 1, 0 \}^{m \times n}} \underbrace{\begin{bmatrix} x_1 \\ x_2 \\ x_3 \\ x_4 \\ x_5 \end{bmatrix}}_{\xv ~ \in ~ \Rbb^n} \le \underbrace{\begin{bmatrix} 0 \\ -1 \\ 1 \\ 5 \\ 4 \\ -1 \\ -3 \\ -3 \end{bmatrix}}_{\bv ~ \in ~ \Rbb^m} \end{align*} $$

满足$\Av \xv \le \bv$$\xv$称为差分约束系统的解

差分约束系统的解不唯一,若$\xv$是解,则$\xv + c \onev$也是解

约束图

给定差分约束系统$\Av \xv \le \bv$,其约束图是带权有向图$\Gcal = (\Vcal, \Ecal)$

  • $\Vcal = \{ v_0, v_1, \ldots, v_n \}$,每个$x_i$对应一个$v_i$,此外引入一个额外的$v_0$
  • $\Ecal = \{ (v_i, v_j) \mid x_j -x_i \le b_k \} \cup \{ (v_0, v_1), (v_0,v_2), \ldots, (v_0, v_n) \}$,每个约束对应一条边,权重为$b_k$$v_0$指向其它所有点的边权重为$0$

$$ \begin{align*} \qquad \begin{cases} x_1 - x_2 \le 0 \\ x_1 - x_5 \le -1 \\ x_2 - x_5 \le 1 \\ x_3 - x_1 \le 5 \\ x_4 - x_1 \le 4 \\ x_4 - x_3 \le -1 \\ x_5 - x_3 \le -3 \\ x_5 - x_4 \le -3 \end{cases} \quad \Longrightarrow \end{align*} $$

根据约束图求解

给定差分约束系统$\Av \xv \le \bv$,其约束图是带权有向图$\Gcal = (\Vcal, \Ecal)$

$\Gcal$包含负环,系统无解,否则有解$\xv = [\delta(v_0, v_1), \ldots, \delta(v_0, v_n)]$

设负环为$v_i, v_{i+1}, \ldots, v_{j-1}, v_j$,其中$v_j = v_i$,则

$$ \begin{align*} \quad (v_i, v_{i+1}) & \Longleftrightarrow x_{i+1} - x_i \le w(v_i, v_{i+1}) \\ (v_{i+1}, v_{i+2}) & \Longleftrightarrow x_{i+2} - x_{i+1} \le w(v_{i+1}, v_{i+2}) \\ & \quad \vdots \\ (v_{j-1}, v_j) & \Longleftrightarrow x_j - x_{j-1} \le w(v_{j-1}, v_j) \end{align*} $$

注意$x_j = x_i$,累加可得$0 \le \sum_{k=i}^{j-1} w(v_k,v_{k+1}) \overset{\text{负环}}{<} 0$

根据约束图求解

给定差分约束系统$\Av \xv \le \bv$,其约束图是带权有向图$\Gcal = (\Vcal, \Ecal)$

$\Gcal$包含负环,系统无解,否则有解$\xv = [\delta(v_0, v_1), \ldots, \delta(v_0, v_n)]$

$\forall (v_i, v_j) \in \Ecal$,对应约束$x_j - x_i \le w(v_i, v_j)$,根据三角不等式

$$ \begin{align*} \quad \delta(v_0, v_j) \le \delta(v_0, v_i) + w(v_i, v_j) \Longrightarrow \delta(v_0, v_j) - \delta(v_0, v_i) \le w(v_i, v_j) \end{align*} $$

$x_j = \delta(v_0, v_j)$$x_i = \delta(v_0, v_i)$即可

综上,可对其约束图以$v_0$为源点运行 Bellman-Ford 算法

  • 若检测到负环,则原差分约束系统无解
  • 若无负环,则$v_0$到其它点的最短路径就是原差分约束系统的一个解
根据约束图求解

$$ \begin{align*} \qquad \begin{cases} x_1 - x_2 \le 0 \\ x_1 - x_5 \le -1 \\ x_2 - x_5 \le 1 \\ x_3 - x_1 \le 5 \\ x_4 - x_1 \le 4 \\ x_4 - x_3 \le -1 \\ x_5 - x_3 \le -3 \\ x_5 - x_4 \le -3 \end{cases} \quad \Longrightarrow \end{align*} $$

$\xv = [-5, -3, 0, -1, -4]$就是一个解