本文属于「征服LeetCode」系列文章之一,这一系列正式开始于2021/08/12。由于LeetCode上部分题目有锁,本系列将至少持续到刷完所有无锁题之日为止;由于LeetCode还在不断地创建新题,本系列的终止日期可能是永远。在这一系列刷题文章中,我不仅会讲解多种解题思路及其优化,还会用多种编程语言实现题解,涉及到通用解法时更将归纳总结出相应的算法模板。
为了方便在PC上运行调试、分享代码文件,我还建立了相关的仓库:https://github.com/memcpy0/LeetCode-Conquest。在这一仓库中,你不仅可以看到LeetCode原题链接、题解代码、题解文章链接、同类题目归纳、通用解法总结等,还可以看到原题出现频率和相关企业等重要信息。如果有其他优选题解,还可以一同分享给他人。
由于本系列文章的内容随时可能发生更新变动,欢迎关注和收藏征服LeetCode系列文章目录一文以作备忘。
给你一个字符串数组 ideas
表示在公司命名过程中使用的名字列表。公司命名流程如下:
- 从
ideas
中选择 2 个 不同 名字,称为ideaA
和ideaB
。 - 交换
ideaA
和ideaB
的首字母。 - 如果得到的两个新名字 都 不在
ideas
中,那么ideaA ideaB
(串联ideaA
和ideaB
,中间用一个空格分隔)是一个有效的公司名字。 - 否则,不是一个有效的名字。
返回 不同 且有效的公司名字的数目。
示例 1:
输入:ideas = ["coffee","donuts","time","toffee"]
输出:6
解释:下面列出一些有效的选择方案:
- ("coffee", "donuts"):对应的公司名字是 "doffee conuts" 。
- ("donuts", "coffee"):对应的公司名字是 "conuts doffee" 。
- ("donuts", "time"):对应的公司名字是 "tonuts dime" 。
- ("donuts", "toffee"):对应的公司名字是 "tonuts doffee" 。
- ("time", "donuts"):对应的公司名字是 "dime tonuts" 。
- ("toffee", "donuts"):对应的公司名字是 "doffee tonuts" 。
因此,总共有 6 个不同的公司名字。
下面列出一些无效的选择方案:
- ("coffee", "time"):在原数组中存在交换后形成的名字 "toffee" 。
- ("time", "toffee"):在原数组中存在交换后形成的两个名字。
- ("coffee", "toffee"):在原数组中存在交换后形成的两个名字。
示例 2:
输入:ideas = ["lack","back"]
输出:0
解释:不存在有效的选择方案。因此,返回 0 。
提示:
2 <= ideas.length <= 5 * 10^4
1 <= ideas[i].length <= 10
ideas[i]
由小写英文字母组成ideas
中的所有字符串 互不相同
分析
什么样的一对字符串无法交换首字母?
示例 1 中的 c o f f e e coffee coffee 和 t i m e time time ,虽然这样两个字符串完全不一样,但如果交换了 c o f f e e coffee coffee 和 t i m e time time 的首字母,会得到字符串 t o f f e e toffee toffee ,它在数组 i d e a s ideas ideas 中,不符合题目要求。
又例如 i d e a s = [ a a , a b , a c , b c , b d , b e ] ideas=[aa,ab,ac,bc,bd,be] ideas=[aa,ab,ac,bc,bd,be] ,将其分成两组:
- 第一组: a a , a b , a c aa,ab,ac aa,ab,ac 。
- 第二组: b c , b d , b e bc,bd,be bc,bd,be 。
其中每一组内的字符串是不能交换首字母的,因为交换后字符串不变,必然在 i d e a s ideas ideas 中。
考虑交换第一组的字符串和第二组的字符串,哪些是可以交换首字母的,哪些是不能交换首字母的?
- 第一组的 a c ac ac 无法和第二组的任何字符串交换,因为交换后会得到 b c bc bc ,它在 i d e a s ideas ideas 中。
- 第二组的 b c bc bc 无法和第一组的任何字符串交换,因为交换后会得到 a c ac ac ,它在 i d e a s ideas ideas 中。
- 其余字符串对可以交换首字母。
上面的分析立刻引出了如下方法。
方法一:按照首字母分组,存储后缀
按照首字母,把 i d e a s ideas ideas 分成(至多) 26 26 26 组字符串。
例如 i d e a s = [ a a , a b , a c , b c , b d , b e ] ideas=[aa,ab,ac,bc,bd,be] ideas=[aa,ab,ac,bc,bd,be] 分成如下两组(只记录去掉首字母后的字符串):
- A A A 组(集合): a , b , c {a,b,c} a,b,c 。
- B B B 组(集合): c , d , e {c,d,e} c,d,e 。
分组后:
- 从 A A A 中选一个不等于 c c c 的字符串,这有 2 2 2 种选法。
- 从 B B B 中选一个不等于 c c c 的字符串,这有 2 2 2 种选法。
考虑两个字符串的先后顺序(谁在左谁在右),有 2 2 2 种方法。根据乘法原理,有 2 × 2 × 2 = 8 2×2×2=8 2×2×2=8 对符合要求的字符串。
由于无法选交集中的字符串,一般地,从
A
A
A 和
B
B
B 中可以选出
2
⋅
(
∣
A
∣
−
∣
A
∩
B
∣
)
⋅
(
∣
B
∣
−
∣
A
∩
B
∣
)
2⋅(∣A∣−∣A∩B∣)⋅(∣B∣−∣A∩B∣)
2⋅(∣A∣−∣A∩B∣)⋅(∣B∣−∣A∩B∣)
对符合要求的字符串。其中
∣
S
∣
∣S∣
∣S∣ 表示集合
S
S
S 的大小。
枚举所有组对,计算上式,累加到答案中。
class Solution:
def distinctNames(self, ideas: List[str]) -> int:
groups = defaultdict(set)
for s in ideas:
groups[s[0]].add(s[1:]) # 按照首字母分组
ans = 0
for a, b in combinations(groups.values(), 2): # 枚举所有组对
m = len(a & b) # 交集的大小
ans += (len(a) - m) * (len(b) - m)
return ans * 2 # 乘 2 放到最后
use std::collections::HashSet;
impl Solution {
pub fn distinct_names(ideas: Vec<String>) -> i64 {
let mut groups = vec![HashSet::new(); 26];
for s in ideas { // 按首字母分组,保存后缀
groups[(s.as_bytes()[0] - b'a') as usize].insert(s[1..].to_string());
}
let mut ans = 0i64;
for a in 1..26 { // 枚举所有组对
for b in 0..a {
let m = groups[a].iter().filter(|&s| groups[b].contains(s)).count();
ans += (groups[a].len() - m) as i64 * (groups[b].len() - m) as i64;
}
}
ans * 2 // 乘2放到最后
}
}
class Solution {
public:
long long distinctNames(vector<string>& ideas) {
unordered_set<string> groups[26];
for (auto &s : ideas) {
groups[s[0] - 'a'].insert(s.substr(1)); // 按首字母分组,保存后缀
}
long long ans = 0;
for (int a = 1; a < 26; ++a) { // 枚举所有组对
for (int b = 0; b < a; ++b) {
int m = 0; // 交集大小
for (auto &s : groups[a]) m += groups[b].count(s);
ans += (long long) (groups[a].size() - m) * (groups[b].size() - m);
}
}
return ans * 2; // 乘2放到最后
}
};
class Solution {
public long distinctNames(String[] ideas) {
Set<String>[] groups = new HashSet[26];
Arrays.setAll(groups, i -> new HashSet<>());
for (String s : ideas) {
groups[s.charAt(0) - 'a'].add(s.substring(1)); // 按照首字母分组
}
long ans = 0;
for (int a = 1; a < 26; a++) { // 枚举所有组对
for (int b = 0; b < a; b++) {
int m = 0; // 交集的大小
for (String s : groups[a]) {
if (groups[b].contains(s)) {
m++;
}
}
ans += (long) (groups[a].size() - m) * (groups[b].size() - m);
}
}
return ans * 2; // 乘 2 放到最后
}
}
func distinctNames(ideas []string) (ans int64) {
group := [26]map[string]bool{}
for i := range group {
group[i] = map[string]bool{}
}
for _, s := range ideas {
group[s[0]-'a'][s[1:]] = true // 按照首字母分组
}
for i, a := range group { // 枚举所有组对
for _, b := range group[:i] {
m := 0 // 交集的大小
for s := range a {
if b[s] {
m++
}
}
ans += int64(len(a)-m) * int64(len(b)-m)
}
}
return ans * 2 // 乘 2 放到最后
}
var distinctNames = function(ideas) {
const groups = Array.from({ length: 26 }, () => new Set());
for (const s of ideas) {
groups[s.charCodeAt(0) - 'a'.charCodeAt(0)].add(s.slice(1)); // 按照首字母分组
}
let ans = 0;
for (let a = 1; a < 26; a++) { // 枚举所有组对
for (let b = 0; b < a; b++) {
let m = 0; // 交集的大小
for (const s of groups[a]) {
if (groups[b].has(s)) {
m++;
}
}
ans += (groups[a].size - m) * (groups[b].size - m);
}
}
return ans * 2; // 乘 2 放到最后
};
复杂度分析:
- 时间复杂度: O ( n m ∣ Σ ∣ ) O(nm|Σ|) O(nm∣Σ∣) ,其中 n n n 为 i d e a s ideas ideas 的长度, m ≤ 10 m≤10 m≤10 为单个字符串的长度, ∣ Σ ∣ = 26 |Σ|=26 ∣Σ∣=26 是字符集合的大小。注意枚举组对的逻辑看上去是 O ( n m ∣ Σ ∣ 2 ) O(nm |Σ|^2) O(nm∣Σ∣2) 的,但去掉第二层 O ( ∣ Σ ∣ ) O(|Σ|) O(∣Σ∣) 的循环后,剩余循环相当于把 i d e a s ideas ideas 遍历了一遍,是 O ( n m ) O(nm) O(nm) ,所以总的时间复杂度是 O ( n m ∣ Σ ∣ ) O(nm |Σ|) O(nm∣Σ∣) 。
- 空间复杂度: O ( n m + ∣ Σ ∣ ) O(nm+ |Σ|) O(nm+∣Σ∣) 。
方法二:按照后缀分组,计算交集
公式还是同样的:
2
⋅
(
∣
A
∣
−
∣
A
∩
B
∣
)
⋅
(
∣
B
∣
−
∣
A
∩
B
∣
)
2⋅(∣A∣−∣A∩B∣)⋅(∣B∣−∣A∩B∣)
2⋅(∣A∣−∣A∩B∣)⋅(∣B∣−∣A∩B∣)
下文把去掉首字母后的剩余部分称作后缀。横看成岭侧成峰,换一个角度计算交集大小
∣
A
∩
B
∣
∣A∩B∣
∣A∩B∣ 。
在遍历 i d e a s = [ a a , a b , a c , b c , b d , b e ] ideas=[aa,ab,ac,bc,bd,be] ideas=[aa,ab,ac,bc,bd,be] 的过程中,当我们遍历到 b c bc bc 时,发现之前遍历过一个后缀也为 c c c 的字符串 a c ac ac ,这就对交集大小 ∣ A ∩ B ∣ ∣A∩B∣ ∣A∩B∣ 产生了 1 1 1 的贡献,也就是交集大小 ∣ A ∩ B ∣ ∣A∩B∣ ∣A∩B∣ 增加 1 1 1 。
具体来说,在遍历 i d e a s ideas ideas 的过程中,维护如下信息:
- (所有首字母开头的单词的)集合大小 s i z e [ a ] size[a] size[a] 。遍历到 s = i d e a s [ i ] s=ideas[i] s=ideas[i] 时,把 s i z e [ s [ 0 ] ] size[s[0]] size[s[0]] 加一。
- 交集大小 i n t e r s e c t i o n [ a ] [ b ] intersection[a][b] intersection[a][b] 。遍历到 s = i d e a s [ i ] s=ideas[i] s=ideas[i] 时,设 b = s [ 0 ] b=s[0] b=s[0] ,把 i n t e r s e c t i o n [ a ] [ b ] intersection[a][b] intersection[a][b] 和 i n t e r s e c t i o n [ b ] [ a ] intersection[b][a] intersection[b][a] 加一,其中 a a a 是和 s s s 同后缀的其他字符串的首字母。
- 为了快速知道前面有哪些字符串和 s s s 有着相同的后缀,用一个哈希表 g r o u p s groups groups 维护, k e y key key 是后缀, v a l u e value value 是后缀对应的首字母列表。注意题目保证所有字符串互不相同。
代码实现时,可以用把哈希表中维护的首字母列表压缩成一个二进制数。
class Solution:
def distinctNames(self, ideas: List[str]) -> int:
size = [0] * 26 # 各个集合的大小
intersections = [[0] * 26 for _ in range(26)] # 交集大小
groups = defaultdict(list) # 后缀 -> 首字母列表
for s in ideas:
b = ord(s[0]) - ord('a')
size[b] += 1 # 增加集合大小
g = groups[s[1:]]
for a in g: # a是和s有着相同后缀的字符串的首字母
intersections[a][b] += 1 # 增加交集大小
intersections[b][a] += 1
g.append(b)
ans = 0
for a in range(1, 26): # 枚举所有组对
for b in range(a):
m = intersections[a][b]
ans += (size[a] - m) * (size[b] - m)
return ans * 2 # 乘2放到最后
use std::collections::HashMap;
impl Solution {
pub fn distinct_names(ideas: Vec<String>) -> i64 {
let mut size = vec![0; 26]; // 集合大小
let mut intersection = vec![vec![0; 26]; 26]; // 交集大小
let mut groups = HashMap::new(); // 后缀 -> 首字母列表
for s in ideas {
let b = (s.as_bytes()[0] - b'a') as usize;
size[b] += 1; // 增加集合大小
let suffix = &s[1..];
let mask = *groups.get(suffix).unwrap_or(&0);
groups.insert(suffix.to_string(), mask | 1 << b); // 把b加到mask中
for a in 0..26 { // a 是和 s 有着相同后缀的首字母
if (mask >> a & 1) > 0 { // a 在 mask 中
intersection[b][a] += 1; // 增加交集大小
intersection[a][b] += 1; // 增加交集大小
}
}
}
let mut ans = 0i64;
for a in 1..26 { // 枚举所有组对
for b in 0..a {
let m = intersection[a][b];
ans += (size[a] - m) as i64 * (size[b] - m) as i64;
}
}
ans * 2 // 乘 2 放到最后
}
}
/**
* @param {string[]} ideas
* @return {number}
*/
var distinctNames = function(ideas) {
const size = Array(26).fill(0); // 集合大小
const intersection = Array.from({ length: 26 }, () => Array(26).fill(0)); // 交集大小
const groups = {}; // 后缀 -> 首字母
for (let s of ideas) {
const b = s.charCodeAt(0) - 'a'.charCodeAt(0);
size[b]++; // 增加集合大小
const suffix = s.slice(1); // 后缀
const mask = groups[suffix] ?? 0;
groups[suffix] = mask | 1 << b; // 把 b 加到 mask 中
for (let a = 0; a < 26; a++) { // a 是和 s 有着相同后缀的字符串的首字母
if (mask >> a & 1) { // a 在 mask 中
intersection[b][a]++; // 增加交集大小
intersection[a][b]++;
}
}
}
let ans = 0;
for (let a = 1; a < 26; a++) { // 枚举所有组对
for (let b = 0; b < a; b++) {
const m = intersection[a][b];
ans += (size[a] - m) * (size[b] - m);
}
}
return ans * 2; // 乘 2 放到最后
};
func distinctNames(ideas []string) (ans int64) {
size := [26]int{} // 集合大小
intersection := [26][26]int{} // 交集大小
groups := map[string]int{} // 后缀 -> 首字母
for _, s := range ideas {
b := s[0] - 'a'
size[b]++ // 增加集合大小
suffix := s[1:]
mask := groups[suffix]
groups[suffix] = mask | 1<<b // 把 b 加到 mask 中
for a := 0; a < 26; a++ { // a 是和 s 有着相同后缀的字符串的首字母
if mask>>a&1 > 0 { // a 在 mask 中
intersection[b][a]++ // 增加交集大小
intersection[a][b]++
}
}
}
for a := 1; a < 26; a++ { // 枚举所有组对
for b := 0; b < a; b++ {
m := intersection[a][b]
ans += int64(size[a]-m) * int64(size[b]-m)
}
}
return ans * 2 // 乘 2 放到最后
}
class Solution {
public:
long long distinctNames(vector<string>& ideas) {
int size[26]{}; // 集合大小
int intersection[26][26]{}; // 交集大小
unordered_map<string, int> groups; // 后缀 -> 首字母
for (auto &s : ideas) {
int b = s[0] - 'a';
size[b]++; // 增加集合大小
auto suffix = s.substr(1);
int mask = groups[suffix];
groups[suffix] = mask | 1 << b; // 把 b 加到 mask 中
for (int a = 0; a < 26; ++a) { // a 是和 s 有着相同后缀的字符串的首字母
if (mask >> a & 1) { // a 在 mask 中
intersection[b][a]++; // 增加交集大小
intersection[a][b]++;
}
}
}
long long ans = 0;
for (int a = 1; a < 26; a++) { // 枚举所有组对
for (int b = 0; b < a; b++) {
int m = intersection[a][b];
ans += (long long) (size[a] - m) * (size[b] - m);
}
}
return ans * 2; // 乘 2 放到最后
}
};
class Solution {
public long distinctNames(String[] ideas) {
int[] size = new int[26]; // 集合大小
int[][] intersection = new int[26][26]; // 交集大小
Map<String, Integer> groups = new HashMap<>(); // 后缀 -> 首字母
for (String s : ideas) {
int b = s.charAt(0) - 'a';
size[b]++; // 增加集合大小
String suffix = s.substring(1);
int mask = groups.getOrDefault(suffix, 0);
groups.put(suffix, mask | 1 << b); // 把 b 加到 mask 中
for (int a = 0; a < 26; a++) { // a 是和 s 有着相同后缀的字符串的首字母
if ((mask >> a & 1) > 0) { // a 在 mask 中
intersection[b][a]++; // 增加交集大小
intersection[a][b]++;
}
}
}
long ans = 0;
for (int a = 1; a < 26; a++) { // 枚举所有组对
for (int b = 0; b < a; b++) {
int m = intersection[a][b];
ans += (long) (size[a] - m) * (size[b] - m);
}
}
return ans * 2; // 乘 2 放到最后
}
}
复杂度分析:
- 时间复杂度: O ( n ( m + ∣ Σ ∣ ) + ∣ Σ ∣ 2 ) O(n(m+ |Σ|)+ |Σ|^2) O(n(m+∣Σ∣)+∣Σ∣2) ,其中 n n n 为 i d e a s ideas ideas 的长度, m ≤ 10 m≤10 m≤10 为单个字符串的长度, ∣ Σ ∣ = 26 |Σ|=26 ∣Σ∣=26 是字符集合的大小。
- 空间复杂度: O ( n m + ∣ Σ ∣ 2 ) O(nm+|Σ|^2) O(nm+∣Σ∣2) 。