【LeetCode Hot100 刷题日记(8/100)】3. 无重复字符的最长子串——滑动窗口、字符串、双指针、哈希表📝

77 阅读6分钟

📝 【3. 无重复字符的最长子串】

📌 题目链接:leetcode.cn/problems/longest-substring-without-repeating-characters/

🔍 难度:中等 | 🏷️ 标签:字符串、双指针、滑动窗口、哈希表

⏱️ 目标时间复杂度:O(n)

💾 空间复杂度:O(∣Σ∣),其中 Σ 是字符集大小,通常为 ASCII 字符集,即 O(128) ≈ O(1)


🧠 题目分析

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

✅ 示例说明:

输入: s = "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
输入: s = "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。
输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
     注意 "pwke" 是一个子序列,不是子串。

❓ 关键点解析:

  • 子串 vs 子序列:子串要求连续,子序列不要求。本题是「子串」!
  • 目标:找最长的 连续且无重复字符 的片段。
  • 暴力解法:枚举所有子串 → 时间复杂度 O(n³),不可接受。
  • 优化方向:利用「滑动窗口」思想,将时间降至 O(n)。

🔍 核心算法及代码讲解

🎯 核心思想:滑动窗口(Sliding Window)

滑动窗口是一种在数组或字符串上进行高效搜索的技术,特别适用于“寻找满足条件的最短/最长连续子数组”类问题。

✅ 为什么用滑动窗口?

  • 我们要找的是连续子串,且不能有重复字符;
  • 当我们向右移动左边界时,右边界不会回退(单调性);
  • 所以可以用两个指针维护一个“合法区间”,动态扩展与收缩。

🧩 窗口如何工作?

操作动作
右指针 rk 向右尝试加入新字符
若出现重复左指针 i 向右移动,直到不再重复
记录当前窗口长度更新最大值

🛠️ 数据结构选择:unordered_set<char> occ

  • 快速判断字符是否已存在;
  • 支持 insert, erase, count 操作,均 O(1) 平均时间;
  • 不需要存储索引,只需知道是否存在即可。

💡 优化技巧:避免重复遍历

  • 一旦某个字符重复,我们不需要从头开始,而是直接跳过前面的无效部分;
  • 这正是滑动窗口的核心优势:只向前走,不回头

🧱 解题思路(分步详解)

🚶‍♂️ 步骤一:初始化变量

unordered_set<char> occ;        // 记录当前窗口内出现的字符
int rk = -1, ans = 0;           // 右指针初始在-1,ans记录答案

rk = -1 表示尚未开始,相当于在字符串左侧边界外。


🔄 步骤二:枚举左指针 i

  • i0 开始,代表当前子串的起始位置;
  • 每次循环前,先移除 s[i-1](因为左指针右移了);
  • 这样保证 occ 中始终是 [i, rk] 区间的字符集合。

🧠 类比:想象你在扫地,每往前一步,就扔掉左边的地砖。


➡️ 步骤三:扩展右指针 rk

while (rk + 1 < n && !occ.count(s[rk + 1])) {
    occ.insert(s[rk + 1]);
    ++rk;
}
  • 只要下一个字符不在集合中,就继续加入并右移;
  • 一旦发现重复,停止扩展,进入下一步。

📌 注意:rk + 1 < n 防止越界。


📏 步骤四:更新答案

ans = max(ans, rk - i + 1);
  • 当前窗口 [i, rk] 是以 i 开始的最长无重复子串;
  • 长度为 rk - i + 1
  • 更新全局最大值。

🔁 步骤五:循环结束,返回结果

  • i 遍历完所有可能的起始点;
  • 最终 ans 即为所求。

📊 算法分析

项目分析
时间复杂度O(n) ✅
虽然有嵌套 while,但 rki 都只会向前移动,总共最多走 n 步,因此总操作次数 ≤ 2n
空间复杂度O(∣Σ∣) ✅
哈希集合最多存储所有不同字符,ASCII 字符集为 128,可视为常数空间
适用场景所有“最长/最短连续子串”、“固定/变长窗口”问题,如:
• 最长重复子串
• 字符串中包含特定字符的最小窗口
• 数组中和为 k 的子数组

💻 代码实现(保留原模板,完整注释)

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

class Solution {
public:
    int lengthOfLongestSubstring(string s) {
        // 哈希集合,记录每个字符是否出现过
        unordered_set<char> occ;
        int n = s.size();
        // 右指针,初始值为 -1,相当于我们在字符串的左边界的左侧,还没有开始移动
        int rk = -1, ans = 0;
        // 枚举左指针的位置,初始值隐性地表示为 -1
        for (int i = 0; i < n; ++i) {
            if (i != 0) {
                // 左指针向右移动一格,移除一个字符
                occ.erase(s[i - 1]);
            }
            // 不断地移动右指针,直到遇到重复字符
            while (rk + 1 < n && !occ.count(s[rk + 1])) {
                // 将新的字符加入集合
                occ.insert(s[rk + 1]);
                // 右指针右移
                ++rk;
            }
            // 当前窗口 [i, rk] 是以 i 开始的最长无重复子串
            ans = max(ans, rk - i + 1);
        }
        return ans;
    }
};

// 测试
signed main(){
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);

    Solution sol;
    
    // 测试用例1
    string s1 = "abcabcbb";
    cout << "Input: \"" << s1 << "\" -> Output: " << sol.lengthOfLongestSubstring(s1) << endl; // 输出: 3

    // 测试用例2
    string s2 = "bbbbb";
    cout << "Input: \"" << s2 << "\" -> Output: " << sol.lengthOfLongestSubstring(s2) << endl; // 输出: 1

    // 测试用例3
    string s3 = "pwwkew";
    cout << "Input: \"" << s3 << "\" -> Output: " << sol.lengthOfLongestSubstring(s3) << endl; // 输出: 3

    // 测试用例4:空字符串
    string s4 = "";
    cout << "Input: \"" << s4 << "\" -> Output: " << sol.lengthOfLongestSubstring(s4) << endl; // 输出: 0

    // 测试用例5:单字符
    string s5 = "a";
    cout << "Input: \"" << s5 << "\" -> Output: " << sol.lengthOfLongestSubstring(s5) << endl; // 输出: 1

    return 0;
}

🚀 面试拓展 & 优化建议

🔍 面试题常考变形:

  1. 返回最长子串本身(而非长度)

    • 修改:记录 startmaxLen,最后截取子串;
    • 示例:
      string longestSubstring = s.substr(start, maxLen);
      
  2. 支持 Unicode 字符(如中文)

    • 使用 unordered_set<wchar_t>unordered_map<int, int> 映射码点;
    • 注意内存开销增加。
  3. 允许重复字符,但限制重复次数

    • 使用 unordered_map<char, int> 记录频次;
    • 当某字符频次 > k 时,左指针右移。
  4. 扩展到子数组问题

    • 如:“和为 k 的最长子数组”、“包含至少 k 个不同字符的最短子串”等。

✅ 滑动窗口通用模板(面试必背)

int slidingWindow(vector<int>& nums) {
    unordered_map<int, int> freq;  // 频次统计
    int left = 0, right = 0;
    int result = 0;

    while (right < nums.size()) {
        // 扩展右边界
        freq[nums[right]]++;
        right++;

        // 缩小左边界,保持条件成立
        while (invalidCondition(freq)) {
            freq[nums[left]]--;
            if (freq[nums[left]] == 0) freq.erase(nums[left]);
            left++;
        }

        // 更新答案
        result = max(result, right - left);
    }

    return result;
}

📌 该模板可用于解决大多数滑动窗口问题,核心是:维护窗口合法性 + 动态调整边界


🌟 本期完结,下期见!🔥

👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!
💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪


📣 下一期预告:LeetCode 热题 100 第9题 —— 438.找到字符串中所有字母异位词(中等)

🔹 题目:给定一个字符串 s 和一个非空字符串 p,找出 s 中所有 p 的字母异位词的起始索引。

🔹 核心思路:使用滑动窗口 + 哈希表统计字符频次,比较窗口内字符是否与 p 完全一致。

🔹 考点:滑动窗口、字符频次统计、双指针、字符串匹配。

🔹 难度:中等,是“模式匹配”类问题的经典应用,常用于文本处理系统设计。

💡 提示:不要暴力枚举所有子串!要用滑动窗口优化至 O(n)!


📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!