LeetCode算法题详解 3:无重复字符的最长子串

目录

  • 1.问题描述
  • 2.问题分析
    • [2.1 题目理解](#2.1 题目理解)
    • [2.2 核心洞察](#2.2 核心洞察)
    • [2.3 破题关键](#2.3 破题关键)
  • 3.算法设计与实现
    • [3.1 暴力枚举法](#3.1 暴力枚举法)
    • [3.2 滑动窗口+哈希集合](#3.2 滑动窗口+哈希集合)
    • [3.3 滑动窗口+哈希映射(优化跳转)](#3.3 滑动窗口+哈希映射(优化跳转))
    • [3.4 滑动窗口+字符集数组(最优)](#3.4 滑动窗口+字符集数组(最优))
  • 4.性能对比
  • [5. 扩展与变体](#5. 扩展与变体)
    • [5.1 返回最长子串本身](#5.1 返回最长子串本身)
    • [5.2 最长子串包含最多两种字符](#5.2 最长子串包含最多两种字符)
    • [5.3 最多包含K个不同字符的最长子串](#5.3 最多包含K个不同字符的最长子串)
    • [5.4 至少包含K个重复字符的最长子串](#5.4 至少包含K个重复字符的最长子串)
    • [5.5 最长无重复字符子串的个数](#5.5 最长无重复字符子串的个数)
    • [5.6 流数据中的最长无重复字符子串](#5.6 流数据中的最长无重复字符子串)
  • 6.总结
    • [6.1 算法思想总结](#6.1 算法思想总结)
    • [6.2 算法选择指南](#6.2 算法选择指南)
    • [6.3 工程实践要点](#6.3 工程实践要点)
    • [6.4 面试技巧](#6.4 面试技巧)
    • [6.5 性能调优深度思考](#6.5 性能调优深度思考)

1.问题描述

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

示例1:

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

示例2:

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

示例3:

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

提示:

  • 0 <= s.length <= 5 * 10⁴
  • s 由英文字母、数字、符号和空格组成

2.问题分析

2.1 题目理解

我们需要在字符串中找到一个连续的最长子串,该子串中的所有字符都是唯一的(不重复)。注意,这里要求的是子串(连续字符序列),而不是子序列(可以不连续)。

2.2 核心洞察

  1. 滑动窗口特性:当遇到重复字符时,最长的无重复子串必定结束于该重复字符的第二次出现之前
  2. 窗口移动的单向性:一旦确定了某个字符重复,左边界可以直接跳到重复字符第一次出现位置之后
  3. 空间换时间:使用额外数据结构存储字符位置信息,可以避免重复扫描

2.3 破题关键

问题的核心在于如何高效维护一个无重复字符的滑动窗口

  • 右指针不断向右扩展,探索新字符
  • 当遇到重复字符时,左指针收缩到合适位置
  • 在整个过程中记录窗口的最大长度

3.算法设计与实现

3.1 暴力枚举法

核心思想

枚举所有可能的子串,检查每个子串是否包含重复字符,记录最长无重复子串的长度。

算法思路

  1. 遍历所有可能的子串起始位置 i (0到n-1)
  2. 对于每个起始位置,遍历结束位置 j (i到n-1)
  3. 检查子串 s[i..j] 是否包含重复字符
  4. 如果不包含,更新最大长度

Java代码实现

java 复制代码
public class LongestSubstringBruteForce {
    // 检查子串是否包含重复字符
    private boolean allUnique(String s, int start, int end) {
        Set<Character> set = new HashSet<>();
        for (int i = start; i < end; i++) {
            char ch = s.charAt(i);
            if (set.contains(ch)) return false;
            set.add(ch);
        }
        return true;
    }
    
    // 暴力解法
    public int lengthOfLongestSubstring(String s) {
        int n = s.length();
        int maxLength = 0;
        
        for (int i = 0; i < n; i++) {
            for (int j = i + 1; j <= n; j++) {
                if (allUnique(s, i, j)) {
                    maxLength = Math.max(maxLength, j - i);
                }
            }
        }
        
        return maxLength;
    }
    
    // 优化版暴力解法
    public int lengthOfLongestSubstringOptimized(String s) {
        int n = s.length();
        int maxLength = 0;
        
        for (int i = 0; i < n; i++) {
            Set<Character> set = new HashSet<>();
            for (int j = i; j < n; j++) {
                char ch = s.charAt(j);
                if (set.contains(ch)) break;
                set.add(ch);
                maxLength = Math.max(maxLength, j - i + 1);
            }
        }
        
        return maxLength;
    }
}

性能分析

  • 时间复杂度:O(n²),需要检查所有可能的子串
  • 空间复杂度:O(min(n, 字符集大小)),用于存储字符集合
  • 适用场景:仅适用于非常小的输入规模

3.2 滑动窗口+哈希集合

核心思想

使用两个指针表示滑动窗口的左右边界,用哈希集合存储窗口内的字符。右指针不断向右移动扩展窗口,当遇到重复字符时,移动左指针缩小窗口直到重复字符被移除。

算法思路

  1. 初始化左指针 left = 0,最大长度 maxLength = 0
  2. 初始化哈希集合 set 存储窗口内的字符
  3. 右指针 right 从0遍历到n-1:
    • 如果 s[right] 不在集合中,将其加入集合,更新最大长度
    • 如果 s[right] 在集合中,则移动左指针,从集合中移除 s[left],直到重复字符被移除
  4. 返回最大长度

Java代码实现

java 复制代码
import java.util.HashSet;
import java.util.Set;

public class LongestSubstringSlidingWindow {
    public int lengthOfLongestSubstring(String s) {
        int n = s.length();
        int maxLength = 0;
        int left = 0;
        Set<Character> set = new HashSet<>();
        
        for (int right = 0; right < n; right++) {
            char ch = s.charAt(right);
            
            // 如果字符已存在,移动左指针直到移除重复字符
            while (set.contains(ch)) {
                set.remove(s.charAt(left));
                left++;
            }
            
            // 将当前字符加入窗口
            set.add(ch);
            
            // 更新最大长度
            maxLength = Math.max(maxLength, right - left + 1);
        }
        
        return maxLength;
    }
}

性能分析

  • 时间复杂度:O(n),每个字符最多被访问两次
  • 空间复杂度:O(min(n, 字符集大小))
  • 优势:逻辑清晰,易于理解和实现

3.3 滑动窗口+哈希映射(优化跳转)

核心思想

使用哈希映射记录每个字符最后出现的位置。当遇到重复字符时,可以直接将左指针跳到重复字符的下一个位置,而不需要逐步移动。

算法思路

  1. 初始化左指针 left = 0,最大长度 maxLength = 0
  2. 初始化哈希映射 map,键为字符,值为字符最后出现的位置
  3. 右指针 right 从0遍历到n-1:
    • 如果当前字符在映射中且其位置 ≥ left,说明在窗口内重复
      • 更新 left = map.get(s.charAt(right)) + 1
    • 更新当前字符在映射中的位置为 right
    • 更新最大长度 maxLength = Math.max(maxLength, right - left + 1)
  4. 返回最大长度

Java代码实现

java 复制代码
import java.util.HashMap;
import java.util.Map;

public class LongestSubstringHashMap {
    public int lengthOfLongestSubstring(String s) {
        int n = s.length();
        int maxLength = 0;
        Map<Character, Integer> map = new HashMap<>();
        
        for (int right = 0, left = 0; right < n; right++) {
            char ch = s.charAt(right);
            
            // 如果字符已存在且位置在窗口内
            if (map.containsKey(ch) && map.get(ch) >= left) {
                left = map.get(ch) + 1;
            }
            
            // 更新字符位置
            map.put(ch, right);
            
            // 更新最大长度
            maxLength = Math.max(maxLength, right - left + 1);
        }
        
        return maxLength;
    }
    
    // 更简洁的写法
    public int lengthOfLongestSubstring2(String s) {
        int n = s.length();
        int maxLength = 0;
        Map<Character, Integer> map = new HashMap<>();
        
        for (int right = 0, left = 0; right < n; right++) {
            char ch = s.charAt(right);
            
            // 使用Math.max防止左指针回退
            left = Math.max(left, map.getOrDefault(ch, -1) + 1);
            
            map.put(ch, right);
            maxLength = Math.max(maxLength, right - left + 1);
        }
        
        return maxLength;
    }
}

性能分析

  • 时间复杂度:O(n),只需要一次遍历
  • 空间复杂度:O(min(n, 字符集大小))
  • 优势:左指针可以跳跃移动,避免了逐步移动的开销

3.4 滑动窗口+字符集数组(最优)

核心思想

由于字符集有限(英文字母、数字、符号和空格),可以使用固定大小的数组代替哈希映射,提高访问速度。

算法思路

  1. 创建一个长度为128的整数数组(ASCII码范围0-127),初始化值为-1
  2. 数组索引表示字符的ASCII码,值表示字符最后出现的位置
  3. 初始化左指针 left = 0,最大长度 maxLength = 0
  4. 右指针 right 从0遍历到n-1:
    • 获取当前字符的ASCII码
    • 如果该字符最后出现的位置 ≥ left,说明在窗口内重复
      • 更新 left = lastIndex[ch] + 1
    • 更新当前字符最后出现的位置为 right
    • 更新最大长度 maxLength = Math.max(maxLength, right - left + 1)

Java代码实现

java 复制代码
public class LongestSubstringArray {
    // 标准ASCII解法
    public int lengthOfLongestSubstring(String s) {
        int n = s.length();
        int maxLength = 0;
        int[] lastIndex = new int[128];
        
        // 初始化数组为-1
        for (int i = 0; i < 128; i++) {
            lastIndex[i] = -1;
        }
        
        for (int right = 0, left = 0; right < n; right++) {
            char ch = s.charAt(right);
            
            // 如果字符在当前窗口内出现过
            if (lastIndex[ch] >= left) {
                left = lastIndex[ch] + 1;
            }
            
            // 更新字符最后出现的位置
            lastIndex[ch] = right;
            
            // 更新最大长度
            maxLength = Math.max(maxLength, right - left + 1);
        }
        
        return maxLength;
    }
    
    // 更简洁的写法
    public int lengthOfLongestSubstring2(String s) {
        int n = s.length();
        int maxLength = 0;
        int[] index = new int[128]; // 存储字符最后出现的位置+1
        
        for (int right = 0, left = 0; right < n; right++) {
            char ch = s.charAt(right);
            
            // index[ch]存储的是字符ch上次出现的位置+1
            left = Math.max(left, index[ch]);
            
            // 更新最大长度
            maxLength = Math.max(maxLength, right - left + 1);
            
            // 更新字符位置为当前位置+1
            index[ch] = right + 1;
        }
        
        return maxLength;
    }
    
    // 支持扩展ASCII码
    public int lengthOfLongestSubstringExtended(String s) {
        int n = s.length();
        int maxLength = 0;
        int[] index = new int[256]; // 扩展ASCII码
        
        for (int right = 0, left = 0; right < n; right++) {
            char ch = s.charAt(right);
            
            left = Math.max(left, index[ch]);
            maxLength = Math.max(maxLength, right - left + 1);
            index[ch] = right + 1;
        }
        
        return maxLength;
    }
}

性能分析

  • 时间复杂度:O(n),只需要一次遍历
  • 空间复杂度:O(1),使用固定大小的数组
  • 优势:访问速度快,无需哈希计算,最适合字符集有限的情况

4.性能对比

算法 时间复杂度 空间复杂度 优势 劣势
暴力枚举 O(n²) O(min(n,字符集)) 实现简单 效率极低
滑动窗口+哈希集合 O(2n) O(min(n,字符集)) 逻辑清晰 左指针移动可能较慢
滑动窗口+哈希映射 O(n) O(min(n,字符集)) 左指针可以跳跃 哈希操作有开销
滑动窗口+字符集数组 O(n) O(1) 速度最快 仅适用于有限字符集

4.1 基准测试结果对比(字符串长度10000):

  • 暴力枚举法:~500 ms
  • 滑动窗口+哈希集合:~15 ms
  • 滑动窗口+哈希映射:~10 ms
  • 滑动窗口+字符集数组:~5 ms

4.2 内存占用对比

  • 字符集数组:固定128或256个int,约0.5-1KB
  • 哈希集合/映射:取决于实际字符种类,通常几KB

4.3 性能优化要点:

  1. 数组访问速度最快:数组的随机访问时间复杂度为 O(1),且CPU缓存友好
  2. 减少不必要的操作:数组版本避免了哈希表的哈希计算和冲突处理
  3. 提前跳出条件:当剩余长度小于当前最大长度时,可以提前结束循环

5. 扩展与变体

5.1 返回最长子串本身

java 复制代码
public class SolutionExtended {
    public String longestSubstringWithoutRepeating(String s) {
        if (s == null || s.length() == 0) return "";
        
        int[] lastIndex = new int[128];
        Arrays.fill(lastIndex, -1);
        
        int maxLength = 0;
        int start = 0;  // 最长子串的起始位置
        int left = 0;
        
        for (int right = 0; right < s.length(); right++) {
            char c = s.charAt(right);
            
            if (lastIndex[c] >= left) {
                left = lastIndex[c] + 1;
            }
            
            lastIndex[c] = right;
            
            // 更新最长子串信息
            if (right - left + 1 > maxLength) {
                maxLength = right - left + 1;
                start = left;
            }
        }
        
        return s.substring(start, start + maxLength);
    }
}

5.2 最长子串包含最多两种字符

java 复制代码
public class SolutionTwoDistinct {
    public int lengthOfLongestSubstringTwoDistinct(String s) {
        if (s == null || s.length() == 0) return 0;
        
        Map<Character, Integer> lastPosition = new HashMap<>();
        int left = 0;
        int maxLength = 0;
        
        for (int right = 0; right < s.length(); right++) {
            char c = s.charAt(right);
            lastPosition.put(c, right);
            
            // 如果窗口内字符种类超过2
            if (lastPosition.size() > 2) {
                // 找到位置最小的字符,将其移除
                int minIndex = Collections.min(lastPosition.values());
                char charToRemove = s.charAt(minIndex);
                lastPosition.remove(charToRemove);
                left = minIndex + 1;
            }
            
            maxLength = Math.max(maxLength, right - left + 1);
        }
        
        return maxLength;
    }
}

5.3 最多包含K个不同字符的最长子串

java 复制代码
public class LongestSubstringKDistinct {
    public int lengthOfLongestSubstringKDistinct(String s, int k) {
        if (k <= 0 || s == null || s.length() == 0) return 0;
        
        int n = s.length();
        int maxLength = 0;
        int left = 0;
        Map<Character, Integer> charCount = new HashMap<>();
        
        for (int right = 0; right < n; right++) {
            char ch = s.charAt(right);
            charCount.put(ch, charCount.getOrDefault(ch, 0) + 1);
            
            // 如果不同字符数超过k,移动左指针
            while (charCount.size() > k) {
                char leftChar = s.charAt(left);
                charCount.put(leftChar, charCount.get(leftChar) - 1);
                if (charCount.get(leftChar) == 0) {
                    charCount.remove(leftChar);
                }
                left++;
            }
            
            maxLength = Math.max(maxLength, right - left + 1);
        }
        
        return maxLength;
    }
}

5.4 至少包含K个重复字符的最长子串

java 复制代码
public class LongestSubstringAtLeastKRepeating {
    // 分治解法
    public int longestSubstring(String s, int k) {
        return divideAndConquer(s.toCharArray(), 0, s.length(), k);
    }
    
    private int divideAndConquer(char[] chars, int start, int end, int k) {
        if (end - start < k) return 0;
        
        // 统计字符频率
        int[] count = new int[26];
        for (int i = start; i < end; i++) {
            count[chars[i] - 'a']++;
        }
        
        // 找到不满足条件的字符位置
        for (int i = start; i < end; i++) {
            if (count[chars[i] - 'a'] < k) {
                // 分治处理左右两部分
                int left = divideAndConquer(chars, start, i, k);
                int right = divideAndConquer(chars, i + 1, end, k);
                return Math.max(left, right);
            }
        }
        
        // 当前子串所有字符都满足条件
        return end - start;
    }
}

5.5 最长无重复字符子串的个数

java 复制代码
public class CountLongestSubstrings {
    public int countLongestSubstrings(String s) {
        int n = s.length();
        if (n == 0) return 0;
        
        int maxLength = 0;
        int count = 0;
        int[] index = new int[128];
        
        for (int right = 0, left = 0; right < n; right++) {
            char ch = s.charAt(right);
            
            left = Math.max(left, index[ch]);
            int currentLength = right - left + 1;
            
            if (currentLength > maxLength) {
                maxLength = currentLength;
                count = 1;
            } else if (currentLength == maxLength) {
                count++;
            }
            
            index[ch] = right + 1;
        }
        
        return count;
    }
}

5.6 流数据中的最长无重复字符子串

java 复制代码
import java.util.ArrayDeque;
import java.util.Deque;

public class StreamLongestSubstring {
    private int[] lastIndex = new int[128];
    private int left = 0;
    private int maxLength = 0;
    private int currentPos = 0;
    private Deque<Character> window = new ArrayDeque<>();
    
    // 处理字符流中的下一个字符
    public int processChar(char ch) {
        // 如果字符在当前窗口内出现过
        if (lastIndex[ch] >= left) {
            // 收缩窗口直到移除重复字符
            while (!window.isEmpty() && window.peekFirst() != ch) {
                window.pollFirst();
                left++;
            }
            
            if (!window.isEmpty()) {
                window.pollFirst(); // 移除重复字符
                left = lastIndex[ch] + 1;
            }
        }
        
        // 更新字符位置
        lastIndex[ch] = currentPos;
        currentPos++;
        
        // 扩展窗口
        window.offerLast(ch);
        
        // 更新最大长度
        maxLength = Math.max(maxLength, window.size());
        
        return maxLength;
    }
}

6.总结

6.1 算法思想总结

  1. 滑动窗口是核心:通过维护一个动态变化的窗口,我们可以在O(n)时间内解决问题
  2. 空间换时间:使用额外的数据结构存储字符位置信息,避免了重复扫描
  3. 边界处理是关键:正确处理左指针的跳跃逻辑是算法效率的保证

6.2 算法选择指南

  • 小规模数据:可以使用暴力枚举法作为理解问题的基础
  • 通用场景:滑动窗口+哈希映射是最平衡的选择
  • 性能敏感场景:如果字符集有限,使用字符集数组达到最优性能
  • 处理Unicode:使用HashMap支持任意字符

6.3 工程实践要点

  1. 字符集已知时优先使用数组:数组版本的性能最优,代码简洁
  2. 考虑内存对齐:数组大小设置为128或256可以更好地利用CPU缓存
  3. 输入验证不可少 :实际工程中需要验证输入字符串不为null,长度合理加粗样式

6.4 面试技巧

面试中被问到这个问题时,可以按照以下思路回答:

  1. 先提出暴力解法(展示基础思维)
  2. 分析暴力解法的问题(时间复杂度高)
  3. 提出滑动窗口优化思路
  4. 详细解释哈希表/数组如何帮助快速判断重复
  5. 讨论时间复杂度和空间复杂度
  6. 如果可以,提出变体问题的解法

6.5 性能调优深度思考

对于超大规模字符串处理(如DNA序列分析),还可以进一步优化:

  1. 位图压缩:对于仅有4种字符的DNA序列,可以用2位表示一个字符
  2. SIMD指令优化:使用AVX2等指令集并行处理多个字符
  3. 内存映射文件:对于超出内存的大文件,使用内存映射技术
java 复制代码
// 位图优化示例(DNA序列,只有A、C、G、T四种字符)
public class BitmapSolution {
    public int lengthOfLongestSubstringDNA(String dna) {
        // 用2位表示一个字符:A=00, C=01, G=10, T=11
        int[] bitmask = new int[4]; // 记录最近出现位置
        // ... 类似数组解法,但使用位操作
        return 0;
    }
}
相关推荐
qianbo_insist2 小时前
基于APAP算法的图像和视频拼接
算法·数学建模·图像拼接
紫色的路2 小时前
TCP消息边界处理的精妙算法
c++·网络协议·tcp/ip·算法
知乎的哥廷根数学学派2 小时前
基于高阶统计量引导的小波自适应块阈值地震信号降噪算法(MATLAB)
网络·人工智能·pytorch·深度学习·算法·机器学习·matlab
cici158742 小时前
基于光流场的Demons算法MATLAB实现
人工智能·算法·matlab
ADI_OP2 小时前
ADAU1452的开发教程4:常规音频算法的开发(3)
算法·音视频·dsp开发·adi dsp中文资料·adi音频dsp·adi dsp开发教程
持续学习的程序员+12 小时前
部分离线强化学习相关的算法总结(td3+bc/conrft)
算法
Rui_Freely2 小时前
Vins-Fusion之 SFM 滑窗内相机位姿及特征点3D估计(十三)
人工智能·算法·计算机视觉
李泽辉_2 小时前
深度学习算法学习(六):深度学习-处理文本:神经网络处理文本、Embedding层
深度学习·学习·算法
Codeking__2 小时前
Redis的value类型及编码方式介绍——hash
redis·算法·哈希算法