目录

题目描述

LCR 034. 验证外星语词典

题意分析

给一组单词 words 和一个字符串 orderorder 是 26 个小写字母的一个排列,代表外星语中字母的先后次序。判断 words 是否按这套次序升序排列(允许相等,即非递减)。

「按某种次序排列」需要拆成两层。外层是相邻性:整体有序等价于每一对相邻单词都满足前者不大于后者,因为字典序比较具有传递性,不需要两两全比。这把 $O(n^2)$ 的比较量降到 $O(n)$ 对。

内层是单词比较规则。字典序的定义是:从左往右找第一个不同的字符,谁的字符靠前谁就小;若一路都相同,则短的那个更小(前缀关系)。这两条缺一不可,第二条是本题最常被忽略的地方——"apple""app" 的前三位完全相同,此时更短的 "app" 应当排在前面。

唯一被改写的是「谁靠前」的判定依据:不再是字母表的天然顺序,而是 order 给定的位置。所以需要一次预处理,把字符映射到它在 order 中的下标。

约束里 order 恰好是 26 个小写字母的排列,这保证了映射是双射、每个字符都有唯一下标,不存在缺失字符的情况。

边界:只有一个单词时天然有序;相邻两词完全相同时合法;某个词是另一个的前缀时要按长度定序。

解法:哈希表统计状态

核心思路

直接的想法是写一个自定义比较器再调排序,看排完是否与原数组一致。这不但多了 $O(n \log n)$ 的排序开销,还要额外存一份数组,而我们其实只需要验证,不需要真的排序。

去掉排序之后,问题就是「逐对验证相邻单词」。于是第一件事是把「按 order 比较字符」变成一次常数时间的操作。

预处理的关键在于映射方向:要建的是「字符 → 它在 order 中的位置」,而不是「位置 → 字符」。因为比较时手里拿着的是单词里的字符,需要立刻问出它排第几。所以写法是 index[order.charAt(i) - 'a'] = i——下标是字符,值是名次。方向搞反会得到一个完全错误的比较依据。

有了 index,比较两个单词就变成逐位比较它们字符的名次。这里定义一个统一的取值规则可以把「前缀」这条规则也吸收进来:下标越界时把名次取为 -1。因为真实名次范围是 [0, 25]-1 严格小于任何真实字符的名次,于是「短词已经耗尽而长词还有字符」自动等价于「短词在这一位更小」,正是前缀规则想要的结论。

这样单词比较的逻辑就统一成一个循环:从第 0 位扫到两词长度的最大值,取出两边的名次 i1i2。若 i1 > i2,说明前一个词更大,整体无序,直接返回假;若 i1 < i2,说明这一对已经分出胜负且顺序正确,立即跳出去看下一对;若相等则继续看下一位。

循环的不变量是:进入第 j 轮时,两词的前 j 位名次完全相同。正因为如此,第一次出现差异的那一位就唯一决定了两词的大小关系,比出结果后必须 break 而不能继续比——继续比会让后面的位错误地覆盖已经确定的结论。

Go 版把这个逻辑拆成了另一种等价写法:先只比较公共前缀部分(循环到两词较短的长度),用一个标志位记录是否已经分出大小;若一路相同,则回到长度上判断——前一个词更长就说明违反前缀规则,返回假。两种写法的判定结果完全一致,前者靠 -1 哨兵统一,后者靠显式的长度检查。

解题步骤

  • 建名次表for (int i = 0; i < 26; ++i) index[order.charAt(i) - 'a'] = i;。下标是字符偏移、值是名次,方向不能反。order 是 26 个字母的排列,所以循环恰好跑满 26 次且每个字符都被赋值一次。
  • 只比相邻对for (int i = 0; i < words.length - 1; ++i),比较 words[i]words[i+1]。上界写 length - 1 是因为最后一个词没有后继;靠传递性,相邻全部合法即整体有序。
  • 逐位比较到较长的长度for (int j = 0; j < Math.max(l1, l2); ++j)。用最大值而不是最小值,配合越界取 -1 的规则,才能让前缀情形被这个循环覆盖到。
  • 取名次时处理越界i1 = j >= l1 ? -1 : index[w1.charAt(j) - 'a']i2 同理。-1 小于所有真实名次,语义上正好是「这个词已经结束了,比任何字符都小」。
  • 三路判定i1 > i2 返回假;i1 < i2 跳出内层循环去看下一对;相等则继续。break 是必须的,否则会用后面的位覆盖掉已经得出的正确结论。
  • 全部相邻对都通过则返回真

words = ["word", "world", "row"]order = "worldabcefghijkmnpqstuvxyz" 走一遍。先建名次表:w 是 0、o 是 1、r 是 2、l 是 3、d 是 4,其余字母从 5 开始依次排开。

第一对 "word""world"j=0 两边都是 w,名次都是 0,相等;j=1 都是 o,相等;j=2 都是 r,相等;j=3 左边是 d(名次 4)、右边是 l(名次 3),4 > 3 成立,立即返回假。这与题目预期一致——在这套字母序里 l 排在 d 前面,所以 "world" 应当排在 "word" 之前。

再看一个返回真的用例 words = ["hello", "leetcode"]order = "hlabcdefgijkmnopqrstuvwxyz"h 名次 0、l 名次 1。第一对 j=0 时左边 h 名次 0、右边 l 名次 1,0 < 1 成立,跳出内层循环;没有更多相邻对,返回真。注意后面的字符根本没被比较过——第一位已经定胜负,这正是 break 的价值。

最后看前缀用例 words = ["apple", "app"]order 为正常字母序。j=0,1,2 三位都是 app,名次相等;j=3 时左词还有字符 l(名次 11),右词已越界取 -111 > -1 成立,返回假。这正是「长词不能排在它的前缀之前」这条规则,由 -1 哨兵自动实现,不需要单独写长度判断。

代码实现

class Solution {
    public boolean isAlienSorted(String[] words, String order) {
        // 建「字符 -> 名次」的映射,方向不能反。
        int[] index = new int[26];
        for (int i = 0; i < index.length; ++i) {
            index[order.charAt(i) - 'a'] = i;
        }
        // 字典序有传递性,只需验证每一对相邻单词。
        for (int i = 0; i < words.length - 1; ++i) {
            String w1 = words[i];
            String w2 = words[i + 1];
            int l1 = w1.length(), l2 = w2.length();
            // 扫到较长的长度,配合越界取 -1 覆盖前缀规则。
            for (int j = 0; j < Math.max(l1, l2); ++j) {
                int i1 = j >= l1 ? -1 : index[w1.charAt(j) - 'a'];
                int i2 = j >= l2 ? -1 : index[w2.charAt(j) - 'a'];
                if (i1 > i2) {
                    return false;
                }
                // 已分出大小,后面的位不能再参与判定。
                if (i1 < i2) {
                    break;
                }
            }
        }
        return true;
    }
}
func isAlienSorted(words []string, order string) bool {
    // 建「字符 -> 名次」的映射,方向不能反。
    index := make(map[byte]int)
    for i := range order {
        index[order[i]] = i
    }
    for i := 0; i < len(words)-1; i++ {
        w1, w2 := words[i], words[i+1]
        l1, l2 := len(w1), len(w2)
        // flag 为真表示公共前缀一路相同,尚未分出大小。
        flag := true
        for j := 0; j < min(l1, l2) && flag; j++ {
            i1, i2 := index[w1[j]], index[w2[j]]
            if i1 > i2 {
                return false
            }
            if i1 < i2 {
                flag = false
            }
        }
        // 公共前缀相同时,长的排在前面即违反前缀规则。
        if flag && l1 > l2 {
            return false
        }
    }
    return true
}

复杂度分析

  • 时间复杂度:$O(S)$,S 是所有单词的总字符数。建名次表是固定 26 次;每一对相邻单词最多比较到两者长度的最大值,每个单词至多参与两次比较(作为前者一次、作为后者一次),累计不超过 $2S$,每次比较都是常数时间的数组取值。
  • 空间复杂度:$O(1)$。名次表长度恒为 26,由字符集大小决定而与输入规模无关;除此之外只有若干整型变量,没有开与输入同阶的结构。Go 版用哈希映射存名次,同样是至多 26 项的常数空间。

关键点总结

  • 「整体有序」等价于「相邻两两有序」,靠的是比较关系的传递性,这一步把验证从平方级降到线性,是所有「判断是否已排序」类题目的第一刀。
  • 自定义字母序的题目,第一件事永远是建「字符 → 名次」的映射,把陌生的比较规则翻译成整数大小比较,后续逻辑就与普通字典序完全一致了。
  • -1 当越界哨兵,把「前缀更小」这条规则并入统一的逐位比较,比写额外的长度分支更短也更不易漏;哨兵值必须严格小于所有合法值才成立。
  • 比出大小后必须立即跳出,字典序只由第一个不同的位置决定,继续比较会让结论被后续位覆盖。
  • 前缀情形是字典序题目的高频陷阱,写完一定要用「长词在前、短词在后且互为前缀」的用例验一遍。
  • 面试视角:面试官常追问「如果不是验证而是要求你按这套序排序呢」,答案是用同一个名次表写比较器再排序;再往上一层追问「如果 order 未知,只给出有序的单词列表要你推出字母序呢」,那就是拓扑排序的题目,能顺着说出这条演进路线会显著加分。

易错点总结

  • 映射方向写反:写成 index[i] = order.charAt(i) - 'a',得到的是「名次 → 字符」,比较时取到的数值毫无意义,["hello","leetcode"] 这类用例会随机地对或错。
  • 内层循环只扫到 min(l1, l2) 且不补长度判断["apple","app"] 的公共前缀全部相同,循环结束后直接放行,错误返回真。
  • 比出大小后不 break["hello","leetcode"] 在第 0 位已确定顺序正确,若继续比较第 1 位的 ee、第 2 位的 le,会因为 l 名次大于 e 而错误返回假。
  • 越界哨兵取 0 而不是 -10order 首字母的合法名次,["ab","a"] 中短词越界后被当成首字母,与真实字符相等时会漏判。
  • 外层循环上界写成 words.length:访问 words[i + 1] 时越界,抛数组越界异常。
  • 直接用 w1.compareTo(w2):比的是天然字母序,order = "hlabcdefgijkmnopqrstuvwxyz"["hello","leetcode"] 会被判成无序,自定义次序完全没被用上。
  • 认为相等的相邻单词非法["app","app"] 是合法的非递减序列,若把 i1 == i2 且长度也相同的情形判成假,会错误返回。
  • 建名次表时循环次数用 order.length() 之外的值:写成遍历 26 但 order 被误传成短串会越界;本题保证 order 恰好 26 位,但读题时要确认这一点再依赖它。
  • 用排序后比对原数组的方式实现:需要额外 $O(n)$ 空间和 $O(n \log n)$ 时间,且相等元素的稳定性会干扰判等,属于绕远路的写法。

相似题目

题目 难度 考察点
953. 验证外星语词典 简单 与本题同题,可直接套用名次表加相邻比较
269. 火星词典 困难 反过来做:由有序单词列表反推字母序,需要建图并做拓扑排序
791. 自定义字符串排序 中等 同样给定字符优先级,但要求真的重排字符串而不只是验证
165. 比较版本号 中等 同样是逐段比较、首个差异定胜负,但比较单位是数字且缺位要补零
944. 删列造序 简单 换成按列检查是否非递减,统计需要删除的列数而非整体判定
1122. 数组的相对排序 简单 同样先建「元素 → 优先级」的映射,再据此排序,未出现的元素另有规则
1061. 按字典序排列最小的等效字符串 中等 字符之间存在等价关系,需并查集归并后再取字典序最小的代表