LeetCode热题100图论题解析

211 阅读6分钟

难度标识:⭐:简单,⭐⭐:中等,⭐⭐⭐:困难

tips:这里的难度不是根据LeetCode难度定义的,而是根据我解题之后体验到题目的复杂度定义的。

1.岛屿数量

思路

这题可以使用深度优先搜索(DFS)。每次我们找到一个陆地单元格,我们都会通过深度优先搜索找到与其连接的所有陆地,并将它们都标记为已访问。这样,每个岛屿都只会被计数一次。

代码

var numIslands = function (grid) {
    const m = grid.length, n = grid[0].length
    function dfs(i, j) {
        if (i < 0 || j < 0 || i >= m || j >= n || grid[i][j] === '0') return;
        grid[i][j] = '0'
        dfs(i, j - 1)
        dfs(i, j + 1)
        dfs(i - 1, j)
        dfs(i + 1, j)
    }
    let count = 0
    for (let i = 0; i < m; i++) {
        for (let j = 0; j < n; j++) {
            if (grid[i][j] === '1') {
                dfs(i, j)
                count++
            }
        }
    }
    return count
};

2.腐烂的橘子 ⭐⭐

思路

这题我们可以使用广度优先搜索(BFS)。

  1. 初始状态

    • 遍历整个网格,找到所有初始时腐烂的橘子并加入一个队列中,同时统计新鲜橘子的数量。
  2. 腐烂过程

    • 当队列不为空时,从队列中取出一个腐烂的橘子。
    • 查看这个橘子周围的四个方向。如果某个方向是新鲜橘子,则把这个新鲜橘子标记为腐烂,并将其位置加入到队列中。
    • 每次从队列中完全取出一层的腐烂橘子(即取出队列的长度次),时间就加1。因为所有这些橘子是在同一时间腐烂的。
  3. 结束条件

    • 当队列为空时,即没有更多的橘子可以被腐烂时,检查整个网格。如果还有新鲜橘子,则返回-1,因为它们无法被腐烂。
    • 否则,返回所用的时间。

核心思路是先统计原始网格中腐烂的橘子和好橘子的数量,统计完成后腐烂的橘子存储到一个队列中,然后将这个队列中腐烂橘子传染周围橘子腐烂的操作完成了,在传染周围的时候将周围被传染的橘子记录到一个新队列中,当原始的队列腐烂的橘子传染完周围的橘子,再将周围被传染的橘子做同样的操作,直到最后好的橘子数量为0,那就传染完了,如果好的橘子数量最后始终不为0,那么意味有橘子不会被传染。

代码

var orangesRotting = function (grid) {
    const m = grid.length, n = grid[0].length
    let q = [], goodNum = 0;
    for (let i = 0; i < m; i++) {
        for (let j = 0; j < n; j++) {
            if (grid[i][j] === 1) {
                goodNum++
            } else if (grid[i][j] === 2) {
                q.push([i, j])
            }
        }
    }
    if (goodNum === 0) return 0
    let time = 0
    const direct = [[0, -1], [0, 1], [-1, 0], [1, 0]]
    while (q.length) {
        const newQ = []
        for (let [x, y] of q) {
            for (let [dx, dy] of direct) {
                const newX = x + dx, newY = y + dy;
                if (newX >= 0 && newY >= 0 && newX < m && newY < n && grid[newX][newY] === 1) {
                    grid[newX][newY] = 2
                    goodNum--
                    newQ.push([newX, newY])
                }
            }
        }
        if (!newQ.length) break
        q = newQ
        time++
    }
    return goodNum === 0 ? time : -1
};

3.课程表 ⭐⭐

思路

解这题可以用「拓扑排序」来解决。以下是核心思想:

  1. 课程依赖关系构建有向图

    • 使用一个列表来表示每个课程的所有后续课程。例如,a -> b 表示学习课程 b 之前需要先学习课程 a。
    • 同时为每一个课程设置一个「入度」值。入度是指有多少先修课程需要先学习。例如,如果 b 依赖于 a 和 c,那么 b 的入度是 2。
  2. 开始拓扑排序

    • 查找所有入度为 0 的课程(这些课程不依赖于其他课程,可以立即学习)并加入队列。

    • 开始循环,当队列不为空时:

      • 取出一个课程,并将它标记为已学习(减少已学习的课程数)。
      • 为这个课程的所有后续课程的入度减 1(因为已经学习了当前课程,所以后续课程的依赖减少了)。
      • 如果某个后续课程的入度变为 0,将其加入队列。
  3. 结束条件

    • 如果最后所有课程都被学习(即已学习的课程数等于总课程数),则返回 true,表示可以完成所有课程。
    • 否则,返回 false。

这个方法的基本原理是,如果有循环依赖,例如 a 依赖于 b,b 依赖于 c,c 又依赖于 a,那么这些课程的入度都不会变为 0,它们不会被加入队列,也就不会被学习。所以,通过拓扑排序我们可以找出这样的循环依赖,并判断是否所有课程都可以学习。如果对上面的思路还是不理解推荐看看官方题解的视频以及这个关于图的介绍。

代码

var canFinish = function (numCourses, prerequisites) {
    const graph = new Array(numCourses).fill(0).map(() => [])
    const indegree = new Array(numCourses).fill(0)
    for (let [a, b] of prerequisites) {
        graph[b].push(a)
        indegree[a]++
    }
    const q = []
    let count = 0
    for (let i = 0; i < numCourses; i++) {
        if (indegree[i] === 0) {
            q.push(i)
        }
    }
    while (q.length) {
        let curr = q.shift()
        count++
        for (let node of graph[curr]) {
            indegree[node]--
            if (indegree[node] === 0) {
                q.push(node)
            }
        }
    }
    return count === numCourses
};

4.实现 Trie (前缀树)

思路

  1. 定义节点结构

    • 每个节点包含一个子节点的集合,每个子节点与父节点之间的关系由一个字符(通常是一个字母)表示。
    • 每个节点还有一个标志位,用于标记该节点是否表示一个完整的单词的末尾。
  2. 初始化

    • 创建一个根节点,这个节点不代表任何字符,而是作为整棵树的起点。
  3. 插入操作

    • 从根节点开始,对于要插入的单词中的每个字符,检查当前节点的子节点中是否有这个字符。如果有,移动到那个子节点;如果没有,创建一个新的子节点,然后移动到那个子节点。
    • 当插入完单词中的最后一个字符后,将当前节点的标志位设置为真,表示有一个完整的单词在这里结束。
  4. 搜索操作

    • 从根节点开始,对于要搜索的单词中的每个字符,检查当前节点的子节点中是否有这个字符。如果有,移动到那个子节点;如果没有,返回 false。
    • 当搜索完单词中的最后一个字符后,检查当前节点的标志位。如果为真,表示这个单词存在于前缀树中;否则,表示不存在。
  5. 前缀搜索

    • 这个操作和搜索操作非常相似,但不需要检查最后一个节点的标志位。只要能按照前缀中的字符找到路径,就返回 true;否则,返回 false。

利用上述核心思想,可以创建一个高效的前缀树数据结构,该结构支持字符串的插入、搜索和前缀搜索。

代码

function Node() {
    this.children = {}
    this.isEnd = false
}

var Trie = function () {
    this.root = new Node()
};
Trie.prototype.insert = function (word) {
    let curr = this.root
    for (let ch of word) {
        if (!curr.children[ch]) {
            curr.children[ch] = new Node()
        }
        curr = curr.children[ch]
    }
    curr.isEnd = true
};
Trie.prototype.search = function (word) {
    let curr = this.root
    for (let ch of word) {
        if (!curr.children[ch]) return false
        curr = curr.children[ch]
    }
    return curr.isEnd
};
Trie.prototype.startsWith = function (prefix) {
    let curr = this.root
    for (let ch of prefix) {
        if (!curr.children[ch]) return false
        curr = curr.children[ch]
    }
    return true
};

总的来说,图的题目相对于之前的那些题目来说代码量会更多一点,所以学的时候花费的时候也要多点,但是如果学会了,理解了,其实也挺简单的,行则将至。