文章目录
-
- 问题描述
- 核心算法:拓扑排序
- 算法步骤
-
- 第一步:初始化所有字符
- 第二步:构建有向图
-
- [建图过程详解(以测试用例 1 为例)](#建图过程详解(以测试用例 1 为例))
- [第三步:拓扑排序(Kahn 算法)](#第三步:拓扑排序(Kahn 算法))
-
- [拓扑排序详细过程(测试用例 1)](#拓扑排序详细过程(测试用例 1))
- 第四步:检查是否存在环
- 完整代码(带详细注释)
- 复杂度分析
- 关键知识点总结
-
- [1. 拓扑排序的应用场景](#1. 拓扑排序的应用场景)
- [2. Kahn 算法的核心思想](#2. Kahn 算法的核心思想)
- [3. 环检测的方法](#3. 环检测的方法)
- [4. 字典序的性质](#4. 字典序的性质)
问题描述
外星文字典(Alien Dictionary):给定一个按外星语言字母表顺序排序的单词列表,还原出外星语言的字母表顺序。
这是一个经典的图论 + 拓扑排序问题,广泛应用于任务调度、课程安排等场景。
核心算法:拓扑排序
这个问题本质上是拓扑排序问题。我们需要根据单词的排序关系,推导出字符之间的先后顺序。
关键概念
- 有向图:用邻接表表示字符之间的先后关系
- 入度:指向某个节点的边的数量,表示有多少个字符排在该字符之前
- 拓扑排序:对有向无环图(DAG)进行线性排序,使得对于任意有向边 u→v,u 在排序中都出现在 v 之前
- Kahn 算法:基于 BFS 的拓扑排序算法
算法步骤
第一步:初始化所有字符
python
indegree = {}
for w in words:
for c in w:
if c not in indegree:
indegree[c] = 0
目的:遍历所有单词中的每个字符,初始化入度为 0。
示例 :对于 words = ["wrt","wrf","er","ett","rftt"]
python
indegree = {
'w': 0, # 来自 "wrt"
'r': 0, # 来自 "wrt"
't': 0, # 来自 "wrt"
'f': 0, # 来自 "wrf"
'e': 0, # 来自 "er"
}
第二步:构建有向图
python
graph = defaultdict(list)
for i in range(len(words) - 1):
cur = words[i]
next_word = words[i + 1]
j = 0
length = min(len(cur), len(next_word))
while j < length:
if cur[j] != next_word[j]:
graph[cur[j]].append(next_word[j])
indegree[next_word[j]] += 1
break
j += 1
if j < len(cur) and j == len(next_word):
return ""
核心逻辑:
- 比较相邻的两个单词,找到第一个不同的字符
- 如果
cur[j] != next_word[j],说明在外星语言中cur[j]应该排在next_word[j]之前 - 建立一条有向边:
cur[j] → next_word[j] - 同时增加
next_word[j]的入度
为什么只比较第一个不同的字符?
- 因为单词列表已经按字典序排列
- 第一个不同的字符决定了两个单词的先后顺序
- 后面的字符顺序无法从这两个单词中确定
特殊情况处理:
python
if j < len(cur) and j == len(next_word):
return ""
- 如果
next_word是cur的前缀,但cur更长,则违反字典序规则 - 例如:
["abc", "ab"]是不合法的,因为"ab"应该排在"abc"之前
建图过程详解(以测试用例 1 为例)
第 1 轮:比较 "wrt" 和 "wrf"
cur = "wrt", next_word = "wrf"
j = 0: 'w' == 'w' ✓ 相同
j = 1: 'r' == 'r' ✓ 相同
j = 2: 't' != 'f' ✗ 不同!
发现:t 应该排在 f 之前
操作:
graph['t'].append('f') → graph = {'t': ['f']}
indegree['f'] += 1 → indegree['f'] = 1
第 2 轮:比较 "wrf" 和 "er"
cur = "wrf", next_word = "er"
j = 0: 'w' != 'e' ✗ 不同!
发现:w 应该排在 e 之前
操作:
graph['w'].append('e') → graph = {'t': ['f'], 'w': ['e']}
indegree['e'] += 1 → indegree['e'] = 1
第 3 轮:比较 "er" 和 "ett"
cur = "er", next_word = "ett"
j = 0: 'e' == 'e' ✓ 相同
j = 1: 'r' != 't' ✗ 不同!
发现:r 应该排在 t 之前
操作:
graph['r'].append('t') → graph = {'t': ['f'], 'w': ['e'], 'r': ['t']}
indegree['t'] += 1 → indegree['t'] = 1
第 4 轮:比较 "ett" 和 "rftt"
cur = "ett", next_word = "rftt"
j = 0: 'e' != 'r' ✗ 不同!
发现:e 应该排在 r 之前
操作:
graph['e'].append('r') → graph = {'t': ['f'], 'w': ['e'], 'r': ['t'], 'e': ['r']}
indegree['r'] += 1 → indegree['r'] = 1
建图完成后的状态:
graph(邻接表):
't' → ['f']
'w' → ['e']
'r' → ['t']
'e' → ['r']
indegree(入度):
'w': 0 ← 没有字符排在 w 之前
'r': 1 ← e 排在 r 之前
't': 1 ← r 排在 t 之前
'f': 1 ← t 排在 f 之前
'e': 1 ← w 排在 e 之前
图结构可视化:
w → e → r → t → f
第三步:拓扑排序(Kahn 算法)
python
queue = deque()
# 将所有入度为 0 的字符加入队列
for c in indegree:
if indegree[c] == 0:
queue.append(c)
ans = []
# BFS 过程
while queue:
cur = queue.popleft()
ans.append(cur)
for next_char in graph[cur]:
indegree[next_char] -= 1
if indegree[next_char] == 0:
queue.append(next_char)
算法原理:
- 入度为 0 的字符表示没有前驱,可以作为起点
- 每次取出一个入度为 0 的字符,加入结果列表
- 删除该字符发出的所有边(将后续字符的入度减 1)
- 如果某个字符的入度变为 0,说明它的所有前驱都已处理完毕,加入队列
- 重复上述过程,直到队列为空
拓扑排序详细过程(测试用例 1)
python
words1 = ["wrt", "wrf", "er", "ett", "rftt"]
print(alienOrder(words1)) # 输出:"wertf"
初始化队列:
python
queue = deque(['w']) # 只有 'w' 的入度为 0
ans = []
第 1 轮 BFS:
queue = ['w']
ans = []
1. cur = queue.popleft() = 'w'
2. ans.append('w') → ans = ['w']
3. 遍历 graph['w'] = ['e']:
- indegree['e'] -= 1 → indegree['e'] = 0
- indegree['e'] == 0,加入队列
- queue.append('e') → queue = ['e']
状态:queue = ['e'], ans = ['w']
第 2 轮 BFS:
queue = ['e']
ans = ['w']
1. cur = queue.popleft() = 'e'
2. ans.append('e') → ans = ['w', 'e']
3. 遍历 graph['e'] = ['r']:
- indegree['r'] -= 1 → indegree['r'] = 0
- indegree['r'] == 0,加入队列
- queue.append('r') → queue = ['r']
状态:queue = ['r'], ans = ['w', 'e']
第 3 轮 BFS:
queue = ['r']
ans = ['w', 'e']
1. cur = queue.popleft() = 'r'
2. ans.append('r') → ans = ['w', 'e', 'r']
3. 遍历 graph['r'] = ['t']:
- indegree['t'] -= 1 → indegree['t'] = 0
- indegree['t'] == 0,加入队列
- queue.append('t') → queue = ['t']
状态:queue = ['t'], ans = ['w', 'e', 'r']
第 4 轮 BFS:
queue = ['t']
ans = ['w', 'e', 'r']
1. cur = queue.popleft() = 't'
2. ans.append('t') → ans = ['w', 'e', 'r', 't']
3. 遍历 graph['t'] = ['f']:
- indegree['f'] -= 1 → indegree['f'] = 0
- indegree['f'] == 0,加入队列
- queue.append('f') → queue = ['f']
状态:queue = ['f'], ans = ['w', 'e', 'r', 't']
第 5 轮 BFS:
queue = ['f']
ans = ['w', 'e', 'r', 't']
1. cur = queue.popleft() = 'f'
2. ans.append('f') → ans = ['w', 'e', 'r', 't', 'f']
3. 遍历 graph['f'] = [] (空列表)
- 无操作
状态:queue = [], ans = ['w', 'e', 'r', 't', 'f']
第四步:检查是否存在环
python
return ''.join(ans) if len(ans) == len(indegree) else ""
判断逻辑:
- 如果
len(ans) == len(indegree),说明所有字符都被访问到,拓扑排序成功 - 否则说明图中存在环,无法形成合法的字典序,返回空字符串
为什么能检测环?
- 如果存在环(如 a→b→c→a),环中的字符入度永远无法变为 0
- 这些字符永远不会被加入队列
- 最终
ans的长度会小于字符总数
完整代码(带详细注释)
python
class Solution:
def alienOrder(self, words: List[str]) -> str:
"""
外星文字典问题
给定一个按外星语言字母表顺序排序的单词列表,还原出外星语言的字母表顺序
参数:
words: 按外星语言字典序排列的单词列表
返回:
外星语言的字母表顺序字符串,如果不存在合法顺序则返回空字符串
"""
# graph: 邻接表表示的有向图,key 是字符,value 是该字符指向的所有字符列表
# 例如:graph['w'] = ['e'] 表示 w 在 e 之前
graph = defaultdict(list)
# indegree: 记录每个字符的入度(有多少个字符排在该字符之前)
indegree = {}
# 第一步:初始化,将所有出现的字符都加入入度表,初始入度为 0
for w in words:
for c in w:
if c not in indegree:
indegree[c] = 0
# 第二步:构建有向图
# 通过比较相邻的两个单词,找出字符之间的先后关系
for i in range(len(words) - 1):
cur = words[i] # 当前单词
next_word = words[i + 1] # 下一个单词
j = 0
# 取两个单词中较短的长度,用于逐字符比较
length = min(len(cur), len(next_word))
# 逐字符比较,找到第一个不同的字符
while j < length:
if cur[j] != next_word[j]:
# 找到第一个不同的字符,说明 cur[j] 应该排在 next_word[j] 之前
# 添加一条从 cur[j] 指向 next_word[j] 的有向边
graph[cur[j]].append(next_word[j])
# next_word[j] 的入度加 1
indegree[next_word[j]] += 1
# 只需要比较到第一个不同的字符即可,后面的字符顺序无法确定
break
j += 1
# 特殊情况处理:如果前面的字符都相同,但前一个单词更长
# 例如:["abc", "ab"] 是不合法的字典序,因为 "ab" 应该排在 "abc" 之前
# 当 j == len(next_word) 时,说明 next_word 是 cur 的前缀
# 此时如果 cur 更长 (j < len(cur)),则违反了字典序规则,返回空字符串
if j < len(cur) and j == len(next_word):
return ""
# 第三步:拓扑排序(使用 Kahn 算法)
# 初始化队列,将所有入度为 0 的字符加入队列
# 入度为 0 表示没有字符排在该字符之前,可以作为起点
queue = deque()
for c in indegree:
if indegree[c] == 0:
queue.append(c)
# 存储拓扑排序结果
ans = []
# BFS 过程:不断从队列中取出入度为 0 的字符
while queue:
# 取出队首字符
cur = queue.popleft()
# 加入结果列表
ans.append(cur)
# 遍历该字符指向的所有后续字符
for next_char in graph[cur]:
# 将后续字符的入度减 1(相当于删除当前字符发出的边)
indegree[next_char] -= 1
# 如果后续字符的入度变为 0,说明它的所有前驱都已处理完毕
# 将其加入队列
if indegree[next_char] == 0:
queue.append(next_char)
# 第四步:检查是否存在环
# 如果结果列表的长度等于字符总数,说明所有字符都被访问到,拓扑排序成功
# 否则说明图中存在环,无法形成合法的字典序,返回空字符串
return ''.join(ans) if len(ans) == len(indegree) else ""
复杂度分析
时间复杂度:O©
- C 是所有单词中字符的总数
- 初始化字符:O©
- 构建图:O©,需要遍历所有字符
- 拓扑排序:O(V + E),其中 V 是字符数(最多 26),E 是边数(最多 C)
- 总体:O©
空间复杂度:O(1)
- 字符集大小固定(26 个小写字母)
- 图和入度表的空间都是常数级别
- 严格来说是 O(Σ),其中 Σ 是字符集大小
关键知识点总结
1. 拓扑排序的应用场景
- 任务调度:某些任务必须在其他任务之前完成
- 课程安排:某些课程是其他课程的先修课
- 依赖解析:如包管理器中的依赖关系
- 编译器:指令调度、依赖分析
2. Kahn 算法的核心思想
- 维护所有入度为 0 的节点
- 每次取出一个入度为 0 的节点,删除其发出的所有边
- 如果新产生入度为 0 的节点,加入队列
- 重复直到没有入度为 0 的节点
3. 环检测的方法
- 如果拓扑排序无法包含所有节点,说明存在环
- 也可以使用 DFS + 三色标记法检测环
4. 字典序的性质
- 比较两个单词时,第一个不同的字符决定先后顺序
- 如果单词 A 是单词 B 的前缀,则 A 应该排在 B 之前