🧩 无重复字符的最长子串

难度:中等

📝 题目描述

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

🧪 示例

示例 1:

输入: s = "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。注意 "bca""cab" 也是正确答案。

示例 2:

输入: s = "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。

示例 3:

输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。


📏 提示

  • ​0 \le s.length \le 5 \times 10^4
  • s 由英文字母、数字、符号和空格组成

代码实现

import java.util.HashSet;
import java.util.Set;

public class Solution {

    /**
     * 计算不含有重复字符的最长子串的长度
     * 使用滑动窗口算法
     *
     * @param s 输入字符串
     * @return 最长子串的长度
     */
    public int lengthOfLongestSubstring(String s) {
        // 1. 边界条件处理:如果字符串为空或长度为0,直接返回0
        if (s == null || s.length() == 0) {
            return 0;
        }

        // 2. 初始化数据结构
        // 使用 HashSet 来存储当前滑动窗口中的字符,用于快速判断字符是否重复
        Set<Character> charSet = new HashSet<>();
  
        // 定义滑动窗口的左边界指针,初始指向索引 0
        int left = 0;
  
        // 用于记录过程中出现的最大窗口长度
        int maxLength = 0;

        // 3. 开始滑动窗口
        // 右指针 right 负责向右探索新的字符,遍历整个字符串
        for (int right = 0; right < s.length(); right++) {
            // 获取当前右指针指向的字符
            char currentChar = s.charAt(right);

            // 4. 核心逻辑:判断重复
            // 如果当前字符 currentChar 已经存在于集合 charSet 中
            // 说明出现了重复字符,我们需要收缩窗口的左边界
            while (charSet.contains(currentChar)) {
                // 从集合中移除左指针指向的字符(即窗口最左边的字符)
                charSet.remove(s.charAt(left));
          
                // 左指针向右移动一位,缩小窗口
                // 这一步会一直执行,直到把那个造成重复的旧字符移出窗口为止
                left++;
            }

            // 5. 扩展窗口
            // 经过上面的 while 循环,当前窗口内一定不包含 currentChar
            // 所以可以安全地将当前字符加入集合
            charSet.add(currentChar);

            // 6. 更新最大长度
            // 当前窗口的长度计算公式为:右指针位置 - 左指针位置 + 1
            // 我们不断比较并保留历史最大值
            maxLength = Math.max(maxLength, right - left + 1);
        }

        // 7. 返回结果
        return maxLength;
    }

    // 主方法用于测试
    public static void main(String[] args) {
        Solution solution = new Solution();

        // 测试用例 1
        String s1 = "abcabcbb";
        // 预期输出: 3 (子串 "abc")
        System.out.println("输入: " + s1 + " -> 输出: " + solution.lengthOfLongestSubstring(s1));

        // 测试用例 2
        String s2 = "bbbbb";
        // 预期输出: 1 (子串 "b")
        System.out.println("输入: " + s2 + " -> 输出: " + solution.lengthOfLongestSubstring(s2));

        // 测试用例 3
        String s3 = "pwwkew";
        // 预期输出: 3 (子串 "wke")
        System.out.println("输入: " + s3 + " -> 输出: " + solution.lengthOfLongestSubstring(s3));
    }
}

滑动窗口算法是一种用于处理数组字符串连续子区间问题的高效技巧。它的核心思想是通过维护一个动态的“窗口”来避免暴力解法中的大量重复计算,从而将时间复杂度从 O(n²) 优化到 O(n)。

你可以把它想象成在数据序列上移动一个可变大小的“窥视镜”,我们只关注“镜子”里的内容,并通过移动“镜子”来寻找满足特定条件的最优解。

🎯 核心原理:双指针与动态窗口

滑动窗口的实现依赖于两个指针,通常称为 leftright,它们共同定义了当前窗口的边界 [left, right]

  • right 指针(扩张者):负责向右移动,不断将新元素纳入窗口,探索新的可能性。
  • left 指针(收缩者):负责在特定条件下向右移动,将元素移出窗口,以维持窗口的有效性(例如,保证窗口内无重复元素)。

算法的基本流程如下:

  1. 扩张right 指针向右移动,将新元素加入窗口。
  2. 判断:检查当前窗口是否满足题目要求的条件。
  3. 收缩:如果窗口不满足条件(例如,出现了重复字符),则移动 left 指针,缩小窗口,直到窗口重新满足条件。
  4. 更新:在窗口满足条件时,记录或更新当前找到的最优解(如最长/最短长度)。

这个过程之所以高效,是因为数组中的每个元素最多只会被 right 指针访问一次(加入窗口),被 left 指针访问一次(移出窗口),总操作次数是线性的。

📝 通用解题模板

绝大多数滑动窗口问题都可以套用以下模板来解决:

int left = 0;
// 用于记录窗口状态,如字符频次、元素和等
Map/Array/Set windowState = ...; 
int result = ...; // 用于记录最终结果

for (int right = 0; right < array.length; right++) {
    // 1. 将 right 指向的元素加入窗口,并更新窗口状态
    // add array[right] to windowState
  
    // 2. 判断窗口状态是否不满足条件
    // 如果不满足,则移动 left 指针收缩窗口
    while (windowState 不满足条件) {
        // 将 left 指向的元素移出窗口,并更新窗口状态
        // remove array[left] from windowState
        left++;
    }
  
    // 3. 此时窗口状态满足条件,更新最终结果
    // result = max/min(result, right - left + 1);
}
return result;

📚 主要分类与应用场景

滑动窗口问题通常可以分为两大类:

固定大小窗口

窗口的大小 k 是预先给定的。解题时,窗口像滑块一样在数组上从左到右移动,每次移动一步。

  • 特点leftright 指针同步移动,窗口大小不变。
  • 典型问题:计算所有长度为 k 的子数组的最大和。

可变大小窗口

窗口的大小不固定,需要根据题目条件动态调整。这是更常见、也更灵活的一类。它又可以根据求解目标细分为:

  • 求最长子串/子数组

    • 策略right 指针不断扩张窗口。当窗口变得“不合法”(如出现重复字符)时,移动 left 指针使其重新“合法”。在每次窗口“合法”时,尝试更新最大长度。
    • 典型问题无重复字符的最长子串
  • 求最短子串/子数组

    • 策略right 指针不断扩张窗口。当窗口首次变得“合法”(如包含了所有目标字符)时,记录当前长度。然后,尝试移动 left 指针收缩窗口,看能否在保持“合法”的前提下找到更短的窗口。
    • 典型问题:最小覆盖子串。

✅ 适用条件

并非所有问题都适合用滑动窗口。一个问题要能用滑动窗口高效解决,通常需要满足以下条件:

  1. 问题对象是连续区间:求解的是子串或子数组,而不是子序列。
  2. 窗口状态具有单调性:当窗口扩大或缩小时,其状态(如是否包含重复、元素和的大小)的变化是单向的、可预测的。例如,窗口扩大时,如果出现了重复,那么继续扩大只会让情况更糟或保持不变,而不会自动变好。
  3. 状态可快速更新:在窗口边界移动时,能够快速(通常是 O(1) 时间)地更新窗口状态,而不需要重新遍历整个窗口。