从一次手滑,我洞悉了用户输入的所有可能性(3330. 找到初始输入字符串 I)


🤯 从一次手滑,我洞悉了用户输入的所有可能性!

大家好,我是你们的老朋友,一名在代码世界里摸爬滚打多年的开发者。今天想和大家聊聊一个我在最近项目中遇到的"小"问题,以及它如何演变成一个有趣的算法探索之旅。坐稳了,我们发车!🚀

我遇到了什么问题?

想象一下这个场景:我们正在开发一个电商平台的后台系统,运营同学需要手动输入一批"活动标签",比如 newarrival, bestseller, flashsale 等。为了提高系统的容错性,我们希望系统能"聪明"一点。

有一天,运营的同学跑过来和我说:"嘿,哥们,我刚才输入标签的时候,不小心把 flashsale 打成了 flashsaleee,结果系统没识别,还得我删了重来,有点麻烦。能不能优化下?"

这个问题立刻引起了我的思考。用户在快速输入时,因为键盘延迟或者手指没及时抬起,很容易发生"长按"失误,把一个字符打出好几次。比如想打 cool,结果打成了 coool

我的任务是:当系统看到一个最终的输入字符串时(比如 flashsaleee),我需要能反推出所有可能的、用户"原本想输入"的字符串。 为什么要这么做?

  1. 提升容错性 :如果 flashsaleee 可能的原始意图包含 flashsale,我们就可以智能地提示或自动纠正。
  2. 数据分析:通过分析这些可能的原始意-图,我们可以了解哪些标签容易被打错,从而优化我们的标签设计。

我们把问题简化一下:假设用户最多只会犯一次"长按"的错误 。现在给你一个字符串 word,代表用户最终输入的结果,你能算出它可能对应多少种原始意图吗?

这就是我们今天要解决的核心问题,它和 LeetCode 上的 3330. 找到初始输入字符串 I 不谋而合。

恍然大悟:逆向思维是关键!

一开始,我的思路有点跑偏。我想着:"我是不是要从一个'正确'的字符串出发,去生成所有可能的手滑版本?" 比如从 abc 生成 aabc, abbc, abcc, aaabc... 很快我就发现这条路太复杂了,而且我们根本不知道"正确"的字符串是哪个。😅

这时,我"踩了个坑"又"恍然大悟"的瞬间来了。

坑: 试图正向生成,逻辑复杂且不切实际。 悟: 为什么不逆向思考呢?我们已经有了最终结果 word,应该从它入手,去反推可能的"源头"。

这个想法太棒了!我们来分析一下 word = "abbcccc" 这个例子。

  1. 零失误的情况 :最简单的一种可能,就是用户根本没手滑,他想打的就是 abbcccc。这本身就是 1 种方案。
  2. 恰好一次失误的情况
    • word 里的 bb,有没有可能是手滑打出来的?当然有!它可能是想打 b,结果按久了。这就产生了 1 个新的原始意图:abcccc
    • 再看 cccc。这个长度为 4 的块,可能是想打 ccc 时按久了,也可能是想打 cc 时按久了,甚至是想打 c 时按久了。这分别对应了 3 个新的原始意图:abbccc, abbcc, abbc

看到了吗?对于一个长度为 L (且 L > 1) 的连续字符块,它能贡献 L-1 种可能的原始意图。

所以,总方案数 = (零失误方案) + (所有可能的一次失误方案) = 1 + (来自'bb'的方案) + (来自'cccc'的方案) = 1 + (2-1) + (4-1) = 1 + 1 + 3 = 5 种。

思路一下子就清晰了!问题转化成了:如何高效地找出字符串中所有连续相同字符的块,并计算它们的长度。

三种姿势解决问题,总有一款适合你

下面我分享三种代码实现思路,从最经典到最模块化,带你吃透这个问题。

解法1:双指针探戈 💃

这是处理字符串区间问题的经典招式。我们用一个指针 i 标记一个块的开始,另一个指针 j 去寻找这个块的结束。

<思路> 我们初始化 ans = 1(代表零失误)。然后遍历字符串,每次用指针 i 定位一个新块的起点,用指针 j 向后扫描直到字符不再相同。j - i 就是这个块的长度。如果长度大于1,就累加 长度-1ans。然后把 i 更新到 j 的位置,继续寻找下一个块。 </思路>

java 复制代码
/*
 * 思路:双指针法,也叫分组计数法。一个指针i固定块的开头,另一个指针j寻找块的结尾。
 * 时间复杂度:O(N),两个指针都只从头到尾扫了一遍字符串。
 * 空间复杂度:O(1),非常节省空间,是竞赛和性能敏感场景的首选。
 */
class Solution {
    public int findPermutationCount(String word) {
        int n = word.length();
        int ans = 1; // 默认包含"零失误"这一种情况

        for (int i = 0; i < n; ) {
            int j = i;
            // 为何用 word.charAt(j)?
            // 相比 word.toCharArray(),charAt() 不会预先创建一个完整的字符数组,
            // 它是"按需访问",在内存上更优,特别是处理大字符串时。
            while (j < n && word.charAt(j) == word.charAt(i)) {
                j++;
            }
            int groupLength = j - i;
            if (groupLength > 1) {
                ans += (groupLength - 1);
            }
            i = j; // i 直接跳到下一个块的开头
        }
        return ans;
    }
}
解法2:邻里关系检查 👋

这种方法更"线性"一点,我们只关心当前字符和它前一个邻居的关系。

<思路> 同样初始化 ans = 1。我们从第二个字符开始遍历,维护一个 currentGroupLength 变量。如果当前字符和前一个相同,currentGroupLength 就加一。如果不同,说明上一个块结束了,我们就结算这个块的贡献,然后把 currentGroupLength 重置为1。 这里有个小小的"坑" :循环结束后,最后一个块的信息还存在 currentGroupLength 里,别忘了对它进行最后的结算! </思路>

java 复制代码
/*
 * 思路:单次遍历,比较相邻字符。代码结构扁平,但要注意处理好边界,尤其是最后一个块。
 * 时间复杂度:O(N),只遍历一次。
 * 空间复杂度:O(1),同样是常数空间。
 */
class Solution {
    public int findPermutationCount(String word) {
        if (word.length() <= 1) return 1;

        int ans = 1;
        int currentGroupLength = 1;

        for (int i = 1; i < word.length(); i++) {
            if (word.charAt(i) == word.charAt(i - 1)) {
                currentGroupLength++;
            } else {
                // 上一个块结束了,结算!
                if (currentGroupLength > 1) {
                    ans += (currentGroupLength - 1);
                }
                // 开始统计新块
                currentGroupLength = 1;
            }
        }

        // 循环结束,别忘了结算最后一个块!我就在这里错过一次 😉
        if (currentGroupLength > 1) {
            ans += (currentGroupLength - 1);
        }

        return ans;
    }
}
解法3:先分离,再征服 🗂️

这种方法最有"工程化"的味道。我们把问题拆成两步:1. 数据提取;2. 数据处理。

<思路> 第一步,我们先遍历一遍字符串,把所有连续块的长度都找出来,存到一个列表里。第二步,我们再遍历这个长度列表,根据 ans += 长度 - 1 的规则计算最终结果。这种方法代码逻辑非常清晰,可读性好,但牺牲了一点空间。 </思路>

java 复制代码
/*
 * 思路:预处理 + 计算。先提取所有块的长度,再统一计算。代码模块化,易于理解和维护。
 * 时间复杂度:O(N),遍历字符串O(N),遍历长度列表O(M),M<=N,所以还是O(N)。
 * 空间复杂度:O(M),M是块的数量。最坏情况(如"abcde")是O(N)。
 */
import java.util.ArrayList;
import java.util.List;

class Solution {
    public int findPermutationCount(String word) {
        if (word.isEmpty()) return 0;
      
        // 步骤1:数据提取
        List<Integer> groupLengths = new ArrayList<>();
        for (int i = 0; i < word.length(); ) {
            int j = i;
            while (j < word.length() && word.charAt(j) == word.charAt(i)) {
                j++;
            }
            groupLengths.add(j - i);
            i = j;
        }

        // 步骤2:数据处理
        int ans = 1;
        for (int length : groupLengths) {
            if (length > 1) {
                ans += (length - 1);
            }
        }
        return ans;
    }
}

解读一下题目的"提示"

  • 1 <= word.length <= 100:这个提示像是在给我们吃定心丸。它告诉我们,字符串长度很小,所以即使是空间复杂度为 O(N) 的解法3也完全没问题。同时,这也意味着我们不必过度优化,O(N) 的时间复杂度已经足够优秀。
  • word 只包含小写英文字母:这为我们排除了处理数字、符号、大小写等复杂情况的干扰,让我们能专注于核心的"计数"逻辑。

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

掌握了"分组计数"这个思想,很多问题都会迎刃而解。

  1. 数据压缩 (Run-Length Encoding)aaabbc 压缩成 a3b2c1。这不就是我们分组计数的过程吗?我们识别出块 aaa, bb, c,然后记录下字符和长度。
  2. 更智能的文本编辑器:在IDE里,如果连续敲下多个空格,编辑器可能会自动转换成一个Tab。这背后就有识别连续字符块的逻辑。
  3. 日志分析 :分析服务器日志时,连续出现的大量相同错误码 [E503, E503, E503, ...] 可能指示着同一个持续性问题。我们可以将它们分组,作为一个事件来分析,而不是看作几十个孤立事件。

拓展阅读:LeetCode 上的相关题目

如果你对这类问题意犹未尽,强烈推荐去挑战一下这几道题:


好了,今天的分享就到这里。希望通过这个从实际问题出发的算法之旅,能让你对"分组计数"和"逆向思维"有更深的理解。记住,很多时候,换个角度看问题,世界会豁然开朗!下次见!👋

相关推荐
hie988949 分钟前
MATLAB锂离子电池伪二维(P2D)模型实现
人工智能·算法·matlab
杰克尼19 分钟前
BM5 合并k个已排序的链表
数据结构·算法·链表
.30-06Springfield1 小时前
决策树(Decision tree)算法详解(ID3、C4.5、CART)
人工智能·python·算法·决策树·机器学习
我不是哆啦A梦1 小时前
破解风电运维“百模大战”困局,机械版ChatGPT诞生?
运维·人工智能·python·算法·chatgpt
xiaolang_8616_wjl1 小时前
c++文字游戏_闯关打怪
开发语言·数据结构·c++·算法·c++20
small_wh1te_coder1 小时前
硬件嵌入式学习路线大总结(一):C语言与linux。内功心法——从入门到精通,彻底打通你的任督二脉!
linux·c语言·汇编·嵌入式硬件·算法·c
挺菜的2 小时前
【算法刷题记录(简单题)002】字符串字符匹配(java代码实现)
java·开发语言·算法
凌肖战5 小时前
力扣网编程55题:跳跃游戏之逆向思维
算法·leetcode
88号技师6 小时前
2025年6月一区-田忌赛马优化算法Tianji’s horse racing optimization-附Matlab免费代码
开发语言·算法·matlab·优化算法
ゞ 正在缓冲99%…6 小时前
leetcode918.环形子数组的最大和
数据结构·算法·leetcode·动态规划