🤯 从一次手滑,我洞悉了用户输入的所有可能性!
大家好,我是你们的老朋友,一名在代码世界里摸爬滚打多年的开发者。今天想和大家聊聊一个我在最近项目中遇到的"小"问题,以及它如何演变成一个有趣的算法探索之旅。坐稳了,我们发车!🚀
我遇到了什么问题?
想象一下这个场景:我们正在开发一个电商平台的后台系统,运营同学需要手动输入一批"活动标签",比如 newarrival
, bestseller
, flashsale
等。为了提高系统的容错性,我们希望系统能"聪明"一点。
有一天,运营的同学跑过来和我说:"嘿,哥们,我刚才输入标签的时候,不小心把 flashsale
打成了 flashsaleee
,结果系统没识别,还得我删了重来,有点麻烦。能不能优化下?"
这个问题立刻引起了我的思考。用户在快速输入时,因为键盘延迟或者手指没及时抬起,很容易发生"长按"失误,把一个字符打出好几次。比如想打 cool
,结果打成了 coool
。
我的任务是:当系统看到一个最终的输入字符串时(比如 flashsaleee
),我需要能反推出所有可能的、用户"原本想输入"的字符串。 为什么要这么做?
- 提升容错性 :如果
flashsaleee
可能的原始意图包含flashsale
,我们就可以智能地提示或自动纠正。 - 数据分析:通过分析这些可能的原始意-图,我们可以了解哪些标签容易被打错,从而优化我们的标签设计。
我们把问题简化一下:假设用户最多只会犯一次"长按"的错误 。现在给你一个字符串 word
,代表用户最终输入的结果,你能算出它可能对应多少种原始意图吗?
这就是我们今天要解决的核心问题,它和 LeetCode 上的 3330. 找到初始输入字符串 I 不谋而合。
恍然大悟:逆向思维是关键!
一开始,我的思路有点跑偏。我想着:"我是不是要从一个'正确'的字符串出发,去生成所有可能的手滑版本?" 比如从 abc
生成 aabc
, abbc
, abcc
, aaabc
... 很快我就发现这条路太复杂了,而且我们根本不知道"正确"的字符串是哪个。😅
这时,我"踩了个坑"又"恍然大悟"的瞬间来了。
坑: 试图正向生成,逻辑复杂且不切实际。 悟: 为什么不逆向思考呢?我们已经有了最终结果 word
,应该从它入手,去反推可能的"源头"。
这个想法太棒了!我们来分析一下 word = "abbcccc"
这个例子。
- 零失误的情况 :最简单的一种可能,就是用户根本没手滑,他想打的就是
abbcccc
。这本身就是 1 种方案。 - 恰好一次失误的情况 :
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,就累加 长度-1
到 ans
。然后把 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
只包含小写英文字母:这为我们排除了处理数字、符号、大小写等复杂情况的干扰,让我们能专注于核心的"计数"逻辑。
举一反三:这个思想还能用在哪?
掌握了"分组计数"这个思想,很多问题都会迎刃而解。
- 数据压缩 (Run-Length Encoding) :
aaabbc
压缩成a3b2c1
。这不就是我们分组计数的过程吗?我们识别出块aaa
,bb
,c
,然后记录下字符和长度。 - 更智能的文本编辑器:在IDE里,如果连续敲下多个空格,编辑器可能会自动转换成一个Tab。这背后就有识别连续字符块的逻辑。
- 日志分析 :分析服务器日志时,连续出现的大量相同错误码
[E503, E503, E503, ...]
可能指示着同一个持续性问题。我们可以将它们分组,作为一个事件来分析,而不是看作几十个孤立事件。
拓展阅读:LeetCode 上的相关题目
如果你对这类问题意犹未尽,强烈推荐去挑战一下这几道题:
- 925. 长按键入 (Long Pressed Name): 堪称本题的"逆问题"。给你原始字符串和输入结果,判断是否可能通过长按得到。思路惊人地相似!
- 443. 压缩字符串 (String Compression): 经典的 RLE 压缩问题,要求在原地修改数组,非常考验对数组操作的熟练度。
- 228. 汇总区间 (Summary Ranges): 虽然处理的是数字,但核心思想同样是寻找连续的"块"(这次是连续的数字),并将其表示为区间。
好了,今天的分享就到这里。希望通过这个从实际问题出发的算法之旅,能让你对"分组计数"和"逆向思维"有更深的理解。记住,很多时候,换个角度看问题,世界会豁然开朗!下次见!👋