LeetCode 318. 最大单词长度乘积
题目描述
题意分析
给一个字符串数组,从中挑出两个不共享任何字母的单词,让它们长度的乘积最大,返回这个最大值;如果任意两个单词都有公共字母,返回 0。
「不共享任何字母」是唯一的约束,它只关心两个单词各自出现过哪些字母,完全不关心每个字母出现了几次、出现在什么位置。这条信息一旦被识别出来,就等于说:一个单词在判重时的全部有效信息,可以被压缩成一个「字母是否出现」的集合。
约束里另外两个数字给出了明确信号:字符串只含小写英文字母,也就是最多 26 种;单词数量规模在千级别。26 这个数小于 32,意味着一个
int就能装下整个字母集合;千级别的 $n$ 意味着 $O(n^2)$ 的两两枚举完全可以接受,不需要更花哨的做法。所以真正需要优化的不是「枚举多少对」,而是「判断一对要花多久」。边界:数组只有一个单词时不存在任何一对,答案为 0;所有单词两两都有公共字母时答案同样是 0,所以答案初值取 0 而不是负无穷;单词长度乘积最大约为 1000 × 1000,
int不会溢出;题面不保证单词互不相同,重复的两个单词一定有公共字母(除非是空串,而题面保证长度至少为 1),会被自然排除。
解法:位掩码枚举单词对
核心思路
先看暴力做法:枚举所有下标对
(i, j),对每一对再拿两层循环逐字符比较,看有没有相同字母。判断一对的代价是 $O(L_i \times L_j)$,总代价能到 $O(n^2 L^2)$。即使用HashSet优化成把一个单词装进集合再遍历另一个,单次判断也还是 $O(L)$。瓶颈很清楚:同一个单词在 $n$ 次配对里被反复重新扫描,做了大量重复劳动。顺着这个瓶颈想:既然一个单词参与判断时唯一有用的信息是「它含有哪些字母」,那就把这份信息预处理一次、复用 $n$ 次。而「26 个字母各自在或不在」正是一个 26 位的布尔向量,用整数的二进制位来表示最省:第 $k$ 位为 1 表示字母
'a' + k出现过。于是得到本解法的核心不变量:
\[\text{words}[i] \text{ 与 } \text{words}[j] \text{ 无公共字母} \iff \text{mask}[i] \mathrel{\&} \text{mask}[j] = 0\]mask[i]的第 $k$ 位为 1,当且仅当字母'a' + k在words[i]中出现过;lengths[i]恒等于words[i].length()。在这个表示下,「两个单词共享某个字母」等价于「存在某一位在两个掩码上同时为 1」,也就是mask[i] & mask[j] != 0。取反即得判定式:这一步把单次判断从 $O(L)$ 压到了 $O(1)$——一条按位与指令。整体复杂度随之从 $O(n^2 L)$ 降到 $O(n^2 + L_{\text{total}})$,其中预处理只扫一遍全部字符。
剩下的就是常规的最大值维护:对每一对通过判定的
(i, j),用lengths[i] * lengths[j]更新答案。注意只枚举j > i,因为乘积对称,枚举一半就够。
解题步骤
- 开两个长度为
n的数组mask与lengths。为什么要单独存长度:后面的双层循环里会访问 $O(n^2)$ 次长度,存成int数组比每次调用words[i].length()更直接,也让主循环里完全不再触碰字符串。- 对每个单词扫一遍字符,执行
bitMask |= 1 << (c - 'a')。为什么用|=而不是+=:同一个字母可能出现多次,|=幂等,重复置位不改变结果;用加法会进位到相邻位,直接破坏「一位代表一个字母」的语义。- 把算好的掩码与长度写回数组。为什么必须先全部预处理完再进入配对循环:预处理是 $O(L)$ 一次性开销,若放进配对循环内部就退化成每对重算,优化前功尽弃。
- 双层循环枚举
i从 0 到n-1、j从i+1到n-1。为什么j从i+1起:一是避免i == j自己和自己配对(自己和自己必然有公共字母,虽然会被判定式挡住,但语义上不该出现),二是(i,j)与(j,i)乘积相同,枚举上三角即可省掉一半。- 判定
(mask[i] & mask[j]) == 0才更新答案。为什么括号不能省:Java 与 Go 中&的优先级低于==,写成mask[i] & mask[j] == 0在 Java 里直接编译报错,在其他语言里可能被解析成mask[i] & (mask[j] == 0),语义完全错。- 答案初值取 0 并原样返回。为什么不是
-1或负无穷:题面规定不存在合法配对时就返回 0,初值 0 让这种情况自然落入主逻辑,无需任何特判。以
具体用例 words = ["abcw","baz","foo","bar","xtfn","abcdef"]走一遍。预处理阶段,把每个单词转成掩码(这里用出现的字母集合来表示更直观):
abcw → {a,b,c,w},长度 4;baz → {a,b,z},长度 3;foo → {f,o},长度 3;bar → {a,b,r},长度 3;xtfn → {f,n,t,x},长度 4;abcdef → {a,b,c,d,e,f},长度 6。注意foo里的两个o只置位一次,这正是|=的幂等性在起作用。配对阶段,
answer初值 0:
i=0(abcw)与j=1(baz):都含 a、b,按位与非 0,跳过。与j=2(foo):{a,b,c,w}与{f,o}无交集,按位与为 0,乘积4 × 3 = 12,answer更新为 12。与j=3(bar):共享 a、b,跳过。与j=4(xtfn):{a,b,c,w}与{f,n,t,x}无交集,乘积4 × 4 = 16,answer更新为 16。与j=5(abcdef):共享 a,跳过。
i=1(baz):与foo无交集,乘积3 × 3 = 9,不超过 16;与xtfn无交集,乘积3 × 4 = 12,不超过 16;与bar、abcdef都共享 a,跳过。
i=2(foo):与bar无交集,乘积3 × 3 = 9;与xtfn共享 f,跳过;与abcdef共享 f,跳过。
i=3(bar):与xtfn无交集,乘积3 × 4 = 12;与abcdef共享 a、b,跳过。
i=4(xtfn)与j=5(abcdef):共享 f,跳过。
循环结束,返回answer = 16,对应abcw与xtfn。
代码实现
class Solution {
// 两个单词没有公共字母,等价于它们的位掩码按位与结果为 0。
public int maxProduct(String[] words) {
int n = words.length;
int[] mask = new int[n];
int[] lengths = new int[n];
for (int i = 0; i < n; i++) {
int bitMask = 0;
for (int j = 0; j < words[i].length(); j++) {
bitMask |= 1 << (words[i].charAt(j) - 'a');
}
mask[i] = bitMask;
lengths[i] = words[i].length();
}
int answer = 0;
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
if ((mask[i] & mask[j]) == 0) {
answer = Math.max(answer, lengths[i] * lengths[j]);
}
}
}
return answer;
}
}
func maxProduct(words []string) int {
// 两个单词没有公共字母,等价于它们的位掩码按位与结果为 0。
n := len(words)
mask := make([]int, n)
lengths := make([]int, n)
for i, word := range words {
bitMask := 0
for j := 0; j < len(word); j++ {
bitMask |= 1 << (word[j] - 'a')
}
mask[i] = bitMask
lengths[i] = len(word)
}
answer := 0
for i := 0; i < n; i++ {
for j := i + 1; j < n; j++ {
if mask[i]&mask[j] == 0 {
product := lengths[i] * lengths[j]
if product > answer {
answer = product
}
}
}
}
return answer
}
复杂度分析
- 时间复杂度:$O(n^2 + L)$,其中 $L$ 是所有单词的总字符数。凭什么:预处理阶段每个字符只被访问一次,合计 $O(L)$;配对阶段上三角共 $n(n-1)/2$ 对,每对只做一次按位与、一次比较、一次乘法,全是常数指令,合计 $O(n^2)$。关键在于字符串长度 $L$ 与配对次数 $n^2$ 从相乘变成了相加。
- 空间复杂度:$O(n)$。凭什么:只额外开了
mask与lengths两个长度为 $n$ 的整型数组,每个单词的字母集合被压缩进单个int,与单词本身的长度无关。
关键点总结
- 「只关心字母是否出现」是压缩成位掩码的触发条件。一旦发现题目对某个维度只需要「在/不在」的布尔判断,且取值种类不超过 64,就应立刻想到用整数的二进制位表示集合。
- 集合运算与位运算的对应关系要背熟:交集是
&,并集是|,对称差是^,判空是== 0,加入元素是|= 1 << k,判断元素是否存在是(mask >> k) & 1。本题只用到「交集为空」这一条。- 把 $O(n^2)$ 内层的重复计算提到循环外预处理,是从 $O(n^2 L)$ 降到 $O(n^2 + L)$ 的通用手法,和「前缀和」「预处理哈希」属于同一类思想。
|=的幂等性正是处理重复元素所需要的语义,用+=或++计数会破坏位与位之间的独立性。- 答案初值的选取应让「无解」自然成立。这里题面规定无解返回 0,恰好等于乘积的下界,所以初值 0 既是答案也是哨兵,一个特判都不用写。
- 面试视角:这道题的考点百分之百是「你能不能想到位掩码」,用
HashSet求交集虽然也能过但直接绕开了考点。写完后主动补一句「掩码可以进一步用哈希表按掩码去重,只保留每个掩码下最长的单词,当重复单词很多时能显著减少配对数」,是很自然的加分延伸。
易错点总结
- 错误写法:
if (mask[i] & mask[j] == 0)(漏掉外层括号) → 在 Java 中==优先级高于&,表达式变成mask[i] & (mask[j] == 0),int与boolean做按位与,编译直接报错;在优先级不同的语言里则会静默算出完全错误的结果。- 错误写法:
bitMask += 1 << (c - 'a')→ 用例words = ["aa","b"],字母a被加两次,bitMask从1变成2,被误认成含有字母b,于是"aa"与"b"被判为有公共字母,答案错成 0(正确答案是 2)。- 错误写法:内层循环写成
for (int j = 0; j < n; j++)且不跳过i == j→ 用例words = ["abc"]之外的任意输入下,mask[i] & mask[i]只有在单词为空串时才为 0;题面虽保证长度至少为 1 使这条侥幸不出错,但一旦允许空串(如words = [""]),就会拿同一个单词自乘,答案错成 0 与实际语义不符,且白跑一倍循环。- 错误写法:
answer初值设成Integer.MIN_VALUE→ 用例words = ["a","ab","abc"],任意两词都共享字母a,一次都不进入更新分支,最终返回-2147483648而不是题目要求的 0。- 错误写法:预处理写在配对双层循环内部,每次现算两个单词的掩码 → 用例
words含 1000 个长度 1000 的单词时,重复扫描字符导致约 $10^9$ 次字符操作,直接超时。- 错误写法:
1 << (c - 'a')写成1 << c→ 用例words = ["a","z"],'a'的 ASCII 是 97,1 << 97在 Java 中按97 % 32 = 1处理得到 2,'z'的 122 按122 % 32 = 26得到1 << 26,两个掩码碰巧不冲突结果对了,但换成["a","b"]时'a' → 1<<1、'b' → 1<<2也不冲突,而["a","!"]这类越界字符会让位彻底错乱;根本问题是没有把字符归一化到 0~25。- 错误写法:用
HashSet<Character>存字母并调用retainAll判交集 → 用例 1000 个长单词时虽然逻辑正确,但每次判交集是 $O(26)$ 的对象操作外加大量装箱,实测比按位与慢一到两个数量级;面试中这么写等于放弃了本题唯一的考点。- 错误写法:只用掩码不存长度,判定通过后回头写
words[i].length() * words[j].length()→ 逻辑正确但把字符串对象的方法调用放进了 $O(n^2)$ 的热循环,在 Java 中还可能触发额外的边界检查;用例规模拉满时是明显的常数级劣化。- 错误写法:认为掩码相同的单词一定可以互相替换而只保留第一个 → 用例
words = ["ab","abab"],两者掩码相同但长度不同,若保留的是较短的"ab",与某个无冲突单词配对时会算出偏小的乘积;去重时必须保留同掩码下最长的那个。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| LCR 005. 最大单词长度乘积 | 中等 | 与本题同题,可直接套用同一份代码 |
| 1239. 串联字符串的最大长度 | 中等 | 从「两两配对」升级为「任意子集拼接」,需要回溯或子集 DP 来累积掩码 |
| 982. 按位与为零的三元组 | 困难 | 判定条件从两元变三元,靠预统计所有两两与值的频次把 $O(n^3)$ 降下来 |
| 1178. 猜字谜 | 困难 | 判定从「交集为空」变成「子集关系」,要枚举掩码的所有子集来做匹配统计 |
| 78. 子集 | 中等 | 直接用 0 到 $2^n-1$ 的整数枚举全部子集,是把整数当集合看的最基础练习 |
| 187. 重复的DNA序列 | 中等 | 每个碱基编成 2 位,用滑动窗口维护定长子串的整数编码,考的是位编码而非集合 |
| 421. 数组中两个数的最大异或值 | 中等 | 同样是两两配对求极值,但靠 01 字典树按位贪心把 $O(n^2)$ 优化到 $O(n)$ |
| 698. 划分为k个相等的子集 | 中等 | 用掩码表示「哪些元素已被选走」,是状态压缩 DP 的典型入口 |