一、题目本质:这是什么问题?
这道题的核心是 "在有序序列中,按顺序找到满足特定统计条件的元素"。
- 有序序列:字符串的字符顺序是固定的。
- 特定统计条件:字符出现的次数等于1。
- 按顺序找:必须是从左往右第一个满足条件的。
关键矛盾 :统计频率需要遍历整个字符串,但找第一个又需要顺序。这就引出了经典的两遍遍历法。
二、解题思路的诞生过程(像侦探破案)
想象你是侦探,要在人群中找到第一个只出现一次的人。
-
第一次调查(建立档案):
- 你拿着笔记本(哈希表),从头到尾问每个人:"你叫什么?"
- 每次听到一个名字,就在笔记本上给这个名字做标记(计数+1)。
- 调查结束后,你知道每个名字出现了几次。
-
第二次调查(按顺序指认):
- 你按照最初见面的顺序,再次一个个看这些人。
- 每看一个人,就查笔记本上他的名字出现了几次。
- 当查到第一个人"只出现一次"时,立刻抓住他(返回结果)。
- 如果查到结尾都没找到,就报告"找不到"(返回-1)。
为什么必须两遍?
- 如果只用一遍:当你第一次遇到"张三"时,你不知道后面还会不会出现第二个"张三"。只有等整个调查结束,你才知道每个人总共出现了几次。
- 如果只用第二遍:你没有笔记本,无法知道每个人总共出现几次。
三、代码逐行解析(带面试话术)
python
def firstUniqChar(s: str) -> int:
# 步骤1:构建"档案本"(哈希表统计频率)
freq = {} # 创建一个空字典,键是字符,值是出现次数
for char in s: # 第一遍遍历:每个字符都登记
# 如果char已经在字典里,get返回当前值;如果不在,返回0
# 然后 +1,这样新字符就从1开始计数,旧字符就累加
freq[char] = freq.get(char, 0) + 1
# 此时,freq字典的样子示例:s="leetcode"
# {'l':1, 'e':3, 't':1, 'c':1, 'o':1, 'd':1}
# 步骤2:按顺序"指认"
for i in range(len(s)):
if freq[s[i]] == 1: # 如果该字符只出现一次
return i # 返回其索引
# 如果循环结束都没返回,说明没有符合条件的字符
return -1
1、firstUniqChar(s: str) -> int
这是 Python 的类型注解(Type
Hints)。它不是代码执行逻辑的一部分,而是像"注释"或"标签"一样,用来告诉阅读代码的人这个函数期望接收什么类型的参数,以及返回什么类型的值。
参数注解s: str
s:这是函数的参数名。
: str:冒号后面的 str 是一个类型提示,意思是参数 s 期望是一个字符串类型。
返回值注解:-> int
->:这个箭头指示了函数的返回类型。
int:表示这个函数预期会返回一个整数。
返回什么类型通常是看题目要求,你可以这样判断返回类型:

2、 freq.get(char, 0) 的工作原理
- 查找键:在字典 freq 中查找键 char
- 如果存在:返回对应的值
- 如果不存在:返回第二个参数指定的默认值(这里是 0)
bash
freq = {} # 空字典
# 第一次遇到字符 'a'
char = 'a'
current_value = freq.get('a', 0) # 返回 0(因为'a'不在字典中)
freq['a'] = 0 + 1 # 现在 freq = {'a': 1}
# 第二次遇到字符 'a'
char = 'a'
current_value = freq.get('a', 0) # 返回 1(因为'a'已在字典中,值为1)
freq['a'] = 1 + 1 # 现在 freq = {'a': 2}
- 为什么使用
.get()而不是直接访问?
如果你尝试直接访问不存在的键,会报错:
bash
# 错误写法
freq = {}
freq['a'] = freq['a'] + 1 # KeyError: 'a'(第一次访问,'a'不存在)
get()方法的语法
bash
字典.get(键, 默认值)
- 如果键存在:返回该键对应的值
- 如果键不存在:返回指定的默认值(这里是0)
- 键不存在时返回 0 是专门为 计数统计 场景设计的:
- 如果字符已经在字典中(之前出现过),就返回它当前的计数(大于等于1)。
- 如果字符不在字典中,就返回0,然后我们加1,表示这是第一次出现。
3、freq[s[i]]可以拆解为:
i:当前循环的索引(数字)s[i]:字符串s中第i个位置的字符freq[字符]:在字典freq中查找这个字符对应的出现次数
在 Python 的 for 循环中,循环变量 不需要提前定义。当你写:
for i in range(len(s)):Python 会自动:
创建变量 i
在每次循环时给 i 赋值(0, 1, 2, ...)
循环结束后,i 会保留最后一次的值(但通常我们不依赖这个)
4、为什么不用 for char in s?
因为题目要求返回索引,而不是字符:
for char in s:只能得到字符,不知道位置for index, char in enumerate(s):同时得到位置和字符
5、区分这些变量:
让我们想象一个场景:你拿着一张购物清单(这就是s),正在超市里一个个地拿商品(每次拿起的那个商品就是char)。
s=你的购物清单(一张纸条,上面写着一串文字)。
- 例如:s="leo",你的清单上写着"leo"。
- s是这个字符串整体的名字(变量名)。你一提到S,指的就是"leo"这整个字符 char=你当前正从清单上看着的、准备去拿的那一个商品。
char = 你当前正从清单上看着的、准备去拿的那一个商品。
- 当你开始购物(开始循环),你的手指会从清单的第一个字母开始移动。
- 第一次循环:char = 'l' (你看到了"苹果")
- 第二次循环:char = 'e' (你看到了"牛奶")
- 第三次循环:char = 'o' (你看到了"面包")
- char 是你在循环过程中,用来临时存放当前单个字符的变量名。
- s 是完整的、不变的容器。循环不会改变 s 本身。
- char 是临时的、变化的代表。它依次成为 s 中的每一个元素。char 是字符本身('a', 'b', 'c', ...)
bash
for char in s: # 直接遍历字符串中的字符
# char 是字符本身
# 比如 s = "hello" 时:
# 第一次循环:char = 'h'
# 第二次循环:char = 'e'
# ...
- i 是索引数字(0, 1, 2, ...)
bash
for i in range(len(s)): # 遍历索引数字
# i 是索引位置(0, 1, 2, ...)
# 比如 s = "hello" 时:
# 第一次循环:i = 0, s[i] = 'h'
# 第二次循环:i = 1, s[i] = 'e'
# ...
- s[i] 是通过索引获取字符
四、复杂度分析(面试必说)
- 时间复杂度:O(n)
- 第一次遍历:O(n)
- 第二次遍历:O(n)
- 总时间:O(2n) = O(n) (常数系数可以忽略)
- 空间复杂度:O(1) 或 O(k)
- 字典最多存储字符集大小
- 如果是英文字母:最多26*2=52个,是常数,所以是O(1)
- 如果是Unicode:可能很多,但通常也视为O(1)
五、测试用例设计(展现测试思维)
面试时写完代码一定要说:"我考虑几个测试用例验证一下..."
| 测试用例 | 输入 | 预期输出 | 测试目的 |
|---|---|---|---|
| 正常情况 | "leetcode" |
0 (字符'l') |
基本功能验证 |
| 唯一字符在中间 | "loveleetcode" |
2 (字符'v') |
验证按顺序查找 |
| 没有唯一字符 | "aabb" |
-1 |
边界情况处理 |
| 空字符串 | "" |
-1 |
极端边界 |
| 全部重复 | "zzzz" |
-1 |
全重复场景 |
| 单字符 | "a" |
0 |
最小输入 |
| 大小写敏感 | "sTreSS" |
取决于题目要求 | 验证是否区分大小写 |
六、力扣实战:第387题
- 题目链接 :387. 字符串中的第一个唯一字符
- 难度:简单 ⭐
- 建议练习步骤 :
- 先理解 :读完题目,确保理解是找第一个不重复的字符。
- 手写思路:在白纸上画一下两遍遍历的过程。
- 尝试写代码:参考上面的代码,但尝试自己写。
- 提交测试:用上面的测试用例验证。
- 查看题解:如果卡住,看官方题解,但重点理解思路。
七、面试实战话术模板
当面试官出这道题时,你可以这样回答:
- 复述题目:"好的,这道题是要在字符串中找到第一个只出现一次的字符,并返回它的索引,对吧?"
- 举例说明 :"比如输入
"leetcode",字符'l'只出现一次且是第一个,应该返回索引0。" - 阐述思路 :"我的思路是两次遍历。第一次用哈希表统计每个字符的频率,第二次按顺序遍历,找到第一个频率为1的字符。"
- 分析复杂度:"时间复杂度是O(n),空间复杂度是O(字符集大小),可以看作O(1)。"
- 写代码:边写边解释关键行。
- 主动测试 :"写完了,我用几个例子验证一下:
"leetcode"返回0,"aabb"返回-1,空字符串返回-1,都符合预期。"
八、常见陷阱与改进
- 陷阱1 :用
list.count()方法对每个字符计数 → 时间复杂度O(n²),太慢。 - 陷阱2:只遍历一次,用复杂的数据结构记录位置 → 可以但没必要,两遍遍历最简单清晰。
- 改进:
bash
def firstUniqChar(s: str) -> int: # 这里的s是函数参数名,代表传入的整个字符串
freq = {}
# 第一次遍历:统计频率
for char in s: # 遍历整个字符串s,每次循环char代表一个字符
# char 会依次是: 'l', 'e', 'e', 't', 'c', 'o', 'd', 'e'
freq[char] = freq.get(char, 0) + 1
# 第二次遍历:查找第一个唯一字符
for index, char in enumerate(s): # 同样遍历s,char依然依次代表每个字符
if freq[char] == 1: # 检查当前这个字符(char)的频率
return index
return -1
for index, char in enumerate(s):
是 Python 中一个非常常用的循环语法。 这行代码的意思是:遍历字符串 s,同时获取每个字符的索引(位置)和字符本身。
3.1 enumerate(s) 的作用
enumerate() 是 Python 内置构造函数,它会给可迭代对象(如字符串、列表)的每个元素添加一个计数器(索引)。
bash
# 示例
s = "abc"
list(enumerate(s)) # 返回:[(0, 'a'), (1, 'b'), (2, 'c')]
3.2 for index, char in ... 的作用
这是元组解包(tuple unpacking):
- enumerate(s) 每次产生一个元组,如 (0, 'a')
- Python 自动将这个元组拆分成两部分:
- index 接收第一个值(索引)
- char 接收第二个值(字符)
-
改进 :如果字符集只有小写字母,可以用长度为26的数组代替字典,更高效:
pythondef firstUniqChar(s: str) -> int: count = [0] * 26 # 26个小写字母的计数器 for c in s: count[ord(c) - ord('a')] += 1 for i, c in enumerate(s): if count[ord(c) - ord('a')] == 1: return i return -1
九、举一反三
掌握这道题后,你可以解决一系列类似问题:
- 变体1:最后一个不重复字符(倒序遍历)
- 变体2:第一个重复字符(频率>1)
- 变体3:所有不重复字符(遍历字典,按值过滤)
- 相关题 :
- 剑指 Offer 50:第一个只出现一次的字符
- 其他涉及哈希表计数的题目
十、今日任务清单
- ✅ 理解两遍遍历的必要性
- ✅ 记住代码模板
- ✅ 设计测试用例
- ⬜ 去力扣387题提交代码(至少AC一次)
- ⬜ 口头练习面试话术3遍
记住 :这道题是哈希表最基础的应用,掌握它就打开了算法的一扇门。不要背代码,要理解为什么必须两遍遍历------这是面试官真正想听的。