Skip to content
给定一个由字母组成的单词,需要统计其中“特殊字符”的数量。对于字母表里的每个字母,若其小写形式和大写形式都在单词中出现过,且所有小写字母的出现位置均严格位于其对应大写字母首次出现之前,则该字母计为一个特殊字符。
形式化定义:对字母 c,记 lastLower[c] 为小写形式在单词中最后一次出现的下标,firstUpper[c] 为大写形式第一次出现的下标。若这两个位置均存在且满足 lastLower[c] < firstUpper[c],字母 c 就是特殊字符。
基本概念
- 特殊字符:同时满足“大小写都存在”和“小写的最后出现位置 < 大写的首次出现位置”的字母。
- Last occurrence:小写字母可能多次出现,只有最后一次的位置有用。
- First occurrence:大写字母只需要第一次出现的位置——越早出现,越可能满足
<条件。 - 字母集合固定为 26 个小写/大写英文字母,键空间已知且有限。
工作原理
一种直接的思路是扫描单词,同时维护候选小写字母集合和已出现的大写字母集合,并在过程中剔除无效的小写。例如,当一个大写字母已经出现后,再遇到对应的小写字母时,该小写字母就无法满足“最后出现位置 < 大写的首次出现位置”(因为大写首次位置更早),可当即淘汰。
更规整的解法是把判定拆成两个独立阶段:
- 收集极值:遍历单词,记录每个字母的
lastLower(小写最后位置)和firstUpper(大写首次位置)。 - 一次比较:对所有 26 个字母,检查位置关系。
两个阶段完全解耦,避免了在扫描过程中动态维护集合和增删元素带来的交错状态。
定长数组在这里很合适:26 个小写字母用一个长度为 26 的数组存储最后出现下标,初始化为 -1(表示未出现);26 个大写字母用另一个长度 26 的数组存储首次出现下标,初始化为 Infinity(或一个大于所有可能下标的值)。遍历结束后,一次 26 次的循环即可得到结果。时间复杂度 O(n),空间 O(1),没有堆分配。
基本用法
定长数组 O(1) 空间实现
ts
function numberOfSpecialChars(word: string): number {
const lastLower = new Array(26).fill(-1);
const firstUpper = new Array(26).fill(Infinity);
for (let i = 0; i < word.length; i++) {
const ch = word[i];
if (ch >= 'a' && ch <= 'z') {
lastLower[ch.charCodeAt(0) - 97] = i; // 每次覆盖,保留最后出现位置
} else {
const idx = ch.charCodeAt(0) - 65;
if (firstUpper[idx] === Infinity) {
firstUpper[idx] = i; // 仅记录首次出现
}
}
}
let count = 0;
for (let i = 0; i < 26; i++) {
if (
lastLower[i] !== -1 &&
firstUpper[i] !== Infinity &&
lastLower[i] < firstUpper[i]
) {
count++;
}
}
return count;
}调用示例:
ts
numberOfSpecialChars("aaAbBcC"); // 2
numberOfSpecialChars("aA"); // 1
numberOfSpecialChars("AbcDEfg"); // 0- 第一个例子里,'a' 的小写最后位置在索引 1,大写首次位置在索引 2,满足条件;'b' 的小写最后位置在索引 3,大写首次位置在索引 4,同样满足;'c' 只有大写,不计。结果 2。
- 第二个例子中,'a' 满足,结果 1。
- 第三个例子中没有小写字母能够出现在其大写首次出现之前,结果为 0。
基于 Set 的对比解法
ts
function numberOfSpecialChars_set(word: string): number {
if (word.length < 2) return 0;
const upperSeen = new Set<string>();
const lowerCandidates = new Set<string>();
for (const ch of word) {
if (ch >= 'a' && ch <= 'z') {
if (upperSeen.has(ch.toUpperCase())) {
lowerCandidates.delete(ch);
continue;
}
lowerCandidates.add(ch);
} else {
upperSeen.add(ch);
}
}
// 遍历结束后,若某个候选小写的大写从未出现,也需移除
const toRemove: string[] = [];
for (const c of lowerCandidates) {
if (!upperSeen.has(c.toUpperCase())) {
toRemove.push(c);
}
}
for (const c of toRemove) {
lowerCandidates.delete(c);
}
return lowerCandidates.size;
}Set 版本在迭代过程中会通过 toUpperCase() 创建临时字符串,并依赖哈希表进行查找。相比定长数组,常量开销更大;当输入规模达到 10⁵ 级别且调用频繁时,临时对象的大量分配可能触发垃圾回收,增加运行开销。
示例
| 输入 | 输出 | 说明 |
|---|---|---|
"aaAbBcC" | 2 | 字母 a、b 满足条件。 |
"aA" | 1 | 只有字母 a 满足。 |
"AbcDEfg" | 0 | 所有小写字母的位置都不在大写首次出现之前。 |
"zzZ" | 1 | 小写 z 最后位置 1,大写 Z 首次位置 2,1 < 2 满足。 |
"Zzz" | 0 | 大写 Z 在索引 0,小写 z 最后位置在索引 2,2 < 0 不成立。 |
"abcABC" | 3 | a 最后位置 0,A 首次位置 3;b 最后 1,B 首次 4;c 最后 2,C 首次 5,全部满足。 |
注意点
- 键空间固定:定长数组替换 Set 或 Map 的前提是 key 空间已知且有限,比如 26 个字母、10 个数字或 128 个 ASCII 字符。每个槽位仅存储标量(一个下标),不适合键空间动态或取值范围巨大的场景。
- 遍历次序决定覆盖逻辑:对每个小写字母直接覆盖下标即可得到最后出现位置;大写字母需要判断是否为首次出现,否则
firstUpper被后续更大下标覆盖会破坏比较条件。 - 比较条件的完整性:必须同时校验
lastLower[i] !== -1(小写出现过)、firstUpper[i] !== Infinity(大写出现过)以及lastLower[i] < firstUpper[i],任一缺失都不能视为特殊字符。 - Set 方案的迭代安全:如果直接在
for...of或forEach中删除Set元素,依赖实现,可能导致不可预期的行为。应先收集待删除元素,再统一删除。
