引言
在字符串处理问题中,寻找第一个不重复的字符是一个经典面试题。这类问题不仅考察对字符串处理的基本功,还考察对数据结构和算法的灵活应用。本文将详细分析两种不同的解法,并探讨它们的优缺点。
目录
[解法一:两次遍历 + 固定数组(哈希表)](#解法一:两次遍历 + 固定数组(哈希表))
[1. 使用位运算优化空间](#1. 使用位运算优化空间)
[2. 同时记录索引和频率](#2. 同时记录索引和频率)
[1. Unicode字符支持](#1. Unicode字符支持)
[2. 大小写不敏感版本](#2. 大小写不敏感版本)
[1. 文本编辑器](#1. 文本编辑器)
[2. 数据清洗](#2. 数据清洗)
[3. 密码学](#3. 密码学)
[4. 自然语言处理](#4. 自然语言处理)
[1. 提前终止](#1. 提前终止)
[2. 使用队列优化](#2. 使用队列优化)
[1. 字符集假设错误](#1. 字符集假设错误)
[2. 索引越界](#2. 索引越界)
[3. 忘记处理空字符串](#3. 忘记处理空字符串)
[1. 找到第二个唯一字符](#1. 找到第二个唯一字符)
[2. 找到第一个重复字符](#2. 找到第一个重复字符)
问题描述
给定一个字符串 s,找到字符串中第一个不重复的字符,并返回它的索引。如果不存在这样的字符,则返回 -1。
示例 1:
text
输入:s = "leetcode"
输出:0
解释:字符 'l' 是第一个只出现一次的字符
示例 2:
text
输入:s = "loveleetcode"
输出:2
解释:字符 'v' 是第一个只出现一次的字符
示例 3:
text
输入:s = "aabb"
输出:-1
解释:没有不重复的字符
解法一:两次遍历 + 固定数组(哈希表)
cpp
class Solution {
public:
int firstUniqChar(string s) {
// 统计每个字符出现的次数
int count[26] = {0}; // 假设字符串只包含小写字母
// 第一次遍历:统计频率
for(auto ch : s) {
count[ch - 'a']++; // 将字符映射到0-25的索引
}
// 第二次遍历:找到第一个频率为1的字符
for(int i = 0; i < s.size(); i++) {
if(count[s[i] - 'a'] == 1) {
return i; // 返回索引
}
}
return -1; // 没有找到唯一字符
}
};
算法分析
核心思路
-
频率统计:使用大小为26的数组统计每个小写字母出现的次数
-
顺序查找:再次遍历字符串,按顺序检查每个字符的出现次数
-
返回结果:找到第一个出现次数为1的字符,返回其索引
时间复杂度
-
第一次遍历:O(n),统计字符频率
-
第二次遍历:O(n),查找第一个唯一字符
-
总时间复杂度:O(n),其中n是字符串长度
空间复杂度
- O(1),使用固定大小的数组(26个元素)
适用条件
-
字符串只包含小写英文字母
-
字符集有限且已知
解法二:使用哈希表(更通用)
cpp
class Solution {
public:
int firstUniqChar(string s) {
// 使用哈希表统计频率
unordered_map<char, int> frequency;
// 统计每个字符出现的次数
for(char ch : s) {
frequency[ch]++;
}
// 查找第一个唯一字符
for(int i = 0; i < s.size(); i++) {
if(frequency[s[i]] == 1) {
return i;
}
}
return -1;
}
};
算法对比
| 特性 | 固定数组法 | 哈希表法 |
|---|---|---|
| 字符集 | 仅限小写字母 | 任意字符 |
| 空间复杂度 | O(1) | O(k),k为字符集大小 |
| 平均查找时间 | O(1) | O(1) |
| 内存使用 | 固定26个int | 动态分配 |
| 适用场景 | 已知有限字符集 | 任意字符集 |
算法优化与变种
1. 使用位运算优化空间
如果只需要判断字符是否重复,可以使用位运算:
cpp
int firstUniqChar(string s) {
int seen = 0; // 记录已出现的字符
int repeated = 0; // 记录重复的字符
for(char ch : s) {
int mask = 1 << (ch - 'a');
if(seen & mask) {
repeated |= mask; // 标记为重复
} else {
seen |= mask; // 标记为已出现
}
}
for(int i = 0; i < s.size(); i++) {
int mask = 1 << (s[i] - 'a');
if(!(repeated & mask)) {
return i;
}
}
return -1;
}
2. 同时记录索引和频率
cpp
int firstUniqChar(string s) {
// 使用vector存储字符的最后出现位置和频率
vector<pair<int, int>> freq(26, {-1, 0});
for(int i = 0; i < s.size(); i++) {
int idx = s[i] - 'a';
if(freq[idx].first == -1) {
freq[idx].first = i; // 记录第一次出现的位置
}
freq[idx].second++; // 增加频率
}
int result = INT_MAX;
for(auto& p : freq) {
if(p.second == 1) {
result = min(result, p.first);
}
}
return result == INT_MAX ? -1 : result;
}
处理扩展字符集
1. Unicode字符支持
cpp
#include <unordered_map>
#include <string>
int firstUniqChar(string s) {
// 使用unordered_map支持任意Unicode字符
unordered_map<char32_t, int> frequency;
// 统计频率
for(int i = 0; i < s.size(); ) {
// 处理UTF-8编码的字符
char32_t codePoint = getNextCodePoint(s, i);
frequency[codePoint]++;
}
// 查找第一个唯一字符
for(int i = 0; i < s.size(); ) {
char32_t codePoint = getNextCodePoint(s, i);
if(frequency[codePoint] == 1) {
return i;
}
}
return -1;
}
2. 大小写不敏感版本
cpp
int firstUniqCharCaseInsensitive(string s) {
int count[26] = {0};
// 统计频率(忽略大小写)
for(char ch : s) {
if(ch >= 'A' && ch <= 'Z') {
count[ch - 'A']++;
} else if(ch >= 'a' && ch <= 'z') {
count[ch - 'a']++;
}
}
// 查找第一个唯一字符
for(int i = 0; i < s.size(); i++) {
int idx;
if(s[i] >= 'A' && s[i] <= 'Z') {
idx = s[i] - 'A';
} else if(s[i] >= 'a' && s[i] <= 'z') {
idx = s[i] - 'a';
} else {
continue; // 非字母字符
}
if(count[idx] == 1) {
return i;
}
}
return -1;
}
实际应用场景
1. 文本编辑器
-
查找文档中首次出现的独特字符
-
实现"查找下一个唯一标识符"功能
2. 数据清洗
-
在数据集中查找唯一的标识符
-
检测重复的条目
3. 密码学
-
分析密码中的字符分布
-
检测弱密码模式
4. 自然语言处理
-
分析文本中的特殊字符
-
识别语言中的独特字母
测试用例设计
全面的测试应该包括:
cpp
void testFirstUniqChar() {
Solution solution;
// 基本测试
assert(solution.firstUniqChar("leetcode") == 0);
assert(solution.firstUniqChar("loveleetcode") == 2);
assert(solution.firstUniqChar("aabb") == -1);
// 边界测试
assert(solution.firstUniqChar("") == -1);
assert(solution.firstUniqChar("a") == 0);
assert(solution.firstUniqChar("aa") == -1);
// 特殊字符测试
assert(solution.firstUniqChar("aabcc") == 2);
assert(solution.firstUniqChar("abcabc") == -1);
assert(solution.firstUniqChar("z") == 0);
// 混合测试
assert(solution.firstUniqChar("dddccdbba") == 8);
assert(solution.firstUniqChar("itwqbtcdprfsuprkrjkausiterybzncbmdvkgljxuekizvaivszowqtmrttiihervpncztuoljftlxybpgwnjb") == 61);
}
性能优化技巧
1. 提前终止
如果可以在统计频率时就确定结果,可以提前终止:
cpp
int firstUniqChar(string s) {
int count[26] = {0};
vector<int> firstIndex(26, -1);
for(int i = 0; i < s.size(); i++) {
int idx = s[i] - 'a';
count[idx]++;
if(firstIndex[idx] == -1) {
firstIndex[idx] = i;
}
}
int result = INT_MAX;
for(int i = 0; i < 26; i++) {
if(count[i] == 1) {
result = min(result, firstIndex[i]);
}
}
return result == INT_MAX ? -1 : result;
}
2. 使用队列优化
对于流式数据,可以使用队列:
cpp
class StreamChecker {
private:
queue<char> charQueue;
int count[26] = {0};
public:
// 添加字符到流中
void addChar(char ch) {
int idx = ch - 'a';
count[idx]++;
charQueue.push(ch);
// 移除队首重复字符
while(!charQueue.empty() && count[charQueue.front() - 'a'] > 1) {
charQueue.pop();
}
}
// 获取当前第一个唯一字符
char getFirstUnique() {
return charQueue.empty() ? '#' : charQueue.front();
}
};
常见错误与注意事项
1. 字符集假设错误
cpp
// 错误:假设输入只有小写字母
int firstUniqChar(string s) {
int count[26] = {0};
for(char ch : s) {
count[ch - 'a']++; // 如果ch是大写字母或其他字符,这里会出错
}
// ...
}
2. 索引越界
cpp
// 错误:没有检查字符范围
int idx = s[i] - 'a'; // 如果s[i]不是小写字母,idx可能是负数或很大
count[idx]++; // 可能访问非法内存
3. 忘记处理空字符串
cpp
// 错误:没有处理空字符串
int firstUniqChar(string s) {
// 如果s为空,s.size()-1会是很大的正数(无符号整数下溢)
for(int i = 0; i < s.size(); i++) {
// ...
}
}
扩展问题
1. 找到第二个唯一字符
cpp
int secondUniqChar(string s) {
int count[26] = {0};
int first = -1, second = -1;
// 统计频率
for(char ch : s) {
count[ch - 'a']++;
}
// 找到第一个和第二个唯一字符
for(int i = 0; i < s.size(); i++) {
if(count[s[i] - 'a'] == 1) {
if(first == -1) {
first = i;
} else if(second == -1) {
second = i;
return second;
}
}
}
return -1;
}
2. 找到第一个重复字符
cpp
int firstRepeatingChar(string s) {
unordered_set<char> seen;
for(int i = 0; i < s.size(); i++) {
if(seen.count(s[i])) {
return i;
}
seen.insert(s[i]);
}
return -1;
}
总结
寻找字符串中第一个唯一字符的问题看似简单,但蕴含了许多重要的编程概念:
-
数据结构选择:根据字符集大小选择数组或哈希表
-
时间空间权衡:不同的解法在时间和空间复杂度上的取舍
-
边界条件处理:空字符串、单个字符、全重复字符等情况
-
编码细节:字符编码、索引计算、越界检查等
关键要点:
-
使用频率统计是解决此类问题的核心思路
-
两次遍历的方法既简单又高效
-
根据具体需求选择合适的字符表示方法
-
注意处理各种边界情况和异常输入
掌握这种基本问题不仅能帮助我们在面试中表现出色,还能在实际开发中解决许多类似的问题,如查找第一个重复字符、统计字符频率等。这种"频率统计+顺序查找"的模式是解决许多字符串问题的通用方法。