图论

0 阅读11分钟

200. 岛屿数量 这个问题是经典的 “岛屿数量” (Number of Islands) 。简单来说,就是在一片茫茫大海上,给你一张地图,让你数数一共有多少个连在一起的岛。


🏠 生活案例:物业查封违建房屋

想象你是一个物业管理员,小区里有很多连在一起的违建平房('1' 表示有房子,'0' 表示空地)。你的任务是统计一共有几违建。

  1. 巡逻(遍历) :你挨个单元格看过去。如果你看到一个空地('0'),直接走人。
  2. 发现目标:一旦你看到一间房子('1'),说明你找到了一个新岛屿!计数器加 1。
  3. 彻底查封(BFS/广度优先搜索) :为了不重复计算,你不能只记这一个点。你得把跟这间房上下左右连着的所有房子全都贴上封条(变成 '0'),表示它们属于同一个岛。
  4. 叫人帮忙(队列) :你站在第一间房门口,给周围的房子发信号,让你的同事们去查封相邻的。同事查完后再给下一圈发信号,直到这一整堆房子都被查封完。

💻 代码实现与生活化注释

这段代码使用了 BFS(广度优先搜索) 的方法:

JavaScript

/**
 * @param {character[][]} grid - 大海的地图
 * @return {number} - 岛屿的总数
 */
var numIslands = function (grid) {
    if (!grid || grid.length == 0) return 0;
    let rows = grid.length;
    let cols = grid[0].length;
    let count = 0; // 岛屿计数器

    // 上、下、左、右四个方向的“探路雷达”
    let directions = [
        [-1, 0], [1, 0],
        [0, -1], [0, 1]
    ];

    // 开始逐行逐列巡逻
    for (let i = 0; i < rows; i++) {
        for (let j = 0; j < cols; j++) {
            // 如果发现陆地 '1'
            if (grid[i][j] == '1') {
                count = count + 1; // 发现新岛屿,总数加 1
                grid[i][j] = '0';  // 立即贴上封条(淹没它),防止重复统计
                
                let queue = [];    // 准备一个队列,用来记录待查封的连带陆地
                queue.push([i, j]); // 把第一个发现的点扔进队列

                // 只要队列里还有房子,就继续往外扩散搜寻
                while (queue.length) {
                    let [x, y] = queue.shift(); // 取出当前查封到的房子

                    // 检查这间房子的上下左右
                    for (let [dx, dy] of directions) {
                        let nx = x + dx;
                        let ny = y + dy;

                        // 判断:这个相邻点是否在地图内?而且是不是陆地 '1'?
                        if (nx >= 0 && nx < rows && ny >= 0 && ny < cols && grid[nx][ny] == '1') {
                            grid[nx][ny] = '0'; // 是陆地就贴上封条(淹没)
                            queue.push([nx, ny]); // 把这个点加入队列,等会查它的邻居
                        }
                    }
                }
            }
        }
    }
    return count; // 巡逻结束,返回总共发现的岛屿数
};

🗝️ 核心逻辑拆解

  1. “淹没”法:代码中最重要的部分是 grid[nx][ny] = '0'

    • 如果不把发现过的陆地变回水,你的算法就会在同一个岛上无限循环,或者把同一个岛数成好几个。
  2. 队列 (Queue)

    • 就像石子丢进水里产生的涟漪,由近及远。
    • 当你发现陆地 A,你会把 A 的邻居 B、C 放进队列。查完 A 之后,再去查 B 的邻居,以此类推。
  3. 结果

    • 外层的两个 for 循环保证了地图上每一个点都被路过。
    • 内层的 while 循环保证了只要碰到一块陆地,就一定会把它所在的整块岛屿“抹平”。

总结: 每当你遇到一个 '1',就像触发了一个“连锁反应”,把整个岛都消除掉,然后给你的奖牌榜(count)加一分。

994. 腐烂的橘子

这个问题是经典的 “腐烂的橘子” (Rotting Oranges) 。它和之前的岛屿问题很像,但多了一个“时间”的概念,是一个非常典型的 BFS(广度优先搜索) 应用场景。


🏠 生活案例:僵尸病毒爆发

想象你在一个办公室里,每个工位(格子)可能有三种状态:

  • 0:空位。
  • 1:健康的员工(新鲜橘子)。
  • 2:僵尸(腐烂的橘子)。

规则是这样的:

  1. 每过一分钟,所有的僵尸都会向他们上下左右相邻的健康员工发起攻击,把他们也变成僵尸。
  2. 你的任务是:计算出最快需要多少分钟,办公室里所有的健康员工都会变成僵尸。
  3. 特殊情况:如果有人躲在被墙包围的密室里(四周都是空位),僵尸永远抓不到他,那就返回 -1

💻 代码实现与生活化注释

这段代码的核心逻辑是 “同步扩散”

JavaScript

/**
 * @param {number[][]} grid - 橘子仓库地图
 * @return {number} - 全变腐烂所需的最少分钟数
 */
var orangesRotting = function (grid) {
    if (!grid || grid.length == 0) return 0;
    let rows = grid.length;
    let cols = grid[0].length;
    let queue = [];     // 僵尸们的“出征队列”
    let freshFruit = 0; // 记录目前还有多少个健康员工

    // 1. 【清点人数】:开局先扫一遍办公室
    for (let i = 0; i < rows; i++) {
        for (let j = 0; j < cols; j++) {
            if (grid[i][j] == 2) {
                // 发现原始僵尸,让他们进队列准备出发
                queue.push([i, j]);
            }
            if (grid[i][j] == 1) {
                // 统计有多少健康员工
                freshFruit = freshFruit + 1;
            }
        }
    }

    let directions = [[-1, 0], [1, 0], [0, -1], [0, 1]]; // 上下左右四个攻击方向
    let sumMin = 0; // 计时器(分钟)

    // 2. 【病毒扩散】:只要队列里还有僵尸,天就还没亮
    while (queue.length) {
        let size = queue.length; // 重点:记录这一分钟开始前,共有多少个僵尸
        let infected = false;    // 标记这一分钟内是否有健康员工“遭殃”

        // 这一分钟,所有在队列里的僵尸同时向外扩散一圈
        for (let i = 0; i < size; i++) {
            let [x, y] = queue.shift();

            for (let [dx, dy] of directions) {
                let nx = x + dx;
                let ny = y + dy;

                // 如果邻居是健康的员工
                if (nx >= 0 && nx < rows && ny >= 0 && ny < cols && grid[nx][ny] == 1) {
                    freshFruit = freshFruit - 1; // 健康人数减 1
                    grid[nx][ny] = 2;            // 变成僵尸
                    queue.push([nx, ny]);         // 新僵尸加入队列,下一分钟他们去传染别人
                    infected = true;             // 确认这一分钟有人被传染了
                }
            }
        }
        
        // 如果这一分钟确实有人被传染了,时间才加 1 分钟
        if (infected) sumMin = sumMin + 1;
    }

    // 3. 【最终核对】:扩散完了,看看是不是还有人幸存
    // 如果健康员工数为 0,返回总分钟数;否则返回 -1(说明有人被隔离开了)
    return freshFruit == 0 ? sumMin : -1;
};

🗝️ 核心逻辑:为什么是 BFS?

  1. 层级扩散 (Layer by Layer) : 你会发现代码里有一个 let size = queue.length; 和内部的 for 循环。这非常关键!它代表了**“一分钟内发生的所有事” 。所有的僵尸是同时**行动的,而不是一个传完所有人才轮到下一个。
  2. 新鲜橘子计数器 (freshFruit) : 开局数一遍,传染过程中减一。最后只要看这个数是不是 0,就知道有没有橘子被落下了。
  3. 为什么会有 -1 如果某个新鲜橘子周围全是 0(空位),那么 grid[nx][ny] == 1 的条件永远无法满足,它就永远不会进队列,freshFruit 也就永远不会变成 0

总结: 这个问题就像是在水里滴入了几滴墨水(腐烂橘子),我们要计算墨水完全染黑整杯水(所有新鲜橘子)的速度。如果杯子里有油滴(空位)挡住了墨水,有些地方就永远染不黑。

207. 课程表

这个问题是经典的 “课程表” (Course Schedule) ,它本质上是在考察图论中的 拓扑排序 (Topological Sort)


🏠 生活案例:大学选课与“死循环”

想象你在大学里规划选课方案。有些课有“先修要求”:

  • 想学《高数B》,必须先过《高数A》。
  • 想学《数据结构》,必须先过《C语言》。

你的目标是: 看看能不能把所有课都上完。

什么时候会失败? 最尴尬的情况是 “循环依赖”

你想学 A,老师说先去学 B;你想学 B,老师说先去学 C;你想学 C,老师说先去学 A。 结果你一门课都选不上。这就是代码里要检测的 “环”


💻 代码实现与生活化注释

这段代码采用了 Kahn 算法(入度表法)

JavaScript

/**
 * @param {number} numCourses - 总共有多少门课
 * @param {number[][]} prerequisites - 先修课要求清单
 * @return {boolean} - 是否能修完所有课
 */
var canFinish = function(numCourses, prerequisites) {
    let queue = []; // “可以立即修读”的课表(入职清单)
    
    // graph: 记录学完某门课后,可以“解锁”哪些后续课程
    let graph = Array.from({length: numCourses}, () => []);
    
    // indegree: 入度表,记录每门课前面还有几门“拦路虎”(先修课)
    let indegree = new Array(numCourses).fill(0);

    // 1. 【梳理依赖关系】:看清单,把课与课的关系连起来
    for (let [course, pre] of prerequisites) {
        graph[pre].push(course); // 学完 pre 之后,可以去学 course
        indegree[course]++;      // 想学 course,拦路虎的数量加 1
    }

    // 2. 【找突破口】:看看哪些课没有先修要求,可以直接学
    for (let i = 0; i < numCourses; i++) {
        if (indegree[i] == 0) {
            queue.push(i); // 没有拦路虎的课,扔进“待修”队列
        }
    }

    let completed = 0; // 记录我已经修完了几门课

    // 3. 【开始修课】:
    while (queue.length) {
        let curr = queue.shift(); // 修完当前这门课
        completed++;             // 拿学分,计数加 1

        // 看看修完这门课后,哪些后续课的“拦路虎”变少了
        for (let next of graph[curr]) {
            indegree[next]--; // 后续课的拦路虎减 1
            
            // 如果某门后续课的所有先修课都过完了(拦路虎变成 0)
            if (indegree[next] == 0) {
                queue.push(next); // 这门课也可以去修了
            }
        }
    }

    // 4. 【最后检查】:修完的数量是否等于总课数?
    // 如果相等,说明没遇到死循环;如果不等,说明有些课因为互相依赖卡死了
    return completed == numCourses;
};

🗝️ 核心逻辑拆解

  1. 入度 (In-degree)

    • 就像是一个关卡的“解锁条件”。如果一门课的入度是 2,说明你得先搞定另外 2 门指定的课才能碰它。
    • 当入度降为 0,门就开了。
  2. 队列 (Queue)

    • 它是我们的“待办清单”。里面装的永远是当前立刻就能做的任务。
  3. 如何检测“环”?

    • 如果图中存在环(比如 A→B→A),那么 A 和 B 的入度永远不会降到 0。
    • 这样它们就永远进不了队列。
    • 最后 completed 的数量就会比总课程数 numCourses 少,逻辑就会返回 false

总结: 拓扑排序就像是把一堆乱麻般的依赖关系,理成一条直线。如果理不直,说明里面有个“死扣”(环)。

208. 实现 Trie (前缀树)

这个问题是 “实现 Trie (前缀树)” 。它在搜索提示(比如你在百度搜“如何”,下面自动出“如何做饭”)和输入法纠错中非常常用。


🏠 生活案例:新华字典的查字法

想象你正在编写一本微型字典。为了节省空间并快速查找,你决定不按照整词排列,而是按照**“字母分岔”**来组织:

  1. 分层管理:如果你要存 appleapply

    • 首先在“根目录”找字母 a
    • a 下面找 p,再找第二个 p,再找 l
    • 到这一步,你会面临一个分岔:一边走向 e(apple),一边走向 y(apply)。
  2. 公共前缀:你会发现 appl 这四个字母是共用的。字典里不需要存两遍 appl,只需要存一遍,然后最后分个岔就行。

  3. 结束标记:你在 applee 下面打个红点,表示“到这儿为止是一个完整的单词”。如果你查 app,虽然字母都对,但 p 下面没有红点,说明 app 只是个前缀,不是你存入的完整单词。


💻 代码实现与生活化注释

前缀树的每一个节点就像字典里的一个活页夹

JavaScript

/**
 * 初始化前缀树节点
 */
var Trie = function() {
    this.children = {}; // 活页夹里的下一层字母(分岔)
    this.isEnd = false; // 红点标记:到这儿是不是一个完整的词
};

/**
 * 插入一个单词:就像在字典里加新词
 */
Trie.prototype.insert = function(word) {
    let node = this; // 从字典根目录开始
    for (let ch of word) {
        // 如果当前字母还没开分岔,就新建一个活页夹
        if (!node.children[ch]) {
            node.children[ch] = new Trie();
        }
        // 钻进这个字母的分岔继续走
        node = node.children[ch];
    }
    // 单词写完了,在最后一个字母处打个红点
    node.isEnd = true;
};

/**
 * 查找单词:必须字母全对,且最后有红点
 */
Trie.prototype.search = function(word) {
    let node = this;
    for (let ch of word) {
        // 如果查到一半没路了,说明没这个词
        if (!node.children[ch]) {
            return false;
        }
        node = node.children[ch];
    }
    // 走完了,看看这儿有没有红点(是不是完整单词)
    return node.isEnd === true;
};

/**
 * 查找前缀:只要有路走就行,不管有没有红点
 */
Trie.prototype.startsWith = function(prefix) {
    let node = this;
    for (let ch of prefix) {
        if (!node.children[ch]) {
            return false; // 没路了,前缀不成立
        }
        node = node.children[ch];
    }
    return true; // 路走通了,说明这个前缀存在
};

🗝️ 核心逻辑拆解

  • 节点结构:你会发现 this.children 是一个对象(或哈希表)。在 insert 时,我们其实是在嵌套对象,类似 node.a.p.p.l.e.isEnd = true

  • 搜索 vs 前缀查找

    • search 比较严谨:你搜 app,如果我只存了 apple,它会返回 false(因为第二个 p 没打红点)。
    • startsWith 比较宽松:你搜 app,只要字典里有 apple,它就返回 true
  • 空间换时间:前缀树虽然消耗了一些内存来存储节点,但它的查找速度极快。无论字典里有 10 个词还是 100 万个词,查找 apple 的速度只取决于这个词的长度(5 个字母就查 5 次)。

总结: Trie 树就是把所有单词的共同部分“叠”在一起,通过一套高效的路径索引,实现“一查到底”的效果。