返回

图论

理论基础

二叉树和回溯算法章节中已经讲过深搜和广搜,二叉树的遍历就是深搜和广搜在二叉树结构上的应用, 而回溯算法本身就是深搜,只不过利用其回溯的过程。在图论中,深搜和广搜就是在图上的遍历,图的存储方式一般是 邻接表和邻接矩阵。

dfs 与 bfs 区别

深度优先搜索(dfs)和广度优先搜索(bfs)区别:

  • dfs是可一个方向去搜,不到黄河不回头,直到遇到绝境了,搜不下去了,再换方向(换方向的过程就涉及到了回溯)。
  • bfs是先把本节点所连接的所有节点遍历一遍,走到下一个节点的时候,再把连接节点的所有节点遍历一遍,搜索方向更像是广度,四面八方的搜索过程。

dfs模板

正是因为dfs搜索可一个方向,并需要回溯,所以用递归的方式来实现是最方便的。回溯操作就在递归函数的下面,递归和回溯是相辅相成的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
void dfs(参数) {
    if (终止条件) {
        存放结果;
        return;
    }

    for (选择:本节点所连接的其他节点) {
        处理节点;
        dfs(图,选择的节点); // 递归
        回溯,撤销处理结果
    }
}

深搜三部曲

  1. 确认递归函数,参数:在写递归函数的过程中,发现需要什么参数,再去补充;一般深搜需要 二维数组数组结构保存所有路径,需要一维数组保存单一路径
  2. 确认终止条件:终止条件不仅是结束本层递归,同时也是收获结果的时候;很多dfs写法,没有写终止条件,其实终止条件写在了下面dfs递归的逻辑里了,也就是不符合条件,直接不会向下递归
  3. 处理目前搜索节点出发的路径:一般这里就是一个for循环的操作,去遍历 目前搜索节点 所能到的所有节点

广搜的使用场景

广搜的搜索方式就适合于解决两个点之间的最短路径问题

因为广搜是从起点出发,以起始点为中心一圈一圈进行搜索,一旦遇到终点,记录之前走过的节点就是一条最短路。

当然,也有一些问题是广搜 和 深搜都可以解决的,例如岛屿问题,这类问题的特征就是不涉及具体的遍历方式,只要能把相邻且相同属性的节点标记上就行。

代码框架

很多网上的资料都是直接说BFS用队列来实现,其实仅仅需要一个容器能保存遍历过的元素就可以,用队列,还是用栈,甚至用数组,都是可以的。

用队列的话,就是保证方向统一,例如统一顺时针或者逆时针,因为队列是先进先出,所以加入元素和弹出元素的顺序是没有改变的

如果用栈的话,就是第一圈是方向1,第二圈是反方向,第三圈又回到方向1。,因为栈是先进后出,加入元素和弹出元素的顺序改变了

那么广搜需要注意搜索的顺序吗? 不需要!所以用队列,还是用栈都是可以的,但大家都习惯用队列了,所以下面也用队列来给出广搜代码模板

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func bfs(参数) {
    // 定义队列
    // 起始节点入队
    for len(queue) != 0 {
        // 取队首元素
        for 选择本节点所连接的其他节点 {
            // 处理节点
            // 新节点入队
        }
    }
}

并查集

并查集用来解决连通性问题:判断两个元素是否在同一个集合里

  • 将两个元素添加到一个集合中
  • 判断两个元素是否在一个集合

原理

将A,B两个元素放在同一集合(连通),只需要用一个一维数组来表示,即father[A] = B,father[B] = B,这样就表示AB连通了;根据A可以找到B,根据B可以找到B,AB最终都会找到B,即AB同根,表示两个元素在一个集合里

同理,将A,B,C三个元素放在同一个集合(连通),即:father[A] = B,father[B] = C,father[C] = C,这样就表示ABC连通了;根据A可以找到B,根据B可以找到C,根据C可以找到C,ABC最终都会找到C,即ABC同根,表示三个元素在一个集合里,寻根过程:

1
2
3
4
func find(u int) int {
    if father[u] == u return u  // 如果根就是自己,直接返回
    else return find(father[u]) // 如果根不是自己,就根据数组下标一层一层向下找根
}

father数组初始化的时候要 father[i] = i,默认自己指向自己

判断两个元素是否在同一个集合里,若通过find函数找到两个元素属于同一个根的话,那么这两个元素就是同一个集合,代码如下:

1
2
3
4
5
func isSame(a int, b int) bool {
    a = find(a)
    b = find(b)
    return a == b
}

路径压缩

如果让所有节点都直接挂载根下,这样在寻根的时候就只需要一步,想要达到这样的效果,就需要 路径压缩,将非根节点的所有节点直接指向根节点。

实现:在递归的过程中,让 father[u] 接住递归函数 find(father[u]) 的返回结果

因为 find 函数向上寻找根节点时,father[u] 表示 u 的父节点,那么让 father[u] 直接获取find函数返回的根节点,这样就让根节点直接作为了节点 u 的父节点

1
2
3
4
5
// 并查集里寻根的过程
func find(u int) int {
    if father[u] == u return u
    else return father[u] = find(father[u]) // 路径压缩
}

并查集模板

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
var father []int

func validPath(n int, edges [][]int, source int, destination int) bool {
    father = make([]int, n)
    initial(n)
    for i := range edges {
        join(edges[i][0], edges[i][1])
    }
    return isSame(source, destination)
}

// 并查集初始化
func initial(n int) {
    for i := 0; i < n; i++ {
        father[i] = i
    }
}

// 并查集里寻根
func find(u int) int {
    if father[u] == u {
        return u
    }
    father[u] = find(father[u])
    return father[u]
}

// 判断a和b是否找到同一个根
func isSame(a int, b int) bool {
    a = find(a)
    b = find(b)
    return a == b
}

// 边a->b加入并差集
func join(a int, b int) {
    a = find(a)
    b = find(b)
    if a == b {
        return // 同根说明已经在一个集合里
    }
    father[a] = b
}

注意:join函数里要有find函数进行寻根的过程,这样就保证元素在有向图里是强连通的;也要接收保存find寻根的返回值,用路径压缩后的父节点连接

模拟过程

1、join(1, 8)

2、join(3, 8)

3指向1不指向8是因为在join函数里分别对3和8寻根之后,3的根为3,8的根为1,再进行关联就为3指向1

3、join(1, 7)

4、join(8, 5)

这里8的根是3,那么3应该指向5,因为是在join函数里分别对a和b进行了寻根之后再进行的关联

但是这里1不指向8了,3直接指向了8,这是因为路经压缩,这减少了下次查询的路径长度

5、join(2, 9)

6、join(6, 9)

因为9的根为 2,所以用6指向2

13578是同一个集合,269是同一个集合

复杂度分析

路径压缩版并查集

  • 空间复杂度: O(n) ,申请一个father数组
  • 路径压缩后的并查集时间复杂度在O(logn)与O(1)之间,且随着查询或者合并操作的增加,时间复杂度会越来越趋于O(1)

在第一次查询的时候,相当于是n叉树上从叶子节点到根节点的查询过程,时间复杂度是logn,但路径压缩后,后面的查询操作都是O(1),而 join 函数和 isSame函数里涉及的查询操作也是一样的过程


所有可能的路径

797. 所有可能的路径 - 力扣(LeetCode)

给定一个二维数组含有n个元素,表示有向无环图,每个元素表示该元素下标节点能到达的节点(一条有向边),返回节点0到节点n-1的所有路径

  1. 确认递归函数,参数:一个图是用来遍历,一个是目前遍历的节点;单一路径和路径集合可以放在全局变量
  2. 确认终止条件:当目前遍历的节点为最后一个节点的时候,就找到了一条从出发点到终止点的路径
  3. 处理目前搜索节点出发的路径:得到当前遍历节点的指向的下一个节点,将选中的节点加入到单一路径中
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var path []int  // 节点0到节点n-1的所有路径
var res [][]int // 节点0到节点n-1的一条路径

func allPathsSourceTarget(graph [][]int) [][]int {
    path = make([]int, 0) // 力扣中全局变量需要重新初始化,否则会携带之前测试case的答案
    res = make([][]int, 0)
    path = append(path, 0) // 从节点0出发
    dfs(graph, 0)
    return res
}
func dfs(graph [][]int, cur int) {
    // 终止条件:当前遍历节点是节点n-1
    if cur == len(graph)-1 {
        temp := make([]int, len(path))
        copy(temp, path)
        res = append(res, temp) // 将找到的一条路径加入结果集
        return
    }
    for i := 0; i < len(graph[cur]); i++ { // 遍历当前节点连接的所有节点
        path = append(path, graph[cur][i]) // 将遍历到的节点加入路径
        dfs(graph, graph[cur][i])          // 进入下一层递归
        path = path[:len(path)-1]          // 回溯 在路径中撤销本节点
    }
}

岛屿数量

200. 岛屿数量 - 力扣(LeetCode)

给定一个二维数组,表示一块区域;元素由0和1组成,1代表陆地,0代表海洋;若陆地被海洋包裹,则说明是岛屿;假设该区域四条边外是海洋;被海洋包裹的多块水平或垂直相连陆地看作一个岛屿;返回该区域中岛屿的数量

本题思路:遍历一遍所有节点;每遇到一个陆地节点且没有被标记过,计数器就加一,然后把该陆地节点所能遍历到的陆地都标记上;在遇到标记过的陆地节点和海洋节点的时候直接跳过;这样计数器就是最终岛屿的数量

把陆地节点所能遍历到的陆地都标记上可以使用 DFS,BFS或者并查集

对于本题来说,DFS和BFS都是对一个节点进行四周遍历,区别是DFS用递归栈来保存遍历过的状态,BFS用队列来保存遍历过的状态

DFS(推荐下面第二种优化后的写法)

  1. 确认递归函数,参数:一个是图用来遍历,一个是目前遍历的节点的横纵坐标(坐标用来访问四周节点和设置标记节点变量);保存节点标记的变量可以放在全局变量
  2. 确认终止条件:索引越界或遇到海洋
  3. 处理目前搜索节点出发的路径:得到当前遍历节点的指向的下一个节点

注意:本题是网格,dfs时要四个方向都搜

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
var visited [][]bool

func numIslands(grid [][]byte) int {
    res := 0
    visited = make([][]bool, len(grid))
    for i := range visited {
        visited[i] = make([]bool, len(grid[i]))
    }
    for i := range grid {
        for j := range grid[i] {
            if grid[i][j] == '1' && !visited[i][j] { // 遇到陆地节点且没有被标记过
                res += 1
                dfs(grid, i, j)
            }
        }
    }
    return res
}
func dfs(grid [][]byte, x int, y int) {
    if grid[x][y] == '0' || visited[x][y] { // 遇到海洋节点或标记过的节点
        return
    }
    visited[x][y] = true  // 标记节点
    if x != len(grid)-1 { // x没到最后一层
        dfs(grid, x+1, y) // 递归 向下探索
    }
    if y != len(grid[x])-1 { // y没到最后一列
        dfs(grid, x, y+1)    // 递归 向右探索
    }
    if x != 0 {             // x不是第一层
        dfs(grid, x-1, y)   // 递归 向上探索
    }
    if y != 0 {             // y不是第一列
        dfs(grid, x, y-1)   // 递归 向左探索
    }
}

DFS优化:将遍历过的陆地直接置为0

注意:在终止条件中,先判断索引,再判断是否遇到海洋,防止越界;同理由于判断列数要用到行数,所有也要放在行数判断之后

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func numIslands(grid [][]byte) int {
    res := 0
    for i := range grid {
        for j := range grid[i] {
            if grid[i][j] == '1' { // 遇到陆地节点且没有被标记过
                res += 1
                dfs(grid, i, j)
            }
        }
    }
    return res
}
func dfs(grid [][]byte, x int, y int) {
    // 下标越界或遇到海洋节点
    if x < 0 || y < 0 || x == len(grid) || y == len(grid[x]) || grid[x][y] == '0' {
        return
    }
    grid[x][y] = '0'  // 标记节点
    dfs(grid, x+1, y) // 递归 向下探索
    dfs(grid, x, y+1) // 递归 向右探索
    dfs(grid, x-1, y) // 递归 向上探索
    dfs(grid, x, y-1) // 递归 向左探索
}

BFS

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
func numIslands(grid [][]byte) int {
    res := 0
    for i := range grid {
        for j := range grid[i] {
            if grid[i][j] == '1' { // 遇到陆地节点且没有被标记过
                res += 1
                bfs(grid, i, j)
            }
        }
    }
    return res
}
func bfs(grid [][]byte, x int, y int) {
    queue := make([][]int, 0)                           // 定义队列
    queue = append(queue, []int{x, y})                  // 起始节点入队
    move := [4][2]int{{1, 0}, {0, 1}, {0, -1}, {-1, 0}} // 下右左上
    for len(queue) != 0 {                               // 遍历队列
        node := queue[0]         // 取队首元素
        queue = queue[1:]        // 更新队列
        for i := 0; i < 4; i++ { // 遍历当前节点四周
            x = node[0] + move[i][0]
            y = node[1] + move[i][1]
            // 下标越界或遇到海洋节点
            if x < 0 || y < 0 || x == len(grid) || y == len(grid[x]) || grid[x][y] == '0' {
                continue
            }
            grid[x][y] = '0'                   // 标记节点
            queue = append(queue, []int{x, y}) // 入队
        }
    }
}

岛屿的最大面积

695. 岛屿的最大面积 - 力扣(LeetCode)

给定一个二维数组,表示一块区域;元素由0和1组成,1代表陆地,0代表海洋;若陆地被海洋包裹,则说明是岛屿;假设该区域四条边外是海洋;被海洋包裹的多块水平或垂直相连陆地看作一个岛屿;返回该区域中岛屿的最大面积

本题思路:遍历一遍所有节点;每遇到一个陆地节点且没有被标记过,说明是新岛屿,然后把该陆地节点所能遍历到的陆地都标记上;在遇到标记过的陆地节点和海洋节点的时候直接跳过;在标记时统计该岛屿的面积,遍历该岛屿结束后,与已保存的最大岛屿面积作比较取最大值,

把陆地节点所能遍历到的陆地都标记上可以使用 DFS,BFS或者并查集

对于本题来说,DFS和BFS都是对一个节点进行四周遍历,区别是DFS用递归栈来保存遍历过的状态,BFS用队列来保存遍历过的状态

DFS

  1. 递归参数:一个是图用来遍历,一个是目前遍历的节点的横纵坐标(坐标用来访问四周节点),记录每个岛屿面积的变量可设为全局
  2. 确认终止条件:索引越界或遇到海洋
  3. 处理目前搜索节点出发的路径:得到当前遍历节点的指向的下一个节点

注意:每次遍历完岛屿并记录结束后,要清空全局变量,使其在下次岛屿遍历中记录

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
var area int

func maxAreaOfIsland(grid [][]int) int {
    res := 0
    for i := range grid {
        for j := range grid[i] {
            if grid[i][j] == 1 {
                dfs(grid, i, j)
                res = max(res, area)
                area = 0 // 重置面积
            }
        }
    }
    return res
}
func dfs(grid [][]int, x int, y int) {
    if x < 0 || y < 0 || x == len(grid) || y == len(grid[x]) || grid[x][y] == 0 {
        return
    }
    grid[x][y] = 0    // 标记
    area += 1         // 统计面积
    dfs(grid, x+1, y) // 下
    dfs(grid, x, y+1) // 右
    dfs(grid, x-1, y) // 上
    dfs(grid, x, y-1) // 左
}

飞地的数量

1020. 飞地的数量 - 力扣(LeetCode)

给定一个二维数组,表示一块区域;元素由0和1组成,1代表陆地,0代表海洋;若陆地被海洋包裹,则说明是岛屿;假设该区域四条边外是海洋;被海洋包裹的多块水平或垂直相连陆地看作一个岛屿;返回该区域中所有不能到达矩阵边界的岛屿的面积总和

本题思路一:遍历一遍所有节点;每遇到一个陆地节点且没有被标记过,说明是新岛屿,然后把该陆地节点所能遍历到的陆地都标记上;在遇到标记过的陆地节点和海洋节点的时候直接跳过;在标记时统计该岛屿的面积,同时检查该节点是否到达边界,一旦该岛屿可以到达边界则标记该岛屿;遍历该岛屿结束后,若该岛屿不能到达边界,则将该岛屿面积记入结果集

思路二:本题要求找到不靠边的陆地面积,那么我们只要从周边找到陆地然后 通过 dfs或者bfs 将周边靠陆地且相邻的陆地都变成海洋,然后再去重新遍历地图的时候,统计此时还剩下的陆地就可以了。

把陆地节点所能遍历到的陆地都标记上可以使用 DFS,BFS或者并查集

对于本题来说,DFS和BFS都是对一个节点进行四周遍历,区别是DFS用递归栈来保存遍历过的状态,BFS用队列来保存遍历过的状态

思路一DFS

  1. 递归参数:一个是图用来遍历,一个是目前遍历的节点的横纵坐标(坐标用来访问四周节点),记录每个岛屿面积的变量和标记该岛屿能否到达矩阵边界的变量可设为全局
  2. 确认终止条件:索引越界或遇到海洋
  3. 处理目前搜索节点出发的路径:得到当前遍历节点的指向的下一个节点

注意:每次遍历完岛屿并记录结束后,要清空全局变量,使其在下次岛屿遍历中记录

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
var area int
var arrived bool

func numEnclaves(grid [][]int) int {
    res := 0
    for i := range grid {
        for j := range grid[i] {
            if grid[i][j] == 1 {
                dfs(grid, i, j)
                if !arrived {
                    res += area // 记录不能到达边界的面积
                }
                area = 0        // 重置
                arrived = false // 重置
            }
        }
    }
    return res
}
func dfs(grid [][]int, x int, y int) {
    if x < 0 || y < 0 || x == len(grid) || y == len(grid[x]) {
        arrived = true // 上一块陆地是边界
        return
    }
    if grid[x][y] == 0 {
        return
    }
    grid[x][y] = 0    // 标记
    area += 1         // 统计面积
    dfs(grid, x+1, y) // 下
    dfs(grid, x, y+1) // 右
    dfs(grid, x-1, y) // 上
    dfs(grid, x, y-1) // 左
}

思路二DFS

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
func numEnclaves(grid [][]int) int {
    m, n := len(grid), len(grid[0])
    for i := 0; i < m; i++ {
        if grid[i][0] == 1 { // 第一列
            dfs(grid, i, 0)
        }
        if grid[i][n-1] == 1 { // 最后一列
            dfs(grid, i, n-1)
        }
    }
    for i := 0; i < n; i++ {
        if grid[0][i] == 1 { // 第一排
            dfs(grid, 0, i)
        }
        if grid[m-1][i] == 1 { // 最后一排
            dfs(grid, m-1, i)
        }
    }
    res := 0
    for i := range grid {
        for j := range grid[i] {
            if grid[i][j] == 1 {
                res++
            }
        }
    }
    return res
}
func dfs(grid [][]int, x, y int) {
    if x < 0 || y < 0 || x == len(grid) || y == len(grid[0]) || grid[x][y] == 0 {
        return
    }
    grid[x][y] = 0
    dfs(grid, x+1, y)
    dfs(grid, x, y+1)
    dfs(grid, x-1, y)
    dfs(grid, x, y-1)
}

被围绕的区域

130. 被围绕的区域 - 力扣(LeetCode)

给定一个二维数组,表示一块区域;元素由x和o组成,o代表陆地,x代表海洋;若陆地被海洋包裹,则说明是岛屿;假设该区域四条边外是海洋;被海洋包裹的多块水平或垂直相连陆地看作一个岛屿;将该区域中所有不能到达矩阵边界的岛屿的都置为海洋

遍历地图周边一圈,将与周边相邻的’O’都改成’A',然后再遍历一遍地图,遇到的 ‘O’ 一定都是地图中间的’O',全部改成’X',遇到’A’全部改回’O'

把陆地节点所能遍历到的陆地都标记上可以使用 DFS,BFS或者并查集

对于本题来说,DFS和BFS都是对一个节点进行四周遍历,区别是DFS用递归栈来保存遍历过的状态,BFS用队列来保存遍历过的状态

  1. 递归参数:一个是图用来遍历,一个是目前遍历的节点的横纵坐标(坐标用来访问四周节点),记录标记过的陆地的变量可设为全局
  2. 确认终止条件:索引越界或遇到海洋
  3. 处理目前搜索节点出发的路径:得到当前遍历节点的指向的下一个节点

注意:

  • 标记可到边界的陆地可直接将该陆地置为其他值,后面遍历区域时再将其恢复;用此方法时注意最后遍历时要用if elseif
  • DFS中遇到标记过的变量也要返回,不然会陷入死循环,一直标记标记过的变量
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
func solve(board [][]byte) {
    for row := 0; row < len(board); row++ {
        if board[row][0] == 'O' { // 第一列
            dfs(board, row, 0)
        }
        if board[row][len(board[0])-1] == 'O' { // 最后一列
            dfs(board, row, len(board[0])-1)
        }
    }
    for col := 1; col < len(board[0])-1; col++ {
        if board[0][col] == 'O' { // 第一排去首尾剩下中间的
            dfs(board, 0, col)
        }
        if board[len(board)-1][col] == 'O' { // 最后一排去首尾剩下中间的
            dfs(board, len(board)-1, col)
        }
    }
    for i := range board {
        for j := range board[i] {
            if board[i][j] == 'A' {
                board[i][j] = 'O' // 恢复可以到达边界的陆地
            } else if board[i][j] == 'O' {
                board[i][j] = 'X' // 不能到达边界的陆地
            }
        }
    }
}
func dfs(board [][]byte, x int, y int) {
    if x < 0 || y < 0 || x == len(board) || y == len(board[x]) || board[x][y] == 'X' || board[x][y] == 'A' {
        return
    }
    board[x][y] = 'A'  // 标记该陆地
    dfs(board, x+1, y) // 下
    dfs(board, x, y+1) // 右
    dfs(board, x-1, y) // 上
    dfs(board, x, y-1) // 左
}

太平洋大西洋水流问题

417. 太平洋大西洋水流问题 - 力扣(LeetCode)

给定一个二维数组,表示一块岛屿,该岛屿左方和上方被太平洋包围,右方和下方被大西洋包围;每个元素表示每块陆地的高度,水可以从高流向相等或小于的陆地;水到岛屿边缘就可以直接入海;返回可以同时流入大西洋和太平洋的陆地坐标

反过来想,从太平洋边上的节点 逆流而上,将遍历过的节点都标记上;从大西洋的边上节点 逆流而上,将遍历过的节点也标记上; 最后遍历岛屿,两方都标记过的节点就是既可以流到太平洋也可以流到大西洋的陆地

用一个三维数组来标记节点,前两个维度标记节点横纵坐标,第三个维度长度为2标记节点能否到达太平洋好大西洋,可作为全局变量;dfs时第三维作为入参标记当前从哪个洋逆流而上

dfs遍历时,需要当前节点和下一个节点的状态比较,可以先比较再决定是否递归遍历,这就需要四个偏移量来计算向四周移动后的坐标,遍历四个偏移量来表示向四周遍历

DFS

注意:

  1. 先遍历第一列太平洋和最后一列大西洋后,再次遍历排时仍要从当前排第一个元素出发,因为副对角俩元素可以同时到达两个洋
  2. 由于是在循环中遍历四个方向所以是跳过本次循环,不是return返回
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
var visited [][][]bool
var ds = [4][2]int{{1, 0}, {0, 1}, {-1, 0}, {0, -1}} // 四周偏移量

func pacificAtlantic(heights [][]int) [][]int {
    res := make([][]int, 0)
    visited = make([][][]bool, len(heights))
    for i := range visited {
        visited[i] = make([][]bool, len(heights[i]))
    }
    for i := range visited {
        for j := range visited[i] {
            visited[i][j] = make([]bool, 2)
        }
    }
    for row := 0; row < len(heights); row++ {
        dfs(heights, row, 0, 0)                 // 标记第一列太平洋
        dfs(heights, row, len(heights[0])-1, 1) // 标记最后一列大西洋
    }
    for col := 0; col < len(heights[0]); col++ {
        dfs(heights, 0, col, 0)              // 标记第一排太平洋
        dfs(heights, len(heights)-1, col, 1) // 标记最后一排大西洋
    }
    for i := range heights {
        for j := range heights[i] {
            if visited[i][j][0] && visited[i][j][1] {
                res = append(res, []int{i, j})
            }
        }
    }
    return res
}
func dfs(heights [][]int, x int, y int, sign int) {
    visited[x][y][sign] = true
    for _, move := range ds {
        nextX := x + move[0]
        nextY := y + move[1]
        if nextX < 0 || nextY < 0 || nextX == len(heights) || nextY == len(heights[nextX]) {
            continue // 索引越界
        }
        if heights[x][y] > heights[nextX][nextY] || visited[nextX][nextY][sign] {
            continue // 高度不合适或标记过
        }
        dfs(heights, nextX, nextY, sign)
    }
}

最大人工岛

827. 最大人工岛 - 力扣(LeetCode)

给定一个二维数组,表示一块区域;元素由0和1组成,1代表陆地,0代表海洋;若陆地被海洋包裹,则说明是岛屿;假设该区域四条边外是海洋;被海洋包裹的多块水平或垂直相连陆地看作一个岛屿;现在可以改变一个海洋使其变为陆地,返回改变后的最大岛屿面积

思路:

  1. 遍历地图一次,得出各个岛屿的面积,并做编号记录,可以使用map记录,key为岛屿编号,value为岛屿面积
  2. 再遍历地图一次,遇到0就统计其四周相邻岛屿的面积(用到上一步记录好的岛屿面积),将面积加在一起后,再加一得到一个总面积,遍历所有 0 之后,就可以得出一个最大人工岛

优化:第一次遍历时可以直接修改原数组的元素值作为标记,在遍历完一个岛屿后统计其面积,并将标记值加一

注意:

  • 第二次遍历统计0四周岛屿面积时需要再用一个map记录该岛屿是否被统计过,因为有的岛屿可能占用0四周的多个陆地,若不记录,则同一岛屿可能被同一0统计多次
  • 最后返回结果需要判断是否改变过0,若没有,说明该区域全是陆地,直接返回区域中陆地数
  • for循环遍历四周时需要用新变量来表示四周坐标,不能直接修改原坐标,因为递归返回上一层时需要用到本层的坐标
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
var ds = [4][2]int{{1, 0}, {0, 1}, {-1, 0}, {0, -1}}
var mark int
var area int

func largestIsland(grid [][]int) int {
    res := 0
    mark = 2
    getSize := make(map[int]int)
    // 统计各个岛屿面积
    for i := range grid {
        for j := range grid[i] {
            if grid[i][j] == 1 {
                dfs(grid, i, j)      // 遍历该岛屿
                getSize[mark] = area // 保存岛屿面积
                area = 0             // 置零
                mark++               // 标记值更新
            }
        }
    }
    // 遍历0得到最大面积
    for i := range grid {
        for j := range grid[i] {
            if grid[i][j] == 0 {
                // 得到四周岛屿总面积
                total := 0
                accounted := make(map[int]bool)
                for _, move := range ds {
                    x := i + move[0]
                    y := j + move[1]
                    if x < 0 || y < 0 || x == len(grid) || y == len(grid[x]) || grid[x][y] == 0 {
                        continue // 索引越界或遇到海洋
                    } else if accounted[grid[x][y]] {
                        continue // 该岛屿被统计过
                    } else {
                        accounted[grid[x][y]] = true // 标记该岛屿
                        total += getSize[grid[x][y]] // 统计该岛屿面积
                    }
                }
                res = max(res, 1+total) // 更新最大面积
            }
        }
    }
    if res == 0 {
        return len(grid) * len(grid)
    }
    return res
}
func dfs(grid [][]int, x int, y int) {
    if x < 0 || y < 0 || x == len(grid) || y == len(grid[x]) || grid[x][y] != 1 {
        return // 索引越界或遇到海洋或标记过
    }
    grid[x][y] = mark // 标记陆地
    area++            // 岛屿面积更新
    for _, move := range ds {
        nextX := x + move[0]
        nextY := y + move[1]
        dfs(grid, nextX, nextY)
    }
}

单词接龙

127. 单词接龙 - 力扣(LeetCode)

给定两个单词 beginWordendWord 和一个字典 wordList ;每次从字典中选一个单词,该单词和上一个单词只有一个字母不同,返回 beginWordendWord最短转换序列 中的 单词数目 ;序列中第一个单词是 beginWord,序列中最后一个单词是 endWord;beginWord不在字典中,endWord在字典中;如果不存在这样的转换序列,返回 0

以示例1为例,从这个图中可以看出 hit 到 cog 的路线,不止一条,有三条,一条是最短的长度为5,两条长度为6

本题要解决两个问题:

  • 怎样表示图中的链接:若俩单词只有一个字符不同,则有链接
  • 起点和终点的最短路径长度:无向图求最短路,广搜最为合适;广搜只要搜到了终点,则一定是最短的路径,因为广搜就是以起点中心向四周扩散的搜索;本题如果用深搜会比较麻烦,要在到达终点的不同路径中选则一条最短路, 而广搜只要达到终点,一定是最短路

优化:字典可以转成map结构,查找更快一些

注意:

  • 无向图需要标记节点是否走过,否则会死循环;本题即标记该单词是否用过
  • 比较俩单词是否只差一个单词时,对单词中每个字母用26个字母逐个替换并检索字典中是否存在,若存在则路径长度加一,若为最终单词则返回,否则更新路径长度后入队
  • go中字符串和字符互相转换:[]rune(s)string([]rune) ,只处理英文用byte,遍历26字母时go中用的是rune
  • 遍历下一个字符前需要将上一个字符的状态复原
  • 对每个单词记录其路径长度,同时作为是否访问过的标志,由于是BFS,所以找到一个链接后,还会继续找本单词的其他链接,所以不能用一个变量来记录路径长度,应该单独记录每个单词的路径长度
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
func ladderLength(beginWord string, endWord string, wordList []string) int {
    // 记录到达各单词的路径长度
    length := make(map[string]int)
    // 初始化到达beginword的路径长度为1
    length[beginWord] = 1
    // 队列BFS
    queue := make([]string, 0)
    // 初始化beginword入队
    queue = append(queue, beginWord)
    // wordList转换为字典存储
    wordMap := make(map[string]int)
    for i, v := range wordList {
        wordMap[v] = i
    }
    // BFS
    for len(queue) != 0 {
        // 取队首
        word := queue[0]
        queue = queue[1:]
        // 逐个位置替换
        for i := 0; i < len(word); i++ {
            // 将不可变字符串转化为字符切片
            wordSlice := []rune(word)
            // 26个字母逐个试
            for j := 'a'; j <= 'z'; j++ {
                // 替换字符
                wordSlice[i] = j
                // 判断字典中是否有该单词
                if _, ok := wordMap[string(wordSlice)]; ok {
                    // 判断该单词是否未被遍历使用过
                    if _, ok := length[string(wordSlice)]; !ok {
                        // 判断是否到达最终单词
                        if string(wordSlice) == endWord {
                            return length[word] + 1
                        }
                        // 记录到达该单词的路径长度(在替换前单词路径长度基础上加一)
                        length[string(wordSlice)] = length[word] + 1
                        // 该单词入队
                        queue = append(queue, string(wordSlice))
                    }
                }
            }
        }
    }
    return 0
}
// 写法二
func ladderLength(beginWord string, endWord string, wordList []string) int {
    // BFS遍历
    queue := []string{beginWord}
    mp := map[string]int{}
    mp[beginWord] = 1
    for len(queue) != 0 {
        cur := queue[0]
        queue = queue[1:]
        // 判是否已到终点
        if cur == endWord {
            return mp[cur]
        }
        // 遍历字典中所有单词
        for _, word := range wordList {
            // 判字典中该单词是否已遍历过或与当前单词一致
            if _, ok := mp[word]; ok || cur == word {
                continue
            }
            // 判是否相差一个字符
            if isValid(cur, word) {
                mp[word] = mp[cur] + 1
                queue = append(queue, word)
            }
        }
    }
    return 0
}
func isValid(str1, str2 string) bool {
    cnt := 0
    for i := range str1 {
        if str1[i] != str2[i] {
            cnt++
        }
    }
    return cnt == 1
}

钥匙和房间

841. 钥匙和房间 - 力扣(LeetCode)

给定一个二维数组,第一维表示房间,第二维表示房间中的钥匙;0号房间一定是打开的,进入房间必须要钥匙,可以通过进入房间获得其他房间的钥匙;若能够进入所有房间则返回true,否则返回false

用一个数组记录所有房间的可否进入状态,同时也作为是否入过队的标志;BFS遍历给定二维数组,获得新钥匙就更新房间的可进入状态;最后遍历所有房间的可进入状态,若有房间无法进入则返回false,否则返回true

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func canVisitAllRooms(rooms [][]int) bool {
    accessible := make([]bool, len(rooms))
    accessible[0] = true            // 0号房间可进入
    queue := make([][]int, 0)       // 定义队列
    queue = append(queue, rooms[0]) // 0号房入队
    for len(queue) != 0 {
        room := queue[0]           // 取队首
        queue = queue[1:]          // 出队
        for _, key := range room { // 遍历本房间中钥匙
            if !accessible[key] { // 此房间未入过队
                accessible[key] = true            // 更新可进入状态
                queue = append(queue, rooms[key]) // 新房入队
            }
        }
    }
    for i := range accessible {
        if !accessible[i] {
            return false // 有房间无法进入
        }
    }
    return true
}

BFS优化:队列用一维数组保存房间下标

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func canVisitAllRooms(rooms [][]int) bool {
    accessible := make([]bool, len(rooms))
    accessible[0] = true     // 0号房间可进入
    queue := make([]int, 0)  // 定义队列
    queue = append(queue, 0) // 0号房入队
    for len(queue) != 0 {
        idx := queue[0]                    // 取队首
        queue = queue[1:]                  // 出队
        for _, key := range rooms[idx] {   // 遍历本房间中钥匙
            if !accessible[key] {          // 此房间未入过队
                accessible[key] = true     // 更新可进入状态
                queue = append(queue, key) // 新房入队
            }
        }
    }
    for i := range accessible {
        if !accessible[i] {
            return false // 有房间无法进入
        }
    }
    return true
}

岛屿的周长

463. 岛屿的周长 - 力扣(LeetCode)

给定一个二维数组,表示一块区域;元素由0和1组成,1代表陆地,0代表海洋;若陆地被海洋包裹,则说明是岛屿;假设该区域四条边外是海洋;被海洋包裹的多块水平或垂直相连陆地看作一个岛屿;该区域中恰好有一个岛屿,求该岛屿的周长

遍历区域,每遇到一个陆地,岛屿周长就加四;查看该陆地的四周,每有一个岛屿,则周长减一

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func islandPerimeter(grid [][]int) int {
    res := 0
    for i := range grid {
        for j := range grid[i] {
            if grid[i][j] == 1 { // 遇到陆地
                res += 4
                if j != len(grid[0])-1 && grid[i][j+1] == 1 {res -= 1} // 不是最后一列且右有陆地
                if i != len(grid)-1 && grid[i+1][j] == 1 {res -= 1} // 不是最后一排且下有陆地
                if i != 0 && grid[i-1][j] == 1 {res -= 1} // 不是第一排且上有陆地
                if j != 0 && grid[i][j-1] == 1 {res -= 1} // 不是第一列且左有陆地
            }
        }
    }
    return res
}

寻找图中是否存在路径

1971. 寻找图中是否存在路径 - 力扣(LeetCode)

给定整数 n表示节点总数、二维数组 edges 表示边、sourcedestination表示起点和终点,如果从 sourcedestination 存在 有效路径 ,则返回 true,否则返回 false

本题判断起点和终点是否连通,也就是判断是否在一个集合里,可以用并查集来判断

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
var father []int

func validPath(n int, edges [][]int, source int, destination int) bool {
    father = make([]int, n)
    initial(n)
    for i := range edges {
        join(edges[i][0], edges[i][1])
    }
    return isSame(source, destination)
}

// 并查集初始化
func initial(n int) {
    for i := 0; i < n; i++ {
        father[i] = i
    }
}

// 并查集里寻根
func find(u int) int {
    if father[u] == u {
        return u
    }
    father[u] = find(father[u])
    return father[u]
}

// 判断a和b是否找到同一个根
func isSame(a int, b int) bool {
    a = find(a)
    b = find(b)
    return a == b
}

// 边a->b加入并差集
func join(a int, b int) {
    a = find(a)
    b = find(b)
    if a == b {
        return // 同根说明已经在一个集合里
    }
    father[a] = b
}

冗余连接

684. 冗余连接 - 力扣(LeetCode)

给定一个二维数组表示图中的边;要求去掉一条边,使图变为树(只有一个根节点);返回该边,若有多个答案,返回二维数组中的最后一条边

思路:遍历给定边,在将边加入并查集的过程中,若发现边的俩节点同根,说明该边加入后一定会形成环,返回该边即可

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
var father []int
var n int

func findRedundantConnection(edges [][]int) []int {
    n = len(edges)
    father = make([]int, n+1)
    initial()
    for i := 0; i < n; i++ {
        if isSame(edges[i][0], edges[i][1]) {
            return edges[i]
        }
        join(edges[i][0], edges[i][1])
    }
    return []int{}
}
func initial() {
    for i := 0; i <= n; i++ {
        father[i] = i
    }
}
func isSame(a int, b int) bool {
    a = find(a)
    b = find(b)
    return a == b
}
func find(u int) int {
    if father[u] == u {
        return u
    }
    father[u] = find(father[u])
    return father[u]
}
func join(a int, b int) {
    a = find(a)
    b = find(b)
    if a == b {
        return
    }
    father[a] = b
}

冗余连接 II

685. 冗余连接 II - 力扣(LeetCode)

给定一个二维数组表示图中的有向边;要求去掉一条边(只有一个根节点),使图变为树;返回该边,若有多个答案,返回二维数组中的最后一条边

由于是有向边,所以需要考虑到边的方向,可以将删除边的情况分为两种,一种是有入度为2的节点,另一种是没有入度为2的节点

  • 若有入度为2的节点,则需要在两条边中选择一条删除,且删除二维数组中后面的那一条
  • 若无入度为2的节点,则说明仅存在一个有向环,这时和上一题一样只需要找出加入一条边,一旦该边加入即成环,返回该边即可

思路:

  1. 第一遍遍历各个边,统计每个节点的入度,节点值作为索引,度数作为值
  2. 第二遍遍历各个边,一旦发现该边指向的节点入度为2,则将该边记录;若有入度为2的节点,一定会统计出两条边
  3. 若第二遍遍历中能统计到两条边,则先看删掉第二条边(数组中靠后)后能否构成树,若可以则返回该边,否则返回第一条边
    • 初始化并查集;将各个边加入并查集;在加入前判断,若是要删除的边则不加入,若构建树的过程中会成环则说明不能成树返回false
  4. 若第二遍遍历中没有统计到两条边,说明只存在一个有向环,则找到构成环的边返回即可
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
var father []int
var n int

func findRedundantDirectedConnection(edges [][]int) []int {
    inDegree := make([]int, len(edges)+1) // 记录各节点的入度
    for i := range edges {
        inDegree[edges[i][1]] += 1
    }
    twoDegree := make([]int, 0) // 记录入度为2的节点的两条边的索引
    for i := range edges {
        if inDegree[edges[i][1]] == 2 {
            twoDegree = append(twoDegree, i) // 找到入度为2的节点的一条边
        }
    }
    if len(twoDegree) != 0 {
        if isTreeAfterRemoveEdge(edges, twoDegree[1]) {
            return edges[twoDegree[1]] // 去掉数组中靠后的该边能构成树
        }
        return edges[twoDegree[0]] // 去掉数组中靠前的该边能构成树
    }
    return getRemoveEdge(edges)
}

// isTreeAfterRemoveEdge 判断删除一条边之后能否成树
func isTreeAfterRemoveEdge(edges [][]int, deleteEdgeIdx int) bool {
    n = len(edges)
    father = make([]int, n+1)
    initial()
    for i := range edges {
        if i == deleteEdgeIdx {
            continue // 不添加要删除的边
        }
        if isSame(edges[i][0], edges[i][1]) {
            return false // 该边加入会成环说明不能成树
        }
        join(edges[i][0], edges[i][1]) // 加入并查集
    }
    return true
}

// getRemoveEdge 找到加入后会成环的边
func getRemoveEdge(edges [][]int) []int {
    n = len(edges)
    father = make([]int, n+1)
    initial()
    for i := range edges {
        if isSame(edges[i][0], edges[i][1]) {
            return edges[i] // 找到加入后会成环的边
        }
        join(edges[i][0], edges[i][1]) // 加入并查集
    }
    return []int{}
}

// 并查集
func initial() {
    for i := 0; i <= n; i++ {
        father[i] = i
    }
}
func isSame(a int, b int) bool {
    a = find(a)
    b = find(b)
    return a == b
}
func find(u int) int {
    if father[u] == u {
        return u
    }
    father[u] = find(father[u])
    return father[u]
}
func join(a int, b int) {
    a = find(a)
    b = find(b)
    if a == b {
        return
    }
    father[a] = b
}

最小生成树

卡码网:53. 寻宝

给定所有点和边的权值,求把所有点联通的最小总权值

1、prim算法

prim算法:从节点的角度,采用贪心的策略,每次寻找距离最小生成树最近的节点,找到后将其加入到最小生成树中

最小生成树是所有节点的最小连通子图, 即:以最小的成本(边的权值)将图中所有节点链接到一起

  1. 选距离生成树最近的节点
  2. 将最近的节点加入生成树
  3. 更新非生成树节点到生成树的距离(即更新minDist数组)

minDist数组:记录每一个节点距离最小生成树的最近距离

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
package main

import (
    "fmt"
    "math"
)

func main() {
    // v是节点个数,e是边个数
    var v, e int
    fmt.Scan(&v, &e)

    // grid邻接矩阵保存图,权值初始化为10001,代表不相连
    grid := make([][]int, v+1)
    for i := range grid {
        grid[i] = make([]int, v+1)
        for j := range grid[i] {
            grid[i][j] = 10001
        }
    }

    // 填充grid
    var x, y, k int
    for ; e > 0; e-- {
        fmt.Scan(&x, &y, &k)
        grid[x][y] = k
        grid[y][x] = k
    }

    // Prim算法
    // 初始化minDist为10001
    minDist := make([]int, v+1)
    for i := range minDist {
        minDist[i] = 10001
    }
    // 记录节点是否在最小生成树中
    isInTree := make([]bool, v+1)

    // 从节点1开始加入minDist
    minDist[1] = 0

    // 遍历节点1~v-1
    for i := 1; i < v; i++ {
        // 初始化最近节点为-1,最近距离为最大整型
        cur := -1
        minVal := math.MaxInt32
        // 1. 遍历minDist找到离树的最近节点
        for j := 1; j <= v; j++ {
            // 判断节点是否不在树中且小于目前记录的最近距离
            if !isInTree[j] && minDist[j] < minVal {
                // 更新最近距离和节点
                minVal = minDist[j]
                cur = j
            }
        }

        // 2. 最近节点加入树
        isInTree[cur] = true

        // 3. 遍历节点,更新非树中各节点到树的最近距离
        for j := 1; j <= v; j++ {
            // 判断是否非树中节点且离刚新入树的节点距离小于minDist记录的距离
            if !isInTree[j] && grid[cur][j] < minDist[j] {
                // 更新minDist
                minDist[j] = grid[cur][j]
                // 记录边
                // parent[j] = cur
            }
        }
    }

    // 计算最小生成树的总权重
    result := 0
    // 遍历节点2~v
    for i := 2; i <= v; i++ {
        result += minDist[i]
    }
    fmt.Println(result)
}

2、Kruskal算法

Kruskal算法:从边的角度,采用贪心的策略,每次寻找权值最小的边,找到后将其加入到最小生成树中

  • 将边按权值排序,优先选最小的边加入到生成树里
    • 若该边首尾的两个节点在一个集合,则说明连上这条边图中会出现环
    • 若该边首尾的两个节点不在同一个集合,则将该边加入到最小生成树,并把两个节点加入同一个集合

判断两个节点是否在同一个集合——并查集

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
package main

import (
    "fmt"
    "sort"
)

// 边结构体,包含边的两个端点和边的权值
type Edge struct {
    From int
    To   int
    Cost int
}

// 并查集结构体
type UnionFind struct {
    parent []int
}

// 初始化并查集
func NewUnionFind(size int) *UnionFind {
    parent := make([]int, size)
    for i := range parent {
        parent[i] = i
    }
    return &UnionFind{parent}
}

// 查找根节点
func (uf *UnionFind) Find(x int) int {
    if uf.parent[x] != x {
        uf.parent[x] = uf.Find(uf.parent[x]) // 路径压缩
    }
    return uf.parent[x]
}

// 加入集合
func (uf *UnionFind) Union(x, y int) {
    rootX := uf.Find(x)
    rootY := uf.Find(y)
    if rootX != rootY {
        uf.parent[rootY] = rootX
    }
}

func main() {
    // v是节点数,e是边数
    var v, e int
    fmt.Scan(&v, &e)

    // 获取各个边
    edges := make([]Edge, e)
    for i := 0; i < e; i++ {
        var v1, v2, val int
        fmt.Scan(&v1, &v2, &val)
        edges[i] = Edge{v1, v2, val}
    }

    // 按边的权值从小到大排序
    sort.Slice(edges, func(i, j int) bool {
        return edges[i].Cost < edges[j].Cost
    })

    // 初始化并查集
    uf := NewUnionFind(v + 1)

    result := 0
    // 遍历边
    for _, edge := range edges {
        // 判断两个端点是否不在同一个集合中
        if uf.Find(edge.From) != uf.Find(edge.To) {
            // 计入结果集
            result += edge.Cost
            // 加入生成树中(入集)
            uf.Union(edge.From, edge.To)
            // 保存最小生成树的边
            // res_edge = append(res_edge, edge) 
        }
    }

    fmt.Println(result)
}

总结:

在稀疏图中,用Kruskal更优; 在稠密图中,用prim算法更优。

边数量较少为稀疏图,接近或等于完全图(所有节点皆相连)为稠密图

  • Prim算法 时间复杂度为 $O(n^2)$,其中n为节点数量,它的运行效率和图中边无关,适用稠密图;
  • Kruskal算法 时间复杂度 为 $O(nlogn)$,其中n为边的数量,适用稀疏图

拓扑排序

卡码网:117. 软件构建

给定所有点和边,边表示依赖关系,判断能否按照依赖关系访问所有点

拓扑排序:给出一个有向图,把这个有向图转成线性的排序

由于拓扑排序会检测有向图是否有环,有环是不能做线性排序的,所以拓扑排序也是图论中判断有向无环图的常用方法

拓扑排序的过程,其实就两步:

  1. 找到入度为0 的节点,加入结果集
  2. 将该节点从图中移除

循环以上两步,直 所有节点都在图中被移除了

结果集的顺序,就是我们想要的拓扑排序顺序 (结果集里顺序可能不唯一)

若结果集元素个数不等于图中节点个数,则说明图中一定有环,因为找不到入度为0的节点入集

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
package main

import (
    "fmt"
)

func main() {
    // n个点,m个依赖关系,t依赖s
    var n, m, s, t int
    fmt.Scan(&n, &m)

    // 记录每个文件的入度
    inDegree := make([]int, n)
    // 记录文件依赖关系
    umap := make(map[int][]int)
    result := make([]int, 0)

    for i := 0; i < m; i++ {
        fmt.Scan(&s, &t)
        // t的入度加一
        inDegree[t]++
        // 记录s->t
        umap[s] = append(umap[s], t)
    }

    queue := make([]int, 0)
    // 遍历节点入度
    for i := 0; i < n; i++ {
        if inDegree[i] == 0 {
            // 入度为0的节点作为开头加入队列
            queue = append(queue, i)
        }
    }

    for len(queue) > 0 {
        // 从队列中取出当前入度为0的点
        cur := queue[0]
        queue = queue[1:]
        result = append(result, cur)
        // 获取cur指向的点
        files := umap[cur]
        // 遍历cur指向的点
        for _, f := range files {
            // cur的指向的点入度-1
            inDegree[f]--
            // 判断该点入度是否为0
            if inDegree[f] == 0 {
                // 入度为0入队
                queue = append(queue, f)
            }
        }
    }
    // 判断结果集元素个数是否等于总节点数
    if len(result) == n {
        // 顺序打印拓扑排序序列
        for i := 0; i < n-1; i++ {
            fmt.Print(result[i], " ")
        }
        fmt.Println(result[n-1])
    } else {
        // 不是有向无环图,无法排序
        fmt.Println(-1)
    }
}

Dijkstra

卡码网:47. 参加科学大会

给出一个有向图,一个起点,一个终点,问起点到终点的最短路径

  1. 选离源点最近且未被访问过的节点
  2. 标记该最近节点被访问
  3. 更新非访问节点到源点的距离(即更新minDist数组)

minDist数组:记录每个节点到源点的最短距离

朴素版

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
package main

import (
    "fmt"
    "math"
)

func main() {
    // n个节点m条边,p1->p2权值val
    var n, m, p1, p2, val int
    fmt.Scan(&n, &m)

    // 初始化邻接矩阵,用于存储边的权重
    grid := make([][]int, n+1)
    for i := range grid {
        grid[i] = make([]int, n+1)
        for j := range grid[i] {
            // 使用math.MaxInt32表示两点间无边
            grid[i][j] = math.MaxInt32 
        }
    }

    // 读入边信息并构建邻接矩阵
    for i := 0; i < m; i++ {
        fmt.Scan(&p1, &p2, &val)
        grid[p1][p2] = val
    }

    // 起点为节点1终点为节点n
    start := 1
    end := n

    // 存储从源点到每个节点的最短距离
    minDist := make([]int, n+1)
    for i := range minDist {
        // 初始化为无穷大
        minDist[i] = math.MaxInt32 
    }
    // 起始点到自身的距离为0
    minDist[start] = 0 

    // 记录点是否被访问过
    visited := make([]bool, n+1)

    // 遍历节点1-n
    for i := 1; i <= n; i++ {
        
        minVal := math.MaxInt32
        cur := 1

        // 遍历minDist选取距离源点最近且未访问过的节点
        for v := 1; v <= n; v++ {
            if !visited[v] && minDist[v] < minVal {
                minVal = minDist[v]
                cur = v
            }
        }

        // 标记该节点已被访问
        visited[cur] = true 

        // 更新非访问节点到源点的距离(即更新minDist数组)
        for v := 1; v <= n; v++ {
            if !visited[v] && grid[cur][v] != math.MaxInt32 && minDist[cur]+grid[cur][v] < minDist[v] {
                minDist[v] = minDist[cur] + grid[cur][v]
                // 记录边
                // parent[v] = cur; 
            }
        }
    }

    if minDist[end] == math.MaxInt32 {
        // 不能到达终点
        fmt.Println(-1) 
    } else {
        // 到达终点最短路径
        fmt.Println(minDist[end]) 
    }
}
  • 时间复杂度:$O(n^2)$
  • 空间复杂度:$O(n^2)$

堆优化版:邻接表(数组+链表)存储边,适用于稀疏图

在处理三部曲里的第一步(选离源点最近且未被访问过的节点)的时候 ,可以不用去遍历所有节点,直接把边(带权值)加入到小顶堆(利用堆来自动排序),每次从堆顶里取出边自然就是距离源点最近的节点所在的边,这样就不需要两层for循环来寻找最近的节点了

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
package main

import (
    "container/heap"
    "fmt"
    "math"
)

// 定义边的结构体
type Edge struct {
    to  int
    val int
}

func main() {
    var n, m, p1, p2, val int
    fmt.Scan(&n, &m)

    // 初始化邻接表
    grid := make([][]Edge, n+1)

    // 读入边信息并构建邻接表
    for i := 0; i < m; i++ {
        fmt.Scan(&p1, &p2, &val)
        grid[p1] = append(grid[p1], Edge{p2, val})
    }

    start := 1
    end := n

    // 存储从源点到每个节点的最短距离
    minDist := make([]int, n+1)
    for i := range minDist {
        minDist[i] = math.MaxInt32 // 初始化为无穷大
    }
    minDist[start] = 0 // 起始点到自身的距离为0

    // 使用优先队列实现最小堆
    pq := make(PriorityQueue, 0)
    heap.Init(&pq)
    heap.Push(&pq, &Item{value: start, priority: 0})

    // 记录顶点是否被访问过
    visited := make([]bool, n+1)

    for pq.Len() > 0 {
        // 从优先队列中取出当前距离起点最近的节点
        item := heap.Pop(&pq).(*Item)
        u := item.value

        if visited[u] {
            continue
        }
        visited[u] = true

        // 更新与当前节点相邻的节点的最短距离
        for _, edge := range grid[u] {
            v := edge.to
            w := edge.val

            if !visited[v] && minDist[u]+w < minDist[v] {
                minDist[v] = minDist[u] + w
                heap.Push(&pq, &Item{value: v, priority: minDist[v]})
            }
        }
    }

    // 输出结果
    if minDist[end] == math.MaxInt32 {
        fmt.Println(-1) // 不能到达终点
    } else {
        fmt.Println(minDist[end]) // 到达终点最短路径
    }
}

// 优先队列实现(最小堆)
type Item struct {
    value    int // 节点编号
    priority int // 源点到该节点的距离
}

type PriorityQueue []*Item

func (pq PriorityQueue) Len() int { return len(pq) }

func (pq PriorityQueue) Less(i, j int) bool {
    return pq[i].priority < pq[j].priority
}

func (pq PriorityQueue) Swap(i, j int) {
    pq[i], pq[j] = pq[j], pq[i]
}

func (pq *PriorityQueue) Push(x interface{}) {
    item := x.(*Item)
    *pq = append(*pq, item)
}

func (pq *PriorityQueue) Pop() interface{} {
    old := *pq
    n := len(old)
    item := old[n-1]
    *pq = old[0 : n-1]
    return item
}
  • 时间复杂度:$O(ElogE)$, $E$ 为边的数量
  • 空间复杂度:$O(N + E)$, $N$ 为节点的数量

Bellman_ford

卡码网:94. 城市间货物运输 I

给出一个有向图,一个起点,一个终点,问起点到终点的最短路径,边的权值有负数,但不存在任何负权回路。

负权回路是指一系列道路的总权值为负,这样的回路使得通过反复经过回路中的道路,理论上可以无限地减少总成本或无限地增加总收益。

minDist数组:表示起点到各个节点的最短距离,例如minDist[3] = 5表示起点到达节点3的最小距离为5

Bellman_ford:对所有边松弛n-1次,得出起点到终点的最短路径

  • 松弛:动态规划,假设minDist[B] 表示到达B节点的最小权值,minDist[B] = min(minDist[A] + value, minDist[B])
    1. 状态一: minDist[A] + value 可以推出 minDist[B]
    2. 状态二: minDist[B]本身就有权值 (可能是C->B的权值)
  • 对所有边松弛一次,相当于计算起点到与起点一条边相连的节点的最短距离;对所有边松弛n-1次,就一定能得到起点到终点的最短距离
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
package main

import (
    "fmt"
    "math"
)

func main() {
    var n, m, p1, p2, val int
    fmt.Scan(&n, &m)

    // 创建邻接表
    grid := make([][]int, 0)

    // 读入边信息并构建邻接表
    for i := 0; i < m; i++ {
        fmt.Scan(&p1, &p2, &val)
        grid = append(grid, []int{p1, p2, val})
    }

    start := 1
    end := n

    // 存储从源点到每个节点的最短距离
    minDist := make([]int, n+1)
    for i := range minDist {
        // 初始化为无穷大
        minDist[i] = math.MaxInt32 
    }
    // 起始点到自身的距离为0
    minDist[start] = 0 

    // 进行 n-1 次松弛操作
    for i := 1; i < n; i++ {
        for _, side := range grid {
            from := side[0]
            to := side[1]
            price := side[2]

            if minDist[from] != math.MaxInt32 && minDist[to] > minDist[from]+price {
                minDist[to] = minDist[from] + price
            }
        }
    }

    // 输出结果
    if minDist[end] == math.MaxInt32 {
        // 不能到达终点
        fmt.Println("unconnected") 
    } else {
        // 到达终点最短路径
        fmt.Println(minDist[end]) 
    }
}

  • 时间复杂度: $O(N*E)$ ,$N$为节点数量,$E$为图中边的数量
  • 空间复杂度: $O(N)$ ,即minDist数组所开辟的空间

Bellman_ford 队列优化算法

卡码网:94. 城市间货物运输 I

给出一个有向图,一个起点,一个终点,问起点到终点的最短路径,边的权值有负数,但不存在任何负权回路。

负权回路是指一系列道路的总权值为负,这样的回路使得通过反复经过回路中的道路,理论上可以无限地减少总成本或无限地增加总收益。

Bellman_ford队列优化算法 ,也叫SPFA算法(Shortest Path Faster Algorithm)。

SPFA的称呼来自 1994年西南交通大学段凡丁的论文,其实Bellman_ford提出后不久 (20世纪50年代末期) 就有队列优化的版本,国际上不承认这个算法是是国内提出的。 所以国际上一般称呼该算法为Bellman_ford队列优化算法(Queue improved Bellman-Ford

Bellman_ford算法每次都是对所有边进行松弛,其实是多做了一些无用功。只需要对上一次松弛的时候更新过的节点作为出发节点所连接的边进行松弛就够了。基于以上思路,用队列来记录上次松弛的时候更新过的节点;在加入队列的过程可以有一个优化:用visited数组记录已经在队列里的元素,已经在队列的元素不用重复加入

依然使用minDist数组表示起点到各个节点的最短距离,例如minDist[3] = 5表示起点到节点3的最小距离为5

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
package main

import (
    "container/list"
    "fmt"
    "math"
)

// Edge 结构体表示图中的一条边
type Edge struct {
    to  int // 目标节点
    val int // 边的权重
}

func main() {
    var n, m, p1, p2, val int
    fmt.Scan(&n, &m)

    // 创建邻接表
    grid := make([]*list.List, n+1)
    for i := range grid {
        grid[i] = list.New()
    }

    // 读取边信息并构建邻接表
    for i := 0; i < m; i++ {
        fmt.Scan(&p1, &p2, &val)
        grid[p1].PushBack(Edge{to: p2, val: val})
    }

    start := 1
    end := n

    // 存储从源点到每个节点的最短距离
    minDist := make([]int, n+1)
    for i := range minDist {
        // 初始化为无穷大
        minDist[i] = math.MaxInt32 
    }
    minDist[start] = 0


    que := list.New()
    que.PushBack(start)

    // 记录已经在队列里的元素
    isInQueue := make([]bool, n+1)
    isInQueue[start] = true
    // 遍历队列
    for que.Len() > 0 {
        front := que.Front()
        node := front.Value.(int)
        que.Remove(front)
        // 从队列里取出的时候,要取消标记,只保证已经在队列里的元素不用重复加入
        isInQueue[node] = false

        for e := grid[node].Front(); e != nil; e = e.Next() {
            edge := e.Value.(Edge)
            to := edge.to
            value := edge.val
            if minDist[to] > minDist[node]+value {
                // 开始松弛
                minDist[to] = minDist[node] + value
                // 已经在队列里的元素不用重复添加
                if !isInQueue[to] {
                    que.PushBack(to)
                    isInQueue[to] = true
                }
            }
        }
    }

    // 输出结果
    if minDist[end] == math.MaxInt32 {
        // 不能到达终点
        fmt.Println("unconnected") 
    } else {
        // 到达终点的最短路径
        fmt.Println(minDist[end]) 
    }
}
  • 时间复杂度:最坏的情况下是 $O(N * E)$,一般情况下为$O(K * N)$

SPFA(队列优化版Bellman_ford)在理论上时间复杂度更胜一筹,但实际上,也要看图的稠密程度,如果图很大且非常稠密的情况下,虽然SPFA的时间复杂度接近Bellman_ford,但SPFA实际时间消耗可能更多

bellman_ford 判断负权回路

卡码网:95. 城市间货物运输 II

给出一个有向图,一个起点,一个终点,问起点到终点的最短路径,边的权值有负数,图中可能存在负权回路。

负权回路是指一系列道路的总权值为负,这样的回路使得通过反复经过回路中的道路,理论上可以无限地减少总成本或无限地增加总收益,算法需要能检测出这种特殊情况

在有负权回路的情况下,一直都会有更短的最短路,所以松弛第n次后,minDist数组也会发生改变。所有判断图中是否有负权回路的核心思路就是在松弛n-1次的基础上,再多松弛一次,看minDist数组是否发生变化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package main

import (
    "fmt"
    "math"
)

func main() {
    var n, m, p1, p2, val int
    fmt.Scan(&n, &m)

    var grid [][3]int

    for i := 0; i < m; i++ {
        fmt.Scan(&p1, &p2, &val)
        // p1 指向 p2,权值为 val
        grid = append(grid, [3]int{p1, p2, val})
    }

    // 起点
    start := 1 
    // 终点
    end := n    

    minDist := make([]int, n+1)
    for i := range minDist {
        // 初始化为无穷大
        minDist[i] = math.MaxInt32 
    }
    minDist[start] = 0

    flag := false
    // 松弛 n 次,最后一次判断负权回路
    for i := 1; i <= n; i++ { 
        for _, side := range grid {
            from := side[0]
            to := side[1]
            price := side[2]
            if i < n {
                if minDist[from] != math.MaxInt32 && minDist[to] > minDist[from]+price {
                    minDist[to] = minDist[from] + price
                }
            } else { 
                // 多加一次松弛判断负权回路
                if minDist[from] != math.MaxInt32 && minDist[to] > minDist[from]+price {
                    flag = true
                }
            }
        }
    }

    if flag {
        fmt.Println("circle")
    } else if minDist[end] == math.MaxInt32 {
        fmt.Println("unconnected")
    } else {
        fmt.Println(minDist[end])
    }
}
  • 时间复杂度: $O(N * E)$ , $N$为节点数量,$E$为图中边的数量
  • 空间复杂度: $O(N)$,即minDist数组所开辟的空间

bellman_ford 单源有限最短路

卡码网:96. 城市间货物运输 III

给出一个有向图,一个起点,一个终点,一个整数k,问在最多经过 k 个点的条件下,起点到终点的最短路径,边的权值有负数,图中可能存在负权回路。

负权回路是指一系列道路的总权值为负,这样的回路使得通过反复经过回路中的道路,理论上可以无限地减少总成本或无限地增加总收益,算法需要能检测出这种特殊情况

本题是单源有限最短路问题,最多经过k个点, 那么就是k + 1条边相连的节点

对所有边松弛一次,相当于计算起点到与起点一条边相连的节点的最短距离,那么对所有边松弛k + 1次,就得到从起点到与起点k + 1条边相连的节点的最短距离

每次计算 minDist 时候,要基于对所有边上一次松弛的minDist数值才行,所以要记录上一次松弛的minDist

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
package main

import (
    "bufio"
    "fmt"
    "os"
    "strconv"
)

func main() {
    scanner := bufio.NewScanner(os.Stdin)
    scanner.Split(bufio.ScanWords)

    n, m := readTwoInts(scanner)
    grid := make([][]int, m)

    for i := 0; i < m; i++ {
        p1, p2, val := readThreeInts(scanner)
        grid[i] = []int{p1, p2, val}
    }

    src, dst, k := readThreeInts(scanner)

    minDist := make([]int, n+1)
    // 用来记录上一次遍历的结果
    minDistCopy := make([]int, n+1)

    for i := range minDist {
        minDist[i] = int(^uint(0) >> 1) // Equivalent of INT_MAX in Go
        // 获取上一次计算的结果
        minDistCopy[i] = minDist[i]
    }
    minDist[src] = 0

    for i := 1; i <= k+1; i++ {
        copy(minDistCopy, minDist)
        for _, side := range grid {
            from, to, price := side[0], side[1], side[2]
            // 注意使用 minDistCopy 来计算 minDist 
            if minDistCopy[from] != int(^uint(0)>>1) && minDist[to] > minDistCopy[from]+price {
                minDist[to] = minDistCopy[from] + price
            }
        }
    }

    if minDist[dst] == int(^uint(0)>>1) {
        // 不能到达终点
        fmt.Println("unreachable")
    } else {
        // 到达终点最短路径
        fmt.Println(minDist[dst])
    }
}

func readTwoInts(scanner *bufio.Scanner) (int, int) {
    scanner.Scan()
    text := scanner.Text()
    a, _ := strconv.Atoi(text)
    scanner.Scan()
    text = scanner.Text()
    b, _ := strconv.Atoi(text)
    return a, b
}

func readThreeInts(scanner *bufio.Scanner) (int, int, int) {
    a, b := readTwoInts(scanner)
    scanner.Scan()
    text := scanner.Text()
    c, _ := strconv.Atoi(text)
    return a, b, c
}
  • 时间复杂度: $O(K * E)$ ,$K$为至多经过$K$个节点,$E$为图中边的数量
  • 空间复杂度: $O(N)$ ,即minDist数组所开辟的空间

对比本题与前面讲解过的 94.城市间货物运输I 和 95.城市间货物运输II

94.城市间货物运输I,没有负权回路的,那么多松弛多少次,对结果都没有影响

  • 求节点1到节点n的最短路径,松弛n-1次就够了,松弛大于n-1次,结果也不会变
  • 在对所有边进行第一次松弛的时候,如果基于本次计算的minDist来计算minDist(相当于多做松弛了),也是对最终结果没影响

95.城市间货物运输II,判断是否有负权回路,一旦有负权回路, 对所有边松弛n-1次以后,再做松弛,minDist数值一定会变,根据这一点来判断是否有负权回路

  • 只需要判断minDist数值是否变化了就行,而minDist的数值对不对,并不关心

本题计算minDist一定要基于上次的minDist数值,其关键在于本题的两个因素:

  • 本题可以有负权回路,说明只要多做松弛,结果是会变的
  • 本题要求最多经过k个节点,对松弛次数是有限制的

Floyd

卡码网:97. 小明逛公园

多源最短路问题,求多个起点到多个终点的多条最短路径

Floyd 算法对边的权值正负没有要求,核心思想是动态规划

  1. grid[i][j][k] = m:节点i到节点j,以[1...k]集合为中间节点的最短距离为m
  2. 递推公式:分两种情况,节点i到节点j的最短路径经过节点k、节点i到节点j的最短路径不经过节点k
    • grid[i][j][k] = min(grid[i][k][k-1]+grid[k][j][k-1], grid[i][j][k-1])
  3. 初始化:grid[i][j][0] = valgrid[i][j][k] = max (k ≠ 0)
  4. 遍历顺序:三维中i j为底平面,k为纵轴,自底向上遍历

空间优化:

  1. 从滚动数组的角度来看,定义一个grid[i][j][2]这么大的数组就可以,因为k只依赖于k-1的状态,那么只记录grid[i][j][1]grid[i][j][0]就好,之后就是grid[i][j][1]grid[i][j][0]交替滚动

  2. 如果本层刚计算好的grid[i][k]比上一层 (即k-1层)计算的grid[i][k]小,说明确实有ik的更短路径,那么基于更小的grid[i][k]去计算gird[i][j]没有问题。如果 本层刚计算好的grid[i][k]比上一层 (即k-1层)计算的grid[i][k]大, 这不可能,因为这样也不会做更新grid[i][k]的操作

    所以本层计算中就没必要区分,grid[i][k]grid[k][j]是属于k - 1层的还是k层的,故递归公式可以为:

    • grid[i][j] = min(grid[i][j], grid[i][k] + grid[k][j])
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package main

import (
    "fmt"
)

func main() {

    var n, m int
    fmt.Scanf("%d %d", &n, &m)

    grid := make([][]int, n+1)
    for i := range grid {
        grid[i] = make([]int, n+1)
        for j := range grid[i] {
            // 最大距离
            grid[i][j] = 10005 
        }
    }

    for i := 0; i < m; i++ {
        var p1, p2, val int
        fmt.Scanf("%d %d %d", &p1, &p2, &val)
        // 双向图
        grid[p1][p2] = val
        grid[p2][p1] = val 
    }

    // 开始Floyd
    for k := 1; k <= n; k++ {
        for i := 1; i <= n; i++ {
            for j := 1; j <= n; j++ {
                grid[i][j] = min(grid[i][j], grid[i][k]+grid[k][j])
            }
        }
    }

    // 输出结果
    var z int
    fmt.Scanf("%d", &z)
    for z > 0 {
        z--
        var start, end int
        fmt.Scanf("%d %d", &start, &end)
        if grid[start][end] == 10005 {
            fmt.Println(-1)
        } else {
            fmt.Println(grid[start][end])
        }
    }
}

func min(a, b int) int {
    if a < b {
        return a
    }
    return b
}
  • 时间复杂度:$O(n^3)$
  • 空间复杂度:$O(n^2)$

A star

卡码网:126. 骑士的攻击

Astar是一种广搜的改良版,有的是Astar是 dijkstra 的改良版;其实只是场景不同,在搜索最短路的时候,如果是无权图(边的权值都是1) 就用广搜,代码简洁,时间效率和 dijkstra 差不多 (具体要取决于图的稠密),如果是有权图(边有不同的权值),优先考虑 dijkstra;而 Astar 关键在于启发式函数, 也就是影响广搜或者 dijkstra 从容器(队列)里取元素的优先顺序;下面介绍BFS版本的A star

对队列中的节点按权值F进行排序:F = G + H

  • G:起点达目前遍历节点的距离
  • H:目前遍历的节点到终点的距离

起点到终点的距离 = 起点到目前遍历节点的距离 + 目前遍历节点到终点的距离

本题的图是无权网格,在计算两点距离时通常有三种计算方式

  1. 曼哈顿距离:$d = \abs{(x_1-x_2)+\abs{y_1-y_2}}$
  2. 欧氏距离(欧拉距离):$d = \sqrt{(x_1-x_2)^2 + (y_1-y_2)^2 )}$
  3. 切比雪夫距离:$d = max(\abs{x_1 - x_2}, \abs{y_1 - y_2})$

选择不同的距离计算方式也会导致 Astar算法的结果不同,本题需要采用欧拉距离才能最大程度体现点与点之间的距离

可以使用优先级队列排序,每次出队的就是F最小的节点

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
package main

import (
    "container/heap"
    "fmt"
)

const (
    maxN = 1001
)

var moves [maxN][maxN]int
var dir = [8][2]int{
    {-2, -1}, {-2, 1}, {-1, 2}, {1, 2},
    {2, 1}, {2, -1}, {1, -2}, {-1, -2},
}

type Knight struct {
    x, y, g, h, f int
}

type KnightHeap []Knight

func (h KnightHeap) Len() int           { return len(h) }
func (h KnightHeap) Less(i, j int) bool { return h[i].f < h[j].f }
func (h KnightHeap) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }
func (h *KnightHeap) Push(x interface{}) {
    *h = append(*h, x.(Knight))
}
func (h *KnightHeap) Pop() interface{} {
    old := *h
    n := len(old)
    x := old[n-1]
    *h = old[0 : n-1]
    return x
}
// 欧拉距离
func heuristic(k Knight, b1, b2 int) int {
    // 统一不开根号,这样可以提高精度
    return (k.x-b1)*(k.x-b1) + (k.y-b2)*(k.y-b2)
}

func astar(start Knight, b1, b2 int) int {
    h := &KnightHeap{}
    heap.Init(h)
    heap.Push(h, start)

    for h.Len() > 0 {
        cur := heap.Pop(h).(Knight)
        if cur.x == b1 && cur.y == b2 {
            return moves[b1][b2]
        }
        for _, d := range dir {
            nextX := cur.x + d[0]
            nextY := cur.y + d[1]
            if nextX < 1 || nextX >= maxN || nextY < 1 || nextY >= maxN {
                continue
            }
            if moves[nextX][nextY] == 0 {
                moves[nextX][nextY] = moves[cur.x][cur.y] + 1
                // 开始计算F
                next := Knight{
                    x: nextX,
                    y: nextY,
                    g: cur.g + 5,
                    h: heuristic(Knight{x: nextX, y: nextY}, b1, b2),
                }
                next.f = next.g + next.h
                heap.Push(h, next)
            }
        }
    }
    // unreachable case
    return -1 
}

func main() {
    var n, a1, a2, b1, b2 int
    fmt.Scan(&n)
    for n > 0 {
        fmt.Scan(&a1, &a2, &b1, &b2)
        for i := range moves {
            for j := range moves[i] {
                moves[i][j] = 0
            }
        }

        if a1 < 1 || a1 >= maxN || a2 < 1 || a2 >= maxN || b1 < 1 || b1 >= maxN || b2 < 1 || b2 >= maxN {
            fmt.Println(-1)
            n--
            continue
        }

        start := Knight{
            x: a1,
            y: a2,
            g: 0,
            h: heuristic(Knight{x: a1, y: a2}, b1, b2),
        }
        start.f = start.g + start.h

        result := astar(start, b1, b2)
        fmt.Println(result)
        n--
    }
}
  • 时间复杂度:$O(nlogn)$ ,n为节点数量。
  • 空间复杂度:$O(b ^ d)$ ,d 为起点到终点的深度,b 是图中节点间的连接数量

最短路算法总结

四大最短路算法:Dijkstra、Bellman_ford、SPFA 和 Floyd

img

(因为A * 属于启发式搜索,和上面最短路算法并不是一类,不适合一起对比,所以没有放在一起)

  1. 如果遇到单源且边为正数,直接Dijkstra,至于使用朴素版还是堆优化版还是取决于图的稠密度,一般情况下,可以直接用堆优化版本
  2. 如果遇到单源边可为负数,直接 Bellman-Ford,同样 SPFA 还是 Bellman-Ford 取决于图的稠密度,一般情况下,直接用 SPFA
  3. 如果有负权回路,优先 Bellman-Ford, 如果是有限节点最短路也优先 Bellman-Ford,理由是写代码比较方便
  4. 如果是遇到多源点求最短路,直接 Floyd,除非源点特别少,且边都是正数,那可以多次 Dijkstra 求出最短路径,但这种情况很少,一般出现多个源点了,就用 Floyd
  5. A star由于其高效性,在实际工程应用中使用最为广泛,游戏开发、地图导航、数据包路由等都广泛使用 A * 算法 ,但由于其结果的不唯一性,也就是可能是次短路的特性,一般不适合作为算法题,

腐烂的橘子

994. 腐烂的橘子 - 力扣(LeetCode)

在给定的 m x n 网格 grid 中,每个单元格可以有以下三个值之一:值 0 代表空单元格;值 1 代表新鲜橘子;值 2 代表腐烂的橘子。每分钟,腐烂的橘子 周围 4 个方向上相邻 的新鲜橘子都会腐烂。返回 直到单元格中没有新鲜橘子为止所必须经过的最小分钟数。如果不可能,返回 -1

思路:多源BFS

  1. 先遍历一边网格,统计出新鲜橘子个数,同时将烂橘子坐标入队;
  2. 遍历队列,每遍历一次当前队列中所有烂橘子分钟数+1,遍历时向四周探索一次若发现新鲜橘子,则传染该新鲜橘子,新鲜橘子计数-1,感染后的烂橘子坐标入队;
  3. 重复该过程直到队列为空,说明没有新感染的烂橘子;
  4. 此时判断是否还存在新鲜橘子,若存在说明烂橘子无法感染所有新鲜橘子,返回-1;否则说明所有橘子都已为烂橘子,此时返回经过的分钟数

注意:

  1. 若初始化分钟数为0,则返回经过的分钟数时需要-1,因为最后一次队中的烂橘子没有感染其他新鲜橘子,但是遍历最后一次队中的烂橘子时分钟数+1了,所以网格中没有新鲜橘子经过的最小分钟数应该-1
  2. 由于返回经过的分钟数-1了,所以有可能一开始网格中就没有橘子,此时分钟数为0,会返回-1,但返回-1代表网格中有新鲜橘子永远不会被感染,正确应该返回0,说明经过0分钟网格中就没有新鲜橘子了;所以在最后判断没有新鲜橘子后,需要返回经过的分钟数时需要与0比较,返回大值,保证返回经过的分钟数不会为负数
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
var directions = [4][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}}

func orangesRotting(grid [][]int) int {
    // 统计目前的空格数和新鲜橘子数
    count1 := 0
    // 存储烂橘子
    queue := make([][]int, 0)
    // 遍历初始网格
    for i := range grid {
        for j := range grid[i] {
            // 判断是否遇到新鲜橘子
            if grid[i][j] == 1 {
                count1++
            } else if grid[i][j] == 2 {
                // 烂橘子入队
                queue = append(queue, []int{i, j})
            }
        }
    }
    minute := 0
    // 遍历烂橘子队列
    for len(queue) != 0 {
        // 分钟数+1
        minute++
        // 记录本分钟的烂橘子数
        cnt := len(queue)
        // 遍历本分钟所有烂橘子
        for i := 0; i < cnt; i++ {
            // 取一个烂橘子
            curX, curY := queue[0][0], queue[0][1]
            queue = queue[1:]
            for _, offset := range directions {
                x, y := curX+offset[0], curY+offset[1]
                if x >= 0 && y >= 0 && x < len(grid) && y < len(grid[0]) && grid[x][y] == 1 {
                    // 更新新鲜橘子数
                    count1--
                    // 烂橘子扩散
                    grid[x][y] = 2
                    // 记录烂橘子坐标
                    queue = append(queue, []int{x, y})
                }
            }
        }
    }
    // 还有新鲜橘子
    if count1 != 0 {
        return -1
    }
    return max(minute-1, 0)
}

课程表

207. 课程表 - 力扣(LeetCode)

给定一个整数表示要上的课程数,一个二维数组表示每俩课程间的依赖关系,前依赖后;判断是否能修完全部课

思路:拓扑排序;判断能否把一个 有向无环图 转成 线性的排序

  1. 用数组记录每个节点的入度,不需要前置节点的节点的入度为0;用哈希表记录依赖当前节点的所有节点,key:当前节点,value:依赖当前节点的所有节点
  2. 将入度为0的节点入队,逐个访问表示学习该课,同时将依赖该课的所有课程入度-1,若入度减为0,则入队
  3. 队列遍历结束后,遍历所有节点入度,判断是否仍有入度不为0的节点,若有说明有环,返回false,否则返回true
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
func canFinish(numCourses int, prerequisites [][]int) bool {
    indegree := make([]int, numCourses)
    mp := make(map[int][]int)
    queue := make([]int, 0)
    // 遍历前置关系,统计入度和依赖关系
    for _, pairs := range prerequisites {
        // 统计入度
        indegree[pairs[0]]++
        // 记录依赖关系
        mp[pairs[1]] = append(mp[pairs[1]], pairs[0])
    }
    // 遍历入度找出入度为0的课入队
    for i, v := range indegree {
        if v == 0 {
            queue = append(queue, i)
        }
    }
    // 遍历队列
    for len(queue) != 0 {
        // 取出一个节点表示学习该课
        cur := queue[0]
        queue = queue[1:]
        // 判断是否有依赖当前课的课
        if courses, ok := mp[cur]; ok {
            // 遍历所有依赖当前课的课
            for _, course := range courses {
                // 更新入度
                indegree[course]--
                // 判断入度是否减为0
                if indegree[course] == 0 {
                    // 0入度节点入队
                    queue = append(queue, course)
                }
            }
        }
    }
    // 遍历入度判断是否仍有入度非0的节点
    for _, v := range indegree {
        if v != 0 {
            return false
        }
    }
    return true
}

实现 Trie (前缀树)

208. 实现 Trie (前缀树) - 力扣(LeetCode)

Trie(发音类似 “try”)或者说 前缀树 是一种树形数据结构,用于高效地存储和检索字符串数据集中的键。这一数据结构有相当多的应用情景,例如自动补全和拼写检查。

要求实现 Trie 类:

  • Trie() 初始化前缀树对象
  • void insert(String word) 向前缀树中插入字符串 word
  • boolean search(String word) 如果字符串 word 在前缀树中,返回 true(即,在检索之前已经插入);否则,返回 false
  • boolean startsWith(String prefix) 如果之前已经插入的字符串 word 的前缀之一为 prefix ,返回 true ;否则,返回 false

Trie介绍

Trie 是一颗非典型的多叉树模型,多叉好理解,即每个节点的分支数量可能为多个。

非典型是因为它和一般的多叉树不一样,尤其在节点的数据结构设计上,一般的多叉树的节点是这样的:

1
2
3
4
5
6
type TreeNode struct {
    // 节点值
    Val int    
    // 指向孩子节点
    children []*TreeNode    
}

而 Trie 的节点是这样的(假设只包含’a'~‘z’中的字符):

1
2
3
4
5
6
type TrieNode struct {
    // 标记该节点是否是一个串的结束
    isEnd bool 
    // 字母映射表
    children [26]*TrieNode 
}

TrieNode节点中并没有直接保存字符值的数据成员,但**字母映射表children**中保存了对当前节点而言下一个可能出现的所有字符的链接,因此可以通过一个父节点来获知它所有子节点的值

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
type Trie struct {
    isEnd    bool
    children [26]*Trie
}

func Constructor() Trie {
    return Trie{}
}

func (this *Trie) Insert(word string) {
    // 初始化指向根节点
    cur := this
    // 遍历要插入的字符串
    for _, ch := range word {
        // 判当前节点是否无指向该字符的子节点
        if cur.children[ch-'a'] == nil {
            // 创建该子节点并记入当前节点
            cur.children[ch-'a'] = new(Trie)
        }
        // 指向该子节点
        cur = cur.children[ch-'a']
    }
    // 遍历字符串结束标记当前字符为一个结尾
    cur.isEnd = true
}

func (this *Trie) Search(word string) bool {
    cur := this
    for _, ch := range word {
        // 判断是否未找到该字符
        if cur.children[ch-'a'] == nil {
            return false
        }
        cur = cur.children[ch-'a']
    }
    // 若当前字符是一个结尾则返回true,反之返回false
    return cur.isEnd
}

func (this *Trie) StartsWith(prefix string) bool {
    cur := this
    for _, ch := range prefix {
        if cur.children[ch-'a'] == nil {
            return false
        }
        cur = cur.children[ch-'a']
    }
    return true
}

除法求值

399. 除法求值 - 力扣(LeetCode)

给定一个变量对数组 equations 和一个实数值数组 values 作为已知条件,其中 equations[i] = [Ai, Bi]values[i] 共同表示等式 Ai / Bi = values[i] 。每个 AiBi 是一个表示单个变量的字符串。另有一些以数组 queries 表示的问题,其中 queries[j] = [Cj, Dj] 表示第 j 个问题,请根据已知条件找出 Cj / Dj = ? 的结果作为答案。返回 所有问题的答案 。如果存在某个无法确定的答案,则用 -1.0 替代这个答案。如果问题中出现了给定的已知条件中没有出现的字符串,也需要用 -1.0 替代这个答案。

**注意:**输入总是有效的。除法运算中不会出现除数为 0 的情况,且不存在任何矛盾的结果。

**注意:**未在等式列表中出现的变量是未定义的,因此无法确定它们的答案

思路:由于变量之间的倍数关系具有传递性,处理有传递性关系的问题,可以使用「并查集」,需要在并查集的「合并」与「查询」操作中维护这些变量之间的倍数关系

说明:要注意看题目中的「注意」和「数据范围」,例如:每个 AiBi 是一个表示单个变量的字符串。所以用例 equation = ["ab", "cd"] ,这里的 ab 视为一个变量,不表示 a * b。如果面试中遇到这样的问题,一定要和面试官确认清楚题目的条件。还有 1 <= equations.length <= 20values[i] > 0.0 可以避免一些特殊情况的讨论。

分析示例:

  • a / b = 2.0 说明 a = 2b, a 和 b 在同一个集合中

  • b / c = 3.0 说明 b = 3c ,b 和 c 在同一个集合中

求 $\frac{a}{c}$ ,可以把 a = 2b,b = 3c 依次代入,得到 $\frac{a}{c} = \frac{2b}{c} = \frac{2·3c}{c} = 6.0$

求 $\frac{b}{a}$,很显然根据 a = 2b,知道 $\frac{b}{a} = 0.5$,也可以把 b 和 a 都转换成为 c 的倍数, $\frac{b}{a} = \frac{b}{2b} = \frac{3c}{6c} = \frac{1}{2} = 0.5$

通过上面的两个计算不难知道:可以将题目给出的 equation 中的两个变量所在的集合进行「合并」,同在一个集合中的两个变量就可以通过某种方式计算出它们的比值。具体来说,可以把不同的变量的比值转换成为相同的变量的比值,这样在做除法的时候就可以消去相同的变量,然后再计算转换成相同变量以后的系数的比值,就是题目要求的结果。统一了比较的标准,可以以 O(1) 的时间复杂度完成计算。

如果两个变量不在同一个集合中, 返回 −1.0。并且根据题目的意思,如果两个变量中 至少有一个 变量没有出现在所有 equations 出现的字符集合中,也返回 −1.0

构建有向图

通过示例的分析可以知道,题目给出的 equationsvalues 可以表示成一个图,equations 中出现的变量就是图的顶点,「分子」于「分母」的比值可以表示成一个有向关系(因为「分子」和「分母」是有序的,不可以对换),并且这个图是一个带权图,values 就是对应的有向边的权值。示例 中给出的 equationsvalues 表示的「图形表示」、「数学表示」和「代码表示」如下表所示。其中 parent[a] = b 表示:结点 a 的(直接)父亲结点是 b,与之对应的有向边的权重,记为 weight[a] = 2.0,即 weight[a] 表示结点 a 到它的 直接父亲结点 的有向边的权重

img

「统一变量」与「路径压缩」的关系

刚刚在分析例 1 的过程中,提到了:可以把一个一个 query 中的不同变量转换成 同一个变量,这样在计算 query 的时候就可以以 O(1) 的时间复杂度计算出结果,在「并查集」的一个优化技巧中,「路径压缩」就恰好符合了这样的应用场景。

为了避免并查集所表示的树形结构高度过高,影响查询性能。「路径压缩」就是针对树的高度的优化。「路径压缩」的效果是:在查询一个结点 a 的根结点同时,把结点 a 到根结点的沿途所有结点的父亲结点都指向根结点。如下图所示,除了根结点以外,所有的结点的父亲结点都指向了根结点。特别地,也可以认为根结点的父亲结点就是根结点自己。如下图所示:路径压缩前后,并查集所表示的两棵树形结构等价,路径压缩以后的树的高度为 2,查询性能最好。

image.png

由于有「路径压缩」的优化,两个同在一个连通分量中的不同的变量,它们分别到根结点(父亲结点)的权值的比值,就是题目的要求的结果

如何在「查询」操作的「路径压缩」优化中维护权值变化

如下图所示,在结点 a 执行一次「查询」操作。路径压缩会先一层一层向上先找到根结点 d,然后依次把 cba 的父亲结点指向根结点 d

image.png
  • c 的父亲结点已经是根结点了,它的权值不用更改;
  • b 的父亲结点要修改成根结点,它的权值就是从当前结点到根结点经过的所有有向边的权值的乘积,因此是 3.0 乘以 4.0 也就是 12.0;
  • a 的父亲结点要修改成根结点,它的权值就是依然是从当前结点到根结点经过的所有有向边的权值的乘积,但是 没有必要把这三条有向边的权值乘起来,这是因为 b 到 c,c 到 d 这两条有向边的权值的乘积在把 b 指向 d 的时候已经计算出来了。因此,a 到根结点的权值就等于 b 到根结点 d 的新的权值乘以 a 到 b 的原来的有向边的权值。

如何在「合并」操作中维护权值的变化

「合并」操作基于这样一个 很重要的前提:要合并的两棵树的高度最多为 2,换句话说两棵树都必需是「路径压缩」以后的效果,两棵树的叶子结点到根结点最多只需要经过一条有向边。

例如已知 $\frac{a}{b} = 3.0$,又已知 $\frac{d}{c} = 4.0$,现在合并结点 ad 所在的集合,其实就是把 a 的根结点 b 指向 d 的根结 c,那么如何计算 b 指向 c 的这条有向边的权重呢?根据 a 经过 b 可以到达 ca 经过 d 也可以到达 c,因此 两条路径上的有向边的权值的乘积是一定相等的。设 bc 的权值为 x,那么 3.0 ⋅ x = 6.0 ⋅ 4.0 ,得 x = 8.0

image.png
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
var father []int
var weight []float64

func calcEquation(equations [][]string, values []float64, queries [][]string) []float64 {
    // 1. 预处理
    // 声明并查集
    father = make([]int, 2*len(equations))
    weight = make([]float64, 2*len(equations))
    // 初始化并查集
    initial(2 * len(equations))
    // 记录所有变量 将变量的值与 id 进行映射
    mp := make(map[string]int)
    // 变量号
    id := 0
    // 遍历给定方程
    for i, v := range equations {
        // 将变量标号后记入字典
        if _, ok := mp[v[0]]; !ok {
            mp[v[0]] = id
            id++
        }
        if _, ok := mp[v[1]]; !ok {
            mp[v[1]] = id
            id++
        }
        // 将该方程计入并查集
        join(mp[v[0]], mp[v[1]], values[i])
    }
    // 2. 做查询
    res := make([]float64, len(queries))
    // 遍历所求方程
    for i, v := range queries {
        id1, ok1 := mp[v[0]]
        id2, ok2 := mp[v[1]]
        // 判该方程中变量是否未知
        if !ok1 || !ok2 {
            res[i] = float64(-1)
        } else {
            res[i] = isSame(id1, id2)
        }
    }
    return res
}

// 初始化并查集
func initial(n int) {
    for i := 0; i < n; i++ {
        father[i] = i
        weight[i] = float64(1)
    }
}

// 并查集里寻根
func find(u int) int {
    if father[u] == u {
        return u
    }
    // 保存当前节点的父节点
    origin := father[u]
    // 路径压缩 更新当前节点的父节点
    father[u] = find(father[u])
    // 路径压缩 更新权值
    weight[u] *= weight[origin]
    return father[u]
}

// 判是否同根
func isSame(a, b int) float64 {
    rootA := find(a)
    rootB := find(b)
    if rootA == rootB {
        // 同一并查集中返回分别到根结点比值
        return weight[a] / weight[b]
    }
    // 不在同一并查集中返回-1
    return float64(-1)
}

// 将a->b加入并查集
func join(a, b int, val float64) {
    rootA := find(a)
    rootB := find(b)
    if rootA == rootB {
        return
    }
    father[rootA] = rootB
    weight[rootA] = weight[b] * val / weight[a]
}
  • 时间复杂度:O((N+Q)logA)

    • 构建并查集 O(NlogA) ,这里 N 为输入方程 equations 的长度,每一次执行合并操作的时间复杂度是 O(logA),这里 A 是 equations 里不同字符的个数;
    • 查询并查集 O(QlogA),这里 Q 为查询数组 queries 的长度,每一次查询时执行「路径压缩」的时间复杂度是 O(logA)
  • 空间复杂度:O(A):创建字符与 id 的对应关系 hashMap 长度为 A,并查集底层使用的两个数组 parent 和 weight 存储每个变量的连通分量信息,parent 和 weight 的长度均为 A

课程表 II

210. 课程表 II - 力扣(LeetCode)

在上上题207. 课程表基础上,要求返回学习顺序。可能会有多个正确的顺序,只要返回 任意一种 就可以。如果不可能完成所有课程,返回 一个空数组

思路:从队列取出节点时加入结果集;最后判断是否有入度非0的节点,若有则返回空数组,否则返回结果集

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
func findOrder(numCourses int, prerequisites [][]int) []int {
    // 统计入度和依赖关系
    indegree := make([]int, numCourses)
    mp := make(map[int][]int)
    for _, pairs := range prerequisites {
        indegree[pairs[0]]++
        mp[pairs[1]] = append(mp[pairs[1]], pairs[0])
    }
    // 入度为0的节点入队(起始点)
    queue := []int{}
    for i, v := range indegree {
        if v == 0 {
            queue = append(queue, i)
        }
    }
    // 遍历图
    res := make([]int, 0)
    for len(queue) != 0 {
        cur := queue[0]
        queue = queue[1:]
        res = append(res, cur)
        // 判当前节点是否有后续课程
        if courses, ok := mp[cur]; ok {
            for _, v := range courses {
                indegree[v]--
                if indegree[v] == 0 {
                    queue = append(queue, v)
                }
            }
        }
    }
    // 判是否还剩有节点入度不为0
    for _, v := range indegree {
        if v != 0 {
            return []int{}
        }
    }
    return res
}

蛇梯棋

909. 蛇梯棋 - 力扣(LeetCode)

给定一个大小为 n x n 的整数矩阵,编号遵循 转行交替方式从左下角开始 (即 board[n - 1][0] ),每一行改变方向。一开始位于棋盘的左下角方格 1。每一回合,玩家需要从当前方格 curr 开始出发,前进范围[1,6](模拟骰子)。当玩家到达编号 n^2 的右上角方格时,游戏结束。

  • board[r][c] != -1 ,则存在 “蛇” 或 “梯子”。蛇或梯子的目的地是 board[r][c]。编号为 1n^2 的方格不是任何蛇或梯子的起点
  • 玩家在每次掷骰的前进过程中最多只能爬过蛇或梯子一次:就算目的地是另一条蛇或梯子的起点,玩家也不能继续移动。

返回达到编号为 n^2 的右上角方格所需的最少掷骰次数,如果不可能,则返回 -1

思路:

  1. 扁平化:将二维棋盘转换为一维数组
  2. BFS:用队列遍历,队列中存节点;字典存起点到该点的步数,同时代表是否访问过
  3. 队列为空,说明到不了终点;遍历队列时遇到终点直接返回步数即可
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
func snakesAndLadders(board [][]int) int {
    // 扁平化降维
    n := len(board)
    nums := []int{0}
    idx := 1
    for i := n - 1; i >= 0; i-- {
        temp := make([]int, n)
        copy(temp, board[i])
        if idx%2 == 0 {
            slices.Reverse(temp)
        }
        idx++
        nums = append(nums, temp...)
    }
    // BFS
    queue := []int{1}
    mp := map[int]int{}
    mp[1] = 0
    for len(queue) != 0 {
        cur := queue[0]
        queue = queue[1:]
        // 判是否已到终点
        if cur == n*n {
            return mp[cur]
        }
        // 尝试从当前点掷骰子,移动 1 至 6 步
        for i := 1; i <= 6; i++ {
            newPos := cur + i
            // 判是否越界
            if newPos > n*n {
                continue
            }
            // 判是否有蛇或梯
            if nums[newPos] != -1 {
                newPos = nums[newPos]
            }
            // 判是否已访问过
            if _, ok := mp[newPos]; ok {
                continue
            }
            queue = append(queue, newPos)
            mp[newPos] = mp[cur] + 1
        }
    }
    return -1
}

最小基因变化

433. 最小基因变化 - 力扣(LeetCode)

给定两个字符串和一个字符串数组,要让第一个字符串变为第二个字符串,每次只能变一个字符,而且变化后的字符串必须是数组中的,返回最少变化次数

思路:

  1. BFS:用队列遍历,队列中存节点;字典存起点到该点的步数,同时代表是否访问过
  2. 队列为空,说明到不了终点;遍历队列时遇到终点直接返回步数即可
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
func minMutation(startGene string, endGene string, bank []string) int {
    // BFS遍历
    queue := []string{startGene}
    mp := map[string]int{}
    mp[startGene] = 0
    for len(queue) != 0 {
        cur := queue[0]
        queue = queue[1:]
        // 判当前基因是否为最终基因
        if cur == endGene {
            return mp[cur]
        }
        // 遍历基因库
        for _, str := range bank {
            // 判库中基因是否已访问过或当前基因是否与库中基因一致
            if _, ok := mp[str]; ok || cur == str {
                continue
            }
            // 判当前基因是否变一个字符就与库中基因一致
            if isValid(cur, str) {
                mp[str] = mp[cur] + 1
                queue = append(queue, str)
            }
        }
    }
    return -1
}
func isValid(str1, str2 string) bool {
    cnt := 0
    for i := range str1 {
        if str1[i] != str2[i] {
            cnt++
        }
    }
    return cnt == 1
}

添加与搜索单词 - 数据结构设计

211. 添加与搜索单词 - 数据结构设计 - 力扣(LeetCode)

本题是前缀树的变种,在查询字符串是,.可以表示任何一个小写字符

思路:在匹配的过程中,如果遇到了 '.' ,则需要对当前节点的所有子树都进行DFS遍历,只要有任何一个子树能最终匹配完成,那么就代表能匹配完成。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
type WordDictionary struct {
    isEnd    bool
    children [26]*WordDictionary
}

func Constructor() WordDictionary {
    return WordDictionary{}
}

func (this *WordDictionary) AddWord(word string) {
    cur := this
    for _, ch := range word {
        if cur.children[ch-'a'] == nil {
            cur.children[ch-'a'] = new(WordDictionary)
        }
        cur = cur.children[ch-'a']
    }
    cur.isEnd = true
}

func (this *WordDictionary) Search(word string) bool {
    return dfs(word, this, 0)
}
func dfs(word string, cur *WordDictionary, idx int) bool {
    // 判是否到最后一个字符
    if idx == len(word) {
        return cur.isEnd
    }
    // 判当前字符是否为.
    if word[idx] == '.' {
        // 遍历当前节点的所有孩子
        for i := 0; i < 26; i++ {
            if cur.children[i] != nil && dfs(word, cur.children[i], idx+1) {
                return true
            }
        }
        return false
    } else {
        if cur.children[word[idx]-'a'] != nil {
            return dfs(word, cur.children[word[idx]-'a'], idx+1)
        }
        return false
    }
}

单词搜索 II

212. 单词搜索 II - 力扣(LeetCode)

给定一个 m x n 二维字符网格 board 和一个单词(字符串)列表 words返回所有二维网格上的单词 。单词必须按照字母顺序,通过 相邻的单元格 内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母在一个单词中不允许被重复使用。

思路一:前缀树+DFS+回溯

前缀树(字典树)是一种树形数据结构,用于高效地存储和检索字符串数据集中的键。前缀树可以用 O(∣S∣) 的时间复杂度完成如下操作,其中 ∣S∣ 是插入字符串或查询前缀的长度:

  • 向前缀树中插入字符串 word;

  • 查询前缀串 prefix 是否为已经插入到前缀树中的任意一个字符串 word 的前缀;

根据题意,需要逐个遍历二维网格中的每一个单元格;然后搜索从该单元格出发的所有路径,找到其中对应 words 中的单词的路径。这是一个回溯的过程:

  • 遍历二维网格中的所有单元格

  • 深度优先搜索所有从当前正在遍历的单元格出发的、由相邻且不重复的单元格组成的路径。因为题目要求同一个单元格内的字母在一个单词中不能被重复使用;所以在深度优先搜索的过程中,每经过一个单元格,都将该单元格的字母临时修改为特殊字符(例如 #),以避免再次经过该单元格。

  • 若当前路径是 words 中的单词,则将其添加到结果集中。若当前路径是 words 中任意一个单词的前缀,则继续搜索;反之,若当前路径不是 words 中任意一个单词的前缀,则剪枝。预先将 words 中的所有字符串添加到前缀树中,而后用 O(∣S∣) 的时间复杂度查询当前路径是否为 words 中任意一个单词的前缀。

注意:

  • 平时 TrieNode 中的 isEnd 标记属性直接换成记录以当前字符结尾的字符串 s,这样在 DFS 过程中无须额外记录当前字符串
  • 因为同一个单词可能在多个不同的路径中出现,所以需要使用哈希集合对结果集去重
  • 在回溯的过程中,不需要每一步都判断完整的当前路径是否是 words 中任意一个单词的前缀;而是可以记录下路径中每个单元格所对应的前缀树结点,每次只需要判断新增单元格的字母是否是上一个单元格对应前缀树结点的子结点即可
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
type TrieNode struct {
    word     string
    children [26]*TrieNode
}

func (t *TrieNode) Insert(word string) {
    cur := t
    for _, ch := range word {
        if cur.children[ch-'a'] == nil {
            cur.children[ch-'a'] = new(TrieNode)
        }
        cur = cur.children[ch-'a']
    }
    cur.word = word
}

var dirs = [4][2]int{{1, 0}, {0, 1}, {-1, 0}, {0, -1}}

func findWords(board [][]byte, words []string) []string {
    // 初始化前缀树
    root := new(TrieNode)
    for _, word := range words {
        root.Insert(word)
    }
    // DFS
    mp := map[string]struct{}{}
    var dfs func(*TrieNode, int, int)
    dfs = func(cur *TrieNode, i, j int) {
        // 判是否非前缀
        if cur.children[board[i][j]-'a'] == nil {
            return
        }
        cur = cur.children[board[i][j]-'a']
        // 判是否找到一个有效单词
        if cur.word != "" {
            mp[cur.word] = struct{}{}
        }
        // 回溯
        temp := board[i][j]
        board[i][j] = '#'
        for _, offset := range dirs {
            x := i + offset[0]
            y := j + offset[1]
            if x >= 0 && y >= 0 && x < len(board) && y < len(board[0]) && board[x][y] != '#' {
                dfs(cur, x, y)
            }
        }
        board[i][j] = temp
    }
    // 遍历二维网格
    for i := range board {
        for j := range board[i] {
            dfs(root, i, j)
        }
    }
    // 去重后加入结果集
    res := make([]string, 0, len(mp))
    for k := range mp {
        res = append(res, k)
    }
    return res
}

思路二:前缀树+DFS+回溯+删除匹配过的单词(推荐)

考虑以下情况。假设给定一个所有单元格都是 a 的二维字符网格和单词列表 [“a”, “aa”, “aaa”, “aaaa”] 。当使用方法一来找出所有同时在二维网格和单词列表中出现的单词时,需要遍历每一个单元格的所有路径,会找到大量重复的单词。

为了缓解这种情况,可以将匹配到的单词从前缀树中移除,来避免重复寻找相同的单词。因为这种方法可以保证每个单词只能被匹配一次;所以也不需要再对结果集去重了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
type TrieNode struct {
    word     string
    children [26]*TrieNode
}

func (t *TrieNode) Insert(word string) {
    cur := t
    for _, ch := range word {
        if cur.children[ch-'a'] == nil {
            cur.children[ch-'a'] = new(TrieNode)
        }
        cur = cur.children[ch-'a']
    }
    cur.word = word
}

var dirs = [4][2]int{{1, 0}, {0, 1}, {-1, 0}, {0, -1}}

func findWords(board [][]byte, words []string) []string {
    // 初始化前缀树
    root := new(TrieNode)
    for _, word := range words {
        root.Insert(word)
    }
    // DFS
    res := []string{}
    var dfs func(*TrieNode, int, int)
    dfs = func(cur *TrieNode, i, j int) {
        // 判是否非前缀
        if cur.children[board[i][j]-'a'] == nil {
            return
        }
        cur = cur.children[board[i][j]-'a']
        // 判是否找到一个有效单词
        if cur.word != "" {
            res = append(res, cur.word)
            // 删除匹配过的单词
            cur.word = ""
        }
        // 回溯
        temp := board[i][j]
        board[i][j] = '#'
        for _, offset := range dirs {
            x := i + offset[0]
            y := j + offset[1]
            if x >= 0 && y >= 0 && x < len(board) && y < len(board[0]) && board[x][y] != '#' {
                dfs(cur, x, y)
            }
        }
        board[i][j] = temp
    }
    // 遍历二维网格
    for i := range board {
        for j := range board[i] {
            dfs(root, i, j)
        }
    }
    return res
}

建立四叉树

427. 建立四叉树 - 力扣(LeetCode)

给定一个 n * n 矩阵 grid ,矩阵由若干 01 组成。用四叉树表示该矩阵 grid 。四叉树每个节点有四个子节点还有有两个属性:

  • val:储存叶子结点所代表的区域的值。1 对应 True,0 对应 False。注意,当 isLeafFalse 时,val无所谓

  • isLeaf: 当这个节点是一个叶子结点时为 True,如果它有 4 个子节点则为 False

思路:DFS递归分治

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func construct(grid [][]int) *Node {
    var dfs func(int, int, int, int) *Node
    dfs = func(startX, startY, endX, endY int) *Node {
        // 检查当前n*n网格中值是否相同
        n := endX - startX
        for i := startX; i < endX; i++ {
            for j := startY; j < endY; j++ {
                if grid[i][j] != grid[startX][startY] {
                    // 非叶子节点
                    return &Node{
                        TopLeft:     dfs(startX, startY, endX-n/2, endY-n/2),
                        TopRight:    dfs(startX, startY+n/2, endX-n/2, endY),
                        BottomLeft:  dfs(startX+n/2, startY, endX, endY-n/2),
                        BottomRight: dfs(startX+n/2, startY+n/2, endX, endY),
                    }
                }
            }
        }
        // 是叶节点
        return &Node{Val: grid[startX][startY] == 1, IsLeaf: true}
    }
    return dfs(0, 0, len(grid), len(grid))
}
  • 时间复杂度:O(n^2 logn)
  • 空间复杂度:O(logn)
Built with Hugo
Theme Stack designed by Jimmy