200. 岛屿数量 这个问题是经典的 “岛屿数量” (Number of Islands) 。简单来说,就是在一片茫茫大海上,给你一张地图,让你数数一共有多少个连在一起的岛。
🏠 生活案例:物业查封违建房屋
想象你是一个物业管理员,小区里有很多连在一起的违建平房('1' 表示有房子,'0' 表示空地)。你的任务是统计一共有几堆违建。
- 巡逻(遍历) :你挨个单元格看过去。如果你看到一个空地(
'0'),直接走人。 - 发现目标:一旦你看到一间房子(
'1'),说明你找到了一个新岛屿!计数器加 1。 - 彻底查封(BFS/广度优先搜索) :为了不重复计算,你不能只记这一个点。你得把跟这间房上下左右连着的所有房子全都贴上封条(变成
'0'),表示它们属于同一个岛。 - 叫人帮忙(队列) :你站在第一间房门口,给周围的房子发信号,让你的同事们去查封相邻的。同事查完后再给下一圈发信号,直到这一整堆房子都被查封完。
💻 代码实现与生活化注释
这段代码使用了 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; // 巡逻结束,返回总共发现的岛屿数
};
🗝️ 核心逻辑拆解
-
“淹没”法:代码中最重要的部分是
grid[nx][ny] = '0'。- 如果不把发现过的陆地变回水,你的算法就会在同一个岛上无限循环,或者把同一个岛数成好几个。
-
队列 (Queue) :
- 就像石子丢进水里产生的涟漪,由近及远。
- 当你发现陆地 A,你会把 A 的邻居 B、C 放进队列。查完 A 之后,再去查 B 的邻居,以此类推。
-
结果:
- 外层的两个
for循环保证了地图上每一个点都被路过。 - 内层的
while循环保证了只要碰到一块陆地,就一定会把它所在的整块岛屿“抹平”。
- 外层的两个
总结: 每当你遇到一个 '1',就像触发了一个“连锁反应”,把整个岛都消除掉,然后给你的奖牌榜(count)加一分。
994. 腐烂的橘子
这个问题是经典的 “腐烂的橘子” (Rotting Oranges) 。它和之前的岛屿问题很像,但多了一个“时间”的概念,是一个非常典型的 BFS(广度优先搜索) 应用场景。
🏠 生活案例:僵尸病毒爆发
想象你在一个办公室里,每个工位(格子)可能有三种状态:
- 0:空位。
- 1:健康的员工(新鲜橘子)。
- 2:僵尸(腐烂的橘子)。
规则是这样的:
- 每过一分钟,所有的僵尸都会向他们上下左右相邻的健康员工发起攻击,把他们也变成僵尸。
- 你的任务是:计算出最快需要多少分钟,办公室里所有的健康员工都会变成僵尸。
- 特殊情况:如果有人躲在被墙包围的密室里(四周都是空位),僵尸永远抓不到他,那就返回
-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?
- 层级扩散 (Layer by Layer) : 你会发现代码里有一个
let size = queue.length;和内部的for循环。这非常关键!它代表了**“一分钟内发生的所有事” 。所有的僵尸是同时**行动的,而不是一个传完所有人才轮到下一个。 - 新鲜橘子计数器 (
freshFruit) : 开局数一遍,传染过程中减一。最后只要看这个数是不是0,就知道有没有橘子被落下了。 - 为什么会有
-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;
};
🗝️ 核心逻辑拆解
-
入度 (In-degree) :
- 就像是一个关卡的“解锁条件”。如果一门课的入度是 2,说明你得先搞定另外 2 门指定的课才能碰它。
- 当入度降为 0,门就开了。
-
队列 (Queue) :
- 它是我们的“待办清单”。里面装的永远是当前立刻就能做的任务。
-
如何检测“环”?
- 如果图中存在环(比如 A→B→A),那么 A 和 B 的入度永远不会降到 0。
- 这样它们就永远进不了队列。
- 最后
completed的数量就会比总课程数numCourses少,逻辑就会返回false。
总结: 拓扑排序就像是把一堆乱麻般的依赖关系,理成一条直线。如果理不直,说明里面有个“死扣”(环)。
208. 实现 Trie (前缀树)
这个问题是 “实现 Trie (前缀树)” 。它在搜索提示(比如你在百度搜“如何”,下面自动出“如何做饭”)和输入法纠错中非常常用。
🏠 生活案例:新华字典的查字法
想象你正在编写一本微型字典。为了节省空间并快速查找,你决定不按照整词排列,而是按照**“字母分岔”**来组织:
-
分层管理:如果你要存
apple和apply。- 首先在“根目录”找字母
a。 - 在
a下面找p,再找第二个p,再找l。 - 到这一步,你会面临一个分岔:一边走向
e(apple),一边走向y(apply)。
- 首先在“根目录”找字母
-
公共前缀:你会发现
appl这四个字母是共用的。字典里不需要存两遍appl,只需要存一遍,然后最后分个岔就行。 -
结束标记:你在
apple的e下面打个红点,表示“到这儿为止是一个完整的单词”。如果你查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 树就是把所有单词的共同部分“叠”在一起,通过一套高效的路径索引,实现“一查到底”的效果。