LeetCode 424. 替换后的最长重复字符|不定长滑动窗口:如何判断窗口是否合法?

目录

LeetCode 424. 替换后的最长重复字符|不定长滑动窗口:如何判断窗口是否合法?

题目链接:424. 替换后的最长重复字符

PS:自己弄了个算法可视化网站,可以看看代码是怎么一步步运行的:算法过程可视化

上一篇做了 3. 无重复字符的最长子串,我们通过滑动窗口,找到了不包含重复字符的最长子串。

这次的 424 看起来有点相反:我们希望窗口里的字符尽可能相同。

不过题目允许我们最多替换 k 个字符。

这就多了一个问题:窗口里即使有不同字符,也不代表它不合法。

那么,我们该怎么判断当前窗口能不能通过最多 k 次替换,变成所有字符都相同的字符串?

一、先确定思路:什么样的窗口才算合法?

题目给定一个字符串 s 和整数 k,我们最多可以替换 k 个字符,要求找到替换后包含相同字符的最长子串长度。

例如:

python 复制代码
s = "AABABBA"
k = 1

其中一个满足条件的子串是:

text 复制代码
A A B A
    ↓
A A A A

只需要把 B 替换成 A,就能得到长度为 4 的重复字符子串。

所以答案是 4。

因为题目要求的是连续子串,而且长度不固定,我们可以继续使用不定长滑动窗口。

设置两个指针:

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

但和上一道题不同,这次不能简单地通过「有没有重复字符」来判断窗口是否合法。

因为窗口中允许存在不同字符,只要能够在 k 次以内把它们替换掉就行。

所以我们首先需要解决:当前窗口最少需要替换多少个字符?

二、怎么计算最少需要替换的字符数量?

假设当前窗口是:

text 复制代码
[A, A, B, A, C]

其中:

text 复制代码
A:3 次
B:1 次
C:1 次

如果想让窗口中的字符全部相同,我们应该保留哪一种字符?

显然是出现次数最多的 A。

因为保留的字符越多,需要替换的字符就越少。

对于这个窗口:

text 复制代码
窗口长度 = 5
最多的字符 A = 3

需要替换 = 5 - 3 = 2

所以我们可以得到这个公式:

最少替换次数 = 当前窗口长度 - 窗口内字符的最高出现次数。

用变量表示:

python 复制代码
window_len = right - left + 1
max_freq = max(freq.values())

那么:

python 复制代码
window_len - max_freq

就是让当前窗口中的字符全部相同,至少需要替换的次数。

接下来,只需要和 k 比较即可。

什么时候合法?

如果:

python 复制代码
window_len - max_freq <= k

说明需要替换的字符数量没有超过 k,窗口合法。

如果:

python 复制代码
window_len - max_freq > k

说明需要替换的字符太多,当前窗口不合法。

这里要注意:题目要求的是最多替换 k 次,而不是必须替换 k 次。

例如:

text 复制代码
窗口:[A, A, A, B]

窗口长度 = 4
max_freq = 3
k = 2

只需要替换一个 B,就能得到 "AAAA"。

虽然没有用完两次替换机会,但窗口仍然合法。

所以判断条件是 <= k,而不是 == k。

三、怎么知道哪个字符出现得最多?

现在我们已经知道,需要通过最高字符频率来判断窗口是否合法。

但问题来了:窗口一直在变化,我们怎么知道当前哪个字符出现得最多?

这里可以使用字典 freq,记录窗口中每种字符出现的次数。

例如:

python 复制代码
freq = {
    'A': 3,
    'B': 1,
    'C': 1
}

每次右指针加入一个字符,就把它的出现次数加 1:

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

🚩 那么,怎么从字典中找出最大的次数?

Python 提供了 max():

python 复制代码
max_freq = max(freq.values())

其中:

  • freq.values():获取字典中所有字符的出现次数。
  • max():找出这些次数中的最大值。

例如:

python 复制代码
freq = {'A': 3, 'B': 1, 'C': 1}

max(freq.values())  # 3

这样我们就知道了,当前窗口中最多的字符出现了 3 次。

不过,窗口不只是会扩大,还会缩小。

当左边的字符被移出去时,字典中的频率也会变化。

因此,如果要准确判断当前窗口是否合法,就需要使用当前窗口的真实最高频率。

这一点我们在后面的代码注意事项中再具体说明。

四、什么时候需要缩小窗口?

前面已经确定了窗口的合法条件:

python 复制代码
(right - left + 1) - max(freq.values()) <= k

既然我们要找最长子串,就应该尽可能扩大窗口。

每次让 right 向右移动,加入一个新字符。

如果加入之后,窗口仍然合法,就可以保留当前窗口,继续寻找更长的答案。

但如果需要替换的字符数量超过了 k 呢?

例如:

python 复制代码
s = "AABABBA"
k = 1

当前窗口:

text 复制代码
[A, A, B, A, B]
 L           R

其中:

text 复制代码
A:3 次
B:2 次

窗口长度 = 5
需要替换 = 5 - 3 = 2

因为 2 > k,所以窗口不合法。

既然右指针继续扩大窗口已经让替换次数超出了限制,我们就尝试移动左指针,把左边的字符移出去。

移出第一个 A:

text 复制代码
[A, A, B, A, B]
    L        R

当前窗口:[A, B, A, B]

现在:

text 复制代码
A:2 次
B:2 次

窗口长度 = 4
需要替换 = 4 - 2 = 2

还是超过了 k = 1。

所以还需要继续收缩。

再移出一个 A:

text 复制代码
[A, A, B, A, B]
       L     R

当前窗口:[B, A, B]

此时:

text 复制代码
A:1 次
B:2 次

窗口长度 = 3
需要替换 = 3 - 2 = 1

现在只需要替换一个字符,窗口终于恢复合法。

所以这里不能只收缩一次,而是需要:

只要当前窗口需要替换的字符数量超过 k,就不断移动 left,直到窗口重新合法。

对应的判断就是:

python 复制代码
while (right - left + 1) - max(freq.values()) > k:

这里使用 while,而不是 if,因为收缩一次不一定能够恢复合法。

五、什么时候更新最大长度?

现在,我们已经知道了整个窗口的变化过程:

  • 右指针负责加入新字符,尝试扩大窗口。
  • 如果需要替换的字符超过 k,就移动左指针缩小窗口。
  • 直到窗口重新满足最多替换 k 次的要求。

那么,什么时候更新最大长度?

和上一道 LeetCode 3 类似,我们应该在当前窗口合法时更新答案。

因为我们要找的是最长合法窗口,而不是最长的任意窗口。

可以分成两种情况:

  1. 加入新字符后,窗口仍然合法,不需要收缩,直接更新最大长度。
  2. 加入新字符后,窗口不合法,需要先收缩,恢复合法以后再更新最大长度。

所以,我们可以统一把更新答案的操作放在收缩循环后面:

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

这样,无论这一轮是否进入 while,执行到这里时,窗口都已经合法。

而且,窗口长度可以直接通过两个指针计算:

python 复制代码
right - left + 1

不需要再额外维护一个变量记录长度。

到这里,整个思路就已经完整了:

text 复制代码
用 left、right 维护窗口
          ↓
用 freq 记录窗口中各字符的出现次数
          ↓
right 向右移动,加入新字符
          ↓
计算窗口最少需要替换的次数
          ↓
需要替换的次数 > k?
          │
          ├── 是 → 不断移动 left
          │                ↓
          │           直到窗口恢复合法
          │                ↓
          └── 否 ──────────┘
                           ↓
                    更新最大长度
                           ↓
                    继续移动 right

接下来就可以按照这个思路写代码了。

六、完整代码

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

        while right < len(s):
            # 加入右边的新字符
            freq[s[right]] = freq.get(s[right], 0) + 1

            # 替换次数超过 k,就不断缩小窗口
            while (right - left + 1) - max(freq.values()) > k:
                freq[s[left]] -= 1
                left += 1

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

            right += 1

        return max_len

七、代码中的几个细节

1. 为什么用 freq.get()?

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

这里的 get() 是字典的方法。

它会先尝试获取对应字符的出现次数:

  • 如果字符已经存在,就返回原来的次数。
  • 如果字符不存在,就返回默认值 0。

然后再加 1。

例如:

python 复制代码
freq = {'A': 2}

freq.get('A', 0)  # 2
freq.get('B', 0)  # 0

这样就不需要每次都先判断字符是否存在于字典中。

2. 为什么这里不需要删除次数为 0 的字符?

收缩窗口时:

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

表示左边的字符被移出了窗口,对应的出现次数减 1。

即使某个字符的次数变成 0,这里也不需要删除它。

因为我们使用的是:

python 复制代码
max(freq.values())

只关心窗口中最高的字符频率 ,次数为 0 的键不会影响最大值。

但上一道 LeetCode 3 使用的是 len(freq) 来统计不同字符的数量。即使某个字符的次数为 0,它仍然会被 len(freq) 计算进去,所以需要删除。

因此,是否删除次数为 0 的键,取决于后续如何使用字典。

3. 为什么 max_freq 不能只在收缩前计算?

这里有一个比较容易忽略的细节。

假设我们这样写:

python 复制代码
max_freq = max(freq.values())

while (right - left + 1) - max_freq > k:
    freq[s[left]] -= 1
    left += 1

看起来没有问题,但注意:max_freq 是进入 while 之前计算出来的值。

当左指针移动时:

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

字典中的字符频率已经发生变化。

但 max_freq 不会跟着自动更新。

例如:

text 复制代码
窗口:[A, A, B]
k = 0

A:2 次
B:1 次
max_freq = 2

窗口长度为 3,需要替换 1 个字符,超过了 k,所以开始收缩。

移出第一个 A 后:

text 复制代码
窗口:[A, B]

A:1 次
B:1 次

现在真实的最高频率已经变成 1。

但之前保存的 max_freq 仍然是 2。

如果继续用旧值判断:

text 复制代码
窗口长度 - max_freq
= 2 - 2
= 0

程序就会认为不需要继续收缩。

可实际上 "AB" 仍然不合法,因为 k = 0,我们不能替换任何字符。

所以,如果我们的思路是:窗口不合法 → 不断收缩 → 直到恢复合法

那么就应该在每次判断时重新获取当前窗口的最高频率:

python 复制代码
while (right - left + 1) - max(freq.values()) > k:

这样,即使收缩过程中字符频率发生变化,下一次判断也会使用更新后的频率。

补充: LeetCode 424 还有一种常见的优化写法,会维护一个只增不减的 max_freq。这种写法允许当前窗口暂时不合法,但仍然可以正确求出历史最长长度。不过,它的正确性依赖额外的推导,并不等同于「每次收缩后窗口一定合法」。

这里先采用实时计算最高频率的方法,让代码和前面的思路保持一致,也更容易理解。

八、复杂度分析

  • 时间复杂度:O(n)

    虽然代码中使用了嵌套的 while 循环,但两个指针都只向右移动,不会回退。

    • right:每个字符最多加入窗口一次,总共移动 n 次。
    • left:每个字符最多移出窗口一次,总共最多移动 n 次。
    • max(freq.values()):每次最多检查 26 种大写字母的出现次数,因此可以视为 O(1)。

    所以整体时间复杂度为 O(n) ,而不是 O(n²)。

  • 空间复杂度:O(1)

    额外空间主要来自字典 freq。

    • 题目中的字符串只包含大写英文字母。
    • 因此字典最多记录 26 种字符。
    • left、right、max_len 等变量只占用常数空间。

    所以整体辅助空间复杂度为 O(1)。

九、最后复盘

这道题和上一道 LeetCode 3 都是在寻找最长合法窗口,但它们对「合法」的定义完全不同。

3. 无重复字符的最长子串 424. 替换后的最长重复字符
目标 找最长无重复子串 找最长可替换成相同字符的子串
合法条件 所有字符都不重复 需要替换的字符数 <= k
收缩条件 出现重复字符 需要替换的字符数 > k
收缩目的 消除重复 减少需要替换的字符数量
更新答案 窗口合法后 窗口合法后

这次\的思路,其实就是先把题目中的要求转化成一个能够计算的条件:

text 复制代码
最多替换 k 个字符
        ↓
尽量保留出现次数最多的字符
        ↓
需要替换的次数 = 窗口长度 - 最高字符频率
        ↓
判断是否超过 k

有了这个条件,我们才能进一步确定窗口什么时候扩大、什么时候收缩。

所以做滑动窗口题时,我觉得可以先问、三个问题:

  1. 什么样的窗口才算合法?
  2. 什么时候需要扩大缩小窗口?
  3. 什么时候可以用当前窗口更新答案?

把这三个问题想清楚,再去写代码,整个过程就会顺畅很多。

相关推荐
计算机编程指导师1 小时前
计算机毕设怎么做?基于Spark+Django的股票数据分析与可视化系统实战指导 源码 毕业设计 选题推荐 毕设选题 数据分析 机器学习
大数据·python·spark·django·毕业设计·股票·课程设计
高级程序源1 小时前
django房屋租赁系统28636-计算机课程设计、毕业设计
python·mysql·elasticsearch·小程序·django·flask·课程设计
deepseek231 小时前
SQLite WAL 开了仍报 database is locked?Python 后台任务的事务与幂等修复
数据库·python·sqlite·并发编程·wal
Nebula_g1 小时前
JavaSE高级:动态代理
java·开发语言·算法·javase
垆边人似月.1 小时前
华为题:盛最多水的容器
数据结构·算法
杨逢昌工厂6S管理1 小时前
116——杨逢昌:归位三定闭环法的旺季轻量化执行方案与工具清单
经验分享·笔记·职场和发展·学习方法
想喝奶茶啾啾1 小时前
AMBA CHI架构以及规范中使用的术语 翻译笔记(1)
笔记·arm·chi
怦然丶心动1 小时前
用 Java 把干支历法算清楚:从节气切年到可读摘要
java·算法·vue·独立开发·spring boot / spring
计算机编程指导师2 小时前
计算机大数据项目推荐:基于Hadoop+Django的人工智能社交媒体情绪数据分析可视化系统源码 毕业设计 选题推荐 毕设选题 数据分析 机器学习
大数据·hadoop·python·计算机·spark·毕业设计·社交媒体