题目描述

image.png

题目链接

https://leetcode.cn/problems/longest-substring-without-repeating-characters/

思路

我们先用一个例子考虑如何在较优的时间复杂度内通过本题。我们以字符串【3】无重复字符的最长子串 - 图2为例,找出从每一个字符开始的,不包含重复字符的最长子串,那么其中最长的那个字符串即为答案。对于示例字符串,我们列举出如下这些结果,其中括号中表示选中的字符以及最长的字符串:

  • 【3】无重复字符的最长子串 - 图3开始的最长字符串为【3】无重复字符的最长子串 - 图4
  • 【3】无重复字符的最长子串 - 图5开始的最长字符串为【3】无重复字符的最长子串 - 图6
  • 【3】无重复字符的最长子串 - 图7开始的最长字符串为【3】无重复字符的最长子串 - 图8
  • 【3】无重复字符的最长子串 - 图9开始的最长字符串为【3】无重复字符的最长子串 - 图10
  • 【3】无重复字符的最长子串 - 图11开始的最长字符串为【3】无重复字符的最长子串 - 图12
  • 【3】无重复字符的最长子串 - 图13开始的最长字符串为【3】无重复字符的最长子串 - 图14
  • 【3】无重复字符的最长子串 - 图15开始的最长字符串为【3】无重复字符的最长子串 - 图16
  • 【3】无重复字符的最长子串 - 图17开始的最长字符串为【3】无重复字符的最长子串 - 图18

发现了什么?如果我们依次递增地枚举子串的起始位置,那么子串的结束位置也是递增的!这里的原因在于,假设我们选择字符串中的第【3】无重复字符的最长子串 - 图19个字符作为起始位置,并且得到了不包含重复字符的最长子串的结束位置为【3】无重复字符的最长子串 - 图20。那么当我们选择第【3】无重复字符的最长子串 - 图21个字符作为起始位置时,首先从【3】无重复字符的最长子串 - 图22【3】无重复字符的最长子串 - 图23的字符显然是不重复的,并且由于少了原本的第【3】无重复字符的最长子串 - 图24个字符,我们可以尝试继续增大【3】无重复字符的最长子串 - 图25,直到右侧出现了重复字符为止。

这样一来,我们就可以使用「滑动窗口」来解决这个问题了:

  1. 我们使用两个指针表示字符串中的某个子串(或窗口)的左右边界,其中左指针代表着上文中「枚举子串的起始位置【3】无重复字符的最长子串 - 图26」,而右指针即为上文中的【3】无重复字符的最长子串 - 图27
  2. 在每一步的操作中,我们会将左指针向右移动一格,表示枚举下一个字符【3】无重复字符的最长子串 - 图28作为起始位置,然后我们可以不断地向右移动右指针,但需要保证这两个指针对应的子串中没有重复的字符。在移动结束后,这个子串就对应着「以左指针开始的,不包含重复字符的最长子串」。记录下这个子串的长度;
  3. 在枚举结束后,我们找到的最长的子串的长度即为答案。

判断重复字符
在上面的流程中,我们还需要使用一种数据结构来判断「是否有重复的字符」,常用的数据结构为哈希表。在左指针向右移动时,我们从哈希表中移除这个字符,在右指针向右移动时,我们往哈希表中添加一个字符。

代码实现

  1. public int lengthOfLongestSubstring(String s) {
  2. Set<Character> set = new HashSet<>();
  3. int res = 0;
  4. // 右指针初始值为-1,相当于我们在字符串的左边界的左侧,还没有开始移动
  5. int rk = -1;
  6. for (int k = 0; k < s.length(); k++) {
  7. // 左指针向右移动一格,移除一个字符
  8. if (k != 0) {
  9. set.remove(s.charAt(k - 1));
  10. }
  11. // 右指针不断右移,直到出现重复字符
  12. while (rk + 1 < s.length() && !set.contains(s.charAt(rk + 1))) {
  13. set.add(s.charAt(rk + 1));
  14. rk++;
  15. }
  16. // 记录最长的无重复字符子串
  17. res = Math.max(res, rk - k + 1);
  18. }
  19. return res;
  20. }

进阶实现:
上面的做法涉及哈希表频繁的插入删除操作,并且左指针和右指针会分别遍历整个字符串一次,实现较为啰嗦。实际上,我们可以用 HashMap 来保存字符及其下标位置,维护左右窗口的字符下标即可:

  1. public int lengthOfLongestSubstring(String s) {
  2. Map<Character, Integer> map = new HashMap<>();
  3. int res = 0;
  4. for (int k = 0, rk = 0; rk < s.length(); rk++) {
  5. char c = s.charAt(rk);
  6. if (map.containsKey(c)) {
  7. // 这里加一表示忽略第一个重复字符,即返回上一个重复字符的下一个位置
  8. k = Math.max(k, map.get(c) + 1);
  9. }
  10. res = Math.max(res, rk - k + 1);
  11. // 只需添加元素即可,因为重复字符会更新在map中的value值,value一直是最新下标
  12. map.put(c, rk);
  13. }
  14. return res;
  15. }

针对第 8 行代码,为什么要和 k 做一次 max 比较呢?举个例子,如果当前字符 c 包含在 map 中的话,也就是第 6 行代码成立时,此时有两类情况:

  1. 当前字符包含在当前有效的子串中,比如:abca,当我们遍历到第二个 a 时,当前有效最长子串是 abc,我们又遍历到 a,那么此时更新 k 为【3】无重复字符的最长子串 - 图29,当前有效子串更新后即为 bca;
  2. 当前字符不包含在当前最长有效子串中,如:abba,我们先添加 a、b 到 map 中,此时 k=0,之后再添加 b 时,发现 map 中包含 b 并且 b 包含在最长有效子串中,我们更新【3】无重复字符的最长子串 - 图30,此时有效子串为 b。随后,我们遍历到 a,发现 a 也包含在 map 中,如果我们还像第一种情况一样直接加一的话,就会发现 【3】无重复字符的最长子串 - 图31,实际上,k 此时应该不变,k 始终为 2,子串变成 ba 才对。

为了处理以上两类情况,于是我们每次更新【3】无重复字符的最长子串 - 图32

复杂度分析

  • 时间复杂度:【3】无重复字符的最长子串 - 图33,其中【3】无重复字符的最长子串 - 图34是字符串的长度,左指针和右指针分别会遍历整个字符串一次。
  • 空间复杂度:【3】无重复字符的最长子串 - 图35,其中【3】无重复字符的最长子串 - 图36表示字符集(即字符串中可以出现的字符),【3】无重复字符的最长子串 - 图37表示字符集的大小。在本题中没有明确说明字符集,因此可以默认为所有 ASCII 码在 [0,128) 内的字符,即【3】无重复字符的最长子串 - 图38。我们需要用到哈希表来存储出现过的字符,而字符最多有【3】无重复字符的最长子串 - 图39个,因此空间复杂度为【3】无重复字符的最长子串 - 图40