从暴力到滑动窗口的终极形态:力扣3「无重复字符的最长子串」的优化进化之路

作者:胡萝卜术日期:2026/7/19

从暴力到滑动窗口的终极形态:力扣3「无重复字符的最长子串」的优化进化之路

当我们从数组和链表的“冰冷内存”转向字符串的“流式字符”时,滑动窗口才真正展现出它最优雅的一面。这道题,就是滑动窗口思想的“封神之作”。

前言

在连续攻克了链表专题的重重关卡——从反转链表(206)到LRU缓存(146)——之后,是时候进入一个全新的数据结构领域了。今天,我们首先要面对的,是字符串/数组专题中最经典、最基础、也是面试中出现频率最高的题目之一——力扣3. 无重复字符的最长子串(Longest Substring Without Repeating Characters)

这道题在LeetCode上标记为中等(Medium),但它的江湖地位绝不亚于任何一道Hard题。在字节跳动、腾讯、Google、Amazon的面试中,它几乎是“开场白”级别的必考题。它完美地考察了**滑动窗口(Sliding Window)**这一核心算法思想,而且提供了从暴力到优化的完整进化路线。

题目描述极其简洁:给定一个字符串 s ,请你找出其中不含有重复字符的 最长子串 的长度。

很多同学一看:“找子串,无重复,这还不简单?双重循环遍历所有子串,再用Set判重就行了。”——这固然能解,但面对 10^5 级别的字符串长度时,O(n²) 的复杂度会让你在面试官面前直接“社死”。

今天,我们将从最直观的暴力枚举法出发,逐步进化到滑动窗口(HashSet版),最后抵达那个让时间复杂度彻底降至 O(n) 的滑动窗口(HashMap优化版)。这不仅仅是解一道题,更是一次对“双指针”和“状态维护”的深刻洗礼。

题目回顾

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

示例1:

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

示例2:

1输入: s = "bbbbb"
2输出: 1
3

示例3:

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

约束条件: 0 <= s.length <= 5 * 10^4s 由英文字母、数字、符号和空格组成。

核心难点:如何高效地“伸缩”窗口?

“无重复字符的子串”本质上是一个区间问题。我们需要在字符串上维护一个区间 [left, right],保证这个区间内的字符都没有重复。

当我们在 right 指针向右扩展时,如果发现新加入的字符在窗口内已经存在了,我们就必须移动 left 指针,把那个重复的字符“踢出”窗口。这个“踢出”的过程,决定了算法的效率:

  • 如果 left 一次只挪动一步,算法可能在某些情况下退化。
  • 如果 left 能直接跳到重复字符的下一个位置,效率将达到极致。

第一层:暴力法 —— 最直观的“地毯式搜索”

如果暂时想不到优化,暴力法就是我们思考的起点。

核心思想

枚举所有子串的起始位置 i 和结束位置 j,用 Set 检查子串 s[i:j] 是否有重复字符。如果没有重复,记录最大长度。

1class Solution {
2    public int lengthOfLongestSubstring(String s) {
3        int n = s.length();
4        int maxLen = 0;
5        for (int i = 0; i < n; i++) {
6            for (int j = i; j < n; j++) {
7                if (allUnique(s, i, j)) {
8                    maxLen = Math.max(maxLen, j - i + 1);
9                }
10            }
11        }
12        return maxLen;
13    }
14    
15    private boolean allUnique(String s, int left, int right) {
16        Set<Character> set = new HashSet<>();
17        for (int i = left; i <= right; i++) {
18            if (set.contains(s.charAt(i))) return false;
19            set.add(s.charAt(i));
20        }
21        return true;
22    }
23}
24

复杂度分析:

  • 时间复杂度: O(n^3)。外层循环 O(n),内层循环 O(n),判重又是 O(n),三重循环直接爆炸。
  • 空间复杂度: O(min(n, m))m 为字符集大小。

点评:这个解法显然无法通过 LeetCode 的极限测试,但它的意义在于让我们理解了问题的本质是区间合法性检查。接下来所有的优化,都围绕着“如何在不重复遍历的情况下,动态维护一个合法区间”展开。

第二层:滑动窗口(标准HashSet版) —— 双指针的初次登场

暴力法为什么慢?因为对于每一个起始位置 i,我们都要重新扫描一遍 j 来构建子串,这中间存在大量的重复计算。

如果我们在 i 固定的情况下,把 j 一直向右扩展,直到遇到重复字符为止,记录长度。然后我们移动 i(左指针),此时 j 没有必要回到 i 的位置重新开始,因为 [i+1, j] 这段区间内大概率也是合法的。这就是滑动窗口的雏形。

核心思想

维护一个窗口 [left, right],用 HashSet 存储窗口内的字符:

  1. right 指针不断向右移动,尝试扩大窗口。
  2. 如果 s[right] 不在 Set 中,加入 Set,更新最大长度。
  3. 如果 s[right] 已经在 Set 中,说明窗口内存在重复。此时我们需要移动 left 指针,将 s[left]Set 中移除,直到窗口中不再包含 s[right] 这个重复字符。
1class Solution {
2    public int lengthOfLongestSubstring(String s) {
3        int n = s.length();
4        Set<Character> set = new HashSet<>();
5        int left = 0, right = 0;
6        int maxLen = 0;
7        
8        while (right < n) {
9            char c = s.charAt(right);
10            // 如果窗口内已有 c,移动 left 缩小窗口,直到移除重复
11            while (set.contains(c)) {
12                set.remove(s.charAt(left));
13                left++;
14            }
15            // 此时窗口内没有重复字符
16            set.add(c);
17            maxLen = Math.max(maxLen, right - left + 1);
18            right++;
19        }
20        return maxLen;
21    }
22}
23

复杂度分析:

  • 时间复杂度: O(2n) = O(n)。在最坏情况下(如 "aaaaa"),leftright 各自遍历了整个字符串一次。
  • 空间复杂度: O(min(n, m))

**点评:**这是滑动窗口的标准写法,思路清晰,容易理解。面试中写出这个,你已经可以拿到 80 分了。但仔细观察,你会发现 while 循环中的 left 是一次一次移动的。在极端情况下(比如 s 极长且全是重复字符),这个 while 虽然总体是 O(n),但在某些字符集很大的场景下,能不能“跳”得更快一点呢?

第三层:滑动窗口(HashMap跳表版) —— 极致的“跳跃”

在第二层的写法中,当遇到重复字符 c 时,我们是通过循环一步一步移动 left 来缩小窗口的。但实际上,我们知道 left 应该跳到什么位置——跳到上一次出现 c 的位置的后面一个位置

如果我们用 HashMap<Character, Integer> 存储每个字符最近一次出现的位置,那么当遇到重复字符 c 时,我们可以直接将 left 更新为 map.get(c) + 1,而不需要一步一步地挪动。

但是!这里有一个巨大的陷阱: left 只能向前移动(不能后退)。如果 map.get(c) + 1 小于当前的 left,说明这个“上一次出现的位置”已经在窗口之外了,我们不应该让 left 后退,所以应该取 leftmap.get(c) + 1最大值

1class Solution {
2    public int lengthOfLongestSubstring(String s) {
3        int n = s.length();
4        Map<Character, Integer> map = new HashMap<>(); // 字符 -> 最新出现的位置(索引)
5        int left = 0;
6        int maxLen = 0;
7        
8        for (int right = 0; right < n; right++) {
9            char c = s.charAt(right);
10            // 如果 c 出现过,并且它的位置在 [left, right] 区间内
11            if (map.containsKey(c) && map.get(c) >= left) {
12                // 左指针直接跳到重复字符的下一个位置
13                left = map.get(c) + 1;
14            }
15            // 更新 c 的最新位置(无论是否重复,都要更新为当前 right)
16            map.put(c, right);
17            // 计算当前窗口长度
18            maxLen = Math.max(maxLen, right - left + 1);
19        }
20        return maxLen;
21    }
22}
23

图解流程(以 s = "abba" 为例):

  1. right=0, 'a'left=0,max=1。map: a=0。
  2. right=1, 'b'left=0,max=2。map: a=0, b=1。
  3. right=2, 'b':发现 map 中 b=1 >= left(0),left = 1+1 = 2。max = max(2, 2-2+1=1) = 2。map: b=2。
  4. right=3, 'a':发现 map 中 a=0,但 0 >= left(2) 是 false,所以 left 不动(保持2)。max = max(2, 3-2+1=2) = 2。结果正确(最长子串 "ab" 或 "ba")。

复杂度分析:

  • 时间复杂度: O(n)leftright 各遍历一次,且 left 是跳跃前进的,比第二层更快。
  • 空间复杂度: O(min(n, m))

点评:这是本题的最优解。它完美地利用了“最近出现位置”这个信息,将窗口调整的操作从 while 循环降维成了数学运算。在面试中,如果你能写出这个版本,并清楚地解释“为什么 left = Math.max(left, map.get(c) + 1)”,面试官一定会对你刮目相看。

第四层(终极优化):数组代替HashMap —— 极致的常数优化

由于题目只说了 s 由字母、数字、符号和空格组成,在 ASCII 码范围内(0-127,或者扩展到 256)。我们可以用一个 长度为 128 或 256 的整型数组 来代替 HashMap,实现更快的 O(1) 存取。

1class Solution {
2    public int lengthOfLongestSubstring(String s) {
3        int[] lastIndex = new int[128]; // 初始化为 -1
4        Arrays.fill(lastIndex, -1);
5        int left = 0;
6        int maxLen = 0;
7        
8        for (int right = 0; right < s.length(); right++) {
9            char c = s.charAt(right);
10            int idx = c; // 自动转型为 int ASCII 
11            if (lastIndex[idx] >= left) {
12                left = lastIndex[idx] + 1;
13            }
14            lastIndex[idx] = right;
15            maxLen = Math.max(maxLen, right - left + 1);
16        }
17        return maxLen;
18    }
19}
20

如果字符集扩展到 Unicode(比如汉字),数组就不够用了,还是得用 HashMap。但在绝大多数面试场景(英文字母)下,数组写法是加分项。

深度总结:三种解法进化图谱

解法时间复杂度空间复杂度核心思想面试推荐度
暴力法O(n^3)O(min(n,m))三重循环枚举+判重⭐(仅用于理解定义)
滑动窗口(HashSet)O(2n)O(min(n,m))双指针维护窗口,循环收缩左边界⭐⭐⭐⭐(标准答案)
滑动窗口(HashMap跳表)O(n)O(min(n,m))左指针直接跳到重复字符后一位⭐⭐⭐⭐⭐(最优解,必会)
数组代替HashMapO(n)O(128)常数级优化,极客风范⭐⭐⭐⭐⭐(加分项)

从这道题中我们学到了什么?

  1. 滑动窗口是处理“子串/子数组”问题的第一武器。当你看到“最长子串”、“最短子数组”、“包含特定条件”等关键词时,首先应该想到滑动窗口。
  2. “空间换时间”的极致运用。从 Set 到 HashMap 再到数组,我们都在用额外的空间存储“历史位置”信息,从而让时间从 O(n^2) 降为 O(n)。
  3. left 指针的“不可后退性”。这是滑动窗口的精髓所在。left 只能前进(或不变),绝对不能后退。在 HashMap 版本中,Math.max 的存在正是为了守护这一原则,防止因为窗口外的过期数据导致 left 错误地回退。
  4. 从“循环缩窗”到“数学跳窗”。第二层用 while 循环收缩,虽然总体是 O(n),但第三层用 map.get 直接跳转,大大减少了常数时间的开销。这种“利用额外信息替代循环”的思路,在算法优化中极其常见。

最后的一些心里话

力扣3是一道经典中的经典。它不像 LRU 那样需要手写复杂的数据结构,也不像反转链表那样考验指针的物理操作,它考验的是你对“区间状态”的敏锐感知。

很多同学在做这道题时,死记硬背了 HashMap 的模板,但当被问到“为什么要取 max”时,却支支吾吾。我希望你能记住我们今天讲的“过期数据”的概念:map 里的位置可能已经小于 left 了,这时候它就不是“窗口内”的数据,不应该影响 left 的跳跃。

从今天起,每当你看到一个字符串处理问题,先问问自己:“我能用滑动窗口吗?”——这将是你解题思路的第一步。

记住今天的口诀:

无重子串滑动窗,左移右扩拉长度。 哈希记录索引位,左针跳过重复处。 若遇旧位在窗外,千万莫把指针误。


如果你觉得这篇题解帮你彻底搞懂了无重复字符的最长子串,欢迎点赞、收藏、转发!我们下一站将剑指力扣5「最长回文子串」,不见不散! 🚀


从暴力到滑动窗口的终极形态:力扣3「无重复字符的最长子串」的优化进化之路》 是转载文章,点击查看原文


相关推荐


MCP 入门实战:写一个能读本地文件的极简服务
To_OC2026/7/11

前几天折腾 AI IDE 的时候,一直有个特别烦人的痛点:大模型只能跟你聊代码逻辑,没法直接读我本地的项目文件。每次想让它帮我看个配置、改个脚本,都得手动复制一大段内容粘贴进去,文件长了特别折腾。 直到我看到有人提 MCP,说能让大模型直接调用本地工具。我寻思不就是读个文件嘛,应该不难,索性自己动手写个最简单的文件读取 MCP 服务。结果真上手才发现,坑全在细节里,折腾了小半天才跑通。今天顺着我当时的思路捋一遍,省得后面有人跟我一样走弯路。 先搞懂:MCP 到底在中间干了啥 说实话,最开始我对


Gson → kotlinx.serialization
plainGeek2026/7/3

Gson → kotlinx.serialization 老写法(Java + Gson) Gson gson = new Gson(); // 序列化 Item item = new Item(1, "商品", 9.99); String json = gson.toJson(item); // 反序列化 Item parsed = gson.fromJson(json, Item.class); List<Item> list = gson.fromJson(jsonArray,


图解 MongoDB 12|索引与查询优化地图:一条主线,三个判断轴
十三Tech2026/6/25

到这里,索引与查询优化这个阶段就讲完了。从第 04 篇的索引模型,到第 11 篇的慢查询排查闭环,中间穿过了索引类型、ESR 原则、explain、覆盖查询。这些不是孤立的知识点,而是一条连贯的主线——每一步都在回答「怎么让查询又快又省」。 这一篇是阶段的收束,不引入新机制,而是把前面讲过的东西收成一张地图和三个判断轴,方便你在实际工作中快速调用。后面进入存储引擎与内存阶段(13–17)时,会从「查询怎么用索引」下沉到「索引和数据怎么在内存里」。 一条主线 这条主线有六个节点,对应这个阶段的六


Vue集成uuid生成唯一标识实践指南
独泪了无痕2026/6/16

一、核心基础 1.1 UUID 是什么   UUID(通用唯一标识符,Universally Unique Identifier) 是一个 128 位用于标识信息的唯一标识符,通常以 32 个十六进制的字符串形式呈现,具有全球唯一性(理论上重复概率可忽略),非常适合用于标识网络中的资源、数据记录或其他任何需要唯一标识的实体。 UUID 生成器:devtool.tech/uuid 1.2 uuid.js 库概述   uuid.js 是用于生成 UUID 的 JavaScript 库,解决


Agent 系列(16):工具链设计——让 LLM 用对工具的五个原则
冬奇Lab2026/6/9

工具文档是写给 LLM 的,不是写给人的 你有没有写过这样的工具文档: @lc_tool def get_data(query: str) -> str: """Get data.""" ... 这对人类来说是糟糕的文档,对 LLM 来说更糟——它不知道这个工具做什么、什么时候调它、传什么参数。 工具设计有三条核心维度:描述质量(LLM 选不选你)、错误处理(出错时崩不崩)、粒度设计(参数好不好提取)。本文用实验数据说话。 Demo 1:描述质量——真正影响工具选择的条件 对


实战解析:如何用自然语言驱动混沌工程?Blade AI Agent 实现故障演练全链路自动化
阿里云云原生2026/6/1

作者:林曜、穹谷 混沌工程为什么难落地? 每个 SRE 团队都知道混沌工程的价值——在可控条件下主动注入故障,验证系统韧性,防患于未然。 但现实是,绝大多数团队的故障演练停留在“年度任务”而非“日常习惯”。原因很简单: 门槛太高,流程太碎。 一次完整演练五步:定位目标 → 拼装命令 → 确认安全 → 验证效果 → 善后清理。每一步都要查文档、写参数、跑命令。即使是经验丰富的工程师,单次演练也需要 20-30 分钟。而任何一步遗漏(忘了验证、忘了清理),后果都可能比不演练更糟。 Blade AI


策略周度复盘 | 2026年wk19
0xAI2026/5/12

本文观点仅供参考,不构成任何投资建议。投资有风险,入市需谨慎。 一、本周大盘走势 本周从周三开始开盘,只有3个交易日(5月6日-8日),但是整个大A还是实现了开门红。到周五收盘为止,整个大盘走势稳扎稳打,虽然有大涨,不过回调也比较有限,仍然维持着比较强势的多头态势。再加上外围美股市场AI科技大行其道,一片”涨声“,所以下周开盘,大概率还会延续本周的涨势。手上有票的朋友不必慌张,可以继续持股等着更大的涨幅。 接下来,还是老规矩,我们以真实数据说话,一图胜千言。本周三大股指本周仍然是以创业板为主,


🚀 2026 年 4 月 GitHub 十大热门项目排行榜 🔥
一点一木2026/5/2

欢迎来到 2026 年 4 月 GitHub 热门开源项目排行榜!本月榜单横跨 成长型通用智能体、Claude Code 技能与记忆、文档—Markdown 数据管线、Token 经济学 CLI、多智能体协作平台、Harness / 工作流治理、Agent-Native 教育 与 金融时序基础模型 等方向。这些项目共同指向:把编码智能体从「单次对话」推进到「可协作、可度量、可沉淀」;它们几乎全部围绕「更稳的 Harness、更省的 Token、更真的垂直数据」展开,不再是概念验证,而是可以立刻嵌


数据仓库是什么?怎么搭建数据仓库?
isNotNullX2026/4/23

我们每天都在跟数据打交道,但提到数据仓库这个词,大多数人的第一反应还是——听说过,但说不清到底是什么。 有人觉得它就是存数据的地方; 有人觉得它和数据库差不多; 也有不少人以为,只有大厂、只有数据团队才需要数据仓库。 实际上,只要企业存在多个业务系统、多个部门协同、多个分析口径,数据仓库几乎就会成为绕不开的一步。 这篇文章,我们就把数据仓库这件事彻底讲清楚: 数据仓库到底是什么?企业为什么需要它?怎么搭建?又能给企业带来什么价值? 开始之前,我整理了一份数据仓库建设解决方案,里面涵盖了从


C语言-----扫雷游戏
2026/4/14

扫雷游戏的功能说明 : • 使⽤控制台实现经典的扫雷游戏 • 游戏可以通过菜单实现继续玩或者退出游戏 • 扫雷的棋盘是9*9的格⼦ • 默认随机布置10个雷 • 可以排查雷: ◦ 如果位置不是雷,就显⽰周围有⼏个雷 ◦ 如果位置是雷,就炸死游戏结束 ◦ 把除10个雷之外的所有⾮雷都找出来,排雷成功,游戏结束 test.c //⽂件中写游戏的测试逻辑 game.c //⽂件中写游戏中函数的实现等 game.h //⽂件中写游戏需要的数据类型和函数声明等 逻辑开始: 一、菜单 输入1进入游戏,输入

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2026 聚合阅读