Leetcode 49 用 "身份证''巧解
-
- 第一反应
- 真正的关键:给每个字符串生成一张"身份证"
- 那这张"身份证"怎么做?
- [中间我还想过:能不能用 set?](#中间我还想过:能不能用 set?)
- 接下来才真正轮到哈希表
- [但这里又出现了一个问题:list 不能当字典的 key](#但这里又出现了一个问题:list 不能当字典的 key)
- 我第一次写出来的代码也有几个小错误
- 最后返回
- 最后得到的代码
- 还能继续改进吗?
-
- defaultdict
- 排序
- [为什么 sorted() 可以直接排字母?](#为什么 sorted() 可以直接排字母?)
- [为什么还要用 join()?](#为什么还要用 join()?)
- 后面还是哈希表分组
- 完整代码
- 排序法和计数法有什么区别?
第一反应
字母异位词不看顺序,只看两件事:
text
包含哪些字母
每个字母分别出现了几次
比如:
text
eat
tea
ate
虽然顺序完全不一样,但它们都是:
text
a:1
e:1
t:1
所以显然应该被分到一起。
问题是,怎么分?
我最开始想的是:拿一个字符串当"母本"
比如:
text
["eat", "tea", "tan", "ate", "nat", "bat"]
先拿:
text
eat
然后一个个和后面的字符串比较:
text
eat vs tea
eat vs tan
eat vs ate
...
如果发现:
text
tea
ate
和它是字母异位词,就把它们放进同一个列表。
这个思路乍看没问题。
但很快就出现一个让我很烦的问题:
text
eat 已经和 tea 匹配了
eat 又和 ate 匹配了
那之后遍历到 tea 的时候怎么办?
要不要跳过?
怎么知道它已经被分过组?
如果继续这样想,很容易变成:
text
字符串之间两两比较
+
还要记录哪些字符串已经处理过
越写越麻烦。
后来才发现,问题其实出在一开始的方向上。
根本没有必要让字符串之间互相比。
真正的关键:给每个字符串生成一张"身份证"
既然字母异位词的本质是:
text
每个字母出现的次数完全相同
那就可以把这个统计结果直接当成它的"身份证"。
比如:
text
eat
对应:
text
a:1
e:1
t:1
其他字母:0
而:
text
tea
得到的统计结果完全一样。
这样事情就简单了:
text
身份证一样 → 放进同一组
身份证不一样 → 新建一组
这样就完全不需要考虑:
text
谁和谁匹配
谁已经匹配过
下一次要不要跳过
这些问题直接消失了。
那这张"身份证"怎么做?
因为题目已经限定:
text
字符串只包含小写字母
所以一共就:
text
26 个字母
可以直接准备一个长度为 26 的列表:
python
count = [0] * 26
也就是:
text
下标:
0 1 2 3 4 ... 25
对应:
a b c d e ... z
这里我一开始又卡了一下:
字符是 a、e、t,我要怎么找到它们对应的下标?
后来想起来 Python 有 函数可以 把小写字符转为数字:
text
ord()
例如:
python
ord('a') == 97
ord('b') == 98
所以:
python
ord(ch) - ord('a')
正好可以把:
text
a → 0
b → 1
c → 2
...
z → 25
于是:
python
for ch in word:
count[ord(ch) - ord('a')] += 1
例如 "eat":
text
e → count[4] += 1
a → count[0] += 1
t → count[19] += 1
最后就得到这整个字符串的字符统计。
中间我还想过:能不能用 set?
这个也不行。
因为 set 只能告诉我们:
text
有什么字母
但不能告诉我们:
text
每个字母有几个
比如:
text
abb
ab
转成 set:
python
set("abb") == {'a', 'b'}
set("ab") == {'a', 'b'}
看起来一样。
但它们显然不是字母异位词。
所以这里必须保存:
text
字符出现次数
不能只保存"字符是否出现过"。
接下来才真正轮到哈希表
这时候我们需要一个字典:
python
groups = {}
它的结构可以理解成:
text
key
→ 字符统计结果,也就是"身份证"
value
→ 所有拥有这张身份证的字符串
例如最后可能是:
text
身份证 A → ["eat", "tea", "ate"]
身份证 B → ["tan", "nat"]
身份证 C → ["bat"]
这样每来一个字符串:
text
字符串
↓
统计 26 个字母
↓
生成身份证
↓
去 groups 里找
如果这个身份证已经存在:
text
直接 append 到原来的组
如果不存在:
text
创建一个新的列表
这就比"字符串之间互相比"干净很多。
但这里又出现了一个问题:list 不能当字典的 key
我们统计出来的:
text
count
是一个列表,例如:
text
[1, 0, 0, 0, 1, ...]
但 Python 的列表不能直接作为字典的 key。
原因简单理解就是:
text
list 可以被修改
而字典的 key 需要保持稳定。
所以这里把它转成元组:
python
key = tuple(count)
元组和列表很像:
python
[1, 2, 3] # list
(1, 2, 3) # tuple
但是 tuple 创建以后不能修改,因此可以作为字典的 key。
这题里其实不用把元组想得特别复杂,只需要记住:
text
count 列表
↓
tuple(count)
↓
变成可以作为哈希表 key 的身份证
就够了。
我第一次写出来的代码也有几个小错误
我当时写的是类似:
python
count[ord[i] - 97] += 1
但 ord 是函数,所以应该用:
python
ord(i)
而且比起直接记 97,更推荐:
python
ord(i) - ord('a')
这样一眼就知道是在算:
text
当前字符 - a = 0~25 的下标
最后返回
我一开始也写成了取某一个元素。
但我们真正需要的不是某一个分组,而是 字典里所有的分组。
前面的 groups 大概会长成这样:
python
groups = {
key1: ["eat", "tea", "ate"],
key2: ["tan", "nat"],
key3: ["bat"]
}
题目最终需要的是:
text
[
["eat", "tea", "ate"],
["tan", "nat"],
["bat"]
]
也就是说:
我们只需要字典里的所有 value,不再需要 key。
Python 字典可以通过:
python
groups.values()
取出所有 value。
不过 values() 返回的不是普通的 list,而是一个字典的视图对象:
text
dict_values([['eat', 'tea', 'ate'], ['tan', 'nat'], ['bat']])
所以如果想让它变成题目要求的列表形式,还要再套一层:
python
list(...)
于是, 最后使用这个用于返回值:
python
return list(groups.values())
就是:
text
先取出 groups 中所有 value
↓
再把这些 value 转成 list
还有其他类似的字典操作:
python
groups.keys()
取所有 key,
python
groups.items()
取 key-value 对。
最后得到的代码
python
class Solution:
def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
groups = {}
for word in strs:
count = [0] * 26
for ch in word:
count[ord(ch) - ord('a')] += 1
key = tuple(count)
if key not in groups:
groups[key] = [word]
else:
groups[key].append(word)
return list(groups.values())
整个过程其实就是:
text
遍历字符串
↓
统计 26 个字母出现次数
↓
把统计结果作为"身份证"
↓
身份证相同的字符串自动进入同一组
↓
最后取出所有组
还能继续改进吗?
可以,不过现在已经不是"算法思路有问题"了,而是可以让代码更简洁。
defaultdict
Python 里可以使用:
python
defaultdict(list)
这样就不用每次手动判断:
python
if key not in groups:
可以直接写:
python
from collections import defaultdict
class Solution:
def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
groups = defaultdict(list)
for word in strs:
count = [0] * 26
for ch in word:
count[ord(ch) - ord('a')] += 1
key = tuple(count)
groups[key].append(word)
return list(groups.values())
因为 defaultdict(list) 的意思就是:
text
这个 key 已经有列表
→ 直接用
这个 key 还没有
→ 自动帮我创建一个空列表
所以:
python
groups[key].append(word)
一行就把两种情况都处理了。
排序
排序法只是换了一种生成 key 的方式,后面的哈希表分组逻辑完全一样。
比如:
text
eat → aet
tea → aet
ate → aet
排序后一样,就分到同一组。
会发现它们最后都会变成:
text
aet
所以:
排序后的字符串,也可以直接作为这一组异位词的"身份证"。
这样就不需要再统计 26 个字母出现的次数。
整个思路就变成:
text
原字符串
↓
把字符排序
↓
得到统一形式
↓
作为哈希表的 key
↓
相同 key 的字符串放进同一个列表
排序作为key"身份证" 的代码为:
python
key = ''.join(sorted(word))
为什么 sorted() 可以直接排字母?
Python 的 sorted() 不只可以排序数字,也可以排序字符。
例如:
python
sorted("tea")
会得到:
text
['a', 'e', 't']
因为字符本身也有对应的编码顺序,小写字母可以按照:
text
a < b < c < ... < z
进行排序。
所以:
python
sorted("eat")
sorted("tea")
sorted("ate")
最后都会得到:
text
['a', 'e', 't']
为什么还要用 join()?
sorted() 返回的是一个 列表:
text
['a', 'e', 't']
但我们希望得到的是一个完整字符串:
text
"aet"
所以要用:
python
''.join(...)
把列表里的字符重新拼起来。
例如:
python
''.join(['a', 'e', 't'])
得到:
text
'aet'
这里前面的:
text
''
表示:
用什么东西把这些字符连接起来。
现在是空字符串,所以字符会直接连在一起。
例如:
python
'-'.join(['a', 'e', 't'])
得到:
text
a-e-t
所以这一句:
python
key = ''.join(sorted(word))
其实可以拆成:
python
chars = sorted(word)
key = ''.join(chars)
例如:
python
word = "tea"
chars = sorted(word)
# ['a', 'e', 't']
key = ''.join(chars)
# "aet"
这样 "eat"、"tea"、"ate" 最后都会得到同一个:
python
key = "aet"
后面还是哈希表分组
这一点其实和前面的计数法完全一样。
例如:
python
groups = {
"aet": ["eat", "tea", "ate"],
"ant": ["tan", "nat"],
"abt": ["bat"]
}
这里:
text
key
→ 排序后的字符串
value
→ 属于这一组的原字符串
所以每来一个字符串:
text
字符串
↓
排序生成 key
↓
去 groups 中找这个 key
↓
存在 → 加入原来的列表
不存在 → 新建一组
完整代码
python
from collections import defaultdict
class Solution:
def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
groups = defaultdict(list)
for word in strs:
key = ''.join(sorted(word))
groups[key].append(word)
return list(groups.values())
排序法和计数法有什么区别?
其实两种方法后半部分完全一样:
text
生成身份证
↓
作为哈希表 key
↓
相同 key 自动分组
区别只在于:
text
计数法:
身份证 = 26 个字母各自出现了多少次
排序法:
身份证 = 排序后的字符串
不过它们的 时间复杂度 稍微不同。
假设一个字符串长度为 k。
排序法需要排序:
text
O(k log k)
而计数法只需要扫描一次字符:
text
O(k)
所以从效率上看,计数法通常更好。
但排序法的优点也很明显:
非常直观。
因为:
text
异位词
↓
排序以后一定完全一样
这个思路很容易理解