拓扑排序应用——火星词典
解密火星词典:拓扑排序的神奇应用
图的构建和拓扑排序基础
大家好!今天我们来解决一道非常有趣且经典的算法题——“火星词典”。这道题表面上看起来是关于字符串比较,但其核心思想却巧妙地指向了图论中的一个重要算法:拓扑排序。
如果你对拓扑排序还不熟悉,别担心!这篇文章会带你一步步把问题拆解,将一个看似复杂的问题,转化为我们熟悉的模型,并最终用代码实现它。
问题初探:外星人的排序规则
首先,让我们理解一下题目。我们拿到了一本外星人语言的词典,里面的单词是按照外星字母表的顺序排好的。我们的任务是,根据这些排好序的单词,反推出这个外星字母表的顺序。
举个例子:words = ["wrt", "wrf"]
- 这两个单词的前两个字母
w
和r
都是一样的。 - 我们看第一个不同的地方:第三个字母,
t
和f
。 - 因为在词典里,
"wrt"
排在"wrf"
的前面,这告诉我们一个关键信息:在外星字母表中,字母t
一定排在字母f
的前面。
这就是解题的突破口!词典中相邻两个单词的顺序,为我们提供了字母之间先后关系的线索。
“Aha!”时刻:从先后关系到有向图
“t
在 f
前面”,这种“A precedes B”的关系,是不是很像什么?没错,这正是有向图中的一条边!
我们可以把每个出现过的字母看作图中的一个节点(Vertex)。
把字母之间的先后关系看作图中的一条有向边(Directed Edge)。
t
在 f
前面 => 可以表示为一条从 t
指向 f
的边:t -> f
。
这条边意味着,在最终的字母表排序中,t
必须出现在 f
的左边。
如果我们把所有单词两两比较,就能得到一系列这样的先后关系,从而构建出一个完整的有向图。例如,对于 words = ["wrt","wrf","er","ett","rftt"]
:
"wrt"
vs"wrf"
=>t -> f
"wrf"
vs"er"
=>w -> e
"er"
vs"ett"
=>r -> t
"ett"
vs"rftt"
=>e -> r
把这些关系整合起来,我们就得到了一个图:

现在问题就转化成了:找到这个有向图的一个线性序列,使得图中所有的节点都在这个序列中,并且对于图中任意一条从 u
到 v
的边,u
在序列中都出现在 v
的前面。
这,正是拓扑排序的定义!
宏伟蓝图
现在我们有了清晰的思路,可以制定一个三步走的战略:
- 数据预处理:遍历所有单词,找出所有出现过的唯一字母,作为我们图中的节点。
- 构建有向图:再次遍历单词列表,两两比较相邻的单词,根据第一个不同的字母找到先后关系,并在图中添加有向边。同时,我们还需要记录每个节点的“入度”(即有多少条边指向它)。
- 拓扑排序 (Kahn’s 算法):
- 找到所有入度为 0 的节点,把它们放入一个队列中。这些是没有“先修课程”的字母,可以作为字母表的开头。
- 当队列不为空时,从中取出一个节点,加入到我们的结果字符串中。
- 接着,遍历这个节点的所有“邻居”(它指向的节点),并将这些邻居的入度减 1。这相当于“完成了一门先修课”。
- 如果某个邻居的入度在减 1 后变成了 0,就把它也加入队列。
- 重复这个过程,直到队列为空。
最后,检查一下结果的合法性。如果结果字符串的长度等于我们找到的唯一字母的数量,说明所有字母都成功排序,拓扑排序完成!否则,说明图中存在环(例如 a->b
, b->a
),这是一种矛盾的顺序,不存在合法的字母表。
代码实现(你的思路,我的解读)
下面,我们来看看你的代码是如何完美实现这个三步走战略的。
class Solution {
public:string alienOrder(vector<string>& words) {// Step 1: 数据预处理int indegree[26];int kinds = 0; // 用来统计唯一字符的数量for(int i = 0; i < 26; i++) indegree[i] = -1; // -1 表示该字符不存在for(const string& s : words){for(char ch : s){if (indegree[ch - 'a'] == -1) { // 第一次见到这个字符indegree[ch - 'a'] = 0; // 标记为存在,初始入度为0kinds++;}}}// Step 2: 构建图和计算入度vector<vector<int>> graph(26); // 邻接表表示图for(int i = 0; i < words.size() - 1; i++){string cur = words[i];string next = words[i+1];int minLens = min(cur.size(), next.size());int j = 0;for(; j < minLens; j++) {if(cur[j] != next[j]){// 找到第一对不同字符,添加一条边,并更新入度addEdge(cur[j] - 'a', next[j] - 'a', graph, indegree);break;}}// 处理特殊情况: ["abc", "ab"] 是无效的if(j == minLens && cur.size() > next.size()) return "";}// Step 3: 拓扑排序char queue[26]; // 用一个字符数组模拟队列int l = 0, r = 0;// 将所有入度为0的节点入队for(int i = 0; i < 26; i++){if(indegree[i] == 0) queue[r++] = i + 'a';}stringstream ss; // 用于高效拼接结果字符串while(l < r){char cur = queue[l++];ss << cur;// 遍历当前节点的所有邻居for(int next : getEdgeFrom(cur - 'a', graph)){// 将邻居的入度减1if(--indegree[next] == 0){// 如果入度变为0,则入队queue[r++] = next + 'a';}}}// 最后一步:检查结果是否合法return ss.str().size() == kinds ? ss.str() : "";}// 辅助函数:添加边void addEdge(int u, int v, vector<vector<int>>& V, int indegree[]){V[u].push_back(v);indegree[v]++;}// 辅助函数:获取一个节点的所有出边const vector<int>& getEdgeFrom(int u, const vector<vector<int>>& V){return V[u];}
};
代码解读:
indegree
数组:这个数组一物多用,非常巧妙。初始值-1
判断字符是否存在,0
或正数则记录其入度。- 建图逻辑:代码准确地找到了相邻单词的第一个不同点来建立依赖关系,并且正确处理了
["abc", "ab"]
这种前缀关系导致的无效情况。 - 模拟队列:你用一个
char
数组和两个指针l
,r
实现了一个简单的队列,这在处理小规模数据时是完全可行的,并且效率很高。 - 合法性检查:最后通过比较结果字符串长度和唯一字符数
kinds
来判断是否存在环,这是拓扑排序中检测环的经典方法。
总结
“外星人词典”是一个非常棒的例子,它告诉我们如何将一个具体问题抽象成一个我们熟悉的数学模型(有向图),然后应用经典的算法(拓扑排序)来解决它。这个“建模-求解”的思维过程是算法学习中至关重要的一环。
希望这篇解析能帮助你彻底理解这道题的精髓!Happy coding!