在LeetCode做简单题的第5天
前言
时隔快一周再次打开LeetCode做题,今天是一道字符串简单题——有效的字母异位词。我保留了思路过程、编码习惯上的错误纠正,接着又学习了 collections.Counter ,Python内置模块的一个工具,最后弄懂了基于 ord() 的数组最优解法,因为休息了很长时间,再做题时动力较大,我还用豆包顺着题目做了变种拓展,今日整理下来方便以后看看。
一、题目
给定两个字符串 s 和 t,编写一个函数来判断 t 是否是 s 的字母异位词。
字母异位词的含义:两个字符串的字母组成完全相同,只是字母排列顺序不一样。
题目原始限制:字符串只包含小写英文字母。
示例:
输入 s = "anagram", t = "nagaram",输出 True
输入 s = "rat", t = "car",输出 False
二、我的思路过程:字典计数
新建字典,遍历s统计每个字母出现次数;再遍历t,每遇到一个字母就把字典里对应的计数减一,如果减到0就删除这个键。如果t里出现字典不存在的字母,直接返回False。遍历结束后字典为空,说明全部抵消,返回True。
class Solution:
def isAnagram(self, s: str, t: str) -> bool:
dict_s = {}
for i in s:
try: dict_s[i] += 1
except KeyError: dict_s[i] = 1
for j in t:
try:
dict_s[j] -= 1
if dict_s[j] == 0:
del dict_s[j]
except KeyError:
return False
if dict_s == {}:
return True
return False
代码可以通过测试用例,但必须优化的点有:
- 使用
try-except处理正常的键查找逻辑。异常捕获是用来处理意外错误的,不适合做常规判断,可读性差,开销也更大;可以改用dict.get()优雅实现计数。 - 没有提前判断两个字符串长度。异位词的前提一定是长度相等,长度不一样可以直接返回False,省去后面循环的开销。
- 计数归零就删除键不是必要操作,会增加字典删改的额外开销。
优化后的字典版本:
class Solution:
def isAnagram(self, s: str, t: str) -> bool:
if len(s) != len(t):
return False
count = {} # 不要用dict关键字起名字,改成count表示这是一个计数的字典
for char in s:
# 字典内置方法.get语法:.get(key, default),返回对应value或default,源自菜鸟教程
count[char] = count.get(char, 0) + 1
for char in t:
if char not in count:
return False
count[char] -= 1
if count[char] == 0:
del count[char]
return count == {}
三、一种使用内置模块的简洁写法:collections.Counter
Counter 可以直接统计字符串中每个字符的频次,两个Counter对象直接比较即可。
from collections import Counter
class Solution:
def isAnagram(self, s: str, t: str) -> bool:
return Counter(s) == Counter(t)
⚠️ 注意:Counter本质上还是哈希字典,底层依旧存在哈希计算、哈希冲突的开销。刷题写它很省事,但不是最优解法。
四、最优解法:利用固定大小的数组计数
这是本题在给定“仅小写字母”条件下的最优方案,也是今天我理解的重点。
ord(c) 的作用:返回字符对应的ASCII码。(扩展:相反作用的函数chr())
因为ord('a')=97、ord('z')=122,所以 ord(c)-ord('a') 可以把 a~z 映射成下标 0~25,我们开一个长度固定为26的数组,每个下标对应一个字母,用来存频次。
数组是直接下标访问,没有哈希计算的消耗,访问速度更快,空间复杂度是常数O(26),且不会随输入字符串的变大而变大。
class Solution:
def isAnagram(self, s: str, t: str) -> bool:
if len(s) != len(t):
return False
arr = [0] * 26
for c in s:
# a到z的ASCll码是连续的,对应arr连续的26的下标
arr[ord(c) - ord('a')] += 1
for c in t:
arr[ord(c) - ord('a')] -= 1
for num in arr:
if num != 0:
return False
return True
拓展变种:顺着思路举一反三
下面通过修改字符集,拓展和理解这个数组思路:
- 变种1:包含大小写字母,不区分大小写。全部转为小写,依旧使用长度26的数组。
- 变种2:区分大小写,A-Z和a-z是不同字符。一共52个字符,数组长度52。A-Z映射0到25,a-z映射26到51。
- 变种3:区分大小写 + 数字0~9。一共62个字符,数组长度62,数字映射到52到61。
ASCll码不连续的时候,用if判断句改一改下标的计算公式即可:
if c.isupper():
(缩进)idx = ord(c) - ord('A')
elif c.islower():
(缩进)idx = 26 + ord(c) - ord('a')
elif c.isdigit():
(缩进)idx = 52 + ord(c) - ord('0')
else:
总结:当字符集合固定、种类很少的时候(如本题),优先利用ord()、固定数组大小;当字符不确定、存在大量符号/Unicode字符,再改用哈希(字典/Counter)。
五、Counter工具简单扩展
Counter是Python内置模块collections中的工具之一,可以方便快捷地计数。
主要功能:接收一个可迭代对象(字符串、列表、元组),统计里面每个元素出现次数,存成 {元素:次数} 的字典。
与dict不同,读取不存在的key时并不会报错。
此外,不仅可以用dict的方法,还自带一些dict不支持的方法:
.most_common(n):拿到出现次数最多的 n 个元素.+-运算符:两个 Counter 可以加减计数(负次数会被自动删掉)
方法不全,仅记住第一个。
六、时间/空间复杂度
最优解——数组方法:
- 时间复杂度:O (n)
设n是两个字符串的长度(长度相等)
遍历 s:O (n),遍历 t:O (n),最后遍历长度固定 26 的数组:O (1)(常数,和输入规模无关)
总和:O (n + n + 26) = O(n) - 空间复杂度:O (1)(常数空间)
数组永远固定 26 个元素,不管输入字符串多长,占用空间不变,属于常数空间。
最短解——Counter工具:
- 时间复杂度:O (n)
Counter 内部也是遍历字符串统计,两个 Counter 比较也是逐个对比 key,整体依然是 O (n)。
✅ 渐进复杂度和数组版本一样
⚠️ 但是常数更大:哈希计算、哈希冲突处理、哈希表扩容、字典对象本身开销,每一步都有额外工作。
也就是:同是 O (n),数组跑起来更快。 - 空间复杂度:O (k)
k 是字符串中不同字符的数量。本题最多 26 个小写字母,最坏是 O (26),也就是这个场景下也可以说成 O (1);
但它的本质是随字符种类变化的哈希表:如果字符集很大(比如大量 Unicode 字符),k 会跟着输入变大,这时空间就不再是常数。
对比数组:数组是强制固定 26,绝对常数。
七、今日总结
- 异常捕获不要拿来做常规判断,
dict.get()是字典计数更好的选择; - 异位词第一步先判断长度,这是很容易漏掉的优化;
- Counter底层是哈希表,有性能开销;
- ord() 可以把字符映射成数组下标,固定字符集场景下数组是最优解;
- 当字符包含大小写、数字,还有
!@#$%^&*这类零散符号时,需要手动维护一张字符到下标的映射表,if 判断会变得非常冗长,后续新增符号还要修改映射。这种场景下,使用 Counter 是更合适的选择。 - Counter 不需要关心字符的 ASCII 是否连续,任意字符都可以直接统计频次。
- Counter 的特点:读取不存在的键时返回 0 而不是抛出 KeyError,支持 most_common、加减计数等便捷接口。
- Counter 的时间复杂度是 O (n),与数组,只是相比固定数组存在哈希计算的额外常数开销。
5到8点总结出一条更精准的选型标准:
字符集合固定、ASCII 连续 → 数组(最优性能)
字符种类杂乱、ASCII 离散、不确定 → 哈希表 / Counter(优先开发效率)
浙公网安备 33010602011771号