在 LeetCode 的中高频题目中,图论中的环检测 和树形结构的设计是两个绕不开的坎。
今天我们通过两道经典题目------207. 课程表 和 208. 实现 Trie (前缀树),来深入理解 DFS(深度优先搜索)在不同场景下的妙用,以及如何亲手设计一个高效的数据结构。
Part 1:课程表 (Course Schedule)
1. 题目核心:图的有向环检测
题目给定了一组课程的依赖关系(比如想学 A 必须先学 B),问我们能不能修完所有课。 这本质上是在问:这个有向图中是否存在环? 如果存在环(例如 A->B->C->A),就会形成"死锁",导致无法完成。
2. 解题法宝:三色标记法 (DFS)(思路类似于垃圾回收的三色标记)
为了防止 DFS 陷入死循环,并准确判断"当前路径"是否有环,我们需要三个状态,而不仅仅是访问过/没访问过。
-
0 (白色/White) :未访问。完全没探索过的未知区域。
-
1 (灰色/Gray) :正在访问中 。当前侦探正在走这条路,还没走到底。如果 DFS 过程中撞见了 1,说明咬到了自己的尾巴,发现环!
-
2 (黑色/Black) :已完成。这条路已经走到底并退出来了,确认是安全的(无环)。
3. 代码实现 (C++)
在使用 DFS 遍历邻接表时,最容易犯的错误就是混淆循环下标 和邻居节点的值,代码中已重点标注。
C++代码实现:
cpp
class Solution {
vector<vector<int>> g; // 邻接表:存图
vector<int> sign; // 三色标记数组
// 思路:基于三色标记法的 DFS 思路
bool dfs(int i) {
sign[i] = 1; // 标记为 1 (灰色/正在访问),表示"正在这条路上"
// 遍历当前节点 i 的所有邻居
for (int j = 0; j < g[i].size(); ++j) {
int neighbor = g[i][j]; // 【关键】取出真正的邻居节点,千万别用成 j
// 情况一:撞到了"正在访问"的节点 (sign=1)
// 说明我们绕了一圈又回到了当前路径上的某个点!这就是环!
if (sign[neighbor] == 1) {
return true;
}
// 情况二:是新节点 (sign=0),继续递归深入
if (sign[neighbor] == 0) {
if (dfs(neighbor)) {
return true; // 下级汇报有环,我也返回有环
}
}
// 情况三:sign=2 (安全节点),直接跳过
// 注意:不能在这里 return false,因为还要检查别的邻居!
}
// 结束了,标记为 2 (黑色/安全),表示此节点及其后代都没环
sign[i] = 2;
return false;
}
public:
bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
// 1. 初始化
g.assign(numCourses, vector<int>());
sign.assign(numCourses, 0); // 初始化为 0 (白色)
// 2. 建图:把边列表转换为邻接表
// [1, 0] 表示修 1 必须先修 0,即流向为 0 -> 1
for (int i = 0; i < prerequisites.size(); ++i) {
int prev = prerequisites[i][0];
int curr = prerequisites[i][1];
g[curr].push_back(prev);
}
// 3. 遍历每一门课,逐个让 DFS 去探路
// 注意:必须遍历所有课程 numCourses,防止图是非连通的
for (int i = 0; i < numCourses; ++i) {
// 只有没去过的才需要查 (sign=0)
if (sign[i] == 0) {
bool flag = dfs(i);
if (flag == true){ // dfs 返回 true 说明有环
return false; // 有环 = 完不成
}
}
}
return true;
}
};
4. 复杂度分析
-
时间复杂度:O(V + E)。
-
V 是课程数(节点),E 是依赖关系数(边)。
-
DFS 过程中,每个节点最多由 0 变 1 再变 2 一次,每条边最多被检查一次。
-
-
空间复杂度 :O(V + E)。注意不是VE, 因为有的变成黑色就不会二次访问了。
-
邻接表
g需要 O(V + E) 的空间。 -
标记数组
sign需要 O(V) 的空间。 -
递归栈的深度最大为 O(V)。
-
Part 2:实现 Trie (前缀树/字典树)
1. 题目核心:空间换时间
Trie 是一种专门处理字符串匹配的树形结构。
-
特点:每个节点代表一个字符的位置,通常有 26 个分支(对应 'a'-'z')。
-
优势 :利用字符串的公共前缀来减少查询时间。查找 "apple" 和 "app" 会走同一条路。
2. 优化思路:万能的 Find 函数
题目要求实现 search(查完整单词)和 startsWith(查前缀)。 为了代码复用,我们设计一个辅助函数 find(word),用返回值区分三种状态:
-
返回 0 :路断了,不存在。
-
返回 1 :路通了,但没遇到结束标记,说明前缀存在。
-
返回 2 :路通了,且遇到了
end=true,说明完整单词存在。
3. 代码实现 (C++)
关于析构函数:在 LeetCode 刷题中,为了代码简洁和运行速度,通常省略手动 delete 内存的步骤(程序结束时系统会统一回收)。但在实际工程开发中,必须写析构函数来防止内存泄漏。
C++代码实现:
cpp
struct Node {
Node* son[26]{}; // 26个分支,代表 'a'-'z',初始化为空指针
bool end = false; // 标记:走到这里是否是一个完整的单词
};
class Trie {
// 根节点,初始为空
Node* root = new Node();
// 【核心逻辑】万能查找函数
// 返回值含义:0=不存在, 1=前缀存在, 2=完整单词存在
int find(string word) {
Node* cur = root;
for (char c : word) {
c -= 'a'; // 将字符映射为 0-25 的索引
// 如果路断了 (空指针),直接返回 0
if (cur->son[c] == nullptr) {
return 0;
}
cur = cur->son[c]; // 继续向下走
}
// 循环走完,说明路通了。
// 检查 end 标记:如果是 true 返回 2,否则返回 1
return cur->end ? 2 : 1;
}
public:
// 构造函数
Trie() {}
// 插入单词:类似"造路"的过程
void insert(string word) {
Node* cur = root;
for (char c : word) {
c -= 'a';
// 如果前方没路,就 new 一个新节点出来
if (cur->son[c] == nullptr) {
cur->son[c] = new Node();
}
cur = cur->son[c];
}
// 单词走完,打上结束标记
cur->end = true;
}
// 查找完整单词:要求 find 返回 2
bool search(string word) {
return find(word) == 2;
}
// 查找前缀:只要 find 返回不是 0 (即 1 或 2 都可以)
bool startsWith(string prefix) {
return find(prefix) != 0;
}
};
4. 复杂度分析
假设我们要插入或查询的字符串长度为 L。
-
时间复杂度:O(L)。
- 无论是插入、查找还是前缀匹配,我们都只需要遍历一次字符串,树的高度由字符串长度决定,与字典中存了多少个词无关。
-
空间复杂度:O(N * L * 26)。
-
最坏情况下(没有公共前缀),每个字符都需要一个新节点。
-
N 是单词数量,L 是平均长度,26 是字符集大小。
-
实际应用中,由于前缀复用,真实空间占用会远小于最坏情况。
-
总结
-
课程表 教会了我们如何用 DFS + 三色标记法 解决图的环检测问题。
-
Trie 教会了我们如何用 树形结构 + 指针 极其高效地处理字符串前缀问题。