【优选算法篇】拓扑排序——逻辑先后与任务依赖的终极拆解

文章目录

    • 逻辑的枷锁:在依赖网中寻找出路
    • [零、 拓扑排序:打破逻辑混乱的"秩序之光"](#零、 拓扑排序:打破逻辑混乱的“秩序之光”)
    • [一、 课程表 I & II:经典拓扑排序 (Medium)](#一、 课程表 I & II:经典拓扑排序 (Medium))
      • [1.1 题目描述](#1.1 题目描述)
      • [1.2 算法思路:依赖关系的剥离](#1.2 算法思路:依赖关系的剥离)
      • [1.3 C++ 代码实战 (以课程表 II 为例)](#1.3 C++ 代码实战 (以课程表 II 为例))
    • [二、 火星词典:推导未知的秩序 (Hard)](#二、 火星词典:推导未知的秩序 (Hard))
      • [2.1 题目描述](#2.1 题目描述)
      • [2.2 深度拆解:如何从对比中提取"边"?](#2.2 深度拆解:如何从对比中提取“边”?)
      • [2.3 C++ 代码实战](#2.3 C++ 代码实战)
    • [三、 总结:依赖关系的终结者](#三、 总结:依赖关系的终结者)

逻辑的枷锁:在依赖网中寻找出路


零、 拓扑排序:打破逻辑混乱的"秩序之光"

💬 底层逻辑:什么是拓扑排序?

想象你在做一个大型工程,有些工序必须在其他工序之后才能开始。这种"先后顺序"在图论中表现为一条有向边 u → v u \to v u→v,即想要完成 v v v,必须先搞定 u u u。

拓扑排序的数学定义

  1. 有向无环图(DAG):只有不形成"死循环"的依赖关系才能排序。如果 A 依赖 B,B 又依赖 A,这就是逻辑死锁。
  2. 入度(In-degree):指向该节点的边的数量。入度为 0,意味着这个节点"自由"了,不再被任何先置条件限制,可以立即执行。

Kahn 算法(基于 BFS 的经典实现)核心流程

  • 第一步(统计入度):遍历整张图,计算每个节点的入度,并用邻接表存储图结构。
  • 第二步(寻找起点):将所有初始入度为 0 的节点塞进队列。
  • 第三步(剥离依赖):弹出队头,把它指向的所有邻居的入度减 1。这相当于"完成了当前任务,解除了它对后续任务的限制"。
  • 第四步(循环触发):如果邻居的入度被减到了 0,说明它的所有先修条件都已满足,立刻入队。
  • 第五步(结果判定) :如果处理过的节点数等于总节点数,排序成功;否则,图中一定存在

一、 课程表 I & II:经典拓扑排序 (Medium)

1.1 题目描述

题目链接207. 课程表 & 210. 课程表 II

描述

你总共有 n 门课要选。给定先修关系数组 prerequisites,其中 [a, b] 表示学 a 前必须先学 b(即 b → a b \to a b→a)。

  • 课程表 I:只需判断是否能修完所有课。
  • 课程表 II:要求返回一个可行的修课顺序。若有环则返回空数组。

1.2 算法思路:依赖关系的剥离

这是拓扑排序最直接的建模场景。

  1. 建立模型 :课程是节点,[a, b] 代表一条由 b 指向 a 的有向边( b → a b \to a b→a)。

  2. 入度统计 :使用数组 inDegree。若存在 b -> a,则 inDegree[a]++

  3. BFS 逻辑

    • 队列 q 存储当前"可以立刻修读"的课(入度为 0)。
    • 每修完一门课(弹出 q),其后续课程的负担就减轻了(邻居入度 -1)。
    • 只要邻居的入度清零,它就变成了"可以修读"的状态,进入队列。

1.3 C++ 代码实战 (以课程表 II 为例)

cpp 复制代码
class Solution {
public:
    vector<int> findOrder(int n, vector<vector<int>>& prerequisites) {
        // 1. 准备邻接表和入度统计数组
        vector<vector<int>> edges(n); 
        vector<int> inDegree(n, 0);

        // 2. 建图:prerequisites[i] = [a, b] 表示 b 必须在 a 之前
        // 所以建立一条有向边:b -> a
        for (auto& p : prerequisites) {
            int a = p[0], b = p[1];
            edges[b].push_back(a);
            inDegree[a]++;
        }

        // 3. 将所有入度为 0 的节点(没有任何前置要求的课)入队
        queue<int> q;
        for (int i = 0; i < n; i++) {
            if (inDegree[i] == 0) q.push(i);
        }

        // 4. BFS 拓扑排序:不断解除依赖
        vector<int> result;
        while (!q.empty()) {
            int t = q.front();
            q.pop();
            result.push_back(t); // 记录下这门课

            // 遍历这门课的所有后续课程,解除它们的依赖
            for (int neighbor : edges[t]) {
                inDegree[neighbor]--; 
                if (inDegree[neighbor] == 0) {
                    q.push(neighbor); // 依赖全部清除,可以学了
                }
            }
        }

        // 5. 最终判定:若处理过的课程数等于 n,说明无环
        if (result.size() == n) return result;
        return {}; // 有环,无法修完
    }
};

二、 火星词典:推导未知的秩序 (Hard)

2.1 题目描述

题目链接LCR 114. 火星词典

描述

给定一个按火星语字典序排好序的单词列表 words。请你根据这些单词的相对顺序,推导出火星字母的先后顺序。若不存在合法顺序(如存在环),返回空字符串。

2.2 深度拆解:如何从对比中提取"边"?

这道题是拓扑排序的高阶应用,重点在于如何自己发现依赖关系

  1. 字符集识别:先统计出所有出现过的字符,每个字符都是图中的一个节点。

  2. 比较相邻单词

    • 依赖关系隐藏在相邻单词的第一处不同
    • 例如 words[i] = "wrt"words[i+1] = "wrf"
    • 逐位对比:第 1 位 'w' 相同,第 2 位 'r' 相同,第 3 位出现不同:'t' vs 'f'。
    • 关键结论 :在火星语中,'t' 排在 'f' 前。建立有向边 t -> f,并增加 'f' 的入度。
    • 注意:一旦找到第一对不同的字符,该单词对的对比立即结束(后面的位没有参考价值)。
  3. 前缀异常处理

    • 如果 words[i+1]words[i] 的前缀(如 appleapp 前面),这在任何字典序里都是非法的,直接判死刑返回 ""
  4. 运行拓扑:剩下的就是标准的 BFS 逻辑。

2.3 C++ 代码实战

cpp 复制代码
class Solution {
    unordered_map<char, vector<char>> graph; // 邻接表
    unordered_map<char, int> inDegree;       // 统计每个字母的入度
    bool isInvalid = false;                  // 标记非法前缀情况

public:
    string alienOrder(vector<string>& words) {
        // 1. 初始化:只为出现过的字符建立入度记录
        for (const string& word : words) {
            for (char ch : word) inDegree[ch] = 0;
        }

        // 2. 遍历单词对,对比建图
        int n = words.size();
        for (int i = 0; i < n - 1; i++) {
            buildEdges(words[i], words[i+1]);
            if (isInvalid) return ""; 
        }

        // 3. 准备队列:入度为 0 的火星字母
        queue<char> q;
        for (auto& entry : inDegree) {
            if (entry.second == 0) q.push(entry.first);
        }

        // 4. 标准拓扑排序逻辑
        string res = "";
        while (!q.empty()) {
            char curr = q.front(); q.pop();
            res += curr;

            for (char neighbor : graph[curr]) {
                inDegree[neighbor]--;
                if (inDegree[neighbor] == 0) {
                    q.push(neighbor);
                }
            }
        }

        // 5. 判环:结果字符串长度应等于不同字母的总数
        return res.size() == inDegree.size() ? res : "";
    }

private:
    void buildEdges(const string& s1, const string& s2) {
        int len = min(s1.size(), s2.size());
        int j = 0;
        for (; j < len; j++) {
            if (s1[j] != s2[j]) {
                // 发现第一对不同字母,建立 s1[j] -> s2[j] 的关系
                char from = s1[j], to = s2[j];
                // 检查是否已经是重复边,避免干扰入度统计
                bool exist = false;
                for (char c : graph[from]) if (c == to) exist = true;
                
                if (!exist) {
                    graph[from].push_back(to);
                    inDegree[to]++;
                }
                break; // 找到第一对不同就必须停下
            }
        }
        // 特殊边界:如果 s2 是 s1 的前缀(如 "abc", "ab"),非法!
        if (j == s2.size() && s1.size() > s2.size()) isInvalid = true;
    }
};

三、 总结:依赖关系的终结者

💬 复盘总结

拓扑排序不仅是一个算法,更是一种处理 "先后限制" 的工业级方案。

  1. 核心在于入度:入度反映了任务的受限程度。
  2. 建图是门艺术:有时候边是题目给的(课程表),有时候边需要你通过对比逻辑自己去挖掘(火星词典)。
  3. 判环是杀手锏:只要处理完的节点数对不上总数,逻辑中必定存在互相等待的死结。
相关推荐
T1an-12 小时前
博乐科技笔试题
科技·算法
XiYang-DING2 小时前
【LeetCode】118.杨辉三角
算法·leetcode·职场和发展
rqtz2 小时前
【C++】 探秘网络通信:大小端序转换与结构体对齐底层逻辑
c++·网络通信·字节对齐
南境十里·墨染春水2 小时前
C++ 笔记 运算符重载(面象对象)
开发语言·c++·笔记
Yupureki2 小时前
《Linux系统编程》18.线程概念与控制
java·linux·服务器·c语言·jvm·c++
wuhen_n2 小时前
排列算法完全指南 - 从全排列到N皇后,一套模板搞定所有排列问题
前端·javascript·算法
ai生成式引擎优化技术2 小时前
拓世网络技术开发工作室的ts概率递推ai工程应用技术GEOChatGPT,不同用户账号信息,网站引用效果
算法
CylMK2 小时前
题解:UVA1218 完美的服务 Perfect Service
数据结构·c++·算法·深度优先·图论
重生之我是Java开发战士2 小时前
【广度优先搜索】BFS解决拓扑排序:课程表I,课程表II,火星词典
算法·leetcode·广度优先