算法编程题-图的最短路算法
在有向图或者无向图中求解最短路径算法是很多算法编程题的原型。本文将对这类算法进行总结,涉及深度优先搜索算法、Floyd算法、Dijkstra算法等常见的算法。本文以 LeetCode 743 网络延迟时间为例来说明这个问题。
原题描述
有n个网络节点,命名为从1到n,给定一个列表times,每一项有三个元素a, b, t, 表示从节点a到节点b有条路径,传输信号的时间是t,现在从节点k发送一个信号,信号在网络中传输,求网络中所有节点都收到这个信号的最短时间,如果存在无法收到信号的节点,则最短时间返回-1。
思路算法
深度优先搜索
思路简述
很容易想到的一种办法是深度优先搜索,搜索完所有可能的路径,然后在搜索的过程中,记录每一个节点收到信号的时间,统计最短时间即可。但是搜索所有路径需要耗费的时间是巨大的,这里需要考虑剪枝。可以想到,如果在搜索到一个节点的时候,发现当前时间大于等于历史上搜索到达该节点的时间,那么这个节点就没有必要继续搜索下去了。
对于无向图,该算法同样适用,搜索过程的代码也是一样的。对于有负权的图,也应该是适用的,且搜索过程的代码也不变。
代码
func networkDelayTime(times [][]int, n int, k int) int {
// 先把图整理成邻接表的形式
graph := make([][][]int, n)
for _, time := range times {
a := time[0] - 1
b := time[1] - 1
t := time[2]
graph[a] = append(graph[a], []int{b, t})
}
costTime := make([]int, n)
for i := 0; i < n; i++ {
costTime[i] = -1 // 表征每一个节点收到信号的时间
}
costTime[k-1] = 0
// 开始深度优先搜索
var dfs func(curNode, curTime int)
dfs = func(curNode, curTime int) {
if costTime[curNode] == -1 || costTime[curNode] > curTime {
costTime[curNode] = curTime
}
for _, adj := range graph[curNode] {
if costTime[adj[0]] == -1 || costTime[adj[0]] > curTime + adj[1] {
dfs(adj[0], curTime + adj[1])
}
}
}
dfs(k-1, 0)
ans := -1
for _, time := range costTime {
if time == -1 {
return -1
}
if ans == -1 || time > ans {
ans = time
}
}
return ans
}
复杂度分析
- 时间复杂度:不好估计,应该不是 O ( m ) O(m) O(m) , 其中m为边数,因为在搜索的过程中,一个节点可能被访问多次,同样每一条边也会被访问多次。
- 空间复杂度:
O
(
m
)
O(m)
O(m), 其中m为边数,因为是用邻接表来保存的边的信息,每条边只被保存一次。这里也说明,对于图的两种保存方式,邻接矩阵和邻接表,应该根据图的稀疏程度来决定选择那一种保存方式。

Floyd算法
思路简述
Floyd算法一种多源求取最短路径的算法。一般时间复杂度为
O
(
n
3
)
O(n^3)
O(n3)。具体的过程就是通过遍历每一个节点,将每一个节点作为一个中转点i,然后遍历所有的入边点j和所有的出边点p,如果ji+ip < jp, 则可以更新点j到点p的距离。
对于无向图,本方法依然使用,只要将每一条无向边看做是两条相反方向的有向边即可。图中允许存在负权重,但不能出现一条环路上全是负权值,因为这样无法找到最短路径。
代码
func networkDelayTime(times [][]int, n int, k int) int {
// 先把图整理成连接矩阵的形式
matrix := make([][]int, n)
for i := 0; i < n; i++ {
matrix[i] = make([]int, n)
for j := 0; j < n; j++ {
matrix[i][j] = -1
}
matrix[i][i] = 0
}
for _, time := range times {
a := time[0] - 1
b := time[1] - 1
t := time[2]
matrix[a][b] = t
}
// Floyd算法过程
for i := 0; i < n; i++ { // 中转点
for j := 0; j < n; j++ { // 入度
for p := 0; p < n; p++ { // 出度
if matrix[j][i] != -1 && matrix[i][p] != -1 {
if matrix[j][p] == -1 || matrix[j][p] > matrix[j][i] + matrix[i][p] {
matrix[j][p] = matrix[j][i] + matrix[i][p]
}
}
}
}
}
ans := -1
for i := 0; i < n; i++ {
if matrix[k - 1][i] == -1 {
return -1
}
ans = max(ans, matrix[k - 1][i])
}
return ans
}
复杂度分析
- 时间复杂度: O ( n 3 ) O(n^3) O(n3)
- 空间复杂度:
O
(
n
2
)
O(n^2)
O(n2)

朴素dijkstra算法
思路简述
单源最短路径可以直接用dijkstra算法求出源点到图中每一个节点的距离。dijkstra(迪克斯特拉)算法的基本过程为:从源点开始不断寻找离源点距离最近且没有被使用过的中转点,找到之后,尝试用这个点作为目标点和源点之间的中转点,更新图中所有节点的到源点的最新最短距离。
该算法对于无向图同样适用,但是不适用带有负权值边的图。
代码
func networkDelayTime(times [][]int, n int, k int) int {
// 先把图整理成连接矩阵的形式
matrix := make([][]int, n)
for i := 0; i < n; i++ {
matrix[i] = make([]int, n)
for j := 0; j < n; j++ {
matrix[i][j] = -1
}
matrix[i][i] = 0
}
for _, time := range times {
a := time[0] - 1
b := time[1] - 1
t := time[2]
matrix[a][b] = t
}
dis := make([]int, n)
for i := 0; i < n; i++ {
dis[i] = -1
}
nativeDijkstra(matrix, dis, k - 1) // 朴素迪克斯特拉算法
ans := -1
for i := 0; i < n; i++ {
if dis[i] == -1 {
return -1
}
ans = max(ans, dis[i])
}
return ans
}
// nativeDijkstra 朴素迪克斯特拉算法
func nativeDijkstra(matrix [][]int, dis []int, s int) {
n := len(matrix)
updated := make([]bool, n) // 标识是否被更新过
dis[s] = 0
for i := 0; i < n; i++ { // 迭代n次
t := -1
for j := 0; j < n; j++ {
// 找到之前没用过的中转点
if !updated[j] && dis[j] != -1 && (t == -1 || dis[j] < dis[t]) {
t = j
}
}
if t == -1 {
break
}
// 根据找到的中转点t更新所有点到源点的距离
updated[t] = true
for j := 0; j < n; j++ {
if matrix[t][j] != -1 && (dis[j] == -1 || dis[j] > dis[t] + matrix[t][j]) {
dis[j] = dis[t] + matrix[t][j]
}
}
}
}
复杂度分析
- 时间复杂度: O ( n 2 ) O(n^2) O(n2)
- 空间复杂度:
O
(
n
2
)
O(n^2)
O(n2)

堆优化dijkstra算法
思路简述
可以注意到,在朴素的dijkstra算法中,寻找未使用过的且离源点最近的中转点时用的是遍历的方法,这里就可以采用优先级队列进行优化。再结合邻接表,在稀疏图中的性能提升相当明显。
代码
func networkDelayTimeV3(times [][]int, n int, k int) int {
// 先把图整理成邻接表的形式
graph := make([][][]int, n)
for _, time := range times {
a := time[0] - 1
b := time[1] - 1
t := time[2]
graph[a] = append(graph[a], []int{b, t})
}
dis := make([]int, n)
for i := 0; i < n; i++ {
dis[i] = -1
}
heapDijkstra(graph, dis, k - 1)
ans := -1
for i := 0; i < n; i++ {
if dis[i] == -1 {
return -1
}
ans = max(ans, dis[i])
}
return ans
}
func heapDijkstra(graph [][][]int, dis []int, s int) {
h := Heap{}
n := len(graph)
updated := make([]bool, n)
dis[s] = 0
heap.Push(&h, &Path{s, 0})
for len(h) > 0 {
p := heap.Pop(&h).(*Path)
if !updated[p.PointNO] { // 没有被用过
for _, item := range graph[p.PointNO] {
b := item[0]
t := item[1]
if dis[b] == -1 || dis[b] > dis[p.PointNO] + t {
dis[b] = dis[p.PointNO] + t
heap.Push(&h, &Path{b, dis[b]})
}
}
updated[p.PointNO] = true
}
}
}
type Path struct {
PointNO int // 经典编号
Dis int // 该点到源点的距离
}
type Heap []*Path
func (h Heap) Len() int {
return len(h)
}
func (h Heap) Less(i, j int) bool {
return h[i].Dis < h[j].Dis
}
func (h Heap) Swap(i, j int) {
h[i], h[j] = h[j], h[i]
}
func (h *Heap) Push(v interface{}) {
*h = append(*h, v.(*Path))
}
func (h *Heap) Pop() interface{} {
ret := (*h)[len(*h) - 1]
*h = (*h)[: len(*h) - 1]
return ret
}
复杂度分析
- 时间复杂度: O ( m l o g n + m a x ( m , n ) ) O(mlogn + max(m, n)) O(mlogn+max(m,n)), 在迪克斯特拉算法中,注意到每条边对应的点时间对最多进入堆一次,所以迪克斯特拉算法的时间复杂度的一部分就是 m l o g n mlogn mlogn
- 空间复杂度:
O
(
m
a
x
(
m
,
n
)
O(max(m, n)
O(max(m,n)

Bellman-Ford算法
思路简述
Bellman-Ford算法也是一种单源最短路径算法,其通过迭代n轮,在每一轮迭代中,再去遍历所有边a->b,并且尝试将a->b作为源点s->b路径上的最后一条边能不能减少b到源点的距离。这种算法的优点在于实现简单,且适用于无向图和负权边图,缺点就是计算复杂度相当高。
代码
func networkDelayTime(times [][]int, n int, k int) int {
dis := make([]int, n)
for i := 0; i < n; i++ {
dis[i] = -1
}
dis[k - 1] = 0
bf(times, dis, k - 1) // 贝尔曼-福德算法
ans := -1
for i := 0; i < n; i++ {
if dis[i] == -1 {
return -1
}
ans = max(ans, dis[i])
}
return ans
}
func bf(edges [][]int, dis []int, s int) {
n := len(edges)
for i := 0; i < n; i++ { // 迭代n轮
prev := append([]int(nil), dis...)
for _, edge := range edges { // 遍历所有的边,作为s->b路径上的最后一条边
a := edge[0] - 1
b := edge[1] - 1
c := edge[2]
if prev[a] != -1 && (dis[b] == -1 || dis[b] > prev[a] + c) {
dis[b] = prev[a] + c
}
}
}
}
复杂度分析
- 时间复杂度: O ( m ∗ n ) O(m * n) O(m∗n)
- 空间复杂度:
O
(
n
)
O(n)
O(n)

SPFA算法
思路简述
SPFA算法的全称是Shorted Path Faster Algorithmn,是贝尔曼-福德算法的队列优化版本。核心思路就是认为只有之前更新成功过的节点才能作为中转节点,所以用一个双端队列来保存更新成功过的节点,然后根据这个节点的出边来做节点距离的更新。
代码
func networkDelayTime(times [][]int, n int, k int) int {
// 先把图整理成邻接表的形式
graph := make([][][]int, n)
for _, time := range times {
a := time[0] - 1
b := time[1] - 1
t := time[2]
graph[a] = append(graph[a], []int{b, t})
}
dis := make([]int, n)
for i := 0; i < n; i++ {
dis[i] = -1
}
spfa(graph, dis, k - 1)
ans := -1
for i := 0; i < n; i++ {
if dis[i] == -1 {
return -1
}
ans = max(ans, dis[i])
}
return ans
}
func spfa(graph [][][]int, dis []int, s int) {
n := len(dis)
deque := list.New()
dis[s] = 0
deque.PushBack(s)
visited := make([]bool, n) // 记录对应点是否在队列中
visited[s] = true
for deque.Len() != 0 {
p := deque.Remove(deque.Front()).(int)
visited[s] = false
for _, edge := range graph[p] {
a := edge[0]
c := edge[1]
if dis[a] == -1 || dis[a] > dis[p] + c {
dis[a] = dis[p] + c
if visited[a] {
continue
}
deque.PushBack(a) // 更新成功的点才加入队列中作为中转点
visited[a] = false
}
}
}
}
复杂度分析
- 时间复杂度: O ( m n ) O(mn) O(mn), 这里有意思的是,SPFA算法的名称是由段凡丁提出来的,并且在论文中还断言该算法的时间复杂度为 O ( k m ) O(km) O(km),其中k为很小的常数,但是后面被人证明严格复杂度还是 O ( m ∗ n ) O(m * n) O(m∗n)。可见,有些算法的时间复杂度并不好分析。
- 空间复杂度:
O
(
m
)
O(m)
O(m)

1625

被折叠的 条评论
为什么被折叠?



