开篇:算法小白的困惑
作为一名算法爱好者,在刷题的道路上,想必大家都和我一样,有过迷茫与困惑。面对浩如烟海的算法题,常常感觉无从下手。就拿 “340. 至多包含 K 个不同字符的最长子串” 这道题来说,初次看到时,真的是一头雾水。题目要求找出给定字符串 s 中,至多包含 k 个不同字符的最长子串,并返回其长度。这看似简单的描述,实则暗藏玄机,要如何高效地找到这个子串呢?它不仅考验对字符串的处理能力,更涉及到一些巧妙的算法思维,而这正是其魅力与挑战所在。今天,就让我们一起深入探究这道题,看看如何一步步攻克它。
一、问题剖析
让我们先来仔细剖析这道题。题目给定一个字符串 s 和一个整数 k ,要求找出至多包含 k 个不同字符的最长子串,并返回其长度。例如,输入字符串 s = “eceba”,k = 2,那么满足条件的最长子串就是 “ece”,长度为 3 ;再比如输入 s = “aa”,k = 1,最长子串就是 “aa”,长度为 2 。从这些示例可以看出,要在字符串的众多子串中精准定位到符合条件的最长子串并非易事。
这道题的关键在于如何高效地遍历字符串,利用合适的数据结构来记录字符出现的情况,进而通过巧妙的指针移动找到最长子串。而滑动窗口和哈希表的结合,正是解决此类问题的一把利刃,后续我们会详细讲解它们是如何协同作战的。
二、滑动窗口 + 哈希表:解题 “王炸” 组合
(一)滑动窗口基础
滑动窗口,简单来说,就像是一个在字符串上灵活移动的 “窗框”,窗框的两边分别由两个指针 left 和 right 把控。初始时,这两个指针都指向字符串的开头,框住的就是字符串开头的第一个字符,这是初始的窗口。随着算法的推进,right 指针会像一个勤劳的探索者,一步步地向右移动,不断扩大这个窗口,去探索后面的字符;而 left 指针则像是一个谨慎的守护者,当窗口内的情况不符合要求时,它就会适时地向右移动,缩小窗口,确保窗口里的字符始终满足题目的条件。
我们可以通过一个生活中的例子来加深理解。假设你在一个长长的书架前找书,你想找到一段连续的书架区间,使得这个区间内最多只有 k 种不同类别的书。一开始,你的目光聚焦在书架的最左端(相当于 left 和 right 都指向开头),然后慢慢向右扫视(right 移动),一旦发现当前扫视的区间内书的类别超过了 k 种,就把目光的左边界向右挪一挪(left 移动),直到找到满足条件的最长区间。通过这两个指针的默契配合,就能高效地遍历整个字符串,找到我们想要的最长子串。
(二)哈希表的关键角色
哈希表在这个算法中扮演着至关重要的角色,它就像是窗口的 “记忆小助手”。哈希表主要用于记录字符出现的位置或频次,方便我们快速判断窗口内不同字符的数量。以字符串 “eceba” 为例,当 right 指针逐步向右移动时,哈希表可以记录每个字符出现的最新位置。一开始,哈希表为空,当 right 指向第一个 ‘e’ 时,哈希表记录 ‘e’ 的位置(假设索引从 0 开始,这里就是 0);接着 right 指向 ‘c’,哈希表更新为 ‘e’ 的位置 0,‘c’ 的位置 1;再到下一个 ‘e’,哈希表更新 ‘e’ 的位置为 2 。如此一来,我们通过查看哈希表的大小就能知道当前窗口内不同字符的数量,当哈希表大小超过 k 时,就知道需要移动 left 指针来调整窗口了。它能够让我们在 O (1) 的时间复杂度内完成字符的查找、插入和删除操作,大大提高了算法的效率,这在处理大规模数据的时候尤为关键。
三、步步为营:代码实现详解
(一)初始化工作
在 Java 代码实现中,首先要进行一些初始化操作。定义两个指针 left 和 right ,它们都初始化为 0 ,这表示滑动窗口起始位置在字符串的开头。同时,创建一个 LinkedHashMap ,这里使用 LinkedHashMap 是因为它既能像普通哈希表一样快速存取元素,又能保持元素插入的顺序,方便后续操作,其初始容量设置为 k + 1 ,这是为了应对后续可能出现的 k 个不同字符再加一个新字符的情况。还需要初始化一个变量 max_len ,用于记录最长子串的长度,初始值设为 1 ,这是考虑到字符串中至少有一个字符的情况,后续会不断更新这个最大值。这些初始化操作就像是搭建舞台,为后续精彩的算法表演做好准备。
int left = 0;
int right = 0;
LinkedHashMap<Character, Integer> hashmap = new LinkedHashMap<Character, Integer>(k + 1);
int max_len = 1;
(二)遍历字符串
接下来进入关键的遍历字符串环节。使用一个 while 循环,条件是 right 指针小于字符串的长度 n ,确保能遍历完整个字符串。在循环体内,首先获取当前 right 指针指向的字符 character 。接着,判断这个字符是否已经在哈希表 hashmap 中,如果是,先将其从哈希表中删除,这一步看似奇怪,实则精妙,目的是为了将该字符重新插入哈希表,使其成为哈希表中该字符的最新位置记录,保证后续判断窗口内字符顺序的准确性,然后将字符及其当前位置 right 插入哈希表,并将 right 指针向右移动一位。
while (right < n) {
Character character = s.charAt(right);
if (hashmap.containsKey(character))
hashmap.remove(character);
hashmap.put(character, right++);
此时,要检查窗口内的字符情况。如果哈希表的大小等于 k + 1 ,说明窗口内已经有了 k + 1 个不同字符,不符合题目要求,需要调整窗口。通过获取哈希表中第一个元素(即最左边的字符,也就是最早进入窗口的字符)的键值对,将其从哈希表中移除,同时更新 left 指针,使其指向这个被移除字符的下一个位置,这样就缩小了窗口,使得窗口内再次满足至多包含 k 个不同字符的条件。
if (hashmap.size() == k + 1) {
Map.Entry<Character, Integer> leftmost = hashmap.entrySet().iterator().next();
hashmap.remove(leftmost.getKey());
left = leftmost.getValue() + 1;
}
(三)更新最大长度
在每一次移动指针和调整窗口后,都需要更新最长子串的长度。通过比较当前 max_len 和 right - left 的大小,取较大值更新 max_len 。这里 right - left 表示当前窗口的长度,因为 right 是窗口的右边界(不包含),left 是窗口的左边界,它们的差值就是窗口内字符的数量。不断比较更新,就能确保 max_len 始终记录着满足条件的最长子串长度。
max_len = Math.max(max_len, right - left);
就这样,通过不断地遍历、调整窗口和更新最大长度,最终就能找到给定字符串中至多包含 k 个不同字符的最长子串的长度,看似复杂的过程,在代码的一步步执行下,变得清晰而高效。
四、代码示例:多语言实现
(一)Java 实现
public class LeetCode340 {
public int lengthOfLongestSubstringKDistinct(String s, int k) {
int n = s.length();
// 如果字符串为空或者k为0,直接返回0
if (n * k == 0) return 0;
// 滑动窗口左右指针,初始都指向字符串开头
int left = 0;
int right = 0;
// LinkedHashMap用于存储字符及其在滑动窗口中的最右位置,初始容量设为k + 1
LinkedHashMap<Character, Integer> hashmap = new LinkedHashMap<Character, Integer>(k + 1);
int max_len = 1;
while (right < n) {
Character character = s.charAt(right);
// 如果字符已在哈希表中,先移除再插入,保证其位置最新
if (hashmap.containsKey(character))
hashmap.remove(character);
hashmap.put(character, right++);
// 当哈希表中不同字符数量达到k + 1时,需要调整窗口
if (hashmap.size() == k + 1) {
// 移除最左边的字符,即最早进入窗口的字符
Map.Entry<Character, Integer> leftmost = hashmap.entrySet().iterator().next();
hashmap.remove(leftmost.getKey());
// 更新左指针,使其指向被移除字符的下一个位置
left = leftmost.getValue() + 1;
}
// 更新最长子串的长度
max_len = Math.max(max_len, right - left);
}
return max_len;
}
}
在这段 Java 代码中,通过滑动窗口和 LinkedHashMap 的协同工作,高效地解决了问题。LinkedHashMap的特性使得它既能快速操作元素,又能记住元素插入顺序,方便找到最左边的字符。每次移动指针和调整窗口后,及时更新最长子串长度,确保最终得到正确结果。
五、算法复杂度分析
(一)时间复杂度
时间复杂度方面,需要分情况讨论。在最好的情况下,如果字符串本身包含的不同字符数量不超过 k 个,那么只需要一次遍历就能得到结果,因为 right 指针从左到右依次移动,没有遇到需要频繁调整 left 指针的情况,时间复杂度为 O (N) ,这里的 N 是字符串的长度。然而,在最坏的情况下,当输入字符串包含了 n 个不同字符时,每一次当窗口内的不同字符数量达到 k + 1 个,需要调整 left 指针。而调整 left 指针时,为了找到需要移除的最左边字符,可能需要遍历当前窗口内的所有字符,这一步在最坏情况下每次需要花费 O (k) 的时间,由于 right 指针总共要移动 N 次,所以总的时间复杂度就是 O (Nk) 。这种最坏情况的出现,就像是在一个装满各种杂物的大箱子里,要不断地挑选出特定的几种物品,而且每次挑选都要翻遍几乎整个箱子,非常耗时。
(二)空间复杂度
空间复杂度相对较为直观,主要取决于哈希表所占用的空间。在算法执行过程中,哈希表最多存储 k + 1 个不同的字符及其对应的位置信息,因为一旦哈希表中字符数量达到 k + 1 ,就会触发窗口调整,移除最左边的字符,使得哈希表中的元素数量始终控制在 k + 1 以内。所以,空间复杂度为 O (k) ,这里的 k 是题目给定的参数,表示最多允许的不同字符数量。这就好比我们出门背包,最多装 k + 1 件不同的物品,背包的空间占用取决于这个 k 的大小。通过对时间和空间复杂度的分析,能让我们更全面地了解这个算法的性能,在实际应用中根据需求选择最合适的解法。
六、举一反三:类似题目拓展
掌握了这道题的解法后,我们不妨来看看一些与之类似的题目,进一步拓展思维。
比如 “至多包含 K 种字符的子串数量”,这道题与我们今天讲的题目的联系在于,都是围绕字符串中字符的种类和子串的特性展开。但解法上存在差异,在求解子串数量时,需要在滑动窗口移动过程中,更加细致地统计满足条件的子串出现的次数,每当窗口内字符种类符合条件时,子串数量就要相应增加,这涉及到对窗口内字符变化的更精准把控,不像原题目只需关注最长子串长度。
再如 “找到包含所有出现次数不少于 k 次的字符的最长子串”,它同样利用滑动窗口思想,但重点在于先统计字符出现频次,然后在窗口移动时,确保窗口内不仅字符种类受限,还要满足特定字符的出现次数要求,需要多一层频次判断逻辑,相比之下,今天的题目则聚焦于单纯的字符种类数量。
通过接触这些类似题目,大家可以发现,虽然它们各有难点,但核心的滑动窗口和哈希表等基础方法是相通的,只要掌握了这些基础,再遇到新的变形题,也能逐步拆解,找到解题思路,希望大家在算法学习的道路上越走越远,攻克更多难题。
七、总结
至此,我们通过 “滑动窗口 + 哈希表” 的巧妙组合,成功攻克了 “340. 至多包含 K 个不同字符的最长子串” 这道算法难题。滑动窗口如同精准的探测器,在字符串的世界里快速穿梭,寻找符合条件的子串区域;哈希表则像一位可靠的记录员,清晰地记录字符的出现情况,为窗口的调整提供关键依据。
在学习算法的道路上,遇到困难是常有的事,但每一道难题都是成长的阶梯。希望大家通过这道题的学习,不仅掌握了解题方法,更能体会到算法思维的精妙之处。多练习、多思考、多总结,相信大家在面对其他算法挑战时,也能游刃有余,逐步提升自己的编程能力,向着算法高手的目标奋勇前进,去征服更多未知的算法高峰。