题目描述

✅ 269. 火星词典

题意分析

一组单词已经按某种未知字母顺序排好,要求推导所有出现过的字母的一种合法排列。该排列只需让给定单词序列符合字典序,不要求恢复唯一顺序;如果约束矛盾,返回空字符串。

字典序由两个词的第一处不同字符决定;若一词是另一词的前缀,较短词必须在前。输出还要包含没有与其他字母形成直接约束的字符,每个出现过的字母输出一次。

解法:拓扑排序

核心思路

[!blue]

先把出现过的每个字符注册为图节点,再从相邻单词中提取字母的先后约束。比较相邻两词时,跳过共同前缀,若第一处不同字符分别是 a、b,前词排在后词之前就要求 a 在 b 之前,建立边 a → b。这一处已经决定词序,后面不同的字符不再提供额外约束。

如果较短长度以内完全相同,却是较长词排在前面,那么后词是前词的真前缀。无论怎样排列字母,都无法让这个顺序合法,应立即返回空字符串;完全相同的词,或较短前缀在前,则不增加边。

只比较相邻词就足够:若所有相邻词在推导出的字母序下都满足前后关系,整个单词列表也就有序,不必对所有词两两比较。同一条字母约束可能重复出现,邻接集合只新增一次边,入度也只能相应增加一次。

得到的是字母之间的部分先后关系,用拓扑排序补成完整排列。所有零入度字符都可以作为当前下一个输出,输出后删除它们的出边,释放后继。多个候选同时为零入度时任取一个,都符合题目允许返回任意合法顺序的要求。

若所有字符都被输出,每条边的起点一定在终点之前,词典约束全部满足。若队列耗尽仍有字符未输出,剩余依赖中存在环,无法排出完整字母序,必须返回空字符串,而不是已经输出的部分结果。

解题步骤

  1. 扫描全部单词,为每个出现字符建立邻接集合和初始零入度,保留孤立字符。
  2. 比较每对相邻词,找到首个不同字符;先排除长词在其短前缀之前的非法情况。
  3. 有首差时建立对应先后边,只有边首次出现才增加后继入度。
  4. 将全部零入度字符入队,依次输出并递减后继入度,新变为零的后继也入队。
  5. 输出数量等于出现过的字符总数时返回结果,否则说明存在循环矛盾,返回空字符串。

代码实现

class Solution {
    // 如果后一个单词是前一个单词的真前缀,输入字典顺序非法。
    public String alienOrder(String[] words) {
        Map<Character, Set<Character>> graph = new HashMap<>();
        Map<Character, Integer> indegree = new HashMap<>();

        for (String word : words) {
            for (char ch : word.toCharArray()) {
                graph.putIfAbsent(ch, new HashSet<>());
                indegree.putIfAbsent(ch, 0);
            }
        }

        for (int i = 0; i < words.length - 1; i++) {
            String w1 = words[i];
            String w2 = words[i + 1];
            int len = Math.min(w1.length(), w2.length());
            int j = 0;

            while (j < len && w1.charAt(j) == w2.charAt(j)) {
                j++;
            }

            if (j == len && w1.length() > w2.length()) {
                return "";
            }

            if (j < len) {
                char from = w1.charAt(j);
                char to = w2.charAt(j);

                // 同一条边只登记一次入度,避免无法归零
                if (graph.get(from).add(to)) {
                    indegree.put(to, indegree.get(to) + 1);
                }
            }
        }

        Queue<Character> queue = new ArrayDeque<>();

        for (char ch : indegree.keySet()) {
            if (indegree.get(ch) == 0) {
                queue.offer(ch);
            }
        }

        StringBuilder sb = new StringBuilder();

        while (!queue.isEmpty()) {
            char cur = queue.poll();

            sb.append(cur);

            for (char next : graph.get(cur)) {
                indegree.put(next, indegree.get(next) - 1);

                if (indegree.get(next) == 0) {
                    queue.offer(next);
                }
            }
        }

        // 未输出全部字符说明还有循环依赖,不能返回部分顺序
        if (sb.length() != indegree.size()) {
            return "";
        }

        return sb.toString();
    }
}
func alienOrder(words []string) string {
    // 如果后一个单词是前一个单词的真前缀,输入字典顺序非法。
    graph := make(map[byte]map[byte]struct{})
    indegree := make(map[byte]int)
    for _, w := range words {
        for i := 0; i < len(w); i++ {
            ch := w[i]
            if _, ok := graph[ch]; !ok {
                graph[ch] = make(map[byte]struct{})
            }
            if _, ok := indegree[ch]; !ok {
                indegree[ch] = 0
            }
        }
    }
    for i := 0; i < len(words)-1; i++ {
        w1 := words[i]
        w2 := words[i+1]
        minLen := len(w1)
        if len(w2) < minLen {
            minLen = len(w2)
        }
        j := 0
        for j < minLen && w1[j] == w2[j] {
            j++
        }
        if j == minLen && len(w1) > len(w2) {
            return ""
        }
        if j < minLen {
            from := w1[j]
            to := w2[j]
            // 同一条边只登记一次入度,避免无法归零
            if _, ok := graph[from][to]; !ok {
                graph[from][to] = struct{}{}
                indegree[to]++
            }
        }
    }

    queue := make([]byte, 0)
    for ch, deg := range indegree {
        if deg == 0 {
            queue = append(queue, ch)
        }
    }

    order := make([]byte, 0, len(indegree))
    for len(queue) > 0 {
        cur := queue[0]
        queue = queue[1:]
        order = append(order, cur)
        for next := range graph[cur] {
            indegree[next]--
            if indegree[next] == 0 {
                queue = append(queue, next)
            }
        }
    }

    // 未输出全部字符说明还有循环依赖,不能返回部分顺序
    if len(order) != len(indegree) {
        return ""
    }
    return string(order)
}

复杂度分析

  • 时间复杂度:期望 $O(C+V+E)$,C 为总字符数,图中顶点与边均受固定字母表限制。
  • 空间复杂度:图和拓扑结构为 $O(V+E)$;Java 逐词转换字符数组另需最长单词长度的临时空间。

关键点总结

[!green]

  • 相邻词的首个差异提供必要且足够的局部顺序,后续位置不能继续强加边。
  • 真前缀倒置是与字母排列无关的矛盾,需要在建图时单独识别。
  • 图包含全部字符,边只表示已有约束,拓扑排序为没有确定先后的字符选择一种合法顺序。

易错点总结

[!yellow]

  • 对一对单词的所有不同位置都加边,会加入首差之后并不存在的约束,可能制造假环。
  • 只创建出现在边中的字符,会漏掉孤立字符以及完全相同单词中的字符。
  • 邻接集合已经去重,入度却重复增加,会导致删除全部边后入度仍不归零。
  • 把任意包含关系当成前缀关系,判断应是从开头完整匹配到较短词结束。
  • 拓扑队列为空就返回已有部分,可能忽略还被环阻塞的字符;必须核对输出总数。
  • 对结果强行要求唯一固定顺序,超过了题目允许任意合法字母序的要求。

相似题目

题目 难度 关联与区别
953. 验证外星语词典 简单 原题给定字母顺序后验证词典,本题从相邻单词的第一个不同字符反推先后约束。
210. 课程表 II 中等 推导字母边之后,复用拓扑排序输出一个符合依赖关系的顺序。
207. 课程表 中等 用拓扑排序处理有向依赖关系;本题从单词相邻关系提取字符先后约束,该题判断是否能移除全部节点以检测环。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2025/79254259
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!