目录

题目描述

LCR 005. 最大单词长度乘积

题意分析

给一个只含小写字母的字符串数组 words,从中挑两个不含任何公共字母的单词,返回它们长度乘积的最大值;如果不存在这样的两个单词,返回 $0$。

要求的是「最大乘积」,而乘积只由两个单词的长度决定,所以真正需要快速回答的问题只有一个:给定两个单词,它们有没有公共字母。单词内部字母的顺序、重复次数全都无关紧要,只有「出现过哪些字母」这个集合信息有用。

关键约束是字符集只有 26 个小写字母。一个 26 元素的集合,正好能塞进一个 int 的低 26 位;两个集合是否相交,就退化成一次按位与是否为零。这条约束是把「逐字符比较」压成「一次机器指令」的全部依据。

数据规模上,words 的长度是 $10^3$ 级,单词总长在 $10^3 \sim 10^4$ 量级。两两枚举是 $10^6$ 级,完全可以接受;真正不能接受的是在每一对内部再逐字符比较,那会把总代价推到 $10^9$ 以上。

边界方面:不存在合法配对时返回 $0$,因此答案变量初始化为 $0$ 就天然覆盖了这种情况;数组里可能有重复单词,但重复单词彼此必然有公共字母(除非是空串,题目不保证出现),会被判定条件自动排除,不需要去重。

解法:位运算压缩状态

核心思路

暴力做法是枚举所有单词对,对每一对做一次「是否有公共字母」的判断,判断方式是把其中一个单词的字母塞进集合再逐个查另一个。设 $n$ 是单词数、$L$ 是单词平均长度,代价是 $O(n^2 L)$。瓶颈非常清楚:同一个单词的字母集合被反复重建了 $O(n)$ 次

观察点是:单词的字母集合在整个过程中根本不会变,完全可以预处理一次、复用 $O(n)$ 次。而既然字符集只有 26 个字母,这个集合不需要用 HashSet 或长度 26 的布尔数组来存——用一个整数的第 $0$ 到第 $25$ 位表示 'a''z' 是否出现即可。

于是要维护的状态是:mask[i] 的第 $k$ 位为 $1$,当且仅当 words[i] 中出现过字母 'a' + k。有了这个表示,「两个单词无公共字母」这个判定就等价于 mask[i] & mask[j] == 0——按位与的每一位都在同时检查一个字母,一条指令顶 26 次比较。

整体流程变成两段:第一段扫一遍所有单词建 mask,代价 $O(\sum L)$;第二段两两枚举,每对只做一次按位与和一次乘法,代价 $O(n^2)$。答案变量从 $0$ 起步,遇到合法对就取较大者,天然处理了「无解返回 $0$」。

解题步骤

  • 先建掩码表:对每个单词,遍历它的字符,执行 mask[i] |= 1 << (c - 'a')。用「或」而不是「加」,是因为同一个字母出现多次时「或」是幂等的,而「加」会进位串到相邻字母上,把集合信息彻底破坏。
  • c - 'a' 把字母映射到 $0 \sim 25$ 的位下标。题目保证全是小写字母,所以不需要额外的合法性判断。
  • 建表必须独立于配对循环,这正是从 $O(n^2 L)$ 降到 $O(n^2 + \sum L)$ 的那一步——同一个单词的集合只算一次。
  • 配对时内层从 j = i + 1。乘法可交换,(i, j)(j, i) 结果相同,从 i + 1 起可以省掉一半枚举,同时天然排除了 i == j 的自配对。
  • 判定条件写 (mask[i] & mask[j]) == 0。Java 里 & 的优先级低于 ==,括号不能省,否则表达式会被解析成 mask[i] & (mask[j] == 0) 而编译失败或语义错乱。
  • 只在判定通过时更新答案answer = max(answer, len_i * len_j)。乘法只在无公共字母时才有意义,把它放在条件内部既是语义要求也省去无谓计算。
  • 答案初始化为 $0$,循环结束直接返回,无解情形不需要任何特判。

words = ["ab", "cd", "abc"] 走一遍。建表阶段:"ab" 用到 abmask[0] = 0b000011 = 3"cd" 用到 cdmask[1] = 0b001100 = 12"abc" 用到 abcmask[2] = 0b000111 = 7。配对阶段:(0, 1)3 & 12 = 0,无公共字母,乘积 $2 \times 2 = 4$,answer = 4(0, 2)3 & 7 = 3 ≠ 0,公共字母是 ab,跳过;(1, 2)12 & 7 = 4 ≠ 0,公共字母是 c,跳过。返回 4。注意 (1, 2) 那次按位与的结果 4 = 0b100 恰好指出了冲突发生在第 $2$ 位即字母 c 上——这就是位表示相对布尔数组的额外好处:结果本身携带了「哪些字母冲突」的完整信息。

代码实现

class Solution {
    public int maxProduct(String[] words) {
        int n = words.length;
        int[] mask = new int[n];
        // 预处理:每个单词的字母集合压进一个 int 的低 26 位,只算一次。
        for (int i = 0; i < n; ++i) {
            for (char c : words[i].toCharArray()) {
                mask[i] |= 1 << (c - 'a');
            }
        }
        int answer = 0;
        for (int i = 0; i < n; ++i) {
            for (int j = i + 1; j < n; ++j) {
                // 按位与为 0 等价于两个字母集合不相交。
                if ((mask[i] & mask[j]) == 0) {
                    answer = Math.max(answer, words[i].length() * words[j].length());
                }
            }
        }
        return answer;
    }
}
func maxProduct(words []string) (answer int) {
    n := len(words)
    mask := make([]int, n)
    // 预处理:每个单词的字母集合压进一个整数的低 26 位,只算一次。
    for i, w := range words {
        for _, c := range w {
            mask[i] |= 1 << (c - 'a')
        }
    }
    for i, x := range mask {
        for j := i + 1; j < n; j++ {
            // 按位与为 0 等价于两个字母集合不相交。
            if x&mask[j] == 0 {
                answer = max(answer, len(words[i])*len(words[j]))
            }
        }
    }
    return
}

复杂度分析

  • 时间复杂度:$O(n^2 + \sum L)$,其中 $n$ 是单词数、$\sum L$ 是所有单词的总长度。建表阶段每个字符只被访问一次;配对阶段每对只做一次按位与与一次乘法,是常数操作。凭的是「集合预处理」把内层的逐字符比较彻底消掉了。
  • 空间复杂度:$O(n)$,只额外开了长度为 $n$ 的掩码数组。每个集合被压成一个 32 位整数,相比每个单词一个 HashSet 或长度 26 的数组,常数小了一到两个数量级。

关键点总结

  • 字符集有限(26 个小写字母、10 个数字、ASCII 128)时,集合就该用整数的二进制位表示:判交是一次 &、判包含是 a & b == b、求并是 |,全都是常数时间且缓存友好。
  • 「同一个对象在双层循环里被反复重算」是最常见的可优化点,把不变量提到循环外预处理,往往能直接砍掉一个维度。
  • 用「或」而不是「加」来置位,是位集合的铁律;「加」在字母重复时会进位污染相邻位。
  • 答案变量的初值要与「无解时的返回值」对齐,本题恰好都是 $0$,于是不需要任何无解特判——这是初值选择带来的免费收益。
  • 面试视角:面试官抛出这题时,$O(n^2 L)$ 的暴力必须能秒写出来当保底,然后主动指出「字母集合可以压成 int」的优化点。被追问「$n$ 再大怎么办」时,正确回答是:可以按掩码去重(相同掩码只保留最长的那个单词),把 $n$ 上界压到 $2^{26}$ 与去重后的规模,或者枚举每个掩码的补集的子集,但那些做法在本题数据范围下并不划算——能说清「什么时候不该继续优化」同样是加分项。

易错点总结

  • 错误写法:mask[i] += 1 << (c - 'a')。输入 ["aa", "b"]"aa" 的掩码变成 $2$(进位到了字母 b 的位),与 "b" 的掩码相交,返回 0 而不是 2
  • 错误写法:把建掩码的循环写进配对的双层循环里。结果正确但复杂度回到 $O(n^2 L)$,在 $n = 1000$、单词长 $1000$ 的数据上必然超时。
  • 错误写法:Java 里写成 if (mask[i] & mask[j] == 0)== 的优先级高于 &,表达式被解析成 mask[i] & (mask[j] == 0),直接编译报错;括号是必需的。
  • 错误写法:判定写成 (mask[i] | mask[j]) == 0。「或」判的是「两个都是空集」而不是「不相交」,输入 ["ab", "cd"]3 | 12 = 15 ≠ 0,返回 0 而不是 4
  • 错误写法:位移写成 1 << c 而不是 1 << (c - 'a')'a' 的码值是 $97$,Go 里 1 << 97 已经移出 64 位范围变成 $0$,所有掩码都是 $0$,输入 ["ab", "abc"] 会被误判为不相交并返回 6
  • 错误写法:用 Math.max 时把乘积写成 words[i].length() * words[j].length() 之外的长度来源,比如误用 mask 的位数。位数只反映不同字母的个数,"aab" 的位数是 $2$ 而长度是 $3$,输入 ["aab", "cc"] 会返回 4 而不是 6
  • 错误写法:答案变量初始化成 Integer.MIN_VALUE-1。输入 ["a", "a"] 不存在合法配对,循环一次都不更新,返回的是那个哨兵值而不是题目要求的 0
  • 错误写法:用 HashSet<Character> 存集合再调 retainAll 判交。逻辑对,但每次判交都是 $O(26)$ 且带装箱开销,$10^6$ 对下常数放大几十倍,容易卡在时限上。

相似题目

题目 难度 考察点
318. 最大单词长度乘积 中等 与本题同题,可直接套用同一份掩码 + 两两枚举
1239. 串联字符串的最大长度 中等 从「选两个」升级为「选任意多个」,需回溯或子集 dp 维护累积掩码
1178. 猜字谜 困难 判定从「不相交」变成「是子集且含首字母」,要枚举掩码的子集
78. 子集 中等 用 $2^n$ 个整数枚举全部子集,是位表示集合的最基础形态
136. 只出现一次的数字 简单 同样是位运算,但用的是异或的自消性质而非位集合