3. 无重复字符的最长子串
给定一个字符串 s
,请你找出其中不含有重复字符的 最长子串 的长度。
示例 1:
输入: s = "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
示例 2:
输入: s = "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。
示例 3:
输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。
提示:
0 <= s.length <= 5 * 104
s
由英文字母、数字、符号和空格组成
我的解题:
关于这道题我基本上是完全没有思路,不知怎么去做,因此我只能看官方给出的题解了。
官方解题:
以abcabcbb
为例,找出从每一个字符开始的,不包含重复字符的最长子串,这样遍历下来,其中最长的字符串就是答案。这里有一个简化问题,转化问题的思想,因为最长的子串总会有一个开头,这个开头在原字符串中实际上是随即存在的,因此我们从每一个字符开始研究,从他开始往后找每以它为开头的最长不重复串,最终从这些结果中确定最长的,就是我们想要找的答案。实际上这里使用到了一个数学中经常用到的方法,那就是我们在寻找组合的时候,如在数学中我们会遇到一种比赛挑对手的问题,如有六个人ABCDEF,他们六个人要两两进行一次比赛,那么我们通常会画出下面这样的图:
尽管我没有画完,但是我们也已经能够看出我们找组合的方式是从A开始,和后边的每个人都匹配一次,然后从B开始,忽略掉A,然后和后边的每个人再打一次,然后重复这个过程,从C开始,和后边的每个人都打一次…这样,我们就可以遍历得到这个团体中的所有的两两组合,而这里的子串遍历方法也体现了这个思想:试想一下如果我们想要找到这个字符串中所有的子串,我们应该怎样做呢?我们想要找到这个字符串中的每一个子串,实际上也是依托于从每个字符开始为基点来进行寻找的,我们会从每个字符出发,然后寻找以它为起始点的各种子串,这样一来我们就能保证找到所有的子串,且不会出现重复的状况,因为每一个子串的起点都不相同(当然如果有相同字符的话,会出现相同的状况,但是这样找我们可以保证每个子串中都不是完全的同一个字符,并且可以保证找到所有子串)。这个思路实际上就是通过分类的思想,给所有子串加一个可以完全区分的分类,然后根据这个分类去找,这里的分类依据就是首字符是否是同一个位置的字符,而依托于这种思想我们再加上长度限制以及字符重复情况的限制,就可以找到这个字符串中所有的具备成为最长不重复子串的资格的子串了,之后我们再从这个群体中筛选即可。
现在我们继续研究官方给出的解题思路,根据上述的寻找方法我们可以进行这样的寻找:
以 (a)bcabcbb 开始的最长字符串为 (abc)abcbb
以 a(b)cabcbb 开始的最长字符串为 a(bca)bcbb
以 ab(c)abcbb 开始的最长字符串为 ab(cab)cbb
以 abc(a)bcbb 开始的最长字符串为 abc(abc)bb
以 abca(b)cbb 开始的最长字符串为 abca(bc)bb
以 abcab(c)bb 开始的最长字符串为 abcab(cb)b
以 abcabc(b)b 开始的最长字符串为 abcabc(b)b
以 abcabcb(b) 开始的最长字符串为 abcabcb(b)
通过这个寻找过程我们可以发现,如果像这样依次枚举子串的起始位置,然后去寻找以每个字符为起点的最长不重复子串,这个最长不重复子串的结束位置也是递增的,这是为什么呢?我们可以这样理解:每次往后挪动一个起始位置的过程,都会让这个子串中的字符更少,字符更少意味着如果当前子串不向后扩张的话,其内部字符和后边外部的字符重复的概率会降低,也就是说,这个过程使得该子串具备了更大的向后扩张的势能或者潜力,因此这个子串极有可能会向后扩张,当然也有可能不向后扩张,因为可能恰巧后边一个字符和里边的一个字符是重复的,简而言之,每次起始位置的向后移动,都会导致当前的字符串先变短(一定),然后再边长,如果一次挪动导致了该字符串内部恰好没有和后边相同的字符了,那么这个字符串就有可能向后移动一位,而如果仍然有,那么这个字符串就不向后扩张,因此每次起始点向前移动,都会导致字符串的结尾向后移动一位或者是不移动,而不可能向前移动,因此子串的结束为止一定也是递增的,在某种情况下,一个子串会缩短到只有一个长度单位,在这个时候,它的结束位置和起始位置重合且在之后的扩张中要么边长,要么直接向后移动一位,这时它的结束位置仍然是递增的。
按照官方题解中给出的解释:假设我们选择字符串中第k个字符作为起始位置,并且得到了不包含重复字符的最长子串的结束位置 r k r_k rk。当我们选择第k+1个字符作为起始位置时,首先从 k + 1 k+1 k+1,到 r k r_k rk,的字符一定不重复,并且由于缺少了原本的第k个字符,我们可以尝试增大 r k r_k rk,知道右侧出现了重复字符为止。
这样一来就可以使用滑动窗口来解决这个问题了:
使用像个指针表示字符串中的某个子串,这个两个指针表示的东西就是窗口,其中左指针代表上文中的[枚举子串的起始位置],而右指针代表上文中的rk;
在每一步的操作中,我们会将左指针向右移动一格,表示我们开始枚举下一个字符作为起始位置,然后我们可以不断的向右移动右指针,但是需要保证这两个指针对应的子串中没有重复的字符,在移动结束后,这个子串就对应着以左指针开始的,不包含重复字符的最长子串,我们记录下这个子串的长度;
在枚举结束之后,我们找到的最长的子串的长度即为答案。
判断重复字符:在上面的流程中还需要一种比较高效率的重复字符判断机制,我们当然可以使用遍历的方式在寻找,但是这种方式其实非常浪费时间,因此我们使用哈希集合的方式来判断,C++中的std:unordered_set,Java中的HashSet,Python中的set等,在左指针向右移动的时候,我们就从哈希集合中移除一个字符,在右指针向右移动的时候我们向哈希集合中添加一个字符,因为我们的滑动窗口中的字符串中的字符一定没有重复,因此整个滑动窗口的伸长和缩短都一定对应哈希集合中的增添和删除。
个人理解
我认为这种滑动窗口非常像毛毛虫的运动,或者说是贪吃蛇的运动,这个窗口尽管长度会发生意想不到的变化,但是其结构一定是连续的,我们在对这个贪吃蛇进行尝试性的扩张时,仅需要考虑其结束部分的后一个元素即可,而完全没有必要考虑其后边的两个或者是三个,每次只需要考虑一个即可,如果这一个不行,那么后边的肯定免谈,因为其必须连续,不可能中间加入了一个重复字符,再往后跳一个字符继续加入新字符了,一旦出现重复的字符,扩张行为就要直接停止,并宣布以这个字符为开始节点的子串的扩张结束了。
这个题实际上就是告诉了我们如何找到最长子串,滑动窗口实际上是代码的书写方式,最重要的还是这个找最长不重复子串的思路,也就是这种以每一个字符为起始点,然后找以它为起始点的最长子串,然后找这个字符串中所有的这样的子串中最长的那个。实际上就是以某一种特征为依托,找到这个字符串中所有的具备成为最长子串的潜力的所有子串,然后再进行筛选,在此我们实际上就是通过某种方法对所有子串进行分类,然后找到每个分类中的最长不重复子串,这种分类实际上不一定是通过这里这种方法分类,但是分类机制一定是一种绝对的不重合分类,这样我们才能绝对严谨的进行筛查。注意这种分析方法以及记住这种分类方法,以及记住滑动窗口方法,这是一种重要的解题代码书写方法。
代码:
public int lengthOfLongestSubstring(String s) {
int maxSize = 0;
for (int i = 0; i < s.length(); i++) {
Set<Character> uniqueChar = new HashSet<Character>();
//System.out.println(maxSize);
uniqueChar.add(s.charAt(i));
if (i < s.length() - 1) {
int j = i + 1;
int length = 1;
while (true) {
if (j < s.length() && !uniqueChar.contains(s.charAt(j))) {
// System.out.print(s.charAt(j));
uniqueChar.add(s.charAt(j));
length++;
j++;
} else {
if (length > maxSize) {
maxSize = length;
}
break;
}
}
} else {
if (1 > maxSize) {
maxSize = 1;
}
}
}
return maxSize;
}
这个代码的时间复杂度和空间复杂度并不是很好,因此以后我还会研究其他的方法。