LeetCode 3. 无重复字符的最长子串|不定长滑动窗口:什么时候更新最大值?

目录

LeetCode 3. 无重复字符的最长子串|不定长滑动窗口:什么时候更新最大值?

题目链接:3. 无重复字符的最长子串

PS:弄了个网站,可以看代码是怎么一步步运行、得到最终结果的:过程可视化

上一篇做了 209. 长度最小的子数组,第一次接触了不定长滑动窗口。

这是209的题解 这道题 要求的是满足条件的最短子数组,所以当窗口满足条件时,我们会尝试缩小窗口,寻找更短的答案。

但这次的 LeetCode 3,要求的是没有重复字符的最长子串。

同样是不定长滑动窗口,问题却反过来了:我们要尽可能扩大窗口,但又不能让窗口中出现重复字符。

那么,什么时候应该缩小窗口?又应该在什么时候记录最大长度?

一、先确定思路:窗口什么时候扩大,什么时候缩小?

题目要求:给定一个字符串 s,找出其中不含重复字符的最长连续子串的长度。

例如:

python 复制代码
s = "abcabcbb"

答案是 3,因为 "abc" 是一个没有重复字符的子串,长度为 3。

这里需要注意,子串必须连续,不能随意跳过中间的字符。

既然要求连续,而且长度不固定,我们就继续用不定长滑动窗口。

先设置两个指针:

  • left:窗口左边界。
  • right:窗口右边界。

例如:

text 复制代码
s = "abcabcbb"

[a, b, c] a  b  c  b  b
 L     R

当前窗口 "abc" 没有重复字符,长度为 3。

因为我们要找的是最长子串,所以只要窗口合法,就希望它能够继续扩大。

于是让 right 向右移动,加入下一个字符。

但加入之后,窗口可能变成:

text 复制代码
[a, b, c, a] b  c  b  b
 L        R

现在出现了两个 a。

这个窗口已经不满足题目要求了。

所以我们可以先确定一个基本方向:

text 复制代码
右指针向右移动
       ↓
加入一个新字符
       ↓
窗口出现重复了吗?
       │
       ├── 没有 → 窗口合法,可以记录长度
       │
       └── 出现 → 移动 left,缩小窗口
                         ↓
                    直到没有重复

不过,这里有两个问题:

  1. 怎么知道窗口中出现了重复字符?
  2. 出现重复以后,怎么知道要把左边的字符移出去多少个?

这就需要维护窗口中的字符信息了。

二、怎么判断窗口有没有重复字符?

最直接的想法,就是记录当前窗口中出现过哪些字符。

这里可以使用一个字典 freq,记录每个字符出现的次数。

例如:

text 复制代码
当前窗口:[a, b, c]

freq = {
    'a': 1,
    'b': 1,
    'c': 1
}

每个字符都只出现了一次,所以没有重复。

但如果右指针继续移动,又加入一个 a:

text 复制代码
当前窗口:[a, b, c, a]

freq = {
    'a': 2,
    'b': 1,
    'c': 1
}

现在 a 出现了两次,说明窗口已经不合法。

但是,逐个检查字典中的次数,代码写起来也很复杂,还有没有更简单的判断方式?

这个时候,咱们可以观察两个数字:

第一个:当前窗口中一共有多少个字符?

因为 left 和 right 分别指向窗口的左右边界,所以:

python 复制代码
right - left + 1

就是窗口长度。

第二个:当前窗口中有多少种不同字符?

由于字典的每个键对应一种字符,所以:

python 复制代码
len(freq)

就是不同字符的数量。

我们对比一下:

text 复制代码
窗口:[a, b, c]

窗口长度 = 3
不同字符数量 = 3

没有重复

再看:

text 复制代码
窗口:[a, b, c, a]

窗口长度 = 4
不同字符数量 = 3

出现重复

因此:如果窗口长度等于不同字符数量,说明窗口没有重复字符。

反过来:

python 复制代码
len(freq) != right - left + 1

就说明窗口中存在重复字符。

🔖 这里有个前提:freq 必须准确维护当前窗口中的字符,而且当字符出现次数减到 0 时,一定要记得把对应的键删除。。。

否则,len(freq) 就不能代表当前窗口中不同字符的数量。

三、出现重复字符以后,怎么恢复合法窗口?

知道怎么判断重复以后,下一步就是处理重复。

继续看刚才的例子:

text 复制代码
s = "abcabcbb"

当前窗口:
[a, b, c, a] b  c  b  b
 L        R

此时窗口 "abca" 中出现了两个 a。

因为右指针刚刚加入了新的字符,而我们希望尽可能去寻找多个组合,那就需要优先保留右边的新内容,所以可以尝试从左边移出字符。

移出最左边的 a:

text 复制代码
[a, b, c, a] b  c  b  b
    L     R

当前窗口:[b, c, a]

现在窗口重新变成了 "bca"。

没有重复字符,长度为 3。

所以,恢复合法窗口的方式就是:不断移出左边的字符,直到窗口中不再出现重复。

🔖 但为什么要不断移出,而不是只移出一次?

例如:

text 复制代码
s = "abcb"

加入最后一个 b 后:

[a, b, c, b]
 L        R

窗口中出现了两个 b。

如果只移出最左边的 a:

text 复制代码
[b, c, b]
 L     R

还是有两个 b!

所以还得继续移出左边的 b:

text 复制代码
[c, b]
 L  R

现在才恢复合法。

因此,这里需要的是循环收缩:

python 复制代码
while len(freq) != right - left + 1:

只要窗口存在重复字符,就继续缩小。

直到条件不成立,也就是:

text 复制代码
len(freq) == right - left + 1

说明窗口重新合法,才停止收缩。

这和上一道 209 有一个明显区别:

  • 209:窗口满足条件时收缩,是为了寻找更短的合法窗口。
  • 3:窗口不满足条件时收缩,是为了消除重复、恢复合法窗口。

所以,不定长滑动窗口的收缩条件,并不是固定的,而是取决于题目要求。


四、什么时候记录最大长度?

这是我做这道题时比较容易混乱的地方。

这里需要一个变量 max_len,记录目前找到的最长无重复子串长度。

python 复制代码
max_len = 0

但问题是:什么时候更新它???

一开始我想的是:当窗口第一次出现重复字符时,是不是就意味着前面那个无重复子串结束了,这时候就该记录最大值?

但仔细想想,这里有两个问题。

    1. 加入重复字符后的窗口已经不合法了,不能直接拿它的长度更新答案。
    1. 如果整个字符串都没有重复字符呢?

例如:

python 复制代码
s = "abcdef"

右指针可以一直向右移动,窗口始终合法,根本不需要收缩。

如果只在出现重复时更新最大值,就会漏掉这种情况。

所以,更新最大值 需要关注的并不是什么时候出现重复 ,而是:当前窗口是否合法?只要合法,就可以尝试更新最大长度。

结合前面恢复合法窗口的思路,我们可以把过程整理成:

text 复制代码
加入新字符
      ↓
窗口有重复吗?
      │
      ├── 有 → 不断缩小窗口,直到恢复合法
      │                         ↓
      └── 没有 ─────────────────┘
                                ↓
                         更新最大长度

这样,无论当前窗口原本就没有重复,还是经过收缩才恢复合法,最后都能统一更新答案。

对应到代码,就是把更新最大值的操作放在 while 后面:

python 复制代码
while len(freq) != right - left + 1:
    # 移出左边字符,直到没有重复
    ...

max_len = max(max_len, right - left + 1)

同时,需要注意:while 并不是每次都会执行。

  • 如果窗口没有重复,直接跳过 while,更新最大长度。
  • 如果窗口出现重复,就不断收缩,直到恢复合法,再更新最大长度。

因此,当程序执行到:

python 复制代码
max_len = max(max_len, right - left + 1)

时,当前窗口一定是合法的。

而 right - left + 1 为什么能用于更新最大窗口长度,我们下一节再具体解释。


五、窗口长度需要额外维护吗?

这道题还有一个容易想复杂的地方:窗口长度。

我们当然可以设置一个 current,每次扩大窗口就加 1,每次缩小窗口就减 1。

但仔细想想,其实没必要。

因为我们已经有两个指针:

text 复制代码
下标: 0  1  2  3  4
字符: a  b  c  d  e
          L     R

当前窗口是 "bcd"。

text 复制代码
left = 1
right = 3

窗口长度 = 3 - 1 + 1 = 3

所以直接使用:

python 复制代码
right - left + 1

就能得到当前窗口长度。

因此,我们不需要再维护一个额外的长度变量。

这样也避免了每次移动指针时,还得同步修改 current 的麻烦。

到这里,整道题的思路就已经完整了:

text 复制代码
用 left、right 维护窗口
          ↓
用 freq 记录字符出现次数
          ↓
right 向右移动,加入新字符
          ↓
当前窗口有重复吗?
          │
          ├── 有 → 移动 left,缩小窗口
          │                 ↓
          │            直到没有重复
          │                 ↓
          └── 没有 ────────┘
                            ↓
                     更新最大长度
                            ↓
                     继续移动 right

接下来就可以根据这套思路写代码了。

六、完整代码

python 复制代码
class Solution:
    def lengthOfLongestSubstring(self, s: str) -> int:
        freq = {}
        left = 0
        right = 0
        max_len = 0

        while right < len(s):
            # 右指针扩大窗口,记录新字符
            freq[s[right]] = freq.get(s[right], 0) + 1

            # 出现重复时,不断缩小窗口
            while len(freq) != right - left + 1:
                freq[s[left]] -= 1

                if freq[s[left]] == 0:
                    del freq[s[left]]

                left += 1

            # 窗口恢复合法后,更新最大长度
            max_len = max(max_len, right - left + 1)

            right += 1

        return max_len

七、代码中的几个细节

前面已经推导完思路,这里再解释一下具体代码为什么这么写。

1. freq.get(sright, 0) + 1 是什么意思?

python 复制代码
freq[s[right]] = freq.get(s[right], 0) + 1

freq.get(key, default) 是字典的一个方法。

它的意思是:

  • 如果字典中存在这个键,就获取对应的值。
  • 如果不存在,就返回给定的默认值 default

例如:

python 复制代码
freq = {'a': 1}

freq.get('a', 0)  # 1
freq.get('b', 0)  # 0

因此:

python 复制代码
freq[s[right]] = freq.get(s[right], 0) + 1

就是把当前字符的出现次数加 1。

如果字符第一次出现,就从 0 加到 1;如果之前出现过,就在原来的次数上加 1。

2. 为什么删除次数为 0 的键?

收缩窗口时,我们需要把左边的字符移出去:

python 复制代码
freq[s[left]] -= 1

例如:

python 复制代码
freq = {
    'a': 1,
    'b': 1,
    'c': 1
}

如果移出 a,它的次数就会变成 0:

python 复制代码
freq = {
    'a': 0,
    'b': 1,
    'c': 1
}

但注意:字典中的键即使对应的值是 0,仍然会被 len(freq) 统计进去。

也就是说:

python 复制代码
len(freq)  # 仍然是 3

可实际窗口中只剩下 b 和 c 两种字符。

因此,必须在次数减到 0 时删除对应的键:

python 复制代码
if freq[s[left]] == 0:
    del freq[s[left]]

del 是 Python 的删除语句,这里用于删除字典中指定的键值对。

删除后:

python 复制代码
freq = {
    'b': 1,
    'c': 1
}

这样 len(freq) 才能准确表示当前窗口中不同字符的数量。

3. 为什么更新答案放在 while 后面?

之前解释过了, 这里就从代码简单讲一下:

python 复制代码
while len(freq) != right - left + 1:
    ...
    
max_len = max(max_len, right - left + 1)

因为内层 while 的作用是恢复窗口合法性。

  • 如果存在重复,就一直缩小。
    • 当 while 结束时,窗口已经没有重复字符,此时才适合更新最大长度。
  • 如果本来就没有重复,while 会直接跳过,同样可以更新答案。

所以无论本轮是否发生收缩,都不会漏掉合法窗口。


八、复杂度分析

  • 时间复杂度:O(n)

    虽然代码使用了嵌套循环:

    python 复制代码
    while right < len(s):
        while len(freq) != right - left + 1:

    但这里并不是每次外层循环都重新遍历整个字符串。

    • right:从左到右移动,每个字符最多加入窗口一次,总共移动 n 次。
    • left:同样只向右移动,每个字符最多移出窗口一次,总共最多移动 n 次。
    • freq:字典的查询、插入和删除操作,平均时间复杂度为 O(1)。

    因此,两个指针总共最多移动约 2n 次,整体时间复杂度为 O(n) ,而不是 O(n²)。

  • 空间复杂度:O(min(n, K))

    额外空间主要来自记录字符出现次数的字典 freq。

    • n:字符串的长度。
    • K:字符串所属字符集的大小。
    • 字典最多记录 min(n, K) 种不同字符,因此空间复杂度为 O(min(n, K))。

    如果字符集大小固定,例如只考虑 ASCII 字符,那么 K 可以视为常数,空间复杂度也可以记为 O(1)。


九、最后复盘

这道题最开始容易混乱的地方,除去指针的移动方式 ,还在于:什么时候窗口才算合法,以及什么时候应该更新答案。

整个思路可以总结成:

text 复制代码
目标:最长无重复子串
      ↓
使用左右指针维护连续窗口
      ↓
使用 freq 记录字符出现次数
      ↓
右指针不断加入新字符
      ↓
出现重复?
      │
      ├── 是 → 左指针不断右移
      │          ↓
      │       恢复合法
      │
      └── 否
           ↓
     更新当前最大长度
           ↓
      继续扩大窗口

和上一道 209 对比,会发现两道题虽然都使用不定长滑动窗口,但处理方式恰好不同。

209. 长度最小的子数组 3. 无重复字符的最长子串
目标 最短合法窗口 最长合法窗口
收缩条件 总和达到 target 窗口出现重复
收缩目的 寻找更短的合法窗口 恢复窗口合法性
更新答案 收缩之前 收缩之后

对于 LeetCode 3:

先扩大窗口,出现重复就收缩,恢复合法后再更新最大长度。

而窗口长度本身已经可以通过 right - left + 1 得到,不需要再额外维护一个变量。

相关推荐
Water_based2 小时前
水性滚涂漆应用于滚喷机小五金件喷涂工艺详解
经验分享·笔记·产品经理
y1su2 小时前
Leetcode 单调栈模板
java·开发语言·数据结构·算法·leetcode
2601_962885722 小时前
如何用 Python 计算索提诺比率和卡玛比率?
开发语言·python
小小张说故事2 小时前
Python 的描述符到底是个啥?property 和 classmethod 底层都靠它,3 个例子讲透
python
索尔~古德曼2 小时前
CF(1400-1400&1300-1500)
算法
kimnoic2 小时前
Python操作MySQL数据库的两种方式实例分析【pymysql和pandas】
开发语言·python
外收内放2 小时前
Python基础语法练习题(64)
python·学习
爱刷碗的苏泓舒2 小时前
泰勒级数与泰勒展开:GNSS卫地距线性化、Jacobian矩阵与Hessian矩阵
线性代数·算法·矩阵·gnss·雅可比矩阵·卫地距线性化·海森矩阵
fanjiu20202 小时前
flask接收消息
python·flask