Alien Dictionary
一组单词按照一种你不知道的字母顺序排列:26 个小写英文字母按某种秘密顺序排列。单词的比较规则与通常相同。两个单词从第一个不同的位置开始比较;如果一个单词是另一个单词的开头,则较短的单词排在前面。
按字母顺序返回这些单词中出现过的字母,组成一个字符串。如果有多种顺序都符合要求,则返回按普通字典序排列最靠前的顺序。如果不存在符合要求的顺序,则返回 "invalid"。
函数
- wordsstring-array
- 按未知字母表排序的单词
- 返回值string
- 按符合条件的最小顺序排列字母,或“无效”
约束条件
1 ≤ words.length ≤ 50001 ≤ words[i].length ≤ 10- 每个单词都只包含小写英文字母。
- 同一个词可能会出现多次。
示例
- 输入
- words = ["tea", "ten", "ate", "act", "cat"]
- 输出
- "etacn"
- 解释
tea在ten之前,因此 a 在 n 之前。其他几对分别表示 t 在 a 之前、t 在 c 之前,以及 a 在 c 之前。没有规则提到 e,所以最小的顺序以它开头:e、t、a、c、n。
- 输入
- words = ["bat", "tab", "tub", "bus"]
- 输出
- "invalid"
- 解释
bat排在tab前面,意味着 b 排在 t 前面;而tub排在bus前面,意味着 t 排在 b 前面。这两个条件不能同时成立,所以没有符合要求的顺序。
- 输入
- words = ["cooking", "cook"]
- 输出
- "invalid"
- 解释
cook是cooking的开头,所以在所有字母表中都排在前面。列表却把它排在第二位,这无法用字母顺序来解释。
进阶
你怎么判断这个拟合顺序是不是唯一的?
提示
逐个打开提示,每个都会多透露一点。
看看两个相邻的单词,比如
tea和ten。它们能告诉你什么关于字母表的信息?又有哪些问题没有解答?- 相邻的一对单词最多能给出一条规则:在两个单词第一个不同的位置,第一个单词的字母排在第二个单词的字母之前。这些规则是字母构成的图中的边,答案是一个满足所有边的顺序。注意,如果一对单词没有不同的位置,但第一个单词更长,就会出现问题。
使用 Kahn 算法:先放置一个没有规则指向它的字母,移除与它相关的规则,然后重复此过程。将可以放置的字母保存在最小堆中,并始终放置最小的字母。如果有些字母始终没有被放置,说明规则中存在循环。
题解
每对相邻单词最多给出一条规则,取它们首次出现差异的位置:字母 x 排在字母 y 前面。符合条件的顺序就是这些规则的拓扑序。如果存在环,或者较短单词排在以它为前缀的单词之后,这个列表就不可能成立。使用始终优先排列最小的可选字母的最小堆,可以得到最小的顺序。
尝试字母的所有排列
结果正确,但无法在最大的测试中运行完毕
思路
通过检查每一对相邻的单词,测试一种由 k 个互不相同的字母组成的排列。在第一个不同的位置,第一个单词的字母必须靠前;如果没有不同的位置,第一个单词的长度不能更长。从字母顺序排列开始,按升序使用下一个排列依次遍历,找到的第一个符合条件的排列就是最小的合适顺序。
k 个字母有 k! 种排列:10 个字母有 3,628,800 种,26 个字母约有 4 × 10^26 种,并且每次测试最多会读取 5 × 10^4 个字符。如果没有符合条件的排列,搜索就会尝试所有排列。
算法
- 收集所有不重复的字母,并按字母顺序排序。
- 记录每个字母在当前排列中的顺序。
- 检查每一对相邻单词:在第一个不同的位置,第一个单词的字母顺序必须更小;如果没有不同的位置,第一个单词不能更长。
- 如果所有相邻单词都符合条件,就返回当前排列;否则,转到下一个更大的排列。
- 如果没有下一个排列,就返回
"invalid"。
from itertools import permutations
def fits(words, rank):
# The list is sorted under rank when every neighbouring pair is in order.
for first, second in zip(words, words[1:]):
for x, y in zip(first, second):
if x != y:
if rank[x] > rank[y]:
return False
break
else:
# One word starts the other: the shorter must come first.
if len(first) > len(second):
return False
return True
def alienOrder(words):
letters = sorted(set("".join(words)))
# permutations() of a sorted list yields the orders from smallest to largest,
# so the first order that fits is the answer.
for order in permutations(letters):
rank = {ch: i for i, ch in enumerate(order)}
if fits(words, rank):
return "".join(order)
return "invalid"使用最小堆的 Kahn 算法
思路
一对相邻单词中,只有第一个不同的字母起作用:act 排在 cat 前面,说明 a 排在 c 前面,后面的字母永远不会比较。因此,每一对单词最多产生一条边。
Kahn 算法将这些边转换成一个顺序:统计每个字母的入边数,放入一个入边数为 0 的字母,移除它的出边,然后重复。环上的字母总会保留一条来自环上前一个字母的边,因此永远不会被放入。如果放入的字母比出现的字母少,答案就是 "invalid"。
把待处理的字母放入最小堆,并放入最小的字母。任何符合条件的顺序都以一个待处理字母开头,因此它是可能的最小首字母;放入它只会移除边,所以不会阻碍其他字母。在第一个示例中,e 和 t 一开始都可以处理,因此 e 排在前面。普通队列能给出一个有效顺序,但不一定是最小顺序。
算法
- 标记出这些单词中出现的每个字母。
- 对于每对相邻单词,找出第一个不同的位置,并在这两个字母之间添加一条边,每条边只添加一次。如果没有不同的位置且第一个单词更长,则返回
"invalid"。 - 统计入边数量,并将所有入边数量为 0 的已标记字母推入最小堆。
- 弹出最小的字母并将其追加。将它指向的每个字母的入边数量减 1,并将入边数量变为 0 的字母推入堆中。
- 如果所有已标记的字母都已放置,则返回这些字母;否则返回
"invalid"。
import heapq
def alienOrder(words):
# Letters are numbered 0 for 'a' up to 25 for 'z'.
present = [False] * 26
for word in words:
for ch in word:
present[ord(ch) - 97] = True
# before[a][b] is True once you know letter a comes before letter b.
before = [[False] * 26 for _ in range(26)]
indegree = [0] * 26
for first, second in zip(words, words[1:]):
for x, y in zip(first, second):
if x != y:
# Only the first difference tells you anything.
a, b = ord(x) - 97, ord(y) - 97
if not before[a][b]:
before[a][b] = True
indegree[b] += 1
break
else:
# No difference: one word starts the other, so the shorter must come first.
if len(first) > len(second):
return "invalid"
# A min-heap of the letters with nothing left before them.
heap = [c for c in range(26) if present[c] and indegree[c] == 0]
heapq.heapify(heap)
order = []
while heap:
c = heapq.heappop(heap)
order.append(chr(c + 97))
for nxt in range(26):
if before[c][nxt]:
indegree[nxt] -= 1
if indegree[nxt] == 0:
heapq.heappush(heap, nxt)
# A letter on a cycle never gets down to indegree 0, so it is never placed.
if len(order) < sum(present):
return "invalid"
return "".join(order)
易错点和边界情况
- 为一对单词添加多条规则。读取
act和cat的第二个位置会添加 c 在 a 前面的规则,导致第一个示例出现虚假的循环。 - 遗漏前缀陷阱。
cooking在cook前面时,没有不同的字母,因此只处理字母不同情况的循环会返回一个顺序。 - 遗漏没有出现在任何规则中的字母。在第一个示例中,没有规则提到 e,但它也应该出现在答案中。
- 在入度中重复计算一条规则,却只存储一次。该字母的入度永远不会变为 0,于是一个有效的列表被判定为循环。
- 把两个相同的相邻单词当作前缀陷阱;重复的单词顺序正确。
常见问题3
外星人字典的时间复杂度是多少?
O(C + k²),其中 C 是字符总数,k ≤ 26 是不同字母的数量。遍历列表一次即可找出所有首次差异,Kahn 算法最多访问 k² 条边。边表占用 O(k²) 空间。
为什么只比较相邻的单词?
排序具有传递性:如果每个单词都不大于下一个单词,那么整个列表就是有序的。因此,你可以从两个相距较远的单词中读出的任何规则,都已经可以从它们之间相邻的单词对中得出。
为什么单词不能出现在自己的前缀之前?
在任何字母表中,一个单词都排在它自身前缀之后,因为比较会在较短的单词找到差异之前,就先用完其中的字母。这是列表在规则之间没有任何循环时仍可能无法排列的一种情况。
相似题目
运用相同思路的题目。做两三道,才能真正掌握一种模式。
def alienOrder(words):
# 在这里编写代码测试 #1
测试 #2
测试 #3
参数
words = ["tea", "ten", "ate", "act", "cat"]
预期输出
"etacn"