从零开始写算法——图论篇2:课程表 + 实现前缀树(26叉树)

在 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 教会了我们如何用 树形结构 + 指针 极其高效地处理字符串前缀问题。

相关推荐
啊阿狸不会拉杆2 小时前
《数字信号处理》第5章-数字滤波器的基本结构
python·算法·机器学习·matlab·信号处理·数字信号处理·dsp
AI 菌2 小时前
视觉令牌压缩:Vision-centric Token Compression in Large Language Model
人工智能·算法·语言模型·llm
Fleshy数模2 小时前
从原理到实战:逻辑回归,机器学习的“Hello World”
算法·机器学习·逻辑回归
少许极端2 小时前
算法奇妙屋(二十六)-二叉树的深度搜索问题
算法·二叉树·dfs
2401_841495642 小时前
【LeetCode刷题】二叉树的中序遍历
数据结构·python·算法·leetcode··递归·遍历
2301_817497332 小时前
C++中的适配器模式实战
开发语言·c++·算法
砚边数影2 小时前
逻辑回归实战(一):用户流失预测数据集设计,KingbaseES存储标签数据
java·人工智能·算法·机器学习·逻辑回归·线性回归·金仓数据库
范纹杉想快点毕业2 小时前
自学嵌入式系统架构设计:有限状态机入门完全指南,C语言,嵌入式,单片机,微控制器,CPU,微机原理,计算机组成原理
c语言·开发语言·单片机·算法·microsoft
星火开发设计5 小时前
枚举类 enum class:强类型枚举的优势
linux·开发语言·c++·学习·算法·知识