LeetCode 395 - 至少有 K 个重复字符的最长子串
文章目录
- 摘要
- 描述
- 示例 1:
- 示例 2:
- 题解答案
- 题解代码分析
- 代码解析
- **1. 统计字符出现次数**
- 2. 找出不合格的字符
- 3. 分治拆分 + 递归求解
- 4. 所有字符都合格
- 示例测试及结果
- 输出结果:
- 时间复杂度
- 空间复杂度
- 总结
摘要
在日常开发中,我们经常需要分析字符串,比如日志过滤、关键字提取、或者做文本分析。这类问题的核心都是“在一个字符串中找出满足某种约束的最长片段”。
LeetCode 第 395 题 “至少有 K 个重复字符的最长子串” 就是这种典型问题之一。它考察了我们对分治思想的理解,以及如何结合递归与频次统计快速定位符合条件的子串。
虽然看起来像是一个“滑动窗口”问题,但其实这题最优解并不是窗口遍历,而是一个非常有意思的 “分而治之” 过程。
描述
题目要求:
给定一个字符串 s
和一个整数 k
,我们需要找到 s
中的最长子串,这个子串的每一个字符都至少重复了 k
次。
简单来说,就是:
所有字符的出现次数都要 ≥ k。
如果找不到符合要求的子串,返回 0。
示例 1:
输入:s = "aaabb", k = 3
输出:3
解释:最长子串为 "aaa"
示例 2:
输入:s = "ababbc", k = 2
输出:5
解释:最长子串为 "ababb"
题解答案
这个问题的关键在于:
哪些字符“不够格”(出现次数 < k),它们就不能出现在最终的结果中。
因为任何包含这些字符的子串都不可能满足条件。
所以可以把这些“不合格的字符”作为分割点,把原字符串拆成若干子串,然后递归地在每个子串中寻找最长合法部分。
这其实是一种 “分治法(Divide and Conquer)” 的思路。
题解代码分析
下面是完整的 Swift 实现,逻辑清晰,可直接运行
import Foundationclass Solution {func longestSubstring(_ s: String, _ k: Int) -> Int {// 基础情况:如果字符串长度小于 k,肯定不可能有合法子串if s.count < k { return 0 }// 统计字符出现次数var freq: [Character: Int] = [:]for ch in s {freq[ch, default: 0] += 1}// 找出第一个不满足条件的字符for (ch, count) in freq {if count < k {// 用这个字符作为“分割点”let parts = s.split(separator: ch)// 对每个部分递归求解var maxLen = 0for part in parts {maxLen = max(maxLen, longestSubstring(String(part), k))}return maxLen}}// 如果所有字符都满足条件,那整个字符串就是结果return s.count}
}
代码解析
整个算法的思想可以分为三个阶段来看:
1. 统计字符出现次数
我们先遍历字符串,用字典(freq
)记录每个字符出现的次数。
var freq: [Character: Int] = [:]
for ch in s {freq[ch, default: 0] += 1
}
如果字符串长度小于 k,那肯定直接返回 0,不用多想。
2. 找出不合格的字符
如果某个字符出现次数小于 k,它就是整个问题的“拦路虎”。
因为任何包含它的子串都不可能满足要求。
for (ch, count) in freq {if count < k {// 找到第一个不合格字符...}
}
3. 分治拆分 + 递归求解
一旦发现“不合格的字符”,就以它为分界线把字符串切开。
举个例子:
s = "aaabbcc", k = 3
这里 'b'
只出现了 2 次,不达标。
那么就以 'b'
为分割点,把字符串分成两部分:
"aaa"
"cc"
然后递归在每一部分中分别求解。
最后取这两部分结果的最大值。
这一段的核心逻辑是:
let parts = s.split(separator: ch)
var maxLen = 0
for part in parts {maxLen = max(maxLen, longestSubstring(String(part), k))
}
return maxLen
4. 所有字符都合格
如果遍历完所有字符后都没有发现“不合格的字符”,那说明整个字符串本身就是合法的,直接返回 s.count
。
return s.count
示例测试及结果
我们来测试几个案例
let solution = Solution()print(solution.longestSubstring("aaabb", 3)) // 输出:3
print(solution.longestSubstring("ababbc", 2)) // 输出:5
print(solution.longestSubstring("aabcabb", 2)) // 输出:4
print(solution.longestSubstring("abcd", 2)) // 输出:0
输出结果:
3
5
4
0
解释:
"aaabb"
→"aaa"
是最长的合法子串;"ababbc"
→"ababb"
满足条件;"aabcabb"
→ 最长合法子串是"abba"
或"bcabb"
;"abcd"
→ 所有字符只出现一次,不满足k=2
。
时间复杂度
这道题的复杂度分析比较有趣。
最坏情况下,每次递归都可能遍历整个字符串,因此:
- 时间复杂度约为 O(26 * n),因为我们最多只会针对 26 个字母做拆分。
对一般情况来说,性能表现非常稳定。
空间复杂度
递归栈的深度最多为 26 层(因为每次可能由不合格的字符切分一次),
同时字典统计频次的空间是 O(26)。
所以整体空间复杂度是 O(26 + 递归深度) ≈ O(26),也就是常数级别。
总结
这道题的精髓就在于“分治”两个字。
我们不是去暴力枚举所有子串,而是通过统计信息快速确定哪些字符“拖后腿”,再把它们作为分界线递归处理剩余部分。
这种思路非常适合解决**“全局约束 + 局部分割”**类的问题,比如:
- “最长有效括号”
- “满足特定条件的连续区间”
- “日志分片分析”
在实际开发中,如果你在做文本分析(比如统计出现频率、提取关键词等),这种方法也能派上用场。
比如在做“日志聚类”时,你也可以先找出低频字符做分割,从而减少计算量。