文章目录
-
- 逻辑的枷锁:在依赖网中寻找出路
- [零、 拓扑排序:打破逻辑混乱的"秩序之光"](#零、 拓扑排序:打破逻辑混乱的“秩序之光”)
- [一、 课程表 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。
拓扑排序的数学定义:
- 有向无环图(DAG):只有不形成"死循环"的依赖关系才能排序。如果 A 依赖 B,B 又依赖 A,这就是逻辑死锁。
- 入度(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 算法思路:依赖关系的剥离
这是拓扑排序最直接的建模场景。
-
建立模型 :课程是节点,
[a, b]代表一条由b指向a的有向边( b → a b \to a b→a)。 -
入度统计 :使用数组
inDegree。若存在b -> a,则inDegree[a]++。 -
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 深度拆解:如何从对比中提取"边"?
这道题是拓扑排序的高阶应用,重点在于如何自己发现依赖关系。
-
字符集识别:先统计出所有出现过的字符,每个字符都是图中的一个节点。
-
比较相邻单词:
- 依赖关系隐藏在相邻单词的第一处不同。
- 例如
words[i] = "wrt",words[i+1] = "wrf"。 - 逐位对比:第 1 位 'w' 相同,第 2 位 'r' 相同,第 3 位出现不同:'t' vs 'f'。
- 关键结论 :在火星语中,'t' 排在 'f' 前。建立有向边
t -> f,并增加 'f' 的入度。 - 注意:一旦找到第一对不同的字符,该单词对的对比立即结束(后面的位没有参考价值)。
-
前缀异常处理:
- 如果
words[i+1]是words[i]的前缀(如apple在app前面),这在任何字典序里都是非法的,直接判死刑返回""。
- 如果
-
运行拓扑:剩下的就是标准的 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;
}
};
三、 总结:依赖关系的终结者
💬 复盘总结 :
拓扑排序不仅是一个算法,更是一种处理 "先后限制" 的工业级方案。
- 核心在于入度:入度反映了任务的受限程度。
- 建图是门艺术:有时候边是题目给的(课程表),有时候边需要你通过对比逻辑自己去挖掘(火星词典)。
- 判环是杀手锏:只要处理完的节点数对不上总数,逻辑中必定存在互相等待的死结。