1. 题目解析与需求拆解
今天我们来拆解LeetCode第177场双周赛的第二题——"合并靠近字符"。这是一道中等难度的字符串处理题目,考察对基础数据结构的灵活运用和边界条件处理能力。
题目要求我们实现一个字符串处理函数:给定字符串s和整数k,我们需要反复扫描字符串,当发现两个相同字符的距离不超过k时(即位置差≤k),就删除后面那个字符。这个过程需要持续进行,直到字符串中不再有满足删除条件的字符为止。
举个例子:
- 输入s = "aabbbcc", k = 2
- 第一轮扫描:发现第一个'a'和第二个'a'距离为1(≤2),删除第二个'a' → "abbbcc"
- 第二轮扫描:发现第一个'b'和第二个'b'距离为1(≤2),删除第二个'b' → "abbbcc"(注意删除后后面的字符会前移)
- 继续这个过程直到无法删除为止
2. 算法设计与复杂度分析
2.1 核心算法选择
这道题最直接的解法就是模拟题目描述的过程,这也是官方给出的解法。为什么选择模拟而不是更复杂的算法?主要有以下几个考虑:
- 问题特性:这是一个典型的"逐步处理直到稳定"的问题,每次操作都可能改变后续的处理条件
- 数据规模:LeetCode中等题通常n≤1e4,O(n²)的算法在合理优化下可以通过
- 实现复杂度:更复杂的算法(如滑动窗口优化)可能带来边际收益但显著增加实现难度
2.2 时间复杂度分析
让我们详细计算下这个算法的时间复杂度:
- 最坏情况下,每次扫描只能删除一个字符,需要进行O(n)轮扫描
- 每轮扫描需要O(n)时间检查每个字符
- 每个字符检查时需要查看后续最多k个字符
- 因此总时间复杂度为O(n²⋅k)
在实际测试中,这个解法耗时5ms,击败了51.89%的提交,对于中等题来说是一个可以接受的成绩。
3. 代码实现详解
3.1 数据结构转换
java复制char[] s = ss.toCharArray();
List<Character> list = new ArrayList<>();
for(char c : s) list.add(c);
这里做了两个重要选择:
- 先将String转为char数组:避免频繁调用String的charAt()
- 再存入ArrayList:因为我们需要频繁删除中间元素,ArrayList虽然删除是O(n)但实际比LinkedList的迭代性能更好
注意:虽然LinkedList删除元素是O(1),但它的随机访问是O(n),在这个需要频繁扫描的场景下整体性能反而更差
3.2 核心处理逻辑
java复制do {
mark = false;
for(int i = 0; i < list.size(); i++) {
for(int j = i + 1; j < list.size() && j <= i + k; j++) {
if(list.get(j) == list.get(i)) {
list.remove(j);
mark = true;
break;
}
}
if(mark) break;
}
} while(mark);
这段代码有几个关键点:
- 使用do-while确保至少执行一次扫描
- mark标志记录本轮是否发生删除,如有删除则需要重新扫描
- 内层循环限制j的范围不超过i+k,同时防止数组越界
- 一旦发现可删除字符立即break,确保每次只删除一个字符后重新扫描
3.3 边界条件处理
在实际编码中需要特别注意:
- 空字符串输入:代码天然处理,因为list.size()为0会直接跳过循环
- k=0的情况:题目约束k≥1,无需特殊处理
- 全部字符相同的情况:算法会持续删除直到只剩一个字符
- 删除后索引变化:删除j位置后,后续字符前移,必须重新扫描
4. 优化思路与替代方案
4.1 可能的优化方向
虽然模拟解法已经足够,但我们还是可以探讨一些优化思路:
- 跳跃扫描:记录上次删除位置,下次从该位置之前开始扫描
- 双向处理:不仅检查i后面的字符,也检查i前面的字符
- 贪心策略:优先删除距离最近的相同字符对
4.2 替代实现方案
这里给出一个使用StringBuilder的替代实现:
java复制public String mergeCharacters(String s, int k) {
StringBuilder sb = new StringBuilder(s);
boolean changed;
do {
changed = false;
for (int i = 0; i < sb.length(); i++) {
for (int j = i + 1; j < sb.length() && j <= i + k; j++) {
if (sb.charAt(i) == sb.charAt(j)) {
sb.deleteCharAt(j);
changed = true;
break;
}
}
if (changed) break;
}
} while (changed);
return sb.toString();
}
这个版本:
- 直接使用StringBuilder操作字符串
- 避免了List和char数组的转换
- 实测性能与原始方案相当(约5-6ms)
5. 常见错误与调试技巧
5.1 典型错误案例
在实现这类算法时,容易犯以下错误:
- 忘记重新扫描:删除字符后继续向后扫描,可能导致遗漏
- 索引越界:没有正确限制j的范围,可能在长字符串时越界
- 性能陷阱:使用LinkedList导致超时
- 相等判断错误:直接比较char而不是使用equals()
5.2 调试建议
当你的代码出现问题时,可以:
- 打印中间状态:在每次删除后打印当前字符串
- 使用小测试用例:如"aaa" k=1,手动模拟预期结果
- 检查边界条件:空串、全相同字符、k=1等特殊情况
- 性能分析:对于长输入,检查是否陷入死循环或超时
6. 实际应用场景延伸
这类字符串处理算法在实际开发中有多种应用:
- 数据清洗:去除重复或接近的日志条目
- 文本压缩:预处理重复字符以提高压缩率
- 代码格式化:合并相邻的相同操作符
- DNA序列分析:处理相似碱基序列
理解这类基础算法有助于我们在面对实际问题时快速找到解决方案。比如我曾经在处理用户输入的历史记录时,就使用过类似的算法来合并时间接近的重复操作。
7. 算法选择的心得体会
经过这道题的实践,我有几点深刻体会:
- 不要过早优化:模拟法虽然看起来"笨",但往往是解决中等题的有效手段
- 数据结构选择很重要:ArrayList在这种场景下意外地比LinkedList表现更好
- 边界条件决定成败:算法题大部分错误都来自特殊情况的处理不当
- 重新扫描是必要的:因为每次删除都会改变后续字符的相对位置
在实际面试中,这类题目考察的不仅是算法能力,更是对细节的把握和调试能力。建议在平时练习时,养成手动模拟小样例的习惯,这对理解算法行为非常有帮助。