告别烦人的“三连发”:我的智能评论系统过滤之旅(1957. 删除字符使字符串变好)

告别烦人的"三连发":我的智能评论系统过滤之旅 😎

嘿,各位码农兄弟姐妹们!我是你们的老朋友,一个在代码世界里摸爬滚打多年的开发者。今天,我想和大家分享一个在实际项目中遇到的"小需求"以及我是如何从一道看似简单的算法题------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 是字符串长度。我们只遍历了一次 ssb.appendsb.lengthsb.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)。

举一反三,这个思想还能用在哪?

这个"基于局部规则的线性扫描"思想非常强大,是很多文本处理、数据清洗功能的核心:

  1. 敏感词过滤 :遍历文本,用一个指针或滑动窗口匹配敏感词库,匹配到就替换成 *
  2. 代码格式化/Linter :比如自动移除多余的空格,将 a = b 格式化为 a = b。也是从头到尾扫一遍,根据规则(如"非字符串内的多个空格合并为一个")构建新代码。
  3. 数据压缩算法 :经典的游程编码 (Run-Length Encoding)就是这个思想的直接应用。它将 "aaabbc" 扫描并压缩成 "a3b2c1",其核心就是遍历并计数连续字符。

类似好题推荐

如果你觉得这种序列处理题很有意思,可以挑战一下这些:

最终,我用第一种 StringBuilder 的方案快速上线了评论净化功能,效果拔群。一个看似简单的算法题,不仅帮我解决了实际问题,还让我重新梳理了多种序列处理的经典思路。这就是编程的乐趣所在吧!希望我的这次分享对你也有所启发!😉


三种解法对比表格

对比维度 解法1: StringBuilder 回看 解法2: 双指针 解法3: StringBuilder + 计数器
核心思想 边构建边回头看结果的最后两位。 模拟原地修改,用写指针构建新序列。 边构建边维护一个连续字符的计数器状态。
代码简洁度 。逻辑最直接。 中等。需要理解双指针的移动逻辑。 。状态变量清晰。
空间效率(Java) O(N) O(N) O(N)
推荐场景 通用首选。最符合Java习惯,清晰易懂。 面试时展示对经典算法模式的理解。 当处理的数据是流式时,此方法优势明显,因为它不需要存储大量历史结果。
相关推荐
LYFlied23 分钟前
【每日算法】LeetCode 153. 寻找旋转排序数组中的最小值
数据结构·算法·leetcode·面试·职场和发展
唐装鼠24 分钟前
rust自动调用Deref(deepseek)
开发语言·算法·rust
ytttr8731 小时前
MATLAB基于LDA的人脸识别算法实现(ORL数据库)
数据库·算法·matlab
jianfeng_zhu3 小时前
整数数组匹配
数据结构·c++·算法
smj2302_796826523 小时前
解决leetcode第3782题交替删除操作后最后剩下的整数
python·算法·leetcode
LYFlied4 小时前
【每日算法】LeetCode 136. 只出现一次的数字
前端·算法·leetcode·面试·职场和发展
唯唯qwe-5 小时前
Day23:动态规划 | 爬楼梯,不同路径,拆分
算法·leetcode·动态规划
做科研的周师兄5 小时前
中国土壤有机质数据集
人工智能·算法·机器学习·分类·数据挖掘
来深圳5 小时前
leetcode 739. 每日温度
java·算法·leetcode
yaoh.wang5 小时前
力扣(LeetCode) 104: 二叉树的最大深度 - 解法思路
python·程序人生·算法·leetcode·面试·职场和发展·跳槽