news 2026/10/27 1:55:28

LeetCode 3. 无重复字符的最长子串:

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LeetCode 3. 无重复字符的最长子串:

目录

​编辑

一、 引言与题目描述

二、 从零开始的推理:暴力解法与瓶颈

2.1 穷举所有子串(暴力解法)

2.2 寻找瓶颈:我们重复计算了什么?

三、 为什么可以使用滑动窗口?(核心推理)

3.1 单调性的发现

3.2 算法设计

四、Java 代码

五、 算法正确性证明(严谨推理)

六、 复杂度分析

七、 进阶优化思路


一、 引言与题目描述

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

示例:

  • 输入:s = "abcabcbb",输出:3(最长子串为 "abc")

  • 输入:s = "bbbbb",输出:1(最长子串为 "b")

  • 输入:s = "pwwkew",输出:3(最长子串为 "wke")

提示:

  • 0≤s.length≤10 ^ 5

  • s由英文字母、数字、符号和空格组成。

面对这道题,我们如何从零开始思考,并最终写出优雅的 O(N) 算法?让我们开始推理。

二、 从零开始的推理:暴力解法与瓶颈

2.1 穷举所有子串(暴力解法)

寻找“最长”,最直观的想法就是检查所有的子串。我们可以用两层for循环枚举子串的起点i和终点j,然后再用一层循环或者哈希表判断s[i...j]是否有重复字符。

时间复杂度:枚举起点和终点 O(N^2),检查重复 O(N),总复杂度 O(N^3)。
即使优化到 O(N^2)(一边向右扩展终点,一边用哈希表判重),对于 N=105 的数据规模,必定会超时 .

2.2 寻找瓶颈:我们重复计算了什么?

假设字符串是"abcabcbb"。

  1. 我们从第一个a开始,向右探查,得到"abc"。下一个字符是a,出现了重复,子串失效。

  2. 按照暴力解法,下一步我们会把起点移到第二个字符b,即"bca...",重新开始检查。

发现瓶颈:当我们发现"abca"出现重复时,我们其实已经知道"abc"是无重复的。当我们把起点往右移一步变成"bc"时,这个"bc"依然是原来"abc"的一部分,它必然是无重复的。
我们不需要把right指针退回去重新遍历,也不需要丢弃所有已经获取的信息。这就引出了单调性。

三、 为什么可以使用滑动窗口?(核心推理)

3.1 单调性的发现

假设我们有一个区间(窗口)[left, right]表示当前的子串。

  • 如果这个窗口内的字符没有重复,我们就尝试向右移动right,扩大窗口,看能不能找到更长的。

  • 如果移动right后,出现了重复,说明以当前left为起点的最长子串已经被找到了。此时,为了消除重复,我们必须把left向右移动。

关键推理:
如果[left, right]中有重复字符,那么[left-1, right]或更左侧起点的区间,必然也包含这个重复字符(因为它们包含了[left, right]的范围)。
因此,left指针永远不需要向左回退。left和right都是单向向右移动的。

这种“左右指针同向移动,维护一个区间”的思想,就是滑动窗口。

3.2 算法设计

  1. 使用双指针left和right维护一个窗口[left, right]。

  2. right主动向右扩展,将新字符加入窗口,并记录字符出现的次数(使用哈希表)。

  3. 当新加入的字符在窗口内出现次数大于 1(即出现重复)时,left开始向右收缩,把left指向的字符移出窗口,直到那个重复的字符在窗口内唯一。

  4. 在每次窗口合法(无重复)时,记录当前窗口的长度right - left + 1,并更新最大值。

四、Java 代码

以下是基于上述思路整理出的 Java 代码:

class Solution { public int lengthOfLongestSubstring(String s) { char[] str = s.toCharArray(); int n = str.length; int left = 0, right = 0, ret = 0; // 哈希表记录窗口内字符出现的频次 Map<Character, Integer> hash = new HashMap<>(); while(right < n) { char ch = str[right]; // 1. 右指针字符进入窗口,频次 +1 hash.put(ch, hash.getOrDefault(ch, 0) + 1); // 2. 如果当前字符频次 > 1,说明出现了重复字符 // 左指针开始向右移动,直到该字符频次降为 1 while(hash.get(ch) > 1) { hash.put(str[left], hash.get(str[left]) - 1); left++; } // 3. 此时窗口 [left, right] 内必然无重复,更新答案 ret = Math.max(ret, right - left + 1); // 4. 右指针继续右移 right++; } return ret; } }

细节剖析:

  • 为什么while(hash.get(ch) > 1)里只判断ch的频次?因为进入外层循环前,窗口内都是合法的。只有新加入的ch可能打破规则。当ch的频次降为 1 时,其他字符的频次必定也都为 1(因为其他字符没变)。

  • 这里用Map<Character, Integer>记录了频次,实际上也可以直接记录字符最后一次出现的索引,这样可以省去while循环,直接让left跳跃(见后文进阶优化)。

五、 算法正确性证明(严谨推理)

为了证明滑动窗口算法的正确性,我们使用循环不变式来进行形式化证明。

定义:设 MaxLen(right) 为以right为结尾的无重复字符的最长子串长度。最终答案即为 ⁡0≤r<n。

循环不变式:在每次外层while(right < n)循环开始时(即执行hash.put之前),窗口[left, right-1]是一个合法的(无重复字符的)窗口,且它是以right-1结尾的最长合法子串。

证明过程:

  1. 初始化:
    最初left = 0, right = 0,窗口为空[0, -1],天然满足无重复字符的条件,长度为 0,且显然是以right-1(即 -1)结尾的最长合法子串。不变式成立。

  2. 保持:
    假设在某次循环开始时,不变式成立,即[left, right-1]无重复且最长。
    现在进入循环,把str[right]纳入窗口。

    • 情况 A:如果没有产生重复,此时[left, right]仍然无重复。由于之前[left, right-1]是最长的,加入新字符后依然无重复,说明它是以right结尾的最长合法子串。不变式在下次循环前成立。

    • 情况 B:如果str[right]产生了重复。为了消除重复,left必须向右移动。假设重复字符是C。我们让left不断向右移动,直到C的频次降为 1。此时[left, right]再次变为合法。
      为什么这样得到的是最长的?因为如果left不再往后移,窗口就不合法;如果left越过当前的位置,我们就会丢失可能导致更长结果的有效字符组合。所以,当C频次刚变为 1 时,我们找到了以right结尾的最长合法子串。不变式保持。

  3. 终止:
    当right == n时,循环终止。此时我们已经遍历了所有的right位置,ret记录了所有以每个位置结尾的最长合法子串长度中的最大值。因此ret就是整个字符串的最长无重复子串长度。证毕。

六、 复杂度分析

  • 时间复杂度:O(N)。
    虽然代码中有一个嵌套的while循环,但left和right两个指针都只向右移动。right移动 N 次,left最多也移动 N 次。因此while内部的操作总执行次数最多为 2N 次。时间复杂度为 O(N)。

  • 空间复杂度:O(1)
    这里是英文字母、数字、符号和空格,最多128个 ASCII 字符。哈希表最多存储 128 个键值对,所以空间复杂度可以认为是 O(1) 常数级别。


七、 进阶优化思路

提供的代码中,使用Map记录了字符的出现次数。当发生重复时,通过while循环一步步把left挪过去。

实际上,我们可以直接让Map记录字符最后一次出现的索引。
当遇到重复字符ch时,我们不需要while循环,直接让left跳到max(left, 上一次ch出现的索引 + 1)即可。

优化后的代码:

class Solution { public int lengthOfLongestSubstring(String s) { int n = s.length(); int left = 0, ret = 0; // 记录字符最后一次出现的索引 Map<Character, Integer> lastIndex = new HashMap<>(); for (int right = 0; right < n; right++) { char ch = s.charAt(right); if (lastIndex.containsKey(ch)) { // 如果重复,left 直接跳过上次出现的位置 left = Math.max(left, lastIndex.get(ch) + 1); } lastIndex.put(ch, right); ret = Math.max(ret, right - left + 1); } return ret; } }

注:这种写法少了一层while循环,代码更精简,常数时间执行更快。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 1:55:23

基于SpringBoot+Vue的教培机构管理系统-附源码

温馨提示&#xff1a;本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 温馨提示&#xff1a;本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 温馨提示&#xff1a;本人主页置顶文章(点我)开头有 CSDN 平台…

作者头像 李华
网站建设 2026/10/27 1:54:54

SegMAN:Mamba架构在语义分割中的全尺度建模突破

1. SegMAN&#xff1a;语义分割全尺度建模的新突破去年底Mamba架构横空出世&#xff0c;在NLP领域展现出媲美Transformer的潜力后&#xff0c;计算机视觉界就在期待这个新秀能否在CV任务中复制成功。SegMAN的诞生给出了肯定答案——通过将Mamba与局部注意力机制创新性结合&…

作者头像 李华
网站建设 2026/10/27 1:54:37

客服从网页转到社区,怎样接续上下文而不把私聊搬进公共频道

用户先在网页问账单&#xff0c;随后到社区补一句“还是不行”。如果只按昵称拼接会话&#xff0c;Agent可能接错人&#xff1b;如果把原对话原封不动贴过去&#xff0c;又可能把订单信息带进公共频道。所谓跨渠道接续&#xff0c;首先是身份与可见范围的交接。 我会让系统交接…

作者头像 李华
网站建设 2026/10/27 1:53:38

运维智能体开发工程师的核心技术栈与实战经验

1. IT运维智能体开发工程师的角色定位与技术边界运维智能体开发工程师这个岗位在2018年后开始在国内头部科技企业出现&#xff0c;最初脱胎于传统的SRE&#xff08;站点可靠性工程师&#xff09;团队。与普通运维工程师最大的区别在于&#xff0c;这类工程师需要同时具备三个维…

作者头像 李华
网站建设 2026/10/27 1:52:34

【零基础学智能仿真-54】参数化仿真与敏感度分析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/27 1:52:33

产线安规测试工位搭建与设备配置架构

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华