16285 字
约 54 分钟
1
003.无重复字符的最长子串
无标签

003.无重复字符的最长子串

作者:沉默王二 发布时间:2023-12-12T17:50:45.698+0800


之前星球还没有 Markdown 的功能, 所以第一次发布的格式在星球里阅读的话很不友好,于是二哥打算重新优化一版《二哥的 LeetCode 刷题笔记》:

  • 增加代码注释,这样大家更容易看懂并掌握题解;
  • 增加参考资料,这样大家可以从零基础直接开刷,减轻新手刷题的阻力;
  • 增加手绘图,这样大家更容易理解解题思路;
  • 增加 ACM 输入输出,这样大家可以在 Intellij IDEA 中进行调试;
  • 增加题解模拟,这样大家可以根据题解模拟整个题解过程,看完会有一种“哦哦”恍然大悟的感觉;
  • 图片上传至 OSS 并配合 CDN,提升大家在技术派网站中的阅读体感。
  • 另外,这一版本会作为 PDF 的终稿随后在星球进行发布。

并且增加了技术派专栏的阅读地址,只需要绑定星球编号就可以通过「微信扫码登录」的方式在技术派网站上阅读,这样大家的阅读选择也会更加多样化(语雀、技术派、星球都可以,喜欢哪个用哪个)。

我知道,市面上已经有不少的刷题笔记了,二哥也看过很多,但讲真,有些还是看不太懂,可能是因为天赋不够,也可能是因为这些刷题笔记一开始是针对 C语言、Python、Go 或者 C++ 的,后来才翻译成 Java 语言,就导致阅读起来并不幸福🥰。

那我希望《二哥的 LeetCode 刷题笔记》能够给学习算法的球友提供一些不一样的刷题思路,从我自己的角度出发,题解可能会更容易掌握,当然了,最重要的还是教给大家举一反三的解题能力。

这对于那些打算冲中大厂的球友来说,至关重要,因为中大厂几乎都会有算法的笔试题,然后才会有面试机会。当然了,对于那些想通过刷题来学习 Java 的球友,无疑也会多一种选择,冲。

在这里,必须要感谢球友 @炳源,是我们的通力配合,才有了球友们看到的这份市面上独一无二的 LeetCode 刷题笔记。好,就让我们开始吧!

《二哥的 LeetCode 刷题笔记》真的经典。

------------------------by鲁迅

题意

给定一个字符串 s ,请你找出其中不含有重复字符的 最长子串 的长度。

示例

输入:s="abcabcbb"

解释:因为无重复字符的最长子串是"abc",所以其长度为3。

难度

中等

分析1

看完这道题目的描述,脑袋里面要先搞清楚几个概念:

  • 什么是子串?
  • 什么是最长子串?
  • 什么是不含重复字符的最长子串?

这三个概念搞清楚,才能去写题解,对吧?

什么是子串?拿题目给出的示例来说,abcabcbb,它的子串有:

a、b、c、a、b、c、b、b这种单个子串

abc、bca、cab、bcb、cbb这种三个字符组成的子串abca、bcab、cabc、abcb这种四个字符组成的子串abcab、bcabc、cabcb、abcbb这种五个字符组成的子串abcabc、bcabcb、cabcbb这种六个字符组成的子串abcabcbbcabcbb这种七个字符组成的子串abcabcbb这种八个字符组成的子串

什么是最长子串?

就是子串中字符个数最多的那个子串,比如上面的例子中,abcabcbb就是最长子串。

什么是不含重复字符的最长子串?

就是最长子串中没有重复字符的那个子串,abcabcbb 虽然是最长子串,但有重复字符 a、b、c,所以不是不含重复字符的最长子串。

abcabcb 也不是,因为有重复字符 a、b、c。

abcabc 也不是,因为有重复字符 a、b、c。

abcab 也不是,因为有重复字符 a、b。

abca 也不是,因为有重复字符 a。

排除到最后,你会发现,不含重复字符的最长子串有这么几个:

abc、bca、cab

OK,答案出来了,长度为 3。

借着这个思路,我们直接来暴力解题。

class Solution {
    int res = 0; // 用于存储最长子串的长度        // 外层循环,从字符串的第一个字符开始        for (int i = 0; i < s.length(); i++) {            boolean[] book = new boolean[300]; // 布尔数组,用于标记字符是否出现过
        // 内层循环,从当前字符向前遍历            for (int j = i; j >= 0; j--) {                // 如果字符已经在子串中出现过,结束内层循环                if (book[s.charAt(j)])                     break;
            // 标记当前字符为出现过                book[s.charAt(j)] = true;                // 更新最长子串的长度                res = Math.max(res, i - j + 1);            }        }        return res; // 返回最长子串的长度    }}

两层循环,第一层循环用来确定起始位置,第二层循环用来确定结束位置,向前去找最长的子串,如果字符出现过,我们就标记它为 true,并且计算最长子串的长度。

①、题目说 s 由英文字母、数字、符号和空格组成,那么它很可能是 ASCII 码字符集 ,在 ASCII 字符集中,有 128 个不同的字符,这包括了大小写英文字母(52个)、数字(10个)、标点符号和特殊字符(如空格、制表符等),以及一些控制字符。

那为了节省空间,数组 book 的长度其实可以声明为 128,而不是 300。

boolean[] book = new boolean[128]; // 足以覆盖所有 ASCII 字符

当然了,笔试的时候时间紧张,如果没办法考虑周全的话,300 肯定是一个保险的方案,确保出现的字符都能够容纳得下。

②、i - j + 1 用来计算子串的长度,这里的 i 是外层循环的循环变量,j 是内层循环的循环变量,i - j + 1 就是子串的长度。

  • i 是子串末尾字符的索引。
  • j 是子串起始字符的索引。
  • i - j 是子串起始和结束字符之间的距离(即子串的长度减去 1)。
  • 加上 1 (i - j + 1) 是为了包含起始字符在内的整个子串的长度。

在例子 "abcabcbb" 中,当我们考虑以第一个 'b'(索引 1)结尾的子串时:

  • 当 i = 1('b' 的位置)和 j = 1(仍然是 'b' 的位置)时,i - j + 1 = 1,表示子串 "b" 的长度。
  • 当 i = 1 和 j = 0('a' 的位置)时,i - j + 1 = 2,表示子串 "ab" 的长度。

当字符串 s = "abcabcbb" 时,我们用该题解来模拟一下整个题解的过程。

  • 初始化:最长子串长度 res = 0。
  • 开始外层循环:逐字符遍历字符串 s。
  • i = 0(字符 'a'):
  • 初始化 book 数组,其中所有值均为 false。
  • 内层循环:从 j = 0 开始向前遍历。
  • 检查 'a' 是否已经出现过(book[s.charAt(0)]),结果为 false。
  • 标记 'a' 为出现过(book['a'] = true)。
  • 更新 res 为 max(res, i - j + 1),即 max(0, 0 - 0 + 1) = 1。
  • 退出内层循环。
  • 注意:这里的子串是 "a",长度为 1。
  • i = 1(字符 'b'):
  • 初始化 book 数组,其中所有值均为 false。
  • 内层循环:从 j = 1 开始向前遍历。
  • 检查 'b' 是否已经出现过,结果为 false。
  • 标记 'b' 为出现过。
  • 更新 res 为 max(1, 1 - 1 + 1) = 1。
  • 内层循环:j = 0。
  • 检查 'a' 是否已经出现过,结果为 false。
  • 标记 'a' 为出现过。
  • 更新 res 为 max(1, 1 - 0 + 1) = 2。
  • 退出内层循环。
  • 注意:这里的子串是 "ab",长度为 2。
  • i = 2(字符 'c'):
  • 初始化 book 数组,其中所有值均为 false。
  • 内层循环:从 j = 2 开始向前遍历。
  • 检查 'c' 是否已经出现过,结果为 false。
  • 标记 'c' 为出现过。
  • 更新 res 为 max(1, 2 - 2 + 1) = 1。
  • 内层循环:j = 1
  • 检查 'b' 是否已经出现过,结果为 false。
  • 标记 'b' 为出现过。
  • 更新 res 为 max(1, 2 - 1 + 1) = 2。
  • 内层循环:j = 0
  • 检查 'a' 是否已经出现过,结果为 false。
  • 标记 'a' 为出现过。
  • 更新 res 为 max(2, 2 - 0 + 1) = 3。
  • 退出内层循环。
  • 注意:这里的子串是 "abc",长度为 3。
  • i = 3(第二个字符 'a'):
  • 初始化 book 数组,其中所有值均为 false。
  • 内层循环:从 j = 3 开始向前遍历。
  • 检查 'a' 是否已经出现过,结果为 false。
  • 标记 'a' 为出现过。
  • 更新 res 为 max(3, 3 - 3 + 1) = 1。
  • 内层循环:j = 2
  • 检查 'c' 是否已经出现过,结果为 false。
  • 标记 'c' 为出现过。
  • 更新 res 为 max(3, 3 - 2 + 1) = 2。
  • 内层循环:j = 1
  • 检查 'b' 是否已经出现过,结果为 false。
  • 标记 'b' 为出现过。
  • 更新 res 为 max(3, 3 - 1 + 1) = 3。
  • 内层循环:j = 0
  • 检查 'a' 是否已经出现过,结果为 true。
  • 退出内层循环。
  • 注意:这里的子串是 "bca",长度为 3。
  • 继续外层循环,重复以上过程,直到 i 遍历完整个字符串。
  • 结束遍历:所有字符都已被检查。
  • 返回结果:最长无重复字符子串的长度 res。在这个例子中,最长的无重复字符子串长度为 3。

对 for 循环或者 break 比较陌生的球友,可以通过 ACM 的模式在 Intellij IDEA 中 debug 以下,瞬间就明白了。

public class Main003 {
    Scanner scanner = new Scanner(System.in);        System.out.println("输入字符串: ");        String s = scanner.nextLine(); // 从标准输入读取字符串
    Solution solution = new Solution();        int length = solution.lengthOfLongestSubstring(s); // 调用解题函数
    System.out.println("没有重复的最长子串为: " + length); // 输出结果    }}

class Solution { public int lengthOfLongestSubstring(String s) { int res = 0; for (int i = 0; i < s.length(); i++) { boolean[] book = new boolean[128]; // ASCII 字符集 for (int j = i; j >= 0; j--) { if (book[s.charAt(j)]) { break; } book[s.charAt(j)] = true; res = Math.max(res, i - j + 1); } } return res; }}

好,虽然通过两层循环也能解出来,但是这种暴力解法的时间复杂度是 ,效率不高,我们需要想办法优化一下。

分析 2

接下来,我们介绍一个在Java中被隐藏起来的概念——指针,在C/C++中,指针是一种特殊的变量类型,用来记录变量的值在内存中的位置。

在数据结构中,也有一个指针的概念,但它和C/C++中的指针不太一样,虽然它也指向一个的位置,但并非指向存储值的物理位置,而是指向其在数据结构中的相对位置,就如同 Java 中熟知的下标(索引)一样,我们来看一段熟悉的代码:

for(inti=1;i

上面的代码中,i其实就是一个指针,被我们用来表示数组 a 中 1到n 的位置,实际上这就是数据结构中指针的概念。

再来看这段代码,在二分查找中:

int lef = 1, rig = n;

    int mid = (lef + rig) / 2; // 计算中间位置    if(a[mid] < goalVal)        lef = mid; // 如果中间位置的值小于目标值,调整左边界    else        rig = mid; // 如果中间位置的值大于或等于目标值,调整右边界}

这里面的lef,rig,mid其实都是指针,指向数组中的某个位置,然后再用a[](a是一个数组)来取出这个位置的元素进行判断。

我们再来横向比较一下引用、指针、下标这些容易混淆的概念:

![图片](https://clxhuhhr.top/uploads/weblog/erge/erge_lc_image_18.png)

明白这一点,我们再来讲一个算法中经常出现的概念——滑动窗口。

题目要我们求解的是一个区间,所以我们可以考虑这样两个指针,一个指向这个区间的开头,另外一个则指向这个区间的结尾。

我们让头指针不动,向后扩展尾指针,直到遇见重复元素(见下图):

![图片](https://clxhuhhr.top/uploads/weblog/erge/erge_lc_image_19.png)

图片出自**[LeetCode 的一个作者 Ikaruga](https://leetcode.cn/problems/longest-substring-without-repeating-characters/solutions/228576/longest-substring-without-repeating-characters-b-2/)**

否则,向后移动头指针,重复之前的动作。

一个位置一个位置移动,也太低效了,有没有更好的办法呢?

我们可以用一个 **[HashMap](https://javabetter.cn/collection/hashmap.html)** 来存储每个字符的位置,这样我们就可以直接跳到这个字符上次出现的位置,而不用一个一个地移动头指针了。

那由头指针和尾指针组成的区间,我们称之为**滑动窗口**。我用图简单来画一下大家就明白了。

![图片](https://clxhuhhr.top/uploads/weblog/erge/erge_lc_image_20.png)

学过计算机网络的同学,应该都知道滑动窗口协议,用于网络数据传输时的流量控制,以避免网络拥塞。算法中的滑动窗口主要用来解决数组/字符串的子元素问题,比如今天这道题目,在字符串中寻找最长不含重复字符的子串。

![图片](https://clxhuhhr.top/uploads/weblog/erge/erge_lc_image_21.png)

图片来自博客园的huansky

推荐阅读资料:**[博客园上这篇关于滑动窗口的帖子写的非常不错](https://www.cnblogs.com/huansky/p/13488234.html)**

我们来看一下滑动窗口的题解:

class Solution {

        HashMap<Character, Integer> rempos = new HashMap<>(); // 创建一个哈希表来记录字符最近一次出现的位置        int res = 0; // 用于存储最长子串的长度        // 初始化两个指针 i 和 j,表示当前考虑的子串的头和尾        for (int i = 0, j = 0; i < s.length(); i++) {            // 检查当前字符是否出现过            if (rempos.containsKey(s.charAt(i))) {                // 如果出现过,则更新 j 的位置为该字符上次出现位置的下一个位置                // 保证子串 [j, i] 不包含重复字符                j = Math.max(j, rempos.get(s.charAt(i)));            }            // 更新最长子串的长度            res = Math.max(res, i - j + 1);            // 更新当前字符的位置信息            rempos.put(s.charAt(i), i + 1);        }        return res; // 返回最长子串的长度    }}

滑动窗口方法只需要一次遍历:

- 使用两个指针(通常称为头指针和尾指针)创建一个动态的窗口。这个窗口代表当前考虑的子串。
- 移动尾指针:逐个检查字符,扩展窗口。
- 移动头指针:一旦遇到重复字符,就移动头指针,缩小窗口直到子串中不再包含重复字符。

![图片](https://clxhuhhr.top/uploads/weblog/erge/erge_lc_image_22.png)

使用 **[HashMap](https://javabetter.cn/collection/hashmap.html)** 记录每个字符最近一次出现的位置([rempos.put](http://rempos.put)(s.charAt(i), i + 1))。一旦出现重复字符(rempos.containsKey(s.charAt(i))),就可以移动头指针(j = [Math.max](http://Math.max)(j, [rempos.get](http://rempos.get)(s.charAt(i)))),缩小窗口。

当输入 s = "abcabcbb" 时,我们按照滑动窗口的方法来模拟题解过程:

- **初始化**:建立一个哈希表 rempos 来记录字符最近出现的位置,初始化最长子串长度 res = 0,以及两个指针 i = 0(尾指针)和 j = 0(头指针)。
- **开始遍历字符串 s**:
- i = 0,字符为 'a':
- rempos 中没有 'a',将 'a' 添加到 rempos('a': 1),res = max(res, i - j + 1) = max(0, 0 - 0 + 1) = 1。
- 此时子串为a,长度为 1。
- i = 1,字符为 'b':
- rempos 中没有 'b',将 'b' 添加到 rempos('b': 2),res = max(1, 1 - 0 + 1) = 2。
- 此时子串为ab,长度为 2。
- i = 2,字符为 'c':
- rempos 中没有 'c',将 'c' 添加到 rempos('c': 3),res = max(2, 2 - 0 + 1) = 3。
- 此时子串为abc,长度为 3。
- i = 3,字符为 'a':
- rempos 中有 'a',它的位置是 1。将 j 更新为 max(j, [rempos.get](http://rempos.get)('a')) = max(0, 1) = 1。
- 此时子串为bca,长度为 3。
- 更新 rempos 中 'a' 的位置为 4。
- res = max(3, 3 - 1 + 1) = 3。
- i = 4,字符为 'b':
- rempos 中有 'b',它的位置是 2。将 j 更新为 max(j, [rempos.get](http://rempos.get)('b')) = max(1, 2) = 2。
- 此时子串为cab,长度为 3。
- 更新 rempos 中 'b' 的位置为 5。
- res = max(3, 4 - 2 + 1) = 3。
- i = 5,字符为 'c':
- rempos 中有 'c',它的位置是 3。将 j 更新为 max(j, [rempos.get](http://rempos.get)('c')) = max(2, 3) = 3。
- 此时子串为abc,长度为 3。
- 更新 rempos 中 'c' 的位置为 6。
- res = max(3, 5 - 3 + 1) = 3。
- i = 6,字符为 'b':
- rempos 中有 'b',它的位置是 5。将 j 更新为 max(j, [rempos.get](http://rempos.get)('b')) = max(3, 5) = 5。
- 此时子串为cb,长度为 2。
- 此处不更新 res。
- 更新 rempos 中 'b' 的位置为 7。
- res = max(3, 6 - 5 + 1) = 2。
- i = 7,字符为 'b':
- 再次遇到 'b',rempos 中有 'b',它的位置是 7。将 j 更新为 max(j, [rempos.get](http://rempos.get)('b')) = max(5, 7) = 7。
- 此时子串为b,长度为 1。
- 此处不更新 res。
- 更新 rempos 中 'b' 的位置为 8。
- **遍历结束**:字符串遍历完成。
- **输出结果**:最长无重复字符子串的长度为 res = 3。

如果还是不明白的话,可以通过 ACM 的模式调试一下,一步一步跟着代码走,就明白了。

public class Main00301 {

        Scanner scanner = new Scanner(System.in);        System.out.println("输入字符串: ");        String s = scanner.nextLine(); // 从标准输入读取字符串
        int length = lengthOfLongestSubstring(s); // 调用解题函数        System.out.println("没有重复的最长子串为: " + length); // 输出结果    }
    public static int lengthOfLongestSubstring(String s) {        HashMap<Character, Integer> rempos = new HashMap<>();        int res = 0;        for (int i = 0, j = 0; i < s.length(); i++) {            System.out.println("i: " + i + ", j: " + j);            if (rempos.containsKey(s.charAt(i))) {                j = Math.max(j, rempos.get(s.charAt(i)));            }            // 把子串也打印出来            System.out.println("sub: " + s.substring(j, i + 1));            res = Math.max(res, i - j + 1);            rempos.put(s.charAt(i), i + 1);        }        return res;    }}

在这个过程中,滑动窗口从每个可能的起始位置开始,尽可能地向右扩展。每当遇到重复字符时,窗口的左边界就会向右移动,以保证窗口内没有重复字符。通过这种方式,我们能够找出最长的无重复字符子串。

![图片](https://clxhuhhr.top/uploads/weblog/erge/erge_lc_image_23.png)

时间复杂度:

因为每个元素尾指针只访问过一遍,头指针依靠尾指针访问过的信息进行移动,所以时间复杂度比之前的暴力方法低了一个次方级别。

### **总结**

本次我们用了新的算法:**[滑动窗口](https://www.bilibili.com/video/BV14Q4y1P7Np/)**(可以戳视频链接再加深一下印象),一次遍历,通过移动头指针和尾指针来找出一个区间,相对于暴力的两次遍历,显然时间复杂度大大降低了。

这次的题解涉及到的知识点相对较多,但题解中都给出了对应的学习资料,相信大家看完后都能串联到一起,形成一个完整的知识体系。

其中的字符串可以参考下面这个链接:

- **[字符串](https://javabetter.cn/string/string-source.html)**

涉及到一个两个数中求最大的数学操作类 Math 我这里简单帮大家总结一下:

- [Math.abs](http://Math.abs)(x):返回x的绝对值。
- [Math.ceil](http://Math.ceil)(x):返回大于或等于x的最小整数。
- [Math.floor](http://Math.floor)(x):返回小于或等于x的最大整数。
- [Math.round](http://Math.round)(x):将x四舍五入为最接近的整数。
- [Math.max](http://Math.max)(x, y):返回x和y中的较大值。
- [Math.min](http://Math.min)(x, y):返回x和y中的较小值。
- [Math.pow](http://Math.pow)(x, y):返回x的y次方。
- [Math.sqrt](http://Math.sqrt)(x):返回x的平方根。
- [Math.sin](http://Math.sin)(x):返回x的正弦值。
- [Math.cos](http://Math.cos)(x):返回x的余弦值。
- [Math.tan](http://Math.tan)(x):返回x的正切值。
- [Math.random](http://Math.random)():返回一个大于等于0且小于1的随机浮点数。

力扣链接:**[https://leetcode.cn/problems/longest-substring-without-repeating-characters/](https://leetcode.cn/problems/longest-substring-without-repeating-characters/)**

### **一步一个脚印**

不积跬步无以至千里,不积小流无以成江海。LeetCode - 100天从算法小白到卷王正式启动了,我们计划周一到周五至少每天更新一篇,周六周日更新一篇,目标 300 道 LeetCode 经典题。

该题解目前只针对**[二哥编程星球](https://javabetter.cn/zhishixingqiu/)**的球友开放,如果你也想一起打卡学习算法的话,扫下面的优惠券加入我们吧,星球目前 4100+ 人,定价 149 元,从当初的 99 元一路涨上来的,所以早就是优势,别再犹豫了。

![图片](https://clxhuhhr.top/uploads/weblog/erge/erge_lc_image_24.png)

(优惠券仅 300 张,数量有限)

一个人可以走得很快,但一群人才能走得更远。如果想去面中大厂的话,LeetCode 几乎是必刷的,二哥的 LeetCode 题解通俗易懂、图文并茂,理解给你举一反三的解题能力,冲鸭。

        
        
之前星球还没有 Markdown 的功能, 所以第一次发布的格式在星球里阅读的话很不友好,于是二哥打算重新优化一版《二哥的 LeetCode 刷题笔记》:

- 增加代码注释,这样大家更容易看懂并掌握题解;
- 增加参考资料,这样大家可以从零基础直接开刷,减轻新手刷题的阻力;
- 增加手绘图,这样大家更容易理解解题思路;
- 增加 ACM 输入输出,这样大家可以在 Intellij IDEA 中进行调试;
- 增加题解模拟,这样大家可以根据题解模拟整个题解过程,看完会有一种“哦哦”恍然大悟的感觉;
- 图片上传至 OSS 并配合 CDN,提升大家在技术派网站中的阅读体感。
- 另外,这一版本会作为 PDF 的终稿随后在星球进行发布。

并且增加了技术派专栏的阅读地址,只需要绑定**星球编号**就可以通过「**微信扫码登录**」的方式在技术派网站上阅读,这样大家的阅读选择也会更加多样化(语雀、技术派、星球都可以,喜欢哪个用哪个)。

- 语雀专栏地址+密码可通过星球置顶帖查看:[https://t.zsxq.com/15rEo9Pdu](https://t.zsxq.com/15rEo9Pdu)
- 技术派专栏地址:[https://paicoding.com/column/7/3](https://paicoding.com/column/7/3)
- 星球原地址:[https://t.zsxq.com/15PCRWOYj](https://t.zsxq.com/15PCRWOYj)

我知道,市面上已经有不少的刷题笔记了,二哥也看过很多,但讲真,有些还是看不太懂,可能是因为天赋不够,也可能是因为这些刷题笔记一开始是针对 C语言、Python、Go 或者 C++ 的,后来才翻译成 Java 语言,就导致阅读起来并不幸福🥰。

那我希望《**二哥的 LeetCode 刷题笔记**》能够给学习算法的球友提供一些不一样的刷题思路,从我自己的角度出发,题解可能会更容易掌握,当然了,最重要的还是教给大家举一反三的解题能力。

这对于那些打算冲中大厂的球友来说,至关重要,因为中大厂几乎都会有算法的笔试题,然后才会有面试机会。当然了,对于那些想通过刷题来学习 Java 的球友,无疑也会多一种选择,冲。

在这里,必须要感谢球友 **@炳源**,是我们的通力配合,才有了球友们看到的这份市面上独一无二的 LeetCode 刷题笔记。好,就让我们开始吧!

《二哥的 LeetCode 刷题笔记》真的经典。

------------------------by鲁迅

### **题意**

给定一个字符串 s ,请你找出其中不含有重复字符的 **最长子串** 的长度。

### **示例**

输入:s="abcabcbb"

解释:因为无重复字符的最长子串是"abc",所以其长度为3。

### **难度**

中等

### **分析1**

看完这道题目的描述,脑袋里面要先搞清楚几个概念:

- 什么是子串?
- 什么是最长子串?
- 什么是不含重复字符的最长子串?

这三个概念搞清楚,才能去写题解,对吧?

什么是子串?拿题目给出的示例来说,abcabcbb,它的子串有:

a、b、c、a、b、c、b、b这种单个子串

abc、bca、cab、bcb、cbb这种三个字符组成的子串abca、bcab、cabc、abcb这种四个字符组成的子串abcab、bcabc、cabcb、abcbb这种五个字符组成的子串abcabc、bcabcb、cabcbb这种六个字符组成的子串abcabcbbcabcbb这种七个字符组成的子串abcabcbb这种八个字符组成的子串

什么是最长子串?

就是子串中字符个数最多的那个子串,比如上面的例子中,abcabcbb就是最长子串。

什么是不含重复字符的最长子串?

就是最长子串中没有重复字符的那个子串,abcabcbb 虽然是最长子串,但有重复字符 a、b、c,所以不是不含重复字符的最长子串。

abcabcb 也不是,因为有重复字符 a、b、c。

abcabc 也不是,因为有重复字符 a、b、c。

abcab 也不是,因为有重复字符 a、b。

abca 也不是,因为有重复字符 a。

排除到最后,你会发现,不含重复字符的最长子串有这么几个:

abc、bca、cab


OK,答案出来了,长度为 3。

借着这个思路,我们直接来暴力解题。

class Solution {

        int res = 0; // 用于存储最长子串的长度        // 外层循环,从字符串的第一个字符开始        for (int i = 0; i < s.length(); i++) {            boolean[] book = new boolean[300]; // 布尔数组,用于标记字符是否出现过
            // 内层循环,从当前字符向前遍历            for (int j = i; j >= 0; j--) {                // 如果字符已经在子串中出现过,结束内层循环                if (book[s.charAt(j)])                     break;
                // 标记当前字符为出现过                book[s.charAt(j)] = true;                // 更新最长子串的长度                res = Math.max(res, i - j + 1);            }        }        return res; // 返回最长子串的长度    }}

两层循环,第一层循环用来确定起始位置,第二层循环用来确定结束位置,向前去找最长的子串,如果字符出现过,我们就标记它为 true,并且计算最长子串的长度。

①、题目说 s 由英文字母、数字、符号和空格组成,那么它很可能是 **[ASCII 码字符集](https://javabetter.cn/basic-extra-meal/java-unicode.html#_1-ascii)**,在 ASCII 字符集中,有 128 个不同的字符,这包括了大小写英文字母(52个)、数字(10个)、标点符号和特殊字符(如空格、制表符等),以及一些控制字符。

那为了节省空间,数组 book 的长度其实可以声明为 128,而不是 300。

boolean[] book = new boolean[128]; // 足以覆盖所有 ASCII 字符


当然了,笔试的时候时间紧张,如果没办法考虑周全的话,300 肯定是一个保险的方案,确保出现的字符都能够容纳得下。

②、i - j + 1 用来计算子串的长度,这里的 i 是外层循环的循环变量,j 是内层循环的循环变量,i - j + 1 就是子串的长度。

- i 是子串末尾字符的索引。
- j 是子串起始字符的索引。
- i - j 是子串起始和结束字符之间的距离(即子串的长度减去 1)。
- 加上 1 (i - j + 1) 是为了包含起始字符在内的整个子串的长度。

在例子 "abcabcbb" 中,当我们考虑以第一个 'b'(索引 1)结尾的子串时:

- 当 i = 1('b' 的位置)和 j = 1(仍然是 'b' 的位置)时,i - j + 1 = 1,表示子串 "b" 的长度。
- 当 i = 1 和 j = 0('a' 的位置)时,i - j + 1 = 2,表示子串 "ab" 的长度。

当字符串 s = "abcabcbb" 时,我们用该题解来模拟一下整个题解的过程。

- **初始化**:最长子串长度 res = 0。
- **开始外层循环**:逐字符遍历字符串 s。
- **i = 0**(字符 'a'):
- 初始化 book 数组,其中所有值均为 false。
- **内层循环**:从 j = 0 开始向前遍历。
- 检查 'a' 是否已经出现过(book[s.charAt(0)]),结果为 false。
- 标记 'a' 为出现过(book['a'] = true)。
- 更新 res 为 max(res, i - j + 1),即 max(0, 0 - 0 + 1) = 1。
- 退出内层循环。
- **注意**:这里的子串是 "a",长度为 1。
- **i = 1**(字符 'b'):
- 初始化 book 数组,其中所有值均为 false。
- **内层循环**:从 j = 1 开始向前遍历。
- 检查 'b' 是否已经出现过,结果为 false。
- 标记 'b' 为出现过。
- 更新 res 为 max(1, 1 - 1 + 1) = 1。
- **内层循环**:j = 0。
- 检查 'a' 是否已经出现过,结果为 false。
- 标记 'a' 为出现过。
- 更新 res 为 max(1, 1 - 0 + 1) = 2。
- 退出内层循环。
- **注意**:这里的子串是 "ab",长度为 2。
- **i = 2**(字符 'c'):
- 初始化 book 数组,其中所有值均为 false。
- **内层循环**:从 j = 2 开始向前遍历。
- 检查 'c' 是否已经出现过,结果为 false。
- 标记 'c' 为出现过。
- 更新 res 为 max(1, 2 - 2 + 1) = 1。
- **内层循环**:j = 1
- 检查 'b' 是否已经出现过,结果为 false。
- 标记 'b' 为出现过。
- 更新 res 为 max(1, 2 - 1 + 1) = 2。
- **内层循环**:j = 0
- 检查 'a' 是否已经出现过,结果为 false。
- 标记 'a' 为出现过。
- 更新 res 为 max(2, 2 - 0 + 1) = 3。
- 退出内层循环。
- **注意**:这里的子串是 "abc",长度为 3。
- **i = 3**(第二个字符 'a'):
- 初始化 book 数组,其中所有值均为 false。
- **内层循环**:从 j = 3 开始向前遍历。
- 检查 'a' 是否已经出现过,结果为 false。
- 标记 'a' 为出现过。
- 更新 res 为 max(3, 3 - 3 + 1) = 1。
- **内层循环**:j = 2
- 检查 'c' 是否已经出现过,结果为 false。
- 标记 'c' 为出现过。
- 更新 res 为 max(3, 3 - 2 + 1) = 2。
- **内层循环**:j = 1
- 检查 'b' 是否已经出现过,结果为 false。
- 标记 'b' 为出现过。
- 更新 res 为 max(3, 3 - 1 + 1) = 3。
- **内层循环**:j = 0
- 检查 'a' 是否已经出现过,结果为 true。
- 退出内层循环。
- **注意**:这里的子串是 "bca",长度为 3。
- **继续外层循环**,重复以上过程,直到 i 遍历完整个字符串。
- **结束遍历**:所有字符都已被检查。
- **返回结果**:最长无重复字符子串的长度 res。在这个例子中,最长的无重复字符子串长度为 3。

对 for 循环或者 break 比较陌生的球友,可以通过 ACM 的模式在 Intellij IDEA 中 debug 以下,瞬间就明白了。

public class Main003 {

        Scanner scanner = new Scanner(System.in);        System.out.println("输入字符串: ");        String s = scanner.nextLine(); // 从标准输入读取字符串
        Solution solution = new Solution();        int length = solution.lengthOfLongestSubstring(s); // 调用解题函数
        System.out.println("没有重复的最长子串为: " + length); // 输出结果    }}
class Solution {    public int lengthOfLongestSubstring(String s) {        int res = 0;        for (int i = 0; i < s.length(); i++) {            boolean[] book = new boolean[128]; // ASCII 字符集            for (int j = i; j >= 0; j--) {                if (book[s.charAt(j)]) {                    break;                }                book[s.charAt(j)] = true;                res = Math.max(res, i - j + 1);            }        }        return res;    }}

好,虽然通过两层循环也能解出来,但是这种暴力解法的时间复杂度是 ,效率不高,我们需要想办法优化一下。

![图片](https://clxhuhhr.top/uploads/weblog/erge/erge_lc_image_17.png)

### **分析 2**

接下来,我们介绍一个在Java中被隐藏起来的概念——指针,在C/C++中,指针是一种特殊的变量类型,用来记录变量的值在内存中的位置。

在数据结构中,也有一个指针的概念,但它和C/C++中的指针不太一样,虽然它也指向一个的位置,但并非指向存储值的物理位置,而是指向其在数据结构中的相对位置,就如同 Java 中熟知的下标(索引)一样,我们来看一段熟悉的代码:

for(inti=1;i

上面的代码中,i其实就是一个指针,被我们用来表示数组 a 中 1到n 的位置,实际上这就是数据结构中指针的概念。

再来看这段代码,在二分查找中:

int lef = 1, rig = n;
int mid = (lef + rig) / 2; // 计算中间位置    if(a[mid] < goalVal)        lef = mid; // 如果中间位置的值小于目标值,调整左边界    else        rig = mid; // 如果中间位置的值大于或等于目标值,调整右边界}

这里面的lef,rig,mid其实都是指针,指向数组中的某个位置,然后再用a[](a是一个数组)来取出这个位置的元素进行判断。

我们再来横向比较一下引用、指针、下标这些容易混淆的概念:

明白这一点,我们再来讲一个算法中经常出现的概念——滑动窗口。

题目要我们求解的是一个区间,所以我们可以考虑这样两个指针,一个指向这个区间的开头,另外一个则指向这个区间的结尾。

我们让头指针不动,向后扩展尾指针,直到遇见重复元素(见下图):

图片出自**LeetCode 的一个作者 Ikaruga **

否则,向后移动头指针,重复之前的动作。

一个位置一个位置移动,也太低效了,有没有更好的办法呢?

我们可以用一个 HashMap 来存储每个字符的位置,这样我们就可以直接跳到这个字符上次出现的位置,而不用一个一个地移动头指针了。

那由头指针和尾指针组成的区间,我们称之为滑动窗口。我用图简单来画一下大家就明白了。

学过计算机网络的同学,应该都知道滑动窗口协议,用于网络数据传输时的流量控制,以避免网络拥塞。算法中的滑动窗口主要用来解决数组/字符串的子元素问题,比如今天这道题目,在字符串中寻找最长不含重复字符的子串。

图片来自博客园的huansky

推荐阅读资料:博客园上这篇关于滑动窗口的帖子写的非常不错

我们来看一下滑动窗口的题解:

class Solution {
    HashMap<Character, Integer> rempos = new HashMap<>(); // 创建一个哈希表来记录字符最近一次出现的位置        int res = 0; // 用于存储最长子串的长度        // 初始化两个指针 i 和 j,表示当前考虑的子串的头和尾        for (int i = 0, j = 0; i < s.length(); i++) {            // 检查当前字符是否出现过            if (rempos.containsKey(s.charAt(i))) {                // 如果出现过,则更新 j 的位置为该字符上次出现位置的下一个位置                // 保证子串 [j, i] 不包含重复字符                j = Math.max(j, rempos.get(s.charAt(i)));            }            // 更新最长子串的长度            res = Math.max(res, i - j + 1);            // 更新当前字符的位置信息            rempos.put(s.charAt(i), i + 1);        }        return res; // 返回最长子串的长度    }}

滑动窗口方法只需要一次遍历:

  • 使用两个指针(通常称为头指针和尾指针)创建一个动态的窗口。这个窗口代表当前考虑的子串。
  • 移动尾指针:逐个检查字符,扩展窗口。
  • 移动头指针:一旦遇到重复字符,就移动头指针,缩小窗口直到子串中不再包含重复字符。

使用 HashMap 记录每个字符最近一次出现的位置(rempos.put (s.charAt(i), i + 1))。一旦出现重复字符(rempos.containsKey(s.charAt(i))),就可以移动头指针(j = Math.max (j, rempos.get (s.charAt(i)))),缩小窗口。

当输入 s = "abcabcbb" 时,我们按照滑动窗口的方法来模拟题解过程:

  • 初始化:建立一个哈希表 rempos 来记录字符最近出现的位置,初始化最长子串长度 res = 0,以及两个指针 i = 0(尾指针)和 j = 0(头指针)。
  • 开始遍历字符串 s
  • i = 0,字符为 'a':
  • rempos 中没有 'a',将 'a' 添加到 rempos('a': 1),res = max(res, i - j + 1) = max(0, 0 - 0 + 1) = 1。
  • 此时子串为a,长度为 1。
  • i = 1,字符为 'b':
  • rempos 中没有 'b',将 'b' 添加到 rempos('b': 2),res = max(1, 1 - 0 + 1) = 2。
  • 此时子串为ab,长度为 2。
  • i = 2,字符为 'c':
  • rempos 中没有 'c',将 'c' 添加到 rempos('c': 3),res = max(2, 2 - 0 + 1) = 3。
  • 此时子串为abc,长度为 3。
  • i = 3,字符为 'a':
  • rempos 中有 'a',它的位置是 1。将 j 更新为 max(j, rempos.get ('a')) = max(0, 1) = 1。
  • 此时子串为bca,长度为 3。
  • 更新 rempos 中 'a' 的位置为 4。
  • res = max(3, 3 - 1 + 1) = 3。
  • i = 4,字符为 'b':
  • rempos 中有 'b',它的位置是 2。将 j 更新为 max(j, rempos.get ('b')) = max(1, 2) = 2。
  • 此时子串为cab,长度为 3。
  • 更新 rempos 中 'b' 的位置为 5。
  • res = max(3, 4 - 2 + 1) = 3。
  • i = 5,字符为 'c':
  • rempos 中有 'c',它的位置是 3。将 j 更新为 max(j, rempos.get ('c')) = max(2, 3) = 3。
  • 此时子串为abc,长度为 3。
  • 更新 rempos 中 'c' 的位置为 6。
  • res = max(3, 5 - 3 + 1) = 3。
  • i = 6,字符为 'b':
  • rempos 中有 'b',它的位置是 5。将 j 更新为 max(j, rempos.get ('b')) = max(3, 5) = 5。
  • 此时子串为cb,长度为 2。
  • 此处不更新 res。
  • 更新 rempos 中 'b' 的位置为 7。
  • res = max(3, 6 - 5 + 1) = 2。
  • i = 7,字符为 'b':
  • 再次遇到 'b',rempos 中有 'b',它的位置是 7。将 j 更新为 max(j, rempos.get ('b')) = max(5, 7) = 7。
  • 此时子串为b,长度为 1。
  • 此处不更新 res。
  • 更新 rempos 中 'b' 的位置为 8。
  • 遍历结束:字符串遍历完成。
  • 输出结果:最长无重复字符子串的长度为 res = 3。

如果还是不明白的话,可以通过 ACM 的模式调试一下,一步一步跟着代码走,就明白了。

public class Main00301 {
    Scanner scanner = new Scanner(System.in);        System.out.println("输入字符串: ");        String s = scanner.nextLine(); // 从标准输入读取字符串
    int length = lengthOfLongestSubstring(s); // 调用解题函数        System.out.println("没有重复的最长子串为: " + length); // 输出结果    }
public static int lengthOfLongestSubstring(String s) {        HashMap<Character, Integer> rempos = new HashMap<>();        int res = 0;        for (int i = 0, j = 0; i < s.length(); i++) {            System.out.println("i: " + i + ", j: " + j);            if (rempos.containsKey(s.charAt(i))) {                j = Math.max(j, rempos.get(s.charAt(i)));            }            // 把子串也打印出来            System.out.println("sub: " + s.substring(j, i + 1));            res = Math.max(res, i - j + 1);            rempos.put(s.charAt(i), i + 1);        }        return res;    }}

在这个过程中,滑动窗口从每个可能的起始位置开始,尽可能地向右扩展。每当遇到重复字符时,窗口的左边界就会向右移动,以保证窗口内没有重复字符。通过这种方式,我们能够找出最长的无重复字符子串。

时间复杂度:

因为每个元素尾指针只访问过一遍,头指针依靠尾指针访问过的信息进行移动,所以时间复杂度比之前的暴力方法低了一个次方级别。

总结

本次我们用了新的算法:滑动窗口 (可以戳视频链接再加深一下印象),一次遍历,通过移动头指针和尾指针来找出一个区间,相对于暴力的两次遍历,显然时间复杂度大大降低了。

这次的题解涉及到的知识点相对较多,但题解中都给出了对应的学习资料,相信大家看完后都能串联到一起,形成一个完整的知识体系。

其中的字符串可以参考下面这个链接:

涉及到一个两个数中求最大的数学操作类 Math 我这里简单帮大家总结一下:

  • Math.abs (x):返回x的绝对值。
  • Math.ceil (x):返回大于或等于x的最小整数。
  • Math.floor (x):返回小于或等于x的最大整数。
  • Math.round (x):将x四舍五入为最接近的整数。
  • Math.max (x, y):返回x和y中的较大值。
  • Math.min (x, y):返回x和y中的较小值。
  • Math.pow (x, y):返回x的y次方。
  • Math.sqrt (x):返回x的平方根。
  • Math.sin (x):返回x的正弦值。
  • Math.cos (x):返回x的余弦值。
  • Math.tan (x):返回x的正切值。
  • Math.random ():返回一个大于等于0且小于1的随机浮点数。

力扣链接:https://leetcode.cn/problems/longest-substring-without-repeating-characters/

一步一个脚印

不积跬步无以至千里,不积小流无以成江海。LeetCode - 100天从算法小白到卷王正式启动了,我们计划周一到周五至少每天更新一篇,周六周日更新一篇,目标 300 道 LeetCode 经典题。

该题解目前只针对**二哥编程星球 **的球友开放,如果你也想一起打卡学习算法的话,扫下面的优惠券加入我们吧,星球目前 4100+ 人,定价 149 元,从当初的 99 元一路涨上来的,所以早就是优势,别再犹豫了。

(优惠券仅 300 张,数量有限)

一个人可以走得很快,但一群人才能走得更远。如果想去面中大厂的话,LeetCode 几乎是必刷的,二哥的 LeetCode 题解通俗易懂、图文并茂,理解给你举一反三的解题能力,冲鸭。

之前星球还没有 Markdown 的功能, 所以第一次发布的格式在星球里阅读的话很不友好,于是二哥打算重新优化一版《二哥的 LeetCode 刷题笔记》:

  • 增加代码注释,这样大家更容易看懂并掌握题解;
  • 增加参考资料,这样大家可以从零基础直接开刷,减轻新手刷题的阻力;
  • 增加手绘图,这样大家更容易理解解题思路;
  • 增加 ACM 输入输出,这样大家可以在 Intellij IDEA 中进行调试;
  • 增加题解模拟,这样大家可以根据题解模拟整个题解过程,看完会有一种“哦哦”恍然大悟的感觉;
  • 图片上传至 OSS 并配合 CDN,提升大家在技术派网站中的阅读体感。
  • 另外,这一版本会作为 PDF 的终稿随后在星球进行发布。

并且增加了技术派专栏的阅读地址,只需要绑定星球编号就可以通过「微信扫码登录」的方式在技术派网站上阅读,这样大家的阅读选择也会更加多样化(语雀、技术派、星球都可以,喜欢哪个用哪个)。

我知道,市面上已经有不少的刷题笔记了,二哥也看过很多,但讲真,有些还是看不太懂,可能是因为天赋不够,也可能是因为这些刷题笔记一开始是针对 C语言、Python、Go 或者 C++ 的,后来才翻译成 Java 语言,就导致阅读起来并不幸福🥰。

那我希望《二哥的 LeetCode 刷题笔记》能够给学习算法的球友提供一些不一样的刷题思路,从我自己的角度出发,题解可能会更容易掌握,当然了,最重要的还是教给大家举一反三的解题能力。

这对于那些打算冲中大厂的球友来说,至关重要,因为中大厂几乎都会有算法的笔试题,然后才会有面试机会。当然了,对于那些想通过刷题来学习 Java 的球友,无疑也会多一种选择,冲。

在这里,必须要感谢球友 @炳源,是我们的通力配合,才有了球友们看到的这份市面上独一无二的 LeetCode 刷题笔记。好,就让我们开始吧!

《二哥的 LeetCode 刷题笔记》真的经典。

------------------------by鲁迅

题意

给定一个字符串 s ,请你找出其中不含有重复字符的 最长子串 的长度。

示例

输入:s="abcabcbb"

解释:因为无重复字符的最长子串是"abc",所以其长度为3。

难度

中等

分析1

看完这道题目的描述,脑袋里面要先搞清楚几个概念:

  • 什么是子串?
  • 什么是最长子串?
  • 什么是不含重复字符的最长子串?

这三个概念搞清楚,才能去写题解,对吧?

什么是子串?拿题目给出的示例来说,abcabcbb,它的子串有:

a、b、c、a、b、c、b、b这种单个子串

abc、bca、cab、bcb、cbb这种三个字符组成的子串abca、bcab、cabc、abcb这种四个字符组成的子串abcab、bcabc、cabcb、abcbb这种五个字符组成的子串abcabc、bcabcb、cabcbb这种六个字符组成的子串abcabcbbcabcbb这种七个字符组成的子串abcabcbb这种八个字符组成的子串

什么是最长子串?

就是子串中字符个数最多的那个子串,比如上面的例子中,abcabcbb就是最长子串。

什么是不含重复字符的最长子串?

就是最长子串中没有重复字符的那个子串,abcabcbb 虽然是最长子串,但有重复字符 a、b、c,所以不是不含重复字符的最长子串。

abcabcb 也不是,因为有重复字符 a、b、c。

abcabc 也不是,因为有重复字符 a、b、c。

abcab 也不是,因为有重复字符 a、b。

abca 也不是,因为有重复字符 a。

排除到最后,你会发现,不含重复字符的最长子串有这么几个:

abc、bca、cab

OK,答案出来了,长度为 3。

借着这个思路,我们直接来暴力解题。

class Solution {
    int res = 0; // 用于存储最长子串的长度        // 外层循环,从字符串的第一个字符开始        for (int i = 0; i < s.length(); i++) {            boolean[] book = new boolean[300]; // 布尔数组,用于标记字符是否出现过
        // 内层循环,从当前字符向前遍历            for (int j = i; j >= 0; j--) {                // 如果字符已经在子串中出现过,结束内层循环                if (book[s.charAt(j)])                     break;
            // 标记当前字符为出现过                book[s.charAt(j)] = true;                // 更新最长子串的长度                res = Math.max(res, i - j + 1);            }        }        return res; // 返回最长子串的长度    }}

两层循环,第一层循环用来确定起始位置,第二层循环用来确定结束位置,向前去找最长的子串,如果字符出现过,我们就标记它为 true,并且计算最长子串的长度。

①、题目说 s 由英文字母、数字、符号和空格组成,那么它很可能是 ASCII 码字符集 ,在 ASCII 字符集中,有 128 个不同的字符,这包括了大小写英文字母(52个)、数字(10个)、标点符号和特殊字符(如空格、制表符等),以及一些控制字符。

那为了节省空间,数组 book 的长度其实可以声明为 128,而不是 300。

boolean[] book = new boolean[128]; // 足以覆盖所有 ASCII 字符

当然了,笔试的时候时间紧张,如果没办法考虑周全的话,300 肯定是一个保险的方案,确保出现的字符都能够容纳得下。

②、i - j + 1 用来计算子串的长度,这里的 i 是外层循环的循环变量,j 是内层循环的循环变量,i - j + 1 就是子串的长度。

  • i 是子串末尾字符的索引。
  • j 是子串起始字符的索引。
  • i - j 是子串起始和结束字符之间的距离(即子串的长度减去 1)。
  • 加上 1 (i - j + 1) 是为了包含起始字符在内的整个子串的长度。

在例子 "abcabcbb" 中,当我们考虑以第一个 'b'(索引 1)结尾的子串时:

  • 当 i = 1('b' 的位置)和 j = 1(仍然是 'b' 的位置)时,i - j + 1 = 1,表示子串 "b" 的长度。
  • 当 i = 1 和 j = 0('a' 的位置)时,i - j + 1 = 2,表示子串 "ab" 的长度。

当字符串 s = "abcabcbb" 时,我们用该题解来模拟一下整个题解的过程。

  • 初始化:最长子串长度 res = 0。
  • 开始外层循环:逐字符遍历字符串 s。
  • i = 0(字符 'a'):
  • 初始化 book 数组,其中所有值均为 false。
  • 内层循环:从 j = 0 开始向前遍历。
  • 检查 'a' 是否已经出现过(book[s.charAt(0)]),结果为 false。
  • 标记 'a' 为出现过(book['a'] = true)。
  • 更新 res 为 max(res, i - j + 1),即 max(0, 0 - 0 + 1) = 1。
  • 退出内层循环。
  • 注意:这里的子串是 "a",长度为 1。
  • i = 1(字符 'b'):
  • 初始化 book 数组,其中所有值均为 false。
  • 内层循环:从 j = 1 开始向前遍历。
  • 检查 'b' 是否已经出现过,结果为 false。
  • 标记 'b' 为出现过。
  • 更新 res 为 max(1, 1 - 1 + 1) = 1。
  • 内层循环:j = 0。
  • 检查 'a' 是否已经出现过,结果为 false。
  • 标记 'a' 为出现过。
  • 更新 res 为 max(1, 1 - 0 + 1) = 2。
  • 退出内层循环。
  • 注意:这里的子串是 "ab",长度为 2。
  • i = 2(字符 'c'):
  • 初始化 book 数组,其中所有值均为 false。
  • 内层循环:从 j = 2 开始向前遍历。
  • 检查 'c' 是否已经出现过,结果为 false。
  • 标记 'c' 为出现过。
  • 更新 res 为 max(1, 2 - 2 + 1) = 1。
  • 内层循环:j = 1
  • 检查 'b' 是否已经出现过,结果为 false。
  • 标记 'b' 为出现过。
  • 更新 res 为 max(1, 2 - 1 + 1) = 2。
  • 内层循环:j = 0
  • 检查 'a' 是否已经出现过,结果为 false。
  • 标记 'a' 为出现过。
  • 更新 res 为 max(2, 2 - 0 + 1) = 3。
  • 退出内层循环。
  • 注意:这里的子串是 "abc",长度为 3。
  • i = 3(第二个字符 'a'):
  • 初始化 book 数组,其中所有值均为 false。
  • 内层循环:从 j = 3 开始向前遍历。
  • 检查 'a' 是否已经出现过,结果为 false。
  • 标记 'a' 为出现过。
  • 更新 res 为 max(3, 3 - 3 + 1) = 1。
  • 内层循环:j = 2
  • 检查 'c' 是否已经出现过,结果为 false。
  • 标记 'c' 为出现过。
  • 更新 res 为 max(3, 3 - 2 + 1) = 2。
  • 内层循环:j = 1
  • 检查 'b' 是否已经出现过,结果为 false。
  • 标记 'b' 为出现过。
  • 更新 res 为 max(3, 3 - 1 + 1) = 3。
  • 内层循环:j = 0
  • 检查 'a' 是否已经出现过,结果为 true。
  • 退出内层循环。
  • 注意:这里的子串是 "bca",长度为 3。
  • 继续外层循环,重复以上过程,直到 i 遍历完整个字符串。
  • 结束遍历:所有字符都已被检查。
  • 返回结果:最长无重复字符子串的长度 res。在这个例子中,最长的无重复字符子串长度为 3。

对 for 循环或者 break 比较陌生的球友,可以通过 ACM 的模式在 Intellij IDEA 中 debug 以下,瞬间就明白了。

public class Main003 {
    Scanner scanner = new Scanner(System.in);        System.out.println("输入字符串: ");        String s = scanner.nextLine(); // 从标准输入读取字符串
    Solution solution = new Solution();        int length = solution.lengthOfLongestSubstring(s); // 调用解题函数
    System.out.println("没有重复的最长子串为: " + length); // 输出结果    }}

class Solution { public int lengthOfLongestSubstring(String s) { int res = 0; for (int i = 0; i < s.length(); i++) { boolean[] book = new boolean[128]; // ASCII 字符集 for (int j = i; j >= 0; j--) { if (book[s.charAt(j)]) { break; } book[s.charAt(j)] = true; res = Math.max(res, i - j + 1); } } return res; }}

好,虽然通过两层循环也能解出来,但是这种暴力解法的时间复杂度是 ,效率不高,我们需要想办法优化一下。

分析 2

接下来,我们介绍一个在Java中被隐藏起来的概念——指针,在C/C++中,指针是一种特殊的变量类型,用来记录变量的值在内存中的位置。

在数据结构中,也有一个指针的概念,但它和C/C++中的指针不太一样,虽然它也指向一个的位置,但并非指向存储值的物理位置,而是指向其在数据结构中的相对位置,就如同 Java 中熟知的下标(索引)一样,我们来看一段熟悉的代码:

for(inti=1;i

上面的代码中,i其实就是一个指针,被我们用来表示数组 a 中 1到n 的位置,实际上这就是数据结构中指针的概念。

再来看这段代码,在二分查找中:

int lef = 1, rig = n;

    int mid = (lef + rig) / 2; // 计算中间位置    if(a[mid] < goalVal)        lef = mid; // 如果中间位置的值小于目标值,调整左边界    else        rig = mid; // 如果中间位置的值大于或等于目标值,调整右边界}

这里面的lef,rig,mid其实都是指针,指向数组中的某个位置,然后再用a[](a是一个数组)来取出这个位置的元素进行判断。

我们再来横向比较一下引用、指针、下标这些容易混淆的概念:

![图片](https://clxhuhhr.top/uploads/weblog/erge/erge_lc_image_18.png)

明白这一点,我们再来讲一个算法中经常出现的概念——滑动窗口。

题目要我们求解的是一个区间,所以我们可以考虑这样两个指针,一个指向这个区间的开头,另外一个则指向这个区间的结尾。

我们让头指针不动,向后扩展尾指针,直到遇见重复元素(见下图):

![图片](https://clxhuhhr.top/uploads/weblog/erge/erge_lc_image_19.png)

图片出自**[LeetCode 的一个作者 Ikaruga](https://leetcode.cn/problems/longest-substring-without-repeating-characters/solutions/228576/longest-substring-without-repeating-characters-b-2/)**

否则,向后移动头指针,重复之前的动作。

一个位置一个位置移动,也太低效了,有没有更好的办法呢?

我们可以用一个 **[HashMap](https://javabetter.cn/collection/hashmap.html)** 来存储每个字符的位置,这样我们就可以直接跳到这个字符上次出现的位置,而不用一个一个地移动头指针了。

那由头指针和尾指针组成的区间,我们称之为**滑动窗口**。我用图简单来画一下大家就明白了。

![图片](https://clxhuhhr.top/uploads/weblog/erge/erge_lc_image_20.png)

学过计算机网络的同学,应该都知道滑动窗口协议,用于网络数据传输时的流量控制,以避免网络拥塞。算法中的滑动窗口主要用来解决数组/字符串的子元素问题,比如今天这道题目,在字符串中寻找最长不含重复字符的子串。

![图片](https://clxhuhhr.top/uploads/weblog/erge/erge_lc_image_21.png)

图片来自博客园的huansky

推荐阅读资料:**[博客园上这篇关于滑动窗口的帖子写的非常不错](https://www.cnblogs.com/huansky/p/13488234.html)**

我们来看一下滑动窗口的题解:

class Solution {

        HashMap<Character, Integer> rempos = new HashMap<>(); // 创建一个哈希表来记录字符最近一次出现的位置        int res = 0; // 用于存储最长子串的长度        // 初始化两个指针 i 和 j,表示当前考虑的子串的头和尾        for (int i = 0, j = 0; i < s.length(); i++) {            // 检查当前字符是否出现过            if (rempos.containsKey(s.charAt(i))) {                // 如果出现过,则更新 j 的位置为该字符上次出现位置的下一个位置                // 保证子串 [j, i] 不包含重复字符                j = Math.max(j, rempos.get(s.charAt(i)));            }            // 更新最长子串的长度            res = Math.max(res, i - j + 1);            // 更新当前字符的位置信息            rempos.put(s.charAt(i), i + 1);        }        return res; // 返回最长子串的长度    }}

滑动窗口方法只需要一次遍历:

- 使用两个指针(通常称为头指针和尾指针)创建一个动态的窗口。这个窗口代表当前考虑的子串。
- 移动尾指针:逐个检查字符,扩展窗口。
- 移动头指针:一旦遇到重复字符,就移动头指针,缩小窗口直到子串中不再包含重复字符。

![图片](https://clxhuhhr.top/uploads/weblog/erge/erge_lc_image_22.png)

使用 **[HashMap](https://javabetter.cn/collection/hashmap.html)** 记录每个字符最近一次出现的位置([rempos.put](http://rempos.put)(s.charAt(i), i + 1))。一旦出现重复字符(rempos.containsKey(s.charAt(i))),就可以移动头指针(j = [Math.max](http://Math.max)(j, [rempos.get](http://rempos.get)(s.charAt(i)))),缩小窗口。

当输入 s = "abcabcbb" 时,我们按照滑动窗口的方法来模拟题解过程:

- **初始化**:建立一个哈希表 rempos 来记录字符最近出现的位置,初始化最长子串长度 res = 0,以及两个指针 i = 0(尾指针)和 j = 0(头指针)。
- **开始遍历字符串 s**:
- i = 0,字符为 'a':
- rempos 中没有 'a',将 'a' 添加到 rempos('a': 1),res = max(res, i - j + 1) = max(0, 0 - 0 + 1) = 1。
- 此时子串为a,长度为 1。
- i = 1,字符为 'b':
- rempos 中没有 'b',将 'b' 添加到 rempos('b': 2),res = max(1, 1 - 0 + 1) = 2。
- 此时子串为ab,长度为 2。
- i = 2,字符为 'c':
- rempos 中没有 'c',将 'c' 添加到 rempos('c': 3),res = max(2, 2 - 0 + 1) = 3。
- 此时子串为abc,长度为 3。
- i = 3,字符为 'a':
- rempos 中有 'a',它的位置是 1。将 j 更新为 max(j, [rempos.get](http://rempos.get)('a')) = max(0, 1) = 1。
- 此时子串为bca,长度为 3。
- 更新 rempos 中 'a' 的位置为 4。
- res = max(3, 3 - 1 + 1) = 3。
- i = 4,字符为 'b':
- rempos 中有 'b',它的位置是 2。将 j 更新为 max(j, [rempos.get](http://rempos.get)('b')) = max(1, 2) = 2。
- 此时子串为cab,长度为 3。
- 更新 rempos 中 'b' 的位置为 5。
- res = max(3, 4 - 2 + 1) = 3。
- i = 5,字符为 'c':
- rempos 中有 'c',它的位置是 3。将 j 更新为 max(j, [rempos.get](http://rempos.get)('c')) = max(2, 3) = 3。
- 此时子串为abc,长度为 3。
- 更新 rempos 中 'c' 的位置为 6。
- res = max(3, 5 - 3 + 1) = 3。
- i = 6,字符为 'b':
- rempos 中有 'b',它的位置是 5。将 j 更新为 max(j, [rempos.get](http://rempos.get)('b')) = max(3, 5) = 5。
- 此时子串为cb,长度为 2。
- 此处不更新 res。
- 更新 rempos 中 'b' 的位置为 7。
- res = max(3, 6 - 5 + 1) = 2。
- i = 7,字符为 'b':
- 再次遇到 'b',rempos 中有 'b',它的位置是 7。将 j 更新为 max(j, [rempos.get](http://rempos.get)('b')) = max(5, 7) = 7。
- 此时子串为b,长度为 1。
- 此处不更新 res。
- 更新 rempos 中 'b' 的位置为 8。
- **遍历结束**:字符串遍历完成。
- **输出结果**:最长无重复字符子串的长度为 res = 3。

如果还是不明白的话,可以通过 ACM 的模式调试一下,一步一步跟着代码走,就明白了。

public class Main00301 {

        Scanner scanner = new Scanner(System.in);        System.out.println("输入字符串: ");        String s = scanner.nextLine(); // 从标准输入读取字符串
        int length = lengthOfLongestSubstring(s); // 调用解题函数        System.out.println("没有重复的最长子串为: " + length); // 输出结果    }
    public static int lengthOfLongestSubstring(String s) {        HashMap<Character, Integer> rempos = new HashMap<>();        int res = 0;        for (int i = 0, j = 0; i < s.length(); i++) {            System.out.println("i: " + i + ", j: " + j);            if (rempos.containsKey(s.charAt(i))) {                j = Math.max(j, rempos.get(s.charAt(i)));            }            // 把子串也打印出来            System.out.println("sub: " + s.substring(j, i + 1));            res = Math.max(res, i - j + 1);            rempos.put(s.charAt(i), i + 1);        }        return res;    }}

在这个过程中,滑动窗口从每个可能的起始位置开始,尽可能地向右扩展。每当遇到重复字符时,窗口的左边界就会向右移动,以保证窗口内没有重复字符。通过这种方式,我们能够找出最长的无重复字符子串。

![图片](https://clxhuhhr.top/uploads/weblog/erge/erge_lc_image_23.png)

时间复杂度:

因为每个元素尾指针只访问过一遍,头指针依靠尾指针访问过的信息进行移动,所以时间复杂度比之前的暴力方法低了一个次方级别。

### **总结**

本次我们用了新的算法:**[滑动窗口](https://www.bilibili.com/video/BV14Q4y1P7Np/)**(可以戳视频链接再加深一下印象),一次遍历,通过移动头指针和尾指针来找出一个区间,相对于暴力的两次遍历,显然时间复杂度大大降低了。

这次的题解涉及到的知识点相对较多,但题解中都给出了对应的学习资料,相信大家看完后都能串联到一起,形成一个完整的知识体系。

其中的字符串可以参考下面这个链接:

- **[字符串](https://javabetter.cn/string/string-source.html)**

涉及到一个两个数中求最大的数学操作类 Math 我这里简单帮大家总结一下:

- [Math.abs](http://Math.abs)(x):返回x的绝对值。
- [Math.ceil](http://Math.ceil)(x):返回大于或等于x的最小整数。
- [Math.floor](http://Math.floor)(x):返回小于或等于x的最大整数。
- [Math.round](http://Math.round)(x):将x四舍五入为最接近的整数。
- [Math.max](http://Math.max)(x, y):返回x和y中的较大值。
- [Math.min](http://Math.min)(x, y):返回x和y中的较小值。
- [Math.pow](http://Math.pow)(x, y):返回x的y次方。
- [Math.sqrt](http://Math.sqrt)(x):返回x的平方根。
- [Math.sin](http://Math.sin)(x):返回x的正弦值。
- [Math.cos](http://Math.cos)(x):返回x的余弦值。
- [Math.tan](http://Math.tan)(x):返回x的正切值。
- [Math.random](http://Math.random)():返回一个大于等于0且小于1的随机浮点数。

力扣链接:**[https://leetcode.cn/problems/longest-substring-without-repeating-characters/](https://leetcode.cn/problems/longest-substring-without-repeating-characters/)**

### **一步一个脚印**

不积跬步无以至千里,不积小流无以成江海。LeetCode - 100天从算法小白到卷王正式启动了,我们计划周一到周五至少每天更新一篇,周六周日更新一篇,目标 300 道 LeetCode 经典题。

该题解目前只针对**[二哥编程星球](https://javabetter.cn/zhishixingqiu/)**的球友开放,如果你也想一起打卡学习算法的话,扫下面的优惠券加入我们吧,星球目前 4100+ 人,定价 149 元,从当初的 99 元一路涨上来的,所以早就是优势,别再犹豫了。

![图片](https://clxhuhhr.top/uploads/weblog/erge/erge_lc_image_24.png)

(优惠券仅 300 张,数量有限)

一个人可以走得很快,但一群人才能走得更远。如果想去面中大厂的话,LeetCode 几乎是必刷的,二哥的 LeetCode 题解通俗易懂、图文并茂,理解给你举一反三的解题能力,冲鸭。

        
                
                
                知识星球
                
        
        
            
            扫码加入星球
            查看更多优质内容
        
        https://wx.zsxq.com/mweb/views/joingroup/join_group.html?group_id=15522885221412
003.无重复字符的最长子串
http://www.clxhxhhr.top/posts/2428/
作者
clxstart
发布于
2026-09-19
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。