告别烦人的"三连发":我的智能评论系统过滤之旅 😎
嘿,各位码农兄弟姐妹们!我是你们的老朋友,一个在代码世界里摸爬滚打多年的开发者。今天,我想和大家分享一个在实际项目中遇到的"小需求"以及我是如何从一道看似简单的算法题------LeetCode 1957. 删除字符使字符串变好------中找到灵感,并打造出一个健壮又高效的解决方案的。准备好小板凳,故事开始啦!
我遇到了什么问题?
最近,我接手了一个内容社区的"智能评论系统"优化项目。产品经理提出了一个需求:为了提升社区内容的阅读体验,需要对用户的评论进行一些"净化"。有些用户为了刷屏或者表达强烈情感,会输入大量重复的字符,比如:"这篇文章写得太棒棒棒棒棒了!!!" 或者 "楼主在吗吗吗吗吗?"
这种评论不仅影响观感,还占用了宝贵的屏幕空间。我们的目标就是自动将这类评论"变好",规则很简单:不允许出现三个或以上连续的相同字符。
所以,我的任务可以精确地描述为:给定一个字符串(用户评论),删除最少的字符,使它变成一个"好字符串"。比如,"leeetcode"
应该变成 "leetcode"
,"aaabaaaa"
应该变成 "aabaa"
。
瞧,这不就是上面那道 LeetCode 题嘛!现实世界的问题,往往就能在这些经典的算法题中找到影子。
先瞅瞅"通关秘籍"(题目提示解读)
在动手之前,我们先看看题目的提示,这往往藏着解题的关键线索:
1 <= s.length <= 10^5
:字符串长度最长有十万,这意味着 O(N^2) 的算法(比如循环内嵌字符串拼接)会超时,我们必须追求 O(N) 的线性时间复杂度。s 只包含小写英文字母
:字符集很简单,不用考虑复杂的 Unicode 或多字节字符。题目数据保证答案总是唯一的
:这是最重要的提示!它告诉我们,处理过程不需要瞻前顾后,不需要回溯。我们可以从左到右,用一种"贪心"的策略来处理每个字符,因为每一步的局部最优选择,最终会导向全局最优解。这让我可以放心大胆地往前冲!
我是如何用"线性扫描"搞定它的
有了上面的分析,我意识到这个问题的核心是一个线性序列处理任务。我脑海中立刻浮现出几种经典的解决方案。
解法一:StringBuilder 王道,边建边看
这是最符合直觉,也是 Java 开发中最常用的方法。我们从左到右遍历原始评论,同时用一个 StringBuilder
构建我们"净化后"的新评论。
每当遇到一个新字符,我们只需要"回头"看看新评论的末尾,判断加上这个新字符后,会不会构成"三连"。
我的第一个念头就是这个方法,但我也踩了一个小坑 😉。我最初想的是:"记录前一个字符和前前一个字符......" 这样做需要维护好几个变量,逻辑有点绕。
恍然大悟的瞬间 ⚡️:我为什么要自己维护状态?StringBuilder
本身就是状态的存储者啊!我只需要看它末尾的两个字符就行了,逻辑清晰又简单。
java
/*
* 思路:遍历和StringBuilder。这是处理字符串构建问题的标准高效方法。
* 遍历原字符串的每个字符,根据规则决定是否将其添加到StringBuilder中。
* 规则:只有当新字符不会与结果的末尾两个字符形成三连时,才添加。
* 时间复杂度:O(N),只遍历一次字符串。
* 空间复杂度:O(N),最坏情况需要一个与原字符串等长的StringBuilder。
*/
class Solution {
public String makeFancyString(String s) {
// 为什么用StringBuilder?因为它可变。
// 用String做"+"拼接每次都会生成新对象,效率极低(O(N^2))。
// StringBuilder的append操作平均是O(1),是我们的不二之选。
StringBuilder sb = new StringBuilder();
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
int len = sb.length();
// 核心逻辑:回头看,但只看最后两位
// 如果结果长度还不足2,或者新字符与最后两位不构成三连...
if (len < 2 || sb.charAt(len - 1) != c || sb.charAt(len - 2) != c) {
// ...就放心地把它加进来!
sb.append(c);
}
// 否则,啥也不干,相当于"删除"了这个多余的字符。
}
return sb.toString();
}
}
- 时间复杂度 :O(N)。N 是字符串长度。我们只遍历了一次
s
。sb.append
、sb.length
和sb.charAt
平均都是 O(1) 的操作。 - 空间复杂度 :O(N)。需要一个
StringBuilder
来存储结果,其最大长度可能等于 N。
解法二:双指针,经典的"原地"智慧
作为一个有追求的开发者,我总想着用更"炫酷"的方式解决问题。双指针就是处理序列问题的经典技巧之一。
我们可以想象把字符串变成一个字符数组,然后用两个指针:
- 读指针
i
:它负责勇往直前,扫描整个原始数组。 - 写指针
j
:它比较保守,只在确认当前字符是"好的"之后,才把它写到自己的位置,然后前进一步。
这样,j
就慢慢地在数组的前面部分构建出了我们想要的"好字符串"。虽然在 Java 中字符串不可变,我们不能真正地"原地"修改,但这个思想完全可以模拟出来,并且代码非常精炼。
java
/*
* 思路:双指针。这是一种模拟原地修改数组的经典技巧。
* 读指针`i`遍历原字符串,写指针`j`指向结果字符串(在char数组中)的末尾。
* 对于每个读到的字符,如果它"合格",就把它放到写指针`j`的位置,然后`j`前进。
* 时间复杂度:O(N),一次遍历和一次新字符串创建。
* 空间复杂度:O(N),需要一个char数组和最终的结果字符串。
*/
class Solution {
public String makeFancyString(String s) {
int n = s.length();
if (n < 3) {
return s; // 长度小于3,不可能有三连,直接返回
}
// Java中String不可变,我们用char数组模拟这个过程
char[] chars = s.toCharArray();
int j = 0; // 写指针,永远指向下一个可写入的"净土"
for (int i = 0; i < n; i++) { // i是读指针,一路狂奔
// 这里的判断逻辑和解法一本质相同,但操作的是chars数组
// chars[j-1]和chars[j-2]就是已构建好的"好字符串"的末尾
if (j < 2 || chars[i] != chars[j - 1] || chars[i] != chars[j - 2]) {
chars[j] = chars[i];
j++; // 写入成功,写指针前进,占领新位置
}
}
// 最后,根据写指针`j`的位置,从char数组的有效部分创建新字符串
// new String(char[] data, int offset, int count) 是一个非常高效的构造器
return new String(chars, 0, j);
}
}
- 时间复杂度 :O(N)。
toCharArray
O(N),循环 O(N),new String
O(N),整体还是 O(N)。 - 空间复杂度 :O(N)。需要一个 O(N) 的
char[]
和一个 O(N) 的返回字符串。
解法三:计数器,轻装上阵
还有一种思路,就是完全不"回头看"。我们只带一个简单的状态上路:一个计数器 count
。
我们遍历原始字符串,维护当前连续字符 lastChar
和它的出现次数 count
。
- 如果遇到的字符和
lastChar
不一样,说明新的一组字符开始了,我们就更新lastChar
并把count
重置为1。 - 如果一样,就把
count
加1。
每次我们都检查 count
,只要它还小于3,就把当前字符加入结果。这个方法特别适合处理流式数据,因为它不需要存储除了最后一个字符以外的任何历史信息。
java
/*
* 思路:遍历和计数器。通过维护状态变量来避免回看,适合流式处理场景。
* 我们遍历原字符串,用count记录当前连续相同字符的数量。
* 只要一个字符的连续计数值小于3,它就是"好的",可以加入结果中。
* 时间复杂度:O(N)。
* 空间复杂度:O(N)。
*/
class Solution {
public String makeFancyString(String s) {
StringBuilder sb = new StringBuilder();
int count = 0;
char lastChar = ' '; // 初始化一个不可能在输入中出现的字符作为哨兵
for (char c : s.toCharArray()) {
if (c == lastChar) {
count++; // 还是老熟人,计数器加1
} else {
lastChar = c; // 遇见新面孔,更新记录,计数器重置
count = 1;
}
// 只要连续次数还没到3,就欢迎加入
if (count < 3) {
sb.append(c);
}
}
return sb.toString();
}
}
- 时间复杂度:O(N)。
- 空间复杂度:O(N)。
举一反三,这个思想还能用在哪?
这个"基于局部规则的线性扫描"思想非常强大,是很多文本处理、数据清洗功能的核心:
- 敏感词过滤 :遍历文本,用一个指针或滑动窗口匹配敏感词库,匹配到就替换成
*
。 - 代码格式化/Linter :比如自动移除多余的空格,将
a = b
格式化为a = b
。也是从头到尾扫一遍,根据规则(如"非字符串内的多个空格合并为一个")构建新代码。 - 数据压缩算法 :经典的游程编码 (Run-Length Encoding)就是这个思想的直接应用。它将
"aaabbc"
扫描并压缩成"a3b2c1"
,其核心就是遍历并计数连续字符。
类似好题推荐
如果你觉得这种序列处理题很有意思,可以挑战一下这些:
- 1047. 删除字符串中的所有相邻重复项:这道题是删除两个相邻的,用栈来解非常巧妙!
- 1578. 使绳子多彩的最小成本:和我们今天这题几乎一样,但每个字符带有一个"删除成本",让你在必须删除时,选择成本最低的那个,是贪心思想的绝佳练习。
最终,我用第一种 StringBuilder
的方案快速上线了评论净化功能,效果拔群。一个看似简单的算法题,不仅帮我解决了实际问题,还让我重新梳理了多种序列处理的经典思路。这就是编程的乐趣所在吧!希望我的这次分享对你也有所启发!😉
三种解法对比表格
对比维度 | 解法1: StringBuilder 回看 | 解法2: 双指针 | 解法3: StringBuilder + 计数器 |
---|---|---|---|
核心思想 | 边构建边回头看结果的最后两位。 | 模拟原地修改,用写指针构建新序列。 | 边构建边维护一个连续字符的计数器状态。 |
代码简洁度 | 高。逻辑最直接。 | 中等。需要理解双指针的移动逻辑。 | 高。状态变量清晰。 |
空间效率(Java) | O(N) | O(N) | O(N) |
推荐场景 | 通用首选。最符合Java习惯,清晰易懂。 | 面试时展示对经典算法模式的理解。 | 当处理的数据是流式时,此方法优势明显,因为它不需要存储大量历史结果。 |