在字符串算法题中,“无聊前缀”指从索引0开始、其中每一个字符在自身这一段里都至少出现两次的连续前缀。求解最长此类前缀若采用切片加计数器的方式,往往会退化为平方级耗时。通过频次统计配合单次扫描,可以把过程压到O(n)。

一、问题定义与暴力思路的缺陷
给定字符串s,我们要找出最大的k,使得s[0..k]中任意一个字符的出现次数都大于等于2。注意,这里要求的是“前缀”整体满足频次条件,而不是子串任意位置。若k不存在(比如首字符全局只出现一次),则最长长度为0。
最直接的想法是:枚举每个结束位置i,截取s[0..i]并用字典统计,发现某个字符计数为1就停止。这样对每个i都要重建或回退统计,整体最坏O(n^2)。当n达到10^5时明显不可接受。我们需要一种边走边判、不回头的方法。
二、频次统计线性扫描的原理
算法只维护一个哈希表或数组freq,记录当前已扫描前缀里各字符的出现次数。从左到右遍历s,每步把s[i]对应计数加一。若加一之后该计数等于1,意味着s[i]在前缀s[0..i]中第一次露面,它破坏了“所有字符至少两次”的规则,因此最长无聊前缀只能截止到i-1。遍历继续已无意义,可直接break。
如果整轮遍历结束都没出现计数为1的情况,说明整个字符串本身就是无聊前缀,长度为n。该过程每个字符访问一次、哈希操作均摊O(1),总时间O(n),空间O(字符集)。对于英文字母可用长26的数组,对于Unicode可用标准字典。
2.1 为什么遇到频次为1即可终止
前缀具有包含关系:s[0..i]包含s[0..i-1]。一旦s[0..i]里有个字符只出现一次,那么更长的任何以i为端点的前缀也必然包含这个孤独字符,所以不可能更长。因此我们不必再向后试探,当前记录的最大合法长度就是答案。
这也带来一个工程上的好处:输入流可以边读边算,不需要把整个字符串保存在内存里,只需保留freq表和上一个安全下标。对于日志或网络包的场景非常友好。
三、代码实现示例
3.1 Python版本
下面给出易读的Python实现,使用字典统计并在首次出现单数时跳出。
def longest_boring_prefix(s):
freq = {}
max_len = 0
for i, ch in enumerate(s):
freq[ch] = freq.get(ch, 0) + 1
if freq[ch] == 1:
# 当前字符在前缀中第一次出现,无聊前缀在此前结束
break
max_len = i + 1
return max_len
# 测试
print(longest_boring_prefix("aabbccd")) # 输出5,对应aabbcc
print(longest_boring_prefix("abba")) # 输出0,首字符a仅一次
上述代码中,max_len在每次合法时更新为i+1。若首字符就只出现一次,循环直接break,max_len保持0,符合定义。函数对空串也安全,返回0。
在空间上,freq只会含有遍历过的字符种类,最坏等于字符集。时间上只走一趟,因此即便字符串长度很大也能轻松处理。
3.2 C++版本
对于性能敏感的服务,可用定长数组或unordered_map。下面示例假设输入为ASCII可见字符。
#include <iostream>
#include <string>
using namespace std;
int longestBoringPrefix(const string& s) {
int freq[256] = {0};
int max_len = 0;
for (int i = 0; i < (int)s.size(); ++i) {
unsigned char ch = s[i];
freq[ch]++;
if (freq[ch] == 1) {
break;
}
max_len = i + 1;
}
return max_len;
}
int main() {
cout << longestBoringPrefix("aabbccd") << endl; // 5
cout << longestBoringPrefix("abba") << endl; // 0
return 0;
}
C++里用数组比哈希表更快,且缓存友好。若处理UTF-8多字节,需要先按码点解码再统计,否则会把一个汉字拆成多个单字节误判。
两个版本逻辑完全一致,仅语言特性不同。实际面试或竞赛中写出其中一种并讲清终止条件即可。
四、复杂度与常见误区
时间复杂度严格O(n),n为字符串长度;空间复杂度O(m),m为字符集大小。很多人误以为需要用滑动窗口,其实窗口左边界永远在0,根本不需要双指针收缩。
另一个误区是认为“只要前面有重复就行”,例如看到s="aab"就觉得aa重复所以长度为2,但b在s[0..2]中只出现一次,因此正确答案应是0。算法里的freq[ch]==1判断正是拦住这种错误的关键。
五、适用场景与扩展
该算法可稍作修改来求解“最长每个字符频次为k的前缀”,只需把终止条件换成freq[ch] < k。也可用于实时数据清洗,比如丢弃头部所有未形成配对的元素。只要规则是前缀内频次约束,线性扫描都适用。
如果题目反过来问“最短非无聊前缀起点”,那才是滑动窗口或二分的事。理清前缀包含关系,才能选对工具,不至于写出冗余代码。